Crafter.Graphics/AGENTS.template.md

159 lines
38 KiB
Markdown
Raw Normal View History

# AGENTS.md
Guidance for coding agents and contributors working on Crafter.Graphics itself. The [README](README.md) is the user-facing manual, the example READMEs hold the driver investigations, and [AGENTS.consumer.md](AGENTS.consumer.md) is what agents working in a *consumer* of this library read: crafter-build copies it into the dependency-notes section of the consumer's `AGENTS.md` on every build. When any of them disagrees with the code, the code wins; the interfaces in `interfaces/` are the reference.
## Project overview
Crafter.Graphics is the Crafter engine's graphics library: one C++ module, `Crafter.Graphics`, with 41 partitions, built with [crafter-build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) from `project.cpp`. On native targets it is a Vulkan 1.3+ renderer built around `VK_EXT_descriptor_heap` (bindless, no descriptor-set layouts, push data through `vkCmdPushDataEXT`), with a bindless-descriptor-set fallback for drivers without the heap, hardware ray tracing, a compute-shader UI, a Wayland or Win32 window, and input. On `wasm32-*` targets the same C++ surface runs in a browser over WebGPU through a JS bridge, with a software wavefront ray tracer. It depends on Crafter.Event (events), Crafter.Math (vectors, matrices) and Crafter.Asset (`.cmesh`, `.ctex`), all pulled from `forgejo.catcrafts.net` unpinned, and, on native targets, on the Khronos `Vulkan-Headers` and `Vulkan-Utility-Libraries` from GitHub as header-only `ExternalDependency` entries. Its consumers are the examples under `examples/` (each a `LocalProject` of `../../project.cpp`) and the maintainer's applications, which pull `master`.
| Path | What lives there |
|---|---|
| `interfaces/Crafter.Graphics.cppm` | The umbrella: `export import`s every partition, unconditionally. |
| `interfaces/Crafter.Graphics-<Partition>.cppm` | One partition per file, see below. The Vulkan header and the platform headers (`wayland-client.h`, `windows.h`, `stb_truetype.h`, the Linux input headers) go in the global module fragment behind the backend's `#ifdef`. |
| `implementations/Crafter.Graphics-<Partition>.cpp` | Implementation units, in two sets chosen by `project.cpp`: 15 native and 17 DOM units. `Clipboard`, `Font`, `FontAtlas`, `Gamepad`, `Input`, `InputField`, `UI-Shared`, `UIComponents` and `Window` are in both; `-Device-Features.cpp` is a second unit of `:Device`; `-UI-WebGPU.cpp`, `-Mesh-WebGPU.cpp` and `-RenderingElement3D-WebGPU.cpp` are the DOM halves; `-GamepadWin32.c` and `.h` are the C binding to Windows.Gaming.Input. |
| `shaders/` | The five standard UI compute kernels (`ui-quads`, `ui-circles`, `ui-images`, `ui-text`, `ui-fused`) and `ui-shared.glsl`, the GLSL contract user shaders `#include`. Each kernel is compiled twice, see "Design rules". |
| `additional/dom-env.js`, `additional/dom-webgpu.js` | The JS half of the DOM backend: the wasm `env` imports for the window, DOM, router and clipboard, and the WebGPU bridge with the WGSL of the UI kernels, the TLAS build and the wavefront tracer baked in. Shipped as `cfg.files` of every wasm build. |
| `lib/` | Vendored: stb_image, stb_image_write, stb_truetype; the generated Wayland protocol C files (xdg-shell, xdg-decoration, fractional-scale, viewporter, text-input-v3); `libevdev/` with its kernel headers. Not edited. |
| `examples/<Name>/` | Standalone projects, MIT, each with its own `project.cpp`, `main.cpp` and shaders in GLSL and WGSL side by side. `examples/README.md` is the index. |
| `tests/<Name>/main.cpp` | One test per directory, plus the GLSL and the `font.ttf` some need. Declared in `project.cpp`. |
| `project.cpp` | The build description: the dependencies, the backend switch on the target, the partition and implementation lists, the shader builds, libevdev, and every test. |
| `README.md`, `examples/*/README.md` | The manual, and the NVIDIA driver investigations (`VulkanTriangle`, `Sponza`). |
| `AGENTS.consumer.md` | Notes for consumers' agents; crafter-build injects them into consumers' `AGENTS.md`. |
| `bin/`, `build/`, `pipeline_cache.bin` | Generated and gitignored. `build/test-logs/` keeps the output of failed tests; `pipeline_cache.bin` is written by any example or test run from here. |
2026-11-02 14:57:39 +01:00
| `.review-gates/` | The pre-push review's `config.json` and this repository's lens sections (`lenses/`), read by Crafter.Review. Its `push/` and `trees/` are generated and gitignored. |
Partitions, by what they are for. Every partition file exists on every target; one that does not apply compiles to an empty module behind `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM` (or `#ifndef`), and several hold both halves in one file (`Window`, `Device`, `UI`, `Mesh`, `RenderingElement3D`, `RTPass`, `RenderPass`, `Image2D`, `FontAtlas`, `RT`).
- Both backends: `:Types` (`FrameTime`, `TouchPoint`, `CrafterKeys`, `AlignUp`), `:Keys` (`consteval Key(CrafterKeys)` to the platform's raw code), `:Window`, `:Device`, `:Input` (named actions, rebinding), `:Gamepad`, `:Clipboard`, `:Animation`, `:Font` (stb_truetype), `:FontAtlas` (the SDF atlas), `:UI` (`UIRenderer`, the item structs, `Rect`), `:UIComponents` (Tier 3), `:InputField`, `:RenderPass`, `:RTPass`, `:Mesh`, `:RenderingElement3D`, `:Image2D`, `:RT` (portable RT PODs and flag constants), `:GraphicsTypes` (the `using` aliases `GraphicsBuffer`, `GraphicsDescriptorHeap`, `GraphicsComputeShader`, `GraphicsCommandBuffer`), `:ForwardDeclarations`.
- Vulkan only: `:VulkanBuffer`, `:ImageVulkan`, `:SamplerVulkan`, `:DescriptorHeapVulkan` (heap and descriptor-set mode, the RAII slots), `:ComputeShader` (Tier 1), `:ShaderVulkan` (`VulkanShader` and the NVIDIA SPIR-V rewrite), `:ShaderBindingTableVulkan`, `:PipelineRTVulkan`, `:VulkanTransition`, `:Decompress` (`VK_EXT_memory_decompression`).
- DOM only: `:WebGPU` (the `extern "C"` bridge declarations), `:WebGPUBuffer`, `:DescriptorHeapWebGPU`, `:WebGPUComputeShader`, `:PlainComputeShader`, `:ShaderBindingTableWebGPU`, `:PipelineRTWebGPU`, `:Dom`, `:DomEvents`, `:Router`.
## Dev environment
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
crafter-build, 3.0.0 or later (`project.cpp` uses its `ShellQuote`), is the only build tool: the Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; a locally built one runs with `--branch=local`) or a native build of the sibling checkout. `crafter-build --version` prints the compiler it resolved. Loading `project.cpp` clones Crafter.Event, Crafter.Math and Crafter.Asset into the crafter-build cache and, since they are unpinned, `git pull`s them on every invocation, and clones the two Khronos header repositories, and for a Win32 target Vulkan-Loader as well, so a build needs the network. `--local` takes the sibling checkouts (`../Crafter.Event`, `../Crafter.Math`, `../Crafter.Asset`) instead and hands the flag on, so Crafter.Asset picks the sibling Crafter.Math too; two copies of one module on the path do not link.
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
The host is Linux on Wayland. The native Linux backend is Wayland only (no X11), so every test that calls `Device::Initialize` needs `WAYLAND_DISPLAY`: it connects to the display right after creating the Vulkan instance, window or no window. The build links `wayland-client`, `xkbcommon`, `udev` and the Vulkan loader from the host or the sysroot; for a Win32 target it links the loader through an import library that `project.cpp` makes with `llvm-dlltool` from Vulkan-Loader's `loader/vulkan-1.def`, into `build/vulkan-1-<target>/`, since the MinGW toolchains ship none. libevdev is vendored and built as its own static library, `Crafter.Graphics-libevdev`, once per build. A Vulkan 1.3 driver is the floor; `VK_EXT_descriptor_heap` (with `shaderUntypedPointers`), ray tracing and `swapchain_maintenance1` (with the instance's `surface_maintenance1`, for per-present-mode image counts) are optional groups detected at start-up. Recent Mesa's llvmpipe advertises the heap, so a machine without a GPU runs the UI path. The Khronos validation layer is optional and off by default.
Targets, picked with `--target=` and passed down to the dependencies:
| Target | Backend | Notes |
|---|---|---|
| native (`x86_64-unknown-linux-gnu`) | Wayland + Vulkan | the development loop |
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
| `x86_64-w64-mingw32` | Win32 + Vulkan | cross-compiled; links `vulkan-1` (an import library made from Vulkan-Loader's `loader/vulkan-1.def`), `kernel32`, `user32`, `gdi32`, `runtimeobject`, `uuid`; gamepads through Windows.Gaming.Input |
| `wasm32-wasip1` | DOM + WebGPU | no Vulkan loader, no Wayland; `dom-env.js` and `dom-webgpu.js` ship beside the `.wasm`; `--no-webgpu` drops the WebGPU bridge for DOM-only apps |
| `aarch64-alpine-linux-musl` with `--sysroot=`, `--march=armv8-a --mtune=generic` | Wayland + Vulkan | the Mesa Turnip phone target: no descriptor heap, no ray tracing, the descriptor-set path |
Project flags, before or after `test`: `--local`, `--timing` (defines `CRAFTER_TIMING` for `Window::LogTiming` and per-listener update timings; the gated code references `Window` members that no longer exist, so the build fails, issue #178), `--no-webgpu` (wasm only), and crafter-build's `--target=`, `--march=`, `--mtune=`, `--sysroot=`, `--debug`. Flags `ApplyStandardArgs` does not interpret are part of the variant hash, so each combination has its own `bin/Crafter.Graphics-<target>-<march>-<mtune>-<hash>/` with `libCrafter.Graphics.a`, the PCMs and the shaders (`ui-*.comp.spv` and `ui-*.comp.sets.spv`); earlier variants stay until `clean`. Glob for a variant directory rather than predicting its hash.
The Flatpak sandbox sees the host filesystem but not the host `PATH`: the host's binaries are under `/run/host/usr/bin`, from where statically linked tools (`wine`, `qemu-aarch64-static`) run and dynamically linked ones often do not, and it has its own `/tmp`. A `--runner=cmd:<tool>` is probed with `which` inside the sandbox; a missing runner fails those tests rather than skipping them, and skips their build.
## Build, test and style commands
```bash
crafter-build # the native library and its shaders
crafter-build --local # against ../Crafter.Event, ../Crafter.Math, ../Crafter.Asset
crafter-build --target=wasm32-wasip1 # the DOM/WebGPU library
crafter-build --target=x86_64-w64-mingw32 # the Win32 library
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:true Win32Link # links a Win32 executable, without Wine
crafter-build test # build and run every test for the host
crafter-build test 'TLAS*' # name glob
crafter-build test --list
crafter-build test --jobs=1 --timeout=120
CRAFTER_GRAPHICS_VALIDATION=1 crafter-build test # with the validation layer; the tests' validation checks only count then
cd examples/HelloUI && crafter-build -r # build an example against this checkout and run it from its bin dir
crafter-build clean
```
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
`project.cpp` registers no lint rules, so `crafter-build lint` and `format` have nothing to do here; style is kept by hand (see "Code style"). There is no CI. Before a change is done: `crafter-build test` passes on the host with `CRAFTER_GRAPHICS_VALIDATION` set; the examples whose API you touched build (`cd examples/<Name> && crafter-build`), since they are the in-tree consumers; `crafter-build --target=wasm32-wasip1` still compiles when you touched a partition with a DOM half or a backend-agnostic one; and `crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:true Win32Link` still links when you touched `Window`, `Device`, `Gamepad`, `Clipboard` or the link flags in `project.cpp`.
## Testing instructions
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
Tests are declared in `project.cpp`, not discovered, through the `LibraryTest(name)` lambda: it copies the library's settings field by field (`Configuration` is not copyable) and compiles the library's sources plus `tests/<name>/main.cpp` into one executable, so a test reaches non-exported internals the way the library does. A new test is a directory, a `cfg.tests.push_back(LibraryTest("Name"));` and a comment above it saying what it covers and what it needs (a GPU, ray tracing, a compositor, a tool). A test that needs shaders adds them to `test.config.shaders` with the same `ShaderType` lines the examples use; a font or asset goes in `test.config.files`; a tool it runs goes in `requires_` (`{"tool:glslang", "tool:spirv-val"}`), which turns the test into a skip when the tool is missing. The declarations sit in the native branch of `project.cpp`: wasm runs no tests. Wayland-only tests sit in `if (!windows)`; the Win32 tests, `PointerWin32` and `TextWin32`, sit in `if (windows)`, with `requires_ = {"tool:wine"}` when built on Linux. `Win32Link` beside them has no `requires_`, since its link is the test: where there is no Wine it runs as `--runner=cmd:true`, which builds it and runs nothing. A `requires_` would skip the build as well, and a test cannot pick its own runner, as the harness derives every test's from its target.
A test is an `int main()`: exit 0 passes, nonzero fails, 77 skips (for a missing capability, never for a failed check). Each runs in its own process, in parallel, with a 60 s default timeout, and the output of a failed, crashed or timed-out test is in `build/test-logs/<name>.log`; a later pass does not delete the log, so check its timestamp. What the tests need, in three groups:
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
- Pure logic, no GPU: they stage `Device`'s statics or feed synthetic data to the decision functions that were factored out for them (`Device::ResolveFeatures`, `ChooseDevice`, `CheckShaderCapabilities`, `GetMemoryType`, `PreferDirectDeviceWrite`, `PipelineCacheDataCompatible`, `AlignMappedFlushRange`, `BuildMipChainFinalBarriers`, `SwapchainStageUnion`, `BuildTlasWriteBarrier`, `UIRenderer::ClipFlags`, `Window::SwapchainMinImageCount`, `WorkaroundNvidiaAS::Patch`, `DescriptorHeapVulkan::ComputeLayout`, `FontAtlas::Packer`, the font and input-field helpers). `PushConstantRewrite` and `UIFusedShader` compile GLSL with glslang and check the SPIR-V with spirv-val at runtime, and `ExampleShaders` compiles every GLSL shader under `examples/` the way crafter-build would, the descriptor-set build included, so a shader contract change that breaks an example fails here without building the example; `FontAtlasDecode` reads `ui-shared.glsl` and `dom-webgpu.js` and holds their copies of the SDF encoding constants to `FontAtlas`'s; `MouseScroll` and `PointerEnterLeave` drive the Wayland listener callbacks directly, and `TitleWayland` reads the requests a window sends off a `wl_display` connected to a socketpair.
- A headless device: `Device::Initialize()` and no `Window` (`HeapSlotReuse`, `DeferredDeletion`, `MeshLifetime`, `ImageStagingRelease`, `BLASBuildOptions`, the `TLAS*` tests, ...). Headless still means a Wayland display on Linux. Most of the RT ones check `Device::rayTracingSupported` and return 77 without it; `BLASBuildOptions`, `TLASInstanceDirtyTracking` and `MeshDecompressStagingRelease` do not, and crash on a GPU without ray tracing (`Device::RequireRayTracing` throws out of `main`). `DeviceSelection` skips with one GPU.
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
- A window and a live compositor: `FrameLoopSync`, `SetupCmdBufferReuse`, `UIFallbackParity`, `SwapchainTransferDst`, `WindowUpdateClock`, `HeapASRead`, `HeapDynamicIndexHit`, `RTPassPushData`, `WindowOptionalGlobals` (hides xdg-decoration, viewporter and fractional scale from the window in turn), `SwapchainImageCount` (every present mode, and swapchains with more images than frames in flight, in child processes on llvmpipe where the host's GPU never makes one), `TextAntialiasing` (text through `DispatchText` and `DispatchFused` against stb_truetype's own antialiased rasterizer, at 14 and 64 px and stretched, plus a glyph mirrored by its UVs and empty texels shrunk below 4 px), and `FeatureRequests` for its retry case. They render, read the frame back with `Window::CaptureNextFrame` (a PNG, decoded with a private `STB_IMAGE_STATIC` copy of stb_image) or a transfer pass of their own, and check pixels.
Conventions the existing tests follow:
- Open `main.cpp` with a comment that states the behaviour under test, the failure it guards against and what it needs. `tests/HeapSlotReuse/main.cpp` and `tests/UIFallbackParity/main.cpp` show the shape.
- `Check(ok, what)` prints `PASS` or `FAIL` with the description and counts; `main` prints a summary and returns `EXIT_FAILURE` when any failed. Do not stop at the first failure unless later steps are meaningless without it.
- A validation check reads `Device::validationErrorCount` (the debug messenger only counts, it never aborts) and is gated on `std::getenv("CRAFTER_GRAPHICS_VALIDATION")`, since without the layer the count is always zero.
- Process-wide state is tested in a child process: `UIFallbackParity` starts itself twice with `--render <png>`, once with `CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP` set, and compares the frames; `FeatureRequests` runs its Vulkan 1.3 case under `CRAFTER_GRAPHICS_NO_VULKAN_1_4` the same way.
- Files go under `fs::temp_directory_path()`, named per test.
- `HeapASRead --no-workaround` is the manual probe for whether a new NVIDIA driver still has the descriptor-heap AS fault; run it after a driver update and update the README's status note.
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
A bug fix ships with a test that fails before the fix and passes after it. A change to a UI kernel keeps `UIFallbackParity` green: the heap and descriptor-set builds of one source must stay pixel-identical. A category drawn fused must also match the same category drawn standalone. `TextAntialiasing` compares the two for text, and keeps the text's edge ramp at one screen pixel; for quads, circles and images no test does (`UIFusedShader` only pins the push-constant layout): check those by eye, or add the comparison.
Cross targets: the Win32 tests run under Wine from the native crafter-build on the host, which has `wine` on its `PATH` and finds the host's mingw-w64 itself: `WINEPREFIX=<scratch prefix> crafter-build test --target=x86_64-w64-mingw32 PointerWin32 TextWin32 Win32Link`. The Flatpak has no `wine`, so there `PointerWin32` and `TextWin32` skip and only `Win32Link`'s link is checked. A Flatpak before crafter-build 5.0.0 links no Win32 executable: it gave clang the host's `/usr` as the MinGW sysroot, which put glibc's headers on the MinGW include path, and libstdc++'s `shared_ptr`, inlined from nlohmann/json in Crafter.Asset's GLB loader, then read glibc's `__libc_single_threaded` (Crafter.Build #43). For an aarch64 device, `crafter-build test --target=...` tries qemu, which has no Vulkan: build with the sysroot, copy the test's whole bin directory (binary, `*.spv`, `*.sets.spv`, fonts) to the device and run it there, unlocked and with the window visible, since a locked phone's compositor sends no frame callbacks and the FIFO present then blocks forever.
## Code style
Nothing enforces it here, so match the surrounding code:
- The first two lines of every first-party source are `//SPDX-License-Identifier: LGPL-3.0-only` and `//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®`, no space after the slashes, then a blank line. Examples are `MIT`. Shaders carry the same two lines as `//` comments. Vendored files keep their own headers.
- `import std;` everywhere. Standard headers are never `#include`d; the Vulkan header, the Wayland and Win32 headers, `stb_truetype.h` and the Linux input headers go in the global module fragment after `module;`, each behind its backend's `#ifdef`. Tests may `#include <cstdlib>`, `<cstring>` or `<cstddef>` for the macros `import std;` does not provide.
- Four-space indentation in new code. A few files have tab-indented blocks (`Semaphores` in `Window.cppm`, parts of `Window.cpp` and `VulkanBuffer.cppm`); match the block you are in and do not reindent. Eight files have no final newline; leave that alone too. There is no `.editorconfig` or `.gitattributes`.
- `PascalCase` for types, functions and statics, `camelCase` for members and variables, a trailing `_` on private members in the newer classes (`heap_`, `shapedRuns_`), `k`-prefixed constants (`kUIFlagClip`, `kSwapchainWriterStages`, `kRTGeometryInstanceForceOpaque`). Wayland and Win32 callbacks keep the protocol's `snake_case` names. Vulkan structs are filled with designated initialisers.
- Fixed-width types (`std::uint32_t`; `std::uint16_t` for slot indices); `int` only at C boundaries. `std::println` and `std::format`; `std::runtime_error` with a message naming the file or the field for anything a user can get wrong, `std::invalid_argument` for a precondition.
- Comments explain why: the failure mode, the constraint, the rejected alternative, the issue number (`#118`) on the Forgejo tracker. The codebase carries long comments on purpose, and the recent commit bodies have the voice. Keep them, and write them for new non-obvious code.
- GLSL: `#version 460`, `#extension GL_GOOGLE_include_directive : enable`, `#include "ui-shared.glsl"` (crafter-build puts `shaders/` on the include path through `cfg.buildFiles`), the `UI_HEAP_*` macros instead of `descriptor_heap`, `std430` structs mirrored by a C++ struct with a `static_assert` on its size. Examples ship a `.wgsl` beside every `.glsl`.
## Design rules
These were decided deliberately. Do not undo them without the maintainer's say-so.
1. **The target picks the backend, and there is no abstraction layer.** `project.cpp` defines exactly one of `CRAFTER_GRAPHICS_WINDOW_WAYLAND`, `CRAFTER_GRAPHICS_WINDOW_WIN32` and `CRAFTER_GRAPHICS_WINDOW_DOM` from the target triple; there is no runtime backend switch and no X11. `:GraphicsTypes` holds `using` aliases, not virtual interfaces. Where the two backends' APIs differ, portable code `#ifdef`s at the call site, as the examples do, rather than the library inventing a shared signature that is a no-op on one side.
2. **Every partition exists on every target.** crafter-build's dependency scanner does not respect `#ifdef` around an `import :X;`, so a partition file is never left out of the list; its body is gated instead, and the umbrella imports everything unconditionally. Backend-specific implementation units are chosen in `project.cpp`.
3. **One binding model in two builds.** On the heap path pipelines are created with `VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT`, no pipeline layout, and push data through `vkCmdPushDataEXT`. On a driver without the heap the same slots live in one bindless descriptor set per frame (bindings 0 to 3: storage images, sampled images, samplers, storage buffers) and push constants go through `vkCmdPushConstants`. Slot handles, item structs, `UIDispatchHeader` and the `UIRenderer` API mean the same in both. Every compute kernel is compiled twice from one source (`CRAFTER_GRAPHICS_DESCRIPTOR_SETS` selects the `UI_HEAP_*` macros; the variant is `sets`), `ComputeShader::Load` picks the file, and `UIFallbackParity` proves the two render identically. Push blocks stay within 256 bytes. Ray tracing is heap-only.
4. **The UI contracts are frozen.** `UIDispatchHeader` is 48 bytes and starts every UI push block; `QuadItem` is 64, `CircleItem` 48, `ImageItem` 64 and `GlyphItem` 48 bytes, byte for byte with `ui-shared.glsl` under `std430`. They grow only through the reserved `flags` bits and `_pad`. `UIFusedHeader` (128 bytes) is a separate contract for `DispatchFused`.
5. **Three tiers, and the source is the customisation API.** Tier 1 is `ComputeShader` and `UIRenderer::Dispatch`; Tier 2 the standard kernels, item structs and `UIRenderer` helpers; Tier 3 stateless functions that append items to user-owned buffers and never dispatch. No library `Theme` type (colours come as `*Colors` aggregates), no widget tree, no layout engine beyond `Rect::SubRect` and `Inset`, no hit-testing, no focus management; state lives in user-owned PODs (`InputField` is the model). A component that does not fit is copied and edited, not hooked: no virtual overrides, no extension points. Additive changes (another kernel, another stateless component) are fine.
6. **Standard kernels dispatch one workgroup per 8x8 screen tile**, each thread owning one pixel and iterating every item in array order into a local accumulator, stored once. Array order is draw order and there is no inter-workgroup race. Never go back to a workgroup per item: Vulkan orders nothing between the workgroups of one dispatch, and overlapping items then race on the image (striped fills, stale pixels). The cost is `O(W*H*N)` and documented as such; the shared-memory tile cull in `ui-shared.glsl` must stay a strict superset of the per-pixel test.
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image Window - CreateSwapchain asked for exactly numFrames (3) images whatever the surface said, read them into a three-element array, and keyed the command buffers, the fences and the descriptor-heap copy holding "its" swapchain image by the acquired image index. A swapchain of another size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a surface whose minimum is above three got a create info below it (#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on any Wayland compositor. - The present mode is picked first, then the swapchain is sized by SwapchainCapabilities: the surface's counts for that mode where the instance has surface_maintenance1. With swapchain_maintenance1 the swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which is what makes the mode's own count the one that applies, at creation and to the forward progress of a UINT64_MAX acquire; without it the stricter of the mode's and the surface's counts for all modes. It asks for numFrames or that minimum where more, never past the maximum (SwapchainMinImageCount). Turnip's FIFO keeps its three images and MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone. - images, imageViewInfos (was imageViews: they are create-infos), the made imageViews that descriptor sets take, imageInitialised and the render-finished semaphores are per image, as many as the driver made. The views are made once per swapchain. The semaphores only grow, since a present of the old swapchain may still wait on one. - Frames in flight stay numFrames, keyed by currentFrame, frameCounter % numFrames: the command buffer, the fence, the acquire semaphore, the heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is recorded with. currentImage is the acquired image. currentBuffer is gone, so code that used it stops compiling instead of picking the wrong one; RenderPass::Record's frameIdx keeps its name and is now the frame slot, as RenderPass.cppm says. - The frame's fence is waited before the acquire. The acquire semaphores need no spare then, and a frame waits for exactly the one numFrames before it, which the deletion queue and the staging rings count on and fences keyed by image did not promise once images come back out of order. The swapchain slots are written before the fence is reset. - From the acquire to the present the window is recording: a Resize or SetPresentMode from a listener (a VSync key ticked in onUpdate) takes effect after the present, through RebuildSwapchain, together with a present that found the swapchain out of date. Rebuilding mid-frame used to destroy the image the frame was drawing into; with images sized per swapchain it would also index past the new one's end. DescriptorHeapVulkan - Frames and images pair up differently from frame to frame, so no heap copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists an image slot as that window's swapchain image, and Window::Render writes the image a frame acquired into its window's listed slots in that frame's copy, or its set, before onUpdate, skipping a slot that holds it already. A slot is one window's, so two windows can share a heap without writing each other's slots under each other's frames in flight. A slot listed during a frame its window records is written at once. A slot past the heap's image slots, an empty ImageSlot's 0xFFFF among them, is refused. Freeing a slot unlists it, as does initialising the heap in either binding model. WriteImageSet, the one use of which this replaces, is gone, and with it the per-frame dimension of setImageViews. Device - VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2 are enabled where the loader has them (surfaceMaintenance1Supported, vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching swapchain_maintenance1 with its feature where the device has it (swapchainMaintenance1Supported). - DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE numbers them, for ChooseGpu and tests. UI - UIRenderer lists its output slot instead of writing every copy itself, and again on every resize; the resize listener is gone on both backends. The headers' frameIdx is currentFrame. Examples - VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot and write only the per-frame TLASes. They wrote imageViews[f] into copy f once, which a resize left pointing at destroyed images. Tests - SwapchainImageCount: SwapchainMinImageCount on the numbers drivers report; every offered mode keeps its mode and gets what the window asks for; every frame presents the colour it drew, read back a texel per frame; a switch and a resize asked for during a frame wait for its present; a slot listed during a frame holds its image at once; two windows on one heap draw their own colours. The frame checks run again in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose swapchains have more images than frames (this one, else llvmpipe), in each binding model the parent didn't cover; a child skips only for a GPU that can't run the library, and checks it runs the model it was asked for. - FrameLoopSync checks the frame slot follows the frame counter and counts the swapchain's own images. HeapASRead, HeapDynamicIndexHit and RTPassPushData list their swapchain slot and drop their resize rewrites. Docs - README, AGENTS.md and AGENTS.consumer.md: frames and images, the swapchain slots, rebuilds held to the present, the new optional groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:51:18 +01:00
7. **Three frames in flight, a fence per frame, no steady-state wait-idle.** Frames in flight are not swapchain images: the swapchain has as many images as its present mode needs, what lives per frame (command buffer, fence, acquire semaphore, heap copy, TLAS) is keyed by `Window::currentFrame` and what lives per image by `currentImage`, and the image a frame draws into reaches shaders through the heap's swapchain slots (`DescriptorHeapVulkan::AddSwapchainSlot`, #160). The owners of buffers, acceleration structures, shader modules and pipelines (`VulkanBuffer`, `Mesh`, `TlasWithBuffer`, `VulkanShader`, `ComputeShader`, `PipelineRTVulkan`) release them through `Device::EnqueueDeletion`, which has overloads for exactly those handle kinds; `ReclaimDeletions` frees an entry `framesInFlight` frames later and `DrainDeletions` after a wait-idle. Owners of handles the queue has no overload for wait for the GPU first and then destroy directly: `~DescriptorHeapVulkan` in descriptor-set mode, `~Window`, `~FontAtlas` through `ImageVulkan::Destroy`. A destructor never destroys a handle without one or the other: a frame in flight may still use it, and a static owner of a queued kind is destroyed after the driver has gone, which only the queue survives. `Clear()` is for a GPU known to be idle. The deletion queue, its mutex and `RenderingElement3D::tlases` are leaked on purpose for the same reason. Staged re-uploads go through the per-frame staging ring in `VulkanBuffer::Stage`.
8. **The library enables only the device features its own shaders use.** Everything an application's shaders need (`shaderInt16` for a `uint16_t` spec constant, fp16, 16-bit storage, `scalarBlockLayout`) is the application's to ask for through `Device::Initialize(required, optional)`, and `VulkanShader` refuses a module whose capabilities nothing enabled, naming the field. Do not add a feature to the `library` set for an example's convenience; have the example ask.
9. **Barriers are scoped and derived.** A `RenderPass` reports its `SwapchainStage()`; the frame loop's acquire, inter-pass and present barriers take their stage and access masks from the passes (`SwapchainStageUnion`, `SwapchainWriterAccess`, `BuildSwapchainInterPassBarrier`), masked by `Device::stageMask` so the RT stage never appears on a device without it. In the frame loop there is no `ALL_COMMANDS` and no queue-wide memory barrier, and a ranged flush rounds to `nonCoherentAtomSize`. The acceleration-structure builds and the GPU decompression do use global `VkMemoryBarrier`s on purpose (`BuildTlasWriteBarrier`, `BuildTlasReadBarrier`, `Decompress::DecompressOnGPU`): execution order alone never makes a build's writes visible to the shaders that read them. The window never clears the swapchain image (`TRANSFER_DST` is not on every surface); passes draw their own background.
10. **Driver workarounds are fenced and written to be deleted.** The NVIDIA descriptor-heap AS-read rewrite is one block in `ShaderVulkan.cppm` gated on `Device::workaroundDescriptorHeapAS`, with its push-data hook in `RTPass` and `ComputeShader`, a kill switch (`CRAFTER_GRAPHICS_NO_AS_WORKAROUND`), a probe test and a status note in the README and `examples/VulkanTriangle/README.md`. A new workaround gets the same: a `Device` flag, one fenced block, a probe, a note. A fault the library cannot hide (dynamic descriptor indexing in hit shaders) is documented with the consumer-side pattern instead (`examples/Sponza/README.md`).
11. **Prefer the clean breaking change.** When a fix can be a backward-compatible patch or a cleaner API change, make the API change and update the examples and tests in the same commit; the consumers are in this tree or the maintainer's. Do not stage a compat shim "for now".
12. **`lib/` is vendored and untouched.** libevdev is built as its own static library configuration so it compiles once, not once per test; its include path and the kernel headers are absolute because this `project.cpp` is also loaded from a consumer's directory.
## Commit and PR guidelines
- Branch from `master`; PRs go to `master` on `forgejo.catcrafts.net/Catcrafts/Crafter.Graphics`. Agent branches are `claude/<topic>`, issue work `claude/issue-<n>`. The fetch URL is HTTPS and the push URL SSH (`forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Graphics.git`); the server refuses HTTPS pushes.
2026-11-02 14:57:39 +01:00
- Subject: `type(scope): imperative summary`, with `feat`, `fix`, `perf`, `refactor`, `docs` or `test` and the area as the scope (`device`, `rt`, `ui`, `window`, `wayland`, `win32`, `webgpu`, `font-atlas`, `compute`, `gamepad`). The body is grouped under the components it touches (`Device`, `Shaders`, `Window`, `Tests`) and says what was wrong, why, and why the fix has this shape; `git log -3` shows the voice.
- Update the README (and the example README that holds the investigation) in the same change when user-visible behaviour changes, and `AGENTS.consumer.md` when a convention, a signature, a contract or an environment variable consumers lean on changes. Consumers pick the new notes up on their next build.
- Crafter.Event, Crafter.Math and Crafter.Asset are pulled unpinned, so a change that needs one of them lands there first and is built here with `--local` meanwhile. Pushing is per repository: land the sibling, then this one.
2026-11-02 14:57:39 +01:00
- Every push of new commits goes through Crafter.Review's pre-push review first, and goes out only once the owner approves the frozen hash. Run `flatpak run net.catcrafts.CrafterReview init` once in a fresh checkout or worktree: it writes the `/prepush` skill that drives the review, and the skill stays untracked. There is no CI, so the review's gate0, the commands in `.review-gates/config.json`, is the check before the push: the "Before a change is done" checks above, with every example built, and `node --check` on the two JS files in `additional/`. `freeze` refuses merge commits, so the merge of a reviewed branch into `master` is pushed without a freeze of its own.
- Every commit of a pushed range is GPG-signed, has `jorijnvdgraaf@catcrafts.net` as its author and committer, and carries no AI attribution trailer in its message, neither `Co-Authored-By:` nor `Assisted-by:`. `freeze` refuses a range with a commit that breaks any of these (`requireSigned`, `authors` and `aiTrailers` in `.review-gates/config.json`).
- Never commit `bin/`, `build/` or `pipeline_cache.bin`, and never edit `lib/`.
- A second worktree of this repository may sit beside it (`git worktree list`); its branch shows with a `+` in `git branch`. Leave it to whoever opened it.
## Security considerations
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links Build - The MinGW toolchains ship no import library for vulkan-1.dll, so without a Vulkan SDK's every Win32 executable stopped at "unable to find library -lvulkan-1". project.cpp now makes one with the llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's loader/vulkan-1.def. That is the loader's own list of what vulkan-1.dll exports, fetched with GitFetch, unpinned like Vulkan-Headers, so the API the library compiles against and the exports it links against move together. The import library the Wine runs used, made from the calls the library happened to make, went stale with the next new call (234fcce added vkEnumerateInstanceExtensionProperties). The loader's list does not, and a call it does not export fails at link time instead of when the exe starts. - It is made into build/vulkan-1-<target>/ when a stamp beside it, holding the command and the .def it was made from, no longer matches; a fetch or a copy says nothing through the .def's mtime. It goes into the link by its absolute path rather than through -L, so it shadows no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run is a Progress::Diagnostic warning, and the link falls back to the library a previous load made, or to -lvulkan-1. - -luuid joins the Win32 link flags: the gamepad code compares against IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls it into every executable (#184). - project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which is now the minimum. Tests - Win32Link: an executable that uses the library as an application does (Device::Initialize, a Window, StartSync) links. Without Wine it runs as --runner=cmd:true, which builds it and runs nothing; under Wine it also opens a window and closes it. It fails to link without -luuid (IID_IUnknown, IID_IAgileObject) and without the import library ("unable to find library -lvulkan-1"). Under the Flatpak it needs tests/Win32Link/libc-single-threaded.c, which stands in for Catcrafts/Crafter.Build#43 and goes with it. - With the native crafter-build on the host, which has Wine and finds the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now build and pass under Wine with nothing added. Docs - AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what project.cpp runs, the Win32Link check in the commands and the done-list, and a one-line Wine recipe in place of the hand-built import library, shim and project.cpp edit. AGENTS.consumer.md: the minimum, how the import library is made, and the __libc_single_threaded workaround under the Flatpak. README: the minimum, the Win32 build command, and that it needs no Vulkan SDK. Closes #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00
- `project.cpp` is compiled and run by crafter-build; it clones and pulls three sibling repositories from their default branch and two Khronos repositories from GitHub on every invocation, for a Win32 target also clones and pulls Vulkan-Loader, unpinned, and runs `llvm-dlltool` on its `loader/vulkan-1.def` when the import library under `build/` was made from another `.def` or command, and the Sponza example `GitFetch`es a 280 MB asset repository (pinned to a commit). Pin a `commit` in a `GitProject` spec when that matters.
- The validation layer, GPU-assisted validation included, is a developer tool and off by default; `CRAFTER_GRAPHICS_VALIDATION` turns it on. GPU-AV has crashed a vendor SPIR-V compiler on the heap pipelines before, which is why it is opt-in.
- Shaders are read from disk relative to the working directory and checked for the SPIR-V magic and word alignment before they reach the driver; the NVIDIA rewrite edits SPIR-V in memory and `PushConstantRewrite` validates its output with spirv-val.
- `pipeline_cache.bin` is written to the working directory at exit and read back only when its header matches the device; it is gitignored here and should be in a consumer.
- The environment switches (`CRAFTER_GRAPHICS_VALIDATION`, `CRAFTER_GRAPHICS_DEVICE`, `CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP`, `CRAFTER_GRAPHICS_NO_AS_WORKAROUND`, `CRAFTER_GRAPHICS_NO_VULKAN_1_4`, `CRAFTER_PRESENT_IMMEDIATE`) are read with `std::getenv` in `Device.cpp` and `Window.cpp`. A new one takes the `CRAFTER_GRAPHICS_` prefix, means "set to any value", and is listed in the README and in `AGENTS.consumer.md`.
- `dom-env.js` and `dom-webgpu.js` are shipped to browsers as they are. `dom-webgpu.js` replaces `document.body` with an error when `navigator.gpu` is absent, which is why `--no-webgpu` exists for DOM-only pages.
- Tests write only under `fs::temp_directory_path()` and their own bin directory.
## Gotchas
- A new partition is three edits: the interface file, the `ifaces` array in `project.cpp` (a `std::array` with a literal size, so bump it) and the umbrella `Crafter.Graphics.cppm`. An implementation unit goes in `impls` (native), `domImpls` (DOM) or both, again with literal sizes; a test's sources are built from `impls`, so a native unit missing there fails every test link.
- `Device::Initialize` on the Wayland backend connects to the display and throws without one; "headless" tests still need a compositor.
- Buffer slot indices: `AllocateBufferSlots` returns an index relative to the heap's buffer region, while GLSL `descriptor_heap` SSBO arrays index in buffer-descriptor units from heap byte 0. `BufferSlot` converts to `bufferStartElement + raw`; raw code (the RT examples) passes `heap.bufferStartElement` as a `uint16_t` spec constant, which needs `shaderInt16`. In descriptor-set mode `bufferStartElement` is 0. Image and sampler slots are used as they are. A wrong index reads zeros, which looks like "every item field is zero".
- `UIRenderer::RegisterBuffer` refuses a buffer created without `VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT`. Freed slots are handed out again at once, so a slot may only be freed when no frame in flight reads it (after `vkQueueWaitIdle`, or at a scene switch). In descriptor-set mode a slot write destroys the previous view immediately.
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image Window - CreateSwapchain asked for exactly numFrames (3) images whatever the surface said, read them into a three-element array, and keyed the command buffers, the fences and the descriptor-heap copy holding "its" swapchain image by the acquired image index. A swapchain of another size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a surface whose minimum is above three got a create info below it (#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on any Wayland compositor. - The present mode is picked first, then the swapchain is sized by SwapchainCapabilities: the surface's counts for that mode where the instance has surface_maintenance1. With swapchain_maintenance1 the swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which is what makes the mode's own count the one that applies, at creation and to the forward progress of a UINT64_MAX acquire; without it the stricter of the mode's and the surface's counts for all modes. It asks for numFrames or that minimum where more, never past the maximum (SwapchainMinImageCount). Turnip's FIFO keeps its three images and MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone. - images, imageViewInfos (was imageViews: they are create-infos), the made imageViews that descriptor sets take, imageInitialised and the render-finished semaphores are per image, as many as the driver made. The views are made once per swapchain. The semaphores only grow, since a present of the old swapchain may still wait on one. - Frames in flight stay numFrames, keyed by currentFrame, frameCounter % numFrames: the command buffer, the fence, the acquire semaphore, the heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is recorded with. currentImage is the acquired image. currentBuffer is gone, so code that used it stops compiling instead of picking the wrong one; RenderPass::Record's frameIdx keeps its name and is now the frame slot, as RenderPass.cppm says. - The frame's fence is waited before the acquire. The acquire semaphores need no spare then, and a frame waits for exactly the one numFrames before it, which the deletion queue and the staging rings count on and fences keyed by image did not promise once images come back out of order. The swapchain slots are written before the fence is reset. - From the acquire to the present the window is recording: a Resize or SetPresentMode from a listener (a VSync key ticked in onUpdate) takes effect after the present, through RebuildSwapchain, together with a present that found the swapchain out of date. Rebuilding mid-frame used to destroy the image the frame was drawing into; with images sized per swapchain it would also index past the new one's end. DescriptorHeapVulkan - Frames and images pair up differently from frame to frame, so no heap copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists an image slot as that window's swapchain image, and Window::Render writes the image a frame acquired into its window's listed slots in that frame's copy, or its set, before onUpdate, skipping a slot that holds it already. A slot is one window's, so two windows can share a heap without writing each other's slots under each other's frames in flight. A slot listed during a frame its window records is written at once. A slot past the heap's image slots, an empty ImageSlot's 0xFFFF among them, is refused. Freeing a slot unlists it, as does initialising the heap in either binding model. WriteImageSet, the one use of which this replaces, is gone, and with it the per-frame dimension of setImageViews. Device - VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2 are enabled where the loader has them (surfaceMaintenance1Supported, vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching swapchain_maintenance1 with its feature where the device has it (swapchainMaintenance1Supported). - DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE numbers them, for ChooseGpu and tests. UI - UIRenderer lists its output slot instead of writing every copy itself, and again on every resize; the resize listener is gone on both backends. The headers' frameIdx is currentFrame. Examples - VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot and write only the per-frame TLASes. They wrote imageViews[f] into copy f once, which a resize left pointing at destroyed images. Tests - SwapchainImageCount: SwapchainMinImageCount on the numbers drivers report; every offered mode keeps its mode and gets what the window asks for; every frame presents the colour it drew, read back a texel per frame; a switch and a resize asked for during a frame wait for its present; a slot listed during a frame holds its image at once; two windows on one heap draw their own colours. The frame checks run again in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose swapchains have more images than frames (this one, else llvmpipe), in each binding model the parent didn't cover; a child skips only for a GPU that can't run the library, and checks it runs the model it was asked for. - FrameLoopSync checks the frame slot follows the frame counter and counts the swapchain's own images. HeapASRead, HeapDynamicIndexHit and RTPassPushData list their swapchain slot and drop their resize rewrites. Docs - README, AGENTS.md and AGENTS.consumer.md: frames and images, the swapchain slots, rebuilds held to the present, the new optional groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:51:18 +01:00
- The swapchain image stays in `VK_IMAGE_LAYOUT_GENERAL` across the pass list and is never cleared; `Window::clearColor` is unused. `swapchainUsage` says whether `TRANSFER_DST` was available. `onResize` fires after a swapchain rebuild; slots listed with `AddSwapchainSlot` follow by themselves, and anything else holding the old images is made again there. A rebuild asked for during a frame (`Resize` or `SetPresentMode` from a listener) waits for that frame's present, since the rest of the frame indexes the swapchain it acquired from. `RenderPass::Record`'s `frameIdx` is the frame slot, not the image.
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
- `Device::frameCounter` is one clock for every window, so the deletion queue and the staging rings assume one rendering window. The font atlas evicts on it too, on both backends: without a `Window` advancing it nothing is ever evicted, and a full atlas leaves new glyphs blank, so a headless test steps it itself (`FontAtlasEviction`).
- `ImageVulkan::Create`'s default `consumerStage` is the RT stage, corrected to compute on a device without ray tracing; the font atlas passes compute explicitly. `VulkanTransition`'s `GENERAL` mapping does the same.
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
- `UIRenderer::ShapeText` and `FontAtlas` key their caches on the `Font*`: call `UIRenderer::InvalidateFont`, which clears both, before destroying a `Font`, or a new one at the same address aliases stale runs and glyphs. The atlas also keeps the `Font*` of a glyph waiting for a cell until the next frame's first call, where it rasterises from it.
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image Window - CreateSwapchain asked for exactly numFrames (3) images whatever the surface said, read them into a three-element array, and keyed the command buffers, the fences and the descriptor-heap copy holding "its" swapchain image by the acquired image index. A swapchain of another size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a surface whose minimum is above three got a create info below it (#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on any Wayland compositor. - The present mode is picked first, then the swapchain is sized by SwapchainCapabilities: the surface's counts for that mode where the instance has surface_maintenance1. With swapchain_maintenance1 the swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which is what makes the mode's own count the one that applies, at creation and to the forward progress of a UINT64_MAX acquire; without it the stricter of the mode's and the surface's counts for all modes. It asks for numFrames or that minimum where more, never past the maximum (SwapchainMinImageCount). Turnip's FIFO keeps its three images and MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone. - images, imageViewInfos (was imageViews: they are create-infos), the made imageViews that descriptor sets take, imageInitialised and the render-finished semaphores are per image, as many as the driver made. The views are made once per swapchain. The semaphores only grow, since a present of the old swapchain may still wait on one. - Frames in flight stay numFrames, keyed by currentFrame, frameCounter % numFrames: the command buffer, the fence, the acquire semaphore, the heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is recorded with. currentImage is the acquired image. currentBuffer is gone, so code that used it stops compiling instead of picking the wrong one; RenderPass::Record's frameIdx keeps its name and is now the frame slot, as RenderPass.cppm says. - The frame's fence is waited before the acquire. The acquire semaphores need no spare then, and a frame waits for exactly the one numFrames before it, which the deletion queue and the staging rings count on and fences keyed by image did not promise once images come back out of order. The swapchain slots are written before the fence is reset. - From the acquire to the present the window is recording: a Resize or SetPresentMode from a listener (a VSync key ticked in onUpdate) takes effect after the present, through RebuildSwapchain, together with a present that found the swapchain out of date. Rebuilding mid-frame used to destroy the image the frame was drawing into; with images sized per swapchain it would also index past the new one's end. DescriptorHeapVulkan - Frames and images pair up differently from frame to frame, so no heap copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists an image slot as that window's swapchain image, and Window::Render writes the image a frame acquired into its window's listed slots in that frame's copy, or its set, before onUpdate, skipping a slot that holds it already. A slot is one window's, so two windows can share a heap without writing each other's slots under each other's frames in flight. A slot listed during a frame its window records is written at once. A slot past the heap's image slots, an empty ImageSlot's 0xFFFF among them, is refused. Freeing a slot unlists it, as does initialising the heap in either binding model. WriteImageSet, the one use of which this replaces, is gone, and with it the per-frame dimension of setImageViews. Device - VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2 are enabled where the loader has them (surfaceMaintenance1Supported, vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching swapchain_maintenance1 with its feature where the device has it (swapchainMaintenance1Supported). - DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE numbers them, for ChooseGpu and tests. UI - UIRenderer lists its output slot instead of writing every copy itself, and again on every resize; the resize listener is gone on both backends. The headers' frameIdx is currentFrame. Examples - VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot and write only the per-frame TLASes. They wrote imageViews[f] into copy f once, which a resize left pointing at destroyed images. Tests - SwapchainImageCount: SwapchainMinImageCount on the numbers drivers report; every offered mode keeps its mode and gets what the window asks for; every frame presents the colour it drew, read back a texel per frame; a switch and a resize asked for during a frame wait for its present; a slot listed during a frame holds its image at once; two windows on one heap draw their own colours. The frame checks run again in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose swapchains have more images than frames (this one, else llvmpipe), in each binding model the parent didn't cover; a child skips only for a GPU that can't run the library, and checks it runs the model it was asked for. - FrameLoopSync checks the frame slot follows the frame counter and counts the swapchain's own images. HeapASRead, HeapDynamicIndexHit and RTPassPushData list their swapchain slot and drop their resize rewrites. Docs - README, AGENTS.md and AGENTS.consumer.md: frames and images, the swapchain slots, rebuilds held to the present, the new optional groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:51:18 +01:00
- `RenderingElement3D::elements` stores raw pointers; a `std::vector<RenderingElement3D>` that grows after `Add` dangles them (Sponza reserves up front). `BuildTLAS` on an index whose earlier submission has not completed overwrites a TLAS in use; building `tlases[window.currentFrame]` from `onUpdate` is safe, and `tlases[i].address` changes when a TLAS outgrows its storage.
- `PipelineRTVulkan::Init` defaults `maxRecursionDepth` to 1; a closest-hit that traces needs 2, and tracing deeper than the pipeline allows loses the device.
- `BuildTlasWriteBarrier`'s source access mask carries no read bit on purpose: synchronization validation 1.4.363 otherwise reports two builds in one command buffer as a write-after-write hazard.
- NVIDIA, as of driver `615.78.08`: reading a TLAS through the heap still faults (worked around); dynamic descriptor indexing in hit shaders works there and faulted on `610.43.02`, so the Sponza pattern (a spec-constant slot, a dynamic index within the resource) stays the portable one. `HeapDynamicIndexHit` skips on the older drivers.
- The README links `WAVEFRONT-DESIGN.md`, which was removed with the stale docs (`15ce47f`); the wavefront tracer is described in the README's WebGPU section and in `additional/dom-webgpu.js`.
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
- The text kernels decode `FontAtlas`'s SDF in one place, `ui-shared.glsl` (`UI_SDF_ON_EDGE`, `UI_SDF_PIXEL_DIST_SCALE`, `uiGlyphRamp`, `uiGlyphCoverage`), and the WGSL text kernel in `dom-webgpu.js` repeats it; `FontAtlasDecode` holds both copies of the constants to `FontAtlas`'s, and nothing renders the WGSL, so check a change to it by hand (`examples/HelloUI` built with `--target=wasm32-wasip1`, in a browser, or the kernel run through Deno's WebGPU). The WGSL lives in `String.raw` literals: a backtick in a WGSL comment ends the literal and breaks the whole backend, which only `node --check additional/dom-webgpu.js` notices.
- Crafter.Math's misspelled identifiers (`Quanternion*`, `MulitplyAdd`, `CollumSize`) are its API; do not correct them here.
2026-10-31 20:40:16 +01:00
- Dependency notes: crafter-build 3.0.0 or newer keeps a section at the end of this file holding the `AGENTS.consumer.md` of every dependency that ships one (2.2.0 leaves the file alone), rewritten on every `crafter-build` and `crafter-build test`. As of that date Crafter.Math's notes are on its `master`, Crafter.Asset's only on its `claude/glb-loader` branch, and Crafter.Event has none, so a plain build adds Math's and a `--local` build whatever the sibling checkouts hold. Edits inside the section's markers are lost on the next build; a change to those notes belongs in their repositories. Commit the section when it changes.