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 |
|---|---|---|
|
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) + |
|
Real fuzzing + CI + ASan presets |
|
|
Fail-fast |
Was log-and-continue → null-handle UB later. Exceptions are disabled project-wide ( |
|
Draw-loop perf |
Invariant descriptor set bound once instead of per mesh; per-draw |
|
Deferred-path shadows |
Shared |
|
Pipeline cache |
Persisted in |
|
RAII leaf types |
|
|
Staging ring + fence-synced uploads |
Per-upload |
|
VMA adoption |
Allocator moved into |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
PT/RT correctness batch: normals, materials, degenerate scatter, t_min |
Six defects across |
|
PT/RT: precomputed inverse matrices |
The path-tracing kernel called |
|
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) |
|
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 |
|
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 |
|
First-frame delta_time clamp + first input-handling tests |
|
|
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 ( |
|
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; |
|
PipelineBuilder |
The copy-pasted ~70–100-line pipeline-construction block across Rasterizer/PostStage/DeferredRasterizer/SkyBox/CascadedShadowMap became |
|
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. |
|
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 |
|
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 |
|
Multi-mesh Model - a |
Landed end-to-end over the campaign: |
(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 |
(feat) |
Forward |
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: |
(golden) |
Visual golden for forward MASK cut-out (#11 1b) |
The forward alphaMode MASK |
|
OBJ shapes split into separate meshes (#10, the OBJ half) |
|
|
Loaders’ |
The two byte-identical loader-local |
(refactor) |
|
Eight hand-written |
2026-08-02 → 2026-08-03#
Commits |
Theme |
Notes |
|---|---|---|
|
Create-info builder family |
Following |
|
Resource-ownership hardening |
|
|
|
The Slang port of the IBL pipeline had dropped |
|
Shader hot reload completed across all eight SPIR-V subsystems |
|
|
Model-loading dedup and correctness |
Shared textures deduplicated across the glTF and OBJ loaders; |
|
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; |
|
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) |
|
Twelfth member of the create/destroy helper family. Five stages (Rasterizer, DeferredRasterizer, PostStage, SkyBox, CascadedShadowMap) hand-rolled the same null-checked |
(refactor) |
|
Thirteenth member of the create/destroy helper family. DeferredRasterizer’s local |
(perf) |
G-buffer albedo widened to |
|
(feat) |
Per-vertex tangent plumbing (normal-mapping prerequisite) |
The engine |
2026-08-05#
Commits |
Theme |
Notes |
|---|---|---|
(refactor) |
|
|
|
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 |
|
Shared-Slang-module consolidation |
Continued the create/destroy-helper-family shape into the shader tree. Extracted binding-free |
|
Ray/path-tracing correctness |
|
|
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 |
|
Normal mapping and tangents |
|
|
Input and window |
ImGui’s GLFW backend needs the cursor-pos callback installed manually ( |
|
Loader/renderer plumbing |
|
|
Gates and tooling |
The |
(perf) |
|
The eight-corner walk (8 |
(perf) |
|
The finalize loop walked |
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)#
Prebuilt SPIR-V consumption — done. The
VkPipelineCachehalf shipped (f94a0b47); the mtime-gated compile skip and runtime glslc resolution it superseded are gone entirely with the Slang migration (seedocs/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 byBuild-SlangShaders.ps1/compile-slang-shaders.sh, and the C++ engine loads the generated (gitignored).spvvia plain file I/O (Kataglyphis::Shared::readBinaryFile). What remains is only a micro-optimisation: pipelines still read the.spvand create shader modules at startup rather than consuming a fully cached pipeline binary, which the VkPipelineCache already covers on the second run.vk::raiiteardown 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 inApp.cpp— tracked as the- [b]“Renderer-level RAII cleanup consolidation” entry inBACKLOG.md, blocked on inducing device loss to test it.Redundant same-layout swapchain barrier — done (the
NOTE: a same-layout swapchain barriercomment before the post pass inVulkanRenderer.cpp). A sync-validation run (VK_LAYER_KHRONOS_validationwithVALIDATION_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.Per-pass GPU timestamps + debug labels — DONE.
GpuTimedPassbrackets Clouds/ShadowCascades/Main/Sky/Post with timestamp-query pairs,ScopedCmdLabelnames every pass for RenderDoc, and theKATAGLYPHIS_GPU_TIMING_JSONexport 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
cpor 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.