diff --git a/CLAUDE.md b/CLAUDE.md index d0d7010..073571f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,7 @@ assets/ glTF samples + HDR skyboxes - **One declaration of every shared GPU data-layout limit** — the sizes and indices that a C++ block and a shader block must agree on (caster/light/joint/morph/emitter/kernel counts, the map-validity bits). Purely GLSL-side algorithm constants are NOT in scope and this mechanism does not own them: a compute workgroup size, a scan radix, a tap count with no C++ counterpart stays where it is used. `shaders/gpu_limits.glsl` is written in the subset that is valid GLSL *and* valid C++; shaders `#include` it and `graphics/gpu_limits.hpp` includes it inside a `shader_limits` namespace, re-exporting each value under its `k`-name. Add a shader-visible limit **there**, never as a literal on either side, and keep the file inside the common subset (no `constexpr`, `inline`, `namespace`, `static_cast`, unsigned suffixes — each breaks the *other* language, in files that never mention this one). The `gpu_limits_guard` CTest case sweeps `shaders/` for a re-declaration, requires each consumer to use the name rather than a literal, and requires each `k`-constant to be defined *as* the shared declaration, so C++ cannot drift back to hard-coded values behind green shader checks. - **A shadow family's recording and its uploaded validity are one value** — `ShadowMapValidity` (`graphics/shadow_map_validity.hpp`) is applied twice per frame in `Renderer::prepareShadowPlan`, in a fixed order, both from the COMPLETED view set: as ELIGIBILITY, deciding which families may be PREPARED at all (preparation resolves casters and stages hysteresis, so a family that will be neither recorded nor sampled must not be resolved); then as CONFIRMATION (`shadowMapValidityFromPlan`), derived from the finished plan and judged against the counts eligibility expected, which is what `uploadFrameLighting` writes to `LightUBO::shadowMapValidMask` for every sampling path in `shader.frag`. Never skip a family's recording without routing the decision through it — a skipped family's depth image holds an earlier frame's content, and sampling it produces no error, no crash, and shadows from a frame that is gone. - **The shadow pass decides in preparation and records from the plan** — `prepareShadowFrame` (`graphics/shadow_pass_prepare.hpp`) filters, resolves each caster's LOD per view, claims the diagnostic row and builds a `ShadowFramePlan`; `Shadows::recordPass` consumes that plan and nothing else (no draw spans, no view set, no resolver). Anything the pass rasterises with must live in the prepared view or draw: a value read at record time that the comparison never saw is a cached shadow map kept when it should have been re-rendered. +- **A reused shadow map is a claim about the GPU, so it is only ever made after the submit** — `ShadowResidencyStore` (`graphics/shadow_pass_plan.hpp`) records what each physical view's depth image HOLDS, and `prepareShadowFrame` compares this frame's prepared content against it to mark each view `Reused` or `Recorded`. It is owned by `Shadows`, beside the images it describes: that is the whole invalidation story, and why there is no `invalidate()` to forget to call — recreating the images means reconstructing the object that owns both. Two rules the type enforces rather than its callers: only a `Recorded` view commits (a `Reused` one never touched its image, so committing its prepared work would replace the record of what the image holds with a description of a frame that wrote nothing), and an `Invalid` slot is left alone (nothing recorded means nothing overwrote the image, so its record is still true). The commit sits beside `shadowLodResolver_.commitFrame()` BETWEEN `submitFrame` and `presentFrame`, for the same reason: content adopted by a frame that was abandoned would claim an image holds pixels the GPU never drew, and committing after PRESENTATION would be worse still — raii `presentKHR` throws on an out-of-date swapchain, so a resize would skip the commit for a frame whose depth was already being rasterised — and it is `noexcept`, adopting by MOVE out of the plan (`ShadowFramePlan::takeRecorded`, with `static_assert`s pinning the no-throw moves), because on the far side of a submit there is no useful answer to a failed allocation. `RenderTunables::shadowResidencyReuseEnabled` (overlay: "Reuse unchanged shadow views") forces every engaged view to record; it is SCHEDULING, so it is an argument to the law and never part of the content descriptor — a frame recorded with reuse off commits as usual and is reusable the moment it is switched back on. Each SH-01 row carries the disposition it ended up with, because zero raster passes alone cannot separate "reused" from "never engaged". - **GPU data-layout discipline** — every CPU struct shared with a shader (UBO/SSBO) lives in `render/ubo.hpp` with `alignas` + `static_assert`s pinning its std140/std430 offsets and size. Preserve this: when you change a shader-visible struct, update both sides and keep the static_asserts — they are the only thing catching a silent host↔GPU layout mismatch. Mapped host-visible writes go through `graphics/mapped_buffer.hpp` `writeMapped` (a bounds-checked `std::span`), never a raw `void*`. **And a block bound by more than one shader is declared ONCE, in a shared `shaders/*.glsl` include** (`light_ubo.glsl` for `LightUBO`, `material.glsl` for the bindless `Materials` SSBO + `MaterialData`, `shadow_push.glsl` for the `ShadowPushConstants` push block), never hand-copied per shader: field offsets depend on every field before them, so a copy missing an inserted field misreads everything after it, with no validation error and no crash. That is how the sky came to be multiplied by a shadow matrix — `selfShadowViewProj` was added to the struct and `shader.frag`, not to `skybox.frag`, and the wrong value read 1.0 until a scene had two skinned self-shadow casters. The `shader_block_guards` CTest case (`cmake/check_shader_blocks.cmake`) fails on a re-declared block *and* on a shared include that stops declaring it. ## Code Style diff --git a/CMakeLists.txt b/CMakeLists.txt index 42ae292..855b413 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,6 +109,7 @@ add_library(fireengine SHARED src/graphics/shadow_pass_prepare.cpp src/graphics/shadow_caster_deformation.cpp src/graphics/shadow_render_view.cpp + src/graphics/shadow_view_disposition.cpp src/graphics/shadow_diagnostics.cpp src/graphics/shadow_view.cpp src/graphics/vipm.cpp diff --git a/README.md b/README.md index f25248f..ca4beb9 100644 --- a/README.md +++ b/README.md @@ -405,10 +405,23 @@ FE_LOG=render:debug ./fireEngineApp Current categories are `app`, `general`, `gltf`, `physics`, `ragdoll`, and `render`. -`render:debug` also prints a periodic **shadow recording** line — per family, whether it was recorded -or skipped, its raster passes and its GPU milliseconds — which is how `--no-shadows` is checked: it -suppresses the *recording*, not only the sampling, so every family must read `skipped passes=0 -0.000ms`. A frame that still rendered into maps nobody samples would look identical on screen. +`render:debug` also prints a periodic **shadow recording** line — per family, whether it was +sampleable or skipped, how many of its views recorded and how many reused their existing depth +image, its raster passes, and its GPU milliseconds — which is how `--no-shadows` is checked: it +suppresses the *recording*, not only the sampling, so every family must read `skipped recorded=0 +reused=0 passes=0`. A frame that still rendered into maps nobody samples would look identical on +screen. + +A family that records nothing prints **`no span issued`** rather than `0.000ms`, and the distinction +is the point: a reused shadow map opens no timing span at all, so a zero there would be the absence +of a measurement dressed as one. + +**Shadow-map reuse** (on by default) skips re-rendering a shadow view whose content is unchanged — +the same casters, the same transforms, the same resolved geometry, the same light. Turn it off with +`--no-shadow-reuse`, or with the overlay's **"Reuse unchanged shadow views"** checkbox, to get the +"before" half of an A/B: every engaged view then records exactly as it did before the cache existed. +Start the run with the flag when measuring — a mid-run flip leaves the early frames reused, so the +baseline is not comparable from frame one. ## Dependencies diff --git a/assets/shadow_residency/ShadowResidencyTest.gltf b/assets/shadow_residency/ShadowResidencyTest.gltf new file mode 100644 index 0000000..6c6a54b --- /dev/null +++ b/assets/shadow_residency/ShadowResidencyTest.gltf @@ -0,0 +1,961 @@ +{ + "asset": { + "version": "2.0", + "generator": "fireEngine shadow_residency generate.py" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ] + } + ], + "nodes": [ + { + "name": "PointLight", + "translation": [ + 0.0, + 0.0, + 0.0 + ], + "extensions": { + "KHR_lights_punctual": { + "light": 0 + } + } + }, + { + "name": "WallPosX", + "mesh": 0, + "translation": [ + 8.2, + 0.0, + 0.0 + ] + }, + { + "name": "CasterPosX", + "mesh": 1, + "translation": [ + 3.0, + 0.0, + 0.0 + ] + }, + { + "name": "WallNegX", + "mesh": 2, + "translation": [ + -8.2, + 0.0, + 0.0 + ] + }, + { + "name": "CasterNegX", + "mesh": 3, + "translation": [ + -3.0, + 0.0, + 0.0 + ] + }, + { + "name": "WallPosY", + "mesh": 4, + "translation": [ + 0.0, + 8.2, + 0.0 + ] + }, + { + "name": "CasterPosY", + "mesh": 5, + "translation": [ + 0.0, + 3.0, + 0.0 + ] + }, + { + "name": "WallNegY", + "mesh": 6, + "translation": [ + 0.0, + -8.2, + 0.0 + ] + }, + { + "name": "CasterNegY", + "mesh": 7, + "translation": [ + 0.0, + -3.0, + 0.0 + ] + }, + { + "name": "WallPosZ", + "mesh": 8, + "translation": [ + 0.0, + 0.0, + 8.2 + ] + }, + { + "name": "CasterPosZ", + "mesh": 9, + "translation": [ + 0.0, + 0.0, + 3.0 + ] + }, + { + "name": "WallNegZ", + "mesh": 10, + "translation": [ + 0.0, + 0.0, + -8.2 + ] + }, + { + "name": "CasterNegZ", + "mesh": 11, + "translation": [ + 0.0, + 0.0, + -3.0 + ] + }, + { + "name": "Camera", + "camera": 0, + "translation": [ + 6.2, + 3.6, + 6.6 + ], + "rotation": [ + -0.17834651899579498, + 0.3599036106085298, + 0.07029763117992845, + 0.9130827746067259 + ] + } + ], + "meshes": [ + { + "name": "WallPosX", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "NORMAL": 1 + }, + "indices": 2, + "material": 0 + } + ] + }, + { + "name": "CasterPosX", + "primitives": [ + { + "attributes": { + "POSITION": 3, + "NORMAL": 4 + }, + "indices": 5, + "material": 1 + } + ] + }, + { + "name": "WallNegX", + "primitives": [ + { + "attributes": { + "POSITION": 6, + "NORMAL": 7 + }, + "indices": 8, + "material": 0 + } + ] + }, + { + "name": "CasterNegX", + "primitives": [ + { + "attributes": { + "POSITION": 9, + "NORMAL": 10 + }, + "indices": 11, + "material": 1 + } + ] + }, + { + "name": "WallPosY", + "primitives": [ + { + "attributes": { + "POSITION": 12, + "NORMAL": 13 + }, + "indices": 14, + "material": 0 + } + ] + }, + { + "name": "CasterPosY", + "primitives": [ + { + "attributes": { + "POSITION": 15, + "NORMAL": 16 + }, + "indices": 17, + "material": 1 + } + ] + }, + { + "name": "WallNegY", + "primitives": [ + { + "attributes": { + "POSITION": 18, + "NORMAL": 19 + }, + "indices": 20, + "material": 2 + } + ] + }, + { + "name": "CasterNegY", + "primitives": [ + { + "attributes": { + "POSITION": 21, + "NORMAL": 22 + }, + "indices": 23, + "material": 1 + } + ] + }, + { + "name": "WallPosZ", + "primitives": [ + { + "attributes": { + "POSITION": 24, + "NORMAL": 25 + }, + "indices": 26, + "material": 0 + } + ] + }, + { + "name": "CasterPosZ", + "primitives": [ + { + "attributes": { + "POSITION": 27, + "NORMAL": 28 + }, + "indices": 29, + "material": 1 + } + ] + }, + { + "name": "WallNegZ", + "primitives": [ + { + "attributes": { + "POSITION": 30, + "NORMAL": 31 + }, + "indices": 32, + "material": 0 + } + ] + }, + { + "name": "CasterNegZ", + "primitives": [ + { + "attributes": { + "POSITION": 33, + "NORMAL": 34 + }, + "indices": 35, + "material": 1 + } + ] + } + ], + "materials": [ + { + "name": "Mat0", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.52, + 0.51, + 0.49, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + }, + { + "name": "Mat1", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.85, + 0.35, + 0.25, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + }, + { + "name": "Mat2", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.4, + 0.4, + 0.43, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.2, + -8.4, + -8.4 + ], + "max": [ + 0.2, + 8.4, + 8.4 + ] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 3, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 5, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 6, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.2, + -8.4, + -8.4 + ], + "max": [ + 0.2, + 8.4, + 8.4 + ] + }, + { + "bufferView": 7, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 8, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 9, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 10, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 11, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 12, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -8.4, + -0.2, + -8.4 + ], + "max": [ + 8.4, + 0.2, + 8.4 + ] + }, + { + "bufferView": 13, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 14, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 15, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 16, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 17, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 18, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -8.4, + -0.2, + -8.4 + ], + "max": [ + 8.4, + 0.2, + 8.4 + ] + }, + { + "bufferView": 19, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 20, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 21, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 22, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 23, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 24, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -8.4, + -8.4, + -0.2 + ], + "max": [ + 8.4, + 8.4, + 0.2 + ] + }, + { + "bufferView": 25, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 26, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 27, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 28, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 29, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 30, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -8.4, + -8.4, + -0.2 + ], + "max": [ + 8.4, + 8.4, + 0.2 + ] + }, + { + "bufferView": 31, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 32, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 33, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -0.5, + -0.5, + -0.5 + ], + "max": [ + 0.5, + 0.5, + 0.5 + ] + }, + { + "bufferView": 34, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 35, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 288, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 576, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 648, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 936, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 1224, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 1296, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 1584, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 1872, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 1944, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 2232, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 2520, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 2592, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 2880, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 3168, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 3240, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 3528, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 3816, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 3888, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 4176, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 4464, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 4536, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 4824, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 5112, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 5184, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 5472, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 5760, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 5832, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 6120, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 6408, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 6480, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 6768, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 7056, + "byteLength": 72, + "target": 34963 + }, + { + "buffer": 0, + "byteOffset": 7128, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 7416, + "byteLength": 288, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 7704, + "byteLength": 72, + "target": 34963 + } + ], + "buffers": [ + { + "byteLength": 7776, + "uri": "data:application/octet-stream;base64,zcxMPmZmBsFmZgbBzcxMPmZmBkFmZgbBzcxMPmZmBkFmZgZBzcxMPmZmBsFmZgZBzcxMvmZmBsFmZgZBzcxMvmZmBkFmZgZBzcxMvmZmBkFmZgbBzcxMvmZmBsFmZgbBzcxMvmZmBkFmZgbBzcxMvmZmBkFmZgZBzcxMPmZmBkFmZgZBzcxMPmZmBkFmZgbBzcxMvmZmBsFmZgZBzcxMvmZmBsFmZgbBzcxMPmZmBsFmZgbBzcxMPmZmBsFmZgZBzcxMPmZmBsFmZgZBzcxMPmZmBkFmZgZBzcxMvmZmBkFmZgZBzcxMvmZmBsFmZgZBzcxMvmZmBsFmZgbBzcxMvmZmBkFmZgbBzcxMPmZmBkFmZgbBzcxMPmZmBsFmZgbBAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAzcxMPmZmBsFmZgbBzcxMPmZmBkFmZgbBzcxMPmZmBkFmZgZBzcxMPmZmBsFmZgZBzcxMvmZmBsFmZgZBzcxMvmZmBkFmZgZBzcxMvmZmBkFmZgbBzcxMvmZmBsFmZgbBzcxMvmZmBkFmZgbBzcxMvmZmBkFmZgZBzcxMPmZmBkFmZgZBzcxMPmZmBkFmZgbBzcxMvmZmBsFmZgZBzcxMvmZmBsFmZgbBzcxMPmZmBsFmZgbBzcxMPmZmBsFmZgZBzcxMPmZmBsFmZgZBzcxMPmZmBkFmZgZBzcxMvmZmBkFmZgZBzcxMvmZmBsFmZgZBzcxMvmZmBsFmZgbBzcxMvmZmBkFmZgbBzcxMPmZmBkFmZgbBzcxMPmZmBsFmZgbBAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmYGQc3MTL5mZgbBZmYGQc3MTD5mZgbBZmYGQc3MTD5mZgZBZmYGQc3MTL5mZgZBZmYGwc3MTL5mZgZBZmYGwc3MTD5mZgZBZmYGwc3MTD5mZgbBZmYGwc3MTL5mZgbBZmYGwc3MTD5mZgbBZmYGwc3MTD5mZgZBZmYGQc3MTD5mZgZBZmYGQc3MTD5mZgbBZmYGwc3MTL5mZgZBZmYGwc3MTL5mZgbBZmYGQc3MTL5mZgbBZmYGQc3MTL5mZgZBZmYGQc3MTL5mZgZBZmYGQc3MTD5mZgZBZmYGwc3MTD5mZgZBZmYGwc3MTL5mZgZBZmYGwc3MTL5mZgbBZmYGwc3MTD5mZgbBZmYGQc3MTD5mZgbBZmYGQc3MTL5mZgbBAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmYGQc3MTL5mZgbBZmYGQc3MTD5mZgbBZmYGQc3MTD5mZgZBZmYGQc3MTL5mZgZBZmYGwc3MTL5mZgZBZmYGwc3MTD5mZgZBZmYGwc3MTD5mZgbBZmYGwc3MTL5mZgbBZmYGwc3MTD5mZgbBZmYGwc3MTD5mZgZBZmYGQc3MTD5mZgZBZmYGQc3MTD5mZgbBZmYGwc3MTL5mZgZBZmYGwc3MTL5mZgbBZmYGQc3MTL5mZgbBZmYGQc3MTL5mZgZBZmYGQc3MTL5mZgZBZmYGQc3MTD5mZgZBZmYGwc3MTD5mZgZBZmYGwc3MTL5mZgZBZmYGwc3MTL5mZgbBZmYGwc3MTD5mZgbBZmYGQc3MTD5mZgbBZmYGQc3MTL5mZgbBAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmYGQWZmBsHNzEy+ZmYGQWZmBkHNzEy+ZmYGQWZmBkHNzEw+ZmYGQWZmBsHNzEw+ZmYGwWZmBsHNzEw+ZmYGwWZmBkHNzEw+ZmYGwWZmBkHNzEy+ZmYGwWZmBsHNzEy+ZmYGwWZmBkHNzEy+ZmYGwWZmBkHNzEw+ZmYGQWZmBkHNzEw+ZmYGQWZmBkHNzEy+ZmYGwWZmBsHNzEw+ZmYGwWZmBsHNzEy+ZmYGQWZmBsHNzEy+ZmYGQWZmBsHNzEw+ZmYGQWZmBsHNzEw+ZmYGQWZmBkHNzEw+ZmYGwWZmBkHNzEw+ZmYGwWZmBsHNzEw+ZmYGwWZmBsHNzEy+ZmYGwWZmBkHNzEy+ZmYGQWZmBkHNzEy+ZmYGQWZmBsHNzEy+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAZmYGQWZmBsHNzEy+ZmYGQWZmBkHNzEy+ZmYGQWZmBkHNzEw+ZmYGQWZmBsHNzEw+ZmYGwWZmBsHNzEw+ZmYGwWZmBkHNzEw+ZmYGwWZmBkHNzEy+ZmYGwWZmBsHNzEy+ZmYGwWZmBkHNzEy+ZmYGwWZmBkHNzEw+ZmYGQWZmBkHNzEw+ZmYGQWZmBkHNzEy+ZmYGwWZmBsHNzEw+ZmYGwWZmBsHNzEy+ZmYGQWZmBsHNzEy+ZmYGQWZmBsHNzEw+ZmYGQWZmBsHNzEw+ZmYGQWZmBkHNzEw+ZmYGwWZmBkHNzEw+ZmYGwWZmBsHNzEw+ZmYGwWZmBsHNzEy+ZmYGwWZmBkHNzEy+ZmYGQWZmBkHNzEy+ZmYGQWZmBsHNzEy+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAD8AAAA/AAAAPwAAAL8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/AAAAvwAAAL8AAAA/AAAAvwAAAL8AAAC/AAAAvwAAAD8AAAC/AAAAPwAAAD8AAAC/AAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcA" + } + ], + "cameras": [ + { + "name": "Camera", + "type": "perspective", + "perspective": { + "yfov": 0.7, + "znear": 0.05, + "zfar": 500.0 + } + } + ], + "extensionsUsed": [ + "KHR_lights_punctual" + ], + "extensions": { + "KHR_lights_punctual": { + "lights": [ + { + "name": "Point", + "type": "point", + "color": [ + 1.0, + 0.96, + 0.9 + ], + "intensity": 26.0, + "range": 22.0 + } + ] + } + } +} diff --git a/assets/shadow_residency/generate.py b/assets/shadow_residency/generate.py new file mode 100644 index 0000000..41c8f0c --- /dev/null +++ b/assets/shadow_residency/generate.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Generator for the shadow-residency gate scene (arc 2 #4, docs/shadowplans.md). + +`ShadowResidencyTest.gltf` exists to answer ONE question: does a shadow view whose content +has not changed stop rasterising, and does the image it keeps still shade correctly? Every +choice below is in service of that, and most of them are about removing evidence that would +muddy the answer rather than about looking good. + +- **One static point light, and no authored directional or spot.** A point light's fit does + not depend on the camera, so its six faces are the family that reuses reliably — the case + worth measuring. + + **The scene still renders cascades, and that is not a defect.** When an asset authors no + directional light the engine seeds a default sun (`src/fire_engine.cpp`), so this scene + gets four cascades whether or not it asks for them; authoring nothing does NOT make the + cascade family ineligible. It does no harm to the measurement — each family carries its + own timestamp span, so the point family's cost is never mixed with the cascades' — and + with the camera parked the cascades reuse too, which is extra evidence rather than noise. + What the scene DOES avoid is a second punctual light: a spot would add a seventh view + competing for the same GPU with no separate story to tell. + + If the fallback sun ever becomes suppressible, this scene should suppress it — not because + the numbers are wrong today, but because a gate is easier to trust when the frame contains + only what the gate is about. +- **A closed room, so all six cube faces have a receiver.** Six slabs rather than six + quads: a slab's inner surface is real front-facing geometry, so nothing here depends on + double-sided shading or on winding the reader has to check. +- **One caster per axis, between the light and the wall it shades.** All six faces + therefore do real work, and each casts a hard-edged rectangle a capture comparison can + actually see. A face with nothing in it would reuse trivially and prove nothing. +- **Nothing moves.** No animation, no skin, no morph target — a deformable caster can + never be cached (SH-04), and a moving one changes the content every frame, which is the + opposite of what this scene is for. Run it with `--no-taa`: temporal accumulation makes + two captures of the same state differ. + +The gate procedure that uses this lives in docs/acceptance-testing.md. + +Run from anywhere: python3 assets/shadow_residency/generate.py +""" + +import sys +from pathlib import Path + +# The shared glTF machinery lives in the repository's tools/ directory. Resolve it from +# this file, not the working directory, so the script keeps running from anywhere. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) + +from assetgen import Scene, write_gltf # noqa: E402 (path bootstrap must run first) + +GENERATOR = "fireEngine shadow_residency generate.py" + +# --- authored constants ---------------------------------------------------- +# The room's interior half-extent. Large enough that the light's six faces each see a wall +# at a useful distance, small enough that one point light of a sane range lights all of it. +ROOM_HALF = 8.0 +WALL_THICKNESS = 0.4 +# The light sits at the origin, so the six cube faces are symmetric and each gets the same +# amount of work — which is what makes the per-face timings comparable to each other. +LIGHT_POSITION = (0.0, 0.0, 0.0) +LIGHT_RANGE = 22.0 +LIGHT_INTENSITY = 26.0 +# Casters sit between the light and the wall they shade, closer to the light than to the +# wall so the shadow is magnified and its edges are unmistakable in a capture. +CASTER_DISTANCE = 3.0 +CASTER_HALF = 0.5 +# Inside the room, off-axis, looking back through the origin: three walls, the floor and +# four of the six casters are in frame at once. Deliberately NOT on an axis — a camera on +# one would hide the caster in front of it behind its own shadow. +CAMERA_EYE = (6.2, 3.6, 6.6) +CAMERA_TARGET = (-2.2, -1.4, -2.4) + +WALL_COLOUR = (0.52, 0.51, 0.49) +FLOOR_COLOUR = (0.40, 0.40, 0.43) +CASTER_COLOUR = (0.85, 0.35, 0.25) + +# The six axis directions, named. Each contributes one wall and one caster, which is what +# makes "every cube face has both a receiver and an occluder" true by construction rather +# than by a placement someone has to re-check. +AXES = ( + ("PosX", (1.0, 0.0, 0.0)), + ("NegX", (-1.0, 0.0, 0.0)), + ("PosY", (0.0, 1.0, 0.0)), + ("NegY", (0.0, -1.0, 0.0)), + ("PosZ", (0.0, 0.0, 1.0)), + ("NegZ", (0.0, 0.0, -1.0)), +) + + +def wall_half_extent(axis): + """A slab spanning the room in the two axes it is not normal to.""" + return tuple( + WALL_THICKNESS * 0.5 if component else ROOM_HALF + WALL_THICKNESS + for component in axis + ) + + +def scaled(axis, distance): + return tuple(component * distance for component in axis) + + +def build(): + s = Scene(GENERATOR) + + # The light first, so it is light 0 and the scene's only one. + s.add_node( + "PointLight", + light=s.light( + "Point", + "point", + colour=(1.0, 0.96, 0.9), + intensity=LIGHT_INTENSITY, + range_=LIGHT_RANGE, + ), + translation=LIGHT_POSITION, + ) + + for name, axis in AXES: + # The wall sits a half-thickness OUTSIDE the interior, so the interior surface is + # exactly at ROOM_HALF and the geometry never intrudes on the casters. + wall_centre = scaled(axis, ROOM_HALF + WALL_THICKNESS * 0.5) + colour = FLOOR_COLOUR if name == "NegY" else WALL_COLOUR + s.box(f"Wall{name}", wall_half_extent(axis), wall_centre, colour) + # One occluder per face, on the axis between the light and that wall. + s.box( + f"Caster{name}", + (CASTER_HALF, CASTER_HALF, CASTER_HALF), + scaled(axis, CASTER_DISTANCE), + CASTER_COLOUR, + ) + + s.camera(CAMERA_EYE, CAMERA_TARGET) + return s + + +def validate(doc): + """Structural checks, because this scene's whole value is what it does NOT contain. + + A later edit that adds a sun, or animates a caster to make a screenshot livelier, would + not break anything visibly — it would quietly turn the gate into a measurement of + something else. Each assertion below corresponds to a claim the gate makes. + """ + lights = doc.get("extensions", {}).get("KHR_lights_punctual", {}).get("lights", []) + assert len(lights) == 1, f"the gate needs exactly one light, found {len(lights)}" + assert lights[0]["type"] == "point", ( + f"the gate measures the POINT family; found a {lights[0]['type']} light" + ) + assert "range" in lights[0], "a point light without a range has no radial depth to store" + + # Temporal or deforming content would change the content descriptor every frame, so a + # reused view could never happen and the gate would fail for the wrong reason. + assert not doc.get("animations"), "the residency gate scene must not animate" + assert not doc.get("skins"), "the residency gate scene must not contain skinned casters" + for mesh in doc["meshes"]: + for primitive in mesh["primitives"]: + assert "targets" not in primitive, ( + f"'{mesh['name']}' carries morph targets; a morph-capable caster is " + "Deformable (SH-04) and can never be reused" + ) + + names = [node["name"] for node in doc["nodes"]] + for axis_name, _ in AXES: + assert f"Wall{axis_name}" in names, f"no receiver for the {axis_name} cube face" + assert f"Caster{axis_name}" in names, f"no occluder for the {axis_name} cube face" + + # The camera must be INSIDE the room, or the capture is of six slabs from outside and + # every shadow the gate is about is hidden. + assert all(abs(component) < ROOM_HALF for component in CAMERA_EYE), ( + f"camera {CAMERA_EYE} is outside the room (interior half-extent {ROOM_HALF})" + ) + # And it must not be inside a caster, which would fill the frame with one box. + for _, axis in AXES: + centre = scaled(axis, CASTER_DISTANCE) + inside = all( + abs(eye - c) <= CASTER_HALF for eye, c in zip(CAMERA_EYE, centre) + ) + assert not inside, f"camera {CAMERA_EYE} is inside the caster at {centre}" + + # Every caster has to be strictly between the light and its wall: touching either would + # make its shadow degenerate (no penumbra to see) or clip into the receiver. + assert CASTER_HALF < CASTER_DISTANCE < ROOM_HALF - CASTER_HALF, ( + "casters must sit clear of both the light and the walls" + ) + # The furthest interior corner must be inside the light's range, or the faces nearest it + # store a clamped ratio and the capture shows an unlit wedge. + corner_distance = (3.0 ** 0.5) * ROOM_HALF + assert LIGHT_RANGE > corner_distance, ( + f"light range {LIGHT_RANGE} does not reach the room's corner at {corner_distance:.1f}" + ) + + +def main(): + scene = build() + doc = scene.to_gltf() + validate(doc) + out = Path(__file__).resolve().parent / "ShadowResidencyTest.gltf" + write_gltf(out, doc) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/docs/acceptance-testing.md b/docs/acceptance-testing.md index 7feb843..0b67873 100644 --- a/docs/acceptance-testing.md +++ b/docs/acceptance-testing.md @@ -510,6 +510,93 @@ the shadow-texel bound. --- +## Shadow-residency gate scene (arc 2 #4) + +Generated by `assets/shadow_residency/generate.py`. This scene has ONE job — proving that a shadow +view whose content has not changed stops rasterising, and that the image it keeps still shades +correctly — so it deliberately contains as little as possible: one static point light at the origin, +a closed room whose six inner walls receive, and one box caster per axis between the light and the +wall it shades. Nothing animates, nothing is skinned, nothing morphs; a deformable caster can never +be cached (SH-04) and a moving one changes the content every frame. + +```bash +./fireEngineApp shadow_residency/ShadowResidencyTest.gltf nightbox.hdr --overlay --no-taa +``` + +**`--no-taa` is not optional here.** Temporal accumulation makes two captures of the same state +differ, and the whole gate is a comparison of captures. + +**The engine seeds a default sun when a scene authors no directional light** (`FireEngine::…` in +`src/fire_engine.cpp`), so this scene renders four cascades as well as the point cube. That is +harmless — each family carries its OWN timestamp span, so the point family's cost is never mixed +with the cascades' — and it is useful evidence in its own right: with the camera parked, the +cascades reuse too. + +### 1. Reuse actually happens, and the receiver is still told the maps are good + +```bash +FE_LOG=render:debug ./fireEngineApp --no-taa shadow_residency/ShadowResidencyTest.gltf nightbox.hdr +``` + +After the first frame every `shadow recording:` line must read: + +``` +point sampleable recorded=0 reused=6 passes=0 no span issued +``` + +All three halves matter. **`reused=6`** is the whole cube reused, not a partial one. **`passes=0`** +is the recorder confirming it rasterised nothing. **`sampleable`** is the receiver still being told +the maps are valid — a frame that skips the work and then blanks the shadows has not saved anything, +it has changed the picture. **`no span issued`** is deliberate wording: a reused family opens no +timing span, so a `0.000ms` there would be an absence of measurement dressed up as a measurement. + +The overlay's Shadows panel shows the same thing per row in its **State** column (`reused` / +`recorded` / `invalid`), which is how you tell a reused empty map from a recorded empty one. + +### 2. The saving is real — forced-record baseline + +```bash +FE_LOG=render:debug ./fireEngineApp --no-taa --no-shadow-reuse \ + shadow_residency/ShadowResidencyTest.gltf nightbox.hdr +``` + +Every line must now read `point sampleable recorded=6 reused=0 passes=6 ms`. Take the MEDIAN of +those milliseconds across several samples and **discard the first**, which includes pipeline and +descriptor warm-up. (Do not assume the cold frame is the slowest — measured here it was 0.246 ms +against a warm median of 0.264 ms. Discard it because it is not comparable, not because it is +extreme.) Compare against run 1, which has no number to compare at all — that is the result. Do not +report the reuse case as "0.000 ms". + +### 3. The reused image is the right image + +Three captures of the same authored state, which must be byte-identical: + +```bash +S=shadow_residency/ShadowResidencyTest.gltf +./fireEngineApp --no-taa --capture /tmp/c_cold.png --capture-frame 1 $S nightbox.hdr +./fireEngineApp --no-taa --capture /tmp/c_reused.png --capture-frame 240 $S nightbox.hdr +./fireEngineApp --no-taa --no-shadow-reuse --capture /tmp/c_forced.png --capture-frame 240 $S nightbox.hdr +md5 -q /tmp/c_cold.png /tmp/c_reused.png /tmp/c_forced.png # three identical hashes +``` + +Each comparison answers a different question, and both are needed: + +- **reused vs cold** — the depth an early frame recorded is still the depth being sampled hundreds of + frames later. A cache that quietly lost its content would differ here. +- **reused vs forced-record at the same frame** — reuse changed nothing about the image, only about + the work. This is the one that catches a descriptor missing a pixel-producing input: the two runs + see the same scene, so any difference is the cache's fault. + +Look at the image as well as the hash. The two large hard-edged rectangles on the flanking walls are +cast shadows; if a run ever produces an image with a shadow in the wrong place rather than a +different hash, that is a stale map and `--no-shadow-reuse` will confirm it in one run. + +### 4. Vulkan validation stays clean + +Add `--require-validation` to any of the above. A reused view is skipped **entirely** — no barrier, +no clear, no draw — so the layout it rests in between frames is doing real work; a mistake there +shows up as a VUID rather than as a wrong picture. + ## Physics demos Generated by `assets/physics_demos/generate.py`, each mirrored by a headless replay test in diff --git a/docs/architecturalreview.md b/docs/architecturalreview.md index f77026c..4f2e2bd 100644 --- a/docs/architecturalreview.md +++ b/docs/architecturalreview.md @@ -376,7 +376,7 @@ exists; the tiered review inherited the filename.)* | 1 | ✅ Skip redundant world-CSM when no skinned casters *(branch `review-shadow-taa-fixes`)* | B | S | §2.2 | | 2 | ✅ Skip empty self-shadow slots *(same branch; implementation is simpler than proposed — unassigned slots are provably never sampled, so no clear-once/dirty bit is needed, they are skipped outright)* | B | S | §2.1 | | 3 | ✅ `static_assert(kMaxFramesInFlight == 2)` on the TAA history index *(same branch)* | A | XS | §2.4 | -| 4 | Static-scene CSM caching (light+fit+caster epoch) — **in progress, branch `shadow-static-cascade-cache`**. The review's "coarse validity check" was rejected on inspection: light dir + snapped origin + a caster epoch explains a matrix without being one, and a wrong answer here is a silently wrong image. What landed instead is the exact content descriptor (`graphics/shadow_pass_plan.hpp`, structural comparison, no hash) and the PREPARATION phase that builds it (`graphics/shadow_pass_prepare.hpp`) — filtering, LOD resolution and diagnostics moved out of recording, so `Shadows::recordPass` now consumes the plan alone. Every view is still `Recorded` (no residency store yet), verified by a byte-identical per-view diagnostic dump across three scenes and all five families. The residency store + reuse is the remaining half. | B | M | §2.1 | +| 4 | ✅ Static-scene CSM caching *(branches `shadow-static-cascade-cache` + `shadow-residency-reuse`)*. The review's "coarse validity check" was rejected on inspection: light dir + snapped origin + a caster epoch explains a matrix without being one, and a wrong answer here is a silently wrong image. What landed instead is the exact content descriptor (`graphics/shadow_pass_plan.hpp`, structural comparison, no hash), the PREPARATION phase that builds it (`graphics/shadow_pass_prepare.hpp`), and `ShadowResidencyStore` — owned by `Shadows` beside the images it describes, adopted by MOVE between the submit and the present so the post-submit path cannot throw. A reused view receives no barrier, no clear and no draw, and its family opens no timing span. Measured on the purpose-built `ShadowResidencyTest` scene: forced-record holds the point family at a median 0.264 ms/frame while reuse issues no span at all, with cold / reused / forced-record captures byte-identical. NOTE the honest scope — a cascade's matrix moves with the camera, so the durable win is punctual (#15), not CSM. | B | M | §2.1 | | 5 | Compute pre-skinning pass (unify with cloth pattern) | B | L | §1.3 | | 6 | Batch image barriers into single `DependencyInfo`s | C | S | §1.2 | | 7 | Physics per-step scratch persistence | B | S | §3.1 | diff --git a/docs/lod.md b/docs/lod.md index fadbfd6..9eccd13 100644 --- a/docs/lod.md +++ b/docs/lod.md @@ -87,7 +87,11 @@ Object::writeForwardUniforms() [per draw, per frame] [`shadowplans.md`](shadowplans.md) § SH-03 and `graphics/shadow_lod_resolver.hpp`. Since arc 2 #4 that resolution happens in the shadow pass's PREPARATION phase (`graphics/shadow_pass_prepare.hpp`), before anything is recorded, because the same walk decides - whether the view's map can be reused at all. Two caster + whether the view's map can be reused at all. **A reused view is still resolved in full** — the + comparison that decides reuse needs the resolved carrier, so shadow LOD selection costs the same + whether or not the map is re-rendered, and the resolver's hysteresis advances for reused views + exactly as it does for recorded ones. What reuse saves is GPU raster, never selection. Two + caster properties override that selection outright, and both are classified at this same seam: a caster that DEFORMS after the simplifier measured it (SH-04) and one whose coverage is an ALPHA CUTOUT (SH-05) resolve to the whole mesh, with `DeformableFallback` / `AlphaMaskedFallback` as the reason diff --git a/docs/onboarding.md b/docs/onboarding.md index d6387e3..4a90948 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -957,6 +957,29 @@ the same change — most have a test or guard that will catch you, but not all. one constructed value so they cannot drift), and the per-frame-ring buffer handles are carried for recording but EXCLUDED from the comparison, since identical content alternates handles every frame. +- **What an image HOLDS is committed only after the submit, and only for a view that recorded.** + `ShadowResidencyStore` (`graphics/shadow_pass_plan.hpp`) is the other operand of the disposition + law: preparation compares this frame's prepared content against it, and a view whose content + matches rasterises nothing. It is owned by `Shadows`, beside the depth images it is a record of — + which is the entire invalidation story, and why there is no `invalidate()` for anyone to forget: + recreating the images means reconstructing the object holding both. If in-place recreation ever + arrives, move the targets and the store into one private aggregate so replacing them stays a + single act. `Renderer::drawFrame` commits it beside `shadowLodResolver_.commitFrame()`, **after + `submitFrame` and before `presentFrame`** — which is why those are two functions: submission is + the moment the GPU owns the work, while presentation can throw (raii `presentKHR` does exactly + that on an out-of-date swapchain), so committing after it would let a resize skip the commit for a + frame whose depth was already being rasterised and leave the store describing the previous one. + Content adopted by a frame abandoned BEFORE the submit would claim an image holds pixels the GPU + never drew — the same rule read from the other end. Two rules live inside the store rather than at that call site — only a + `Recorded` view commits (a `Reused` view never touched its image, and its prepared work differs in + the diagnostic fields, so committing it would leave the record describing a frame that wrote + nothing), and an `Invalid` slot keeps what it had (nothing recorded means nothing overwrote the + image, so clearing it would force a re-render of content the image still holds). Adoption is a + MOVE (`ShadowFramePlan::takeRecorded`) and the whole path is `noexcept`: after the submit there is + no useful response to a failed allocation, and a throw there would leave residency describing the + previous frame while the images hold this one's depth. `RenderTunables::shadowResidencyReuseEnabled` + turns reuse off for an A/B — it is scheduling, not content, so it stays out of the descriptor and + a frame recorded with it off is reusable as soon as it is back on. - **A caster that deforms poisons its view's reuse, and a diagnostic row is claimed once per frame.** `PreparedShadowDraw::deformable` (from SH-04's classification) makes a draw compare unequal to everything including itself, because skinning rewrites vertices with no revision any compared diff --git a/docs/review-order.md b/docs/review-order.md index 8192b83..7a10b07 100644 --- a/docs/review-order.md +++ b/docs/review-order.md @@ -34,8 +34,9 @@ Read these first when a change touches build configuration, CI, or local tooling | `tools/assetgen/png.py` | Byte-deterministic RGBA PNG writer. Note WHY it exists: `zlib.compress` output varies with the zlib build, so a committed asset embedding a compressed texture could differ between machines. Image data goes into DEFLATE **stored** blocks (RFC 1951, fully specified) with a hand-written Adler-32. Don't "optimise" it into real compression. | | `graphics/shadow_bias.hpp` + `.cpp` | **SH-07's bias law — the EXECUTABLE SPECIFICATION** for what `shaders/shadow_bias.glsl` runs per fragment. Pure and headless-tested because the arithmetic is where the mistakes live: the law takes `worldUnitsPerTexel`, `normalizedDepthPerWorldUnit`, `nDotL` and `filterRadiusTexels`, works in WORLD units and converts once at the end, which is what keeps it projection-independent and stops a scale being applied twice (the defect: `exp2(cascade)` stood in for a texel footprint AND a depth-range conversion simultaneously). The per-projection metrics beneath it are where the geometry differs — note the spot conversion carries the RAY-FORWARD COSINE (the slope term is measured along the light ray, the stored depth is projected onto the cone axis; omitting it over-converts ~41% at 45° off-axis) and the point footprint follows the MAJOR AXIS while its comparison depth stays radial. Degenerate metrics return 0, never infinity: 0 means acne, which is visible and locatable; infinity detaches every shadow in the frame. | | `graphics/shadow_map_validity.hpp` + `.cpp` | **Which map families a frame RECORDS — one decision, used twice.** Pure and Vulkan-free: from `--no-shadows`, whether a primary directional light exists, and the per-family active view counts, it yields one bit per family. Since arc 2 #4 it is applied TWICE in a fixed order, both from `Renderer::prepareShadowPlan`: as ELIGIBILITY, from the COMPLETED view set (world-only is enabled last) BEFORE anything is prepared, because preparation resolves casters and stages hysteresis; and as CONFIRMATION (`shadowMapValidityFromPlan`), from the plan that was actually built and judged against the counts eligibility expected, which is what `uploadFrameLighting` puts in `LightUBO::shadowMapValidMask`. The receiver answers fully lit for a clear bit, and the pass records exactly the views that plan holds. The two halves are the same value on purpose — skipping a family without telling the shader leaves it sampling depth this frame never wrote, which no validation layer or crash will report. Read the WHOLE-FAMILY rule carefully: cascades and world-only need EVERY cascade (a fragment picks its layer by depth, so a missing layer is a hole, not three-quarters of a map) plus a real directional light (they are fitted to a fallback direction otherwise, describing a sun that is not in the scene); point needs whole cubes, leaning on `setPointLight`'s atomicity; self and spot are per-slot and any active slot validates them. | -| `graphics/shadow_pass_plan.hpp` + `.cpp` | **What a shadow view will RASTERISE, described exactly enough to decide whether last frame's image is still the right answer (arc 2 #4).** The hard part of caching a shadow map is the COMPARISON, not the skipping, so everything here is described in the values that reach the GPU — the model matrix written to `ShadowUBO`, the resolved index buffer, the effective cull, the extent and depth bias, and (point only) the light the radial ratio is measured against — never the higher-level quantities that explain them: two transforms can share an AABB, a snapped origin plus a near/far explains a matrix without being one, and a LOD level names a choice without being the geometry it selected. STRUCTURAL comparison, never a hash: a digest is a probabilistic argument for a decision whose failure is a silently wrong image. `PreparedShadowView` is ENCAPSULATED with per-kind factories, so a point face cannot be assembled with projected depth (the comparison would then omit the light while the shader still took its radial branch); its LAYERS come from its identity at construction, so an empty view still has the layers the recorder clears. Diagnostic fields (level, reason) and the recording payload (per-frame-ring buffer handles, which alternate every frame for identical content) are deliberately EXCLUDED from equality. A deformable draw never compares equal, not even to itself. `ShadowViewResidency` holds COMMITTED content only — adopted after submit, like the SH-03 hysteresis — and its absence is structural, because an image in the right layout is not an image holding an answer. `shadowMapValidityFromPlan` is the CONFIRMATION half of the validity law and needs the EXPECTED counts, not just the achieved ones: one of two spots preparing satisfies "some slot is sampleable" while the other light samples a stale map. | -| `graphics/shadow_pass_prepare.hpp` + `.cpp` | **The deciding half of the shadow pass (arc 2 #4).** Turns the frame's casters + the completed view set into a `ShadowFramePlan`: per view, claim the SH-01 row, filter, resolve the LOD, observe, build the prepared draws, then apply `shadowViewDisposition`. Vulkan-free, so every case that matters (a dropped caster, a suppressed family, a self view's two layers) is exercised without a device. Order is load-bearing three times over: FILTER before resolve (a caster this view drops must acquire no dead band against it); ELIGIBILITY before preparation (resolving STAGES hysteresis, so a family that will neither record nor be sampled must not be resolved at all) and CONFIRMATION after it; and families in RECORDING order — that last one for DETERMINISM rather than correctness, since the frame cache is keyed on caster id + generation + logical view and the world-only span is a subset of the same commands resolved against the same aliased view entry, so either order yields the same levels and what the stable order buys is comparable diagnostics between runs. Observation is per LAYER (a self view walks the caster set twice) while selection is counted once — its two layers are one decision. The view SET decides which slots exist: there are no active-count arguments to disagree with it. Terminal on a contradiction: a row claimed by two identities, a caster that resolves to no geometry, a caster with no stated pose (a default matrix compares equal forever, which is a map reused forever for something that is moving), or a view the plan refuses. | +| `graphics/shadow_view_disposition.hpp` + `.cpp` | **What a view DOES in a frame** — `Invalid` / `Reused` / `Recorded`, the two derived questions (`shadowViewSampleable`, `shadowViewRecords`), and `ShadowReusePolicy`. Its own header purely because two files that cannot include each other both need it: the plan produces a disposition, and the diagnostics (which the plan includes) report the one each row ended up with. The LAW that produces it stays in `shadow_pass_plan.hpp`, beside the residency and prepared-view types it reasons about. Note `Reused` is SAMPLEABLE and only `Recorded` is work — every downstream question asks through those two predicates rather than testing the enumerator, so "a reused map is still valid" is stated once. | +| `graphics/shadow_pass_plan.hpp` + `.cpp` | **What a shadow view will RASTERISE, described exactly enough to decide whether last frame's image is still the right answer (arc 2 #4).** The hard part of caching a shadow map is the COMPARISON, not the skipping, so everything here is described in the values that reach the GPU — the model matrix written to `ShadowUBO`, the resolved index buffer, the effective cull, the extent and depth bias, and (point only) the light the radial ratio is measured against — never the higher-level quantities that explain them: two transforms can share an AABB, a snapped origin plus a near/far explains a matrix without being one, and a LOD level names a choice without being the geometry it selected. STRUCTURAL comparison, never a hash: a digest is a probabilistic argument for a decision whose failure is a silently wrong image. `PreparedShadowView` is ENCAPSULATED with per-kind factories, so a point face cannot be assembled with projected depth (the comparison would then omit the light while the shader still took its radial branch); its LAYERS come from its identity at construction, so an empty view still has the layers the recorder clears. Diagnostic fields (level, reason) and the recording payload (per-frame-ring buffer handles, which alternate every frame for identical content) are deliberately EXCLUDED from equality. A deformable draw never compares equal, not even to itself. `ShadowViewResidency` holds COMMITTED content only — adopted after submit, like the SH-03 hysteresis — and its absence is structural, because an image in the right layout is not an image holding an answer. `ShadowResidencyStore` is one of those per physical slot and is the frame-to-frame half of the cache: OWNED BY `Shadows`, beside the images it describes, so reconstruction is the invalidation mechanism and no `invalidate()` exists to forget. Its `commit(plan)` carries two rules the call site therefore cannot get wrong — only a `Recorded` view is adopted (a `Reused` one never touched its image, and its prepared work differs in the diagnostic fields), and an `Invalid` slot is left alone (nothing recorded, so nothing overwrote the image, so the record is still true). An out-of-range address answers "nothing resident", which the law turns into `Recorded` — the safe direction, since a needless re-render costs a frame's raster while a wrong reuse shows shadows from a frame that is gone. `shadowMapValidityFromPlan` is the CONFIRMATION half of the validity law and needs the EXPECTED counts, not just the achieved ones: one of two spots preparing satisfies "some slot is sampleable" while the other light samples a stale map. | +| `graphics/shadow_pass_prepare.hpp` + `.cpp` | **The deciding half of the shadow pass (arc 2 #4).** Turns the frame's casters + the completed view set into a `ShadowFramePlan`: per view, claim the SH-01 row, filter, resolve the LOD, observe, build the prepared draws, then apply `shadowViewDisposition` against the residency store `Shadows` owns. Note what that ordering costs and does not: preparation runs in FULL for a view that turns out to be reused, because the comparison cannot be made without the work that produces its operand — this item buys GPU raster, not CPU preparation. Vulkan-free, so every case that matters (a dropped caster, a suppressed family, a self view's two layers) is exercised without a device. Order is load-bearing three times over: FILTER before resolve (a caster this view drops must acquire no dead band against it); ELIGIBILITY before preparation (resolving STAGES hysteresis, so a family that will neither record nor be sampled must not be resolved at all) and CONFIRMATION after it; and families in RECORDING order — that last one for DETERMINISM rather than correctness, since the frame cache is keyed on caster id + generation + logical view and the world-only span is a subset of the same commands resolved against the same aliased view entry, so either order yields the same levels and what the stable order buys is comparable diagnostics between runs. Observation is per LAYER (a self view walks the caster set twice) while selection is counted once — its two layers are one decision. The view SET decides which slots exist: there are no active-count arguments to disagree with it. Terminal on a contradiction: a row claimed by two identities, a caster that resolves to no geometry, a caster with no stated pose (a default matrix compares equal forever, which is a map reused forever for something that is moving), or a view the plan refuses. | | `graphics/shadow_face_cull.hpp` | SH-05's cull POLICY, Vulkan-free: `ShadowFaceCull` (what a family/layer wants) → `shadowEffectiveCull` (folded against the caster's `doubleSided`) → `ShadowEffectiveCull` (what the rasteriser does). It lives in `graphics/` because the cache's content descriptor has to record the EFFECTIVE answer, so the mapping cannot sit behind a Vulkan type; `render/shadows.hpp` keeps only the translation. Reversing an answer compiles and rasterises — a double-sided sheet front-culled casts nothing at all — so it is pinned by `tests/render/test_shadow_raster_policy.cpp`. | | `graphics/shadow_caster_alpha.hpp` + `.cpp` | **SH-05's classifier — the one place that decides whether a caster's shadow is its triangles or its cutout.** Returns `Masked` for `AlphaMode::Mask` alone: keyed on the DECLARED mode, not on whether a base-colour texture happens to be bound (a textureless MASK material still tests its base-colour factor's alpha, and a shadow that ignored it would occlude where the surface draws nothing), and BLEND maps to `Opaque` deliberately, leaving blend-shadow semantics an open decision. Same shape and same reasons as the SH-04 classifier below; both consumers (the resolver's LOD pin, the shadow pass' fragment path) read ONE derivation made in `Object::buildDrawCommands`. | | `graphics/shadow_caster_deformation.hpp` + `.cpp` | **SH-04's classifier — the one place that decides whether a caster's error claim is about the mesh that gets rasterised.** Returns `Deformable` for three carriers: a skinned/morph-weighted INSTANCE, morph-CAPABLE geometry (deliberately independent of current weights — classifying by weights would swap a caster's error model mid-animation), and storage-vertex geometry whose vertices a compute pass rewrites (cloth). A free function, not an `Object` member, so it is testable against a real `Geometry` without a GPU — classification and the resolver's response to it are pinned by separate tests, so neither is proven only by the other. | @@ -202,7 +203,7 @@ Read these first when a change touches build configuration, CI, or local tooling | File | Pay attention to | |---|---| | `render/environment_precompute.hpp` + `.cpp` | Equirect→cubemap, irradiance, prefilter, BRDF LUT at startup. | -| `render/shadows.hpp` + `shadows.cpp` | **High-attention.** CSM directional + world-only CSM, spot layers, point cubemap-array, **dual-depth per-skinned-object self-shadow** (two layers: capture nearest surface, then `cullMode=eFront` for the next occluder; in-shader `skinnedSelfShadowDepthEpsilon` safety net). `kMaxSkinnedSelfShadowCasters` cap. **Since arc 2 #4 the pass is PLAN-ONLY**: `recordPass(cmd, plan, stats, profiler, frameIndex)` — no draw spans, no view set, no resolver, no validity argument. Everything it rasterises was decided by `prepareShadowFrame`, and each view's disposition says whether it records at all, so a recorder that could re-filter or re-resolve would be a second answer to the question the cache has already answered. What is left here is genuinely Vulkan: per-layer barriers (ReadOnly → Attachment → ReadOnly), dynamic rendering, `layerTarget` resolving (family, layer kind) → image + attachment view + `ShadowPipelinePair` as ONE value (the self pair differs in both, and picking them separately would rasterise the second layer into the image it samples), and per-draw state. **SH-05's pipeline choice is per DRAW**: the pair is selected by the prepared draw's `alpha` — the same value the comparison holds — and the cull mode is the prepared draw's EFFECTIVE `ShadowEffectiveCull`, translated here and decided in `graphics/shadow_face_cull.hpp`. Push constants are pushed per draw because `materialIndex` varies per draw; every other field is the view's, from the prepared view (matrix, depth mode, point light). `beginRasterPass` CHECKS the row's claim rather than making it — preparation claimed it — so rasterising view B into view A's row is refused rather than reported under A's name. | +| `render/shadows.hpp` + `shadows.cpp` | **High-attention.** CSM directional + world-only CSM, spot layers, point cubemap-array, **dual-depth per-skinned-object self-shadow** (two layers: capture nearest surface, then `cullMode=eFront` for the next occluder; in-shader `skinnedSelfShadowDepthEpsilon` safety net). `kMaxSkinnedSelfShadowCasters` cap. **Since arc 2 #4 the pass is PLAN-ONLY**: `recordPass(cmd, plan, stats, profiler, frameIndex)` — no draw spans, no view set, no resolver, no validity argument. Everything it rasterises was decided by `prepareShadowFrame`, and each view's disposition says whether it records at all, so a recorder that could re-filter or re-resolve would be a second answer to the question the cache has already answered. What is left here is genuinely Vulkan: per-layer barriers (ReadOnly → Attachment → ReadOnly), dynamic rendering, `layerTarget` resolving (family, layer kind) → image + attachment view + `ShadowPipelinePair` as ONE value (the self pair differs in both, and picking them separately would rasterise the second layer into the image it samples), and per-draw state. **SH-05's pipeline choice is per DRAW**: the pair is selected by the prepared draw's `alpha` — the same value the comparison holds — and the cull mode is the prepared draw's EFFECTIVE `ShadowEffectiveCull`, translated here and decided in `graphics/shadow_face_cull.hpp`. Push constants are pushed per draw because `materialIndex` varies per draw; every other field is the view's, from the prepared view (matrix, depth mode, point light). `beginRasterPass` CHECKS the row's claim rather than making it — preparation claimed it — so rasterising view B into view A's row is refused rather than reported under A's name. **Also the owner of `ShadowResidencyStore`** (arc 2 #4's reuse half): the record of what these images hold lives beside the images themselves, so recreating them reconstructs it — there is no invalidation call to forget. `residency()` is what preparation compares against; `commitResidency(plan)` is called by the renderer AFTER the submit, never before. A `Reused` view is not touched at all here: no barrier, no clear, no draw, and its family stamps no timestamp, which is what makes the saving observable in the GPU timings rather than merely believed. | | `graphics/shadow_caster_bounds_frame.hpp` + `.cpp` | One frame's caster bounds and the single authority on them: built by `gatherShadowCasters` before the fit, read by the fit, the draw build and the diagnostics. Keyed by (`ShadowCasterId`, `ShadowCasterGeneration`); duplicate keys and missing lookups are TERMINAL, because both mean the prepass and the draw walk disagree about what the scene contains, and the alternative (a recompute, or a default empty box at the origin) is exactly the silent divergence this type exists to prevent. Lifetime is one frame — `reset()` per prepass, nothing cached on `Object`. | | `graphics/shadow_caster_bounds.hpp` | The SH-06 prepass type: one shadow caster's world bounds, its identity, and a `ShadowCasterBoundsKind` saying whether those bounds can be TRUSTED. `Exact` means the bounds were computed from the vertices that will draw, in their current pose (skinning and morph applied); `Stale` means a compute pass rewrites the vertices (cloth), so the CPU copy is the bind pose and the drawn geometry can be anywhere. The distinction is load-bearing: the depth range is fitted to these, and a range fitted to bounds that understate the geometry clips it — which is the defect the fixed extension was hiding. | | `render/cascade_fit.hpp` + `cascade_fit.cpp` | **High-attention (SH-06).** The CSM cascade fit as pure, Vulkan-free carriers: `CascadeReceiverFit::fit` produces the slice's stable light-space XY footprint, texel grid and **exact receiver min/max W from the eight corners**; `fitCasterAwareCascadeDepth` is the depth POLICY — near plane back to the furthest-upstream candidate caster (`classifyFootprint`, which is deliberately depth-INDEPENDENT so the policy never needs a depth range to choose one), far plane to the receiver volume, one `worldPerTexel` of slack on BOTH planes (that widens the depth span by an XY texel's world size; it is not a unit of depth precision). `fitLegacyCascadeDepth` remains as the pre-SH-06 fixed-extension fit and as the stale fallback. `backExtend` is IRRELEVANT on the Exact-only path — passing a NaN there still fits — and used only by the fallback. Both `CascadeReceiverFit` and `CascadeDepthFit` are ENCAPSULATED (read-only accessors, factory-only construction): the depth fit became a class when it gained a `CascadeDepthFitMode` (`LegacyFixedExtension` / `CasterAware` / `LegacyStaleFallback`), because a public aggregate would let a caller pair a mode with a matrix that did not produce it. A single `Stale` caster anywhere in the frame forces the legacy range; a non-finite Exact bound is TERMINAL, never skipped. Note the receiver fit is fed a slice that starts inside the previous cascade's blend band (`kShadowCascadeBlendFraction`, uploaded in `LightUBO::cascadeParams` so the shader and the fit share one value), since those receivers sample this cascade's map. `tests/render/test_cascade_fit.cpp` holds a verbatim copy of the pre-extraction lambda and asserts bit-identical legacy matrices. | @@ -217,9 +218,9 @@ Read these first when a change touches build configuration, CI, or local tooling | `render/taa.hpp` + `taa.cpp` | **High-attention.** Temporal AA subsystem. Owns the RG16F velocity target (written by the forward/transmission passes as a 2nd colour attachment), two ping-pong history HDR targets, and the resolve pass (`taa.frag`): reproject history along velocity → 3×3 neighbourhood clamp → blend → blit into the offscreen HDR target. `historyWritten_` guards the first frame after (re)create. Sub-pixel jitter lives in `Renderer::drawFrame`; motion vectors are jitter-free. | | `render/particle_system.hpp` + `particle_system.cpp` | Renderer-owned GPU particle system. Pooled SSBO partitioned per emitter; compute sim (`particle_simulate.comp`) → buffer barrier → instanced additive billboards into HDR (soft particles via sampled scene depth). Records after the TAA resolve (un-jittered, kept out of history), before post-process. | | `render/soft_body_system.hpp` + `soft_body_system.cpp` | **High-attention.** GPU XPBD cloth solver. Descriptor-free: four compute pipelines (`cloth_predict`/`solve`/`collide`/`finalize`) take every buffer as a `bufferDeviceAddress` pointer in the push constant — per-cloth particle/constraint buffers + the render vertex buffer + a per-frame collider buffer, all `eShaderDeviceAddress`. `recordSolve` = per-substep `predict → per-colour solve → collide`, then `finalize` writes solved positions + normals (recomputed from the per-cloth CSR adjacency, arbitrary topology) into the cloth's storage vertex buffer (compute-write → vertex-input-read barrier). Reads `ClothSimParams` (overlay; compliance is a global multiplier on each constraint's authored per-type stiffness); colliders from `PhysicsWorld::gatherColliders`. Cloths come from the `-c` demo or glTF `extras.Cloth`. | -| `render/render_tunables.hpp` | Plain struct of live, overlay-editable render params (TAA, **`cullingEnabled`**, **`lodEnabled`/`lodPixelErrorBudget`**, debug view, bloom/IBL/sun, particle scales) + the `DebugView` enum (incl. `Lod` and `ShadowLod` tints and `Joints` — the latter has no shader branch: it suppresses the scene mesh and draws the ragdoll joint gizmo/labels instead, and maps to `None` for the shader, so keep it LAST after any new shader-backed view) + `kDebugViewNames` beside it with a count `static_assert`, so adding a view without naming it fails to compile. Seeded from `constants.hpp` + CLI flags; the renderer reads it instead of the `constexpr`s. Read this first — it's the contract between the overlay and the renderer. | +| `render/render_tunables.hpp` | Plain struct of live, overlay-editable render params (TAA, **`cullingEnabled`**, **`lodEnabled`/`lodPixelErrorBudget`**, debug view, bloom/IBL/sun, particle scales) + the `DebugView` enum (incl. `Lod` and `ShadowLod` tints and `Joints` — the latter has no shader branch: it suppresses the scene mesh and draws the ragdoll joint gizmo/labels instead, and maps to `None` for the shader, so keep it LAST after any new shader-backed view) + `kDebugViewNames` beside it with a count `static_assert`, so adding a view without naming it fails to compile. Seeded from `constants.hpp` + CLI flags; the renderer reads it instead of the `constexpr`s. Read this first — it's the contract between the overlay and the renderer. Since arc 2 #4 also **`shadowResidencyReuseEnabled`** (default true): off, every engaged shadow view records even when its image already holds identical content. It is the A/B for the shadow cache and deliberately NOT part of the content descriptor — scheduling, not pixels — so a frame recorded with it off is reusable the moment it goes back on. | | `render/gpu_profiler.hpp` + `gpu_profiler.cpp` | Timestamp `VkQueryPool` ring (`kMaxFramesInFlight` slots). `begin/end(pass)` write a pair, BOTH at bottom-of-pipe — one convention engine-wide, because a top-of-pipe begin fires while the previous pass is still draining and two adjacent sub-millisecond passes then each report time the other spent (the shadow families already stamped bottom-to-bottom, so the frame sum used to mix conventions). The trade is stated in the header: a bubble before a pass is charged to it. Deltas are MODULAR in the queue's `timestampValidBits` — Vulkan leaves the upper bits undefined and defines overflow as wrapping inside that width, so a decreasing raw pair is a wrap, not an anomaly. `resolve` reads the slot a cycle later (safe — the acquire timeline-wait guarantees that frame finished) into `FrameStats`. `slotUsed_` guards reading never-reset queries; `eWithAvailability` skips passes that didn't run. **`vk::Result::eNotReady` is the NORMAL result of that read and must not be treated as failure** — without `WAIT`, `vkGetQueryPoolResults` returns it whenever ANY query in the range is unavailable, and some always are (a pass that did not run leaves its pair reset and unwritten), while the availability words are still written. Bailing on it is what kept per-pass timing dark on every device for months and got the feature parked as a MoltenVK limitation. The arithmetic and the availability policy live in the free `resolveTimestampWords`, which is Vulkan-free and unit-tested (`tests/render/test_gpu_profiler.cpp`) — the GPU call is the only part that needs a device. `GpuTimingState` distinguishes Unsupported / WarmingUp / Valid so the overlay cannot report a live bug as a device limitation again. `FrameStats::gpuMeasuredPassSumMs` is named for what it is — the instrumented passes only, not frame latency. Disabled (with a WARN naming both numbers) when `timestampPeriod==0` / `timestampValidBits==0`. `FrameStats` also carries the frustum-cull tracked/culled counts (populated in `collectDrawCommands`, shown a frame later) and `vdpmGpuAvailable` (B5c-3 — set there from `vdpmManager_ != nullptr`, i.e. device capability independent of whether the GPU front is currently active; drives the overlay's backend checkbox enable/label). | -| `render/debug_overlay.hpp` + `debug_overlay.cpp` | Dear ImGui owner (context + GLFW/Vulkan backends, dynamic rendering). `buildUi(stats, tunables)` builds the panels (incl. the **Culling** group: `cullingEnabled` toggle + tracked/visible/culled readout); `record` draws into the swap image (loadOp Load). `drawWorldLabels(labels, viewProj)` projects world-anchored `DebugLabel`s (ragdoll joint index:name) into the ImGui **foreground draw list** — call it after buildUi and after the frame's `viewProj` is finalised; it maps via `DisplaySize` (retina-correct, not the pixel extent). The Mesh LOD panel's view-dependent block carries the **B5c-3 "GPU-driven front" backend checkbox** (writes `tunables.vdpmGpuBackend`, a reload-free flip — see the manager construction invariant); gated on `stats.vdpmGpuAvailable`, else a disabled checkbox + explicit "(unsupported on this device)" label rather than a silent disable. The **Shadows (SH-01)** panel prints `FrameStats::shadow` per view family and physical slot: raster passes + drawn/candidate draws and triangles (work) kept visually separate from the L0..L3+ columns (LOD selections of DRAWN casters — rejected candidates are never resolved and have no level — counted once per *logical* view, since the self families rasterise twice and are sampled once). Note the two d/c pairs mean different things since SH-03: draws are drawn-over-offered (the cull yield), triangles are drawn-over-FULL-DETAIL (culling and LOD together). Clicking a slot row writes `RenderTunables::shadowViewFocus` — the row's LOGICAL identity plus its group, never its slot, because punctual/self slots compact and a slot-keyed focus would silently retarget to the replacement light (worse once slice 5's tint reads the same focus: the panel shows a completed ring frame while the tint samples the current one). `ShadowFrameStats::focused` therefore SEARCHES the group for that identity and returns the slot it was found in, which is what the header labels. Three outcomes are worded differently on purpose: the rollup, "selection is not a valid view" (structurally unaddressable — `addressable()` also rejects an identity whose KIND cannot occur in the group, e.g. a cascade id under Spot, which would otherwise look valid and then never be found), and "not present in this frame" (well-formed but not found — deliberately silent on whether it returns, since a deleted light and a view that merely did not rasterise are indistinguishable without scene liveness). `beginRasterPass` validates BEFORE mutating and refuses a second, different identity on a row (returning false, changing nothing); the shadow pass treats that as terminal, because merging two views' counters under one name yields a row that reads like a measurement of something that never existed. Column weights are explicit — proportional sizing starved the level columns to one ellipsised character. Timing cells come from `shadowProfilePass(group)`; a slot row shows an em dash (timestamps bracket a family, not a map), and the whole panel shows "pending" while `shadowValid` is false rather than a zeroed table. Point rows decode the flat slot back to `slot / 6` + `slot % 6` and are labelled *slots*, not lights — slot assignment is per-frame order, not an identity. Non-movable (ImGui global state). ImGui core plus GLFW/Vulkan backends come from the vcpkg `imgui[glfw-binding,vulkan-binding]` manifest dependency; `cmake/fireengine_imgui.cmake` wraps the ImGui archive so `fireengine` keeps direct ownership of Vulkan/GLFW linkage. | +| `render/debug_overlay.hpp` + `debug_overlay.cpp` | Dear ImGui owner (context + GLFW/Vulkan backends, dynamic rendering). `buildUi(stats, tunables)` builds the panels (incl. the **Culling** group: `cullingEnabled` toggle + tracked/visible/culled readout); `record` draws into the swap image (loadOp Load). `drawWorldLabels(labels, viewProj)` projects world-anchored `DebugLabel`s (ragdoll joint index:name) into the ImGui **foreground draw list** — call it after buildUi and after the frame's `viewProj` is finalised; it maps via `DisplaySize` (retina-correct, not the pixel extent). The Mesh LOD panel's view-dependent block carries the **B5c-3 "GPU-driven front" backend checkbox** (writes `tunables.vdpmGpuBackend`, a reload-free flip — see the manager construction invariant); gated on `stats.vdpmGpuAvailable`, else a disabled checkbox + explicit "(unsupported on this device)" label rather than a silent disable. The **Shadows (SH-01)** panel prints `FrameStats::shadow` per view family and physical slot, now including a **State** column carrying each row's `ShadowViewDisposition` (arc 2 #4) — zero raster passes alone cannot separate a reused map from a view that never engaged, and since the cache landed the first is the healthy steady state; rollup rows print an em dash, because a family can be half reused and has no single schedule. Beside it: raster passes + drawn/candidate draws and triangles (work) kept visually separate from the L0..L3+ columns (LOD selections of DRAWN casters — rejected candidates are never resolved and have no level — counted once per *logical* view, since the self families rasterise twice and are sampled once). Note the two d/c pairs mean different things since SH-03: draws are drawn-over-offered (the cull yield), triangles are drawn-over-FULL-DETAIL (culling and LOD together). Clicking a slot row writes `RenderTunables::shadowViewFocus` — the row's LOGICAL identity plus its group, never its slot, because punctual/self slots compact and a slot-keyed focus would silently retarget to the replacement light (worse once slice 5's tint reads the same focus: the panel shows a completed ring frame while the tint samples the current one). `ShadowFrameStats::focused` therefore SEARCHES the group for that identity and returns the slot it was found in, which is what the header labels. Three outcomes are worded differently on purpose: the rollup, "selection is not a valid view" (structurally unaddressable — `addressable()` also rejects an identity whose KIND cannot occur in the group, e.g. a cascade id under Spot, which would otherwise look valid and then never be found), and "not present in this frame" (well-formed but not found — deliberately silent on whether it returns, since a deleted light and a view that merely did not rasterise are indistinguishable without scene liveness). `beginRasterPass` validates BEFORE mutating and refuses a second, different identity on a row (returning false, changing nothing); the shadow pass treats that as terminal, because merging two views' counters under one name yields a row that reads like a measurement of something that never existed. Column weights are explicit — proportional sizing starved the level columns to one ellipsised character. Timing cells come from `shadowProfilePass(group)`; a slot row shows an em dash (timestamps bracket a family, not a map), and the whole panel shows "pending" while `shadowValid` is false rather than a zeroed table. Point rows decode the flat slot back to `slot / 6` + `slot % 6` and are labelled *slots*, not lights — slot assignment is per-frame order, not an identity. Non-movable (ImGui global state). ImGui core plus GLFW/Vulkan backends come from the vcpkg `imgui[glfw-binding,vulkan-binding]` manifest dependency; `cmake/fireengine_imgui.cmake` wraps the ImGui archive so `fireengine` keeps direct ownership of Vulkan/GLFW linkage. | | `render/renderer.hpp` + `src/render/renderer.cpp` | **Capstone.** `drawFrame` = named phases (`updateFrameLighting`/`collectDrawCommands`/`recordShadowPass`/`recordForwardPass`/`recordTransmissionPass`/`recordPostProcessing`) plus the inline `taa_.recordResolve`, `recordParticlePass`, `overlay_.record`, and `transitionSwapchainToPresent` (present-split: post-process leaves the swap image in colour-attachment layout). Each pass is wrapped in a `profiler_` scope. Reads `tunables_` for debug view, IBL/bloom/sun, TAA params, particle scales. Study pass ordering, the jitter-free `currentViewProj_`/`previousViewProj_` matrices, `recreateSwapchain` + `buildGlobalDescriptorRequest`. `collectDrawCommands` runs the coarse frustum pre-cull (builds camera + shadow frustums → `scene.cull`); `buildDrawBuckets` does the precise per-camera cull. **Forward command-order invariant:** when a forward pipeline becomes active, push set 0 before binding allocated sets 1/2 through that same layout; higher-set binds preserve set 0. Keep this in sync with transmission recording. | ## Tier 11 — Shaders (verify against the C++ they mirror) diff --git a/docs/roadmap.md b/docs/roadmap.md index ef72386..8977f19 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,41 +81,15 @@ The eight small/XS items landed on `review-shadow-taa-fixes` + `review-xs-cleanu the five the review prioritised, then five a later coverage audit found had no action item. In the review's priority order: -- **#4 [B/M] Static-scene CSM caching** (§2.1) — **in progress on `shadow-static-cascade-cache`.** - The §5.1 epoch idea was rejected once the comparison was written out: an epoch explains a matrix - without being one, and the failure mode is a silently wrong image (shadows from a frame that no - longer exists), so the descriptor is exact and structurally compared, never hashed. - **Landed on the branch:** the content descriptor and disposition law - (`graphics/shadow_pass_plan.hpp`), and the PREPARATION phase that builds it - (`graphics/shadow_pass_prepare.hpp`) — filtering, per-view LOD resolution and the SH-01 row - claim/observations all moved out of recording, leaving `Shadows::recordPass` consuming the plan - and nothing else. Two parallel authorities went with it: the shadow transform (now one push - constant per recorded view, guarded by `shadow_matrix_guard`) and the point light's - position + range (now in the view set, not a renderer-side array). - **Remaining:** the per-slot residency store, committed only after submit, and the reuse it - enables — every view is still `Recorded`, which is what let the restructure be verified as - decision-identical. Then #15 below, which is the same mechanism. - - **The gate for that stage is agreed, and it is NOT the byte-identical row dump** — that gate - worked precisely because nothing changed, and reuse changes what the recorder does. The claim to - prove is: *on the second identical frame, every active CACHEABLE view is reused and every - deformable view is still recorded* — "every view is reused" is false for any self-shadow scene - under the current deformation law, and a gate asserting it would be failed by correct code. Three - layers of evidence: - 1. **Headless two-frame test.** First preparation records; a simulated post-submit commit installs - residency; the second identical preparation reuses the cacheable views. Also: an abandoned - frame commits nothing, and image recreation invalidates residency. - 2. **Mixed-content test.** Rigid views reuse while deformable views record in the same frame, and - the confirmed validity stays set for both — `Reused` is sampleable, so a family that is half - reused is fully valid. - 3. **Real-GPU gate**, as a local Vulkan script rather than a mocked command buffer: a mock would - test a second implementation of recording, while the real gate proves a reused view receives no - barrier, no clear and no draw *and* that its existing depth still shades correctly. On a static - `--no-taa` scene: the reused-frame capture matches the cold recorded reference; cacheable rows - keep identical candidate/drawn/LOD observations while reporting ZERO raster passes; a fully - reused family stamps no timestamps; validation stays VUID-free. - Contracts consumed: SH-01's diagnostics and SH-03's per-view LOD contract - ([`shadowplans.md`](shadowplans.md) § Interaction). +- **#15 [B/C, M] Punctual-shadow change detection** (§2.3) — **next, and now mostly verification.** + Arc 2 #4 landed the whole mechanism on `shadow-residency-reuse` and it is family-agnostic: a + static point light's six faces already reuse today, which is what the gate scene measures. What + this item still owes is the evidence and the follow-through — a scene with a light that MOVES + (proving the faces re-record on the frame the light's position or range changes, since both are in + the content descriptor), a spot equivalent, and a decision about per-face granularity: the cube is + compared per face, but slot assignment is per-light, so a light entering or leaving reshuffles + slots and invalidates its neighbours' residency by identity. Worth measuring before assuming it + matters. (Per-face frustum filtering already exists and is correct.) - **#5 [B/L] Compute pre-skinning pass** (§1.3) — skinning/morphing re-runs in every pass's vertex shader (~11× per skinned vertex per frame). `SoftBodySystem` already proves the compute pattern in-engine. The one genuinely architectural piece here; it also retires SH-04's deformable @@ -133,10 +107,6 @@ items here. All five are genuinely lower-value than the above — three are cond in the review's own words — and are recorded so the arc is scoped honestly, not because each is worth doing: -- **#15 [B/C, M] Punctual-shadow change detection** (§2.3) — spot and point casters re-render every - face every frame even when the light and the geometry in range are static; a point light is - 6 × 1024² per frame. Same epoch/dirty-bit mechanism as #4, so do them together. (Per-face frustum - filtering already exists and is correct — this is about skipping the re-render entirely.) - **#16 [C, XS] `hash_combine`-style mix for the mesh-triangle warm-start key** (§3.3) — `in.key ^= subKey * 0x9E3779B97F4A7C15ULL` (`physics_world.cpp`) is a decent mix, but XOR over the pair key admits collisions across (pair, triangle) combinations. The consequence is only a wrong diff --git a/docs/shadowplans.md b/docs/shadowplans.md index bbdd78d..4499f29 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -1004,6 +1004,40 @@ contracts from this work: holds its casters without drawing them), and the ShadowLod tint reads that content during collection instead of waiting for the shadow pass. Staging is unchanged — levels still commit only after the frame is submitted. + + **Reuse itself landed next (`shadow-residency-reuse`).** `ShadowResidencyStore` records what each + physical view's depth image HOLDS; preparation compares this frame's prepared content against it + and marks each view `Reused` or `Recorded`; `Shadows::recordPass` skips a reused view entirely — + no barrier, no clear, no draw — and a family that records nothing opens no timing span. Four + things about it are contracts rather than implementation details, and anything built on this plan + should treat them as such: + + 1. **The store is owned by `Shadows`, beside the images it describes.** There is no `invalidate()` + to forget: recreating the images means reconstructing the object that owns both. If in-place + recreation ever arrives, the targets and the store move into one private aggregate together. + 2. **Adoption happens between the SUBMIT and the PRESENT, and cannot throw.** Submission is the + moment the GPU owns the work; presentation is a separate act that can fail (raii `presentKHR` + throws on `eErrorOutOfDateKHR`). A resize that threw past the commit would leave the store + describing the previous frame while the images held this one's depth — a stale map arriving + through the error path. Adoption therefore MOVES the recorded views out of the plan + (`ShadowFramePlan::takeRecorded`) rather than copying them, and is `noexcept`. + 3. **Only a `Recorded` view commits; an `Invalid` slot keeps what it had.** A reused view never + touched its image, and an unengaged one did not overwrite it, so its record is still true. + 4. **`Reused` is sampleable.** A family that is half reused is fully valid, and the receiver is + told exactly what it was told when the frame rasterised. + + **Measured** on `assets/shadow_residency/ShadowResidencyTest.gltf` (one static point light, six + cube faces, camera parked, macOS/arm64 + MoltenVK): forced-record holds the point family at a + median **0.264 ms** per frame (29 warm samples, 0.164–0.312), while reuse issues **no timing span + at all** — every sample reads `recorded=0 reused=6 passes=0`. The reused frame, the cold recorded + frame and a same-state forced-record frame are byte-identical captures. The A/B switch is + `--no-shadow-reuse` (overlay: "Reuse unchanged shadow views"); the runbook is + [`acceptance-testing.md`](acceptance-testing.md) § Shadow-residency gate scene. + + **What this does NOT buy: CPU preparation.** A reused view is filtered, resolved and observed in + full — the comparison cannot be made without the work that produces its operand. The saving is + GPU raster only, which is also why the honest headline case is a static PUNCTUAL light rather than + the cascades: a cascade's matrix moves with the camera, so a moving camera re-records it. - **Compute pre-skinning:** expose the pre-deformed vertex buffer, exact deformed bounds, and a deformation revision through the shadow draw description. It can then replace the LOD0 deformable fallback and stop rerunning skin/morph work in every pass. diff --git a/include/fire_engine/graphics/shadow_diagnostics.hpp b/include/fire_engine/graphics/shadow_diagnostics.hpp index f8c89b5..912860f 100644 --- a/include/fire_engine/graphics/shadow_diagnostics.hpp +++ b/include/fire_engine/graphics/shadow_diagnostics.hpp @@ -7,6 +7,7 @@ #include #include +#include namespace fire_engine { @@ -226,6 +227,12 @@ struct ShadowViewStats // Invalid only on a view this frame's plan never CLAIMED — which is not the same as one that // never rasterised: a claimed view may legitimately record nothing. ShadowLogicalViewId logicalId{}; + // What the plan decided this view would DO — recorded alongside the identity so the panel can + // tell apart two rows that look identical in every counter: one that recorded an empty map and + // one that reused an empty map it recorded earlier. Both report zero draws; only the first + // spent a clear and two layout transitions on the GPU, and only the second is the cache + // working. `Invalid` on a row the plan never claimed. + ShadowViewDisposition disposition{ShadowViewDisposition::Invalid}; // ENGAGES this row and states WHICH view it is — the identity, and nothing about work. Called // when the frame's plan claims this slot, which happens whether or not anything is recorded @@ -255,6 +262,15 @@ struct ShadowViewStats // Returns false and counts NOTHING if the row was never claimed or holds a different identity. // Terminal at the caller, for the same reason a merged row is. [[nodiscard]] bool beginRasterPass(ShadowLogicalViewId view) noexcept; + // What this view will do, from the plan that decided it. Called by PREPARATION once the + // disposition exists — after the draws are built, since the comparison needs them. + // + // The identity is CHECKED against the claim, never re-claimed, exactly as `beginRasterPass` + // checks it: a disposition recorded under another view's name would label one view's work with + // another's schedule. Returns false and changes nothing when the row was never claimed or holds + // a different identity; terminal at the caller, like its neighbours. + [[nodiscard]] bool noteDisposition(ShadowLogicalViewId view, + ShadowViewDisposition value) noexcept; // THE FOUR OBSERVATION RULES. Every per-view number in the panel is only readable because these // hold together; each one exists because breaking it produced a plausible-looking wrong answer: // diff --git a/include/fire_engine/graphics/shadow_pass_plan.hpp b/include/fire_engine/graphics/shadow_pass_plan.hpp index 8f81a59..24764dc 100644 --- a/include/fire_engine/graphics/shadow_pass_plan.hpp +++ b/include/fire_engine/graphics/shadow_pass_plan.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include // What a shadow view will RASTERISE this frame, described exactly enough to decide whether last @@ -298,35 +299,6 @@ class PreparedShadowView std::size_t layerCount_{0}; }; -// What a view does this frame. Three states, and deliberately NOT folded into `ShadowMapValidity` — -// that type answers the shader's question ("is this map safe to sample"), which stays Boolean. This -// answers the pass's question, which is a different one: a CSM with two cascades recorded and two -// reused is entirely valid and half the work. -enum class ShadowViewDisposition : std::uint8_t -{ - // Not engaged this frame, or engaged with nothing sampleable behind it. Nothing to record and - // nothing to sample. - Invalid, - // Resident content matches; the image already holds the right depth. Records nothing. - Reused, - // Records this frame, either because the content changed or because it can never be cached. - Recorded, -}; - -[[nodiscard]] std::string_view toString(ShadowViewDisposition disposition) noexcept; - -// The two derived questions. Everything downstream asks one of these rather than testing the -// enumerator, so "reused counts as sampleable" is stated once. -[[nodiscard]] constexpr bool shadowViewSampleable(ShadowViewDisposition disposition) noexcept -{ - return disposition == ShadowViewDisposition::Reused || - disposition == ShadowViewDisposition::Recorded; -} -[[nodiscard]] constexpr bool shadowViewRecords(ShadowViewDisposition disposition) noexcept -{ - return disposition == ShadowViewDisposition::Recorded; -} - // One physical view slot's committed content — what its depth image actually holds. // // COMMITTED is the load-bearing word. A frame that prepared content and then never submitted (an @@ -361,13 +333,12 @@ class ShadowViewResidency { content_ = std::move(content); } - // Forget it: the image was recreated or destroyed (a resize, a device loss), so whatever it - // held is gone. The owner of the images must call this, or a reuse would sample a fresh - // allocation's undefined depth. - void invalidate() noexcept - { - content_.reset(); - } + // There is deliberately NO `invalidate()`. An image that is recreated takes its record with it, + // because `ShadowResidencyStore` lives inside `Shadows` alongside the images themselves — + // reconstruction IS the invalidation, so there is nothing for a caller to remember to call and + // no window in which a store can disagree with the images it describes. If in-place recreation + // ever arrives, the targets and the store move into one private aggregate together rather than + // this hook coming back. private: std::optional content_{}; @@ -378,7 +349,7 @@ class ShadowViewResidency // `active` is the view set's answer (SH-03): a slot the set reports inactive is Invalid regardless // of what its image holds, because nothing this frame vouches for the matrix behind it. [[nodiscard]] ShadowViewDisposition -shadowViewDisposition(bool active, const PreparedShadowView& prepared, +shadowViewDisposition(bool active, ShadowReusePolicy reuse, const PreparedShadowView& prepared, const ShadowViewResidency& resident) noexcept; // One frame's prepared work for every physical shadow view, plus what each will do. @@ -474,6 +445,20 @@ class ShadowFramePlan // depends on which way the receiver faces. [[nodiscard]] bool pointCubesWhole() const noexcept; + // Hands this slot's RECORDED content over to residency, emptying the entry. + // + // A move, not a copy, and that is a correctness property rather than an optimisation: adoption + // happens AFTER the queue has accepted the frame, where a throwing allocation would abandon + // work the GPU is already executing. Moving a prepared view allocates nothing (the static + // asserts in the .cpp pin that), so the post-submit path cannot fail. + // + // Empty for any other disposition, so the store's "Recorded only" rule is expressed once more + // in the type that owns the content. The entry is CLEARED rather than left holding a moved-from + // view: the plan is reset at the start of the next preparation, and until then it should say + // the content is gone instead of describing a husk. + [[nodiscard]] std::optional takeRecorded(ShadowViewGroup group, + std::size_t slot) noexcept; + private: struct Entry { @@ -487,4 +472,42 @@ class ShadowFramePlan std::array entries_{}; }; +// What every physical shadow view's depth image HOLDS — the frame-to-frame half of the cache, and +// the other operand of `shadowViewDisposition`. +// +// OWNED BY THE IMAGES' OWNER (`render/shadows.cpp`), never by the frame. A plan describes one +// frame's intent and is reset at the start of the next; this describes durable GPU content, so it +// lives beside the images it is a record of. That is also the whole invalidation story: there is no +// `invalidate()` to forget to call, because recreating the images means reconstructing the object +// that holds both them and this. +// +// Indexed by the same physical `(group, slot)` as the plan, the view set and the diagnostics, so a +// row, a timing, a plan entry and a residency entry all name one view. +class ShadowResidencyStore +{ +public: + // What this slot's image holds. An out-of-range address answers "nothing resident", which the + // law turns into `Recorded` — the conservative direction: a spurious re-render costs a frame's + // raster, while a spurious reuse shows shadows from a frame that is gone. + [[nodiscard]] const ShadowViewResidency& at(ShadowViewGroup group, + std::size_t slot) const noexcept; + + // Adopt what the frame actually recorded, CONSUMING it. Call AFTER the queue has accepted the + // work: content committed by a frame that was abandoned would claim an image holds pixels + // nothing ever drew. `noexcept`, because this runs on the far side of the submit — see + // `ShadowFramePlan::takeRecorded`. + // + // RECORDED ONLY, and the filter lives here rather than at the call site so there is one place + // that decides. A `Reused` view did not touch its image; committing its prepared work would + // replace the record of what the image holds with a description of a frame that never wrote to + // it — equal in every compared field, by construction, but no longer the recording that made + // the depth. An `Invalid` slot is left alone for the same reason read the other way: nothing + // recorded, so nothing overwrote the image, so the existing record is still true — clearing it + // would force a re-record of content the image still holds. + void commit(ShadowFramePlan& plan) noexcept; + +private: + std::array entries_{}; +}; + } // namespace fire_engine diff --git a/include/fire_engine/graphics/shadow_pass_prepare.hpp b/include/fire_engine/graphics/shadow_pass_prepare.hpp index 8c0e0f5..a09bce2 100644 --- a/include/fire_engine/graphics/shadow_pass_prepare.hpp +++ b/include/fire_engine/graphics/shadow_pass_prepare.hpp @@ -70,6 +70,11 @@ struct ShadowPreparationInputs // When false no frustum is built, and every family's filter passes everything through — the // `--no-cull` path, which must produce the same IMAGE with more draws. bool cullingEnabled{true}; + // Arc 2 #4's A/B (`RenderTunables::shadowResidencyReuseEnabled`). False makes every engaged + // view record even when its image already holds identical content — the pass as it behaved + // before the cache existed. Preparation is unchanged either way: the same filtering, the same + // resolution, the same observations, and the same commit afterwards. + bool residencyReuseEnabled{true}; // Indexed by ShadowViewGroup. std::array raster{}; @@ -92,8 +97,15 @@ struct ShadowPreparationInputs // a producer bug, and both ways of continuing are worse than stopping: dropping the view leaves a // shadow map holding another frame's content with nothing to say so, and degrading through it // produces counters that read like a measurement and are not. +// `residency` is what each physical view's depth image currently HOLDS (`render/shadows.cpp` owns +// it, beside the images). It is the other operand of the disposition law: a view whose prepared +// content matches what its image already holds is `Reused` and rasterises nothing. An empty store — +// a first frame, or images that have just been created — makes every view record, which is the +// conservative direction: a needless re-render costs one frame's raster, while a wrong reuse shows +// shadows from a frame that no longer exists. void prepareShadowFrame(const ShadowPreparationInputs& inputs, const ShadowRenderViewSet& views, - ShadowMapValidity eligible, ShadowLodResolver& resolver, - ShadowFrameStats& stats, ShadowFramePlan& plan); + ShadowMapValidity eligible, const ShadowResidencyStore& residency, + ShadowLodResolver& resolver, ShadowFrameStats& stats, + ShadowFramePlan& plan); } // namespace fire_engine diff --git a/include/fire_engine/graphics/shadow_view_disposition.hpp b/include/fire_engine/graphics/shadow_view_disposition.hpp new file mode 100644 index 0000000..fea1409 --- /dev/null +++ b/include/fire_engine/graphics/shadow_view_disposition.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +// What a shadow view DOES in a frame, and whether this frame takes reuse at all (arc 2 #4). +// +// Its own header because two files that cannot include each other both need it: the plan +// (`shadow_pass_plan.hpp`) produces a disposition per view, and the diagnostics +// (`shadow_diagnostics.hpp`, which the plan includes) report the one each row ended up with. The +// LAW that produces it — what counts as identical content — stays with the plan, beside the +// residency and prepared-view types it reasons about. + +namespace fire_engine +{ + +// What a view does this frame. Three states, and deliberately NOT folded into `ShadowMapValidity` — +// that type answers the shader's question ("is this map safe to sample"), which stays Boolean. This +// answers the pass's question, which is a different one: a CSM with two cascades recorded and two +// reused is entirely valid and half the work. +enum class ShadowViewDisposition : std::uint8_t +{ + // Not engaged this frame, or engaged with nothing sampleable behind it. Nothing to record and + // nothing to sample. + Invalid, + // Resident content matches; the image already holds the right depth. Records nothing. + Reused, + // Records this frame, either because the content changed or because it can never be cached. + Recorded, +}; + +[[nodiscard]] std::string_view toString(ShadowViewDisposition disposition) noexcept; + +// Whether this frame TAKES reuse at all (`RenderTunables::shadowResidencyReuseEnabled`). +// +// SCHEDULING, NOT PIXELS, which is why it is an argument to the law rather than a field of the +// content descriptor: it decides whether identical content is re-rasterised, never what that +// content is. A frame recorded with reuse disabled is therefore perfectly reusable by a later frame +// with it enabled — recording commits residency either way — so the toggle can be flipped mid-run +// and the next frame picks up from the newest content rather than from whatever was resident when +// it was switched off. That is what makes it an honest A/B for the whole item. +enum class ShadowReusePolicy : std::uint8_t +{ + Enabled, + Disabled, +}; + +// The two derived questions. Everything downstream asks one of these rather than testing the +// enumerator, so "reused counts as sampleable" is stated once. +[[nodiscard]] constexpr bool shadowViewSampleable(ShadowViewDisposition disposition) noexcept +{ + return disposition == ShadowViewDisposition::Reused || + disposition == ShadowViewDisposition::Recorded; +} +[[nodiscard]] constexpr bool shadowViewRecords(ShadowViewDisposition disposition) noexcept +{ + return disposition == ShadowViewDisposition::Recorded; +} + +} // namespace fire_engine diff --git a/include/fire_engine/platform/application_args.hpp b/include/fire_engine/platform/application_args.hpp index 1621a2c..2dfd86d 100644 --- a/include/fire_engine/platform/application_args.hpp +++ b/include/fire_engine/platform/application_args.hpp @@ -257,6 +257,14 @@ struct ApplicationArgs args.debug.taa = false; continue; } + if (arg == "--no-shadow-reuse") + { + // Arc 2 #4's A/B: every shadow view records, even one whose image already holds + // identical content. From the FIRST frame, which is what separates this from the + // overlay checkbox — a run whose early frames reused is not a forced-record baseline. + args.debug.shadowResidencyReuse = false; + continue; + } if (arg == "--overlay") { args.debug.overlayVisible = true; diff --git a/include/fire_engine/render/render_tunables.hpp b/include/fire_engine/render/render_tunables.hpp index 4f6f0c4..d8b5213 100644 --- a/include/fire_engine/render/render_tunables.hpp +++ b/include/fire_engine/render/render_tunables.hpp @@ -100,6 +100,17 @@ struct RenderTunables // triggers at `budget`. Live for the same reason the budget is — SH-03's calibration sweeps // both, and the two interact (a wider band hides a budget that is slightly too tight). float shadowLodCoarsenRatio{kShadowLodCoarsenRatio}; + // Arc 2 #4: reuse a shadow view's depth image when this frame's prepared content is identical + // to what the image already holds. On by default — the whole point of the item — and live + // because it is the A/B for it: switched off, every active view records exactly as it did + // before the cache existed, so a suspected stale shadow can be confirmed or cleared in one + // keystroke instead of a rebuild. + // + // SCHEDULING, NOT PIXELS. It decides whether work is skipped, never what that work draws, so it + // is deliberately NOT part of the content descriptor: a frame recorded with reuse off must be + // reusable by a later frame with it back on. Recording still commits residency, so re-enabling + // picks up from the newest content rather than from whatever was resident before the toggle. + bool shadowResidencyReuseEnabled{true}; // Which shadow view the diagnostics panel is interrogating (SH-03 slice 4), keyed by LOGICAL // identity rather than physical slot. Defaults to the scene rollup. // diff --git a/include/fire_engine/render/renderer.hpp b/include/fire_engine/render/renderer.hpp index 6aa5d1b..d1c6e16 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -57,6 +57,12 @@ struct RendererDebug // Disables every shadow-map visibility lookup (cascade, spot, point) in // the forward shader. Surfaces look fully lit by direct lighting. bool noShadows{false}; + // Shadow-map reuse (arc 2 #4). When false (--no-shadow-reuse) every engaged shadow view + // records even when its depth image already holds identical content — the pass as it behaved + // before the residency cache, and the "before" half of the A/B for it. Starting the run with + // it off is what a measurement needs: the overlay checkbox exists too, but a mid-run flip + // cannot produce comparable frames from the first one. + bool shadowResidencyReuse{true}; // Temporal anti-aliasing. When false (--no-taa) the projection jitter and // the resolve pass are both skipped, reverting to the raw aliased image — // the A/B reference for confirming TAA is doing the work. @@ -390,7 +396,11 @@ class Renderer { return !capturePath_.empty(); } - void submitAndPresent(Window& display, vk::CommandBuffer cmd, uint32_t imageIndex); + // Split on purpose — see the definition in renderer.cpp. Submission is the moment the GPU + // owns the frame's work; presentation is a separate act that can throw. Anything recording + // what the GPU now owns (the shadow-LOD dead band, shadow residency) commits BETWEEN them. + void submitFrame(vk::CommandBuffer cmd, uint32_t imageIndex); + void presentFrame(Window& display, uint32_t imageIndex); void recordSkybox(Vec3 cameraPosition, Vec3 cameraTarget, std::vector& drawCommands); void logShadowCasterPlacement(std::span shadowDraws) const; diff --git a/include/fire_engine/render/shadows.hpp b/include/fire_engine/render/shadows.hpp index 5999f24..390f355 100644 --- a/include/fire_engine/render/shadows.hpp +++ b/include/fire_engine/render/shadows.hpp @@ -113,6 +113,33 @@ class Shadows void recordPass(vk::CommandBuffer cmd, const ShadowFramePlan& plan, ShadowFrameStats& stats, const GpuProfiler& profiler, uint32_t frameIndex) const; + // What each shadow image HOLDS, for `prepareShadowFrame` to compare this frame's work against. + // + // It lives here because the images do: a record of GPU content belongs beside the content it + // describes, not in the frame state that is reset every frame. That is also the whole + // invalidation story, and why there is no `invalidate()` to forget to call — recreating the + // images means reconstructing the object that owns both them and this. Should in-place + // recreation ever arrive, the targets and this store move into one private aggregate together, + // so replacing them stays a single act. + [[nodiscard]] const ShadowResidencyStore& residency() const noexcept + { + return residency_; + } + + // Adopt what this frame RECORDED, CONSUMING it from the plan. Call after the queue has accepted + // the work and not before: a frame abandoned after preparation (a lost swapchain, a throw) must + // leave no record claiming an image holds pixels nothing ever drew — the same boundary the + // SH-03 hysteresis commit sits on. Which entries are adopted is the store's own law (`Recorded` + // only), not this call site's. + // + // `noexcept` is deliberate and load-bearing: on the far side of a submit there is no good + // answer to a failed allocation, so adoption moves the prepared views out of the plan rather + // than copying them. The plan is reset by the next preparation, so consuming it costs nothing. + void commitResidency(ShadowFramePlan& plan) noexcept + { + residency_.commit(plan); + } + private: // Where one prepared layer rasterises: the depth image, the single-layer attachment view, the // array layer its barriers target, and the fragment paths that write it. @@ -147,6 +174,8 @@ class Shadows TextureHandle selfShadowMapHandle_{NullTexture}; TextureHandle spotShadowMapHandle_{NullTexture}; TextureHandle pointShadowMapHandle_{NullTexture}; + // Beside the handles above, deliberately: this is the record of what those images contain. + ShadowResidencyStore residency_{}; }; } // namespace fire_engine diff --git a/src/graphics/shadow_diagnostics.cpp b/src/graphics/shadow_diagnostics.cpp index e62744a..448ebcf 100644 --- a/src/graphics/shadow_diagnostics.cpp +++ b/src/graphics/shadow_diagnostics.cpp @@ -95,6 +95,22 @@ bool ShadowViewStats::beginRasterPass(ShadowLogicalViewId view) noexcept return true; } +bool ShadowViewStats::noteDisposition(ShadowLogicalViewId view, + ShadowViewDisposition value) noexcept +{ + // Same agreement as `beginRasterPass`, for the same reason: a row is one logical view's report, + // and a schedule filed under the wrong name reads exactly like a correct one. + assert(claimed() && "a disposition must belong to a claimed view"); + assert((!claimed() || logicalId == view) && + "the disposition being recorded belongs to a different view than claimed this row"); + if (!claimed() || !(logicalId == view)) + { + return false; + } + disposition = value; + return true; +} + void ShadowViewStats::observe(std::uint64_t fullDetailTriangles, bool accepted, std::uint64_t resolvedTriangles, std::uint32_t lodLevel, ShadowLodReason reason, bool countSelection) noexcept diff --git a/src/graphics/shadow_pass_plan.cpp b/src/graphics/shadow_pass_plan.cpp index 7562ba7..346788c 100644 --- a/src/graphics/shadow_pass_plan.cpp +++ b/src/graphics/shadow_pass_plan.cpp @@ -1,6 +1,8 @@ #include "fire_engine/graphics/shadow_pass_plan.hpp" #include +#include +#include namespace fire_engine { @@ -165,20 +167,6 @@ bool PreparedShadowLayer::sameContent(const PreparedShadowLayer& other) const no { return lhs.sameContent(rhs); }); } -std::string_view toString(ShadowViewDisposition disposition) noexcept -{ - switch (disposition) - { - case ShadowViewDisposition::Invalid: - return "invalid"; - case ShadowViewDisposition::Reused: - return "reused"; - case ShadowViewDisposition::Recorded: - return "recorded"; - } - return "unknown"; -} - namespace { @@ -293,6 +281,26 @@ const PreparedShadowView* ShadowFramePlan::view(ShadowViewGroup group, return entry.view.valid() ? &entry.view : nullptr; } +std::optional ShadowFramePlan::takeRecorded(ShadowViewGroup group, + std::size_t slot) noexcept +{ + if (static_cast(group) >= kShadowViewGroupCount || + slot >= shadowViewSlotCount(group)) + { + return std::nullopt; + } + Entry& entry = entries_[shadowViewIndex(group, slot)]; + // RECORDED ONLY. A reused view did not touch its image, so its prepared work must not replace + // the record of what that image holds; an invalid slot rasterised nothing at all. + if (entry.disposition != ShadowViewDisposition::Recorded) + { + return std::nullopt; + } + std::optional taken{std::move(entry.view)}; + entry = Entry{}; + return taken; +} + ShadowViewDisposition ShadowFramePlan::disposition(ShadowViewGroup group, std::size_t slot) const noexcept { @@ -404,13 +412,22 @@ ShadowMapValidity shadowMapValidityFromPlan(const ShadowFramePlan& plan, return validity; } -ShadowViewDisposition shadowViewDisposition(bool active, const PreparedShadowView& prepared, +ShadowViewDisposition shadowViewDisposition(bool active, ShadowReusePolicy reuse, + const PreparedShadowView& prepared, const ShadowViewResidency& resident) noexcept { if (!active) { return ShadowViewDisposition::Invalid; } + // The toggle is asked AFTER engagement and before anything about content: a view nothing + // vouches for stays Invalid whatever the policy says (there is no work to schedule), while a + // view that would have been reused simply records instead. Nothing else changes — the same + // draws, the same order, the same commit afterwards. + if (reuse == ShadowReusePolicy::Disabled) + { + return ShadowViewDisposition::Recorded; + } // FIRST USE. Image creation transitions every layer to the sampler's read-only layout but // leaves the depth contents undefined, so "already in the right layout" is not "already holds // an answer". A slot with no committed content records, whatever its prepared work looks like. @@ -434,4 +451,47 @@ ShadowViewDisposition shadowViewDisposition(bool active, const PreparedShadowVie : ShadowViewDisposition::Recorded; } +const ShadowViewResidency& ShadowResidencyStore::at(ShadowViewGroup group, + std::size_t slot) const noexcept +{ + // "Nothing resident" is a real state every entry starts in, so an out-of-range address is + // answered with it rather than with a separate failure the law would have to learn about. The + // consequence is a re-record, which is the safe direction. + static const ShadowViewResidency kNothingResident{}; + if (static_cast(group) >= kShadowViewGroupCount || + slot >= shadowViewSlotCount(group)) + { + return kNothingResident; + } + return entries_[shadowViewIndex(group, slot)]; +} + +void ShadowResidencyStore::commit(ShadowFramePlan& plan) noexcept +{ + // The whole reason adoption is a move: this runs after `submitFrame`, so an allocation + // failure here would throw out of a frame the GPU is already executing — and the throw would + // leave residency describing the frame BEFORE this one while the images hold this one's depth, + // which is precisely the "shadows from a frame that is gone" failure the type exists to + // prevent. A moved prepared view allocates nothing, and these assertions are what keep that + // true if someone gives it a member whose move can throw. + static_assert( + std::is_nothrow_move_constructible_v, + "residency is adopted after the submit, so moving a prepared view must not throw"); + static_assert( + std::is_nothrow_move_assignable_v, + "residency is adopted after the submit, so moving a prepared view must not throw"); + + for (std::size_t g = 0; g < kShadowViewGroupCount; ++g) + { + const auto group = static_cast(g); + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + if (auto recorded = plan.takeRecorded(group, slot); recorded.has_value()) + { + entries_[shadowViewIndex(group, slot)].commit(std::move(*recorded)); + } + } + } +} + } // namespace fire_engine diff --git a/src/graphics/shadow_pass_prepare.cpp b/src/graphics/shadow_pass_prepare.cpp index db8b8da..0d41db1 100644 --- a/src/graphics/shadow_pass_prepare.cpp +++ b/src/graphics/shadow_pass_prepare.cpp @@ -213,18 +213,11 @@ struct ShadowDrawFilter } // namespace void prepareShadowFrame(const ShadowPreparationInputs& inputs, const ShadowRenderViewSet& views, - ShadowMapValidity eligible, ShadowLodResolver& resolver, - ShadowFrameStats& stats, ShadowFramePlan& plan) + ShadowMapValidity eligible, const ShadowResidencyStore& residency, + ShadowLodResolver& resolver, ShadowFrameStats& stats, ShadowFramePlan& plan) { plan.reset(); - // NOTHING IS RESIDENT YET. The residency store arrives with the reuse half of this item; until - // then every view is a first use, which the law answers with `Recorded`. Consulting the law - // rather than writing `Recorded` here is deliberate: the disposition of a view is one decision - // with one home, and a hard-coded answer at the only call site would have to be found and - // removed later — exactly the kind of second authority this arc exists to retire. - const ShadowViewResidency noResidency{}; - // Family order matches the order the pass records in. That is DETERMINISM, not correctness — a // reversal would not change a single resolved level. A cascade and its world-only twin share // one logical view, so whichever is prepared first is the one whose answer the resolver's frame @@ -351,9 +344,22 @@ void prepareShadowFrame(const ShadowPreparationInputs& inputs, const ShadowRende } // The view is ACTIVE by construction — the set said so above — so the law's remaining - // questions are the cache's own: is there resident content, and does it match. + // questions are the cache's own: is there resident content, and does it match. Note + // this is asked AFTER every draw has been resolved and noted: preparation costs the + // same whether the answer is reuse or not, because the answer cannot be known without + // the work that produces it. const ShadowViewDisposition disposition = - shadowViewDisposition(true, prepared, noResidency); + shadowViewDisposition(true, + inputs.residencyReuseEnabled ? ShadowReusePolicy::Enabled + : ShadowReusePolicy::Disabled, + prepared, residency.at(group, slot)); + // The ROW records what was decided, so the panel can tell "recorded an empty map" from + // "reused the empty map it recorded earlier" — two rows that are otherwise identical in + // every counter, one of which is GPU work and the other the cache doing its job. + if (!viewStats.noteDisposition(view->logicalId(), disposition)) + { + contradictoryShadowViewRow(toString(group), slot); + } if (!plan.add(group, slot, std::move(prepared), disposition)) { unpreparableShadowView(toString(group), slot); diff --git a/src/graphics/shadow_view_disposition.cpp b/src/graphics/shadow_view_disposition.cpp new file mode 100644 index 0000000..2a2631f --- /dev/null +++ b/src/graphics/shadow_view_disposition.cpp @@ -0,0 +1,20 @@ +#include "fire_engine/graphics/shadow_view_disposition.hpp" + +namespace fire_engine +{ + +std::string_view toString(ShadowViewDisposition disposition) noexcept +{ + switch (disposition) + { + case ShadowViewDisposition::Invalid: + return "invalid"; + case ShadowViewDisposition::Reused: + return "reused"; + case ShadowViewDisposition::Recorded: + return "recorded"; + } + return "unknown"; +} + +} // namespace fire_engine diff --git a/src/render/debug_overlay.cpp b/src/render/debug_overlay.cpp index 576a698..1dd66df 100644 --- a/src/render/debug_overlay.cpp +++ b/src/render/debug_overlay.cpp @@ -30,7 +30,7 @@ namespace // which is what the focus identifies and what the ShadowLod tint needs. Worth revisiting if reading // a family's mix on its own turns out to be the common question. bool shadowStatsRow(const char* label, const ShadowViewStats& stats, const char* timing, - bool focusable = false, bool focused = false) + bool focusable = false, bool focused = false, bool rollup = false) { ImGui::TableNextRow(); ImGui::TableNextColumn(); @@ -46,6 +46,12 @@ bool shadowStatsRow(const char* label, const ShadowViewStats& stats, const char* ImGui::TableNextColumn(); ImGui::Text("%llu", static_cast(stats.rasterPasses)); ImGui::TableNextColumn(); + // WHAT THE VIEW DID, which zero raster passes alone cannot say: a reused map and a view that + // never engaged both rasterise nothing, and since arc 2 #4 the first is the normal, healthy + // case. Rollup rows have no single schedule (a family can be half reused), so they print an em + // dash rather than a state they do not have. + ImGui::TextUnformatted(rollup ? "—" : toString(stats.disposition).data()); + ImGui::TableNextColumn(); ImGui::Text("%llu / %llu", static_cast(stats.drawnDraws), static_cast(stats.candidateDraws)); ImGui::TableNextColumn(); @@ -224,7 +230,7 @@ void drawShadowDiagnostics(const FrameStats& stats, RenderTunables& tunables) ImGui::EndTable(); } - constexpr int kColumns = 5 + static_cast(kShadowLodBinCount); + constexpr int kColumns = 6 + static_cast(kShadowLodBinCount); if (ImGui::BeginTable("shadowviews", kColumns, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV)) @@ -234,6 +240,7 @@ void drawShadowDiagnostics(const FrameStats& stats, RenderTunables& tunables) // distribution, which is the one thing this table exists to show, was unreadable. ImGui::TableSetupColumn("View", ImGuiTableColumnFlags_WidthStretch, 2.8f); ImGui::TableSetupColumn("Pass", ImGuiTableColumnFlags_WidthStretch, 0.55f); + ImGui::TableSetupColumn("State", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupColumn("Draws d/c", ImGuiTableColumnFlags_WidthStretch, 1.35f); ImGui::TableSetupColumn("Tris d/c", ImGuiTableColumnFlags_WidthStretch, 2.3f); ImGui::TableSetupColumn("L0", ImGuiTableColumnFlags_WidthStretch, 0.5f); @@ -264,7 +271,8 @@ void drawShadowDiagnostics(const FrameStats& stats, RenderTunables& tunables) const std::string_view name = toString(group); std::snprintf(groupLabel, sizeof(groupLabel), "%.*s", static_cast(name.size()), name.data()); - shadowStatsRow(groupLabel, total, total.touched() ? timing : "idle"); + shadowStatsRow(groupLabel, total, total.touched() ? timing : "idle", + /*focusable=*/false, /*focused=*/false, /*rollup=*/true); for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) { @@ -309,7 +317,7 @@ void drawShadowDiagnostics(const FrameStats& stats, RenderTunables& tunables) std::snprintf(totalTiming, sizeof(totalTiming), "%.3f", static_cast(shadowMs)); } if (shadowStatsRow("Scene total", sceneTotal, totalTiming, /*focusable=*/true, - !tunables.shadowViewFocus.perView)) + !tunables.shadowViewFocus.perView, /*rollup=*/true)) { tunables.shadowViewFocus = ShadowViewFocus{}; // back to the rollup } @@ -517,6 +525,12 @@ void DebugOverlay::buildUi(const FrameStats& stats, RenderTunables& tunables) ImGui::SliderFloat("Shadow coarsen ratio", &tunables.shadowLodCoarsenRatio, 0.25f, 1.0f, "%.2f"); ImGui::EndDisabled(); + // Arc 2 #4's A/B, and NOT gated on the shadow-LOD switch above: reuse is about whether a + // view rasterises at all, which is a different question from which level it would pick. + // Off, every active view records exactly as it did before the cache existed — so a shadow + // that looks wrong can be confirmed as stale, or cleared, without a rebuild. + ImGui::Checkbox("Reuse unchanged shadow views##shadowreuse", + &tunables.shadowResidencyReuseEnabled); if (stats.trianglesGpuPending) { ImGui::Text("Triangles drawn: pending GPU readback"); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index f45681d..4d07143 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -296,6 +296,7 @@ Renderer::Renderer(const Window& window, std::string environmentPath, RendererDe // below), so the GPU path is the default; --vdpm-gpu / --no-vdpm-gpu force it explicitly. tunables_.vdpmGpuBackend = debug.vdpmGpuBackend.value_or(VdpmScan::deviceSupported(device_)); tunables_.noShadows = debug.noShadows; + tunables_.shadowResidencyReuseEnabled = debug.shadowResidencyReuse; tunables_.debugDrawAabbs = debug.physicsDebug; tunables_.debugDrawColliders = debug.physicsDebug; tunables_.debugDrawContacts = debug.physicsDebug; @@ -852,6 +853,7 @@ void Renderer::prepareShadowPlan(const DrawBuckets& buckets) .lodBudgetTexels = tunables_.shadowLodPixelBudget, .hysteresis = ShadowLodHysteresis{.coarsenRatio = tunables_.shadowLodCoarsenRatio}, .cullingEnabled = tunables_.cullingEnabled, + .residencyReuseEnabled = tunables_.shadowResidencyReuseEnabled, }; const auto family = [&](ShadowViewGroup group) -> ShadowFamilyRaster& { return inputs.raster[static_cast(group)]; }; @@ -867,8 +869,10 @@ void Renderer::prepareShadowPlan(const DrawBuckets& buckets) family(ShadowViewGroup::Point) = {kPointShadowMapExtent, kPunctualShadowRasterBiasConstant, kPunctualShadowRasterBiasSlope}; - prepareShadowFrame(inputs, shadowViews_, eligibility.eligible(), shadowLodResolver_, - shadowStatsRing_[currentFrame_], shadowPlan_); + // The residency store comes from `Shadows`, which owns the depth images it is a record of. It + // is read-only here: what this frame recorded is adopted after the submit, not now. + prepareShadowFrame(inputs, shadowViews_, eligibility.eligible(), shadows_.residency(), + shadowLodResolver_, shadowStatsRing_[currentFrame_], shadowPlan_); // CONFIRMATION, from the plan that was actually built and judged against the eligibility that // authorised it. This is what the receiver is told, and what the pass records — one value, one @@ -1436,11 +1440,36 @@ void Renderer::logShadowRecordingSample() const const auto familyLine = [&](ShadowViewGroup group, bool valid) -> std::string { const ShadowViewStats totals = stats_.shadow.groupTotal(group); + // The DISPOSITIONS behind the counters (arc 2 #4). A family can be part reused and part + // recorded, so a single word for the family would be a summary of two different answers. + std::size_t recorded = 0; + std::size_t reused = 0; + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + switch (stats_.shadow.view(group, slot).disposition) + { + case ShadowViewDisposition::Recorded: + ++recorded; + break; + case ShadowViewDisposition::Reused: + ++reused; + break; + case ShadowViewDisposition::Invalid: + break; + } + } + // A family that records nothing OPENS NO TIMING SPAN, so its resolved time is not a + // measurement of zero — it is the absence of one. Printing "0.000ms" for it would be the + // single most misreadable number in a shadow-cache measurement, since "the reuse made it + // free" and "nothing was measured" would look identical. const ProfilePass pass = shadowProfilePass(group); - const float ms = - pass == ProfilePass::Count ? 0.0f : stats_.passMs[static_cast(pass)]; - return std::format("{} {} passes={} {:.3f}ms", toString(group), - valid ? "recorded" : "skipped", totals.rasterPasses, ms); + const bool measured = recorded > 0 && pass != ProfilePass::Count && stats_.gpuValid(); + const std::string timing = + measured ? std::format("{:.3f}ms", stats_.passMs[static_cast(pass)]) + : std::string{recorded > 0 ? "unmeasured" : "no span issued"}; + return std::format("{} {} recorded={} reused={} passes={} {}", toString(group), + valid ? "sampleable" : "skipped", recorded, reused, totals.rasterPasses, + timing); }; log::debug(log::category::render, "shadow recording: {} | {} | {} | {} | {}{}{}", @@ -1946,14 +1975,22 @@ void Renderer::drawFrame(Window& display, RenderableScene& scene, float dt) transitionSwapchainToPresent(cmd, *imageIndex, capturingThisFrame); cmd.end(); - submitAndPresent(display, cmd, *imageIndex); - // IMMEDIATELY after the submit, and before anything that can fail. The contract is "the GPU has - // the work", not "the rest of the frame went well": the shadow-LOD dead band (SH-03) describes - // geometry that was submitted, and `writeCapture()` below throws on an I/O failure, which would - // otherwise discard a frame's worth of legitimately earned hysteresis. Levels are STAGED until - // this line, so a frame abandoned before it (a lost swapchain, an early return) leaves none - // behind — the next beginFrame drops them. + submitFrame(cmd, *imageIndex); + // BETWEEN THE SUBMIT AND THE PRESENT, which is the whole point of the split above. The contract + // for both commits below is "the GPU has the work" — and presentation is not part of that + // contract: it can throw (an out-of-date swapchain does exactly that), and a frame whose depth + // is already being rasterised must not lose its record on the way out. + // + // The shadow-LOD dead band (SH-03) describes geometry that was submitted; levels are STAGED + // until this line, so a frame abandoned BEFORE the submit leaves none behind. shadowLodResolver_.commitFrame(); + // And what the images HOLD is now what this frame rasterised into them. Same boundary, same + // reason, plus one of its own: residency is the record that lets a later frame skip drawing, so + // a missed commit after a real image write is unsafe in the direction that produces a wrong + // picture rather than a slow one. Only `Recorded` views are adopted — the store's own law. + // `noexcept`, so nothing here can fail before the present below is even attempted. + shadows_.commitResidency(shadowPlan_); + presentFrame(display, *imageIndex); // Published with THIS frame's counters, in the same ring slot, so the churn a reader sees sits // beside the draws and levels it describes rather than beside a completed frame's. const ShadowLodTransitions movement = shadowLodResolver_.lastCommitMovement(); @@ -2164,7 +2201,7 @@ CaptureFormat Renderer::resolveCaptureFormat(vk::Format format) void Renderer::recordCaptureCopy(vk::CommandBuffer cmd, uint32_t imageIndex) { - // Snapshot the geometry AND the format now, with the copy. submitAndPresent may recreate + // Snapshot the geometry AND the format now, with the copy. presentFrame may recreate // the swapchain (a resize, or an out-of-date present) before writeCapture runs, and the // buffer would then be decoded against an extent and format the pixels in it never had. captureExtent_ = swapchain_.extent(); @@ -2230,7 +2267,20 @@ void Renderer::writeCapture() extent.width, extent.height, capturePath_); } -void Renderer::submitAndPresent(Window& display, vk::CommandBuffer cmd, uint32_t imageIndex) +// SUBMISSION and PRESENTATION are separate acts, and the split is load-bearing rather than tidy. +// +// Once `submit2` returns, the GPU owns the work: those shadow images WILL be written whatever +// happens next. Presentation is a different question that can fail — vulkan-hpp's raii `presentKHR` +// THROWS on `eErrorOutOfDateKHR` (only `eSuboptimalKHR` is a success code without +// `VULKAN_HPP_HANDLE_ERROR_OUT_OF_DATE_AS_SUCCESS`), and `recreateSwapchain` can throw too. With +// the two fused, a resize at exactly the wrong moment threw past the residency commit while the +// frame's depth was already being rasterised: the store would still describe the PREVIOUS frame's +// content, and a later frame preparing that same content would reuse an image holding something +// else. That is the one failure mode this whole item exists to prevent, arriving through the error +// path instead of the happy one. +// +// So the caller commits what the GPU now owns BETWEEN these two calls. +void Renderer::submitFrame(vk::CommandBuffer cmd, uint32_t imageIndex) { auto imageAvail = frame_.imageAvailable(currentFrame_); auto renderDone = frame_.renderFinished(imageIndex); @@ -2266,7 +2316,11 @@ void Renderer::submitAndPresent(Window& display, vk::CommandBuffer cmd, uint32_t device_.graphicsQueue().submit2(si); frameTimelineValue_[currentFrame_] = signalValue; imageTimelineValue_[imageIndex] = signalValue; +} +void Renderer::presentFrame(Window& display, uint32_t imageIndex) +{ + auto renderDone = frame_.renderFinished(imageIndex); auto swapchain = swapchain_.swapchain(); vk::PresentInfoKHR pi{ .waitSemaphoreCount = 1, diff --git a/src/render/shadows.cpp b/src/render/shadows.cpp index c1052d3..5a25ed4 100644 --- a/src/render/shadows.cpp +++ b/src/render/shadows.cpp @@ -263,7 +263,7 @@ void Shadows::recordPass(vk::CommandBuffer cmd, const ShadowFramePlan& plan, uint32_t frameIndex) const { // Nothing to record at all — `--no-shadows`, a scene with no light any family is fitted to, or - // (once the residency store lands) a frame in which every view's map was reused. Returning here + // a frame in which every view's map was reused. Returning here // is what makes that OBSERVABLE: no draws, no clears, no timestamps, so every shadow row in the // diagnostics and every shadow group in the GPU timings reads zero. if (plan.recordsNothing()) diff --git a/tests/graphics/test_shadow_pass_plan.cpp b/tests/graphics/test_shadow_pass_plan.cpp index 9226ec8..bab3656 100644 --- a/tests/graphics/test_shadow_pass_plan.cpp +++ b/tests/graphics/test_shadow_pass_plan.cpp @@ -76,7 +76,8 @@ TEST_CASE("identical prepared content reuses the resident map", "[ShadowPassPlan { const PreparedShadowView prepared = viewWith(rigidDraw()); const ShadowViewResidency resident = residentFrom(prepared); - CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, prepared, resident) == + ShadowViewDisposition::Reused); CHECK(shadowViewSampleable(ShadowViewDisposition::Reused)); CHECK_FALSE(shadowViewRecords(ShadowViewDisposition::Reused)); } @@ -87,7 +88,8 @@ TEST_CASE("an inactive view is invalid whatever its image holds", "[ShadowPassPl const ShadowViewResidency resident = residentFrom(prepared); // The view set is the authority on engagement (SH-03). Content that matches is irrelevant if // nothing this frame vouches for the matrix behind it. - const ShadowViewDisposition disposition = shadowViewDisposition(false, prepared, resident); + const ShadowViewDisposition disposition = + shadowViewDisposition(false, ShadowReusePolicy::Enabled, prepared, resident); CHECK(disposition == ShadowViewDisposition::Invalid); CHECK_FALSE(shadowViewSampleable(disposition)); CHECK_FALSE(shadowViewRecords(disposition)); @@ -101,14 +103,22 @@ TEST_CASE("first use must record even with matching content", "[ShadowPassPlan]" const ShadowViewResidency empty{}; CHECK_FALSE(empty.hasContent()); CHECK(empty.content() == nullptr); - CHECK(shadowViewDisposition(true, prepared, empty) == ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, prepared, empty) == + ShadowViewDisposition::Recorded); + + // And once it IS committed, the same prepared work reuses. + const ShadowViewResidency resident = residentFrom(prepared); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, prepared, resident) == + ShadowViewDisposition::Reused); - // And once it IS committed, the same prepared work reuses — then `invalidate()` (an image - // recreated under it) takes it back to recording. - ShadowViewResidency resident = residentFrom(prepared); - CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Reused); - resident.invalidate(); - CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Recorded); + // RECREATION is modelled the only way the engine can express it: a fresh store, because the + // store lives with the images and is rebuilt when they are. There is no invalidate() to call — + // a hook would be a second way for the record and the images to disagree, and the reason this + // type has none is that the disagreement is what produces a wrong picture. + const ShadowResidencyStore recreated{}; + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, prepared, + recreated.at(ShadowViewGroup::Cascade, 1)) == + ShadowViewDisposition::Recorded); } TEST_CASE("a point face's light position and range are part of its content", "[ShadowPassPlan]") @@ -118,18 +128,18 @@ TEST_CASE("a point face's light position and range are part of its content", "[S // moves or re-ranges with an unchanged matrix changes every texel, so an equal viewProj is not // enough to reuse. const PreparedShadowView resident = pointFaceWith(rigidDraw()); - CHECK(shadowViewDisposition(true, pointFaceWith(rigidDraw()), residentFrom(resident)) == - ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, pointFaceWith(rigidDraw()), + residentFrom(resident)) == ShadowViewDisposition::Reused); const PreparedShadowView moved = pointFaceWith(rigidDraw(), Vec3{2.0f, 3.0f, 4.01f}); CHECK(moved.viewProj() == resident.viewProj()); // the hole this closes: matrices agree - CHECK(shadowViewDisposition(true, moved, residentFrom(resident)) == + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, moved, residentFrom(resident)) == ShadowViewDisposition::Recorded); const PreparedShadowView reranged = pointFaceWith(rigidDraw(), Vec3{2.0f, 3.0f, 4.0f}, 26.0f); CHECK(reranged.viewProj() == resident.viewProj()); - CHECK(shadowViewDisposition(true, reranged, residentFrom(resident)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, reranged, + residentFrom(resident)) == ShadowViewDisposition::Recorded); } TEST_CASE("the depth mode follows the identity and cannot be set against it", "[ShadowPassPlan]") @@ -162,7 +172,8 @@ TEST_CASE("a projected view carries no light to compare", "[ShadowPassPlan]") const PreparedShadowView view = cascadeView(); CHECK(view.lightRange() == 0.0f); CHECK(view.lightPosition().x() == 0.0f); - CHECK(shadowViewDisposition(true, viewWith(rigidDraw()), residentFrom(viewWith(rigidDraw()))) == + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(rigidDraw()), + residentFrom(viewWith(rigidDraw()))) == ShadowViewDisposition::Reused); } @@ -174,8 +185,8 @@ TEST_CASE("a moved caster records even though its buffers and bounds are unchang const PreparedShadowView resident = viewWith(rigidDraw()); PreparedShadowDraw moved = rigidDraw(); moved.model = translation(1.5f); - CHECK(shadowViewDisposition(true, viewWith(moved), residentFrom(resident)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(moved), + residentFrom(resident)) == ShadowViewDisposition::Recorded); } TEST_CASE("a re-fitted view records — the matrix is compared, not the fit that explains it", @@ -185,8 +196,8 @@ TEST_CASE("a re-fitted view records — the matrix is compared, not the fit that PreparedShadowView refitted = PreparedShadowView::projected( ShadowLogicalViewId::cascade(1), translation(4.5f), 2048, 0.0f, 2.0f); REQUIRE(refitted.addDraw(rigidDraw())); - CHECK(shadowViewDisposition(true, refitted, residentFrom(resident)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, refitted, + residentFrom(resident)) == ShadowViewDisposition::Recorded); } TEST_CASE("a swapped LOD carrier records even at the same level", "[ShadowPassPlan]") @@ -196,13 +207,13 @@ TEST_CASE("a swapped LOD carrier records even at the same level", "[ShadowPassPl const PreparedShadowView resident = viewWith(rigidDraw()); PreparedShadowDraw swapped = rigidDraw(); swapped.indexBuffer = static_cast(99); - CHECK(shadowViewDisposition(true, viewWith(swapped), residentFrom(resident)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(swapped), + residentFrom(resident)) == ShadowViewDisposition::Recorded); PreparedShadowDraw coarser = rigidDraw(); coarser.indexCount = 150; - CHECK(shadowViewDisposition(true, viewWith(coarser), residentFrom(resident)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(coarser), + residentFrom(resident)) == ShadowViewDisposition::Recorded); } TEST_CASE("the level and reason are diagnostics and do not force a re-record", "[ShadowPassPlan]") @@ -213,8 +224,8 @@ TEST_CASE("the level and reason are diagnostics and do not force a re-record", " PreparedShadowDraw relabelled = rigidDraw(); relabelled.level = 2; relabelled.reason = ShadowLodReason::SingleLevel; - CHECK(shadowViewDisposition(true, viewWith(relabelled), residentFrom(resident)) == - ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(relabelled), + residentFrom(resident)) == ShadowViewDisposition::Reused); } TEST_CASE("every pixel-producing field forces a re-record when it changes", "[ShadowPassPlan]") @@ -222,8 +233,8 @@ TEST_CASE("every pixel-producing field forces a re-record when it changes", "[Sh const PreparedShadowView resident = viewWith(rigidDraw()); const auto records = [&](const PreparedShadowDraw& draw) { - return shadowViewDisposition(true, viewWith(draw), residentFrom(resident)) == - ShadowViewDisposition::Recorded; + return shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(draw), + residentFrom(resident)) == ShadowViewDisposition::Recorded; }; PreparedShadowDraw d = rigidDraw(); @@ -260,12 +271,13 @@ TEST_CASE("the material index is content for a masked caster only", "[ShadowPass masked.alpha = ShadowCasterAlpha::Masked; PreparedShadowDraw maskedOther = masked; maskedOther.materialIndex = masked.materialIndex + 1; - CHECK(shadowViewDisposition(true, viewWith(maskedOther), residentFrom(viewWith(masked))) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(maskedOther), + residentFrom(viewWith(masked))) == ShadowViewDisposition::Recorded); PreparedShadowDraw opaqueOther = rigidDraw(); opaqueOther.materialIndex = rigidDraw().materialIndex + 1; - CHECK(shadowViewDisposition(true, viewWith(opaqueOther), residentFrom(viewWith(rigidDraw()))) == + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(opaqueOther), + residentFrom(viewWith(rigidDraw()))) == ShadowViewDisposition::Reused); } @@ -274,8 +286,8 @@ TEST_CASE("per-view raster state changes force a re-record", "[ShadowPassPlan]") const PreparedShadowView resident = viewWith(rigidDraw()); const auto records = [&](const PreparedShadowView& view) { - return shadowViewDisposition(true, view, residentFrom(resident)) == - ShadowViewDisposition::Recorded; + return shadowViewDisposition(true, ShadowReusePolicy::Enabled, view, + residentFrom(resident)) == ShadowViewDisposition::Recorded; }; const auto cascadeVariant = @@ -309,12 +321,12 @@ TEST_CASE("a deformable caster poisons the whole view, in both directions", "[Sh const PreparedShadowView withDeformable = viewWith(deforming); CHECK_FALSE(withDeformable.cacheable()); - CHECK(shadowViewDisposition(true, withDeformable, residentFrom(withDeformable)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, withDeformable, + residentFrom(withDeformable)) == ShadowViewDisposition::Recorded); // Prepared is rigid now, but the resident content was captured with a deformable in the set. - CHECK(shadowViewDisposition(true, viewWith(rigidDraw()), residentFrom(withDeformable)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, viewWith(rigidDraw()), + residentFrom(withDeformable)) == ShadowViewDisposition::Recorded); CHECK(viewWith(rigidDraw()).cacheable()); } @@ -327,19 +339,20 @@ TEST_CASE("a changed draw set forces a re-record", "[ShadowPassPlan]") PreparedShadowDraw second = rigidDraw(); second.casterId = kOtherCaster; REQUIRE(extra.addDraw(second)); - CHECK(shadowViewDisposition(true, extra, residentFrom(resident)) == + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, extra, residentFrom(resident)) == ShadowViewDisposition::Recorded); // Same view, no draws: a caster that left the frame. const PreparedShadowView empty = cascadeView(); - CHECK(shadowViewDisposition(true, empty, residentFrom(resident)) == + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, empty, residentFrom(resident)) == ShadowViewDisposition::Recorded); // An empty view is cacheable and reusable against empty resident content: a cleared map that // stays cleared is a legitimate answer, and it is the case that makes an unchanged empty // cascade free rather than a clear plus two barriers every frame. CHECK(empty.cacheable()); - CHECK(shadowViewDisposition(true, empty, residentFrom(empty)) == ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, empty, residentFrom(empty)) == + ShadowViewDisposition::Reused); } TEST_CASE("draw order is compared, conservatively", "[ShadowPassPlan]") @@ -356,8 +369,8 @@ TEST_CASE("draw order is compared, conservatively", "[ShadowPassPlan]") PreparedShadowView backwards = viewWith(second); REQUIRE(backwards.addDraw(first)); - CHECK(shadowViewDisposition(true, backwards, residentFrom(forwards)) == - ShadowViewDisposition::Recorded); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, backwards, + residentFrom(forwards)) == ShadowViewDisposition::Recorded); } TEST_CASE("disposition names are distinct and non-empty", "[ShadowPassPlan]") @@ -459,9 +472,11 @@ TEST_CASE("both self-shadow layers are content", "[ShadowPassPlan]") const PreparedShadowView resident = selfView(ShadowEffectiveCull::FrontFaces); CHECK(resident.layers().size() == 2); - CHECK(shadowViewDisposition(true, selfView(ShadowEffectiveCull::FrontFaces), + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, + selfView(ShadowEffectiveCull::FrontFaces), residentFrom(resident)) == ShadowViewDisposition::Reused); - CHECK(shadowViewDisposition(true, selfView(ShadowEffectiveCull::None), + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, + selfView(ShadowEffectiveCull::None), residentFrom(resident)) == ShadowViewDisposition::Recorded); } @@ -649,3 +664,229 @@ TEST_CASE("a suppressed frame confirms nothing", "[ShadowPassPlan]") CHECK(shadowMapValidityFromPlan(empty, suppressed).none()); CHECK(empty.recordsNothing()); } + +// --- The residency store: what the images HOLD, between frames ------------------------------- + +namespace +{ + +// A cascade view whose identity MATCHES the slot it will be added at — the plan refuses any other +// pairing, and residency is addressed by that same physical slot. +PreparedShadowView cascadeViewAt(std::size_t slot, const PreparedShadowDraw& draw) +{ + PreparedShadowView view = PreparedShadowView::projected( + ShadowLogicalViewId::cascade(static_cast(slot)), translation(4.0f), 2048, + 0.0f, 2.0f); + REQUIRE(view.addDraw(draw)); + return view; +} + +PreparedShadowView spotViewWith(std::size_t slot, std::uint64_t lightId, + const PreparedShadowDraw& draw) +{ + PreparedShadowView view = spotView(slot, lightId); + REQUIRE(view.addDraw(draw)); + return view; +} + +} // namespace + +TEST_CASE("an empty store records everything, because an image holds no answer yet", + "[ShadowPassPlan]") +{ + const ShadowResidencyStore store{}; + // Creation transitions a depth image's layout but writes no depth, so "nothing resident" is the + // honest starting state and the law's answer to it is to draw. + CHECK_FALSE(store.at(ShadowViewGroup::Cascade, 0).hasContent()); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, cascadeViewAt(0, rigidDraw()), + store.at(ShadowViewGroup::Cascade, 0)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("committing a recorded frame is what makes the next identical one reuse", + "[ShadowPassPlan]") +{ + ShadowResidencyStore store{}; + ShadowFramePlan plan{}; + REQUIRE(plan.add(ShadowViewGroup::Cascade, 2, cascadeViewAt(2, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(plan); + + REQUIRE(store.at(ShadowViewGroup::Cascade, 2).hasContent()); + // The SECOND frame's identical preparation, judged against what the first one left behind. + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, cascadeViewAt(2, rigidDraw()), + store.at(ShadowViewGroup::Cascade, 2)) == + ShadowViewDisposition::Reused); + // Per slot, not per family: nothing was committed for any other cascade, so each still draws. + CHECK_FALSE(store.at(ShadowViewGroup::Cascade, 1).hasContent()); +} + +TEST_CASE("a moved caster is not the content that was committed", "[ShadowPassPlan]") +{ + ShadowResidencyStore store{}; + ShadowFramePlan plan{}; + REQUIRE(plan.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(plan); + + PreparedShadowDraw moved = rigidDraw(); + moved.model = translation(9.0f); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, cascadeViewAt(0, moved), + store.at(ShadowViewGroup::Cascade, 0)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("only a recorded view commits, so residency keeps describing the recording", + "[ShadowPassPlan]") +{ + ShadowResidencyStore store{}; + + // Frame 1 RECORDS a caster resolved at level 1. + ShadowFramePlan first{}; + REQUIRE(first.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(first); + REQUIRE(store.at(ShadowViewGroup::Cascade, 0).content() != nullptr); + REQUIRE(store.at(ShadowViewGroup::Cascade, 0).content()->draws().front().level == 1); + + // Frame 2 REUSES it. The prepared view is identical in every compared field — that is why it + // was reused — but its diagnostic fields describe a decision this image's pixels never saw. + // Committing it would leave the record describing a frame that wrote nothing. + PreparedShadowDraw reResolved = rigidDraw(); + reResolved.level = 3; + reResolved.reason = ShadowLodReason::SingleLevel; + ShadowFramePlan second{}; + REQUIRE(second.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, reResolved), + ShadowViewDisposition::Reused)); + store.commit(second); + + REQUIRE(store.at(ShadowViewGroup::Cascade, 0).content() != nullptr); + CHECK(store.at(ShadowViewGroup::Cascade, 0).content()->draws().front().level == 1); +} + +TEST_CASE("an inactive slot keeps its residency, because nothing overwrote its image", + "[ShadowPassPlan]") +{ + ShadowResidencyStore store{}; + ShadowFramePlan first{}; + REQUIRE(first.add(ShadowViewGroup::Spot, 0, spotViewWith(0, 61, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(first); + REQUIRE(store.at(ShadowViewGroup::Spot, 0).hasContent()); + + // The light goes away for a frame: the slot is claimed Invalid, nothing records, and the depth + // image is not touched. Clearing the record here would force a re-render of content the image + // demonstrably still holds. + ShadowFramePlan second{}; + REQUIRE(second.add(ShadowViewGroup::Spot, 0, spotViewWith(0, 61, rigidDraw()), + ShadowViewDisposition::Invalid)); + store.commit(second); + + CHECK(store.at(ShadowViewGroup::Spot, 0).hasContent()); +} + +TEST_CASE("an out-of-range address holds nothing rather than a neighbour's content", + "[ShadowPassPlan]") +{ + ShadowResidencyStore store{}; + ShadowFramePlan plan{}; + const std::size_t last = shadowViewSlotCount(ShadowViewGroup::Cascade) - 1; + REQUIRE(plan.add(ShadowViewGroup::Cascade, last, cascadeViewAt(last, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(plan); + + // Never clamped into the last valid slot: that would answer one view's question with another + // view's image, and the answer would be "reuse". + CHECK_FALSE(store.at(ShadowViewGroup::Cascade, last + 1).hasContent()); + CHECK_FALSE(store.at(ShadowViewGroup::Count, 0).hasContent()); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, cascadeViewAt(last, rigidDraw()), + store.at(ShadowViewGroup::Cascade, last + 1)) == + ShadowViewDisposition::Recorded); +} + +// --- the reuse toggle: scheduling, not pixels --------------------------------------------------- + +TEST_CASE("reuse disabled records content that would otherwise have been reused", + "[ShadowPassPlan]") +{ + const PreparedShadowView prepared = viewWith(rigidDraw()); + const ShadowViewResidency resident = residentFrom(prepared); + + // The SAME content and the SAME residency, differing only in the policy. That is the whole + // claim: the toggle changes whether identical content is re-rasterised, never what it is. + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, prepared, resident) == + ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Disabled, prepared, resident) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("reuse disabled does not make an inactive view render", "[ShadowPassPlan]") +{ + // Invalid outranks the toggle. A view nothing this frame vouches for has no work to schedule, + // so "record everything" must not conjure a rasterisation of a matrix the set disowned — which + // would also claim a row and stamp a timing for a view that does not exist. + const PreparedShadowView prepared = viewWith(rigidDraw()); + const ShadowViewResidency resident = residentFrom(prepared); + CHECK(shadowViewDisposition(false, ShadowReusePolicy::Disabled, prepared, resident) == + ShadowViewDisposition::Invalid); + CHECK(shadowViewDisposition(false, ShadowReusePolicy::Disabled, prepared, + ShadowViewResidency{}) == ShadowViewDisposition::Invalid); +} + +TEST_CASE("a frame recorded with reuse disabled still leaves usable residency", "[ShadowPassPlan]") +{ + // Recording commits either way, so flipping the toggle back on picks up from the NEWEST content + // rather than from whatever was resident when it was switched off. Without this the A/B would + // be misleading in the direction that matters: the first frame after re-enabling would reuse a + // map some earlier frame recorded. + ShadowResidencyStore store{}; + ShadowFramePlan plan{}; + PreparedShadowDraw moved = rigidDraw(); + moved.model = translation(5.0f); + REQUIRE(plan.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, moved), + ShadowViewDisposition::Recorded)); + store.commit(plan); + + CHECK(shadowViewDisposition(true, ShadowReusePolicy::Enabled, cascadeViewAt(0, moved), + store.at(ShadowViewGroup::Cascade, 0)) == + ShadowViewDisposition::Reused); +} + +TEST_CASE("adoption consumes the recording it adopts", "[ShadowPassPlan]") +{ + // The plan hands its recorded content OVER rather than lending it: adoption runs after the + // submit, where a copy could throw and there would be nothing useful to do about it. What the + // entry must not become is a moved-from husk that still reads as `Recorded` content. + ShadowResidencyStore store{}; + ShadowFramePlan plan{}; + REQUIRE(plan.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, rigidDraw()), + ShadowViewDisposition::Recorded)); + store.commit(plan); + + CHECK(store.at(ShadowViewGroup::Cascade, 0).hasContent()); + CHECK(plan.view(ShadowViewGroup::Cascade, 0) == nullptr); + CHECK(plan.disposition(ShadowViewGroup::Cascade, 0) == ShadowViewDisposition::Invalid); + // The slot is free again, which is what "the entry is cleared" has to mean for a type whose + // whole discipline is one claim per slot. + CHECK(plan.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, rigidDraw()), + ShadowViewDisposition::Recorded)); +} + +TEST_CASE("only a recorded slot can be taken", "[ShadowPassPlan]") +{ + ShadowFramePlan plan{}; + REQUIRE(plan.add(ShadowViewGroup::Cascade, 0, cascadeViewAt(0, rigidDraw()), + ShadowViewDisposition::Reused)); + REQUIRE(plan.add(ShadowViewGroup::Cascade, 1, cascadeViewAt(1, rigidDraw()), + ShadowViewDisposition::Invalid)); + CHECK_FALSE(plan.takeRecorded(ShadowViewGroup::Cascade, 0).has_value()); + CHECK_FALSE(plan.takeRecorded(ShadowViewGroup::Cascade, 1).has_value()); + // A reused entry is left INTACT: the frame is still describing it, and only the recorded ones + // are being handed over. + CHECK(plan.view(ShadowViewGroup::Cascade, 0) != nullptr); + CHECK(plan.disposition(ShadowViewGroup::Cascade, 0) == ShadowViewDisposition::Reused); + // And an address that names no slot answers "nothing", rather than reaching into a neighbour. + CHECK_FALSE( + plan.takeRecorded(ShadowViewGroup::Cascade, shadowViewSlotCount(ShadowViewGroup::Cascade)) + .has_value()); +} diff --git a/tests/graphics/test_shadow_pass_prepare.cpp b/tests/graphics/test_shadow_pass_prepare.cpp index 8ef6001..2bdaace 100644 --- a/tests/graphics/test_shadow_pass_prepare.cpp +++ b/tests/graphics/test_shadow_pass_prepare.cpp @@ -134,6 +134,10 @@ struct Prepared ShadowFramePlan plan{}; ShadowFrameStats stats{}; ShadowLodResolver resolver{}; + // What the depth images hold. Empty here is a first frame: every view records. A test that + // wants a SECOND frame commits this one first, which is exactly what the renderer does after a + // successful submit. + ShadowResidencyStore residency{}; }; // Runs a preparation over the standard view set. Returned by value so each test owns its own @@ -143,7 +147,7 @@ void prepare(Prepared& out, const ShadowPreparationInputs& inputs, const ShadowR ShadowMapValidity eligible) { out.resolver.beginFrame(); - prepareShadowFrame(inputs, views, eligible, out.resolver, out.stats, out.plan); + prepareShadowFrame(inputs, views, eligible, out.residency, out.resolver, out.stats, out.plan); } [[nodiscard]] ShadowPreparationInputs inputsFor(std::span shadowDraws, @@ -170,9 +174,9 @@ void prepare(Prepared& out, const ShadowPreparationInputs& inputs, const ShadowR TEST_CASE("every active view is recorded while nothing is resident", "[ShadowPassPrepare]") { - // The reuse half of the item has no residency store yet, so every view is a first use — and a - // first use RECORDS whatever its prepared content looks like, because image creation - // transitions the layout but leaves the depth undefined. + // An EMPTY residency store is a first frame: every view is a first use, and a first use + // RECORDS whatever its prepared content looks like, because image creation transitions the + // layout but leaves the depth undefined. const std::vector draws{nearCaster()}; const ShadowRenderViewSet views = populatedViews(); Prepared out{}; @@ -439,6 +443,211 @@ TEST_CASE("a caster with no stated pose stops the frame", "[ShadowPassPrepare]") const ShadowRenderViewSet views = populatedViews(); Prepared out{}; out.resolver.beginFrame(); - CHECK_THROWS(prepareShadowFrame(inputsFor(draws), views, allFamilies(), out.resolver, out.stats, - out.plan)); + CHECK_THROWS(prepareShadowFrame(inputsFor(draws), views, allFamilies(), out.residency, + out.resolver, out.stats, out.plan)); +} + +// --- reuse: the second frame, judged against what the first one left in the images --------------- + +namespace +{ + +// The renderer's frame boundary, in the order it happens there: the frame reached the queue, so its +// staged LOD decisions AND the content it recorded are both adopted, and the next frame's counters +// start clean (the real one moves to another ring slot). +void submitFrame(Prepared& out) +{ + out.resolver.commitFrame(); + out.residency.commit(out.plan); + out.stats.reset(); +} + +// Every physical slot the plan holds an entry for, with its disposition — enough to assert about a +// whole frame rather than a hand-picked view, which is what "every cacheable view reused" needs. +[[nodiscard]] bool everySlotIs(const ShadowFramePlan& plan, ShadowViewGroup group, + ShadowViewDisposition expected) +{ + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + const ShadowViewDisposition actual = plan.disposition(group, slot); + if (actual == ShadowViewDisposition::Invalid) + { + continue; // not engaged this frame; the view set, not the cache, decided that + } + if (actual != expected) + { + return false; + } + } + return true; +} + +// What the receiver is told, derived the way the renderer derives it: eligibility from the view +// SET, confirmation from the finished plan. +[[nodiscard]] ShadowMapValidity confirmedFor(const ShadowFramePlan& plan, + const ShadowRenderViewSet& views) +{ + const ShadowFamilyEligibility eligibility{ + .shadowsDisabled = false, + .primaryDirectionalLight = true, + .activeViews = {views.activeCount(ShadowViewGroup::Cascade), + views.activeCount(ShadowViewGroup::WorldOnly), + views.activeCount(ShadowViewGroup::Self), + views.activeCount(ShadowViewGroup::Spot), + views.activeCount(ShadowViewGroup::Point)}, + }; + return shadowMapValidityFromPlan(plan, eligibility); +} + +} // namespace + +TEST_CASE("the second identical frame reuses every cacheable view", "[ShadowPassPrepare]") +{ + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + + // FRAME 1. Nothing is resident, so every engaged view records — including the world-only view, + // which has no draws at all: it still clears its image, and that clear is content. + prepare(out, inputsFor(draws), views, allFamilies()); + for (const ShadowViewGroup group : {ShadowViewGroup::Cascade, ShadowViewGroup::WorldOnly, + ShadowViewGroup::Spot, ShadowViewGroup::Point}) + { + CHECK(everySlotIs(out.plan, group, ShadowViewDisposition::Recorded)); + } + CHECK_FALSE(out.plan.recordsNothing()); + const ShadowMapValidity recordedValidity = confirmedFor(out.plan, views); + + submitFrame(out); + + // FRAME 2, same scene, same camera. The comparison finds every input unchanged. + prepare(out, inputsFor(draws), views, allFamilies()); + for (const ShadowViewGroup group : {ShadowViewGroup::Cascade, ShadowViewGroup::WorldOnly, + ShadowViewGroup::Spot, ShadowViewGroup::Point}) + { + CHECK(everySlotIs(out.plan, group, ShadowViewDisposition::Reused)); + // Reused is SAMPLEABLE: the family is fully valid while doing no GPU work at all. + CHECK(out.plan.sampleableCount(group) > 0); + CHECK_FALSE(out.plan.records(group)); + } + CHECK(out.plan.recordsNothing()); + CHECK(out.plan.pointCubesWhole()); + // And the receiver is told EXACTLY what it was told when the frame rasterised — the half that + // matters, since a frame doing no shadow work at all must still shade with shadows. Reuse is + // sampleable, so confirmation cannot tell the two frames apart. + CHECK(confirmedFor(out.plan, views) == recordedValidity); + // Not vacuous: the fixture engages one cascade of four, so the cascade family is legitimately + // unconfirmed, and `spot` is what keeps the comparison above from being two empty masks. + CHECK(recordedValidity.spot); +} + +TEST_CASE("a frame that never reached the queue leaves nothing resident", "[ShadowPassPrepare]") +{ + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + + prepare(out, inputsFor(draws), views, allFamilies()); + // The frame is ABANDONED — a lost swapchain, a throw before submit. Nothing is committed, so + // the images hold whatever they held before, and the next frame must draw rather than trust a + // record of pixels the GPU was never given. + out.stats.reset(); + prepare(out, inputsFor(draws), views, allFamilies()); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Cascade, ShadowViewDisposition::Recorded)); + CHECK_FALSE(out.plan.recordsNothing()); +} + +TEST_CASE("a moved caster re-records only the families that see it", "[ShadowPassPrepare]") +{ + std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + submitFrame(out); + + // The caster moves. Its model matrix is content for every family that draws it, so all of them + // re-record — but the world-only view, which this caster never entered, still reuses. + draws[0].shadowRequest.pose = + ShadowCasterPose::fromModel(Mat4::translate(Vec3{0.1f, 0.0f, 0.0f})); + prepare(out, inputsFor(draws), views, allFamilies()); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Cascade, ShadowViewDisposition::Recorded)); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Spot, ShadowViewDisposition::Recorded)); + CHECK(everySlotIs(out.plan, ShadowViewGroup::WorldOnly, ShadowViewDisposition::Reused)); +} + +TEST_CASE("rigid views reuse in the same frame a deformable one records", "[ShadowPassPrepare]") +{ + // The claim that matters for a mixed scene, and the reason "every view is reused" would be the + // WRONG gate: a skinned self-shadow caster rewrites its vertices with nothing in the descriptor + // able to see it, so its view can never be cached — while the rigid families around it are + // reused in the same frame, and both are sampleable. + const std::vector rigid{nearCaster(1)}; + std::vector self{ + caster(2, boundsAt(Vec3{0.0f, 0.0f, 0.0f}), ShadowCasterDeformation::Deformable)}; + self[0].selfShadowSlot = 0; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + + prepare(out, inputsFor(rigid, {}, self), views, allFamilies()); + submitFrame(out); + prepare(out, inputsFor(rigid, {}, self), views, allFamilies()); + + CHECK(everySlotIs(out.plan, ShadowViewGroup::Cascade, ShadowViewDisposition::Reused)); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Spot, ShadowViewDisposition::Reused)); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Self, ShadowViewDisposition::Recorded)); + // Half the frame does GPU work; all of it is sampleable. + CHECK(out.plan.records(ShadowViewGroup::Self)); + CHECK_FALSE(out.plan.records(ShadowViewGroup::Cascade)); + CHECK(out.plan.sampleableCount(ShadowViewGroup::Self) == 1); + CHECK(out.plan.sampleableCount(ShadowViewGroup::Cascade) > 0); +} + +TEST_CASE("the reuse toggle records what would have been reused, and says so in the row", + "[ShadowPassPrepare]") +{ + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + submitFrame(out); + + // Reuse OFF: the same scene, the same residency, and every engaged view records — the pass as + // it behaved before the cache existed, which is what makes this an honest A/B. + ShadowPreparationInputs noReuse = inputsFor(draws); + noReuse.residencyReuseEnabled = false; + prepare(out, noReuse, views, allFamilies()); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Cascade, ShadowViewDisposition::Recorded)); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Point, ShadowViewDisposition::Recorded)); + // The ROW carries the decision, so the panel shows a re-render rather than leaving a reader to + // infer one from a raster count the recorder has not produced yet. + CHECK(out.stats.view(ShadowViewGroup::Cascade, 0).disposition == + ShadowViewDisposition::Recorded); + + // And back on: the frame just recorded committed as usual, so this reuses the NEWEST content. + submitFrame(out); + prepare(out, inputsFor(draws), views, allFamilies()); + CHECK(everySlotIs(out.plan, ShadowViewGroup::Cascade, ShadowViewDisposition::Reused)); + const ShadowViewStats& row = out.stats.view(ShadowViewGroup::Cascade, 0); + CHECK(row.disposition == ShadowViewDisposition::Reused); + // A reused row is CLAIMED and OBSERVED while rasterising nothing: the counters still describe + // the geometry the map holds, and the raster passes stay with the recorder, which never runs. + CHECK(row.rasterPasses == 0); + CHECK(row.candidateDraws > 0); + CHECK(row.logicalId.valid()); +} + +TEST_CASE("a view that never engaged reports no disposition at all", "[ShadowPassPrepare]") +{ + // `Invalid` on a row the plan never claimed is what separates "reused, drew nothing" from + // "was not there" — the distinction the whole row exists to preserve. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + + const std::size_t unusedSpot = shadowViewSlotCount(ShadowViewGroup::Spot) - 1; + CHECK(out.stats.view(ShadowViewGroup::Spot, unusedSpot).disposition == + ShadowViewDisposition::Invalid); + CHECK(out.plan.disposition(ShadowViewGroup::Spot, unusedSpot) == + ShadowViewDisposition::Invalid); } diff --git a/tests/platform/test_application_args.cpp b/tests/platform/test_application_args.cpp index 4c10104..411cf3c 100644 --- a/tests/platform/test_application_args.cpp +++ b/tests/platform/test_application_args.cpp @@ -56,6 +56,10 @@ TEST_CASE("ApplicationArgs.EmptyArgsUseDefaults", "[ApplicationArgs]") CHECK(args.debug.view == DebugView::None); CHECK_FALSE(args.debug.noShadows); CHECK(args.debug.taa); + // ON by default (arc 2 #4). Pinned here because the acceptance baseline is defined by its + // absence: if the default ever flipped, every "forced record" measurement would silently become + // a measurement of reuse and still look like a valid run. + CHECK(args.debug.shadowResidencyReuse); CHECK_FALSE(args.debug.overlayVisible); CHECK_FALSE(args.addFloor); CHECK_FALSE(args.addParticles); @@ -504,3 +508,33 @@ TEST_CASE("ApplicationArgs.RepeatedCaptureFlagsTakeTheLastValue", "[ApplicationA CHECK(args.debug.capturePath == "second.png"); CHECK(args.debug.captureFrame == 9); } + +TEST_CASE("ApplicationArgs.NoShadowReuseForcesEveryShadowViewToRecord", "[ApplicationArgs]") +{ + // The A/B switch for the shadow-residency cache. It has to work from the FIRST frame — a run + // whose early frames reused is not a forced-record baseline — which is why it is a flag and not + // only an overlay checkbox. + const auto parsed = parseArgs({"fireEngineApp", "--no-shadow-reuse"}); + CHECK_FALSE(parsed.args.debug.shadowResidencyReuse); + // Nothing else moves with it: reuse is scheduling, and the flag must not quietly imply a + // shadow-LOD or TAA change that would make the two halves of an A/B differ in a second way. + CHECK(parsed.args.debug.taa); + CHECK_FALSE(parsed.args.debug.noShadows); +} + +TEST_CASE("ApplicationArgs.NoShadowReuseCoexistsWithPositionalArguments", "[ApplicationArgs]") +{ + // The measurement commands in docs/acceptance-testing.md pass the flag alongside a scene and a + // skybox, in that order and in the other: a flag that swallowed a positional (or was swallowed + // by one) would silently load a different scene than the baseline it is being compared with. + const auto leading = parseArgs({"fireEngineApp", "--no-shadow-reuse", "scene.gltf", "sky.hdr"}); + CHECK_FALSE(leading.args.debug.shadowResidencyReuse); + CHECK(leading.args.scenePath == "scene.gltf"); + CHECK(leading.args.skyboxPath == "sky.hdr"); + + const auto trailing = + parseArgs({"fireEngineApp", "scene.gltf", "sky.hdr", "--no-shadow-reuse"}); + CHECK_FALSE(trailing.args.debug.shadowResidencyReuse); + CHECK(trailing.args.scenePath == "scene.gltf"); + CHECK(trailing.args.skyboxPath == "sky.hdr"); +}