From 3e8b4252c1576e5bff9007c189a0d25639a1302c Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 13:58:20 +0200 Subject: [PATCH 01/11] Add basisu submodule and do some prework --- .gitmodules | 3 +++ CLAUDE.md | 3 +++ CapriKit.slnx | 12 ++++++++++++ README.md | 11 +++++++---- external/BASIS_UNIVERSAL.md | 9 +++++++++ external/README.md | 9 +++++++++ external/basis_universal | 1 + research/TextureProcessing.md | 17 +++++++++++++++++ .../CapriKit.SuperCompressed.csproj | 3 +++ 9 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .gitmodules create mode 100644 external/BASIS_UNIVERSAL.md create mode 100644 external/README.md create mode 160000 external/basis_universal create mode 100644 research/TextureProcessing.md create mode 100644 source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2de5736 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/basis_universal"] + path = external/basis_universal + url = https://github.com/BinomialLLC/basis_universal.git diff --git a/CLAUDE.md b/CLAUDE.md index 2905dd4..bc032f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,3 +10,6 @@ Assume you can only use Powershell to execute commands, the `dotnet` tool and la ## Technologies See `Directory.Packages.props` for an overview of NuGet packages used. + +## Misc +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. diff --git a/CapriKit.slnx b/CapriKit.slnx index 6ccee88..bb26b6e 100644 --- a/CapriKit.slnx +++ b/CapriKit.slnx @@ -3,8 +3,17 @@ + + + + + + + + + @@ -50,6 +59,9 @@ + + + diff --git a/README.md b/README.md index 7cb8d55..a3fbd43 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,13 @@ I have been writing code since 2003. I started in Visual Basic 6 and I switched 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 @@ -49,6 +55,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/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..863360f --- /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/TextureProcessing.md b/research/TextureProcessing.md new file mode 100644 index 0000000..ee49088 --- /dev/null +++ b/research/TextureProcessing.md @@ -0,0 +1,17 @@ +I am building a game engine in C#, my next project is building the asset pipeline. For texture handling I want to generate mipmaps, process the files for compression and efficient loading and then unpack and upload them to the GPU (using DirectX11) in an efficient format for the GPU to work with them. + +I've settled on using basis_universal project, see `external/basis_universal` for the basis_universal repository (git submodule) which also contains the documentation and usage examples. basis_universal is a c++ project but I want to access the functionality of this library from C#. + +For a much older version of basis_unversal I created two small projects, one in C++/CLR to integrate with the basis_universal library, and one plain C#/.NET t expose this functionality better to other C# projects. The code for that is in `C:\projects\csharp\SuperCompressed`, but I am not certain if this is the best approach. Do not blindly make another C++/CLR project. But consider all options you have for integrating with C++ code. + +Use the (currently empty) C# project in C:\projects\csharp\CapriKit\source\CapriKit.SuperCompressed\ to your C# code wrapper in. If you need an extra project to deal with the C++ stuff feel free to create it. But ideally this is all we need. + +Considerations +- I want to make it easy to update to newer versions of basis_universal +- I want to minimize custom code and duplication or basis_universal functionality +- I want to access the following functionality of the basis_universal library: + - Loading image data from various image files (jpg, png, etc..) + - Generating MipMaps using any of the supported algorithms + - Encoding and compressing images into the .basis file format + - Loading and transcoding images into a graphics device native format +- I want the C# classes/enums/records feel as a very lightweight wrapper for the C++ code, from C# you should not have to work with raw pointers. Though working with things like a `Span<>` or `ReadOnlySpan<>` is fine. \ No newline at end of file diff --git a/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj new file mode 100644 index 0000000..c369e11 --- /dev/null +++ b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj @@ -0,0 +1,3 @@ + + + From c31fdc43834eb2399c3dc211e12e62f22c6ef352 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 15:42:48 +0200 Subject: [PATCH 02/11] Add AI generated wrapper for basis_universal --- .github/workflows/unit-tests.yml | 6 + CapriKit.slnx | 7 - Directory.Packages.props | 1 + research/SuperCompressedPlan.md | 136 +++++++ research/TextureProcessing.md | 17 +- .../CMakeLists.txt | 47 +++ .../CapriKit.SuperCompressed.Native/README.md | 42 +++ .../CapriKit.SuperCompressed.Native/build.ps1 | 58 +++ .../exports.def | 86 +++++ .../BasisTexFormat.cs | 43 +++ .../CapriKit.SuperCompressed.csproj | 25 +- .../CompParamsHandle.cs | 20 + .../CompressionFlags.cs | 38 ++ .../CapriKit.SuperCompressed/DecodeFlags.cs | 20 + source/CapriKit.SuperCompressed/Encoder.cs | 64 ++++ source/CapriKit.SuperCompressed/Image.cs | 49 +++ .../Ktx2FileHandle.cs | 23 ++ .../Ktx2Transcoder.cs | 120 ++++++ .../CapriKit.SuperCompressed/NativeMethods.cs | 356 ++++++++++++++++++ source/CapriKit.SuperCompressed/README.md | 54 +++ .../TranscodeFormat.cs | 49 +++ .../TranscodedImage.cs | 14 + source/CapriKit.Tests/CapriKit.Tests.csproj | 1 + .../SuperCompressed/EncoderTests.cs | 19 + .../SuperCompressed/ImageTests.cs | 32 ++ .../SuperCompressed/Ktx2TranscoderTests.cs | 65 ++++ .../SuperCompressed/NativeMethodsTests.cs | 40 ++ .../SuperCompressed/TestImages.cs | 50 +++ 28 files changed, 1470 insertions(+), 12 deletions(-) create mode 100644 research/SuperCompressedPlan.md create mode 100644 source/CapriKit.SuperCompressed.Native/CMakeLists.txt create mode 100644 source/CapriKit.SuperCompressed.Native/README.md create mode 100644 source/CapriKit.SuperCompressed.Native/build.ps1 create mode 100644 source/CapriKit.SuperCompressed.Native/exports.def create mode 100644 source/CapriKit.SuperCompressed/BasisTexFormat.cs create mode 100644 source/CapriKit.SuperCompressed/CompParamsHandle.cs create mode 100644 source/CapriKit.SuperCompressed/CompressionFlags.cs create mode 100644 source/CapriKit.SuperCompressed/DecodeFlags.cs create mode 100644 source/CapriKit.SuperCompressed/Encoder.cs create mode 100644 source/CapriKit.SuperCompressed/Image.cs create mode 100644 source/CapriKit.SuperCompressed/Ktx2FileHandle.cs create mode 100644 source/CapriKit.SuperCompressed/Ktx2Transcoder.cs create mode 100644 source/CapriKit.SuperCompressed/NativeMethods.cs create mode 100644 source/CapriKit.SuperCompressed/README.md create mode 100644 source/CapriKit.SuperCompressed/TranscodeFormat.cs create mode 100644 source/CapriKit.SuperCompressed/TranscodedImage.cs create mode 100644 source/CapriKit.Tests/SuperCompressed/EncoderTests.cs create mode 100644 source/CapriKit.Tests/SuperCompressed/ImageTests.cs create mode 100644 source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs create mode 100644 source/CapriKit.Tests/SuperCompressed/NativeMethodsTests.cs create mode 100644 source/CapriKit.Tests/SuperCompressed/TestImages.cs diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e613d20..b021ffe 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,12 +20,18 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4.2.2 + with: + submodules: recursive - name: Setup .NET uses: actions/setup-dotnet@v4.3.1 with: global-json-file: global.json + - name: Build basis_universal native library + shell: pwsh + run: .\source\CapriKit.SuperCompressed.Native\build.ps1 + - name: Run unit tests shell: pwsh run: dotnet test diff --git a/CapriKit.slnx b/CapriKit.slnx index bb26b6e..9b45d27 100644 --- a/CapriKit.slnx +++ b/CapriKit.slnx @@ -7,13 +7,6 @@ - - - - - - - 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/research/SuperCompressedPlan.md b/research/SuperCompressedPlan.md new file mode 100644 index 0000000..8a45453 --- /dev/null +++ b/research/SuperCompressedPlan.md @@ -0,0 +1,136 @@ +# CapriKit.SuperCompressed — Plan & Research Findings + +_Follow-up to `TextureProcessing.md`. Investigated 2026-07-09. Goal: wrap basis_universal +(`external/basis_universal` submodule) in C# for the asset pipeline: load images, generate +mipmaps, encode to a supercompressed container, and transcode to GPU-native formats for DirectX11._ + +## Key findings + +### 1. basis_universal now ships an official plain-C API +The current submodule contains a C API maintained by Binomial themselves, which removes the +need for any custom C++ glue: + +- `encoder/basisu_wasm_api.h` — encoder API, `bu_*` functions. Feed raw RGBA32 (or float RGBA) + pixels via `bu_comp_params_set_image_rgba32`, then `bu_compress_texture` with a + `basis_tex_format` (ETC1S/UASTC/XUASTC/…), quality 1–100, effort 0–10, and flags + (`BU_COMP_FLAGS_*`: KTX2 output, sRGB, threading, mipmap generation with clamp/wrap). +- `encoder/basisu_wasm_transcoder_api.h` — transcoder API, `bt_*` functions. Opens `.ktx2` + blobs (`bt_ktx2_open`), queries width/height/levels/layers/faces/format/alpha, computes + output sizes (`bt_basis_compute_transcoded_image_size_in_bytes`), and transcodes any + level/layer/face to GPU formats (`bt_ktx2_transcode_image_level`): BC1/BC3/BC4/BC5/BC7, + BC6H, ASTC, ETC, RGBA32, … +- Constants live in `encoder/basisu_wasm_api_common.h` (`BTF_*` source formats, `TF_*` + transcode targets, `BU_COMP_FLAGS_*`, `DECODE_FLAGS_*`, quality/effort ranges). + +Despite the "wasm" file names the API compiles natively (`example_capi.exe` is built from it; +the Python bindings use the same files as a native shared library). Versioned via +`bu_get_version()` (`BASISU_LIB_VERSION`, currently 250 in the checked-out submodule). +Note: `bu_get_version()` also printf's a "Hello from basisu_wasm_api.cpp" line to stdout. + +**On native builds the API's `uint64` "offsets" are just raw pointers** (`wasm_ptr` casts them +back). So from C# we can pin managed memory and pass the pointer as `ulong` — no `bu_alloc`/ +`bt_free` round-trips, no double copies. The encoder copies pixels into its own storage during +`set_image`, so pinning only for the call duration is safe. + +### 2. The old SuperCompressed project was not C++/CLI +`C:\projects\csharp\SuperCompressed` is a plain native DLL (`SuperCompressed.Native`, +`extern "C"` exports) consumed via `DllImport` (`NativeMethods.cs`). The pain point was the +~500 lines of hand-written C++ glue duplicating basisu logic — which the upstream C API now +makes obsolete. Its C# API shape (Image/Encoder/Transcoder, spans in, no raw pointers out) +is still a good reference. It used StbImageSharp for image loading and ZstdSharp to compress +the `.basis` output (unnecessary with KTX2: `BU_COMP_FLAGS_KTX2_UASTC_ZSTD` supercompresses +inside the container). + +### 3. Integration options considered + +| Option | Verdict | +|---|---| +| P/Invoke against upstream's C API | **Chosen** — near-zero custom native code, upstream-maintained surface | +| C++/CLI | No — runtime-coupled, solves a problem we no longer have | +| Custom C shim + P/Invoke (old approach) | Obsolete; keep in back pocket for API gaps | +| Host upstream's `.wasm` via wasmtime-dotnet | Zero native build, but slow encoding; not primary | +| ClangSharp/CppSharp codegen | Overkill for ~90 simple functions | +| Community NuGet bindings | Stale versions defeat the "easy updates" goal | + +### 4. Native build: CMake, reusing upstream's build definitions +Our own `CMakeLists.txt` can `add_subdirectory(external/basis_universal EXCLUDE_FROM_ALL)` +and link upstream's `basisu_encoder` static-library target — upstream maintains the source +list and compiler flags (MSVC defaults are already right: SSE on, Zstd on, OpenCL off). +`EXCLUDE_FROM_ALL` skips their tools/examples. The whole native build we own is ~10 lines: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(caprikit_basisu CXX) + +set(BASISU_EXAMPLES OFF) +add_subdirectory(/external/basis_universal basisu EXCLUDE_FROM_ALL) + +add_library(caprikit_basisu SHARED + /external/basis_universal/encoder/basisu_wasm_api.cpp + /external/basis_universal/encoder/basisu_wasm_transcoder_api.cpp + exports.def) +target_link_libraries(caprikit_basisu PRIVATE basisu_encoder) +``` + +This mirrors how upstream builds its own `example_capi` target. Details: + +- `exports.def` is required: `BU_WASM_EXPORT` expands to bare `extern "C"` on MSVC (no + `__declspec(dllexport)`). The `.def` file is just the ~90 `bu_*`/`bt_*` names copied from + the two headers; regenerate when the headers change. +- A **vcxproj is not an option**: `dotnet build` cannot build C++ projects (MSB4278 — + `Microsoft.Cpp.Default.props` only exists in Visual Studio's MSBuild). Verified: the + `basisu.vcxproj` entry currently in `CapriKit.slnx` breaks solution-level `dotnet build` + and should be removed. +- A thin `build.ps1` wraps the two cmake commands (configure + `--build --config Release`) + and copies the DLL. Rebuild is only needed when the submodule updates. GitHub Actions + windows runners ship CMake + MSVC, so CI can build it for the unit tests. + +### 5. API gaps in the upstream C API (accepted for v1) +- **No image file loading** — takes raw pixels only. Decision: use **StbImageSharp** + (managed; jpg/png/bmp/tga/psd/gif), same as the old project. +- **No mipmap filter selection** — only on/off + clamp/wrap flags; upstream's default + filter (kaiser) is used. Decision: accept the default; per-level custom mips aren't + expressible in the C API either. Revisit via a tiny shim or upstream PR if ever needed. +- **KTX2 only** — the C transcoder API has no `.basis` reader. Decision: standardize on + `.ktx2` (upstream's recommended container anyway). + +## Decisions (agreed 2026-07-09) + +- P/Invoke (`[LibraryImport]`) against upstream's `bu_*`/`bt_*` C API; no custom native code + beyond a CMakeLists + `.def` file. +- **Windows/x64 only** — the engine is DirectX11-based; no cross-platform requirement. +- **KTX2 only**, StbImageSharp for image loading, default kaiser mip filter. +- Native DLL name: `caprikit_basisu.dll`. + +## Implementation plan + +1. **`source/CapriKit.SuperCompressed.Native/`** — `CMakeLists.txt` (as above), `exports.def`, + `build.ps1`. Produces `caprikit_basisu.dll` (x64 Release). Never modifies the submodule. +2. **Interop layer** (internal, in `CapriKit.SuperCompressed`) — `NativeMethods.cs` with + `[LibraryImport]` mirroring the two headers 1:1; `ulong` handles wrapped in `SafeHandle`s + (`CompParamsHandle`, `Ktx2Handle`); enums mirroring `BTF_*`/`TF_*`/flags constants; + one-time `bu_init`/`bt_init` + `bu_get_version()` check. +3. **Public API** (span-based, no pointers): + - `Image` — RGBA32 pixels + dimensions; `Image.Load(stream/bytes)` via StbImageSharp. + - `Encoder.Encode(Image, BasisTexFormat, Quality, Effort, options) → byte[]` (`.ktx2` blob). + - `Ktx2Transcoder : IDisposable` — `Open(ReadOnlySpan)`; `Width/Height/Levels/Layers/ + Faces/Format/HasAlpha`; `Transcode(level, layer, face, TranscodeFormat) → TranscodedImage` + (byte[], dimensions, pitch). + - `TranscodeFormat → DXGI_FORMAT` mapping belongs in `CapriKit.DirectX11`, not here. +4. **Tests** (TUnit, `CapriKit.Tests`, happy path) — encode a small generated gradient with + mips → open → assert level count/dimensions → transcode to BC7 and RGBA32 → assert buffer + sizes match `bt_basis_compute_transcoded_image_size_in_bytes`. +5. **Packaging/docs** — DLL as `runtimes/win-x64/native` in the NuGet; project README with the + submodule-update procedure (bump submodule → regen `.def` if headers changed → rebuild DLL). + +First milestone: build the DLL and get a `bu_get_version` smoke test passing from C#. + +## Environment verification (2026-07-09) + +- `dotnet build` — all 13 C# projects build; solution-level build **fails** only on the + `external/basis_universal/basisu.vcxproj` entry in `CapriKit.slnx` (remove it). +- CMake — **not on PATH**; use `C:\Program Files\CMake\bin\cmake.exe`. Generator + "Visual Studio 18 2026", MSVC 19.51. +- basis_universal — configures and builds cleanly (`basisu_encoder.lib`, Release x64). + Note: MSVC warns (MSB8029) against build dirs under Temp; use a repo-local build dir. +- `dotnet test source\CapriKit.Tests\CapriKit.Tests.csproj` — 189/189 pass. diff --git a/research/TextureProcessing.md b/research/TextureProcessing.md index ee49088..f2f7231 100644 --- a/research/TextureProcessing.md +++ b/research/TextureProcessing.md @@ -4,7 +4,7 @@ I've settled on using basis_universal project, see `external/basis_universal` fo For a much older version of basis_unversal I created two small projects, one in C++/CLR to integrate with the basis_universal library, and one plain C#/.NET t expose this functionality better to other C# projects. The code for that is in `C:\projects\csharp\SuperCompressed`, but I am not certain if this is the best approach. Do not blindly make another C++/CLR project. But consider all options you have for integrating with C++ code. -Use the (currently empty) C# project in C:\projects\csharp\CapriKit\source\CapriKit.SuperCompressed\ to your C# code wrapper in. If you need an extra project to deal with the C++ stuff feel free to create it. But ideally this is all we need. +Use the (currently empty) C# project in C:\projects\csharp\CapriKit\source\CapriKit.SuperCompressed\ to place your C# code wrapper in. If you need an extra project to deal with the C++ stuff feel free to create it. But ideally this is all we need. Considerations - I want to make it easy to update to newer versions of basis_universal @@ -14,4 +14,17 @@ Considerations - Generating MipMaps using any of the supported algorithms - Encoding and compressing images into the .basis file format - Loading and transcoding images into a graphics device native format -- I want the C# classes/enums/records feel as a very lightweight wrapper for the C++ code, from C# you should not have to work with raw pointers. Though working with things like a `Span<>` or `ReadOnlySpan<>` is fine. \ No newline at end of file +- I want the C# classes/enums/records feel as a very lightweight wrapper for the C++ code, from C# you should not have to work with raw pointers. Though working with things like a `Span<>` or `ReadOnlySpan<>` is fine. + + +## Follow-up information after Claude read the intial instructions: + +Note: I don't need cross-platform capabilities, I'm already tying myself to windows due to the use of DirectX11. +Questions: +- Is it easier to cmake that correctly builds basis_universal and the dlls we need than creating a build.ps1 file? + + +Answers: +- Image file loading: Yes let's use StbImageSharp again +- Mipmap filter selection: Yes, let's keep it simple for now and do not allow algorithm selection and use the default kaiser filter +- Yes, going for ktx2 only is fine. \ No newline at end of file diff --git a/source/CapriKit.SuperCompressed.Native/CMakeLists.txt b/source/CapriKit.SuperCompressed.Native/CMakeLists.txt new file mode 100644 index 0000000..d3dea89 --- /dev/null +++ b/source/CapriKit.SuperCompressed.Native/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.Native/README.md b/source/CapriKit.SuperCompressed.Native/README.md new file mode 100644 index 0000000..984d80f --- /dev/null +++ b/source/CapriKit.SuperCompressed.Native/README.md @@ -0,0 +1,42 @@ +# CapriKit.SuperCompressed.Native + +Builds `caprikit_basisu.dll`, the native half of `CapriKit.SuperCompressed`. It contains +[basis_universal](https://github.com/BinomialLLC/basis_universal)'s encoder and transcoder, +exposed through upstream's own plain-C API (`bu_*` encoder functions, `bt_*` transcoder +functions) which `CapriKit.SuperCompressed` consumes via P/Invoke. + +This is **not** an MSBuild project and is not part of `CapriKit.slnx` — the `dotnet` CLI +cannot build C++. It is built with CMake, and only needs rebuilding when the +`external/basis_universal` submodule is updated. + +## Building + +Requires CMake and the Visual Studio C++ toolchain (x64). + +```pwsh +.\build.ps1 # Release (default) +.\build.ps1 -Configuration Debug +``` + +Output: `\.build\bin\CapriKit.SuperCompressed.Native\caprikit_basisu.dll` +(intermediates in `\.build\native\caprikit_basisu`). + +## Updating basis_universal + +1. Update the submodule to the desired version. +2. Rerun `.\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 + `CapriKit.SuperCompressed`. + +## 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.Native/build.ps1 b/source/CapriKit.SuperCompressed.Native/build.ps1 new file mode 100644 index 0000000..df858be --- /dev/null +++ b/source/CapriKit.SuperCompressed.Native/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.SuperCompressed.Native\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.SuperCompressed.Native' + +# 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.Native/exports.def b/source/CapriKit.SuperCompressed.Native/exports.def new file mode 100644 index 0000000..d61f25b --- /dev/null +++ b/source/CapriKit.SuperCompressed.Native/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.SuperCompressed/BasisTexFormat.cs b/source/CapriKit.SuperCompressed/BasisTexFormat.cs new file mode 100644 index 0000000..2b8df9a --- /dev/null +++ b/source/CapriKit.SuperCompressed/BasisTexFormat.cs @@ -0,0 +1,43 @@ +namespace CapriKit.SuperCompressed; + +/// +/// The texture formats a .ktx2 file can store, mirrors the BTF_* constants in +/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::basis_tex_format). +/// +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 index c369e11..078aa5c 100644 --- a/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj +++ b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj @@ -1,3 +1,22 @@ - - - + + + + $(MSBuildThisFileDirectory)..\..\.build\bin\CapriKit.SuperCompressed.Native\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..53b1614 --- /dev/null +++ b/source/CapriKit.SuperCompressed/CompressionFlags.cs @@ -0,0 +1,38 @@ +namespace CapriKit.SuperCompressed; + +/// +/// Flags for encoding, mirrors the BU_COMP_FLAGS_* constants in +/// external/basis_universal/encoder/basisu_wasm_api_common.h. +/// The native parameter is 64 bits wide, so this enum is backed by ulong. +/// +/// +/// 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..d52b781 --- /dev/null +++ b/source/CapriKit.SuperCompressed/DecodeFlags.cs @@ -0,0 +1,20 @@ +namespace CapriKit.SuperCompressed; + +/// +/// Flags for transcoding, mirrors the DECODE_FLAGS_* constants in +/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::basisu_decode_flags). +/// +[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/Encoder.cs b/source/CapriKit.SuperCompressed/Encoder.cs new file mode 100644 index 0000000..b76a85d --- /dev/null +++ b/source/CapriKit.SuperCompressed/Encoder.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// Encodes images into supercompressed KTX2 textures, which +/// can later transcode to GPU-native formats. +/// +public static class Encoder +{ + 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 defaults match basis_universal's own example code. + /// + /// 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. + /// is always added: this wrapper is KTX2-only because + /// the basis_universal C transcoding API cannot read .basis files. + /// + /// The contents of a .ktx2 file. + public static byte[] Encode(Image image, BasisTexFormat format, int quality = 85, int effort = 2, CompressionFlags flags = CompressionFlags.None) + { + ArgumentNullException.ThrowIfNull(image); + 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/Image.cs b/source/CapriKit.SuperCompressed/Image.cs new file mode 100644 index 0000000..8de4080 --- /dev/null +++ b/source/CapriKit.SuperCompressed/Image.cs @@ -0,0 +1,49 @@ +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); + } + + public Image(byte[] rgba32Pixels, int width, int height) + { + ArgumentNullException.ThrowIfNull(rgba32Pixels); + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + if (rgba32Pixels.Length != width * height * 4) + { + throw new ArgumentException($"Expected {width}x{height}x4 = {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/Ktx2FileHandle.cs b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs new file mode 100644 index 0000000..7f7403e --- /dev/null +++ b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace CapriKit.SuperCompressed; + +/// +/// Owns a native KTX2 file object, created by bt_ktx2_open and freed by +/// bt_ktx2_close. The file data passed to bt_ktx2_open is referenced, +/// not copied, by the native side: the owner of this handle must keep that memory +/// valid until the handle is disposed. +/// +internal sealed class Ktx2FileHandle : SafeHandle +{ + public Ktx2FileHandle() + : base(IntPtr.Zero, ownsHandle: true) { } + + 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..1fa41ee --- /dev/null +++ b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs @@ -0,0 +1,120 @@ +namespace CapriKit.SuperCompressed; + +/// +/// Reads a KTX2 texture (as produced by ) and transcodes its +/// mip levels to GPU-native formats. +/// +public sealed class Ktx2Transcoder : IDisposable +{ + private readonly Ktx2FileHandle handle; + + // The native KTX2 reader references this buffer for the lifetime of the handle, + // so it is allocated on the pinned object heap, where it never moves. + private readonly byte[] fileData; + + private Ktx2Transcoder(Ktx2FileHandle handle, byte[] fileData) + { + this.handle = handle; + this.fileData = fileData; + } + + /// Parses the contents of a .ktx2 file and prepares it for transcoding. + /// The data is not a supported KTX2 texture. + public static unsafe Ktx2Transcoder Open(ReadOnlySpan ktx2Data) + { + var pinnedData = GC.AllocateUninitializedArray(ktx2Data.Length, pinned: true); + ktx2Data.CopyTo(pinnedData); + + Ktx2FileHandle handle; + fixed (byte* pData = pinnedData) + { + handle = NativeMethods.bt_ktx2_open((IntPtr)pData, (uint)pinnedData.Length); + } + + if (handle.IsInvalid) + { + throw new InvalidDataException("Not a valid KTX2 file"); + } + + if (!NativeMethods.bt_ktx2_start_transcoding(handle)) + { + handle.Dispose(); + throw new InvalidDataException("Failed to prepare the KTX2 file for transcoding"); + } + + return new Ktx2Transcoder(handle, pinnedData); + } + + /// Width in pixels of the largest mip level. + public int Width => (int)NativeMethods.bt_ktx2_get_width(handle); + + /// Height in pixels of the largest mip level. + public int Height => (int)NativeMethods.bt_ktx2_get_height(handle); + + /// The number of mip levels. + public int Levels => (int)NativeMethods.bt_ktx2_get_levels(handle); + + /// The number of array layers; 0 for textures that are not arrays. + public int Layers => (int)NativeMethods.bt_ktx2_get_layers(handle); + + /// The number of faces: 6 for cubemaps, 1 otherwise. + public int Faces => (int)NativeMethods.bt_ktx2_get_faces(handle); + + /// The intermediate texture format stored inside the KTX2 file. + public BasisTexFormat Format => NativeMethods.bt_ktx2_get_basis_tex_format(handle); + + public bool HasAlpha => NativeMethods.bt_ktx2_has_alpha(handle); + + public bool IsSrgb => NativeMethods.bt_ktx2_is_srgb(handle); + + /// Transcodes one mip level to the given GPU texture format. + public TranscodedImage Transcode(TranscodeFormat format, int level = 0, int layer = 0, int face = 0, DecodeFlags flags = DecodeFlags.None) + { + var width = NativeMethods.bt_ktx2_get_level_orig_width(handle, (uint)level, (uint)layer, (uint)face); + var height = NativeMethods.bt_ktx2_get_level_orig_height(handle, (uint)level, (uint)layer, (uint)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( + handle, + (uint)level, (uint)layer, (uint)face, + data, + sizeInBytes / bytesPerUnit, + format, + flags, + outputRowPitchInBlocksOrPixels: 0, + outputRowsInPixels: 0, + channel0: -1, channel1: -1, + stateHandle: 0); + + if (!succeeded) + { + throw new InvalidOperationException($"Failed to transcode level {level}, layer {layer}, face {face} from {Format} to {format}"); + } + + return new TranscodedImage(format, (int)width, (int)height, (int)rowPitch, data); + } + + public void Dispose() + { + handle.Dispose(); + } +} diff --git a/source/CapriKit.SuperCompressed/NativeMethods.cs b/source/CapriKit.SuperCompressed/NativeMethods.cs new file mode 100644 index 0000000..129c5be --- /dev/null +++ b/source/CapriKit.SuperCompressed/NativeMethods.cs @@ -0,0 +1,356 @@ +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 disposed. + /// + [LibraryImport(DllName)] + public static partial Ktx2FileHandle 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..450c8de --- /dev/null +++ b/source/CapriKit.SuperCompressed/README.md @@ -0,0 +1,54 @@ +# CapriKit.SuperCompressed + +Supercompressed textures for game engines, 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 +var image = Image.Load(File.OpenRead("albedo.png")); + +// A color texture: sRGB, with a full mipmap chain, using all cores +var ktx2 = Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, + flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp | CompressionFlags.Threaded); + +File.WriteAllBytes("albedo.ktx2", ktx2); +``` + +Transcoding, done at load time: + +```csharp +using var transcoder = Ktx2Transcoder.Open(File.ReadAllBytes("albedo.ktx2")); + +for (var level = 0; level < transcoder.Levels; level++) +{ + var mip = transcoder.Transcode(TranscodeFormat.Bc7Rgba, level); + // 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 from source + +The native DLL is not built by `dotnet build`. Run `..\CapriKit.SuperCompressed.Native\build.ps1` +once (requires CMake and the Visual Studio C++ toolchain), and again after updating the +basis_universal submodule. See `CapriKit.SuperCompressed.Native\README.md` for details. diff --git a/source/CapriKit.SuperCompressed/TranscodeFormat.cs b/source/CapriKit.SuperCompressed/TranscodeFormat.cs new file mode 100644 index 0000000..fc33cb0 --- /dev/null +++ b/source/CapriKit.SuperCompressed/TranscodeFormat.cs @@ -0,0 +1,49 @@ +namespace CapriKit.SuperCompressed; + +/// +/// The GPU texture formats a .ktx2 file can be transcoded to, mirrors the TF_* constants in +/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::transcoder_texture_format). +/// +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..8f07fff --- /dev/null +++ b/source/CapriKit.SuperCompressed/TranscodedImage.cs @@ -0,0 +1,14 @@ +namespace CapriKit.SuperCompressed; + +/// +/// One transcoded mip level: texel data ready to upload to the GPU. +/// +/// The GPU texture format of . +/// 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, int Width, int Height, int RowPitch, byte[] Data); 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/EncoderTests.cs b/source/CapriKit.Tests/SuperCompressed/EncoderTests.cs new file mode 100644 index 0000000..760733b --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/EncoderTests.cs @@ -0,0 +1,19 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class EncoderTests +{ + 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 = Encoder.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/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/Ktx2TranscoderTests.cs b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs new file mode 100644 index 0000000..9dd6502 --- /dev/null +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs @@ -0,0 +1,65 @@ +using CapriKit.SuperCompressed; + +namespace CapriKit.Tests.SuperCompressed; + +internal class Ktx2TranscoderTests +{ + private static byte[] EncodeGradientWithMips() + { + var image = TestImages.CreateGradient(); + return Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 2, + flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp); + } + + [Test] + public async Task OpenReadsTheTextureMetadata() + { + using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + await Assert.That(transcoder.Width).IsEqualTo(16); + await Assert.That(transcoder.Height).IsEqualTo(16); + await Assert.That(transcoder.Levels).IsEqualTo(5); // 16, 8, 4, 2, 1 + await Assert.That(transcoder.Faces).IsEqualTo(1); + await Assert.That(transcoder.Format).IsEqualTo(BasisTexFormat.UastcLdr4x4); + await Assert.That(transcoder.IsSrgb).IsTrue(); + } + + [Test] + public async Task TranscodeToUncompressedRgba32() + { + using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + var level0 = transcoder.Transcode(TranscodeFormat.Rgba32); + + await Assert.That(level0.Width).IsEqualTo(16); + await Assert.That(level0.Height).IsEqualTo(16); + await Assert.That(level0.RowPitch).IsEqualTo(16 * 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 = transcoder.Transcode(TranscodeFormat.Rgba32, level: 4); + await Assert.That(level4.Width).IsEqualTo(1); + await Assert.That(level4.Height).IsEqualTo(1); + await Assert.That(level4.Data.Length).IsEqualTo(4); + } + + [Test] + public async Task TranscodeToBlockCompressedBc7() + { + using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + + var level0 = transcoder.Transcode(TranscodeFormat.Bc7Rgba); + + // 16x16 pixels = 4x4 blocks of 16 bytes each + await Assert.That(level0.Width).IsEqualTo(16); + await Assert.That(level0.Height).IsEqualTo(16); + await Assert.That(level0.RowPitch).IsEqualTo(4 * 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; + } +} From c1c913c89fef58c6083f8a798f4174769fd39430 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 17:21:53 +0200 Subject: [PATCH 03/11] Make transcoder safer for use --- research/SuperCompressedPlan.md | 7 +- source/CapriKit.SuperCompressed/Image.cs | 8 +- .../Ktx2FileHandle.cs | 22 ++-- .../Ktx2Transcoder.cs | 101 +++++++++++------- .../CapriKit.SuperCompressed/NativeMethods.cs | 7 +- source/CapriKit.SuperCompressed/README.md | 9 +- .../SuperCompressed/Ktx2TranscoderTests.cs | 24 ++--- 7 files changed, 109 insertions(+), 69 deletions(-) diff --git a/research/SuperCompressedPlan.md b/research/SuperCompressedPlan.md index 8a45453..589726e 100644 --- a/research/SuperCompressedPlan.md +++ b/research/SuperCompressedPlan.md @@ -113,8 +113,11 @@ This mirrors how upstream builds its own `example_capi` target. Details: 3. **Public API** (span-based, no pointers): - `Image` — RGBA32 pixels + dimensions; `Image.Load(stream/bytes)` via StbImageSharp. - `Encoder.Encode(Image, BasisTexFormat, Quality, Effort, options) → byte[]` (`.ktx2` blob). - - `Ktx2Transcoder : IDisposable` — `Open(ReadOnlySpan)`; `Width/Height/Levels/Layers/ - Faces/Format/HasAlpha`; `Transcode(level, layer, face, TranscodeFormat) → TranscodedImage` + - `Ktx2Transcoder` (static, like `Encoder`) — `Open(ReadOnlySpan) → Ktx2FileHandle` + (a public SafeHandle with internal members that owns both the native reader and the + pinned file data, so the interop marshaller keeps the buffer alive during every native + call); `GetWidth/GetHeight/GetLevels/GetLayers/GetFaces/GetFormat/HasAlpha/IsSrgb(handle)`; + `Transcode(handle, TranscodeFormat, level, layer, face) → TranscodedImage` (byte[], dimensions, pitch). - `TranscodeFormat → DXGI_FORMAT` mapping belongs in `CapriKit.DirectX11`, not here. 4. **Tests** (TUnit, `CapriKit.Tests`, happy path) — encode a small generated gradient with diff --git a/source/CapriKit.SuperCompressed/Image.cs b/source/CapriKit.SuperCompressed/Image.cs index 8de4080..c5a93c8 100644 --- a/source/CapriKit.SuperCompressed/Image.cs +++ b/source/CapriKit.SuperCompressed/Image.cs @@ -26,14 +26,18 @@ public static Image Load(byte[] fileData) 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 != width * height * 4) + if (rgba32Pixels.Length != (long)width * height * 4) { - throw new ArgumentException($"Expected {width}x{height}x4 = {width * height * 4} bytes of RGBA32 pixel data, got {rgba32Pixels.Length} bytes", nameof(rgba32Pixels)); + 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; diff --git a/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs index 7f7403e..b0cf130 100644 --- a/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs +++ b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs @@ -3,15 +3,23 @@ namespace CapriKit.SuperCompressed; /// -/// Owns a native KTX2 file object, created by bt_ktx2_open and freed by -/// bt_ktx2_close. The file data passed to bt_ktx2_open is referenced, -/// not copied, by the native side: the owner of this handle must keep that memory -/// valid until the handle is disposed. +/// 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. /// -internal sealed class Ktx2FileHandle : SafeHandle +public sealed class Ktx2FileHandle : SafeHandle { - public Ktx2FileHandle() - : base(IntPtr.Zero, ownsHandle: true) { } + 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. + FileDate = fileData; + } + + internal byte[] FileDate { get; } public override bool IsInvalid => handle == IntPtr.Zero; diff --git a/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs index 1fa41ee..700eea2 100644 --- a/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs +++ b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs @@ -1,77 +1,103 @@ +using System.Runtime.InteropServices; + namespace CapriKit.SuperCompressed; /// -/// Reads a KTX2 texture (as produced by ) and transcodes its -/// mip levels to GPU-native formats. +/// 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 sealed class Ktx2Transcoder : IDisposable +/// +/// Do not call concurrently with the same . +/// (The native transcoder would need a per-thread transcode state for that, which this +/// wrapper does not use.) +/// +public static class Ktx2Transcoder { - private readonly Ktx2FileHandle handle; - - // The native KTX2 reader references this buffer for the lifetime of the handle, - // so it is allocated on the pinned object heap, where it never moves. - private readonly byte[] fileData; - - private Ktx2Transcoder(Ktx2FileHandle handle, byte[] fileData) - { - this.handle = handle; - this.fileData = fileData; - } - /// Parses the contents of a .ktx2 file and prepares it for transcoding. /// The data is not a supported KTX2 texture. - public static unsafe Ktx2Transcoder Open(ReadOnlySpan ktx2Data) + 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); - Ktx2FileHandle handle; + IntPtr rawHandle; fixed (byte* pData = pinnedData) { - handle = NativeMethods.bt_ktx2_open((IntPtr)pData, (uint)pinnedData.Length); + rawHandle = NativeMethods.bt_ktx2_open((IntPtr)pData, (uint)pinnedData.Length); } - if (handle.IsInvalid) + if (rawHandle == IntPtr.Zero) { throw new InvalidDataException("Not a valid KTX2 file"); } - if (!NativeMethods.bt_ktx2_start_transcoding(handle)) + var ktx2File = new Ktx2FileHandle(pinnedData); + Marshal.InitHandle(ktx2File, rawHandle); + + if (!NativeMethods.bt_ktx2_start_transcoding(ktx2File)) { - handle.Dispose(); + ktx2File.Dispose(); throw new InvalidDataException("Failed to prepare the KTX2 file for transcoding"); } - return new Ktx2Transcoder(handle, pinnedData); + return ktx2File; } /// Width in pixels of the largest mip level. - public int Width => (int)NativeMethods.bt_ktx2_get_width(handle); + public static int GetWidth(Ktx2FileHandle ktx2File) + { + return (int)NativeMethods.bt_ktx2_get_width(ktx2File); + } /// Height in pixels of the largest mip level. - public int Height => (int)NativeMethods.bt_ktx2_get_height(handle); + public static int GetHeight(Ktx2FileHandle ktx2File) + { + return (int)NativeMethods.bt_ktx2_get_height(ktx2File); + } /// The number of mip levels. - public int Levels => (int)NativeMethods.bt_ktx2_get_levels(handle); + public static int GetLevels(Ktx2FileHandle ktx2File) + { + return (int)NativeMethods.bt_ktx2_get_levels(ktx2File); + } /// The number of array layers; 0 for textures that are not arrays. - public int Layers => (int)NativeMethods.bt_ktx2_get_layers(handle); + public static int GetLayers(Ktx2FileHandle ktx2File) + { + return (int)NativeMethods.bt_ktx2_get_layers(ktx2File); + } /// The number of faces: 6 for cubemaps, 1 otherwise. - public int Faces => (int)NativeMethods.bt_ktx2_get_faces(handle); + public static int GetFaces(Ktx2FileHandle ktx2File) + { + return (int)NativeMethods.bt_ktx2_get_faces(ktx2File); + } /// The intermediate texture format stored inside the KTX2 file. - public BasisTexFormat Format => NativeMethods.bt_ktx2_get_basis_tex_format(handle); + public static BasisTexFormat GetFormat(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_get_basis_tex_format(ktx2File); + } - public bool HasAlpha => NativeMethods.bt_ktx2_has_alpha(handle); + public static bool HasAlpha(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_has_alpha(ktx2File); + } - public bool IsSrgb => NativeMethods.bt_ktx2_is_srgb(handle); + public static bool IsSrgb(Ktx2FileHandle ktx2File) + { + return NativeMethods.bt_ktx2_is_srgb(ktx2File); + } /// Transcodes one mip level to the given GPU texture format. - public TranscodedImage Transcode(TranscodeFormat format, int level = 0, int layer = 0, int face = 0, DecodeFlags flags = DecodeFlags.None) + public static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat format, int level = 0, int layer = 0, int face = 0, DecodeFlags flags = DecodeFlags.None) { - var width = NativeMethods.bt_ktx2_get_level_orig_width(handle, (uint)level, (uint)layer, (uint)face); - var height = NativeMethods.bt_ktx2_get_level_orig_height(handle, (uint)level, (uint)layer, (uint)face); + var width = NativeMethods.bt_ktx2_get_level_orig_width(ktx2File, (uint)level, (uint)layer, (uint)face); + var height = NativeMethods.bt_ktx2_get_level_orig_height(ktx2File, (uint)level, (uint)layer, (uint)face); if (width == 0 || height == 0) { throw new ArgumentOutOfRangeException(nameof(level), $"The KTX2 file does not contain level {level}, layer {layer}, face {face}"); @@ -94,7 +120,7 @@ public TranscodedImage Transcode(TranscodeFormat format, int level = 0, int laye 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( - handle, + ktx2File, (uint)level, (uint)layer, (uint)face, data, sizeInBytes / bytesPerUnit, @@ -107,14 +133,9 @@ public TranscodedImage Transcode(TranscodeFormat format, int level = 0, int laye if (!succeeded) { - throw new InvalidOperationException($"Failed to transcode level {level}, layer {layer}, face {face} from {Format} to {format}"); + throw new InvalidOperationException($"Failed to transcode level {level}, layer {layer}, face {face} from {GetFormat(ktx2File)} to {format}"); } return new TranscodedImage(format, (int)width, (int)height, (int)rowPitch, data); } - - public void Dispose() - { - handle.Dispose(); - } } diff --git a/source/CapriKit.SuperCompressed/NativeMethods.cs b/source/CapriKit.SuperCompressed/NativeMethods.cs index 129c5be..e8c79f9 100644 --- a/source/CapriKit.SuperCompressed/NativeMethods.cs +++ b/source/CapriKit.SuperCompressed/NativeMethods.cs @@ -185,10 +185,13 @@ public static partial bool bu_compress_texture( /// /// The native side keeps referencing after this call: the memory - /// must stay valid (and, if managed, pinned) until the returned handle is disposed. + /// 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 Ktx2FileHandle bt_ktx2_open(IntPtr data, uint dataLength); + public static partial IntPtr bt_ktx2_open(IntPtr data, uint dataLength); [LibraryImport(DllName)] public static partial void bt_ktx2_close(IntPtr ktx2Handle); diff --git a/source/CapriKit.SuperCompressed/README.md b/source/CapriKit.SuperCompressed/README.md index 450c8de..7dbc246 100644 --- a/source/CapriKit.SuperCompressed/README.md +++ b/source/CapriKit.SuperCompressed/README.md @@ -18,7 +18,8 @@ Encoding, typically done offline in an asset pipeline: using CapriKit.SuperCompressed; // Image.Load decodes JPG/PNG/BMP/TGA/PSD/GIF; or construct one from raw RGBA32 pixels -var image = Image.Load(File.OpenRead("albedo.png")); +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 = Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, @@ -30,11 +31,11 @@ File.WriteAllBytes("albedo.ktx2", ktx2); Transcoding, done at load time: ```csharp -using var transcoder = Ktx2Transcoder.Open(File.ReadAllBytes("albedo.ktx2")); +using var ktx2File = Ktx2Transcoder.Open(File.ReadAllBytes("albedo.ktx2")); -for (var level = 0; level < transcoder.Levels; level++) +for (var level = 0; level < Ktx2Transcoder.GetLevels(ktx2File); level++) { - var mip = transcoder.Transcode(TranscodeFormat.Bc7Rgba, level); + var mip = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Bc7Rgba, level); // Upload mip.Data to the GPU; mip.RowPitch matches D3D11_SUBRESOURCE_DATA.SysMemPitch } ``` diff --git a/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs index 9dd6502..4b587e7 100644 --- a/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs @@ -14,22 +14,22 @@ private static byte[] EncodeGradientWithMips() [Test] public async Task OpenReadsTheTextureMetadata() { - using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); - await Assert.That(transcoder.Width).IsEqualTo(16); - await Assert.That(transcoder.Height).IsEqualTo(16); - await Assert.That(transcoder.Levels).IsEqualTo(5); // 16, 8, 4, 2, 1 - await Assert.That(transcoder.Faces).IsEqualTo(1); - await Assert.That(transcoder.Format).IsEqualTo(BasisTexFormat.UastcLdr4x4); - await Assert.That(transcoder.IsSrgb).IsTrue(); + await Assert.That(Ktx2Transcoder.GetWidth(ktx2File)).IsEqualTo(16); + await Assert.That(Ktx2Transcoder.GetHeight(ktx2File)).IsEqualTo(16); + await Assert.That(Ktx2Transcoder.GetLevels(ktx2File)).IsEqualTo(5); // 16, 8, 4, 2, 1 + await Assert.That(Ktx2Transcoder.GetFaces(ktx2File)).IsEqualTo(1); + await Assert.That(Ktx2Transcoder.GetFormat(ktx2File)).IsEqualTo(BasisTexFormat.UastcLdr4x4); + await Assert.That(Ktx2Transcoder.IsSrgb(ktx2File)).IsTrue(); } [Test] public async Task TranscodeToUncompressedRgba32() { - using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); - var level0 = transcoder.Transcode(TranscodeFormat.Rgba32); + var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32); await Assert.That(level0.Width).IsEqualTo(16); await Assert.That(level0.Height).IsEqualTo(16); @@ -43,7 +43,7 @@ public async Task TranscodeToUncompressedRgba32() await Assert.That(topRightRed).IsGreaterThan(248); await Assert.That(topRightAlpha).IsEqualTo(255); - var level4 = transcoder.Transcode(TranscodeFormat.Rgba32, level: 4); + var level4 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32, level: 4); await Assert.That(level4.Width).IsEqualTo(1); await Assert.That(level4.Height).IsEqualTo(1); await Assert.That(level4.Data.Length).IsEqualTo(4); @@ -52,9 +52,9 @@ public async Task TranscodeToUncompressedRgba32() [Test] public async Task TranscodeToBlockCompressedBc7() { - using var transcoder = Ktx2Transcoder.Open(EncodeGradientWithMips()); + using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); - var level0 = transcoder.Transcode(TranscodeFormat.Bc7Rgba); + var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Bc7Rgba); // 16x16 pixels = 4x4 blocks of 16 bytes each await Assert.That(level0.Width).IsEqualTo(16); From d388e9c910fdf0a6f84f6e9eeece034be6e88fcc Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 19:59:46 +0200 Subject: [PATCH 04/11] Make it possible to transcode in parallel --- research/SuperCompressedPlan.md | 4 +- source/CapriKit.SuperCompressed/Image.cs | 2 +- .../{Encoder.cs => Ktx2Encoder.cs} | 3 +- .../Ktx2Transcoder.cs | 87 ++++++++++++++----- source/CapriKit.SuperCompressed/README.md | 5 +- .../TranscodedImage.cs | 5 +- .../{EncoderTests.cs => Ktx2EncoderTests.cs} | 4 +- .../SuperCompressed/Ktx2TranscoderTests.cs | 48 +++++++--- 8 files changed, 114 insertions(+), 44 deletions(-) rename source/CapriKit.SuperCompressed/{Encoder.cs => Ktx2Encoder.cs} (95%) rename source/CapriKit.Tests/SuperCompressed/{EncoderTests.cs => Ktx2EncoderTests.cs} (78%) diff --git a/research/SuperCompressedPlan.md b/research/SuperCompressedPlan.md index 589726e..e7aa6cf 100644 --- a/research/SuperCompressedPlan.md +++ b/research/SuperCompressedPlan.md @@ -118,7 +118,9 @@ This mirrors how upstream builds its own `example_capi` target. Details: pinned file data, so the interop marshaller keeps the buffer alive during every native call); `GetWidth/GetHeight/GetLevels/GetLayers/GetFaces/GetFormat/HasAlpha/IsSrgb(handle)`; `Transcode(handle, TranscodeFormat, level, layer, face) → TranscodedImage` - (byte[], dimensions, pitch). + (byte[], level/layer/face, dimensions, pitch); `TranscodeAll(handle, TranscodeFormat) + → TranscodedImage[]` transcodes every image in parallel (`Parallel.ForEach` with a + per-thread native transcode state from `bt_ktx2_create_transcode_state`). - `TranscodeFormat → DXGI_FORMAT` mapping belongs in `CapriKit.DirectX11`, not here. 4. **Tests** (TUnit, `CapriKit.Tests`, happy path) — encode a small generated gradient with mips → open → assert level count/dimensions → transcode to BC7 and RGBA32 → assert buffer diff --git a/source/CapriKit.SuperCompressed/Image.cs b/source/CapriKit.SuperCompressed/Image.cs index c5a93c8..1cb4379 100644 --- a/source/CapriKit.SuperCompressed/Image.cs +++ b/source/CapriKit.SuperCompressed/Image.cs @@ -3,7 +3,7 @@ namespace CapriKit.SuperCompressed; /// -/// An uncompressed 32 bits-per-pixel RGBA image, the input format of . +/// An uncompressed 32 bits-per-pixel RGBA image, the input format of . /// public sealed class Image { diff --git a/source/CapriKit.SuperCompressed/Encoder.cs b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs similarity index 95% rename from source/CapriKit.SuperCompressed/Encoder.cs rename to source/CapriKit.SuperCompressed/Ktx2Encoder.cs index b76a85d..4b47eb0 100644 --- a/source/CapriKit.SuperCompressed/Encoder.cs +++ b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs @@ -6,7 +6,7 @@ namespace CapriKit.SuperCompressed; /// Encodes images into supercompressed KTX2 textures, which /// can later transcode to GPU-native formats. /// -public static class Encoder +public static class Ktx2Encoder { public const int MinQuality = 1; public const int MaxQuality = 100; @@ -30,7 +30,6 @@ public static class Encoder /// The contents of a .ktx2 file. public static byte[] Encode(Image image, BasisTexFormat format, int quality = 85, int effort = 2, CompressionFlags flags = CompressionFlags.None) { - ArgumentNullException.ThrowIfNull(image); ArgumentOutOfRangeException.ThrowIfLessThan(quality, MinQuality); ArgumentOutOfRangeException.ThrowIfGreaterThan(quality, MaxQuality); ArgumentOutOfRangeException.ThrowIfLessThan(effort, MinEffort); diff --git a/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs index 700eea2..2b27556 100644 --- a/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs +++ b/source/CapriKit.SuperCompressed/Ktx2Transcoder.cs @@ -3,15 +3,10 @@ namespace CapriKit.SuperCompressed; /// -/// Reads KTX2 textures (as produced by ) and transcodes their mip +/// 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. /// -/// -/// Do not call concurrently with the same . -/// (The native transcoder would need a per-thread transcode state for that, which this -/// wrapper does not use.) -/// public static class Ktx2Transcoder { /// Parses the contents of a .ktx2 file and prepares it for transcoding. @@ -48,33 +43,33 @@ public static unsafe Ktx2FileHandle Open(ReadOnlySpan ktx2Data) } /// Width in pixels of the largest mip level. - public static int GetWidth(Ktx2FileHandle ktx2File) + public static uint GetWidth(Ktx2FileHandle ktx2File) { - return (int)NativeMethods.bt_ktx2_get_width(ktx2File); + return NativeMethods.bt_ktx2_get_width(ktx2File); } /// Height in pixels of the largest mip level. - public static int GetHeight(Ktx2FileHandle ktx2File) + public static uint GetHeight(Ktx2FileHandle ktx2File) { - return (int)NativeMethods.bt_ktx2_get_height(ktx2File); + return NativeMethods.bt_ktx2_get_height(ktx2File); } /// The number of mip levels. - public static int GetLevels(Ktx2FileHandle ktx2File) + public static uint GetLevels(Ktx2FileHandle ktx2File) { - return (int)NativeMethods.bt_ktx2_get_levels(ktx2File); + return NativeMethods.bt_ktx2_get_levels(ktx2File); } /// The number of array layers; 0 for textures that are not arrays. - public static int GetLayers(Ktx2FileHandle ktx2File) + public static uint GetLayers(Ktx2FileHandle ktx2File) { - return (int)NativeMethods.bt_ktx2_get_layers(ktx2File); + return NativeMethods.bt_ktx2_get_layers(ktx2File); } /// The number of faces: 6 for cubemaps, 1 otherwise. - public static int GetFaces(Ktx2FileHandle ktx2File) + public static uint GetFaces(Ktx2FileHandle ktx2File) { - return (int)NativeMethods.bt_ktx2_get_faces(ktx2File); + return NativeMethods.bt_ktx2_get_faces(ktx2File); } /// The intermediate texture format stored inside the KTX2 file. @@ -94,10 +89,58 @@ public static bool IsSrgb(Ktx2FileHandle ktx2File) } /// Transcodes one mip level to the given GPU texture format. - public static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat format, int level = 0, int layer = 0, int face = 0, DecodeFlags flags = DecodeFlags.None) + /// + /// 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) { - var width = NativeMethods.bt_ktx2_get_level_orig_width(ktx2File, (uint)level, (uint)layer, (uint)face); - var height = NativeMethods.bt_ktx2_get_level_orig_height(ktx2File, (uint)level, (uint)layer, (uint)face); + 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}"); @@ -121,7 +164,7 @@ public static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat var data = new byte[sizeInBytes]; var succeeded = NativeMethods.bt_ktx2_transcode_image_level( ktx2File, - (uint)level, (uint)layer, (uint)face, + level, layer, face, data, sizeInBytes / bytesPerUnit, format, @@ -129,13 +172,13 @@ public static TranscodedImage Transcode(Ktx2FileHandle ktx2File, TranscodeFormat outputRowPitchInBlocksOrPixels: 0, outputRowsInPixels: 0, channel0: -1, channel1: -1, - stateHandle: 0); + stateHandle); if (!succeeded) { throw new InvalidOperationException($"Failed to transcode level {level}, layer {layer}, face {face} from {GetFormat(ktx2File)} to {format}"); } - return new TranscodedImage(format, (int)width, (int)height, (int)rowPitch, data); + return new TranscodedImage(format, level, layer, face, width, height, rowPitch, data); } } diff --git a/source/CapriKit.SuperCompressed/README.md b/source/CapriKit.SuperCompressed/README.md index 7dbc246..7af42ac 100644 --- a/source/CapriKit.SuperCompressed/README.md +++ b/source/CapriKit.SuperCompressed/README.md @@ -33,9 +33,10 @@ Transcoding, done at load time: ```csharp using var ktx2File = Ktx2Transcoder.Open(File.ReadAllBytes("albedo.ktx2")); -for (var level = 0; level < Ktx2Transcoder.GetLevels(ktx2File); level++) +// 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)) { - var mip = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Bc7Rgba, level); // Upload mip.Data to the GPU; mip.RowPitch matches D3D11_SUBRESOURCE_DATA.SysMemPitch } ``` diff --git a/source/CapriKit.SuperCompressed/TranscodedImage.cs b/source/CapriKit.SuperCompressed/TranscodedImage.cs index 8f07fff..591065c 100644 --- a/source/CapriKit.SuperCompressed/TranscodedImage.cs +++ b/source/CapriKit.SuperCompressed/TranscodedImage.cs @@ -4,6 +4,9 @@ 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. /// @@ -11,4 +14,4 @@ namespace CapriKit.SuperCompressed; /// blocks (compressed formats), as expected by for example D3D11_SUBRESOURCE_DATA.SysMemPitch. /// /// The texel data. -public sealed record TranscodedImage(TranscodeFormat Format, int Width, int Height, int RowPitch, byte[] 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.Tests/SuperCompressed/EncoderTests.cs b/source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs similarity index 78% rename from source/CapriKit.Tests/SuperCompressed/EncoderTests.cs rename to source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs index 760733b..e1487b0 100644 --- a/source/CapriKit.Tests/SuperCompressed/EncoderTests.cs +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2EncoderTests.cs @@ -2,7 +2,7 @@ namespace CapriKit.Tests.SuperCompressed; -internal class EncoderTests +internal class Ktx2EncoderTests { private static readonly byte[] Ktx2Magic = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]; @@ -11,7 +11,7 @@ public async Task EncodeProducesAKtx2File() { var image = TestImages.CreateGradient(); - var ktx2 = Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 1); + 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 index 4b587e7..5bb3e35 100644 --- a/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs +++ b/source/CapriKit.Tests/SuperCompressed/Ktx2TranscoderTests.cs @@ -7,7 +7,7 @@ internal class Ktx2TranscoderTests private static byte[] EncodeGradientWithMips() { var image = TestImages.CreateGradient(); - return Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 2, + return Ktx2Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, effort: 2, flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp); } @@ -16,10 +16,10 @@ public async Task OpenReadsTheTextureMetadata() { using var ktx2File = Ktx2Transcoder.Open(EncodeGradientWithMips()); - await Assert.That(Ktx2Transcoder.GetWidth(ktx2File)).IsEqualTo(16); - await Assert.That(Ktx2Transcoder.GetHeight(ktx2File)).IsEqualTo(16); - await Assert.That(Ktx2Transcoder.GetLevels(ktx2File)).IsEqualTo(5); // 16, 8, 4, 2, 1 - await Assert.That(Ktx2Transcoder.GetFaces(ktx2File)).IsEqualTo(1); + 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(); } @@ -31,9 +31,9 @@ public async Task TranscodeToUncompressedRgba32() var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32); - await Assert.That(level0.Width).IsEqualTo(16); - await Assert.That(level0.Height).IsEqualTo(16); - await Assert.That(level0.RowPitch).IsEqualTo(16 * 4); + 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 @@ -44,11 +44,33 @@ public async Task TranscodeToUncompressedRgba32() await Assert.That(topRightAlpha).IsEqualTo(255); var level4 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Rgba32, level: 4); - await Assert.That(level4.Width).IsEqualTo(1); - await Assert.That(level4.Height).IsEqualTo(1); + 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() { @@ -57,9 +79,9 @@ public async Task TranscodeToBlockCompressedBc7() var level0 = Ktx2Transcoder.Transcode(ktx2File, TranscodeFormat.Bc7Rgba); // 16x16 pixels = 4x4 blocks of 16 bytes each - await Assert.That(level0.Width).IsEqualTo(16); - await Assert.That(level0.Height).IsEqualTo(16); - await Assert.That(level0.RowPitch).IsEqualTo(4 * 16); + 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); } } From b72d6cf5f90d705ebea6aaca733e0cf4030e7a96 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 20:22:49 +0200 Subject: [PATCH 05/11] Simplify native build --- .github/workflows/unit-tests.yml | 2 +- research/SuperCompressedPlan.md | 5 ++- .../CapriKit.SuperCompressed.Native/README.md | 42 ----------------- .../CapriKit.SuperCompressed.csproj | 6 +-- source/CapriKit.SuperCompressed/README.md | 45 ++++++++++++++++--- .../basis_universal}/CMakeLists.txt | 4 +- .../basis_universal}/build.ps1 | 6 +-- .../basis_universal}/exports.def | 0 8 files changed, 51 insertions(+), 59 deletions(-) delete mode 100644 source/CapriKit.SuperCompressed.Native/README.md rename source/{CapriKit.SuperCompressed.Native => CapriKit.SuperCompressed/basis_universal}/CMakeLists.txt (92%) rename source/{CapriKit.SuperCompressed.Native => CapriKit.SuperCompressed/basis_universal}/build.ps1 (91%) rename source/{CapriKit.SuperCompressed.Native => CapriKit.SuperCompressed/basis_universal}/exports.def (100%) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b021ffe..18aa73c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,7 +30,7 @@ jobs: - name: Build basis_universal native library shell: pwsh - run: .\source\CapriKit.SuperCompressed.Native\build.ps1 + run: .\source\CapriKit.SuperCompressed\basis_universal\build.ps1 - name: Run unit tests shell: pwsh diff --git a/research/SuperCompressedPlan.md b/research/SuperCompressedPlan.md index e7aa6cf..8b285de 100644 --- a/research/SuperCompressedPlan.md +++ b/research/SuperCompressedPlan.md @@ -104,8 +104,9 @@ This mirrors how upstream builds its own `example_capi` target. Details: ## Implementation plan -1. **`source/CapriKit.SuperCompressed.Native/`** — `CMakeLists.txt` (as above), `exports.def`, - `build.ps1`. Produces `caprikit_basisu.dll` (x64 Release). Never modifies the submodule. +1. **`source/CapriKit.SuperCompressed/basis_universal/`** — `CMakeLists.txt` (as above), + `exports.def`, `build.ps1`. Not an MSBuild project, just the native build helpers for + `caprikit_basisu.dll` (x64 Release). Never modifies the submodule. 2. **Interop layer** (internal, in `CapriKit.SuperCompressed`) — `NativeMethods.cs` with `[LibraryImport]` mirroring the two headers 1:1; `ulong` handles wrapped in `SafeHandle`s (`CompParamsHandle`, `Ktx2Handle`); enums mirroring `BTF_*`/`TF_*`/flags constants; diff --git a/source/CapriKit.SuperCompressed.Native/README.md b/source/CapriKit.SuperCompressed.Native/README.md deleted file mode 100644 index 984d80f..0000000 --- a/source/CapriKit.SuperCompressed.Native/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# CapriKit.SuperCompressed.Native - -Builds `caprikit_basisu.dll`, the native half of `CapriKit.SuperCompressed`. It contains -[basis_universal](https://github.com/BinomialLLC/basis_universal)'s encoder and transcoder, -exposed through upstream's own plain-C API (`bu_*` encoder functions, `bt_*` transcoder -functions) which `CapriKit.SuperCompressed` consumes via P/Invoke. - -This is **not** an MSBuild project and is not part of `CapriKit.slnx` — the `dotnet` CLI -cannot build C++. It is built with CMake, and only needs rebuilding when the -`external/basis_universal` submodule is updated. - -## Building - -Requires CMake and the Visual Studio C++ toolchain (x64). - -```pwsh -.\build.ps1 # Release (default) -.\build.ps1 -Configuration Debug -``` - -Output: `\.build\bin\CapriKit.SuperCompressed.Native\caprikit_basisu.dll` -(intermediates in `\.build\native\caprikit_basisu`). - -## Updating basis_universal - -1. Update the submodule to the desired version. -2. Rerun `.\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 - `CapriKit.SuperCompressed`. - -## 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/CapriKit.SuperCompressed.csproj b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj index 078aa5c..49b6807 100644 --- a/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj +++ b/source/CapriKit.SuperCompressed/CapriKit.SuperCompressed.csproj @@ -1,7 +1,7 @@ - $(MSBuildThisFileDirectory)..\..\.build\bin\CapriKit.SuperCompressed.Native\caprikit_basisu.dll + $(MSBuildThisFileDirectory)..\..\.build\bin\caprikit_basisu\caprikit_basisu.dll @@ -10,13 +10,13 @@ - + diff --git a/source/CapriKit.SuperCompressed/README.md b/source/CapriKit.SuperCompressed/README.md index 7af42ac..c96c16f 100644 --- a/source/CapriKit.SuperCompressed/README.md +++ b/source/CapriKit.SuperCompressed/README.md @@ -1,6 +1,6 @@ # CapriKit.SuperCompressed -Supercompressed textures for game engines, powered by +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 @@ -22,7 +22,7 @@ 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 = Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, +var ktx2 = Ktx2Encoder.Encode(image, BasisTexFormat.UastcLdr4x4, flags: CompressionFlags.Srgb | CompressionFlags.GenMipsClamp | CompressionFlags.Threaded); File.WriteAllBytes("albedo.ktx2", ktx2); @@ -49,8 +49,41 @@ Notes: `GenMipsWrap` to match the texture's sampler addressing mode. - Output is always KTX2; the `.basis` container is not supported. -## Building from source +## Building the native library -The native DLL is not built by `dotnet build`. Run `..\CapriKit.SuperCompressed.Native\build.ps1` -once (requires CMake and the Visual Studio C++ toolchain), and again after updating the -basis_universal submodule. See `CapriKit.SuperCompressed.Native\README.md` for details. +`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.Native/CMakeLists.txt b/source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt similarity index 92% rename from source/CapriKit.SuperCompressed.Native/CMakeLists.txt rename to source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt index d3dea89..6ef7158 100644 --- a/source/CapriKit.SuperCompressed.Native/CMakeLists.txt +++ b/source/CapriKit.SuperCompressed/basis_universal/CMakeLists.txt @@ -3,7 +3,7 @@ # # 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. +# 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. @@ -18,7 +18,7 @@ project(caprikit_basisu C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(BASISU_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../external/basis_universal) +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 diff --git a/source/CapriKit.SuperCompressed.Native/build.ps1 b/source/CapriKit.SuperCompressed/basis_universal/build.ps1 similarity index 91% rename from source/CapriKit.SuperCompressed.Native/build.ps1 rename to source/CapriKit.SuperCompressed/basis_universal/build.ps1 index df858be..a99a39d 100644 --- a/source/CapriKit.SuperCompressed.Native/build.ps1 +++ b/source/CapriKit.SuperCompressed/basis_universal/build.ps1 @@ -1,6 +1,6 @@ # Builds caprikit_basisu.dll from the basis_universal submodule. # Requires CMake and the Visual Studio C++ toolchain. -# Output: \.build\bin\CapriKit.SuperCompressed.Native\caprikit_basisu.dll +# 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. @@ -11,9 +11,9 @@ param( $ErrorActionPreference = 'Stop' -$repoRoot = (Resolve-Path "$PSScriptRoot\..\..").Path +$repoRoot = (Resolve-Path "$PSScriptRoot\..\..\..").Path $buildDir = Join-Path $repoRoot '.build\native\caprikit_basisu' -$outDir = Join-Path $repoRoot '.build\bin\CapriKit.SuperCompressed.Native' +$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' diff --git a/source/CapriKit.SuperCompressed.Native/exports.def b/source/CapriKit.SuperCompressed/basis_universal/exports.def similarity index 100% rename from source/CapriKit.SuperCompressed.Native/exports.def rename to source/CapriKit.SuperCompressed/basis_universal/exports.def From 8c155ff488ebc172b63672727f0b4d0c7f5eb0fa Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 20:37:15 +0200 Subject: [PATCH 06/11] update github actions --- .github/workflows/unit-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 18aa73c..73c5118 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,12 +19,12 @@ 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 From 15be17c727995d0cdb6687f48c71586430c926e4 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 20:44:29 +0200 Subject: [PATCH 07/11] ignore untracked files in the basis_universal submodule to avoid noise caused by builds --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 2de5736..6806295 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "external/basis_universal"] path = external/basis_universal url = https://github.com/BinomialLLC/basis_universal.git + ignore = untracked From f00677e12c9b45ee6ebfb8396c14690b213297b6 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Thu, 9 Jul 2026 20:58:54 +0200 Subject: [PATCH 08/11] fix nitpicks --- CapriKit.slnx | 6 ++---- README.md | 2 +- external/README.md | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CapriKit.slnx b/CapriKit.slnx index 9b45d27..818c7eb 100644 --- a/CapriKit.slnx +++ b/CapriKit.slnx @@ -3,9 +3,7 @@ - - @@ -37,7 +35,7 @@ - + @@ -52,7 +50,7 @@ - + diff --git a/README.md b/README.md index a3fbd43..590dda6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ With CapriKit I try to make a library where I gather all code that I find releva When checking out ensure that you also checkout and update all submodules using: ```pwsh -git submodule update --init --recursive. +git submodule update --init --recursive ``` In general you can use the standard .net commands. These examples here assume you are using `powershell`. diff --git a/external/README.md b/external/README.md index 863360f..c412ee7 100644 --- a/external/README.md +++ b/external/README.md @@ -5,5 +5,5 @@ This folder contains git submodules that point to external repositories. Do not If you do not see any subfolders run: ``` -git submodule update --init --recursive. +git submodule update --init --recursive ``` \ No newline at end of file From 553b2ae8d3d891d7778ea8effff85a984293d8db Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Fri, 10 Jul 2026 15:22:05 +0200 Subject: [PATCH 09/11] Fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- source/CapriKit.SuperCompressed/Ktx2FileHandle.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs index b0cf130..86019f9 100644 --- a/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs +++ b/source/CapriKit.SuperCompressed/Ktx2FileHandle.cs @@ -16,10 +16,10 @@ internal Ktx2FileHandle(byte[] fileData) // 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. - FileDate = fileData; + FileData = fileData; } - internal byte[] FileDate { get; } + internal byte[] FileData { get; } public override bool IsInvalid => handle == IntPtr.Zero; From 629e413820ce4d098971b09c23cd0f4bdc54ae8e Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Fri, 10 Jul 2026 21:21:48 +0200 Subject: [PATCH 10/11] Update readme and other documentation --- CLAUDE.md | 26 ++++-- README.md | 5 +- RESPONSIBLE_USE_OF_AI.md | 2 +- research/AsyncContentPipeline.md | 113 ------------------------ research/ParallelShaderLoading.md | 113 ------------------------ research/SuperCompressedPlan.md | 142 ------------------------------ research/TextureProcessing.md | 30 ------- 7 files changed, 23 insertions(+), 408 deletions(-) delete mode 100644 research/AsyncContentPipeline.md delete mode 100644 research/ParallelShaderLoading.md delete mode 100644 research/SuperCompressedPlan.md delete mode 100644 research/TextureProcessing.md diff --git a/CLAUDE.md b/CLAUDE.md index bc032f9..dbc704b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +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. -## 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. +## 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. -## Technologies -See `Directory.Packages.props` for an overview of NuGet packages used. +## 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. + +## 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. Some projects build or interop with native C/C++ libraries. For those projects use `cmake`. -## Misc 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. \ No newline at end of file diff --git a/README.md b/README.md index 590dda6..318a78b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ _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. @@ -20,7 +20,6 @@ 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 @@ -48,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. 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/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/research/SuperCompressedPlan.md b/research/SuperCompressedPlan.md deleted file mode 100644 index 8b285de..0000000 --- a/research/SuperCompressedPlan.md +++ /dev/null @@ -1,142 +0,0 @@ -# CapriKit.SuperCompressed — Plan & Research Findings - -_Follow-up to `TextureProcessing.md`. Investigated 2026-07-09. Goal: wrap basis_universal -(`external/basis_universal` submodule) in C# for the asset pipeline: load images, generate -mipmaps, encode to a supercompressed container, and transcode to GPU-native formats for DirectX11._ - -## Key findings - -### 1. basis_universal now ships an official plain-C API -The current submodule contains a C API maintained by Binomial themselves, which removes the -need for any custom C++ glue: - -- `encoder/basisu_wasm_api.h` — encoder API, `bu_*` functions. Feed raw RGBA32 (or float RGBA) - pixels via `bu_comp_params_set_image_rgba32`, then `bu_compress_texture` with a - `basis_tex_format` (ETC1S/UASTC/XUASTC/…), quality 1–100, effort 0–10, and flags - (`BU_COMP_FLAGS_*`: KTX2 output, sRGB, threading, mipmap generation with clamp/wrap). -- `encoder/basisu_wasm_transcoder_api.h` — transcoder API, `bt_*` functions. Opens `.ktx2` - blobs (`bt_ktx2_open`), queries width/height/levels/layers/faces/format/alpha, computes - output sizes (`bt_basis_compute_transcoded_image_size_in_bytes`), and transcodes any - level/layer/face to GPU formats (`bt_ktx2_transcode_image_level`): BC1/BC3/BC4/BC5/BC7, - BC6H, ASTC, ETC, RGBA32, … -- Constants live in `encoder/basisu_wasm_api_common.h` (`BTF_*` source formats, `TF_*` - transcode targets, `BU_COMP_FLAGS_*`, `DECODE_FLAGS_*`, quality/effort ranges). - -Despite the "wasm" file names the API compiles natively (`example_capi.exe` is built from it; -the Python bindings use the same files as a native shared library). Versioned via -`bu_get_version()` (`BASISU_LIB_VERSION`, currently 250 in the checked-out submodule). -Note: `bu_get_version()` also printf's a "Hello from basisu_wasm_api.cpp" line to stdout. - -**On native builds the API's `uint64` "offsets" are just raw pointers** (`wasm_ptr` casts them -back). So from C# we can pin managed memory and pass the pointer as `ulong` — no `bu_alloc`/ -`bt_free` round-trips, no double copies. The encoder copies pixels into its own storage during -`set_image`, so pinning only for the call duration is safe. - -### 2. The old SuperCompressed project was not C++/CLI -`C:\projects\csharp\SuperCompressed` is a plain native DLL (`SuperCompressed.Native`, -`extern "C"` exports) consumed via `DllImport` (`NativeMethods.cs`). The pain point was the -~500 lines of hand-written C++ glue duplicating basisu logic — which the upstream C API now -makes obsolete. Its C# API shape (Image/Encoder/Transcoder, spans in, no raw pointers out) -is still a good reference. It used StbImageSharp for image loading and ZstdSharp to compress -the `.basis` output (unnecessary with KTX2: `BU_COMP_FLAGS_KTX2_UASTC_ZSTD` supercompresses -inside the container). - -### 3. Integration options considered - -| Option | Verdict | -|---|---| -| P/Invoke against upstream's C API | **Chosen** — near-zero custom native code, upstream-maintained surface | -| C++/CLI | No — runtime-coupled, solves a problem we no longer have | -| Custom C shim + P/Invoke (old approach) | Obsolete; keep in back pocket for API gaps | -| Host upstream's `.wasm` via wasmtime-dotnet | Zero native build, but slow encoding; not primary | -| ClangSharp/CppSharp codegen | Overkill for ~90 simple functions | -| Community NuGet bindings | Stale versions defeat the "easy updates" goal | - -### 4. Native build: CMake, reusing upstream's build definitions -Our own `CMakeLists.txt` can `add_subdirectory(external/basis_universal EXCLUDE_FROM_ALL)` -and link upstream's `basisu_encoder` static-library target — upstream maintains the source -list and compiler flags (MSVC defaults are already right: SSE on, Zstd on, OpenCL off). -`EXCLUDE_FROM_ALL` skips their tools/examples. The whole native build we own is ~10 lines: - -```cmake -cmake_minimum_required(VERSION 3.20) -project(caprikit_basisu CXX) - -set(BASISU_EXAMPLES OFF) -add_subdirectory(/external/basis_universal basisu EXCLUDE_FROM_ALL) - -add_library(caprikit_basisu SHARED - /external/basis_universal/encoder/basisu_wasm_api.cpp - /external/basis_universal/encoder/basisu_wasm_transcoder_api.cpp - exports.def) -target_link_libraries(caprikit_basisu PRIVATE basisu_encoder) -``` - -This mirrors how upstream builds its own `example_capi` target. Details: - -- `exports.def` is required: `BU_WASM_EXPORT` expands to bare `extern "C"` on MSVC (no - `__declspec(dllexport)`). The `.def` file is just the ~90 `bu_*`/`bt_*` names copied from - the two headers; regenerate when the headers change. -- A **vcxproj is not an option**: `dotnet build` cannot build C++ projects (MSB4278 — - `Microsoft.Cpp.Default.props` only exists in Visual Studio's MSBuild). Verified: the - `basisu.vcxproj` entry currently in `CapriKit.slnx` breaks solution-level `dotnet build` - and should be removed. -- A thin `build.ps1` wraps the two cmake commands (configure + `--build --config Release`) - and copies the DLL. Rebuild is only needed when the submodule updates. GitHub Actions - windows runners ship CMake + MSVC, so CI can build it for the unit tests. - -### 5. API gaps in the upstream C API (accepted for v1) -- **No image file loading** — takes raw pixels only. Decision: use **StbImageSharp** - (managed; jpg/png/bmp/tga/psd/gif), same as the old project. -- **No mipmap filter selection** — only on/off + clamp/wrap flags; upstream's default - filter (kaiser) is used. Decision: accept the default; per-level custom mips aren't - expressible in the C API either. Revisit via a tiny shim or upstream PR if ever needed. -- **KTX2 only** — the C transcoder API has no `.basis` reader. Decision: standardize on - `.ktx2` (upstream's recommended container anyway). - -## Decisions (agreed 2026-07-09) - -- P/Invoke (`[LibraryImport]`) against upstream's `bu_*`/`bt_*` C API; no custom native code - beyond a CMakeLists + `.def` file. -- **Windows/x64 only** — the engine is DirectX11-based; no cross-platform requirement. -- **KTX2 only**, StbImageSharp for image loading, default kaiser mip filter. -- Native DLL name: `caprikit_basisu.dll`. - -## Implementation plan - -1. **`source/CapriKit.SuperCompressed/basis_universal/`** — `CMakeLists.txt` (as above), - `exports.def`, `build.ps1`. Not an MSBuild project, just the native build helpers for - `caprikit_basisu.dll` (x64 Release). Never modifies the submodule. -2. **Interop layer** (internal, in `CapriKit.SuperCompressed`) — `NativeMethods.cs` with - `[LibraryImport]` mirroring the two headers 1:1; `ulong` handles wrapped in `SafeHandle`s - (`CompParamsHandle`, `Ktx2Handle`); enums mirroring `BTF_*`/`TF_*`/flags constants; - one-time `bu_init`/`bt_init` + `bu_get_version()` check. -3. **Public API** (span-based, no pointers): - - `Image` — RGBA32 pixels + dimensions; `Image.Load(stream/bytes)` via StbImageSharp. - - `Encoder.Encode(Image, BasisTexFormat, Quality, Effort, options) → byte[]` (`.ktx2` blob). - - `Ktx2Transcoder` (static, like `Encoder`) — `Open(ReadOnlySpan) → Ktx2FileHandle` - (a public SafeHandle with internal members that owns both the native reader and the - pinned file data, so the interop marshaller keeps the buffer alive during every native - call); `GetWidth/GetHeight/GetLevels/GetLayers/GetFaces/GetFormat/HasAlpha/IsSrgb(handle)`; - `Transcode(handle, TranscodeFormat, level, layer, face) → TranscodedImage` - (byte[], level/layer/face, dimensions, pitch); `TranscodeAll(handle, TranscodeFormat) - → TranscodedImage[]` transcodes every image in parallel (`Parallel.ForEach` with a - per-thread native transcode state from `bt_ktx2_create_transcode_state`). - - `TranscodeFormat → DXGI_FORMAT` mapping belongs in `CapriKit.DirectX11`, not here. -4. **Tests** (TUnit, `CapriKit.Tests`, happy path) — encode a small generated gradient with - mips → open → assert level count/dimensions → transcode to BC7 and RGBA32 → assert buffer - sizes match `bt_basis_compute_transcoded_image_size_in_bytes`. -5. **Packaging/docs** — DLL as `runtimes/win-x64/native` in the NuGet; project README with the - submodule-update procedure (bump submodule → regen `.def` if headers changed → rebuild DLL). - -First milestone: build the DLL and get a `bu_get_version` smoke test passing from C#. - -## Environment verification (2026-07-09) - -- `dotnet build` — all 13 C# projects build; solution-level build **fails** only on the - `external/basis_universal/basisu.vcxproj` entry in `CapriKit.slnx` (remove it). -- CMake — **not on PATH**; use `C:\Program Files\CMake\bin\cmake.exe`. Generator - "Visual Studio 18 2026", MSVC 19.51. -- basis_universal — configures and builds cleanly (`basisu_encoder.lib`, Release x64). - Note: MSVC warns (MSB8029) against build dirs under Temp; use a repo-local build dir. -- `dotnet test source\CapriKit.Tests\CapriKit.Tests.csproj` — 189/189 pass. diff --git a/research/TextureProcessing.md b/research/TextureProcessing.md deleted file mode 100644 index f2f7231..0000000 --- a/research/TextureProcessing.md +++ /dev/null @@ -1,30 +0,0 @@ -I am building a game engine in C#, my next project is building the asset pipeline. For texture handling I want to generate mipmaps, process the files for compression and efficient loading and then unpack and upload them to the GPU (using DirectX11) in an efficient format for the GPU to work with them. - -I've settled on using basis_universal project, see `external/basis_universal` for the basis_universal repository (git submodule) which also contains the documentation and usage examples. basis_universal is a c++ project but I want to access the functionality of this library from C#. - -For a much older version of basis_unversal I created two small projects, one in C++/CLR to integrate with the basis_universal library, and one plain C#/.NET t expose this functionality better to other C# projects. The code for that is in `C:\projects\csharp\SuperCompressed`, but I am not certain if this is the best approach. Do not blindly make another C++/CLR project. But consider all options you have for integrating with C++ code. - -Use the (currently empty) C# project in C:\projects\csharp\CapriKit\source\CapriKit.SuperCompressed\ to place your C# code wrapper in. If you need an extra project to deal with the C++ stuff feel free to create it. But ideally this is all we need. - -Considerations -- I want to make it easy to update to newer versions of basis_universal -- I want to minimize custom code and duplication or basis_universal functionality -- I want to access the following functionality of the basis_universal library: - - Loading image data from various image files (jpg, png, etc..) - - Generating MipMaps using any of the supported algorithms - - Encoding and compressing images into the .basis file format - - Loading and transcoding images into a graphics device native format -- I want the C# classes/enums/records feel as a very lightweight wrapper for the C++ code, from C# you should not have to work with raw pointers. Though working with things like a `Span<>` or `ReadOnlySpan<>` is fine. - - -## Follow-up information after Claude read the intial instructions: - -Note: I don't need cross-platform capabilities, I'm already tying myself to windows due to the use of DirectX11. -Questions: -- Is it easier to cmake that correctly builds basis_universal and the dlls we need than creating a build.ps1 file? - - -Answers: -- Image file loading: Yes let's use StbImageSharp again -- Mipmap filter selection: Yes, let's keep it simple for now and do not allow algorithm selection and use the default kaiser filter -- Yes, going for ktx2 only is fine. \ No newline at end of file From 58a1906456e250dbc3204cbb8e2b59693a5f3a44 Mon Sep 17 00:00:00 2001 From: Roy Triesscheijn Date: Fri, 10 Jul 2026 21:37:51 +0200 Subject: [PATCH 11/11] Update more documentation --- CLAUDE.md | 2 +- source/CapriKit.SuperCompressed/BasisTexFormat.cs | 7 +++---- source/CapriKit.SuperCompressed/CompressionFlags.cs | 9 ++++----- source/CapriKit.SuperCompressed/DecodeFlags.cs | 7 +++---- source/CapriKit.SuperCompressed/Ktx2Encoder.cs | 6 +----- source/CapriKit.SuperCompressed/TranscodeFormat.cs | 7 +++---- 6 files changed, 15 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dbc704b..faa1ef0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When answering a question keep the following rules in mind: 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. +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. Some projects build or interop with native C/C++ libraries. For those projects use `cmake`. diff --git a/source/CapriKit.SuperCompressed/BasisTexFormat.cs b/source/CapriKit.SuperCompressed/BasisTexFormat.cs index 2b8df9a..4c82625 100644 --- a/source/CapriKit.SuperCompressed/BasisTexFormat.cs +++ b/source/CapriKit.SuperCompressed/BasisTexFormat.cs @@ -1,9 +1,8 @@ namespace CapriKit.SuperCompressed; -/// -/// The texture formats a .ktx2 file can store, mirrors the BTF_* constants in -/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::basis_tex_format). -/// +// 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, diff --git a/source/CapriKit.SuperCompressed/CompressionFlags.cs b/source/CapriKit.SuperCompressed/CompressionFlags.cs index 53b1614..cba95d0 100644 --- a/source/CapriKit.SuperCompressed/CompressionFlags.cs +++ b/source/CapriKit.SuperCompressed/CompressionFlags.cs @@ -1,10 +1,9 @@ namespace CapriKit.SuperCompressed; -/// -/// Flags for encoding, mirrors the BU_COMP_FLAGS_* constants in -/// external/basis_universal/encoder/basisu_wasm_api_common.h. -/// The native parameter is 64 bits wide, so this enum is backed by ulong. -/// +// 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 diff --git a/source/CapriKit.SuperCompressed/DecodeFlags.cs b/source/CapriKit.SuperCompressed/DecodeFlags.cs index d52b781..b3d09e6 100644 --- a/source/CapriKit.SuperCompressed/DecodeFlags.cs +++ b/source/CapriKit.SuperCompressed/DecodeFlags.cs @@ -1,9 +1,8 @@ namespace CapriKit.SuperCompressed; -/// -/// Flags for transcoding, mirrors the DECODE_FLAGS_* constants in -/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::basisu_decode_flags). -/// +// 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 { diff --git a/source/CapriKit.SuperCompressed/Ktx2Encoder.cs b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs index 4b47eb0..4fde94e 100644 --- a/source/CapriKit.SuperCompressed/Ktx2Encoder.cs +++ b/source/CapriKit.SuperCompressed/Ktx2Encoder.cs @@ -13,9 +13,7 @@ public static class Ktx2Encoder public const int MinEffort = 0; public const int MaxEffort = 10; - /// - /// Encodes an image into a KTX2 texture. The defaults match basis_universal's own example code. - /// + /// Encodes an image into a KTX2 texture. /// The source image. /// The intermediate texture format stored inside the KTX2 file. /// Encoding quality, (smallest) to (best). @@ -24,8 +22,6 @@ public static class Ktx2Encoder /// Encoding options, for example for color textures, /// or to /// generate mipmaps, and to use all cores. - /// is always added: this wrapper is KTX2-only because - /// the basis_universal C transcoding API cannot read .basis files. /// /// The contents of a .ktx2 file. public static byte[] Encode(Image image, BasisTexFormat format, int quality = 85, int effort = 2, CompressionFlags flags = CompressionFlags.None) diff --git a/source/CapriKit.SuperCompressed/TranscodeFormat.cs b/source/CapriKit.SuperCompressed/TranscodeFormat.cs index fc33cb0..77fb684 100644 --- a/source/CapriKit.SuperCompressed/TranscodeFormat.cs +++ b/source/CapriKit.SuperCompressed/TranscodeFormat.cs @@ -1,9 +1,8 @@ namespace CapriKit.SuperCompressed; -/// -/// The GPU texture formats a .ktx2 file can be transcoded to, mirrors the TF_* constants in -/// external/basis_universal/encoder/basisu_wasm_api_common.h (basist::transcoder_texture_format). -/// +// 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,