diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e613d20..73c5118 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,13 +19,19 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v7 + with: + submodules: recursive - name: Setup .NET - uses: actions/setup-dotnet@v4.3.1 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json + - name: Build basis_universal native library + shell: pwsh + run: .\source\CapriKit.SuperCompressed\basis_universal\build.ps1 + - name: Run unit tests shell: pwsh run: dotnet test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6806295 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/basis_universal"] + path = external/basis_universal + url = https://github.com/BinomialLLC/basis_universal.git + ignore = untracked diff --git a/CLAUDE.md b/CLAUDE.md index 2905dd4..faa1ef0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,27 @@ # CLAUDE.md -See `RESPONSIBLE_USE_OF_AI.md` to read how I use AI agents and tools in this repository. +Read `RESPONSIBLE_USE_OF_AI.md` to understand how I use AI agents and tools in this repository. ## Instructions -You are an AI agent working on C# NuGet packages that aid the creation of videogames and game engines. You are working together with myself, an experienced software engineer who has created multiple game engines before working on this project. Your tasks is to assist me in chores and research. This is a hobby project, the most important things is that I learn new things and understand what is going on in the code. Keep all your replies short and to the point. If you are asked to generate code,keep changes as small as possible. Focus on creating small, maintainable and easy to understand code. Work in small iterative steps and ensure the test in `Caprikit.Tests` cover the happy path of any code generated. +You are an AI agent working on C# NuGet packages that aid the creation of videogames and game engines. You are working with an experienced software engineer who has created multiple game engines before working on this project. Your tasks is to assist me in chores and research. This is a hobby project, the most important thing is that I learn new things and understand what is going on in the code. + +## Responses +When answering a question keep the following rules in mind: +- Do not open with a (flattering) preamble ("Great question") +- Do not restate my question back to me +- Limit the usual summary or recap that you always put at the end of an answer to one line +- Code examples are helpful, but they do not have to be complete or to compile. Use a comment like `// snip` to elude boilerplate code. + +## Code +If you are asked to generate code,keep changes as small as possible. Focus on creating small, maintainable and easy to understand code. Work in small iterative steps and ensure the test in `Caprikit.Tests` cover the happy path of any code generated and follow the test guidelines found in `source\CapriKit.Tests\README.md`. When generating interop code for C or C++ libraries try to create stateless (functional style) code that mimicks the API of the native library but hides native pointers from the public C# API. Double check that any collection or other action by the garbage collector does not invalidate pointers. + +## Documentation +When generating documentation focus on the target audiance: The code maintainers and library users. Write XML documentation to educate the library users on how to use a class, method, property or other component correctly and in which situations it should be used or not be used. Sparingly use in-line comments to explain gotcha's, non-obvious behavior or future points of improvemnts to code maintainers. Remember that documentation also needs to be maintained so focus on writing short to-the-point documentation that is easy for me to maintain. ## Environment -Assume you can only use Powershell to execute commands, the `dotnet` tool and latest version of the .NET SDK are available to you. The primary language we use is C#, shaders are written in HLSL. See `README.md` in the root project for build, run, test and lint commands. Consult `README.md` files in subdirectories to learn the quirks and implementation details of individual projects. +Assume you can only use Powershell to execute commands. The `dotnet` tool and latest version of the .NET SDK are available to you. The primary language we use is C#, shaders are written in HLSL. See `README.md` in the root project for build, run, test and lint commands. Consult `README.md` files in subdirectories to learn the quirks and implementation details of individual projects. Some projects build or interop with native C/C++ libraries. For those projects use `cmake`. + +The `external` folder in the root of the repository contains git submodules that point to external repositories. You can depend on code in these external repositories but you can never make changes to them. ## Technologies -See `Directory.Packages.props` for an overview of NuGet packages used. +See `Directory.Packages.props` for an overview of NuGet packages used. \ No newline at end of file diff --git a/CapriKit.slnx b/CapriKit.slnx index 6ccee88..818c7eb 100644 --- a/CapriKit.slnx +++ b/CapriKit.slnx @@ -35,7 +35,7 @@ - + @@ -50,6 +50,9 @@ + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index fc502fd..f0c18e1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/README.md b/README.md index 7cb8d55..318a78b 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,17 @@ _Roy Triesscheijn's collection of code libraries_ ## Goal -I have been writing code since 2003. I started in Visual Basic 6 and I switched to C# code since 2007. Since then I have written thousands of lines of code. Most of that code was barely tested and sits in forgotten repositories. +I have been writing code since 2003. I started in Visual Basic 6 and I switched to C# code in 2007. Since then I have written thousands of lines of code. Most of that code was barely tested and sits in forgotten repositories. With CapriKit I try to make a library where I gather all code that I find relevant and through documentation and testing I try to make it reusable and have lasting value. For myself, and maybe for others. Most of the code is focussed on game engine development as that is my favorite hobby. ## How to Build, Run, Test and Lint -In general you can use the standard .net commands. These examples here assume you are using `powershell`. (when using Git Bash the `/` characters in +When checking out ensure that you also checkout and update all submodules using: + +```pwsh +git submodule update --init --recursive +``` +In general you can use the standard .net commands. These examples here assume you are using `powershell`. ```pwsh # build the solution @@ -42,6 +47,8 @@ dotnet format # format code dotnet run --project ``` +For native C and C++ libraries (like the basis_universal submodule) I use `cmake` and powershell based build scripts. + > [!WARNING] > These examples assume you are using `powershell`. When using Git Bash the `/` characters are silently converted to `\`, making the commands fail. Use powershell or set `MSYS_NO_PATHCONV=1` to work around this problem. @@ -49,6 +56,3 @@ dotnet run --project > This project uses non-standard output directories. See the `.build` directory in the root of this repository. This keeps all source code in the `source` folder clean. There is a command line utility called `CapriKit.Meta` that assist in publishing in a consistent way. It also contains utilities to run benchmarks and to compare if those results significantly differ from previous versions. Build the project in DEBUG mode and run it from `.build\bin\CapriKit.Meta-Debug\CapriKit.Meta.exe`. - -## TODO -Investigate making all libraries AOT Compatible `true` but if something, like a LightInject cannot be made AOT compatible it will not work. Otherwise suggest compiling with ReadyToRun on the consumer side which is mixed tier-0 compiled code with JIT. (The tier-0 code will slowly be Jitted to more optimized code, thought that can be disabled to avoid hitches). diff --git a/RESPONSIBLE_USE_OF_AI.md b/RESPONSIBLE_USE_OF_AI.md index 1346b1e..081cd0e 100644 --- a/RESPONSIBLE_USE_OF_AI.md +++ b/RESPONSIBLE_USE_OF_AI.md @@ -2,6 +2,6 @@ My work gives me access to AI tools to analyze how they work and how they change the software development process. I sometimes use these tools in my hobby projects to asses them. So you will see commits made by various tools and agents. I also have a personal Claude Code license that I use on a more regular basis to aid me with this project. -This is a hobby project where I want to have fun and learn new things. There are no deadlines, so I don't feel the urge to optimize my time. Instead I want to experiment, learn new things, understand everything, and curate this code base as if it is a beautiful 'bonsai-tree'. I love the zen-like feeling I get from writing code myself and I often find myself coming up with simpler and more elegant solutions than those generated by AI tools. Sometimes I generate larger pieces of code to understand how a certain architecture or pattern 'feels' or because it is easier to improve existing work than staring at a blank canvas. But, in general I limit my use of AI to chores (like generating tests) and research (see the research folder, where I sometimes let the AI dump text files that I want to investigate later). +This is a hobby project where I want to have fun and learn new things. There are no deadlines, so I don't feel the urge to optimize my time. Instead I want to experiment, learn new things, understand everything, and curate this code base as if it is a beautiful 'bonsai-tree'. I love the zen-like feeling I get from writing code myself and I often find myself coming up with simpler and more elegant solutions than those generated by AI tools. Sometimes I generate larger pieces of code such as an interop layer for a C/C++ library, to understand how a certain architecture or pattern 'feels' or because it is easier to improve existing work than staring at a blank canvas. But, in general I limit my use of AI to chores (like generating tests) and research (see the research folder, where I sometimes let the AI dump ideas that I want to investigate later). Merge requests created using AI on this repository are allowed, if the creator can articulate their understanding of the problem they are trying to solve. There's no need to accompany a merge request with a 5 page description with bullet points and emojis. diff --git a/external/BASIS_UNIVERSAL.md b/external/BASIS_UNIVERSAL.md new file mode 100644 index 0000000..1f06509 --- /dev/null +++ b/external/BASIS_UNIVERSAL.md @@ -0,0 +1,9 @@ +# Basis Universal + +Git Submodule for https://github.com/BinomialLLC/basis_universal + +## Building +1. Install cmake (tested 2026-07-09 on commit `1b33fd5` with cmake version 4.3.4) +2. Go to the `basis_universal/build` directory +3. Run `cmake ..` +4. Open `basis_universal/basisu.slnx` and build the `ALL_BUILD` project. \ No newline at end of file diff --git a/external/README.md b/external/README.md new file mode 100644 index 0000000..c412ee7 --- /dev/null +++ b/external/README.md @@ -0,0 +1,9 @@ +# External + +This folder contains git submodules that point to external repositories. Do not make any changes in the subfolders of this `external` directory. + +If you do not see any subfolders run: + +``` +git submodule update --init --recursive +``` \ No newline at end of file diff --git a/external/basis_universal b/external/basis_universal new file mode 160000 index 0000000..1b33fd5 --- /dev/null +++ b/external/basis_universal @@ -0,0 +1 @@ +Subproject commit 1b33fd5098c6e7b58324146b8f5518cbb4cdfb72 diff --git a/research/AsyncContentPipeline.md b/research/AsyncContentPipeline.md deleted file mode 100644 index b1e0ed5..0000000 --- a/research/AsyncContentPipeline.md +++ /dev/null @@ -1,113 +0,0 @@ -# Async Content Pipeline via `System.Threading.Channels` - -> **Note:** This may be relevant for the content manager (still to be designed). The pattern below describes a clean producer/consumer seam between async asset loading and the synchronous render thread, which is exactly the kind of boundary a content manager will need. - -`System.Threading.Channels` is .NET's built-in producer/consumer queue, in `System.Threading.Channels`. Think of it as a `BlockingCollection` redesigned for the `async`/`await` era: thread-safe by construction, lock-free in the common path, supports both sync and async reads/writes, and signals completion explicitly. - -## Why it fits a game engine well - -A channel cleanly separates two threads with different rules: - -- **Producer**: a loader doing async I/O. Doesn't touch D3D's immediate context. Can run on the thread pool. -- **Consumer**: the render thread. Drains the channel each frame using a non-blocking `TryRead`, does the GPU upload, moves on. - -The producer never blocks the render loop; the render loop never blocks waiting for I/O. The channel is the seam. - -## Shape of the data - -Don't push D3D objects through the channel — that defeats the point. Push CPU-side payloads ready for the render thread to upload: - -```csharp -internal sealed record LoadedShader(FilePath Path, string Source); -internal sealed record LoadedMesh(FilePath Path, byte[] Vertices, byte[] Indices); - -// Or a discriminated-union-ish base if you want one channel for many asset types -internal abstract record LoadedAsset; -``` - -## Creating the channel - -```csharp -private readonly Channel loaded = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, // only the render thread reads - SingleWriter = false, // multiple loader tasks may write - }); -``` - -`SingleReader = true` lets the channel use a faster internal path. Use `CreateBounded(capacity)` instead to get backpressure (producers wait when the queue is full). - -## Producer side — the loader - -```csharp -public Task QueueShaderLoad(FilePath path) => Task.Run(async () => -{ - var source = await FileSystem.ReadAllText(path); - await loaded.Writer.WriteAsync(new LoadedShader(path, source)); -}); -``` - -`Task.Run` gets the work off the calling thread and into the thread pool. Everything inside happens off the render thread. `WriteAsync` only awaits if the channel is bounded and full — for an unbounded channel it completes synchronously. - -Many of these can be in flight; the channel handles concurrency. - -## Consumer side — drain in the loop - -```csharp -public void Run() -{ - while (running) - { - ImGuiController.NewFrame((float)elapsed); - HandleInput(); - HandleResize(); - - DrainLoadedAssets(); // <-- new - - SwapChain.Clear(Device.ImmediateDeviceContext); - // ...rest of frame... - } -} - -private void DrainLoadedAssets() -{ - while (loaded.Reader.TryRead(out var asset)) - { - switch (asset) - { - case LoadedShader s: - var vs = ShaderCompiler.CompileVertexShader(FileSystem, s.Source, ...); - // GPU upload via ImmediateDeviceContext is fine here — we're on the render thread - break; - - case LoadedMesh m: - // upload vertex/index buffers via the immediate context - break; - } - } -} -``` - -`TryRead` is **non-blocking** — returns false if the queue is empty. That's the key: the render loop never stalls. If nothing's ready this frame, it just moves on. - -## Shutdown - -```csharp -loaded.Writer.Complete(); // no more writes will be accepted -``` - -After completion, `TryRead` still drains anything left, then returns false forever. Useful for clean teardown. - -## Why this beats rolling a custom queue - -- No `lock`s in your code. -- Backpressure is built in (`CreateBounded`). -- `ReadAllAsync` gives you an `IAsyncEnumerable` for free if you ever want a dedicated consumer task. -- Allocations are minimal — the channel reuses internal nodes. - -## One subtle point - -A channel does not *create* a thread — it just routes between threads that already exist. The producer threads come from `Task.Run` (or wherever the async work is started); the consumer thread is the existing render thread. The channel is just the handoff mechanism. - -For this engine that means the addition is small: one `Channel` field, one `DrainLoadedAssets` call per frame, and a couple of `QueueXLoad` methods on the loader side. Everything else stays the same. diff --git a/research/ParallelShaderLoading.md b/research/ParallelShaderLoading.md deleted file mode 100644 index 520f1a2..0000000 --- a/research/ParallelShaderLoading.md +++ /dev/null @@ -1,113 +0,0 @@ -# Parallel Shader Loading via `Parallel.ForEachAsync` - -> **Note:** Companion to `AsyncContentPipeline.md`. That note covers the producer/consumer *seam* (a `Channel` drained by the render thread). This note covers the *producer* side: how to compile a finite batch of shaders in parallel without standing up a dedicated thread pool, and without ever blocking the render loop. - -## When to reach for this - -For a **finite, known batch** ("compile these N shaders, then we're done"), dedicated long-lived worker threads are overkill — they earn their keep only when workers persist across frames (a standing job system). For a one-shot burst, `Parallel.ForEachAsync` gives you the same parallelism with zero thread lifecycle code, and a natural "batch complete" signal for free. - -## The core idea - -Split the work along the seam the engine already has: - -- **Compile** (`ShaderCompiler.CompileVertexShader` → `VertexShaderByteCode`) is device-free, thread-safe CPU work. Run it in parallel, off the render thread. -- **Create** (`ShaderCompiler.CreateVertexShader` → `IVertexShader`) touches `ID3D11Device`. The device is free-threaded, but we keep this on the render thread anyway so all GPU-facing work lives in one place. - -The `Channel` carries the CPU-side `VertexShaderByteCode` across the seam. - -## `Parallel.ForEachAsync` is synchronous to its *caller* - -`Parallel.ForEach`/`ForEachAsync` does not return until every iteration finishes. That only matters for the thread that calls it — so we never call it on the render thread. Results are written into the channel *from inside the loop body*, so the render thread sees them stream in frame by frame; it does not wait for the whole batch. - -## The loader - -```csharp -using System.Diagnostics.CodeAnalysis; -using System.Threading.Channels; -using CapriKit.DirectX11.Resources.Shaders; -using CapriKit.IO; - -internal sealed class ShaderLoader -{ - // A bare source string can't be compiled or identified on the way out, - // so carry the name + entry point with it. - public readonly record struct Request(string Name, string EntryPoint, string Source); - - private readonly Channel results = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, // only the render thread drains - SingleWriter = false, // many compile tasks write - }); - - // Fire-and-forget from the render thread: returns instantly, work runs on the pool. - public Task CompileAll(IReadOnlyVirtualFileSystem fileSystem, DirectoryPath includePath, Request[] requests, CancellationToken ct = default) - { - var options = new ParallelOptions - { - MaxDegreeOfParallelism = Environment.ProcessorCount, - CancellationToken = ct, - }; - - return Parallel.ForEachAsync(requests, options, async (request, token) => - { - var byteCode = ShaderCompiler.CompileVertexShader( - fileSystem, includePath, request.Source, request.EntryPoint, request.Name); - - await results.Writer.WriteAsync(byteCode, token); - }) - .ContinueWith(_ => results.Writer.Complete(), TaskScheduler.Default); - } - - // Render thread calls this each frame; non-blocking. - public bool TryDequeue([NotNullWhen(true)] out VertexShaderByteCode? byteCode) - => results.Reader.TryRead(out byteCode); -} -``` - -## Starting it - -Do not `await` it on the render thread, but *do* observe the task so exceptions don't vanish: - -```csharp -var loading = shaderLoader.CompileAll(fileSystem, includePath, requests); -_ = loading.ContinueWith(t => Log(t.Exception), TaskContinuationOptions.OnlyOnFaulted); -``` - -## Draining on the render thread - -This is where the **device** call happens, safely on the render thread: - -```csharp -private void DrainCompiledShaders() -{ - while (shaderLoader.TryDequeue(out var byteCode)) - { - var shader = ShaderCompiler.CreateVertexShader(byteCode, Device); // device call: render thread - // store/use `shader` - } -} -``` - -Call `DrainCompiledShaders()` once per frame, where `AsyncContentPipeline.md` put `DrainLoadedAssets()`. - -## Why it works - -- **The compile inside the async body is synchronous CPU work, and that's fine.** `CompileVertexShader` doesn't `await` anything; it runs to completion on whichever pool thread `ForEachAsync` assigned. Because `ForEachAsync` runs up to `MaxDegreeOfParallelism` of these concurrently, you still get real parallel compilation. For purely in-memory sources, plain `Parallel.ForEach` would be equally valid — `ForEachAsync` becomes the right tool the moment a file read enters the body (see below). -- **`WriteAsync` completes synchronously here.** The channel is unbounded, so `WriteAsync` never actually suspends — same practical cost as `TryWrite`, but it throws cleanly if the channel is closed instead of silently returning `false`. -- **`Complete()` on the continuation gives a real "batch done" signal.** Once all requests finish, the channel completes; `TryDequeue` drains the remainder, then returns `false` forever. That's the edge to flip a loading-screen flag on. - -## Where `ForEachAsync` earns the "Async" - -If the input is **paths** rather than in-memory source, the body becomes I/O-bound and the `await` stops being trivial — workers overlap disk reads with other workers' CPU compiles: - -```csharp -return Parallel.ForEachAsync(paths, options, async (path, token) => -{ - var source = await fileSystem.ReadAllTextAsync(path, token); // real await: disk I/O - var byteCode = ShaderCompiler.CompileVertexShader(fileSystem, includePath, source, entryPoint, name); - await results.Writer.WriteAsync(byteCode, token); -}); -``` - -That is the case where `ForEachAsync` clearly beats `Parallel.ForEach`. diff --git a/source/CapriKit.SuperCompressed/BasisTexFormat.cs b/source/CapriKit.SuperCompressed/BasisTexFormat.cs new file mode 100644 index 0000000..4c82625 --- /dev/null +++ b/source/CapriKit.SuperCompressed/BasisTexFormat.cs @@ -0,0 +1,42 @@ +namespace CapriKit.SuperCompressed; + +// Mirrors the BTF_* constants (basist::basis_tex_format) in +// external/basis_universal/encoder/basisu_wasm_api_common.h. +/// The intermediate texture formats a .ktx2 file can store. +public enum BasisTexFormat : uint +{ + Etc1s = 0, + UastcLdr4x4 = 1, + UastcHdr4x4 = 2, + AstcHdr6x6 = 3, + UastcHdr6x6 = 4, + XuastcLdr4x4 = 5, + XuastcLdr5x4 = 6, + XuastcLdr5x5 = 7, + XuastcLdr6x5 = 8, + XuastcLdr6x6 = 9, + XuastcLdr8x5 = 10, + XuastcLdr8x6 = 11, + XuastcLdr10x5 = 12, + XuastcLdr10x6 = 13, + XuastcLdr8x8 = 14, + XuastcLdr10x8 = 15, + XuastcLdr10x10 = 16, + XuastcLdr12x10 = 17, + XuastcLdr12x12 = 18, + AstcLdr4x4 = 19, + AstcLdr5x4 = 20, + AstcLdr5x5 = 21, + AstcLdr6x5 = 22, + AstcLdr6x6 = 23, + AstcLdr8x5 = 24, + AstcLdr8x6 = 25, + AstcLdr10x5 = 26, + AstcLdr10x6 = 27, + AstcLdr8x8 = 28, + AstcLdr10x8 = 29, + AstcLdr10x10 = 30, + AstcLdr12x10 = 31, + AstcLdr12x12 = 32, + Xubc7 = 33, +} diff --git a/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj new file mode 100644 index 0000000..49b6807 --- /dev/null +++ b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj @@ -0,0 +1,22 @@ + + + + $(MSBuildThisFileDirectory)..\..\.build\bin\caprikit_basisu\caprikit_basisu.dll + + + + + + + + + + + + + + + + diff --git a/source/CapriKit.SuperCompressed/CompParamsHandle.cs b/source/CapriKit.SuperCompressed/CompParamsHandle.cs new file mode 100644 index 0000000..df237f9 --- /dev/null +++ b/source/CapriKit.SuperCompressed/CompParamsHandle.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// Owns a native compression parameters object, created by bu_new_comp_params +/// and freed by bu_delete_comp_params. +/// +internal sealed class CompParamsHandle : SafeHandle +{ + public CompParamsHandle() + : base(IntPtr.Zero, ownsHandle: true) { } + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + return NativeMethods.bu_delete_comp_params(handle); + } +} diff --git a/source/CapriKit.SuperCompressed/CompressionFlags.cs b/source/CapriKit.SuperCompressed/CompressionFlags.cs new file mode 100644 index 0000000..cba95d0 --- /dev/null +++ b/source/CapriKit.SuperCompressed/CompressionFlags.cs @@ -0,0 +1,37 @@ +namespace CapriKit.SuperCompressed; + +// Mirrors the BU_COMP_FLAGS_* constants in +// external/basis_universal/encoder/basisu_wasm_api_common.h; backed by ulong to match +// the 64-bit native flags parameter. +/// Encoding options for . +/// +/// Bits 23-24 (XUASTC LDR syntax), 25-26 (texture type) and 29-31 (XUBC7 base encoder) +/// are multi-bit fields rather than independent flags. The default (0) means: full +/// arithmetic XUASTC syntax, 2D texture, BC7F base encoder. +/// +[Flags] +public enum CompressionFlags : ulong +{ + None = 0, + UseOpenCL = 1ul << 8, + Threaded = 1ul << 9, + DebugOutput = 1ul << 10, + Ktx2Output = 1ul << 11, + Ktx2UastcZstd = 1ul << 12, + Srgb = 1ul << 13, + GenMipsClamp = 1ul << 14, + GenMipsWrap = 1ul << 15, + YFlip = 1ul << 16, + PrintStats = 1ul << 18, + PrintStatus = 1ul << 19, + DebugImages = 1ul << 20, + Rec2020 = 1ul << 21, + ValidateOutput = 1ul << 22, + XuastcLdrSyntaxHybrid = 1ul << 23, + XuastcLdrSyntaxFullZstd = 2ul << 23, + TextureType2DArray = 1ul << 25, + TextureTypeCubemapArray = 2ul << 25, + TextureTypeVideoFrames = 3ul << 25, + DisableDeblocking = 1ul << 27, + ForceDeblocking = 1ul << 28, +} diff --git a/source/CapriKit.SuperCompressed/DecodeFlags.cs b/source/CapriKit.SuperCompressed/DecodeFlags.cs new file mode 100644 index 0000000..b3d09e6 --- /dev/null +++ b/source/CapriKit.SuperCompressed/DecodeFlags.cs @@ -0,0 +1,19 @@ +namespace CapriKit.SuperCompressed; + +// Mirrors the DECODE_FLAGS_* constants (basist::basisu_decode_flags) in +// external/basis_universal/encoder/basisu_wasm_api_common.h. +/// Options for the transcode methods. +[Flags] +public enum DecodeFlags : uint +{ + None = 0, + PvrtcDecodeToNextPow2 = 2, + TranscodeAlphaDataToOpaqueFormats = 4, + Bc1ForbidThreeColorBlocks = 8, + OutputHasAlphaIndices = 16, + HighQuality = 32, + NoEtc1sChromaFiltering = 64, + NoDeblockFiltering = 128, + ForceDeblockFiltering = 512, + XuastcLdrDisableFastBc7Transcoding = 1024, +} diff --git a/source/CapriKit.SuperCompressed/Image.cs b/source/CapriKit.SuperCompressed/Image.cs new file mode 100644 index 0000000..1cb4379 --- /dev/null +++ b/source/CapriKit.SuperCompressed/Image.cs @@ -0,0 +1,53 @@ +using StbImageSharp; + +namespace CapriKit.SuperCompressed; + +/// +/// An uncompressed 32 bits-per-pixel RGBA image, the input format of . +/// +public sealed class Image +{ + private readonly byte[] pixels; + + /// + /// Loads a JPG, PNG, BMP, TGA, PSD or GIF image using StbImageSharp, + /// converting it to RGBA32 when necessary. + /// + public static Image Load(Stream stream) + { + var result = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha); + return new Image(result.Data, result.Width, result.Height); + } + + /// + public static Image Load(byte[] fileData) + { + var result = ImageResult.FromMemory(fileData, ColorComponents.RedGreenBlueAlpha); + return new Image(result.Data, result.Width, result.Height); + } + + /// + /// Wraps existing RGBA32 pixel data in an image. The array is not copied: + /// later changes to it are visible through . + /// + public Image(byte[] rgba32Pixels, int width, int height) + { + ArgumentNullException.ThrowIfNull(rgba32Pixels); + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + if (rgba32Pixels.Length != (long)width * height * 4) + { + throw new ArgumentException($"Expected {width}x{height}x4 = {(long)width * height * 4} bytes of RGBA32 pixel data, got {rgba32Pixels.Length} bytes", nameof(rgba32Pixels)); + } + + pixels = rgba32Pixels; + Width = width; + Height = height; + } + + public int Width { get; } + public int Height { get; } + + /// The pixel data in row-major RGBA order, 4 bytes per pixel, no padding between rows. + public ReadOnlySpan Pixels => pixels; +} diff --git a/source/CapriKit.SuperCompressed/Ktx2Encoder.cs b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs new file mode 100644 index 0000000..4fde94e --- /dev/null +++ b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// Encodes images into supercompressed KTX2 textures, which +/// can later transcode to GPU-native formats. +/// +public static class Ktx2Encoder +{ + public const int MinQuality = 1; + public const int MaxQuality = 100; + public const int MinEffort = 0; + public const int MaxEffort = 10; + + /// Encodes an image into a KTX2 texture. + /// The source image. + /// The intermediate texture format stored inside the KTX2 file. + /// Encoding quality, (smallest) to (best). + /// Encoding effort, (fastest) to (best). + /// + /// Encoding options, for example for color textures, + /// or to + /// generate mipmaps, and to use all cores. + /// + /// The contents of a .ktx2 file. + public static byte[] Encode(Image image, BasisTexFormat format, int quality = 85, int effort = 2, CompressionFlags flags = CompressionFlags.None) + { + ArgumentOutOfRangeException.ThrowIfLessThan(quality, MinQuality); + ArgumentOutOfRangeException.ThrowIfGreaterThan(quality, MaxQuality); + ArgumentOutOfRangeException.ThrowIfLessThan(effort, MinEffort); + ArgumentOutOfRangeException.ThrowIfGreaterThan(effort, MaxEffort); + + using var compParams = NativeMethods.bu_new_comp_params(); + if (compParams.IsInvalid) + { + throw new InvalidOperationException("Failed to create basis_universal compression parameters"); + } + + var width = (uint)image.Width; + var height = (uint)image.Height; + if (!NativeMethods.bu_comp_params_set_image_rgba32(compParams, 0, image.Pixels, width, height, width * 4)) + { + throw new InvalidOperationException("basis_universal rejected the source image"); + } + + if (!NativeMethods.bu_compress_texture(compParams, format, quality, effort, flags | CompressionFlags.Ktx2Output, 0.0f)) + { + throw new InvalidOperationException($"basis_universal failed to encode the image to {format}"); + } + + var size = checked((int)NativeMethods.bu_comp_params_get_comp_data_size(compParams)); + var pCompressedData = NativeMethods.bu_comp_params_get_comp_data_ofs(compParams); + + var ktx2 = new byte[size]; + Marshal.Copy(pCompressedData, ktx2, 0, size); + return ktx2; + } +} diff --git a/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs new file mode 100644 index 0000000..86019f9 --- /dev/null +++ b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// An opened KTX2 texture, created by and passed to the +/// other methods. Dispose it to free the native reader and +/// the file data it references. +/// +public sealed class Ktx2FileHandle : SafeHandle +{ + internal Ktx2FileHandle(byte[] fileData) + : base(IntPtr.Zero, ownsHandle: true) + { + // The native KTX2 reader references the file data instead of copying it. Storing the + // pinned buffer here keeps it reachable for at least as long as any native call can + // read through this handle: the interop marshaller keeps the handle (and with it this + // field) alive for the duration of every call the handle is passed to. + FileData = fileData; + } + + internal byte[] FileData { get; } + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + NativeMethods.bt_ktx2_close(handle); + return true; + } +} diff --git a/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs new file mode 100644 index 0000000..2b27556 --- /dev/null +++ b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs @@ -0,0 +1,184 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// Reads KTX2 textures (as produced by ) and transcodes their mip +/// levels to GPU-native formats. Open a texture with , then pass the +/// returned handle to the other methods. +/// +public static class Ktx2Transcoder +{ + /// Parses the contents of a .ktx2 file and prepares it for transcoding. + /// The data is not a supported KTX2 texture. + public static unsafe Ktx2FileHandle Open(ReadOnlySpan ktx2Data) + { + // The native KTX2 reader references the buffer for the lifetime of the handle, so + // it is copied to the pinned object heap, where it never moves, and stored in the + // handle, which keeps it reachable while the native side can still read it. + var pinnedData = GC.AllocateUninitializedArray(ktx2Data.Length, pinned: true); + ktx2Data.CopyTo(pinnedData); + + IntPtr rawHandle; + fixed (byte* pData = pinnedData) + { + rawHandle = NativeMethods.bt_ktx2_open((IntPtr)pData, (uint)pinnedData.Length); + } + + if (rawHandle == IntPtr.Zero) + { + throw new InvalidDataException("Not a valid KTX2 file"); + } + + var ktx2File = new Ktx2FileHandle(pinnedData); + Marshal.InitHandle(ktx2File, rawHandle); + + if (!NativeMethods.bt_ktx2_start_transcoding(ktx2File)) + { + ktx2File.Dispose(); + throw new InvalidDataException("Failed to prepare the KTX2 file for transcoding"); + } + + return ktx2File; + } + + /// Width in pixels of the largest mip level. + public static uint GetWidth(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_width(ktx2File); + } + + /// Height in pixels of the largest mip level. + public static uint GetHeight(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_height(ktx2File); + } + + /// The number of mip levels. + public static uint GetLevels(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_levels(ktx2File); + } + + /// The number of array layers; 0 for textures that are not arrays. + public static uint GetLayers(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_layers(ktx2File); + } + + /// The number of faces: 6 for cubemaps, 1 otherwise. + public static uint GetFaces(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_faces(ktx2File); + } + + /// The intermediate texture format stored inside the KTX2 file. + public static BasisTexFormat GetFormat(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_basis_tex_format(ktx2File); + } + + public static bool HasAlpha(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_has_alpha(ktx2File); + } + + public static bool IsSrgb(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_is_srgb(ktx2File); + } + + /// Transcodes one mip level to the given GPU texture format. + /// + /// Do not use concurrently with the same : it uses the file's + /// shared transcode state. Use to process a file on all cores. + /// + public static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat format, uint level = 0, uint layer = 0, uint face = 0, DecodeFlags flags = DecodeFlags.None) + { + return Transcode(ktx2File, format, level, layer, face, flags, stateHandle: 0); + } + + /// + /// Transcodes every mip level, layer, and face in the file to the given GPU texture + /// format, in parallel. The result is ordered by level, then layer, then face. + /// + public static TranscodedImage[] TranscodeAll(Ktx2FileHandle ktx2File, TranscodeFormat format, DecodeFlags flags = DecodeFlags.None) + { + var levels = GetLevels(ktx2File); + var layers = Math.Max(GetLayers(ktx2File), 1); // 0 for textures that are not arrays + var faces = GetFaces(ktx2File); + + var work = new List<(uint Level, uint Layer, uint Face)>(); + for (var level = 0u; level < levels; level++) + { + for (var layer = 0u; layer < layers; layer++) + { + for (var face = 0u; face < faces; face++) + { + work.Add((level, layer, face)); + } + } + } + + // Transcoding the same file from multiple threads is only safe when every thread + // uses its own native transcode state: each worker creates one before its first + // item and destroys it after its last, even when an item throws. + var images = new TranscodedImage[work.Count]; + Parallel.ForEach( + work, + NativeMethods.bt_ktx2_create_transcode_state, + (item, _, index, stateHandle) => + { + images[index] = Transcode(ktx2File, format, item.Level, item.Layer, item.Face, flags, stateHandle); + return stateHandle; + }, + NativeMethods.bt_ktx2_destroy_transcode_state); + + return images; + } + + private static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat format, uint level, uint layer, uint face, DecodeFlags flags, ulong stateHandle) + { + var width = NativeMethods.bt_ktx2_get_level_orig_width(ktx2File, level, layer, face); + var height = NativeMethods.bt_ktx2_get_level_orig_height(ktx2File, level, layer, face); + if (width == 0 || height == 0) + { + throw new ArgumentOutOfRangeException(nameof(level), $"The KTX2 file does not contain level {level}, layer {layer}, face {face}"); + } + + // For compressed formats sizes are expressed in blocks, for uncompressed formats in pixels + var bytesPerUnit = NativeMethods.bt_basis_get_bytes_per_block_or_pixel(format); + uint rowPitch; + if (NativeMethods.bt_basis_transcoder_format_is_uncompressed(format)) + { + rowPitch = width * bytesPerUnit; + } + else + { + var blockWidth = NativeMethods.bt_basis_get_block_width(format); + var blocksPerRow = (width + blockWidth - 1) / blockWidth; + rowPitch = blocksPerRow * bytesPerUnit; + } + + var sizeInBytes = NativeMethods.bt_basis_compute_transcoded_image_size_in_bytes(format, width, height); + var data = new byte[sizeInBytes]; + var succeeded = NativeMethods.bt_ktx2_transcode_image_level( + ktx2File, + level, layer, face, + data, + sizeInBytes / bytesPerUnit, + format, + flags, + outputRowPitchInBlocksOrPixels: 0, + outputRowsInPixels: 0, + channel0: -1, channel1: -1, + stateHandle); + + if (!succeeded) + { + throw new InvalidOperationException($"Failed to transcode level {level}, layer {layer}, face {face} from {GetFormat(ktx2File)} to {format}"); + } + + return new TranscodedImage(format, level, layer, face, width, height, rowPitch, data); + } +} diff --git a/source/CapriKit.SuperCompressed/NativeMethods.cs b/source/CapriKit.SuperCompressed/NativeMethods.cs new file mode 100644 index 0000000..e8c79f9 --- /dev/null +++ b/source/CapriKit.SuperCompressed/NativeMethods.cs @@ -0,0 +1,359 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// P/Invoke bindings for caprikit_basisu.dll, a 1:1 mirror of basis_universal's C API. +/// Method names match the native entry points so the upstream headers double as documentation: +/// external/basis_universal/encoder/basisu_wasm_api.h (bu_*, encoder) and +/// basisu_wasm_transcoder_api.h (bt_*, transcoder). +/// +/// +/// The native API models memory as uint64 "offsets" for WASM compatibility; in this native +/// build they are simply raw pointers. Managed memory is pinned (spans) or allocated by the +/// caller, so the native bu_alloc/bu_free/bt_alloc/bt_free helpers are deliberately not bound. +/// wasm_bool_t is a uint32, marshalled as a 4-byte bool. +/// +internal static partial class NativeMethods +{ + private const string DllName = "caprikit_basisu"; + + /// + /// The BASISU_LIB_VERSION this wrapper was written against; bu_get_version() and + /// bt_get_version() must match. Update when updating the basis_universal submodule. + /// + public const uint ExpectedLibVersion = 250; + + // The encoder and transcoder must be initialized once before any other call. + static NativeMethods() + { + bu_init(); + bt_init(); + } + + // ---- Encoder (basisu_wasm_api.h) ---- + + /// Also prints a greeting to stdout; do not call in library code paths. + [LibraryImport(DllName)] + public static partial uint bu_get_version(); + + [LibraryImport(DllName)] + public static partial void bu_enable_debug_printf([MarshalAs(UnmanagedType.U4)] bool flag); + + [LibraryImport(DllName)] + private static partial void bu_init(); + + [LibraryImport(DllName)] + public static partial CompParamsHandle bu_new_comp_params(); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bu_delete_comp_params(IntPtr paramsHandle); + + [LibraryImport(DllName)] + public static partial ulong bu_comp_params_get_comp_data_size(CompParamsHandle paramsHandle); + + /// + /// Returns a pointer into native memory owned by the params object; it is invalidated + /// by the next clear, compress or delete call on the same params object. + /// + [LibraryImport(DllName)] + public static partial IntPtr bu_comp_params_get_comp_data_ofs(CompParamsHandle paramsHandle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bu_comp_params_clear(CompParamsHandle paramsHandle); + + /// Copies a 32bpp RGBA image into the params object (image data is not retained). + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bu_comp_params_set_image_rgba32( + CompParamsHandle paramsHandle, + uint imageIndex, + ReadOnlySpan imageData, + uint width, uint height, + uint pitchInBytes); + + /// Copies a 128bpp float RGBA image into the params object (image data is not retained). + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bu_comp_params_set_image_float_rgba( + CompParamsHandle paramsHandle, + uint imageIndex, + ReadOnlySpan imageData, + uint width, uint height, + uint pitchInBytes); + + /// + /// Compresses the images previously set on the params object. On success the result is + /// available via bu_comp_params_get_comp_data_size/_ofs. Quality is 1-100, effort 0-10. + /// + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bu_compress_texture( + CompParamsHandle paramsHandle, + BasisTexFormat desiredBasisTexFormat, + int qualityLevel, int effortLevel, + CompressionFlags flags, + float lowLevelUastcRdoOrDctQuality); + + // ---- Transcoder (basisu_wasm_transcoder_api.h) ---- + + /// Also prints a greeting to stdout; do not call in library code paths. + [LibraryImport(DllName)] + public static partial uint bt_get_version(); + + [LibraryImport(DllName)] + public static partial void bt_enable_debug_printf([MarshalAs(UnmanagedType.U4)] bool flag); + + [LibraryImport(DllName)] + private static partial void bt_init(); + + // basis_tex_format helpers + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_tex_format_is_xuastc_ldr(BasisTexFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_tex_format_is_astc_ldr(BasisTexFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_tex_format_is_xubc7(BasisTexFormat format); + + [LibraryImport(DllName)] + public static partial uint bt_basis_tex_format_get_block_width(BasisTexFormat format); + + [LibraryImport(DllName)] + public static partial uint bt_basis_tex_format_get_block_height(BasisTexFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_tex_format_is_hdr(BasisTexFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_tex_format_is_ldr(BasisTexFormat format); + + // transcoder_texture_format helpers + + [LibraryImport(DllName)] + public static partial uint bt_basis_get_bytes_per_block_or_pixel(TranscodeFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_transcoder_format_has_alpha(TranscodeFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_transcoder_format_is_hdr(TranscodeFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_transcoder_format_is_ldr(TranscodeFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_transcoder_texture_format_is_astc(TranscodeFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_transcoder_format_is_uncompressed(TranscodeFormat format); + + [LibraryImport(DllName)] + public static partial uint bt_basis_get_uncompressed_bytes_per_pixel(TranscodeFormat format); + + [LibraryImport(DllName)] + public static partial uint bt_basis_get_block_width(TranscodeFormat format); + + [LibraryImport(DllName)] + public static partial uint bt_basis_get_block_height(TranscodeFormat format); + + [LibraryImport(DllName)] + public static partial TranscodeFormat bt_basis_get_transcoder_texture_format_from_basis_tex_format(BasisTexFormat format); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_basis_is_format_supported(TranscodeFormat transcodeFormat, BasisTexFormat basisTexFormat); + + [LibraryImport(DllName)] + public static partial uint bt_basis_compute_transcoded_image_size_in_bytes(TranscodeFormat format, uint origWidth, uint origHeight); + + // KTX2 files + + /// + /// The native side keeps referencing after this call: the memory + /// must stay valid (and, if managed, pinned) until the returned handle is closed. + /// Returns a raw handle (zero on failure) because the source generator cannot construct + /// a , whose constructor is internal on purpose; wrap the + /// result via . + /// + [LibraryImport(DllName)] + public static partial IntPtr bt_ktx2_open(IntPtr data, uint dataLength); + + [LibraryImport(DllName)] + public static partial void bt_ktx2_close(IntPtr ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_width(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_height(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_levels(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_faces(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_layers(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial BasisTexFormat bt_ktx2_get_basis_tex_format(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_etc1s(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_uastc_ldr_4x4(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_hdr(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_hdr_4x4(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_hdr_6x6(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_ldr(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_astc_ldr(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_xuastc_ldr(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_xubc7(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_block_width(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_block_height(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_deblocking_filter_index(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_has_alpha(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_color_model(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_color_primaries(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_transfer_func(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_srgb(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_flags(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_total_samples(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_channel_id0(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_dfd_channel_id1(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_is_video(Ktx2FileHandle ktx2Handle); + + [LibraryImport(DllName)] + public static partial float bt_ktx2_get_ldr_hdr_upconversion_nit_multiplier(Ktx2FileHandle ktx2Handle); + + // KTX2 levels + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_orig_width(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_orig_height(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_actual_width(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_actual_height(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_num_blocks_x(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_num_blocks_y(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + public static partial uint bt_ktx2_get_level_total_blocks(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_get_level_alpha_flag(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_get_level_iframe_flag(Ktx2FileHandle ktx2Handle, uint levelIndex, uint layerIndex, uint faceIndex); + + // Transcoding + + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_start_transcoding(Ktx2FileHandle ktx2Handle); + + /// Only needed for transcoding the same file from multiple threads; pass 0 otherwise. + [LibraryImport(DllName)] + public static partial ulong bt_ktx2_create_transcode_state(); + + [LibraryImport(DllName)] + public static partial void bt_ktx2_destroy_transcode_state(ulong stateHandle); + + /// + /// Transcodes one mip level of one layer/face into , + /// whose size is given in blocks (compressed formats) or pixels (uncompressed formats). + /// + [LibraryImport(DllName)] + [return: MarshalAs(UnmanagedType.U4)] + public static partial bool bt_ktx2_transcode_image_level( + Ktx2FileHandle ktx2Handle, + uint levelIndex, uint layerIndex, uint faceIndex, + Span outputBlocks, + uint outputBlocksSizeInBlocksOrPixels, + TranscodeFormat format, + DecodeFlags decodeFlags, + uint outputRowPitchInBlocksOrPixels, + uint outputRowsInPixels, + int channel0, int channel1, + ulong stateHandle); +} diff --git a/source/CapriKit.SuperCompressed/README.md b/source/CapriKit.SuperCompressed/README.md new file mode 100644 index 0000000..c96c16f --- /dev/null +++ b/source/CapriKit.SuperCompressed/README.md @@ -0,0 +1,89 @@ +# CapriKit.SuperCompressed + +Supercompressed textures, powered by +[basis_universal](https://github.com/BinomialLLC/basis_universal). Encode images into +compact `.ktx2` files in your asset pipeline, then transcode them at load time to whatever +block-compressed format the GPU wants (BC1-BC7 for DirectX 11, plus ETC/ASTC and +uncompressed formats). + +**Windows x64 only.** The managed API is a thin wrapper over `caprikit_basisu.dll`, which +contains basis_universal's encoder and transcoder (shipped in the NuGet package as a +`runtimes/win-x64/native` asset). + +## Usage + +Encoding, typically done offline in an asset pipeline: + +```csharp +using CapriKit.SuperCompressed; + +// Image.Load decodes JPG/PNG/BMP/TGA/PSD/GIF; or construct one from raw RGBA32 pixels +using var stream = File.OpenRead("albedo.png"); +var image = Image.Load(stream); + +// A color texture: sRGB, with a full mipmap chain, using all cores +var ktx2 = Ktx2Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, + flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp | CompressionFlags.Threaded); + +File.WriteAllBytes("albedo.ktx2", ktx2); +``` + +Transcoding, done at load time: + +```csharp +using var ktx2File = Ktx2Transcoder.Open(File.ReadAllBytes("albedo.ktx2")); + +// Transcodes every mip level (and layer/face) in parallel; +// use Ktx2Transcoder.Transcode to transcode a single image instead +foreach (var mip in Ktx2Transcoder.TranscodeAll(ktx2File, TranscodeFormat.Bc7Rgba)) +{ + // Upload mip.Data to the GPU; mip.RowPitch matches D3D11_SUBRESOURCE_DATA.SysMemPitch +} +``` + +Notes: + +- Pick `BasisTexFormat.Etc1s` for the smallest files, `UastcLdr4x4` for higher quality, or one + of the XUASTC formats for the newest codec. All of them transcode to all `TranscodeFormat`s. +- Mipmaps are generated by basis_universal (kaiser filter). Use `GenMipsClamp` or + `GenMipsWrap` to match the texture's sampler addressing mode. +- Output is always KTX2; the `.basis` container is not supported. + +## Building the native library + +`caprikit_basisu.dll` is not built by `dotnet build` — the `dotnet` CLI cannot build C++. +The `basis_universal` subfolder holds a small CMake project (not part of `CapriKit.slnx`) +that compiles the submodule's encoder and transcoder, exposed through upstream's own +plain-C API (`bu_*`/`bt_*` functions), into the DLL this project P/Invokes. + +Requires CMake and the Visual Studio C++ toolchain (x64): + +```pwsh +.\basis_universal\build.ps1 # Release (default) +.\basis_universal\build.ps1 -Configuration Debug +``` + +Output: `\.build\bin\caprikit_basisu\caprikit_basisu.dll` +(intermediates in `\.build\native\caprikit_basisu`). It only needs rebuilding when +the `external/basis_universal` submodule is updated. + +### Updating basis_universal + +1. Update the submodule to the desired version. +2. Rerun `.\basis_universal\build.ps1`. It regenerates `exports.def` from the + `BU_WASM_EXPORT` declarations in the C API headers, so added/removed functions are + handled automatically. +3. If `bu_get_version()` (`BASISU_LIB_VERSION`) changed, update the version check in + `NativeMethods.cs`. + +### Native build implementation notes + +- `CMakeLists.txt` pulls in the submodule's own CMake build via `add_subdirectory` and links + its `basisu_encoder` static library — upstream maintains the source list and compiler + flags. The submodule is never modified. +- `exports.def` is required because `BU_WASM_EXPORT` expands to plain `extern "C"` on MSVC, + without `__declspec(dllexport)`. It is generated by `build.ps1`; do not edit by hand. +- `BASISD_SUPPORT_KTX2_ZSTD` and `BASISU_SUPPORT_SSE` are defined explicitly on the DLL + target because upstream applies them per-directory; without this, our translation units + would silently mismatch the static library. +- The DLL uses a static CRT (`/MT`) so it has no Visual C++ redistributable dependency. diff --git a/source/CapriKit.SuperCompressed/TranscodeFormat.cs b/source/CapriKit.SuperCompressed/TranscodeFormat.cs new file mode 100644 index 0000000..77fb684 --- /dev/null +++ b/source/CapriKit.SuperCompressed/TranscodeFormat.cs @@ -0,0 +1,48 @@ +namespace CapriKit.SuperCompressed; + +// Mirrors the TF_* constants (basist::transcoder_texture_format) in +// external/basis_universal/encoder/basisu_wasm_api_common.h. +/// The GPU texture formats a .ktx2 file can be transcoded to. +public enum TranscodeFormat : uint +{ + Etc1Rgb = 0, + Etc2Rgba = 1, + Bc1Rgb = 2, + Bc3Rgba = 3, + Bc4R = 4, + Bc5Rg = 5, + Bc7Rgba = 6, + Pvrtc14Rgb = 8, + Pvrtc14Rgba = 9, + AstcLdr4x4Rgba = 10, + AtcRgb = 11, + AtcRgba = 12, + Rgba32 = 13, + Rgb565 = 14, + Bgr565 = 15, + Rgba4444 = 16, + Fxt1Rgb = 17, + Pvrtc24Rgb = 18, + Pvrtc24Rgba = 19, + Etc2EacR11 = 20, + Etc2EacRg11 = 21, + Bc6h = 22, + AstcHdr4x4Rgba = 23, + RgbHalf = 24, + RgbaHalf = 25, + Rgb9e5 = 26, + AstcHdr6x6Rgba = 27, + AstcLdr5x4Rgba = 28, + AstcLdr5x5Rgba = 29, + AstcLdr6x5Rgba = 30, + AstcLdr6x6Rgba = 31, + AstcLdr8x5Rgba = 32, + AstcLdr8x6Rgba = 33, + AstcLdr10x5Rgba = 34, + AstcLdr10x6Rgba = 35, + AstcLdr8x8Rgba = 36, + AstcLdr10x8Rgba = 37, + AstcLdr10x10Rgba = 38, + AstcLdr12x10Rgba = 39, + AstcLdr12x12Rgba = 40, +} diff --git a/source/CapriKit.SuperCompressed/TranscodedImage.cs b/source/CapriKit.SuperCompressed/TranscodedImage.cs new file mode 100644 index 0000000..591065c --- /dev/null +++ b/source/CapriKit.SuperCompressed/TranscodedImage.cs @@ -0,0 +1,17 @@ +namespace CapriKit.SuperCompressed; + +/// +/// One transcoded mip level: texel data ready to upload to the GPU. +/// +/// The GPU texture format of . +/// The mip level this image was transcoded from. +/// The array layer this image was transcoded from; 0 for textures that are not arrays. +/// The cubemap face this image was transcoded from; 0 for textures that are not cubemaps. +/// Width in pixels. +/// Height in pixels. +/// +/// Distance in bytes between consecutive rows of pixels (uncompressed formats) or rows of +/// blocks (compressed formats), as expected by for example D3D11_SUBRESOURCE_DATA.SysMemPitch. +/// +/// The texel data. +public sealed record TranscodedImage(TranscodeFormat Format, uint Level, uint Layer, uint Face, uint Width, uint Height, uint RowPitch, byte[] Data); diff --git a/source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt b/source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt new file mode 100644 index 0000000..6ef7158 --- /dev/null +++ b/source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt @@ -0,0 +1,47 @@ +# Builds caprikit_basisu.dll: basis_universal's plain-C API (the bu_*/bt_* functions) +# compiled into a shared library for P/Invoke from CapriKit.SuperCompressed. +# +# The basis_universal submodule is never modified. Its own CMake build provides the +# basisu_encoder static library, so upstream maintains the source list and compiler flags. +# See ../README.md for how to build and how to update to a newer basis_universal. +cmake_minimum_required(VERSION 3.20) + +# Use a static CRT (/MT) so the DLL has no Visual C++ redistributable dependency. +# Must be set before the first project() call. Safe across the DLL boundary because +# all native memory is allocated and freed on the native side of the C API. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + +project(caprikit_basisu C CXX) + +# Same C++ standard as upstream; their CMAKE_CXX_STANDARD is directory-scoped +# and does not reach our target. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(BASISU_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../external/basis_universal) + +# Pin the upstream build options we rely on. The compile definitions on our target +# below must stay consistent with these: upstream applies BASISD_SUPPORT_KTX2_ZSTD and +# BASISU_SUPPORT_SSE per-directory/per-target, so our target does not inherit them. +set(BASISU_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BASISU_ZSTD ON CACHE BOOL "" FORCE) +set(BASISU_SSE ON CACHE BOOL "" FORCE) +set(BASISU_OPENCL OFF CACHE BOOL "" FORCE) + +# EXCLUDE_FROM_ALL: only build what caprikit_basisu links against (basisu_encoder), +# not the basisu command line tool. +add_subdirectory(${BASISU_ROOT} basis_universal EXCLUDE_FROM_ALL) + +# BU_WASM_EXPORT expands to plain `extern "C"` on MSVC (no __declspec(dllexport)), +# so exports.def supplies the DLL exports. It is (re)generated by build.ps1 from the +# BU_WASM_EXPORT declarations in the two C API headers. +add_library(caprikit_basisu SHARED + ${BASISU_ROOT}/encoder/basisu_wasm_api.cpp + ${BASISU_ROOT}/encoder/basisu_wasm_transcoder_api.cpp + exports.def) + +target_compile_definitions(caprikit_basisu PRIVATE + BASISD_SUPPORT_KTX2_ZSTD=1 + BASISU_SUPPORT_SSE=1) + +target_link_libraries(caprikit_basisu PRIVATE basisu_encoder) diff --git a/source/CapriKit.SuperCompressed/basis_universal/build.ps1 b/source/CapriKit.SuperCompressed/basis_universal/build.ps1 new file mode 100644 index 0000000..a99a39d --- /dev/null +++ b/source/CapriKit.SuperCompressed/basis_universal/build.ps1 @@ -0,0 +1,58 @@ +# Builds caprikit_basisu.dll from the basis_universal submodule. +# Requires CMake and the Visual Studio C++ toolchain. +# Output: \.build\bin\caprikit_basisu\caprikit_basisu.dll +# +# Rerun this script after updating the basis_universal submodule; it also +# regenerates exports.def so new bu_*/bt_* functions are exported automatically. +param( + [ValidateSet('Release', 'Debug')] + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path "$PSScriptRoot\..\..\..").Path +$buildDir = Join-Path $repoRoot '.build\native\caprikit_basisu' +$outDir = Join-Path $repoRoot '.build\bin\caprikit_basisu' + +# Find CMake: PATH first, then the default install location. +$cmake = (Get-Command cmake -ErrorAction SilentlyContinue)?.Source ?? 'C:\Program Files\CMake\bin\cmake.exe' +if (-not (Test-Path $cmake)) { + throw "CMake not found on PATH or at '$cmake'. Install CMake first." +} + +# Regenerate exports.def from the C API headers so it never goes stale. +$headers = "$repoRoot\external\basis_universal\encoder\basisu_wasm_api.h", + "$repoRoot\external\basis_universal\encoder\basisu_wasm_transcoder_api.h" +$names = Select-String -Path $headers -Pattern 'BU_WASM_EXPORT\("([^"]+)"\)' | + ForEach-Object { $_.Matches[0].Groups[1].Value } +if ($names.Count -eq 0) { + throw 'No BU_WASM_EXPORT declarations found; did the basis_universal C API move?' +} + +$defLines = @( + '; Generated by build.ps1 from the BU_WASM_EXPORT declarations in' + '; external/basis_universal/encoder/basisu_wasm_api.h and basisu_wasm_transcoder_api.h.' + '; Do not edit by hand; rerun build.ps1 to regenerate.' + 'LIBRARY caprikit_basisu' + 'EXPORTS' +) + ($names | ForEach-Object { " $_" }) +$defContent = ($defLines -join "`r`n") + "`r`n" + +$defPath = Join-Path $PSScriptRoot 'exports.def' +if ((Test-Path $defPath) -and (Get-Content $defPath -Raw) -eq $defContent) { + Write-Host "exports.def is up to date ($($names.Count) exports)." +} else { + Set-Content -Path $defPath -Value $defContent -NoNewline + Write-Host "exports.def (re)generated ($($names.Count) exports)." +} + +& $cmake -S $PSScriptRoot -B $buildDir -A x64 +if ($LASTEXITCODE -ne 0) { throw 'CMake configure failed.' } + +& $cmake --build $buildDir --config $Configuration +if ($LASTEXITCODE -ne 0) { throw 'CMake build failed.' } + +New-Item -ItemType Directory -Force $outDir | Out-Null +Copy-Item (Join-Path $buildDir "$Configuration\caprikit_basisu.dll") $outDir -Force +Write-Host "caprikit_basisu.dll -> $outDir" diff --git a/source/CapriKit.SuperCompressed/basis_universal/exports.def b/source/CapriKit.SuperCompressed/basis_universal/exports.def new file mode 100644 index 0000000..d61f25b --- /dev/null +++ b/source/CapriKit.SuperCompressed/basis_universal/exports.def @@ -0,0 +1,86 @@ +; Generated by build.ps1 from the BU_WASM_EXPORT declarations in +; external/basis_universal/encoder/basisu_wasm_api.h and basisu_wasm_transcoder_api.h. +; Do not edit by hand; rerun build.ps1 to regenerate. +LIBRARY caprikit_basisu +EXPORTS + bu_get_version + bu_enable_debug_printf + bu_init + bu_alloc + bu_free + bu_new_comp_params + bu_delete_comp_params + bu_comp_params_get_comp_data_size + bu_comp_params_get_comp_data_ofs + bu_comp_params_clear + bu_comp_params_set_image_rgba32 + bu_comp_params_set_image_float_rgba + bu_compress_texture + bt_get_version + bt_enable_debug_printf + bt_init + bt_alloc + bt_free + bt_basis_tex_format_is_xuastc_ldr + bt_basis_tex_format_is_astc_ldr + bt_basis_tex_format_is_xubc7 + bt_basis_tex_format_get_block_width + bt_basis_tex_format_get_block_height + bt_basis_tex_format_is_hdr + bt_basis_tex_format_is_ldr + bt_basis_get_bytes_per_block_or_pixel + bt_basis_transcoder_format_has_alpha + bt_basis_transcoder_format_is_hdr + bt_basis_transcoder_format_is_ldr + bt_basis_transcoder_texture_format_is_astc + bt_basis_transcoder_format_is_uncompressed + bt_basis_get_uncompressed_bytes_per_pixel + bt_basis_get_block_width + bt_basis_get_block_height + bt_basis_get_transcoder_texture_format_from_basis_tex_format + bt_basis_is_format_supported + bt_basis_compute_transcoded_image_size_in_bytes + bt_ktx2_open + bt_ktx2_close + bt_ktx2_get_width + bt_ktx2_get_height + bt_ktx2_get_levels + bt_ktx2_get_faces + bt_ktx2_get_layers + bt_ktx2_get_basis_tex_format + bt_ktx2_is_etc1s + bt_ktx2_is_uastc_ldr_4x4 + bt_ktx2_is_hdr + bt_ktx2_is_hdr_4x4 + bt_ktx2_is_hdr_6x6 + bt_ktx2_is_ldr + bt_ktx2_is_astc_ldr + bt_ktx2_is_xuastc_ldr + bt_ktx2_is_xubc7 + bt_ktx2_get_block_width + bt_ktx2_get_block_height + bt_ktx2_get_deblocking_filter_index + bt_ktx2_has_alpha + bt_ktx2_get_dfd_color_model + bt_ktx2_get_dfd_color_primaries + bt_ktx2_get_dfd_transfer_func + bt_ktx2_is_srgb + bt_ktx2_get_dfd_flags + bt_ktx2_get_dfd_total_samples + bt_ktx2_get_dfd_channel_id0 + bt_ktx2_get_dfd_channel_id1 + bt_ktx2_is_video + bt_ktx2_get_ldr_hdr_upconversion_nit_multiplier + bt_ktx2_get_level_orig_width + bt_ktx2_get_level_orig_height + bt_ktx2_get_level_actual_width + bt_ktx2_get_level_actual_height + bt_ktx2_get_level_num_blocks_x + bt_ktx2_get_level_num_blocks_y + bt_ktx2_get_level_total_blocks + bt_ktx2_get_level_alpha_flag + bt_ktx2_get_level_iframe_flag + bt_ktx2_start_transcoding + bt_ktx2_create_transcode_state + bt_ktx2_destroy_transcode_state + bt_ktx2_transcode_image_level diff --git a/source/CapriKit.Tests/CapriKit.Tests.csproj b/source/CapriKit.Tests/CapriKit.Tests.csproj index aa0dd27..c907671 100644 --- a/source/CapriKit.Tests/CapriKit.Tests.csproj +++ b/source/CapriKit.Tests/CapriKit.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/source/CapriKit.Tests/SuperCompressed/ImageTests.cs b/source/CapriKit.Tests/SuperCompressed/ImageTests.cs new file mode 100644 index 0000000..1aa6efc --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/ImageTests.cs @@ -0,0 +1,32 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class ImageTests +{ + [Test] + public async Task LoadDecodesImageFilesToRgba32() + { + var image = Image.Load(TestImages.CreateTgaFile()); + + await Assert.That(image.Width).IsEqualTo(2); + await Assert.That(image.Height).IsEqualTo(2); + await Assert.That(image.Pixels.Length).IsEqualTo(2 * 2 * 4); + + // The first pixel is red; TGA stores BGRA but Image is always RGBA + await Assert.That(image.Pixels[0]).IsEqualTo((byte)255); + await Assert.That(image.Pixels[1]).IsEqualTo((byte)0); + await Assert.That(image.Pixels[2]).IsEqualTo((byte)0); + await Assert.That(image.Pixels[3]).IsEqualTo((byte)255); + } + + [Test] + public async Task ConstructorAcceptsRawPixels() + { + var image = TestImages.CreateGradient(4, 2); + + await Assert.That(image.Width).IsEqualTo(4); + await Assert.That(image.Height).IsEqualTo(2); + await Assert.That(image.Pixels.Length).IsEqualTo(4 * 2 * 4); + } +} diff --git a/source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs b/source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs new file mode 100644 index 0000000..e1487b0 --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs @@ -0,0 +1,19 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class Ktx2EncoderTests +{ + private static readonly byte[] Ktx2Magic = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]; + + [Test] + public async Task EncodeProducesAKtx2File() + { + var image = TestImages.CreateGradient(); + + var ktx2 = Ktx2Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 1); + + await Assert.That(ktx2.Length).IsGreaterThan(Ktx2Magic.Length); + await Assert.That(ktx2.AsSpan(0, Ktx2Magic.Length).SequenceEqual(Ktx2Magic)).IsTrue(); + } +} diff --git a/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs new file mode 100644 index 0000000..5bb3e35 --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs @@ -0,0 +1,87 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class Ktx2TranscoderTests +{ + private static byte[] EncodeGradientWithMips() + { + var image = TestImages.CreateGradient(); + return Ktx2Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 2, + flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp); + } + + [Test] + public async Task OpenReadsTheTextureMetadata() + { + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + await Assert.That(Ktx2Transcoder.GetWidth(ktx2File)).IsEqualTo(16u); + await Assert.That(Ktx2Transcoder.GetHeight(ktx2File)).IsEqualTo(16u); + await Assert.That(Ktx2Transcoder.GetLevels(ktx2File)).IsEqualTo(5u); // 16, 8, 4, 2, 1 + await Assert.That(Ktx2Transcoder.GetFaces(ktx2File)).IsEqualTo(1u); + await Assert.That(Ktx2Transcoder.GetFormat(ktx2File)).IsEqualTo(BasisTexFormat.UastcLdr4x4); + await Assert.That(Ktx2Transcoder.IsSrgb(ktx2File)).IsTrue(); + } + + [Test] + public async Task TranscodeToUncompressedRgba32() + { + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32); + + await Assert.That(level0.Width).IsEqualTo(16u); + await Assert.That(level0.Height).IsEqualTo(16u); + await Assert.That(level0.RowPitch).IsEqualTo(16u * 4); + await Assert.That(level0.Data.Length).IsEqualTo(16 * 16 * 4); + + // The gradient's top-right pixel is fully red; UASTC at the lower effort levels + // is quite lossy, so only check that the pixel is still clearly red + var topRightRed = (int)level0.Data[(16 - 1) * 4 + 0]; + var topRightAlpha = (int)level0.Data[(16 - 1) * 4 + 3]; + await Assert.That(topRightRed).IsGreaterThan(248); + await Assert.That(topRightAlpha).IsEqualTo(255); + + var level4 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32, level: 4); + await Assert.That(level4.Width).IsEqualTo(1u); + await Assert.That(level4.Height).IsEqualTo(1u); + await Assert.That(level4.Data.Length).IsEqualTo(4); + } + + [Test] + public async Task TranscodeAllTranscodesEveryLevelLayerAndFace() + { + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + var images = Ktx2Transcoder.TranscodeAll(ktx2File, TranscodeFormat.Rgba32); + + // 5 mip levels, not an array, not a cubemap + await Assert.That(images.Length).IsEqualTo(5); + for (var level = 0u; level < images.Length; level++) + { + var image = images[level]; + var expectedWidth = 16u >> (int)level; + await Assert.That(image.Level).IsEqualTo(level); + await Assert.That(image.Layer).IsEqualTo(0u); + await Assert.That(image.Face).IsEqualTo(0u); + await Assert.That(image.Width).IsEqualTo(expectedWidth); + await Assert.That(image.Height).IsEqualTo(expectedWidth); + await Assert.That(image.Data.Length).IsEqualTo((int)(expectedWidth * expectedWidth * 4)); + } + } + + [Test] + public async Task TranscodeToBlockCompressedBc7() + { + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Bc7Rgba); + + // 16x16 pixels = 4x4 blocks of 16 bytes each + await Assert.That(level0.Width).IsEqualTo(16u); + await Assert.That(level0.Height).IsEqualTo(16u); + await Assert.That(level0.RowPitch).IsEqualTo(4u * 16); + await Assert.That(level0.Data.Length).IsEqualTo(4 * 4 * 16); + } +} diff --git a/source/CapriKit.Tests/SuperCompressed/NativeMethodsTests.cs b/source/CapriKit.Tests/SuperCompressed/NativeMethodsTests.cs new file mode 100644 index 0000000..93e189e --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/NativeMethodsTests.cs @@ -0,0 +1,40 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class NativeMethodsTests +{ + [Test] + public async Task NativeLibraryVersionMatchesWrapper() + { + await Assert.That(NativeMethods.bu_get_version()).IsEqualTo(NativeMethods.ExpectedLibVersion); + await Assert.That(NativeMethods.bt_get_version()).IsEqualTo(NativeMethods.ExpectedLibVersion); + } + + [Test] + public async Task CompParamsLifecycle() + { + using var compParams = NativeMethods.bu_new_comp_params(); + await Assert.That(compParams.IsInvalid).IsFalse(); + + var pixels = new byte[4 * 4 * 4]; + var imageSet = NativeMethods.bu_comp_params_set_image_rgba32(compParams, 0, pixels, 4, 4, 4 * 4); + await Assert.That(imageSet).IsTrue(); + + // Nothing has been compressed yet + await Assert.That(NativeMethods.bu_comp_params_get_comp_data_size(compParams)).IsEqualTo(0ul); + await Assert.That(NativeMethods.bu_comp_params_clear(compParams)).IsTrue(); + } + + [Test] + public async Task TranscodeFormatHelpers() + { + // 4x4 pixels at 4 bytes per pixel + var rgbaSize = NativeMethods.bt_basis_compute_transcoded_image_size_in_bytes(TranscodeFormat.Rgba32, 4, 4); + await Assert.That(rgbaSize).IsEqualTo(64u); + + await Assert.That(NativeMethods.bt_basis_get_bytes_per_block_or_pixel(TranscodeFormat.Bc7Rgba)).IsEqualTo(16u); + await Assert.That(NativeMethods.bt_basis_transcoder_format_is_uncompressed(TranscodeFormat.Rgba32)).IsTrue(); + await Assert.That(NativeMethods.bt_basis_transcoder_format_has_alpha(TranscodeFormat.Bc7Rgba)).IsTrue(); + } +} diff --git a/source/CapriKit.Tests/SuperCompressed/TestImages.cs b/source/CapriKit.Tests/SuperCompressed/TestImages.cs new file mode 100644 index 0000000..257ef0a --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/TestImages.cs @@ -0,0 +1,50 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal static class TestImages +{ + /// Creates an opaque RGBA32 image with a red/green gradient. + public static Image CreateGradient(int width = 16, int height = 16) + { + var pixels = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var i = (y * width + x) * 4; + pixels[i + 0] = (byte)(x * 255 / (width - 1)); + pixels[i + 1] = (byte)(y * 255 / (height - 1)); + pixels[i + 2] = 128; + pixels[i + 3] = 255; + } + } + + return new Image(pixels, width, height); + } + + /// + /// Creates the contents of a 32-bit uncompressed true-color TGA file + /// with a single red pixel, followed by green, blue and white pixels. + /// + public static byte[] CreateTgaFile() + { + var tga = new byte[18 + 4 * 4]; + tga[2] = 2; // Uncompressed true-color + tga[12] = 2; // Width: 2 + tga[14] = 2; // Height: 2 + tga[16] = 32; // Bits per pixel + tga[17] = 0x28; // 8 alpha bits, top-left origin + + // Pixels are stored as BGRA + ReadOnlySpan pixels = + [ + 0, 0, 255, 255, // Red + 0, 255, 0, 255, // Green + 255, 0, 0, 255, // Blue + 255, 255, 255, 255, // White + ]; + pixels.CopyTo(tga.AsSpan(18)); + return tga; + } +}