# C++ Renderer Improvement Campaign (2026-07)

Working log of the "fix all" pass over `Src/GraphicsEngineVulkan`, driven by
a three-way analysis (architecture/complexity, testing, performance). Each
unit ships only after: container debug build green, the full commit suite
(72 tests at campaign start, 697 as of 2026-10-01), and a
validation-layer-clean runtime check where rendering changed.
<!-- commit-suite-test-count: 705 -->

## Shipped

| Commit | Unit | Notes |
| --- | --- | --- |
| `675e5ee0` | **Consume the cascaded shadow map** | The CSM pass rendered 3×2048² depth layers per frame that no compiled shader sampled. Forward lighting now selects a cascade by distance + PCF (GUI slider) + `cascaded_shadow_intensity`. Same commit: `GLM_FORCE_DEPTH_ZERO_TO_ONE` moved from App.cpp (no projection math) to the engine target — camera/CSM projections were using [-1,1] depth in a [0,1] API; GUI cascade default synced to `MAX_CASCADES` |
| `c2be278e` | **Real fuzzing + CI + ASan presets** | `ObjLoader::loadVertices` bounds-hardened (negative/out-of-range indices → OOB reads; `exit()` on parse error killed the app on GUI model reload). `obj_parsing_fuzz_test` mirrors the walk; 60 s coverage-guided ASan fuzzing, zero findings. Stale `RendererTest.BasicSetup` ctest filter removed; fuzz target added to Linux CI; `x64-ClangCL-Windows-Debug-ASan` presets added (matrix had TSan only) |
| `1a088886` | **Fail-fast `ASSERT_VULKAN`** | Was log-and-continue → null-handle UB later. Exceptions are disabled project-wide (`/EHs-`, `VULKAN_HPP_NO_EXCEPTIONS`), so the macro now logs critical + `abort()`. All 41 call sites audited: creation/allocation only |
| `cb5989fb` | **Draw-loop perf** | Invariant descriptor set bound once instead of per mesh; per-draw `std::vector<vk::Buffer>` heap churn removed (rasterizer + all CSM cascade loops) |
| `cab3fa08` | **Deferred-path shadows** | Shared `common/cascaded_shadow.glsl`; both lighting paths use the same cascades |
| `f94a0b47` | **Pipeline cache** | Persisted in `VulkanDevice`, all pipeline sites consume it; two-run verified (saves on graceful exit only) |
| `325a7fb9` (folded in) | **RAII leaf types** | `VulkanBuffer`/`VulkanImage` became move-only with destructor release during the VMA rewrite; verified via full teardown tests + clean exit |
| `70c2da4b` | **Staging ring + fence-synced uploads** | Per-upload `queue.waitIdle()` → per-submit fence; one persistent mapped staging buffer with geometric growth |
| `325a7fb9` | **VMA adoption** | Allocator moved into `VulkanDevice`; `VulkanBuffer`/`VulkanImage` on `vmaCreateBuffer/Image`; persistently mapped UBOs via `MAPPED_BIT`; RT device addresses intact. Verified incl. RT/path-trace GPU tests |
| `5f07a019` | **glTF strip/fan triangulation** | The primitive filter dropped any TRIANGLE_STRIP/FAN mesh entirely. Both now triangulate into the list (strips alternate winding, fans share vertex 0; points/lines still skipped); material ids per emitted triangle. CPU test: 4-vertex strip -> 2 triangles (was an empty parse). 98/98, validation-clean |
| `1b382036` | **glTF flat normals when NORMAL is absent** | A normal-less glTF defaulted every vertex to (0,1,0) - lit as if all faces pointed up. Now per-triangle geometric flat normals from world positions (OBJ-path parity), degenerate triangles skipped. CPU red/green on an XY-plane triangle (true normal +/-Z). 97/97, validation-clean |
| `39698f7e` | **Swapchain recreate: oldSwapchain handoff + checked create** | recreate destroyed everything then created with oldSwapchain=nullptr (no image recycling); createSwapchainKHR's result was unchecked (null handle -> UB on resize/surface-lost races). Now the old handle survives as the handoff and is destroyed after the new one; ASSERT_VULKAN on the result. Surface-lost was already routed to fatal. Resize path not headless-testable; init + result check validation-clean every launch. 96/96 |
| `7923ffeb` | **glTF input-surface hardening + fuzz target** | GltfLoader trusted cgltf on GUI-picked files: buffer-view offset+size unchecked vs buffer->size (OOB read), base64 length maths underflowed for sub-quad URIs (~SIZE_MAX read), cgltf_validate never called. All three fixed; new self-contained gltf_parsing_fuzz_test in both CI lanes + two CPU regression tests. 96/96, validation-clean |
| `07631be3` | **Single-pass multiview cascades** | One render pass with a cascade view mask + full-array framebuffer replaces 3 passes and a pass-through geometry shader (deleted); gl_ViewIndex picks the matrix in the VS; union caster culling keeps the per-cascade test's safety property. Coverage identical (12.276% vs 12.267%); ShadowCascades 0.0477 -> 0.0408 ms (-14%). 94/94, validation-clean |
| `6b423fbf` | **White-furnace golden** | KATAGLYPHIS_PT_FURNACE debug mode (uniform env + albedo 1); an unbiased estimator converges every pixel to exactly tonemap(env) = 186 regardless of geometry. Green: 186.005 mean, uniformity 1.0. Reds: spurious bounce 1/pi -> 136.9/0.25 (fires); RR-reweight removal -> 185.54 (real, sub-threshold on an open scene, documented not chased). Probes were edit-and-run shader cycles - runtime glslc paying off same-day. 94/94, validation-clean |
| `61df6ddf` | **Kompute playground gated OFF by default** | The demo linked kompute with exceptions enabled in every build - and can never conform (kompute headers throw; -fno-exceptions is a compile error there). KATAGLYPHIS_BUILD_KOMPUTE_PLAYGROUND=OFF wraps the target AND kompute's configure/subdir; the version-resolution machinery survives for the ON path. Gated configure: zero kompute lines. 93/93 |
| `e18dd19f` | **sRGB texture views** | PNG/JPG bytes are sRGB-encoded; UNORM views fed gamma-space values into linear lighting with post's encode on top. eR8G8B8A8Srgb for uploads + default; hardware decodes at sample time, mips filter in linear. A/B census: 16.5k pixels shift (3.5%, exactly the textured content), max delta 243 - whole-frame means were blind to it. Parity held 0.20. 93/93, validation-clean |
| `cfca331b` | **glTF from the GUI + reloadModel hardening** | The picker scan was == ".obj" (bundled cube.glb unpickable); reloadModel force-fed every file to the OBJ parser; add_model dereferenced null on failed loads. All three fixed; the listing-consistency test had encoded OBJ-only as its contract and now asserts the loadable set. Red proofs: list test fails "OBJ-only again" on the old scan; the null test dies with SEH 0xC0000005 on the old binary. 93/93 |
| `3b90b9b8` | **Deferred: world-position G-buffer target removed** | The largest G-buffer attachment (full-res rgba16f position) stored what depth already encodes; the lighting subpass reconstructs from the depth input attachment (inv_view * inv_proj * NDC, divide). Renumbered the whole pass (6->5 attachments, 4->3 outputs, 5->4 inputs/bindings); globalUBO now fragment-visible. Parity 0.200 vs 0.205 before - the reconstruction agrees better than the paths already disagreed. Main 0.0647 -> 0.0639 ms (scene too small for bandwidth to register - the claim is structural). 91/91, validation-clean |
| `fb6f53f9` | **Runtime glslc resolution + loud compile failures** | The baked container glslc path made host runtime compilation a silent no-op (system() unchecked) - the root of the spv-staleness saga. Resolves at call time (baked -> VULKAN_SDK/Bin -> PATH); nonzero exits log the stale spv being served. Proven: touch a kernel, run a golden, the engine regenerates the spv mid-run. GLSL iteration no longer needs manual compile-shaders.ps1. 91/91, validation-clean |
| `126ccc9c` | **Multi-model textures + untextured-mtl Kd fix** | The shared texture array bound model 0's textures only - added models' local IDs collided with them. Per-model texture_offset in ObjectDescription + 4 shader sites + flattened binding. En route: materials WITHOUT map_Kd got textureID 0 (not -1) - the bundled dinosaur rendered its Kd colours as flat white for the engine's whole life; loader fixed, CPU red/green on the shipped asset. Golden oracle earned: colour-spread was blind twice (dino ships no textures; sponza bricks near-greyscale) - texture-DETAIL fraction works: 0.045 green vs exactly 0 on the old binding, wall pinned at 17% of crop by a forced-red probe first. ABI note: growing the shared struct needed the fresh-container sequence (suite exit 3). 91/91, validation-clean |
| `81799ea2` | **PT estimator: 1/pi on NEE** | The throughput carries albedo, so NEE must carry the Lambertian 1/pi - directly lit surfaces were pi-times hot vs the sky transport and vs forward. Bounce path needed NOTHING (cosine sampling of a Lambertian cancels pi and cosine exactly - the survey's "no PDF division" was wrong for that half). Measured: rig lit crop 208.5 -> 188.2 (forward: 158.1; gap = PT's indirect sky). Furnace golden folded into the PT-goldens remainder (wants a uniform-env toggle). 89/89, validation-clean |
| `91269849` | **Traced world follows GUI transforms** | The transform path updated object descriptions but never the AS - RT/PT rendered the old pose forever. TLAS-only rebuild (BLAS untouched) before the descriptor update, which binds the new handle + resets PT accumulation. Golden: move swings 0.397 of the crop; stale-TLAS red is exactly 0 (RT is deterministic). Build-hygiene note: restoring a file with PowerShell Copy-Item preserves the SOURCE mtime and ninja skips the recompile - the "restored" binary still ran red code until a manual touch; bash cp (no -p) does not have this trap. 89/89, validation-clean |
| `35472bb2` | **Materials reach the raster paths + loader OOB fix** | Forward hard-coded roughness 0.9 + clamp-to-slot-0 for untextured materials; deferred's lighting pass "read" a material channel its own geometry pass hard-coded to 0.9. Both now: diffuse fallback + shininess->roughness (Beckmann), parity-guarded (0.205 vs 1.0). Underneath: tinyobj's -1 no-material face id cast to uint32_t made EVERY material fetch on EVERY untextured model an out-of-bounds buffer-device-address read - fixed in ObjLoader, CPU red/green test fails on the old loader with the shipped rig. Chain proven live: rig lit luminance 166.9 -> 158.1 when the default 0.7 diffuse landed. 88/88, validation-clean |
| `b513d010` | **The GUI light reaches diffuse + HDR offscreen** | Every BRDF in pbr/brdf multiplied light color/intensity into SPECULAR only - diffuse ran on an implicit radiance-1 white light across forward, deferred AND RT (all share the BRDFs); rchit additionally hard-coded the light entirely. Fixed in all five; paired with FP16 offscreen targets in the same unit because diffuse x radiance-10 exceeds 1.0 everywhere and UNORM clamped it flat at the 186 ceiling (the 07-21 HDR null result predicted exactly this pairing). Measured: PT light golden 0.027 -> 0.751 (the ceiling hid 3/4 of the response), deferred parity IMPROVED to 0.129, shadows 12.3% vs 4% floor. New forward golden whose instrument note matters: swung-pixel counts DO NOT discriminate (specular alone swings 0.41 - red PASSED it); the load-bearing assert is unlit-crop luminance 18.9 green vs 97.6 red. Rider: the shared-include freshness guard now skips generated/ naga exports - and it caught the include-blind mtime guard mid-unit. 87/87, validation-clean |
| `68ac8866` | **PT quality controls + Russian roulette** | spp and bounce cap were hardcoded 8/8 (stale comments claimed 64/32). GUI sliders -> push constants -> kernel loop bounds; RR from the 4th segment (unbiased, survivors reweighted); slider changes reset the accumulation history (mean over two estimators is biased). Golden: bounces 8-vs-1 swings 13.2% of the panel-free crop (all indirect sky light vanishes at 1 bounce); the hardcoded-bounds red kernel measures 7.3e-5 and fails. 86/86, validation-clean |
| `3a885f47` | **PT light transport: NEE + gradient sky** | The mode's only light was an accidental radiance-1 furnace (env term commented out on miss) - the GUI light did nothing in PT. Now: per-bounce NEE shadow ray toward the directional light + soft gradient sky at half intensity. Golden on the shadow rig: swung-pixel fraction between radiance 10 and 0 in the panel-free crop - green 0.027, pre-NEE kernel EXACTLY 0 (red proven by checking out the committed kernel + recompiling). Three instruments measured before one worked: whole-crop mean luminance saw 0.057 on the skeleton (crop nearly all sky) and 0.29 on the rig (lit plane CLAMPS at the 186 UNORM ceiling + the responding band hugged the panel edge at 0.714w, outside the 0.75w crop - found by pixel-censusing the lit/unlit dumps: 4556 pixels swing, max delta 244). 85/85, validation-clean |
| `5a0d85c8` | **Compile-then-read shader ordering + TLAS dispatch guard** | PathTracing/PostStage read spv bytes before compileShader regenerated them - GLSL edits reached the GPU one process-start late. Reordered (other stages were already correct). Red/green impossible on the host and the reason is the next filed item: glslcExe is CMake-baked to the CONTAINER's path, so runtime compilation silently no-ops in container-built binaries (system() unchecked). Plus: RT/PT record now requires a built TLAS - the async-load window used to dispatch against never-written descriptor sets (20 validation errors in the accumulation golden's pre-load frames; now 0 across the suite). 84/84, validation-clean |
| `cb387e2a` | **PT temporal accumulation - the mode finally converges** | The kernel re-rendered a bit-identical noisy frame forever (no frame term in the seed, nowhere to accumulate). Now: frame index folded into the RNG seed, running mean in a persistent rgba32f history image (rgba8 would quantize and stall), compute-compute barrier for the cross-frame read-modify-write, resets on camera move + resize + AS rebuild. The AS-rebuild reset is itself a fix the golden found: model load/reload swapped the traced world under a live accumulation, which kept blending half-loaded-scene frames until the camera moved. Golden `PathTracingAccumulatesAndConverges` earned its instrument: v1 whole-frame mean was vacuous (history healing from load frames mimicked convergence with sampling DISABLED), v2 center crop measured the ImGui FPS digits (~0.29 constant; the panel covers the left ~70% of the frame - seen via amplified diff-map PNGs), final metric = changed-pixel fraction in the GUI-free right edge: green 5.2e-4 early -> exactly 0 at depth ~45, red probe exactly 0 early (FAILS). The red probe also exposed that createPipeline reads spv BEFORE compileShader regenerates it (shader edits reach the GPU one run late) - filed in BACKLOG with the pre-TLAS PT dispatch gap. 84/84, validation-clean |
| `ecbf14ae` | **PT/RT correctness batch: normals, materials, degenerate scatter, t_min** | Six defects across `path_tracing.comp` and `raytrace.rchit`, four of them the SAME defect in both files. (1) Hit normals were transformed as POINTS (`w=1`): the instance translation entered before the normalize, so any meaningfully translated object had every normal collapsed toward the translation direction — fixed with the inverse-transpose (row-multiply by worldToObject; also correct under non-uniform scale). (2) Untextured materials (`textureID = -1`) were clamped to 0 and sampled whatever texture sat in slot 0 — now they use `material.diffuse` (the intended fallback sat commented out one line below, in both files). (3) rchit fed the BRDF an object-space `N` and object-space hit position against world-space `L`/`cam_pos` — now world-space throughout. (4) RTIOW's degenerate-scatter guard was missing: a random unit vector landing on −normal makes a near-zero bounce direction; falls back to the normal. (5) PT ray-query `t_min` 0.0 → 0.001 to match the rgen (the 1e-4 offset alone under-guards at rig scale). (6) Five BRDF includes with zero uses deleted; stale "32 segments" comment corrected. PT survey items 4/5/6/8 + rider; survey-1 item 5 partially (its hard-coded RT light colour remains open). Verified: shader compile, 83/83 with renderModesSuite driving RT+PT on the new SPIR-V, validation-clean run |
| `27fddfd8` | **PT/RT: precomputed inverse matrices** | The path-tracing kernel called `inverse(view)` / `inverse(projection)` INSIDE the per-sample loop - 24 full 4x4 inversions per pixel per frame - and raytrace.rgen did 3 more per pixel, while GlobalUBO has carried CPU-precomputed `inv_view`/`inv_projection` since the clouds pass (whose comment calls per-pixel inverse() "ruinously expensive"). Swapped to the UBO fields; the loop-invariant camera origin is hoisted out of the sample loop, with the per-bounce-mutated rayOrigin kept per-sample (the first attempt made it const and the bounce re-base caught it at compile). First item of the PT survey, whose full 11-item list is in BACKLOG. Verified: shader compile, 83/83 incl. renderModesSuite driving RT+PT on the new SPIR-V, validation-clean run |
| `68f8b84a` | **BLAS compaction** | BLAS were built ePreferFastTrace with no eAllowCompaction and never compacted - VRAM held the full build-size structures for the process lifetime. Now: builds carry eAllowCompaction; after the (synchronous) build, a query pool reads each compacted size, right-sized replacements are copy-compacted, originals destroyed, and the TLAS - built after - picks up the new device addresses naturally. Query failure degrades to keeping uncompacted BLAS with a warning. BLAS-only by design: TLAS compaction gains little and the pending TLAS-refit-on-move item builds on the plain path. Measured: default scene logs 15.98 MB post-compaction (pre-size is not retained anywhere to log - noted, not hidden). Verified: RT/path-trace goldens trace against the compacted BLAS, 83/83, validation-clean (every compaction step is individually validated) |
| `23f53622` | **Cascade texel snapping - shadow edges stop crawling** | The ortho box was refitted to exact frustum corners every frame, so it translated AND resized continuously and sub-texel camera motion made every shadow edge shimmer. Stabilized path (opt-in via a shadowMapResolution param; updateCascades passes the live map size): world-fixed light basis (the legacy lookAt anchored at the slice center absorbs camera translation continuously, making any later snap a no-op), box sized from the slice's bounding radius (a function of fov/aspect/splits only), center snapped to whole texels, one-texel pad because the snap can shift the center by up to a texel. Three tests incl. a legacy red control (the old path must crawl or the whole-texel assertions are vacuous) and a coverage guarantee. Shadow golden IMPROVED: 9.78% darkened vs the 4% floor - the radius box + depth clamp cover casters the tight fit clipped. 83/83, validation-clean |
| `e5f67566` | **Single-time command buffers freed instead of leaked** | endAndSubmitCommandBuffer is fully synchronous (fence wait, or queue.waitIdle in both fallbacks), so the old "avoid freeing potentially pending buffers" rationale was wrong for this path - nothing reaches the tail before the wait completes. Every texture/buffer upload, layout transition and AS build leaked one primary command buffer into the pool for the process lifetime; GUI model reloads leaked dozens each. Now freed on the success AND submit-failure paths. No unit test can observe the leak (Vulkan has no live-buffer query); the legality proof is structural (free strictly after the wait) plus empirical - freeing a pending buffer is exactly what the validation layer reports, and the 80-test suite drives hundreds of these submissions validation-clean. Per-submit fence create/destroy deliberately left as-is |
| `3f80072a` | **First-frame delta_time clamp + first input-handling tests** | `last_time` started at 0, so the first frame's delta was the ENTIRE startup wall clock (window + scene + renderer construction, seconds) - a key held during load teleported the camera. Pure `clamp_frame_delta` (10 FPS cap, negatives to zero) wired into `update_frame_timing`. Plus the first coverage for the input path that routes ALL keyboard/mouse into the camera: 8 tests over key press/release/REPEAT, out-of-range keys (ASan-enforced), first-mouse-move zero-delta, consume-and-reset axis deltas, and the ImGui capture gate tested in BOTH directions with a real context. ESC-to-close and cursor-callback swapping deliberately untested (need a live GLFWwindow); both suites registered in the Windows CI CPU filter, without which they would silently never run there. Verified: 80/80, validation-clean run |
| `d7fff8b8` | **Deferred mode was unreachable end-to-end; parity golden was vacuous** | Two real defects, found while chasing three phantom ones: (1) the GUI mode radios lived in function-local statics and wrote themselves back into the shared vars every frame, stomping any programmatic mode change; (2) the post pass's input descriptor was written once at init, so even a surviving mode switch presented the STALE forward image - proven by forcing the deferred lighting shader to pure red and observing an unchanged capture. Both fixed (radios derive from shared vars; descriptors rebind on mode change). The three "deferred shader defects" from survey 2 turned out to live in DEAD legacy files (`rasterizer/g_buffer_*`, unreferenced by any code) - the live `deferred/{geometry,lighting}` pair is healthy. Golden upgraded: absolute per-channel means (a probe shader is visible there; a relative diff of two equally-affected captures is not) + per-pixel diff with a measured threshold - clean parity 0.203/channel, a single deliberate extra tonemap in the LIVE shader pushes it to 2.105, threshold 1.0. Verified: red/green on the live shader, 72/72, validation-clean 9s run |
| `4062f3da` | **Shadow pancaking via depth clamp** | Casters between the light and a cascade's near plane were CLIPPED (near hugs the camera frustum + 10-unit pad; `depthClampEnable` was false) while `isVisibleAsShadowCaster` deliberately keeps them - a ceiling/overhang cast nothing. Fix: `PipelineBuilder::setDepthClamp` + guarded `depthClamp` device feature + enabled on the CSM pipeline. First attempt (extending the near plane to scene bounds) was REJECTED by `GoldenRender.ShadowsDarkenSomePixels` (2.4% darkened vs 4% required): the shader bias is constant in normalized depth, so widening the range scales it in world units and eats contact shadows - the 60x-scaled rig amplified it. Clamp keeps the range tight. Verified: 72/72 incl. the golden, validation-clean 9s run. Open: a raised-slab golden proving the rescue itself |
| `00bbebad` | **PipelineBuilder** | The copy-pasted ~70–100-line pipeline-construction block across Rasterizer/PostStage/DeferredRasterizer/SkyBox/CascadedShadowMap became `kataglyphis.vulkan.pipeline_builder`; stages state only what differs. −416/+51 at call sites; configurations preserved bit-for-bit |
| `30ed20f5` | **glTF alphaMode MASK cutoff (increment 1)** | Cut-out foliage/decals (glTF MASK) rendered as the solid quad they are modelled as - the raster shaders never read base-colour alpha. `ObjMaterial` got a trailing `float alphaCutoff` (shared C++/GLSL struct, scalar layout, -1 = not MASK); `GltfLoader` reads `cgltf_alpha_mode_mask`/`alpha_cutoff`; forward + deferred shading `discard` below the cutoff. Safe-by-default: -1 for every OBJ/OPAQUE material (default ctor) skips the test, so existing rendering is bit-unchanged. CPU red/green (`GltfParseUnit.MaskAlphaModeSetsTheCutoff`: cutoff 0.5 vs the -1 default) + `GltfParseUnit.*` finally added to the Windows CI filter (was missing). Verified: fresh-container ABI-skew build exit 0, 10/10 CPU tests, 15/15 GPU goldens unchanged (DeferredMatchesForwardRoughly confirms both edited paths still agree), validation-clean. Follow-ups (backlog #11): shadow-pass MASK (1b), RT/PT (1c), a visual golden, BLEND |
| `3e51d8ac` | **glTF COLOR_0 vertex colours** | The engine Vertex always carried a color field and both vertex shaders forwarded it as fragment_color, but no fragment shader read it and GltfLoader hardcoded (1,1,1) - vertex-coloured glTF rendered white (dead plumbing, the gap RPT closed in d2a1427). GltfLoader now reads COLOR_0 (vec3/vec4 via cgltf_accessor_read_float); ObjLoader's -1 absent sentinel became (1,1,1); forward + deferred shaders multiply fragment_color into the base colour per spec. Constructed vertex_colored_quad.gltf (r/g/b/white corners) + GltfParseUnit.ReadsColor0VertexColours (red without the loader change: every colour reads white). Not ABI-skew. Verified: 12/12 CPU tests, 15/15 GPU goldens UNCHANGED (identity multiply on the white-vertex scene), validation-clean |
| `5bbe0ac6` | **glTF MASK shadow casting (increment 1b)** | A MASK caster (cut-out foliage) cast the shadow of its solid quad - the depth-only cascade pass had no UVs or material access. The shadow VS now forwards UV + moves light matrices to set 1 + repurposes the unused cascadeIndex push slot as objectIndex; the (was empty) shadow FS replicates the forward pass's ObjectDescription/buffer-reference material walk (shared set 0, bindings 2/3/4) and discards below the cutoff for textured MASK casters. CSM pipeline layout gains set 0 = sharedRenderDescriptors.getLayout() (the set recordCommands already received but ignored); the push reaches the fragment stage; VulkanRenderer passes the shared layout. Adding a CascadedShadowMap.ixx member = ABI skew - an incremental build tripped the documented stale-BMI ASan ctor overflow; fixed with delete-build + -FreshContainer. 15/15 goldens UNCHANGED + validation-clean (the material fetch runs for EVERY caster, proving fetch/2-set-binding/UV end to end; ShadowsDarkenSomePixels confirms opaque shadows unaffected). Discard logic identical to the golden-tested forward MASK. Follow-up: visual cut-out golden needs a card-over-plane rig |
| `aaa1271b` + `947e2316` | **Multi-mesh Model - a `Model` holds many `Mesh`es (#10, L)** | Landed end-to-end over the campaign: `Model` moved to `std::vector<Mesh>` (`3587463a`), object descriptions flattened one-per-mesh with `objectIndex` = the flat mesh index across forward/deferred/shadow record loops (`0e49fd5a`), the AS already built one geometry per mesh and the RT/PT kernels fetch via `instanceCustomIndex + gl_GeometryIndexEXT` (`36c30bff`). The load-bearing final step is the **loader split** (`aaa1271b`): `GltfLoader::parseCpu` records a per-primitive `MeshRange {vertexBase, vertexCount, indexStart, indexCount, triStart, triCount}` while leaving the flat vertices/indices/materialIndex getters (and every `GltfParseUnit` test) untouched; `uploadParsed` slices each range into its own `add_new_mesh` (indices re-based by `-vertexBase`, per-range material-index subset, full materials shared) and `adoptParsed` moves the ranges so the async worker path builds the meshes too. A single-primitive glTF yields one range spanning everything - behaviour-identical to before. Verified: CPU `MultiPrimitiveGltfRecordsPerPrimitiveMeshRanges` (two_primitives.gltf → 2 ranges), and the render golden `MultiPrimitiveGltfLoadsAsMultipleMeshes` (`947e2316`) loads it in forward mode and asserts `visibility.meshes_total == 2` through the REAL render loop via the async adopt path - not just the parse. Fresh-container ABI-skew build (MeshRange is `.ixx`-exposed); 14/14 goldens + 13/13 GltfParseUnit green, validation-clean. Correct-by-inspection only: the RT/PT `gl_GeometryIndexEXT > 0` branch (the raster golden exercises the flat objectIndex). Optional future refinement: per-mesh material subsets (each mesh shares the full materials array today - correct, not minimal) |
| (feat) | **glTF KHR_texture_transform (#11 increment 4a)** | An atlas/tiled material (scale/offset on its base-colour texture) sampled at the RAW UV - the loader ignored the extension. Now `ObjMaterial` carries trailing `uv_scale`/`uv_offset` (identity default, scalar-layout-safe so the RT/PT kernels reading the struct stay byte-compatible - existing fields keep their offsets), a shared `material_fetch.glsl` `transform_uv(uv,material)=uv*scale+offset` is applied in the forward/deferred/shadow FS (identity leaves the UV unchanged → existing rendering bit-identical), and GltfLoader reads `cgltf` `KHR_texture_transform` scale/offset from the base-colour texture (rotation deferred). ABI-skew - fresh container. Verified: `ReadsKhrTextureTransformScale` + `MaterialWithoutTextureTransformIsIdentity` CPU tests; `KhrTextureTransformTilesTheTexture` golden - a NEW 8x8 RGB checkerboard fixture (the mask_card's checkerboard is alpha-only, RGB uniform white, so it could not show tiling - decoded it, generated an RGB one) scaled 4x tiles to 32x32 with the REPEAT sampler: detail-fraction 0.552 vs 0.079 with `transform_uv` disabled (RED-proven, threshold 0.15). 18/18 goldens unchanged, validation-clean |
| (feat) | **Forward `material.doubleSided` (#11 increment 2)** | A doubleSided glTF mesh (foliage/decal cards, two-sided surfaces) was back-face culled like everything else and vanished from behind. Now the forward pass disables back-face culling per doubleSided mesh via DYNAMIC cull state, NOT a second pipeline: `PipelineBuilder::setDynamicCullMode` adds `VK_DYNAMIC_STATE_CULL_MODE` (core Vulkan 1.3; opt-in so only the forward Rasterizer changes - deferred/CSM/skybox/post keep static culling), and the record loop sets `eNone` for doubleSided meshes / `eBack` otherwise per draw. Every non-doubleSided draw gets `eBack` = byte-identical to the old static cull, so existing rendering is unchanged. The flag rides glTF→Mesh: GltfLoader reads `cgltf material.double_sided` into MeshRange, uploadParsed forwards it to add_new_mesh (Mesh ctor unchanged), `Scene::isMeshDoubleSided` exposes it. Per-mesh is exact post-#10 (each glTF mesh = one primitive = one material). ABI-skew (5 module interfaces) - fresh container. Verified: `MaskCardDoubleSidedRendersFromBehind` rotates the doubleSided mask_card 180° so its BACK face faces the camera - visible (12779 changed px, checkerboard 0.475) ONLY because doubleSided renders it, RED-PROVEN by forcing single-sided (0 changed px, culled). 16/16 goldens unchanged, validation-clean run loading the card (vkCmdSetCullMode path VUID-clean). Deferred/shadow doubleSided is the deliberate follow-up |
| (golden) | **Visual golden for forward MASK cut-out (#11 1b)** | The forward alphaMode MASK `discard` was CPU-verified (GltfParseUnit cutoff) + shadow-golden verified but never confirmed in the forward COLOUR image. `MaskCardDiscardsCutoutTexelsVisually`: DIFFERENTIAL oracle - render the base scene (A), `addModel` the mask_card over it (B), measure the changed-pixel fraction within the bounding box of the changed pixels. mask_card.gltf's texture is a verified 50/50 8x8 checkerboard, so with the discard working only the opaque squares change (holes leave the background = A). Two instrument traps navigated by LOOKING at the dumped diff: (1) an edge/detail-fraction metric is VACUOUS here (the card is a checkerboard whether or not discard runs - only the hole colour changes), so the differential is required; (2) the opaque ImGui panel covers the left ~68% and the card casts a floor shadow, so the scan is restricted to the GUI-free upper-right (FPS text + shadow excluded). MEASURED 0.37 discard-on vs 0.78 discard-off (not 1.0 - some cut-out texels are grey and blend into the ground), 0.55 gate between, red-proven by disabling the FS discard + recompiling the spv. Placement env-tunable (MASK_X/Y/Z/SCALE) for reframing without a rebuild. Local GPU golden (skips in CI). 15/15 goldens |
| `d5b81523` | **OBJ shapes split into separate meshes (#10, the OBJ half)** | `ObjLoader` flattened every OBJ shape (`o`/`g` group) into ONE mesh - a multi-object OBJ collapsed to a single Mesh, the pre-#10 glTF limitation. Now each shape becomes its own Mesh via a per-shape `MeshRange`, so #10's whole downstream (per-mesh objectIndex/culling/AS/RT-PT) applies to OBJ too. The glTF turnkey slice did NOT transfer: OBJ shapes share one attribute pool and the vertex-dedup map was declared OUTSIDE the shape loop, so vertices were deduped GLOBALLY (a shape's indices could point at a vertex an earlier shape emitted - not a contiguous range). Fix: reset the dedup map PER SHAPE, so each shape's vertices form a contiguous block `uploadParsed` slices (indices re-based by -vertexBase, per-range material subset, full materials shared); `adoptParsed` moves the ranges so the async worker path builds them too. The de-risking insight (the initial "goldens must re-baseline" fear was wrong): per-shape dedup is PIXEL-IDENTICAL (same triangles, vertices just stored per-shape), so pixel goldens don't shift from the vertex-count change - only CPU count-asserts (which use the preserved flat getters) and per-mesh culling could. ABI skew (ObjLoader.ixx member) - fresh-container. Verified: `ObjParseUnit.MultiShapeObjRecordsPerShapeMeshRanges` (dinosaurs, 3 shapes -> >1 range, asserting the ranges tile the flat arrays contiguously + every shape index stays inside its own vertex block - the slice-rebase invariant) + `SingleShapeObjIsOneMeshRangeSpanningEverything` (shadow_rig); the sponza golden (now a 373-mesh OBJ) held at detail 0.031 > 0.02, proving per-mesh materials/textures still render through the async path; 106/106 suite, 12s validation- + ASan-clean run loading 3-shape dinosaurs |
| `c6e1bdb4` + `c7621e36` | **Loaders' `MeshRange` slice dedup (was queued #7)** | The two byte-identical loader-local `MeshRange` structs and per-range slice loops (indices re-based by `-vertexBase`, per-range material subset) now live once in module `kataglyphis.vulkan.mesh_range` (`Src/GraphicsEngineVulkan/scene/MeshRange.ixx`) exporting `sliceMeshRange`; both loaders' `uploadParsed` call it. Unit suite `meshRangeSliceSuite.cpp` pins the re-basing arithmetic |
| (refactor) | **`buildAttachmentDescription` - render-pass attachment dedup** | Eight hand-written `vk::AttachmentDescription` field lists across five files (Rasterizer x2, DeferredRasterizer's local `createAttachmentDesc` lambda, PostStage x2, SkyBox x2, CascadedShadowMap x1) collapsed into one `constexpr` helper in `common/RenderPassHelper.hpp`, following the same shape as `ViewportHelper.hpp`/`SamplerBuilder`. Three fields are identical in EVERY pass and are now baked in - `samples = e1` and both stencil ops `eDontCare` (no pass touches a stencil aspect, even when `chooseDepthFormat` returns `eD32SfloatS8Uint`); the five the passes genuinely disagree on stay parameters defaulting to the majority variant (clear/store/from-eUndefined). PostStage's colour attachment is the sole site overriding all three. Configurations preserved field-for-field; -87/+53 at the call sites. `renderPassHelperSuite.cpp` (8 tests) re-creates every call site and pins it field-by-field with no device, so a dropped stencil op or a flipped load op fails on CPU - the class of defect no pixel oracle sees. 235/235 CPU tests (was 227), `BuildIntegrity` 20/20 |

### 2026-08-02 → 2026-08-03

| Commits | Theme | Notes |
| --- | --- | --- |
| `7e3f4e51`, `768c18b3`, `9078523d`, `79fdbf1b`, `e0d88636`, `3b417b36`, `88c204d2`, `ac06961a` | **Create-info builder family** | Following `buildAttachmentDescription`'s shape, one shared definition each for `vk::RenderPassCreateInfo`, `vk::PipelineLayoutCreateInfo`, `vk::FramebufferCreateInfo`, `vk::ImageViewCreateInfo`, `vk::RenderPassBeginInfo`, `vk::SubpassDescription`, compute-pipeline creation, and framebuffer teardown - each collapsing several hand-rolled call sites into one helper with a `BuildIntegrity` gate against regressions. |
| `82cc423c`, `99e1b483`, `00febd65`, `6eb9df82` | **Resource-ownership hardening** | `VulkanBuffer::create()`/`VulkanImage::create()` now release the previous allocation before taking a new one; `VulkanImageView::create()` releases its previous view before its image; `DescriptorSetGroup::create` refuses a duplicate binding and releases its previous layout/pool; its single-descriptor writers refuse an array binding. |
| `d63e2e1b`, `643a010d`, `899c3576`, `31a78b3d` | **`ibl.slang` port regressions and their restorations** | The Slang port of the IBL pipeline had dropped `fs_downsample_cube`/equirect `textureLoad`, roughness-driven GGX importance sampling in `fs_prefilter`/`fs_brdf_lut`, the forward pass's analytic ambient specular and IBL strength controls, and `fs_irradiance`'s midpoint quadrature (128x64 steps, mip-0 sampling); all four restored. |
| `3145d1a7`, `27de8984`, `7c835ecf`, `11735ee3` | **Shader hot reload completed across all eight SPIR-V subsystems** | `deferredRasterizer.shaderHotReload` wired into `VulkanRenderer` and gated against call sites; shadow/sky/cloud subsystems gained a `shaderHotReload`; `PostStage`/`Rasterizer`/`Raytracing`/`PathTracing` now destroy the pipeline layout on reload; the ray-tracing SBT rebuilds after a reload. |
| `f5017f3f`, `ce428348`, `52e22d96`, `bc748462` | **Model-loading dedup and correctness** | Shared textures deduplicated across the glTF and OBJ loaders; `Model` texture samplers deduplicated by mip level; the glTF loader now walks only the default scene; both loaders' `uploadParsed` tail unified into one definition, forwarding `MeshRange::doubleSided` from OBJ. |
| `3861e54b`, `11828d7b`, `1880ebe0`, `eb590cb1`, `07e98a5b`, `5abaeac8` | **Cascade/shadow fixes** | The cascade light-matrix UBO is double-buffered per swapchain image; the stabilized cascade box snaps to the grid it actually projects onto; the PCF kernel stops sampling past the shadow map's edge; the cascade framebuffer attachment has one image view; the shadow pass binds light matrices at set 1 on the no-shared-set fallback; `CascadedShadowMap` no longer stages its light matrices through a throwaway command pool. |
| `90e7d7ba`, `ca989c14`, `613e4225`, `b33e01c5` | **Input and GUI** | Key/mouse-button RELEASE is honoured under ImGui capture, with real focus/enter callbacks; look mode ends and the mouse origin re-seeds on window focus loss; the GUI model transform is a pure helper (dropping a stale 60x scale); the GUI model picker's default index no longer leaves Position/Rotation controls dead. |

### 2026-08-04

| Commits | Theme | Notes |
| --- | --- | --- |
| (refactor) | **`destroyRenderPass` - render-pass teardown dedup** | Twelfth member of the create/destroy helper family. Five stages (Rasterizer, DeferredRasterizer, PostStage, SkyBox, CascadedShadowMap) hand-rolled the same null-checked `destroyRenderPass`/null-out pair in `cleanUp()`; collapsed into one `Kataglyphis::destroyRenderPass(device, render_pass)` in `common/RenderPassHelper.hpp`, following `destroyPipelineAndLayout`'s by-reference, device-null-is-a-no-op shape. `BuildIntegrity.RenderPassTeardownGoesThroughTheSharedHelper` (cloned from the framebuffer-teardown gate) greps `Src/GraphicsEngineVulkan` for a stray `.destroyRenderPass(` outside the helper. |
| (refactor) | **`createColorAttachment` - the colour twin of `createDepthAttachment`** | Thirteenth member of the create/destroy helper family. DeferredRasterizer's local `createAttachment` lambda, Rasterizer's offscreen texture and VulkanRenderer's path-tracing accumulation image each spelled out `createImage(..., eColorAttachment, eDeviceLocal)` -> `createImageView(..., eColor)` by hand; collapsed into `Kataglyphis::VulkanRendererInternals::createColorAttachment` (`renderer/ColorAttachment.ixx`), mirroring `DepthAttachment.ixx`'s shape. Deliberately not merged with `createDepthAttachment` into one aspect-flag-parameterised function - they differ in what they return (depth picks its own format via `chooseDepthFormat`; colour is told its format) and merging invites a config-struct nobody can read. `BuildIntegrity.NoStageHandRollsTheColorAttachmentChain` (cloned from the depth gate) greps `Src/GraphicsEngineVulkan` for a stray `createImageView(..., eColor, ...)` outside the helper, with a `// COLOR_ATTACHMENT_CHAIN_OK: <marker>` escape hatch for the four non-plain-colour-view non-goals (Clouds storage texture, SkyBox cubemap, Texture's real mip chain, VulkanSwapChain's borrowed view). |
| (perf) | **G-buffer albedo widened to `eR8G8B8A8Srgb`** | `GBUFFER_ALBEDO_FORMAT` was left `UNORM` when its two sibling attachments (`GBUFFER_NORMAL_FORMAT`, `GBUFFER_MATERIAL_FORMAT`) widened to `eR16G16B16A16Sfloat`. Albedo holds *linear* base colour decoded from an sRGB-sourced texture, so its 256 UNORM steps were spent uniformly instead of where the eye is most sensitive; switching to `eR8G8B8A8Srgb` redistributes them at identical bandwidth (no range to recover - glTF caps `baseColorFactor`/`COLOR_0` to `[0,1]`, so a float16 attachment would only double traffic for nothing). Hardware encodes on write and decodes on `SubpassLoad`, so the value carried into lighting is unchanged in linear terms. |
| (feat) | **Per-vertex tangent plumbing (normal-mapping prerequisite)** | The engine `Vertex` had no tangent, so normal mapping had nowhere to build a TBN basis - the Rust twin has carried one since its glTF loader shipped. `Vertex` gains a trailing `glm::vec4 tangent` (xyz + handedness `w`, glTF convention), mirrored in `scene_types.slang`'s `Vertex` and the `Vertex_natural` `BuildIntegrity` offset gate; `getVertexInputAttributeDesc()` grows to 5 attributes (Rasterizer/DeferredRasterizer/SkyBox call sites updated). New `vertex::computeTangents` (ported from the Rust `compute_tangents`, Lengyel's method: per-triangle UV-gradient tangent+bitangent accumulated per vertex, Gram-Schmidt against the normal, degenerate UVs fall back to an arbitrary orthogonal axis) sits beside `computeFlatNormals`. `GltfLoader` reads an authored `TANGENT` accessor verbatim (world-transformed by the node's linear part, handedness flipped under a mirrored/negative-determinant node to match the winding reversal `mirrored` already applies) and generates one otherwise; `ObjLoader` always generates (OBJ has no tangent concept). Deliberately **no shading change** - the shaders declare the field so the buffer layout matches; consuming it is the follow-up (backlog: normal mapping across the four shading paths). Fresh-container ABI-skew build; new `vertexTangentSuite.cpp` (UV-gradient direction, unit length, normal-orthogonality, mirrored-chart handedness, degenerate-UV fallback, hash/equality participation) plus `GltfParseUnit.AuthoredTangentsArePreferredOverGeneratedOnes`/`MissingTangentsAreGeneratedWithUnitLengthAndOrthogonalToTheNormal` and the extended `Vertex_natural` gate. |

### 2026-08-05

| Commits | Theme | Notes |
| --- | --- | --- |
| (refactor) | **`Mesh`'s unread model matrix deleted** | `Mesh` carried a second identity `glm::mat4 model` plus `getModel()`/`setModel()`, with zero readers and zero writers outside its own constructor - the per-model transform is owned by `Model::set_model`/`Model::getModel` and reached through `Scene::update_model_matrix`/`Scene::getModelMatrix`. A live `Mesh::getModel()` was a trap: it compiles, type-checks, and silently returns identity for a model the GUI has rotated. `BuildIntegrity.MeshDoesNotHoldAModelMatrix` source-scans `Mesh.ixx` for the three specific spellings so the dead state cannot come back unnoticed. |
| `e6b88dc2`, `4c21bd26`, `93084671`, `9813dadf`, `ff958a92`, `831fdfaf`, `865943a2`, `5a4398f4`, `a25837cd`, `87cb77de`, `118cff50`, `7efffb3c`, `56e06f5e`, `1e817c1a`, `ac2477d4` | **glTF/OBJ material completion** | Closed the remaining material-slot gaps left after #11. glTF normal maps upload UNORM with per-slot colour space and honour `normalTexture.scale`; `metallicRoughnessTexture` is sampled in all four C++ shading paths (linear, G=roughness/B=metallic per glTF 2.0 §3.9.2); `KHR_texture_transform` now applies per-slot (normal/metallic-roughness/emissive) instead of stamping the base-colour transform onto all four, behind named accessors so a mismatched row pair can't compile; `KHR_materials_unlit` reaches all four shading paths. The OBJ loader gained parity it never had: `map_Bump`/`norm` → `normalTextureID` (`-bm` scale read from whichever directive's texture won), `map_Ke` → `emissiveTextureID`, `.mtl` `Pm`/`Pr` → metallic/roughness, and `map_d` → a new `alphaTextureID` sampled through `alpha_test.slang` at the glTF-MASK 0.5 cutoff (OBJ has no alphaMode). `GltfLoader`'s four texture slots collapsed onto one enumerated table (was three hand-copied guards) and `ObjMaterial` became an aggregate with default member initializers (was two hand-written constructors kept in sync by hand). `GltfLoader`'s shininess fallback was pinned to a named constant after tracing that its `mix(128,1,roughnessFactor)` was dead code always evaluating to 1.0. The Rust `webgpu_renderer` picked up the same per-slot `KHR_texture_transform` treatment. Covered by new `GltfParseUnit`/`ObjParseUnit` fixture tests plus a `BuildIntegrity` structural gate per feature; 184/184 CPU tests at the OBJ-parity midpoint, no host GPU golden run this batch (still `- [b]`). |
| `d8efb5f8`, `8798b0ee`, `0b01c0df`, `74069e12`, `6746f8d1`, `86138954`, `b290e1c3` | **Shared-Slang-module consolidation** | Continued the create/destroy-helper-family shape into the shader tree. Extracted binding-free `common/material_rules.slang` for the three rules `alpha_test.slang`/`path_tracing.slang` were re-deriving by hand, then routed both through `material_fetch.slang`'s `fetch_object_description()`/`fetch_material()`, retiring the stale "RT/PT cannot import material_fetch" claim repeated across five modules (it compiles cleanly; the real constraint is which modules declare bindings). Extracted the byte-identical rasterizer/deferred vertex stage into `common/raster_geometry.slang`. Replaced nineteen shared-set plus six RT-set bare-integer `[vk::binding(...)]` literals with `scene_types.slang`'s named constants. Gave the four texture-slot samples (alpha/normal/metallic-roughness/emissive) one owning `material_textures.slang` module covering both explicit- and implicit-LOD call shapes (verified dead-code elimination keeps a compute/RT importer from picking up a fragment-only capability), then gave their guard-plus-default-plus-combine wrappers - eleven duplicated copies - one owning `resolved_*` set in the same module. Each step added or tightened a `BuildIntegrity` structural gate; verified via `spirv-dis` diffs (byte-identical, or one documented CSE-across-inlined-call-boundary delta) and 5 RT `GoldenRender` tests on the host RX 9070 XT. |
| `4b05307a`, `ff3a2a83`, `7dd78a63`, `888ec105` | **Ray/path-tracing correctness** | `ASManager::createTLAS` stamps `instanceCustomIndex` with a model's first-mesh flat index, but Slang's HLSL-shaped `InstanceIndex()` reads the TLAS *instance* index instead - the two only coincided while every model held exactly one mesh. Switched all five traced object-index sites to `InstanceID()`/`CandidateInstanceID()`/`CommittedInstanceID()` (`4b05307a`, verified via `spirv-dis` opcode counts), then found `path_tracing`'s committed-hit block was still missing the geometry-index term its own candidate loops already added - an out-of-bounds buffer-device-address read on any hit against mesh N>0 of a multi-mesh model, the most likely root cause of the tracked device-lost on `Dinosaurs.obj` (`ff3a2a83`, unconfirmed on hardware - no console access this batch). The `instanceCustomIndex` accumulator itself was extracted into a tested `meshBaseOffsets` helper mirroring `assignTextureOffsets` (`7dd78a63`). Separately, the bounce ray direction (hit normal + random unit vector, length in [0,2]) was reused unnormalized against `TMin`/`TMax` and the sky-gradient extrapolation, both measured in units of ray-direction length; normalized after the degenerate-scatter guard (`888ec105`, verified against the host RX 9070 XT including the white-furnace golden). |
| `af57e6ac`, `f34ecfaa`, `4c2dc931`, `03f479c0`, `f92b45c7`, `c5abc415` | **Clouds** | Six more defects in the compute-pass cloud pipeline. The noise-volume dispatch normalized by half the actual volume extent (covering one octant) and hard-coded two of its four written channels to constants, even though `sample_density` weights all four into density and the cirrus band (`af57e6ac`). `phase_HG`'s denominator added instead of subtracted `2*g*cosTheta` (peaked away from the sun for positive g), and the powder effect was added onto transmittance after the break check instead of only attenuating in-scattered light, letting transmittance rise (`f34ecfaa` - neither confirmable visually this batch, goldens blocked). The GUI's march-step sliders offered 1..128 while the shader floored at 4, so the bottom three slider positions did nothing; host-side clamp constants now gate both the UBO fill and the slider bounds, and the shader's own floor-only clamp on `num_march_steps` was widened to match its sibling's two-sided clamp (`4c2dc931`, `c5abc415`). Sample-position wrapping used `abs(fmod(x,N))/N`, which mirrors negative coordinates instead of wrapping them - visible as a mirror image since the default cloud box straddles the origin; switched to `frac(x)` (`03f479c0`). A write-only `model_to_world` matrix (seven fields filled, zero read - only `inv_model_to_world` is consumed) was deleted along with three comments that all falsely claimed the inverse was CPU-precomputed; Slang has no `inverse()` for SPIR-V, so it has always been formed inline (`f92b45c7`). Each fix added a `BuildIntegrity` structural gate. |
| `8a1398a6`, `abb77ca5` | **Normal mapping and tangents** | `apply_normal_map` multiplied the row-built TBN basis as `mul(TBN, nTs)`, which applies the *inverse* (world-to-tangent) transform - a flat normal-map texel came out as the geometric normal inverted on a Y-up floor. Fixed to `mul(nTs, TBN)`, matching the row-multiply convention the RT/PT shaders already use for their `WorldToObject()` inverse-transpose; applied to both `common/normal_map.slang` and `forward.slang`'s inline WGSL-target twin (`8a1398a6`, verified via the emitted-WGSL diff and a new CPU fixture - host goldens still blocked over RDP). `computeTangents` allocated and zeroed two `vector<vec3>` sized to the whole model on every glTF/OBJ primitive (Θ(P·V) for a P-primitive document); rescoped to the touched vertex range only, ~3.1x on the new `BM_ComputeTangents` benchmark at Arg(512) (142739ns → 46348ns) (`abb77ca5`). |
| `10abd948`, `6959e9fa` | **Input and window** | ImGui's GLFW backend needs the cursor-pos callback installed manually (`install_callbacks = false`), but the engine only installed it while right-drag look mode was active - ImGui's own mouse tracking never updated outside a drag. Installed unconditionally in `Window::init_callbacks`; camera motion now gated by a `WindowInputState::look_mode_active` flag instead of (un)installing the callback (`10abd948`). Separately, look mode never captured the OS cursor, so a right-drag rotation stalled dead the moment the cursor hit a monitor edge; wired `glfwSetInputMode`/raw motion into the look-mode transition and window-focus-loss callbacks so the cursor is hidden/confined during a drag (`6959e9fa`). |
| `e71d998a`, `e5280665`, `b8c86bcb`, `62c840a8`, `b222ab9e`, `aec5abe0` | **Loader/renderer plumbing** | `ObjLoader`/`GltfLoader`'s `uploadParsed` each hand-rolled the same device-free/empty-parse guard pair with differently-worded messages and neither had test coverage despite being pure-CPU reachable; shared through `ModelAssembly::uploadPreconditionsMet` (`e71d998a`). `Scene::reloadModel()` relied on its one caller to pre-resolve `modelPath` while `loadAdditionalModel()` resolved it itself; both now go through the same resolution point (`e5280665`). Seven call sites across the BLAS/TLAS builds, the cloud dispatch and the path-tracing accumulation-image transition discarded `endAndSubmitCommandBuffer`'s bool result via `static_cast<void>`, silently treating a submit failure as success; each now reacts per its own failure mode (`b8c86bcb`). `buildSamplerCreateInfo`'s scalar overload duplicated ten of fifteen fields the `GltfSamplerDesc` overload already set; now delegates (`62c840a8`). `Rasterizer`/`DeferredRasterizer` each wrote their frame-texture teardown twice (`cleanUp()` and `recreateFrameResources()`); extracted into one `releaseFrameTextures()` per class, closing a null-guard asymmetry between the two call sites in the process (`b222ab9e`). `PostStage`'s post pass built a clear colour for an attachment declared `eLoad` (never consumable) and `post.slang` sampled `noisyTxt` twice per texel; both removed (`aec5abe0`). Each landed with a `BuildIntegrity` or unit gate against the specific defect regressing. |
| `cc6d70db`, `b4f7aeb5`, `93cb3237`, `4c72c9c8`, `e21ddfc2`, `ff57a216`, `2edf7a43` | **Gates and tooling** | The `file:line`-citation gate (born as `ModelLoadingDocCitesSymbolsNotLineNumbers`, scoped to one doc) widened in three steps into `SourceAndDocsCiteSymbolsNotLineNumbers`: first across `Src/**/*.{cpp,hpp,ixx}` and every `.slang`, rewriting eighteen citations (at least eight already pointing at unrelated code) (`cc6d70db`); then to catch the bare `:NNN`/`:NNN-NNN` same-file shorthand the filename-anchored pattern missed, rewriting ten more (`b4f7aeb5`); then across `Test/`, fixing eighteen more citations across ten suites, five of them already stale (`93cb3237`). `EveryRegisteredBenchmarkHasAPerfBaselineRow` closed the gap where `Compare-PerfBaseline.ps1`'s never-fatal-on-unmatched rule let the baseline silently stop covering `BM_ComputeTangents` after it grew three `->Arg()` variants; the three missing rows were appended from a fresh clangcl-profile measurement (`4c72c9c8`). `docs/source/clouds.md` shipped as the clouds subsystem's owning doc, with two gated marker-block tables pinning the GUI→SceneUBO→shader field mapping and every `CloudDispatch.hpp` constant against their real sources (`e21ddfc2`). `GoldenRender.EnablingCloudsChangesTheFrameAndAddsDetail` added a real numerical oracle - the existing clouds golden only asserted the device survives, which would pass for a flat wash too; verified on the host RX 9070 XT including a manual negative control (`ff57a216`). `docs/source/shader-sharing.md` gained a per-field/per-shading-path table for every `ObjMaterial` member, gated against the four shading paths actually reading it (`2edf7a43`). |
| (perf) | **`transformAABB` center/extent form** | The eight-corner walk (8 `mat4 * vec4` products + 14 min/max reductions) replaced with the standard Arvo center/extent form (1 `mat4 * vec4` on the centre + 9 multiply-adds on the extents), exact for the same reason the old form was: both are tight for an affine transform. `BM_TransformAABB` on the host RX 9070 XT: `/64` 938ns → 262ns (3.6x), `/512` 7500ns → 2007ns (3.7x); baseline rows refreshed. Runs `models × meshes × 2 passes` per frame (373 meshes in sponza), with zero caching - the perf batch that added `BM_TransformAABB` measured ~11µs/frame at sponza scale, too small to justify invalidate-on-every-model-matrix-write cache complexity; that question is closed, not deferred. New `FrustumUnit.TransformAabbMatchesTheEightCornerReference` pins the new form against a kept file-local copy of the old eight-corner walk across identity/translation/uniform+non-uniform+mirrored scale/rotation/composed matrices. |
| (perf) | **`computeTangents` finalizes each vertex once, not once per incident triangle** | The finalize loop walked `indices` three corners at a time, so a vertex of valence *v* redid the same Gram-Schmidt/normalize/cross *v* times and wrote the same `vec4` *v* times; a `finalized` bitset (sized to the same touched-range as the tangent/bitangent accumulators) now skips a corner's second and later visits. `BM_ComputeTangents` on the host RX 9070 XT actually got slightly *slower* (`/512` 45961ns → 53711ns): its fixture is 512 disjoint quads, each vertex shared by at most 2 triangles, so the third small heap allocation (`finalized`) outweighs the one skipped write per vertex. The win this was written for is real closed meshes, where a shared vertex's average valence is ~6 (five skipped re-finalizes, not one) and `computeTangents` runs once per load, not per frame - the 14x gap against `BM_ComputeFlatNormals` this closes is a load-time constant, not a per-frame one. Baseline rows refreshed to the measured (regressed) numbers rather than backfilled to look better than they are. New `VertexUnit.SharedVertexTangentIsIndependentOfIncidenceCount` pins that a vertex shared by six triangles gets the exact tangent the accumulators dictate, regardless of how many of those six visits actually perform the write. |

## In progress

(nothing left that is actionable here — the only remaining item is
renderer-level RAII, which is `- [b]` in `BACKLOG.md` (blocked on a way to
induce device loss so the device-lost teardown path is testable); see that
entry for the reasoning rather than restating it here.)

## Queued (design notes)

1. **Prebuilt SPIR-V consumption** — **done.** The `VkPipelineCache` half
   shipped (`f94a0b47`); the mtime-gated compile skip and runtime glslc
   resolution it superseded are gone entirely with the Slang migration (see
   `docs/source/shader-sharing.md`) — there is no runtime compile path left to skip.
   Shaders are Slang sources compiled ahead of time to SPIR-V/WGSL by
   `Build-SlangShaders.ps1`/`compile-slang-shaders.sh`, and the C++ engine
   loads the generated (gitignored) `.spv` via plain file I/O
   (`Kataglyphis::Shared::readBinaryFile`). What remains is only a micro-optimisation:
   pipelines still read the `.spv` and create shader modules at startup
   rather than consuming a fully cached pipeline binary, which the
   VkPipelineCache already covers on the second run.
3. **`vk::raii` teardown migration** — leaf types (`VulkanBuffer`,
   `VulkanImage`, samplers) and the render stages are already move-only with
   destructor release. What is left is renderer-level: `VulkanRenderer`'s
   hand-ordered teardown and the device-lost special-casing in `App.cpp` —
   tracked as the `- [b]` "Renderer-level RAII cleanup consolidation" entry
   in `BACKLOG.md`, blocked on inducing device loss to test it.
5. **Redundant same-layout swapchain barrier** — **done** (the `NOTE: a same-layout
   swapchain barrier` comment before the post pass in `VulkanRenderer.cpp`).
   A sync-validation run (`VK_LAYER_KHRONOS_validation` with
   `VALIDATION_CHECK_ENABLE_SYNCHRONIZATION_VALIDATION`) confirmed the post
   render pass's external dependency already covers the ordering, so the
   barrier was removed and the comment left in its place records why.
6. **Per-pass GPU timestamps + debug labels** — **DONE.** `GpuTimedPass`
   brackets Clouds/ShadowCascades/Main/Sky/Post with timestamp-query pairs,
   `ScopedCmdLabel` names every pass for RenderDoc, and the
   `KATAGLYPHIS_GPU_TIMING_JSON` export produces per-pass averages - the
   before/after evidence for every perf unit in the table above (the G-buffer
   and multiview-cascade units cite its numbers directly).

## Verification pattern

The canonical description of the per-unit verification loop - container
build (tar-pipe transport by default), running
`build-clangcl-debug/commitTestSuite.exe` directly from the repo root, the
fresh-container rule for module-interface and shared-header changes, the
validation-grep runtime check, and the cheap shader-only iteration path -
lives in [`docs/source/gpu-golden-testing.md`](gpu-golden-testing.md). `AGENTS.md`
links there rather than restating it.

## The instrument playbook (earned, not designed)

Every unit above that says "measured" went through some of these; they are
the difference between a golden and a vacuous golden.

- **Red/green or it did not happen.** Every fix ships with a probe that
  restores the defect and a test that fails on it. Three probes this
  campaign found the TEST broken instead of proving the fix - which is the
  point.
- **Identical-to-the-digit metrics across a "change" mean the change never
  reached the GPU.** The renderer is deterministic per frame index. This
  signature found the stale-spv consumption, the Copy-Item mtime trap
  (PowerShell preserves the source mtime; ninja skips the recompile - restore
  with bash `cp` or touch after), and one shader probe that silently
  no-oped.
- **Look at the pixels, and count rather than average.** The frame-layout
  and metric cautions (ImGui panel coverage, the panel-free right edge,
  amplified diff-map dumps, counting metrics for sparse signals) live in
  [`docs/source/gpu-golden-testing.md`](gpu-golden-testing.md)'s cautions section.
- **Differential oracles beat colour signatures**: render the
  with/without-defect pair in-process and count what moved, with an explicit
  exclusion for the object's own body.
- **A test encodes the behaviour of the day it was written.** The listing
  test asserted OBJ-only; the parity golden compared forward to forward;
  survey findings pointed at dead files. Liveness-check before fixing,
  re-derive contracts before trusting.
