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.

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 Meshes (#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.

  2. 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.

  3. 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.

  4. 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. 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’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.