From 075093c0a6d952e1bf61b948e27c7460ccce7ef9 Mon Sep 17 00:00:00 2001 From: Nick Newson Date: Sat, 1 Aug 2026 08:14:53 +0100 Subject: [PATCH] SH-06 slices 1-2: extract the cascade fit into receiver/depth carriers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits Renderer::computeShadowCascades' fitCascade lambda into two pure, Vulkan-free carriers in render/cascade_fit.{hpp,cpp}, because the pipeline is receiver slice -> stable XY fit -> candidate query -> depth fit -> render matrix and the candidate query consumes the first. Baking Z into a single fit made the query depend on its own output, which is why a fixed kShadowDepthBackExtend was needed at all. CascadeReceiverFit::fit produces the slice's light-space footprint: normalised basis, frustum and snapped centre, radius, snapped U/V bounds, exact receiver min/max W from the eight corners (not centreW +/- radius — the caster-aware far plane must reach the receiver volume, and the bounding sphere is generally looser in depth), and worldPerTexel. fitLegacyCascadeDepth turns that into the light position and view-projection using today's fixed back-extension, and is the one function SH-06 replaces with the caster-aware policy. The extraction is proven to be a no-op: tests/render/test_cascade_fit.cpp holds a verbatim copy of the pre-extraction lambda and asserts the matrices are bit-identical across four camera/sun poses x the four shipped splits, on both macOS/arm64 and Linux/x86_64, with a second case proving that reference still responds to its own inputs so the equality cannot pass vacuously. That equality forced one contract rather than confirming one: lightDirection must arrive unit length and is rejected, not normalised. Vec3::normalise of an already-unit vector moves it by an ulp whenever its squared length lands just under one, which changed two of the four poses. Tolerance is 8 * FLT_EPSILON on squared length — sized in float rounding, so a 1.0001 scale is caught while real normalise output is not. CascadeReceiverFit is encapsulated like ShadowView (private constructor, static factory, read-only getters). As a public aggregate it had a hole no field-wise validator closes cheaply: lightUp set equal to lightDirection is finite, passes every check worth writing, and sends Mat4::lookAt to its own fallback up — manufacturing exactly the plausible basis this API refuses. A shared full-carrier validator would only hold while every future consumer remembered to call it. The invariant is pinned with STATIC_REQUIRE_FALSE on is_default_constructible/is_aggregate, since there is no longer a runtime state to test. CascadeDepthFit stays a plain aggregate: nothing consumes one, so no policy has to trust it. fitLegacyCascadeDepth therefore validates only backExtend — the one input still arriving from outside — and its own output. Negative extensions are the instructive case, verified against the raw expressions: a small one keeps the range ordered but pulls both planes inside the fitted sphere, and one past -radius reverses it so every depth comparison inverts. Both produce fully finite matrices, which is precisely what the view set's non-finite validation waves through. Both rejections are terminal in the renderer via rejectedCascadeFit. Adds per-cascade fit diagnostics at FE_LOG=render:debug, sampled every 120 frames starting with the first plus unconditionally on the frame --capture-frame selects, reading only values back from the two carriers. A periodic sample alone cannot describe a capture: at a 120-frame stride, frame 300's image would be explained by the fit from frame 241. Later caster-bound and cascade-blend diagnostics should share that capture-triggered condition so all evidence describes one submitted frame. Adds ShadowDepthClipDemo to assets/shadow_lod/generate.py as a depth-clip probe, with a dedicated validator whose eight claims were each negative-tested (two vacuous assertions were found and fixed that way). Both existing generated scenes remain byte-identical. The fit log confirms the placement is exact — cascade 2 reports depth W [-41.340, 33.535], the predicted plane, with the caster centre on it — but the probe is not discriminating, because the shadow pass front-culls and the downstream hemisphere still projects a complete silhouette. It stays a probe; the acceptance fixture will be the frozen ShadowLodMotionDemo failing pose. --- CMakeLists.txt | 2 + assets/shadow_lod/ShadowDepthClipDemo.gltf | 262 +++++++++ assets/shadow_lod/generate.py | 128 +++++ docs/onboarding.md | 15 + docs/review-order.md | 1 + docs/shadowplans.md | 84 ++- include/fire_engine/render/cascade_fit.hpp | 222 +++++++ include/fire_engine/render/renderer.hpp | 4 + src/render/cascade_fit.cpp | 221 +++++++ src/render/renderer.cpp | 141 +++-- tests/render/test_cascade_fit.cpp | 640 +++++++++++++++++++++ 11 files changed, 1648 insertions(+), 72 deletions(-) create mode 100644 assets/shadow_lod/ShadowDepthClipDemo.gltf create mode 100644 include/fire_engine/render/cascade_fit.hpp create mode 100644 src/render/cascade_fit.cpp create mode 100644 tests/render/test_cascade_fit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 20589e6..7b7af01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,7 @@ add_library(fireengine SHARED src/physics/physics_world_events.cpp src/physics/physics_world_queries.cpp src/physics/physics_world_shapes.cpp + src/render/cascade_fit.cpp src/render/compute_pipeline.cpp src/render/debug_draw.cpp src/render/debug_overlay.cpp @@ -419,6 +420,7 @@ add_executable(test_fire_engine tests/graphics/test_shadow_render_view.cpp tests/graphics/test_texture.cpp tests/graphics/test_sampler_settings.cpp + tests/render/test_cascade_fit.cpp tests/render/test_ubo.cpp tests/render/test_descriptors.cpp tests/render/test_device_plan.cpp diff --git a/assets/shadow_lod/ShadowDepthClipDemo.gltf b/assets/shadow_lod/ShadowDepthClipDemo.gltf new file mode 100644 index 0000000..b6a081b --- /dev/null +++ b/assets/shadow_lod/ShadowDepthClipDemo.gltf @@ -0,0 +1,262 @@ +{ + "asset": { + "version": "2.0", + "generator": "fireEngine shadow_lod generate.py" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0, + 1, + 2, + 3 + ] + } + ], + "nodes": [ + { + "name": "Receiver_Floor", + "mesh": 0, + "translation": [ + 0.0, + -0.25, + 0.0 + ], + "extras": { + "Shadow": { + "Casts": false + } + } + }, + { + "name": "DepthClip_Caster", + "mesh": 1, + "translation": [ + 24.22, + 28.14, + 18.29 + ] + }, + { + "name": "Sun", + "rotation": [ + -0.4249625303318254, + 0.3354967344724938, + 0.0, + 0.8407429981702289 + ], + "extensions": { + "KHR_lights_punctual": { + "light": 0 + } + } + }, + { + "name": "Camera", + "camera": 0, + "translation": [ + 13.0, + 7.5, + 17.0 + ], + "rotation": [ + -0.13502949057731012, + 0.30455539054188985, + 0.043662339515153005, + 0.9418633928943053 + ] + } + ], + "meshes": [ + { + "name": "Receiver_Floor", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "NORMAL": 1 + }, + "indices": 2, + "material": 0 + } + ] + }, + { + "name": "DepthClip_Caster", + "primitives": [ + { + "attributes": { + "POSITION": 3, + "NORMAL": 4 + }, + "indices": 5, + "material": 1 + } + ] + } + ], + "materials": [ + { + "name": "Mat0", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.62, + 0.62, + 0.64, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + }, + { + "name": "Mat1", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.85, + 0.5, + 0.25, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 24, + "type": "VEC3", + "min": [ + -24.0, + -0.25, + -24.0 + ], + "max": [ + 24.0, + 0.25, + 24.0 + ] + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 24, + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5123, + "count": 36, + "type": "SCALAR" + }, + { + "bufferView": 3, + "componentType": 5126, + "count": 425, + "type": "VEC3", + "min": [ + -2.0, + -2.0, + -2.0 + ], + "max": [ + 2.0, + 2.0, + 2.0 + ] + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 425, + "type": "VEC3" + }, + { + "bufferView": 5, + "componentType": 5123, + "count": 2304, + "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": 5100, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 5748, + "byteLength": 5100, + "target": 34962 + }, + { + "buffer": 0, + "byteOffset": 10848, + "byteLength": 4608, + "target": 34963 + } + ], + "buffers": [ + { + "byteLength": 15456, + "uri": "data:application/octet-stream;base64,AADAQQAAgL4AAMDBAADAQQAAgD4AAMDBAADAQQAAgD4AAMBBAADAQQAAgL4AAMBBAADAwQAAgL4AAMBBAADAwQAAgD4AAMBBAADAwQAAgD4AAMDBAADAwQAAgL4AAMDBAADAwQAAgD4AAMDBAADAwQAAgD4AAMBBAADAQQAAgD4AAMBBAADAQQAAgD4AAMDBAADAwQAAgL4AAMBBAADAwQAAgL4AAMDBAADAQQAAgL4AAMDBAADAQQAAgL4AAMBBAADAQQAAgL4AAMBBAADAQQAAgD4AAMBBAADAwQAAgD4AAMBBAADAwQAAgL4AAMBBAADAwQAAgL4AAMDBAADAwQAAgD4AAMDBAADAQQAAgD4AAMDBAADAQQAAgL4AAMDBAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAAAAAAAAgAAAAEAAAACAAAAAgAAAAEAAAACAAAAAgAAAAEAAAACAAAAAgAAAAEAAAACAAAAAgAAAAEAAAACAAAAAgAAAAEAAAACAAAAAAAAAAEAAAACAAAAAAAAAAEAAAACA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Bq3HJAAAgL8GrcekUI30JAAAgL8yMY2kk2EIJQAAgL9CLBKkMjENJQAAgL90vhuLAAABABkAAQAaABkAAQACABoAAgAbABoAAgADABsAAwAcABsAAwAEABwABAAdABwABAAFAB0ABQAeAB0ABQAGAB4ABgAfAB4ABgAHAB8ABwAgAB8ABwAIACAACAAhACAACAAJACEACQAiACEACQAKACIACgAjACIACgALACMACwAkACMACwAMACQADAAlACQADAANACUADQAmACUADQAOACYADgAnACYADgAPACcADwAoACcADwAQACgAEAApACgAEAARACkAEQAqACkAEQASACoAEgArACoAEgATACsAEwAsACsAEwAUACwAFAAtACwAFAAVAC0AFQAuAC0AFQAWAC4AFgAvAC4AFgAXAC8AFwAwAC8AFwAYADAAGAAxADAAGQAaADIAGgAzADIAGgAbADMAGwA0ADMAGwAcADQAHAA1ADQAHAAdADUAHQA2ADUAHQAeADYAHgA3ADYAHgAfADcAHwA4ADcAHwAgADgAIAA5ADgAIAAhADkAIQA6ADkAIQAiADoAIgA7ADoAIgAjADsAIwA8ADsAIwAkADwAJAA9ADwAJAAlAD0AJQA+AD0AJQAmAD4AJgA/AD4AJgAnAD8AJwBAAD8AJwAoAEAAKABBAEAAKAApAEEAKQBCAEEAKQAqAEIAKgBDAEIAKgArAEMAKwBEAEMAKwAsAEQALABFAEQALAAtAEUALQBGAEUALQAuAEYALgBHAEYALgAvAEcALwBIAEcALwAwAEgAMABJAEgAMAAxAEkAMQBKAEkAMgAzAEsAMwBMAEsAMwA0AEwANABNAEwANAA1AE0ANQBOAE0ANQA2AE4ANgBPAE4ANgA3AE8ANwBQAE8ANwA4AFAAOABRAFAAOAA5AFEAOQBSAFEAOQA6AFIAOgBTAFIAOgA7AFMAOwBUAFMAOwA8AFQAPABVAFQAPAA9AFUAPQBWAFUAPQA+AFYAPgBXAFYAPgA/AFcAPwBYAFcAPwBAAFgAQABZAFgAQABBAFkAQQBaAFkAQQBCAFoAQgBbAFoAQgBDAFsAQwBcAFsAQwBEAFwARABdAFwARABFAF0ARQBeAF0ARQBGAF4ARgBfAF4ARgBHAF8ARwBgAF8ARwBIAGAASABhAGAASABJAGEASQBiAGEASQBKAGIASgBjAGIASwBMAGQATABlAGQATABNAGUATQBmAGUATQBOAGYATgBnAGYATgBPAGcATwBoAGcATwBQAGgAUABpAGgAUABRAGkAUQBqAGkAUQBSAGoAUgBrAGoAUgBTAGsAUwBsAGsAUwBUAGwAVABtAGwAVABVAG0AVQBuAG0AVQBWAG4AVgBvAG4AVgBXAG8AVwBwAG8AVwBYAHAAWABxAHAAWABZAHEAWQByAHEAWQBaAHIAWgBzAHIAWgBbAHMAWwB0AHMAWwBcAHQAXAB1AHQAXABdAHUAXQB2AHUAXQBeAHYAXgB3AHYAXgBfAHcAXwB4AHcAXwBgAHgAYAB5AHgAYABhAHkAYQB6AHkAYQBiAHoAYgB7AHoAYgBjAHsAYwB8AHsAZABlAH0AZQB+AH0AZQBmAH4AZgB/AH4AZgBnAH8AZwCAAH8AZwBoAIAAaACBAIAAaABpAIEAaQCCAIEAaQBqAIIAagCDAIIAagBrAIMAawCEAIMAawBsAIQAbACFAIQAbABtAIUAbQCGAIUAbQBuAIYAbgCHAIYAbgBvAIcAbwCIAIcAbwBwAIgAcACJAIgAcABxAIkAcQCKAIkAcQByAIoAcgCLAIoAcgBzAIsAcwCMAIsAcwB0AIwAdACNAIwAdAB1AI0AdQCOAI0AdQB2AI4AdgCPAI4AdgB3AI8AdwCQAI8AdwB4AJAAeACRAJAAeAB5AJEAeQCSAJEAeQB6AJIAegCTAJIAegB7AJMAewCUAJMAewB8AJQAfACVAJQAfQB+AJYAfgCXAJYAfgB/AJcAfwCYAJcAfwCAAJgAgACZAJgAgACBAJkAgQCaAJkAgQCCAJoAggCbAJoAggCDAJsAgwCcAJsAgwCEAJwAhACdAJwAhACFAJ0AhQCeAJ0AhQCGAJ4AhgCfAJ4AhgCHAJ8AhwCgAJ8AhwCIAKAAiAChAKAAiACJAKEAiQCiAKEAiQCKAKIAigCjAKIAigCLAKMAiwCkAKMAiwCMAKQAjAClAKQAjACNAKUAjQCmAKUAjQCOAKYAjgCnAKYAjgCPAKcAjwCoAKcAjwCQAKgAkACpAKgAkACRAKkAkQCqAKkAkQCSAKoAkgCrAKoAkgCTAKsAkwCsAKsAkwCUAKwAlACtAKwAlACVAK0AlQCuAK0AlgCXAK8AlwCwAK8AlwCYALAAmACxALAAmACZALEAmQCyALEAmQCaALIAmgCzALIAmgCbALMAmwC0ALMAmwCcALQAnAC1ALQAnACdALUAnQC2ALUAnQCeALYAngC3ALYAngCfALcAnwC4ALcAnwCgALgAoAC5ALgAoAChALkAoQC6ALkAoQCiALoAogC7ALoAogCjALsAowC8ALsAowCkALwApAC9ALwApAClAL0ApQC+AL0ApQCmAL4ApgC/AL4ApgCnAL8ApwDAAL8ApwCoAMAAqADBAMAAqACpAMEAqQDCAMEAqQCqAMIAqgDDAMIAqgCrAMMAqwDEAMMAqwCsAMQArADFAMQArACtAMUArQDGAMUArQCuAMYArgDHAMYArwCwAMgAsADJAMgAsACxAMkAsQDKAMkAsQCyAMoAsgDLAMoAsgCzAMsAswDMAMsAswC0AMwAtADNAMwAtAC1AM0AtQDOAM0AtQC2AM4AtgDPAM4AtgC3AM8AtwDQAM8AtwC4ANAAuADRANAAuAC5ANEAuQDSANEAuQC6ANIAugDTANIAugC7ANMAuwDUANMAuwC8ANQAvADVANQAvAC9ANUAvQDWANUAvQC+ANYAvgDXANYAvgC/ANcAvwDYANcAvwDAANgAwADZANgAwADBANkAwQDaANkAwQDCANoAwgDbANoAwgDDANsAwwDcANsAwwDEANwAxADdANwAxADFAN0AxQDeAN0AxQDGAN4AxgDfAN4AxgDHAN8AxwDgAN8AyADJAOEAyQDiAOEAyQDKAOIAygDjAOIAygDLAOMAywDkAOMAywDMAOQAzADlAOQAzADNAOUAzQDmAOUAzQDOAOYAzgDnAOYAzgDPAOcAzwDoAOcAzwDQAOgA0ADpAOgA0ADRAOkA0QDqAOkA0QDSAOoA0gDrAOoA0gDTAOsA0wDsAOsA0wDUAOwA1ADtAOwA1ADVAO0A1QDuAO0A1QDWAO4A1gDvAO4A1gDXAO8A1wDwAO8A1wDYAPAA2ADxAPAA2ADZAPEA2QDyAPEA2QDaAPIA2gDzAPIA2gDbAPMA2wD0APMA2wDcAPQA3AD1APQA3ADdAPUA3QD2APUA3QDeAPYA3gD3APYA3gDfAPcA3wD4APcA3wDgAPgA4AD5APgA4QDiAPoA4gD7APoA4gDjAPsA4wD8APsA4wDkAPwA5AD9APwA5ADlAP0A5QD+AP0A5QDmAP4A5gD/AP4A5gDnAP8A5wAAAf8A5wDoAAAB6AABAQAB6ADpAAEB6QACAQEB6QDqAAIB6gADAQIB6gDrAAMB6wAEAQMB6wDsAAQB7AAFAQQB7ADtAAUB7QAGAQUB7QDuAAYB7gAHAQYB7gDvAAcB7wAIAQcB7wDwAAgB8AAJAQgB8ADxAAkB8QAKAQkB8QDyAAoB8gALAQoB8gDzAAsB8wAMAQsB8wD0AAwB9AANAQwB9AD1AA0B9QAOAQ0B9QD2AA4B9gAPAQ4B9gD3AA8B9wAQAQ8B9wD4ABAB+AARARAB+AD5ABEB+QASAREB+gD7ABMB+wAUARMB+wD8ABQB/AAVARQB/AD9ABUB/QAWARUB/QD+ABYB/gAXARYB/gD/ABcB/wAYARcB/wAAARgBAAEZARgBAAEBARkBAQEaARkBAQECARoBAgEbARoBAgEDARsBAwEcARsBAwEEARwBBAEdARwBBAEFAR0BBQEeAR0BBQEGAR4BBgEfAR4BBgEHAR8BBwEgAR8BBwEIASABCAEhASABCAEJASEBCQEiASEBCQEKASIBCgEjASIBCgELASMBCwEkASMBCwEMASQBDAElASQBDAENASUBDQEmASUBDQEOASYBDgEnASYBDgEPAScBDwEoAScBDwEQASgBEAEpASgBEAERASkBEQEqASkBEQESASoBEgErASoBEwEUASwBFAEtASwBFAEVAS0BFQEuAS0BFQEWAS4BFgEvAS4BFgEXAS8BFwEwAS8BFwEYATABGAExATABGAEZATEBGQEyATEBGQEaATIBGgEzATIBGgEbATMBGwE0ATMBGwEcATQBHAE1ATQBHAEdATUBHQE2ATUBHQEeATYBHgE3ATYBHgEfATcBHwE4ATcBHwEgATgBIAE5ATgBIAEhATkBIQE6ATkBIQEiAToBIgE7AToBIgEjATsBIwE8ATsBIwEkATwBJAE9ATwBJAElAT0BJQE+AT0BJQEmAT4BJgE/AT4BJgEnAT8BJwFAAT8BJwEoAUABKAFBAUABKAEpAUEBKQFCAUEBKQEqAUIBKgFDAUIBKgErAUMBKwFEAUMBLAEtAUUBLQFGAUUBLQEuAUYBLgFHAUYBLgEvAUcBLwFIAUcBLwEwAUgBMAFJAUgBMAExAUkBMQFKAUkBMQEyAUoBMgFLAUoBMgEzAUsBMwFMAUsBMwE0AUwBNAFNAUwBNAE1AU0BNQFOAU0BNQE2AU4BNgFPAU4BNgE3AU8BNwFQAU8BNwE4AVABOAFRAVABOAE5AVEBOQFSAVEBOQE6AVIBOgFTAVIBOgE7AVMBOwFUAVMBOwE8AVQBPAFVAVQBPAE9AVUBPQFWAVUBPQE+AVYBPgFXAVYBPgE/AVcBPwFYAVcBPwFAAVgBQAFZAVgBQAFBAVkBQQFaAVkBQQFCAVoBQgFbAVoBQgFDAVsBQwFcAVsBQwFEAVwBRAFdAVwBRQFGAV4BRgFfAV4BRgFHAV8BRwFgAV8BRwFIAWABSAFhAWABSAFJAWEBSQFiAWEBSQFKAWIBSgFjAWIBSgFLAWMBSwFkAWMBSwFMAWQBTAFlAWQBTAFNAWUBTQFmAWUBTQFOAWYBTgFnAWYBTgFPAWcBTwFoAWcBTwFQAWgBUAFpAWgBUAFRAWkBUQFqAWkBUQFSAWoBUgFrAWoBUgFTAWsBUwFsAWsBUwFUAWwBVAFtAWwBVAFVAW0BVQFuAW0BVQFWAW4BVgFvAW4BVgFXAW8BVwFwAW8BVwFYAXABWAFxAXABWAFZAXEBWQFyAXEBWQFaAXIBWgFzAXIBWgFbAXMBWwF0AXMBWwFcAXQBXAF1AXQBXAFdAXUBXQF2AXUBXgFfAXcBXwF4AXcBXwFgAXgBYAF5AXgBYAFhAXkBYQF6AXkBYQFiAXoBYgF7AXoBYgFjAXsBYwF8AXsBYwFkAXwBZAF9AXwBZAFlAX0BZQF+AX0BZQFmAX4BZgF/AX4BZgFnAX8BZwGAAX8BZwFoAYABaAGBAYABaAFpAYEBaQGCAYEBaQFqAYIBagGDAYIBagFrAYMBawGEAYMBawFsAYQBbAGFAYQBbAFtAYUBbQGGAYUBbQFuAYYBbgGHAYYBbgFvAYcBbwGIAYcBbwFwAYgBcAGJAYgBcAFxAYkBcQGKAYkBcQFyAYoBcgGLAYoBcgFzAYsBcwGMAYsBcwF0AYwBdAGNAYwBdAF1AY0BdQGOAY0BdQF2AY4BdgGPAY4BdwF4AZABeAGRAZABeAF5AZEBeQGSAZEBeQF6AZIBegGTAZIBegF7AZMBewGUAZMBewF8AZQBfAGVAZQBfAF9AZUBfQGWAZUBfQF+AZYBfgGXAZYBfgF/AZcBfwGYAZcBfwGAAZgBgAGZAZgBgAGBAZkBgQGaAZkBgQGCAZoBggGbAZoBggGDAZsBgwGcAZsBgwGEAZwBhAGdAZwBhAGFAZ0BhQGeAZ0BhQGGAZ4BhgGfAZ4BhgGHAZ8BhwGgAZ8BhwGIAaABiAGhAaABiAGJAaEBiQGiAaEBiQGKAaIBigGjAaIBigGLAaMBiwGkAaMBiwGMAaQBjAGlAaQBjAGNAaUBjQGmAaUBjQGOAaYBjgGnAaYBjgGPAacBjwGoAacB" + } + ], + "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": "Sun", + "type": "directional", + "color": [ + 1.0, + 0.97, + 0.9 + ], + "intensity": 1.35 + } + ] + } + } +} diff --git a/assets/shadow_lod/generate.py b/assets/shadow_lod/generate.py index 429be31..6eec743 100644 --- a/assets/shadow_lod/generate.py +++ b/assets/shadow_lod/generate.py @@ -121,6 +121,123 @@ def _morph_bulge(positions, axis=0, amount=0.6): return deltas, normal_deltas +# --- SH-06 depth-clip fixture ---------------------------------------------- +# A caster far enough UPSTREAM along the sun to fall outside the cascade's fixed light-space +# near plane (`kShadowDepthBackExtend`, 20 world units behind the fitted centre). Its own +# scene, deliberately: ShadowLodDemo is the SH-03/SH-04 calibration baseline, and adding a +# caster to it would silently move every recorded number. +# +# Placed so the whole experiment stays inside ONE cascade. The floor origin is sampled by +# cascade 2 (camera depth ~22.6 m against its 13.64-24.05 m band), and a long thin caster's +# shadow would stretch across the cascade 2/3 boundary — where the old near plane does not +# clip — turning the gate into a cascade-transition experiment instead of a depth-clip one. +# A compact sphere keeps the projected ellipse wholly within cascade 2. +# +# The sphere centre sits ON cascade 2's legacy near plane (w = dot(centre, lightDir) ~ -41.34), +# so that plane cuts through it: before the fix the ellipse has a straight clipped edge, after +# it the ellipse is complete. That is the entire acceptance statement. +DEPTH_CLIP_SHADOW_CENTRE = (2.0, 0.0, 2.0) +DEPTH_CLIP_CASTER_CENTRE = (24.22, 28.14, 18.29) +DEPTH_CLIP_CASTER_RADIUS = 2.0 + + +def build_depth_clip_scene(): + """The SH-06 reproduction: one sphere, one floor, one sun, one camera. + + Minimal on purpose. Every extra caster is another explanation for a missing shadow, and + this scene exists to make exactly one failure unambiguous.""" + s = Scene(GENERATOR) + named = {} + + named["Receiver_Floor"] = s.box( + "Receiver_Floor", (FLOOR_HALF, FLOOR_THICKNESS, FLOOR_HALF), + (0.0, -FLOOR_THICKNESS, 0.0), GREY, extras={"Shadow": {"Casts": False}}) + + # Off-camera by construction: its shadow is the only evidence it exists, which is also + # what makes a clipped shadow impossible to mistake for a caster that simply is not there. + named["DepthClip_Caster"] = s.sphere( + "DepthClip_Caster", DEPTH_CLIP_CASTER_RADIUS, DEPTH_CLIP_CASTER_CENTRE, ORANGE) + + named["Sun"] = s.add_node( + "Sun", + light=s.light("Sun", "directional", colour=(1.0, 0.97, 0.9), intensity=SUN_INTENSITY), + rotation=quat_from_to(LIGHT_FORWARD, SUN_DIRECTION)) + named["Camera"] = s.camera(CAMERA_EYE, CAMERA_TARGET) + return s, named + + +def validate_depth_clip(doc): + """Pins the fixture's authored geometry — NOT the engine's cascade fit. + + Deliberately does not reimplement the cascade fitter in Python: a second implementation + would drift from the renderer's and start passing for the wrong reason. What is checked + here is that the AUTHORED ray, poses and scene shape are what the acceptance statement + assumes. That the caster overlaps the cascade in XY and straddles its legacy near plane is + proven by the pure fit's own headless tests once SH-06 adds them; until then the static + capture is the red test.""" + nodes = doc["nodes"] + names = sorted(n.get("name", "") for n in nodes) + assert names == ["Camera", "DepthClip_Caster", "Receiver_Floor", "Sun"], \ + f"the fixture must stay minimal; got {names}" + + assert not doc.get("animations"), "the fixture is static — a moving caster is not a gate" + assert not doc.get("skins"), "no skins: SH-06 is about depth fitting, not deformation" + # RIGID and STATIC, pinned through the actual references rather than the node dict. An earlier + # version of this check tested `"targets" not in str(node.get("mesh"))`, which is vacuously true + # for every scene ever written: `node["mesh"]` is an integer index, so the string never contains + # "targets". A fixture whose classification is asserted by an assertion that cannot fail is worth + # less than no assertion, because it reads as covered. + meshes = doc.get("meshes", []) + for node in nodes: + extras = node.get("extras", {}) + for unwanted in ("Physics", "Cloth"): + assert unwanted not in extras, \ + f"{node.get('name')} carries extras.{unwanted}; the fixture must stay rigid" + assert "skin" not in node, f"{node.get('name')} is skinned" + assert "weights" not in node, f"{node.get('name')} carries morph weights" + if "mesh" in node: + mesh = meshes[node["mesh"]] + assert "weights" not in mesh, f"mesh of {node.get('name')} carries morph weights" + for primitive in mesh["primitives"]: + assert "targets" not in primitive, \ + f"mesh of {node.get('name')} has morph targets; SH-06 needs rigid geometry" + + lights = doc.get("extensions", {}).get("KHR_lights_punctual", {}).get("lights", []) + kinds = [light["type"] for light in lights] + assert kinds == ["directional"], f"exactly one directional light, no punctual; got {kinds}" + + receive_only = [n.get("name") for n in nodes + if n.get("extras", {}).get("Shadow", {}).get("Casts") is False] + assert receive_only == ["Receiver_Floor"], \ + f"the floor is the only receive-only object; got {receive_only}" + + # The authored ray: the caster centre, followed along the sun, must land on the floor at + # the shadow centre this fixture is built around. Placing the sphere by eye and trusting a + # capture would let the ellipse drift out of the intended cascade unnoticed. + # Read the caster's position OUT OF THE DOCUMENT, never from the module constant. Checking the + # constant against itself is an assertion that cannot fail — the same vacuous shape as the morph + # check above — and it would pass happily while the authored node sat somewhere else entirely. + caster = next(n for n in nodes if n.get("name") == "DepthClip_Caster") + centre = tuple(caster["translation"]) + length = math.sqrt(sum(component * component for component in SUN_DIRECTION)) + direction = tuple(component / length for component in SUN_DIRECTION) + steps = centre[1] / -direction[1] + landing = tuple(centre[i] + steps * direction[i] for i in (0, 2)) + for got, want, axis in zip(landing, (DEPTH_CLIP_SHADOW_CENTRE[0], DEPTH_CLIP_SHADOW_CENTRE[2]), + "xz"): + assert abs(got - want) < 0.05, \ + f"caster ray lands at {axis}={got:.3f}, authored shadow centre is {want}" + + # Entirely behind the camera plane, so the sphere itself can never appear in a capture and + # be mistaken for the shadow it casts. + view = tuple(CAMERA_TARGET[i] - CAMERA_EYE[i] for i in range(3)) + view_length = math.sqrt(sum(component * component for component in view)) + view = tuple(component / view_length for component in view) + ahead = sum((centre[i] - CAMERA_EYE[i]) * view[i] for i in range(3)) + assert ahead + DEPTH_CLIP_CASTER_RADIUS < 0.0, \ + f"caster must sit behind the camera plane; it is {ahead:.2f} m in front" + + def build_scene(animated): """The shared content. `animated` adds the motion channels and nothing else, so the two files describe the same geometry at the same poses.""" @@ -509,6 +626,11 @@ def validate(doc, animated): "ShadowLodMotionDemo": True, } +# SH-06's fixture is built by its own function and checked by its own validator: it shares +# none of the LOD demo's content, and the LOD demo's assertions (named caster set, LOD-capable +# triangle counts) describe a scene this one deliberately is not. +DEPTH_CLIP_VARIANT = "ShadowDepthClipDemo" + def main(): out_dir = os.path.dirname(os.path.abspath(__file__)) @@ -520,6 +642,12 @@ def main(): kind = "animated" if animated else "static baseline" print(f"wrote {write_gltf(path, doc)} ({len(scene.bin)} buffer bytes, {kind})") + scene, _ = build_depth_clip_scene() + doc = scene.to_gltf() + validate_depth_clip(doc) + path = os.path.join(out_dir, f"{DEPTH_CLIP_VARIANT}.gltf") + print(f"wrote {write_gltf(path, doc)} ({len(scene.bin)} buffer bytes, SH-06 depth-clip fixture)") + if __name__ == "__main__": main() diff --git a/docs/onboarding.md b/docs/onboarding.md index 7a1dc31..a8999d7 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -909,6 +909,21 @@ the same change — most have a test or guard that will catch you, but not all. `LodDisabled`, which is a user's toggle, and the panel would then explain a safety fallback with somebody else's reason. There is deliberately **no shadow-proxy setter** — see `Object`'s header for what a validated one must enforce before it comes back. +- **A cascade's texel size comes back OUT of the fit, never recomputed** (SH-06). + `fitCascadeReceiver` (`render/cascade_fit.hpp`) returns `worldPerTexel` alongside the geometry it + snapped to, and `Renderer::computeShadowCascades` hands that value straight to + `ShadowView::orthographic`. SH-02 selection reasons about that number, so a second derivation + anywhere would agree today and drift the moment the fit changes. Same file, same rule for the light + direction in the other direction: it must arrive **unit length and is rejected, not normalised** + (tolerance `8 * FLT_EPSILON` on squared length) — re-normalising an already-unit vector shifts it by + an ulp and changes the shipped matrices, which `tests/render/test_cascade_fit.cpp` pins bit-for-bit + against a copy of the pre-extraction code. Neither half repairs bad input: both return `nullopt`, + and the renderer makes that terminal, because the dangerous failures here are the FINITE ones (a + negative back-extension puts the near plane past the far) that the view set's non-finite validation + waves through. `CascadeReceiverFit` is **encapsulated like `ShadowView`** — `CascadeReceiverFit::fit` + is the only constructor — so a downstream policy can trust the basis it is handed instead of + re-validating it: as a public aggregate, `lightUp = lightDirection` was finite, passed every + field-wise check, and sent `Mat4::lookAt` to its own fallback up. - **A uniform block bound by more than one shader is DECLARED once**, in a shared `shaders/*.glsl` include — never hand-copied into each shader. `LightUBO` lives in [`shaders/light_ubo.glsl`](../shaders/light_ubo.glsl); `shader.frag` and `skybox.frag` `#include` diff --git a/docs/review-order.md b/docs/review-order.md index 618d000..1ecea61 100644 --- a/docs/review-order.md +++ b/docs/review-order.md @@ -196,6 +196,7 @@ Read these first when a change touches build configuration, CI, or local tooling |---|---| | `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 passes: capture nearest surface, then `cullMode=eFront` for next occluder; in-shader `skinnedSelfShadowDepthEpsilon` safety net). `kMaxSkinnedSelfShadowCasters` cap. `recordPass` takes the shadow matrices + `cullingEnabled` and filters each cascade/spot/point-face draw list against its own `Frustum` (self-shadow slots aren't culled). | +| `render/cascade_fit.hpp` + `cascade_fit.cpp` | **High-attention (SH-06).** The CSM cascade fit as two pure, Vulkan-free carriers, split where the caster candidate query has to sit: `fitCascadeReceiver` produces the slice's stable light-space XY footprint, texel grid and **exact receiver min/max W from the eight corners** (not the looser bounding sphere), and `fitLegacyCascadeDepth` turns that into the light position and view-projection using the pre-SH-06 fixed `kShadowDepthBackExtend` — the ONE function the caster-aware depth policy replaces. `CascadeReceiverFit` is **encapsulated like `ShadowView`** — private ctor, `CascadeReceiverFit::fit` the only door — because a public aggregate let a caller set `lightUp` equal to `lightDirection`, which is finite, passes every field-wise check, and sends `Mat4::lookAt` to its own fallback up; the class makes that unexpressible instead of asking every future consumer to remember a validator. `CascadeDepthFit` stays an aggregate: nothing consumes one. Two contracts to keep: both entry points return `nullopt` rather than repairing corrupt input (the depth fit validating `backExtend` — the one input still arriving from outside, where a negative value yields a FINITE matrix the view set's non-finite check would pass — plus its own output), and `lightDirection` must arrive **unit length and is rejected, not normalised**, within `8 * FLT_EPSILON` on squared length (re-normalising an already-unit vector moves it by an ulp, which changes the shipped matrices). `tests/render/test_cascade_fit.cpp` holds a verbatim copy of the pre-extraction lambda and asserts bit-identical matrices, so any change here must be a deliberate one. | | `render/post_processing.hpp` + `post_processing.cpp` | HDR target, bloom chain, ACES/gamma. | | `render/draw_record.hpp` | Tiny shared `recordIndexedDraw(cmd, dc, resources)` — the ONE place the indirect sentinel is honoured, so the three VDPM draw sites (forward, depth prepass, transmission) can't drift: non-null `indirectBuffer` → `drawIndexedIndirect` (explicit stride `sizeof(VkDrawIndexedIndirectCommand)`), else direct `drawIndexed`. | | `render/transmission.hpp` + `transmission.cpp` | **High-attention.** `KHR_materials_transmission` off the captured `sceneColor`. The `shader.frag` split (post-fix): clear/frosted glass does screen-space refraction (roughness-blurred by the sceneColor mip chain); a thin-walled surface that is **also emissive** (a self-lit paper lamp shade) instead scatters to a view-independent irradiance tint — so a bright bulb behind it doesn't beam a camera-tracking blob. Discriminator is the **emissive factor**, NOT thickness. Plus back-face normal flip. Its forward recorder shares the main recorder's descriptor-order invariant: after a pipeline transition, push set 0 before binding allocated sets 1/2 through the same layout. | diff --git a/docs/shadowplans.md b/docs/shadowplans.md index 700d2a9..5acfad1 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -485,7 +485,7 @@ Likely branch: `shadow-per-view-discrete-lod`. SIGTERM, exit 143 after, a minimum record count — so a crashed run fails instead of reporting a reassuring zero. Re-run the sweep when SH-04 and SH-05 change which casters are selected. -#### SH-04: Conservative deformation and proxy policy +#### SH-04: Conservative deformation and proxy policy — ✅ deformation half landed (branch `shadow-deformation-policy`), proxy half open **Deformation half: landed** (`shadow-deformation-policy`). The proxy half is deliberately deferred — see below. @@ -590,6 +590,88 @@ caster is behind the receiver slice it casts into. Note the frame number is only demo advances on wall-clock time, so the moment drifts between runs. Fix this and the same capture must show a complete ellipse. +**Agreed structure (2026-07-31).** The fit splits into TWO carriers, because the pipeline is +`receiver slice → stable XY fit → candidate query → depth fit → render matrix` and the candidate +query consumes the first. Baking Z into it would make the query depend on its own output: + +- **`CascadeReceiverFit`** — slice near/far, normalised light basis, frustum centre and snapped + centre, radius, snapped U/V bounds, **exact receiver min/max W taken from the eight corners** (not + `centreW ± radius`: the far plane must reach the receiver VOLUME, not the looser bounding sphere), + and world units per texel. +- **`CascadeDepthFit`** — near/far W, light position, view-depth span, and the final view-projection + matrix. + +`fitLegacyCascadeDepth(receiver, kShadowDepthBackExtend)` reproduces today's behaviour first, so the +receiver fit and its tests land unchanged; SH-06 then swaps that one function for the caster-aware +policy. Pins: invalid or non-finite camera / aspect / slice / extent / light direction returns +FAILURE rather than letting `makeViewBasis` manufacture a plausible basis from corrupt input; tests +assert projection INVARIANTS (snapped U/V bounds map to clip edges, near/far W map to Vulkan depth +0 and 1) as well as the literal legacy numbers; diagnostics log only values read back from the two +carriers, including normalised light direction and aspect, never re-derived ones. + +**Landed (slice 2, `render/cascade_fit.{hpp,cpp}`).** The `fitCascade` lambda is gone; the renderer +calls `fitCascadeReceiver` then `fitLegacyCascadeDepth`, and takes `worldPerTexel` back OUT of the +receiver fit rather than recomputing it. `tests/render/test_cascade_fit.cpp` carries a verbatim copy +of the pre-extraction lambda and asserts the resulting matrices are **bit-identical** across four +camera/sun poses × the four shipped splits, with a second case proving that reference still responds +to its own inputs (so the equality cannot pass vacuously). + +One contract fell out of that equality rather than being designed in: **`lightDirection` must arrive +unit length and is REJECTED, not normalised, otherwise.** `Vec3::normalise` of an already-unit vector +moves it by an ulp whenever its squared length landed just under one — enough to change two of the +four poses' matrices. Re-normalising defensively would therefore have silently altered every shipped +cascade, and repairing a scaled direction would hide the producer that scaled it, so the fit refuses +it the same way it refuses a NaN aspect. The tolerance is `8 * FLT_EPSILON` on squared length — +sized in float rounding, not a round decimal, so a 1.0001 scale is caught while real `normalise` +output is not. (The engine normalises the sun twice on the way in: `Light::toLighting`, then +`Renderer::updateLightData` into `directionalLightDir_`, which is the authority for what the fit +receives.) + +**The receiver fit is a CLASS, not an aggregate** (`CascadeReceiverFit::fit` is the only +constructor), for the same reason `ShadowView` is one. As a public struct it had a hole no +field-wise validator closes cheaply: a `lightUp` set equal to `lightDirection` — or to zero — is +finite, passes finiteness/ordering/positivity checks, and sends `Mat4::lookAt` to its own fallback +up, manufacturing precisely the plausible basis the API says it refuses. A shared full-carrier +validator (basis finiteness, unit lengths, orthogonality, handedness, snapped-centre and U/V-width +consistency) would have worked only while every future consumer remembered to call it, which is the +drift this extraction exists to remove. Encapsulation makes the invariant unexpressible instead; +`tests/render/test_cascade_fit.cpp` pins it with `STATIC_REQUIRE_FALSE(is_default_constructible)` / +`is_aggregate`, since there is no longer a runtime state to test. + +`CascadeDepthFit` stays a plain aggregate deliberately — nothing CONSUMES one, so no policy has to +trust it. Encapsulate what is an input to something else. + +`fitLegacyCascadeDepth` therefore validates only `backExtend` (the one input still arriving from +outside) and its own output. That output check is the interesting one, and it was verified against +the raw expressions rather than assumed: the view set rejects only non-finite matrices, and a +negative extension is always finite — a small one pulls both planes inside the sphere the cascade +was fitted to (clipping its own contents), and one past `-radius` reverses the range so every depth +comparison in the map inverts. Both look healthy from outside. The renderer makes either rejection +terminal via `rejectedCascadeFit`. + +Per-cascade fit diagnostics log at `FE_LOG=render:debug`: every 120 frames starting with the first, +**plus unconditionally on the frame `--capture-frame` selects**, reading only carrier fields. The +periodic sample alone cannot describe a capture — at a 120-frame stride, frame 300's image would be +explained by the fit from frame 241 — so every later caster-bound / cascade-blend diagnostic should +use the same capture-triggered condition and describe one submitted frame. + +**Fixture status.** `ShadowDepthClipDemo` is a **probe**, not the acceptance fixture, until a pre-fix +capture is genuinely red — the first placement produced a complete ellipse, which is consistent with +the prediction being exact: the shadow pass front-culls, so a near plane through a sphere's centre +removes the upstream hemisphere that never contributed, and the downstream hemisphere still projects +the full silhouette. A null result there says nothing about the arithmetic. + +**The fit log settles the arithmetic half (2026-07-31).** With slice 2's diagnostics on, +`ShadowDepthClipDemo` reports cascade 2 at `depth W [-41.340, 33.535]` — the near plane the placement +predicted, to the printed digit, and the caster centre sits on it (its light-space W is -41.34 by +construction). So the calculation is exact and the probe is placed where it was meant to be; what it +is not is DISCRIMINATING, for the front-culling reason above. The probe therefore stays a probe, and +the next step is the one already agreed: freeze the ORIGINAL failing animated pose into a static +fixture rather than redesigning geometry, and record with it: receiver view depth, selected cascade +and blend factor, caster U/V/W bounds against both cascades involved, and whether the caster was +offered and rasterised in each — enough to separate depth clipping from candidate rejection or +cascade blending. + Keep the stable receiver XY fit, then determine the Z range from candidate caster bounds: 1. Build/extract the receiver slice volume. diff --git a/include/fire_engine/render/cascade_fit.hpp b/include/fire_engine/render/cascade_fit.hpp new file mode 100644 index 0000000..47b338a --- /dev/null +++ b/include/fire_engine/render/cascade_fit.hpp @@ -0,0 +1,222 @@ +#pragma once + +#include +#include + +#include +#include + +namespace fire_engine +{ + +// SH-06: the cascade fit, split into the two halves the pipeline actually needs separately. +// +// The order of work is `receiver slice -> stable XY fit -> candidate query -> depth fit -> render +// matrix`. The candidate query — which casters can project into this slice — consumes the XY fit +// and the receiver's light-space extent, and its ANSWER is what the depth fit is supposed to use. +// A single `fitCascade` that produced the matrix in one pass therefore had to bake a depth range +// before it could know which casters existed, which is precisely why a fixed +// `kShadowDepthBackExtend` was needed at all. Keeping the halves apart is what lets the query sit +// between them. + +// Everything the receiver fit reads. Grouped into one struct so a caller cannot supply half a +// frame's camera and half of another's: the validity check is over the whole set. +struct CascadeReceiverInput +{ + Vec3 cameraPosition{}; + Vec3 cameraTarget{}; + // MUST already be unit length, to within float rounding; a non-unit direction is rejected, not + // normalised. Re-normalising is not free: `normalise` of an already-unit vector moves it by an + // ulp whenever its squared length landed a bit below one, which is enough to change the shipped + // cascade matrices. The fit refuses to repair a corrupt basis anywhere else, and this is the + // same rule. The engine's directional light is normalised TWICE on its way here — + // `Light::toLighting` normalises the node's forward, and `Renderer::updateLightData` normalises + // again into `directionalLightDir_` — and it is that second one, the renderer's own, that is + // the authority for what the fit receives; the tolerance is sized for the rounding those leave, + // not for an intended scale. + Vec3 lightDirection{}; + float fovRadians{0.0f}; + float aspect{0.0f}; + // Camera-space distances bounding this cascade's slice. `sliceFar > sliceNear > 0`. + float sliceNear{0.0f}; + float sliceFar{0.0f}; + std::uint32_t shadowMapExtent{0}; +}; + +// The stable half: the slice's light-space footprint and the texel grid it snaps to. Nothing here +// depends on any caster, so it is safe to compute before the candidate query and to hand TO that +// query. +// +// ENCAPSULATED for the same reason `ShadowView` is, and the reason is not theoretical. As a public +// aggregate this had a hole no downstream validator could close cheaply: `lightUp` set equal to +// `lightDirection` (or to zero) is finite, passes every field-wise check worth writing, and sends +// `Mat4::lookAt` to its own fallback up — manufacturing exactly the plausible basis this API says +// it refuses. The alternative, a shared full-carrier validator over finiteness, unit lengths, +// orthogonality, handedness, snapped-centre and U/V-width consistency, would work only for as long +// as every future consumer remembered to call it. Making the fit the single constructor removes the +// question: downstream policies validate their own external inputs and outputs, and TRUST this. +class CascadeReceiverFit +{ +public: + // The only way to obtain one. Returns nullopt for input that is non-finite or degenerate rather + // than fitting something plausible-looking. `makeViewBasis` is deliberately total — it + // manufactures a fallback forward and a stable up so a camera pointing at itself still renders + // — which is right for a camera and wrong here: a NaN aspect or a zero light direction means + // the render input is corrupt, and a cascade silently fitted around a fabricated basis would + // shadow the wrong half of the scene with no symptom pointing back at the cause. + [[nodiscard]] static std::optional + fit(const CascadeReceiverInput& input) noexcept; + + // The slice and aspect this fit was built from, echoed back. Diagnostics read them from HERE + // rather than from the caller's own variables: a log that re-derives its inputs can agree with + // the code that produced it while both disagree with what actually rendered. + [[nodiscard]] float sliceNear() const noexcept + { + return sliceNear_; + } + [[nodiscard]] float sliceFar() const noexcept + { + return sliceFar_; + } + [[nodiscard]] float aspect() const noexcept + { + return aspect_; + } + + // Orthonormal light basis, guaranteed by the factory: `lightDirection` is the (already unit) + // input unchanged, and `lightUp` is re-orthogonalised against it. + [[nodiscard]] const Vec3& lightDirection() const noexcept + { + return lightDirection_; + } + [[nodiscard]] const Vec3& lightRight() const noexcept + { + return lightRight_; + } + [[nodiscard]] const Vec3& lightUp() const noexcept + { + return lightUp_; + } + + // Centre of the eight slice corners, and that centre snapped to the texel grid along U/V (W is + // untouched — snapping depth would move the near plane every frame for no benefit). + [[nodiscard]] const Vec3& frustumCentre() const noexcept + { + return frustumCentre_; + } + [[nodiscard]] const Vec3& snappedCentre() const noexcept + { + return snappedCentre_; + } + // Bounding-sphere radius of the slice about `frustumCentre`, quantised upwards so a rotating + // camera does not resize the cascade every frame. Always finite and > 0. + [[nodiscard]] float radius() const noexcept + { + return radius_; + } + + // The snapped ortho rectangle in light space: `snappedU/V +/- radius`. These are the bounds the + // projection maps to clip [-1, 1], so a candidate test can use them directly rather than + // re-deriving them from the matrix. + [[nodiscard]] float minU() const noexcept + { + return minU_; + } + [[nodiscard]] float maxU() const noexcept + { + return maxU_; + } + [[nodiscard]] float minV() const noexcept + { + return minV_; + } + [[nodiscard]] float maxV() const noexcept + { + return maxV_; + } + + // W of `frustumCentre`, i.e. the depth about which the legacy fit was symmetric. + [[nodiscard]] float centreW() const noexcept + { + return centreW_; + } + // EXACT receiver depth extent, taken from the eight corners rather than `centreW +/- radius`. + // The bounding sphere is the right shape to snap XY against (it is rotation-invariant) but it + // is generally looser in depth, and the caster-aware far plane must reach the receiver VOLUME — + // using the sphere there would push the far plane past every real receiver and waste depth + // precision. "Generally", not "always": the corner extent can never EXCEED the sphere's, but a + // slice whose extreme corners happen to sit on the light axis makes them equal, so treat this + // as a bound, not a strict inequality. + [[nodiscard]] float receiverMinW() const noexcept + { + return receiverMinW_; + } + [[nodiscard]] float receiverMaxW() const noexcept + { + return receiverMaxW_; + } + // World units per shadow-map texel. SH-02's selection error is expressed in these units, so it + // is returned by the fit rather than recomputed: the two cannot drift. + [[nodiscard]] float worldPerTexel() const noexcept + { + return worldPerTexel_; + } + +private: + // Private, and there is no invalid state to represent: the factory returns nullopt instead, so + // a `CascadeReceiverFit` that exists was fitted. + CascadeReceiverFit() = default; + + float sliceNear_{0.0f}; + float sliceFar_{0.0f}; + float aspect_{0.0f}; + Vec3 lightDirection_{}; + Vec3 lightRight_{}; + Vec3 lightUp_{}; + Vec3 frustumCentre_{}; + Vec3 snappedCentre_{}; + float radius_{0.0f}; + float minU_{0.0f}; + float maxU_{0.0f}; + float minV_{0.0f}; + float maxV_{0.0f}; + float centreW_{0.0f}; + float receiverMinW_{0.0f}; + float receiverMaxW_{0.0f}; + float worldPerTexel_{0.0f}; +}; + +// The caster-dependent half: where the light "camera" sits and how deep it sees. SH-06 replaces +// `fitLegacyCascadeDepth` with a policy that reads candidate caster bounds; the carrier itself does +// not change, so the render path and its tests are unaffected by that swap. +// +// A plain aggregate, unlike the receiver fit, because nothing CONSUMES one: it is the end of the +// chain, read by the renderer and the diagnostics and passed to no policy that would have to trust +// it. The asymmetry is deliberate — encapsulate what is an input to something else. +struct CascadeDepthFit +{ + // Absolute light-space W of the near and far planes (not offsets from the eye). A point at + // `nearW` lands on Vulkan depth 0 and one at `farW` on depth 1. + float nearW{0.0f}; + float farW{0.0f}; + Vec3 lightPosition{}; + // `farW - nearW`. The precision the shadow map has to spend on this cascade. + float viewDepthSpan{0.0f}; + Mat4 viewProj{Mat4::identity()}; +}; + +// Pre-SH-06 depth policy, preserved bit-for-bit: centre the range on the slice's bounding sphere +// and extend both ends by `backExtend`. The near extension is the part SH-06 removes — it is a +// fixed world distance standing in for "how far behind the slice a caster might be", which no +// constant can answer for an arbitrary scene. +// +// The receiver is trusted (only `CascadeReceiverFit::fit` can produce one); `backExtend` is not, +// because it arrives from outside. It is the instructive case, too: a negative value always +// produces a FINITE matrix, which is precisely what the view set's non-finite validation waves +// through. A small negative value pulls both planes INSIDE the fitted sphere, clipping casters and +// receivers the cascade is supposed to cover; past `-radius` it reverses the range outright and +// every depth comparison in the map inverts. Neither reports itself, so both are rejected here. +[[nodiscard]] std::optional +fitLegacyCascadeDepth(const CascadeReceiverFit& receiver, float backExtend) noexcept; + +} // namespace fire_engine diff --git a/include/fire_engine/render/renderer.hpp b/include/fire_engine/render/renderer.hpp index eb50a77..41c4227 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -409,6 +409,10 @@ class Renderer // Throttle for the periodic VDPM perf sample log (CPU record vs GPU compute ms) — the headless // baseline complement to the overlay's live readout. std::uint32_t vdpmPerfLogCounter_{0}; + // Throttle for the SH-06 cascade-fit sample. Every cascade of every frame would be four lines a + // frame; the fit only moves when the camera or sun does, so a periodic sample (starting with + // the first frame, which is the one a capture is usually keyed to) shows the same thing. + std::uint32_t cascadeFitLogCounter_{0}; // Frame capture (--capture). `framesRendered_` counts presented frames, so the capture is keyed // to a RENDER ORDINAL rather than elapsed time — every machine captures the same frame number. // That is not the same as the same picture: the main loop advances animation and physics from diff --git a/src/render/cascade_fit.cpp b/src/render/cascade_fit.cpp new file mode 100644 index 0000000..8f6bfa4 --- /dev/null +++ b/src/render/cascade_fit.cpp @@ -0,0 +1,221 @@ +#include + +#include +#include +#include +#include + +#include +#include + +namespace fire_engine +{ + +namespace +{ + +[[nodiscard]] bool finite(float v) noexcept +{ + return std::isfinite(v); +} + +[[nodiscard]] bool finite(Vec3 v) noexcept +{ + return finite(v.x()) && finite(v.y()) && finite(v.z()); +} + +// A direction this short would send `normaliseOr` / `makeViewBasis` to their fallbacks, which is +// exactly the manufacturing this fit refuses to do. +[[nodiscard]] bool usableDirection(Vec3 v) noexcept +{ + return finite(v) && v.magnitudeSquared() >= float_epsilon * float_epsilon; +} + +// Unit within the slack a float normalise actually leaves, and no more. `Vec3::normalise` routinely +// lands a squared length an ulp or two either side of one — the observed error on the engine's own +// sun directions is half an epsilon — so demanding exactness would reject every real caller. The +// tolerance is therefore sized in units of float rounding rather than as a round decimal: anything +// outside it is a SCALE the producer applied, and a scaled light direction scales every light-space +// depth this fit reports. A hand-picked 1e-3 would have admitted a 0.05% scale as "unit". +[[nodiscard]] bool unitDirection(Vec3 v) noexcept +{ + constexpr float kUnitTolerance = 8.0f * std::numeric_limits::epsilon(); + return finite(v) && std::abs(v.magnitudeSquared() - 1.0f) <= kUnitTolerance; +} + +} // namespace + +std::optional +CascadeReceiverFit::fit(const CascadeReceiverInput& input) noexcept +{ + if (!finite(input.cameraPosition) || !finite(input.cameraTarget) || !finite(input.fovRadians) || + !finite(input.aspect) || !finite(input.sliceNear) || !finite(input.sliceFar)) + { + return std::nullopt; + } + if (input.fovRadians <= 0.0f || input.fovRadians >= pi || input.aspect <= 0.0f) + { + return std::nullopt; + } + if (input.sliceNear <= 0.0f || input.sliceFar <= input.sliceNear) + { + return std::nullopt; + } + if (input.shadowMapExtent == 0) + { + return std::nullopt; + } + if (!unitDirection(input.lightDirection) || + !usableDirection(input.cameraTarget - input.cameraPosition)) + { + return std::nullopt; + } + + // Light basis. `lightUp` is a seed chosen to avoid degeneracy with the light direction, then + // discarded once `lightRight` has been derived from it: the returned up is the + // re-orthogonalised one, so the three axes are perpendicular whatever seed was picked. + const Vec3 lightDirection = input.lightDirection; + const Vec3 upSeed = stableUpForForward(lightDirection); + const Vec3 lightRight = + normaliseOr(Vec3::crossProduct(lightDirection, upSeed), {1.0f, 0.0f, 0.0f}); + const Vec3 lightUp = normaliseOr(Vec3::crossProduct(lightRight, lightDirection), upSeed); + + const ViewBasis basis = makeViewBasis(input.cameraPosition, input.cameraTarget); + const float tanHalfFov = std::tan(input.fovRadians * 0.5f); + + const float nearH = tanHalfFov * input.sliceNear; + const float nearW = nearH * input.aspect; + const float farH = tanHalfFov * input.sliceFar; + const float farW = farH * input.aspect; + + const Vec3 sliceNearCentre = input.cameraPosition + basis.forward * input.sliceNear; + const Vec3 sliceFarCentre = input.cameraPosition + basis.forward * input.sliceFar; + + const std::array corners{sliceNearCentre - basis.right * nearW - basis.up * nearH, + sliceNearCentre + basis.right * nearW - basis.up * nearH, + sliceNearCentre + basis.right * nearW + basis.up * nearH, + sliceNearCentre - basis.right * nearW + basis.up * nearH, + sliceFarCentre - basis.right * farW - basis.up * farH, + sliceFarCentre + basis.right * farW - basis.up * farH, + sliceFarCentre + basis.right * farW + basis.up * farH, + sliceFarCentre - basis.right * farW + basis.up * farH}; + + Vec3 frustumCentre{0.0f, 0.0f, 0.0f}; + for (const auto& c : corners) + { + frustumCentre += c; + } + frustumCentre /= 8.0f; + + float radius = 0.0f; + for (const auto& c : corners) + { + radius = std::max(radius, (c - frustumCentre).magnitude()); + } + radius = std::ceil(radius * 16.0f) / 16.0f; + + const float worldPerTexel = (2.0f * radius) / static_cast(input.shadowMapExtent); + const float centreU = Vec3::dotProduct(frustumCentre, lightRight); + const float centreV = Vec3::dotProduct(frustumCentre, lightUp); + const float centreW = Vec3::dotProduct(frustumCentre, lightDirection); + const float snappedU = std::floor(centreU / worldPerTexel) * worldPerTexel; + const float snappedV = std::floor(centreV / worldPerTexel) * worldPerTexel; + const Vec3 snappedCentre = + lightRight * snappedU + lightUp * snappedV + lightDirection * centreW; + + // Receiver depth read off the corners themselves. Every caster-aware depth policy needs to know + // how deep the geometry it must cover actually reaches, and the bounding sphere does not say. + float receiverMinW = Vec3::dotProduct(corners[0], lightDirection); + float receiverMaxW = receiverMinW; + for (const auto& c : corners) + { + const float w = Vec3::dotProduct(c, lightDirection); + receiverMinW = std::min(receiverMinW, w); + receiverMaxW = std::max(receiverMaxW, w); + } + + // A non-finite result here means the arithmetic overflowed on finite inputs (a slice far enough + // out that the corner sums leave float range). Report failure rather than hand a NaN matrix to + // the shadow pass, where it becomes an empty or garbage map with no obvious cause. + if (!finite(radius) || !finite(worldPerTexel) || worldPerTexel <= 0.0f || + !finite(frustumCentre) || !finite(snappedCentre) || !finite(receiverMinW) || + !finite(receiverMaxW)) + { + return std::nullopt; + } + + CascadeReceiverFit fit{}; + fit.sliceNear_ = input.sliceNear; + fit.sliceFar_ = input.sliceFar; + fit.aspect_ = input.aspect; + fit.lightDirection_ = lightDirection; + fit.lightRight_ = lightRight; + fit.lightUp_ = lightUp; + fit.frustumCentre_ = frustumCentre; + fit.snappedCentre_ = snappedCentre; + fit.radius_ = radius; + fit.minU_ = snappedU - radius; + fit.maxU_ = snappedU + radius; + fit.minV_ = snappedV - radius; + fit.maxV_ = snappedV + radius; + fit.centreW_ = centreW; + fit.receiverMinW_ = receiverMinW; + fit.receiverMaxW_ = receiverMaxW; + fit.worldPerTexel_ = worldPerTexel; + return fit; +} + +std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& receiver, + float backExtend) noexcept +{ + // `backExtend` is the only untrusted input: the receiver could only have come from + // `CascadeReceiverFit::fit`, which guarantees an orthonormal basis, a positive radius and texel + // size and ordered bounds. Re-checking those here would be a second authority on the same + // question — the drift this extraction exists to remove. + if (!finite(backExtend) || backExtend < 0.0f) + { + return std::nullopt; + } + + // Deliberately the original expressions, in the original order: this function's whole job right + // now is to be indistinguishable from what shipped, so the caster-aware policy that replaces it + // has a verified baseline to differ from. + const Vec3 lightPosition = + receiver.snappedCentre() - receiver.lightDirection() * (receiver.radius() + backExtend); + const Mat4 lightView = + Mat4::lookAt(lightPosition, receiver.snappedCentre(), receiver.lightUp()); + const Mat4 lightProj = + Mat4::ortho(-receiver.radius(), receiver.radius(), -receiver.radius(), receiver.radius(), + 0.0f, 2.0f * receiver.radius() + 2.0f * backExtend); + + CascadeDepthFit depth{}; + depth.lightPosition = lightPosition; + // The ortho near plane sits at the light position (near = 0), so the near plane's world-space W + // is the light's own W; the far plane is the ortho far distance beyond it. + depth.nearW = receiver.centreW() - receiver.radius() - backExtend; + depth.farW = receiver.centreW() + receiver.radius() + backExtend; + depth.viewDepthSpan = depth.farW - depth.nearW; + depth.viewProj = lightProj * lightView; + + // An ORDERED, finite output — the check the view set cannot make for us. It rejects a + // non-finite matrix, but a range that came out reversed produces a perfectly finite one whose + // depth comparisons are all backwards, and nothing downstream would notice. + if (!finite(depth.nearW) || !finite(depth.farW) || !(depth.farW > depth.nearW) || + !finite(depth.viewDepthSpan) || !finite(depth.lightPosition)) + { + return std::nullopt; + } + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + if (!finite(depth.viewProj[row, col])) + { + return std::nullopt; + } + } + } + return depth; +} + +} // namespace fire_engine diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 4e13ffb..80a9158 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,16 @@ int packLight(LightUBO& lightData, int& slot, const Lighting& light) noexcept std::format("shadow view rejected by the view set ({}) — corrupt render input", view)); } +// A cascade whose receiver fit refused the frame's camera/light input. Terminal for the same reason +// as `rejectedShadowView`: the fit only fails on input that is non-finite or degenerate, and the +// alternative — a cascade quietly fitted around a manufactured basis — shadows the wrong region +// with nothing pointing back at the corrupt value that caused it. +[[noreturn]] void rejectedCascadeFit(std::uint32_t cascade) +{ + throw std::runtime_error(std::format( + "cascade {} receiver fit rejected the camera/light input — corrupt render input", cascade)); +} + // One SH-03 calibration override, validated and reported. The NUMERIC rule lives in // `graphics/shadow_diagnostics.hpp` where it is headless-testable; this adds the policy: absent // means "use the constant", and anything else unusable is terminal rather than a silent fallback, @@ -451,75 +462,6 @@ void Renderer::updateLightData(Vec3 cameraPosition, Vec3 cameraTarget, float asp void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 cameraTarget, float aspect) { - // Camera basis + light basis (shared by every cascade fit). - const Vec3 lightDir = directionalLightDir_; - const float tanHalfFov = std::tan(kCameraFovRadians * 0.5f); - const ViewBasis basis = makeViewBasis(cameraPosition, cameraTarget); - const Vec3 lightUp = stableUpForForward(lightDir); - const Vec3 lightRight = normaliseOr(Vec3::crossProduct(lightDir, lightUp), {1.0f, 0.0f, 0.0f}); - const Vec3 lightUpOrtho = normaliseOr(Vec3::crossProduct(lightRight, lightDir), lightUp); - const float shadowMapExtentF = static_cast(kShadowMapExtent); - - // Bounding-sphere fit for a single sub-frustum slice. Captures the light - // basis above, called once per cascade. - // - // Returns the matrix AND the descriptor SH-02 selection reasons about, because both fall out of - // the same fit: the texel size the cascade snaps to IS `worldPerTexel` below. Recomputing it - // anywhere else would make the two drift the moment this fit changes. - struct CascadeFit - { - Mat4 viewProj; - float worldPerTexel; - }; - auto fitCascade = [&](float sliceNear, float sliceFar) -> CascadeFit - { - const float nearH = tanHalfFov * sliceNear; - const float nearW = nearH * aspect; - const float farH = tanHalfFov * sliceFar; - const float farW = farH * aspect; - - const Vec3 sliceNearCentre = cameraPosition + basis.forward * sliceNear; - const Vec3 sliceFarCentre = cameraPosition + basis.forward * sliceFar; - - const std::array corners{sliceNearCentre - basis.right * nearW - basis.up * nearH, - sliceNearCentre + basis.right * nearW - basis.up * nearH, - sliceNearCentre + basis.right * nearW + basis.up * nearH, - sliceNearCentre - basis.right * nearW + basis.up * nearH, - sliceFarCentre - basis.right * farW - basis.up * farH, - sliceFarCentre + basis.right * farW - basis.up * farH, - sliceFarCentre + basis.right * farW + basis.up * farH, - sliceFarCentre - basis.right * farW + basis.up * farH}; - - Vec3 frustumCentre{0.0f, 0.0f, 0.0f}; - for (const auto& c : corners) - { - frustumCentre += c; - } - frustumCentre /= 8.0f; - - float radius = 0.0f; - for (const auto& c : corners) - { - radius = std::max(radius, (c - frustumCentre).magnitude()); - } - radius = std::ceil(radius * 16.0f) / 16.0f; - - const float worldPerTexel = (2.0f * radius) / shadowMapExtentF; - const float centreU = Vec3::dotProduct(frustumCentre, lightRight); - const float centreV = Vec3::dotProduct(frustumCentre, lightUpOrtho); - const float centreW = Vec3::dotProduct(frustumCentre, lightDir); - const float snappedU = std::floor(centreU / worldPerTexel) * worldPerTexel; - const float snappedV = std::floor(centreV / worldPerTexel) * worldPerTexel; - const Vec3 snappedCentre = - lightRight * snappedU + lightUpOrtho * snappedV + lightDir * centreW; - - const Vec3 lightPos = snappedCentre - lightDir * (radius + kShadowDepthBackExtend); - const Mat4 lightView = Mat4::lookAt(lightPos, snappedCentre, lightUpOrtho); - const Mat4 lightProj = Mat4::ortho(-radius, radius, -radius, radius, 0.0f, - 2.0f * radius + 2.0f * kShadowDepthBackExtend); - return CascadeFit{lightProj * lightView, worldPerTexel}; - }; - // Log-uniform cascade splits — Practical Split Scheme. Keeps close cascades // small for near-camera detail while still covering kShadowFarPlane. float splits[kShadowCascadeCount]; @@ -536,14 +478,71 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // as this frame's. This is the ONE reset, and it must stay ahead of every populate below. shadowViews_.reset(); + // Periodic, first frame included — AND unconditionally on the frame `--capture-frame` selects. + // The periodic sample alone cannot describe a capture: at a 120-frame stride, frame 300's image + // would be explained by the fit from frame 241 or 361, and the camera has moved in between. The + // capture-triggered condition is what every later caster-bound / cascade-blend diagnostic + // should use too, so all the evidence describes ONE submitted frame. + // + // `framesRendered_` is incremented late in drawFrame, so the frame being built here is the next + // one. A frame abandoned after this point (an out-of-date swapchain) therefore logs its fit and + // never presents, and the real capture frame logs again — read the LAST sample before the + // capture line, not the first. + const bool captureFitFrame = captureWanted() && (framesRendered_ + 1) == captureFrame_; + const bool logFit = ((++cascadeFitLogCounter_ % 120) == 1) || captureFitFrame; + float sliceNear = kCameraNearPlane; for (uint32_t i = 0; i < kShadowCascadeCount; ++i) { - const CascadeFit fit = fitCascade(sliceNear, splits[i]); - if (!shadowViews_.setCascade(i, fit.viewProj, ShadowView::orthographic(fit.worldPerTexel))) + // Two halves, in the order SH-06 needs them: the receiver's stable XY/texel fit first — the + // input a caster candidate query will consume — then the depth policy that turns it into a + // matrix. Only the second call changes when the fixed back-extension goes. + const CascadeReceiverInput fitInput{.cameraPosition = cameraPosition, + .cameraTarget = cameraTarget, + .lightDirection = directionalLightDir_, + .fovRadians = kCameraFovRadians, + .aspect = aspect, + .sliceNear = sliceNear, + .sliceFar = splits[i], + .shadowMapExtent = kShadowMapExtent}; + const std::optional receiver = CascadeReceiverFit::fit(fitInput); + if (!receiver) + { + rejectedCascadeFit(i); + } + const std::optional depth = + fitLegacyCascadeDepth(*receiver, kShadowDepthBackExtend); + if (!depth) + { + rejectedCascadeFit(i); + } + // The texel size the cascade snaps to comes back FROM the fit rather than being recomputed + // here: SH-02 selection reasons about it, and a second derivation would drift the moment + // the fit changes. + if (!shadowViews_.setCascade(i, depth->viewProj, + ShadowView::orthographic(receiver->worldPerTexel()))) { rejectedShadowView(std::format("cascade {}", i)); } + if (logFit) + { + // Every value here is READ BACK from the two carriers, never re-derived from the + // inputs above — a diagnostic that recomputes its own numbers agrees with itself while + // the shipped matrix disagrees with both. + log::debug(log::category::render, + "cascade {} fit: slice [{:.3f}, {:.3f}] aspect {:.4f} lightDir ({:.4f}, " + "{:.4f}, {:.4f}) | radius {:.4f} worldPerTexel {:.5f} | U [{:.3f}, {:.3f}] " + "V [{:.3f}, {:.3f}] | centreW {:.3f} receiverW [{:.3f}, {:.3f}] | depth W " + "[{:.3f}, {:.3f}] span {:.3f} lightPos ({:.3f}, {:.3f}, {:.3f})", + i, receiver->sliceNear(), receiver->sliceFar(), receiver->aspect(), + receiver->lightDirection().x(), receiver->lightDirection().y(), + receiver->lightDirection().z(), receiver->radius(), + receiver->worldPerTexel(), receiver->minU(), receiver->maxU(), + receiver->minV(), receiver->maxV(), receiver->centreW(), + receiver->receiverMinW(), receiver->receiverMaxW(), depth->nearW, + depth->farW, depth->viewDepthSpan, depth->lightPosition.x(), + depth->lightPosition.y(), depth->lightPosition.z()); + } out.cascadeSplits[i] = splits[i]; sliceNear = splits[i]; } diff --git a/tests/render/test_cascade_fit.cpp b/tests/render/test_cascade_fit.cpp new file mode 100644 index 0000000..e6f88e5 --- /dev/null +++ b/tests/render/test_cascade_fit.cpp @@ -0,0 +1,640 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using Catch::Approx; +using fire_engine::CascadeDepthFit; +using fire_engine::CascadeReceiverFit; +using fire_engine::CascadeReceiverInput; +using fire_engine::fitLegacyCascadeDepth; +using fire_engine::Mat4; +using fire_engine::Vec3; +using fire_engine::Vec4; + +namespace +{ + +// The pre-extraction `fitCascade` lambda from `Renderer::computeShadowCascades`, copied verbatim +// down to the order of every operation. It is the whole point of this file: the extraction is only +// safe if the shipped matrices are unchanged, and "unchanged" here means BIT-identical, not close. +// When SH-06 replaces the depth policy, this reference stays as the record of what the fixed +// back-extension used to produce. +struct LegacyFit +{ + Mat4 viewProj; + float worldPerTexel; +}; + +[[nodiscard]] LegacyFit legacyFitCascade(Vec3 cameraPosition, Vec3 cameraTarget, Vec3 lightDirIn, + float aspect, float sliceNear, float sliceFar, + std::uint32_t extent, float backExtend) +{ + const Vec3 lightDir = lightDirIn; + const float tanHalfFov = std::tan(fire_engine::kCameraFovRadians * 0.5f); + const fire_engine::ViewBasis basis = fire_engine::makeViewBasis(cameraPosition, cameraTarget); + const Vec3 lightUp = fire_engine::stableUpForForward(lightDir); + const Vec3 lightRight = + fire_engine::normaliseOr(Vec3::crossProduct(lightDir, lightUp), {1.0f, 0.0f, 0.0f}); + const Vec3 lightUpOrtho = + fire_engine::normaliseOr(Vec3::crossProduct(lightRight, lightDir), lightUp); + const float shadowMapExtentF = static_cast(extent); + + const float nearH = tanHalfFov * sliceNear; + const float nearW = nearH * aspect; + const float farH = tanHalfFov * sliceFar; + const float farW = farH * aspect; + + const Vec3 sliceNearCentre = cameraPosition + basis.forward * sliceNear; + const Vec3 sliceFarCentre = cameraPosition + basis.forward * sliceFar; + + const std::array corners{sliceNearCentre - basis.right * nearW - basis.up * nearH, + sliceNearCentre + basis.right * nearW - basis.up * nearH, + sliceNearCentre + basis.right * nearW + basis.up * nearH, + sliceNearCentre - basis.right * nearW + basis.up * nearH, + sliceFarCentre - basis.right * farW - basis.up * farH, + sliceFarCentre + basis.right * farW - basis.up * farH, + sliceFarCentre + basis.right * farW + basis.up * farH, + sliceFarCentre - basis.right * farW + basis.up * farH}; + + Vec3 frustumCentre{0.0f, 0.0f, 0.0f}; + for (const auto& c : corners) + { + frustumCentre += c; + } + frustumCentre /= 8.0f; + + float radius = 0.0f; + for (const auto& c : corners) + { + radius = std::max(radius, (c - frustumCentre).magnitude()); + } + radius = std::ceil(radius * 16.0f) / 16.0f; + + const float worldPerTexel = (2.0f * radius) / shadowMapExtentF; + const float centreU = Vec3::dotProduct(frustumCentre, lightRight); + const float centreV = Vec3::dotProduct(frustumCentre, lightUpOrtho); + const float centreW = Vec3::dotProduct(frustumCentre, lightDir); + const float snappedU = std::floor(centreU / worldPerTexel) * worldPerTexel; + const float snappedV = std::floor(centreV / worldPerTexel) * worldPerTexel; + const Vec3 snappedCentre = lightRight * snappedU + lightUpOrtho * snappedV + lightDir * centreW; + + const Vec3 lightPos = snappedCentre - lightDir * (radius + backExtend); + const Mat4 lightView = Mat4::lookAt(lightPos, snappedCentre, lightUpOrtho); + const Mat4 lightProj = + Mat4::ortho(-radius, radius, -radius, radius, 0.0f, 2.0f * radius + 2.0f * backExtend); + return LegacyFit{lightProj * lightView, worldPerTexel}; +} + +// Element-wise on the bit patterns, not `memcmp` on the object: float has no unique object +// representation. Each element must also be FINITE — bit equality alone would certify two +// identically poisoned matrices as a match, which is the one way this equivalence check could pass +// while both sides were broken. +[[nodiscard]] bool bitIdentical(const Mat4& a, const Mat4& b) +{ + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + if (!std::isfinite(a[row, col]) || !std::isfinite(b[row, col])) + { + return false; + } + if (std::bit_cast(a[row, col]) != + std::bit_cast(b[row, col])) + { + return false; + } + } + } + return true; +} + +// The shipped cascade splits, so the equivalence check covers the slices that actually render +// rather than round numbers that happen to be well-conditioned. +[[nodiscard]] std::vector> shippedSlices() +{ + std::vector> slices; + float sliceNear = fire_engine::kCameraNearPlane; + for (std::uint32_t i = 0; i < fire_engine::kShadowCascadeCount; ++i) + { + const float p = + static_cast(i + 1) / static_cast(fire_engine::kShadowCascadeCount); + const float linear = fire_engine::kCameraNearPlane + + (fire_engine::kShadowFarPlane - fire_engine::kCameraNearPlane) * p; + const float logSplit = + fire_engine::kCameraNearPlane * + std::pow(fire_engine::kShadowFarPlane / fire_engine::kCameraNearPlane, p); + const float split = fire_engine::kShadowCascadeSplitLambda * logSplit + + (1.0f - fire_engine::kShadowCascadeSplitLambda) * linear; + slices.emplace_back(sliceNear, split); + sliceNear = split; + } + return slices; +} + +struct Scenario +{ + const char* name; + Vec3 cameraPosition; + Vec3 cameraTarget; + Vec3 lightDirection; + float aspect; +}; + +[[nodiscard]] std::vector scenarios() +{ + return { + {"sun down the diagonal", + {0.0f, 2.0f, 8.0f}, + {0.0f, 1.0f, 0.0f}, + Vec3::normalise(Vec3{1.0f, -1.0f, 1.0f}), + 16.0f / 9.0f}, + {"near-vertical sun", + {12.0f, 3.0f, -4.0f}, + {0.0f, 0.5f, 0.0f}, + Vec3::normalise(Vec3{0.02f, -1.0f, 0.01f}), + 4.0f / 3.0f}, + {"low sun, camera looking up", + {-6.0f, 1.0f, -6.0f}, + {2.0f, 6.0f, 3.0f}, + Vec3::normalise(Vec3{-0.9f, -0.1f, 0.4f}), + 1.0f}, + {"tall viewport", + {3.0f, 40.0f, 3.0f}, + {3.0f, 0.0f, 3.5f}, + Vec3::normalise(Vec3{0.3f, -0.8f, -0.5f}), + 0.5f}, + }; +} + +[[nodiscard]] CascadeReceiverInput inputFor(const Scenario& s, float sliceNear, float sliceFar) +{ + return CascadeReceiverInput{.cameraPosition = s.cameraPosition, + .cameraTarget = s.cameraTarget, + .lightDirection = s.lightDirection, + .fovRadians = fire_engine::kCameraFovRadians, + .aspect = s.aspect, + .sliceNear = sliceNear, + .sliceFar = sliceFar, + .shadowMapExtent = fire_engine::kShadowMapExtent}; +} + +} // namespace + +TEST_CASE("CascadeFit.LegacyDepthPolicyIsBitIdentical", "[CascadeFit]") +{ + for (const Scenario& s : scenarios()) + { + for (const auto& [sliceNear, sliceFar] : shippedSlices()) + { + INFO(s.name << " slice [" << sliceNear << ", " << sliceFar << "]"); + const auto receiver = CascadeReceiverFit::fit(inputFor(s, sliceNear, sliceFar)); + REQUIRE(receiver); + const auto depth = + fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(depth); + const LegacyFit legacy = legacyFitCascade( + s.cameraPosition, s.cameraTarget, s.lightDirection, s.aspect, sliceNear, sliceFar, + fire_engine::kShadowMapExtent, fire_engine::kShadowDepthBackExtend); + + CHECK(bitIdentical(depth->viewProj, legacy.viewProj)); + CHECK(receiver->worldPerTexel() == legacy.worldPerTexel); + } + } +} + +// Guards the reference itself: if `legacyFitCascade` had drifted into simply calling the new code, +// or into something insensitive to its inputs, the check above would pass vacuously. +TEST_CASE("CascadeFit.LegacyReferenceRespondsToItsInputs", "[CascadeFit]") +{ + const Scenario s = scenarios().front(); + const LegacyFit a = + legacyFitCascade(s.cameraPosition, s.cameraTarget, s.lightDirection, s.aspect, 1.0f, 10.0f, + fire_engine::kShadowMapExtent, fire_engine::kShadowDepthBackExtend); + const LegacyFit wider = + legacyFitCascade(s.cameraPosition, s.cameraTarget, s.lightDirection, s.aspect, 1.0f, 20.0f, + fire_engine::kShadowMapExtent, fire_engine::kShadowDepthBackExtend); + const LegacyFit deeper = + legacyFitCascade(s.cameraPosition, s.cameraTarget, s.lightDirection, s.aspect, 1.0f, 10.0f, + fire_engine::kShadowMapExtent, fire_engine::kShadowDepthBackExtend * 2.0f); + + CHECK_FALSE(bitIdentical(a.viewProj, wider.viewProj)); + CHECK(a.worldPerTexel < wider.worldPerTexel); + // A different back-extension must move the matrix even though the XY fit is untouched — that is + // the exact axis SH-06 changes. + CHECK_FALSE(bitIdentical(a.viewProj, deeper.viewProj)); + CHECK(a.worldPerTexel == deeper.worldPerTexel); +} + +TEST_CASE("CascadeFit.ProjectionMapsFittedBoundsToClipEdges", "[CascadeFit]") +{ + for (const Scenario& s : scenarios()) + { + INFO(s.name); + const auto receiver = CascadeReceiverFit::fit(inputFor(s, 1.0f, 12.0f)); + REQUIRE(receiver); + const auto depth = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(depth); + + // A point is placed by its light-space (U, V, W) rather than by any world position, so the + // assertion is about the projection's contract, not about a particular scene. + auto atUvw = [&](float u, float v, float w) + { + const Vec3 p = receiver->lightRight() * u + receiver->lightUp() * v + + receiver->lightDirection() * w; + const Vec4 clip = depth->viewProj * Vec4{p.x(), p.y(), p.z(), 1.0f}; + return clip; + }; + + const float midW = 0.5f * (depth->nearW + depth->farW); + // Vulkan clip: x right-handed in [-1, 1], y FLIPPED by Mat4::ortho, z in [0, 1]. + CHECK(atUvw(receiver->minU(), 0.0f, midW).x() == Approx(-1.0f).margin(1e-4)); + CHECK(atUvw(receiver->maxU(), 0.0f, midW).x() == Approx(1.0f).margin(1e-4)); + CHECK(atUvw(0.0f, receiver->minV(), midW).y() == Approx(1.0f).margin(1e-4)); + CHECK(atUvw(0.0f, receiver->maxV(), midW).y() == Approx(-1.0f).margin(1e-4)); + + CHECK(atUvw(0.0f, 0.0f, depth->nearW).z() == Approx(0.0f).margin(1e-4)); + CHECK(atUvw(0.0f, 0.0f, depth->farW).z() == Approx(1.0f).margin(1e-4)); + CHECK(depth->viewDepthSpan == Approx(depth->farW - depth->nearW)); + // The light sits ON the near plane: the legacy ortho near distance is zero. + CHECK(Vec3::dotProduct(depth->lightPosition, receiver->lightDirection()) == + Approx(depth->nearW).margin(1e-3)); + } +} + +TEST_CASE("CascadeFit.ReceiverDepthComesFromTheCornersNotTheSphere", "[CascadeFit]") +{ + for (const Scenario& s : scenarios()) + { + INFO(s.name); + const auto receiver = CascadeReceiverFit::fit(inputFor(s, 1.0f, 12.0f)); + REQUIRE(receiver); + + // Rebuild the slice corners independently and check every one lies within the reported + // depth extent, with the extent touching the extremes rather than merely containing them. + const fire_engine::ViewBasis basis = + fire_engine::makeViewBasis(s.cameraPosition, s.cameraTarget); + const float tanHalfFov = std::tan(fire_engine::kCameraFovRadians * 0.5f); + float minW = std::numeric_limits::max(); + float maxW = std::numeric_limits::lowest(); + for (const float d : {1.0f, 12.0f}) + { + const float h = tanHalfFov * d; + const float w = h * s.aspect; + const Vec3 centre = s.cameraPosition + basis.forward * d; + for (const float sx : {-1.0f, 1.0f}) + { + for (const float sy : {-1.0f, 1.0f}) + { + const Vec3 corner = centre + basis.right * (w * sx) + basis.up * (h * sy); + const float cw = Vec3::dotProduct(corner, receiver->lightDirection()); + minW = std::min(minW, cw); + maxW = std::max(maxW, cw); + } + } + } + CHECK(receiver->receiverMinW() == Approx(minW).margin(1e-4)); + CHECK(receiver->receiverMaxW() == Approx(maxW).margin(1e-4)); + + // The reason the corners are used at all: the sphere can never be TIGHTER in depth, so a + // far plane fitted from it can only sit past the real receivers. This direction is the + // universal one — it holds for any slice — and it is deliberately stated as a bound, not a + // strict inequality: a slice whose extreme corners happen to lie on the light axis makes + // the two extents equal. Strictness is checked below, on a case where it genuinely holds. + CHECK(receiver->receiverMinW() >= receiver->centreW() - receiver->radius()); + CHECK(receiver->receiverMaxW() <= receiver->centreW() + receiver->radius()); + CHECK(receiver->receiverMaxW() - receiver->receiverMinW() <= 2.0f * receiver->radius()); + } + + // The representative case: an off-axis sun over a wide slice, where the corner extent really is + // narrower than the sphere — which is what makes the corner-derived far plane worth having. + const auto wide = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 24.0f)); + REQUIRE(wide); + CHECK(wide->receiverMaxW() - wide->receiverMinW() < 2.0f * wide->radius()); +} + +TEST_CASE("CascadeFit.ReturnedBasisIsOrthonormalAndCentreIsSnapped", "[CascadeFit]") +{ + for (const Scenario& s : scenarios()) + { + INFO(s.name); + const auto receiver = CascadeReceiverFit::fit(inputFor(s, 1.0f, 12.0f)); + REQUIRE(receiver); + + CHECK(receiver->lightDirection().magnitude() == Approx(1.0f).margin(1e-5)); + CHECK(receiver->lightRight().magnitude() == Approx(1.0f).margin(1e-5)); + CHECK(receiver->lightUp().magnitude() == Approx(1.0f).margin(1e-5)); + CHECK(Vec3::dotProduct(receiver->lightDirection(), receiver->lightRight()) == + Approx(0.0f).margin(1e-5)); + CHECK(Vec3::dotProduct(receiver->lightDirection(), receiver->lightUp()) == + Approx(0.0f).margin(1e-5)); + CHECK(Vec3::dotProduct(receiver->lightRight(), receiver->lightUp()) == + Approx(0.0f).margin(1e-5)); + + CHECK( + receiver->worldPerTexel() == + Approx(2.0f * receiver->radius() / static_cast(fire_engine::kShadowMapExtent))); + CHECK(receiver->maxU() - receiver->minU() == Approx(2.0f * receiver->radius())); + CHECK(receiver->maxV() - receiver->minV() == Approx(2.0f * receiver->radius())); + + // Snapping is what stops the cascade shimmering: the centre sits on a texel boundary in U + // and V, and moves in W only. + const float snappedU = 0.5f * (receiver->minU() + receiver->maxU()); + const float snappedV = 0.5f * (receiver->minV() + receiver->maxV()); + CHECK(std::abs(std::remainder(snappedU, receiver->worldPerTexel())) < + receiver->worldPerTexel() * 1e-2f); + CHECK(std::abs(std::remainder(snappedV, receiver->worldPerTexel())) < + receiver->worldPerTexel() * 1e-2f); + CHECK(Vec3::dotProduct(receiver->snappedCentre(), receiver->lightDirection()) == + Approx(receiver->centreW()).margin(1e-3)); + } +} + +// The pin that matters most: corrupt render input must FAIL rather than be repaired into something +// plausible. `makeViewBasis` and `normaliseOr` deliberately manufacture fallbacks; the fit must not +// inherit that behaviour, because a cascade fitted around a fabricated basis shadows the wrong +// region with nothing pointing at the cause. +TEST_CASE("CascadeFit.RejectsCorruptInputRatherThanManufacturingABasis", "[CascadeFit]") +{ + const Scenario s = scenarios().front(); + const CascadeReceiverInput good = inputFor(s, 1.0f, 12.0f); + REQUIRE(CascadeReceiverFit::fit(good)); + + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + + auto rejects = [](CascadeReceiverInput in) { return !CascadeReceiverFit::fit(in).has_value(); }; + + SECTION("non-finite vectors") + { + CHECK(rejects( + [&] + { + auto in = good; + in.cameraPosition = Vec3{nan, 0.0f, 0.0f}; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.cameraTarget = Vec3{0.0f, inf, 0.0f}; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = Vec3{0.0f, 0.0f, nan}; + return in; + }())); + } + SECTION("degenerate directions") + { + // A zero light direction would silently become (0, 0, -1) via `normaliseOr`. + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = Vec3{0.0f, 0.0f, 0.0f}; + return in; + }())); + // Non-unit is rejected rather than normalised: a scaled direction scales every light-space + // depth the fit reports, and repairing it here would hide the producer that scaled it. The + // SUBTLE scales are the ones that matter — a doubled direction is obvious, whereas 1.0001 + // is the kind of drift an accumulated transform produces, and the tolerance is sized in + // float rounding precisely so it is still caught. + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = in.lightDirection * 1.0001f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = in.lightDirection * 0.9999f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = in.lightDirection * 2.0f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.lightDirection = in.lightDirection * 0.9f; + return in; + }())); + // The other side of that tolerance: real `Vec3::normalise` output must still be accepted, + // whatever ulp its squared length landed on, or the fit would reject every genuine caller. + for (const Scenario& scenario : scenarios()) + { + auto in = good; + in.lightDirection = scenario.lightDirection; + CHECK(CascadeReceiverFit::fit(in).has_value()); + } + // A camera pointing at itself would get `makeViewBasis`'s fallback forward. + CHECK(rejects( + [&] + { + auto in = good; + in.cameraTarget = in.cameraPosition; + return in; + }())); + } + SECTION("out-of-range scalars") + { + CHECK(rejects( + [&] + { + auto in = good; + in.fovRadians = 0.0f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.fovRadians = fire_engine::pi; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.fovRadians = nan; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.aspect = 0.0f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.aspect = -1.5f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.aspect = inf; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.sliceNear = 0.0f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.sliceNear = -1.0f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.sliceFar = in.sliceNear; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.sliceFar = in.sliceNear * 0.5f; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.sliceFar = nan; + return in; + }())); + CHECK(rejects( + [&] + { + auto in = good; + in.shadowMapExtent = 0; + return in; + }())); + } + SECTION("finite input whose fit overflows") + { + auto in = good; + in.sliceNear = std::numeric_limits::max() * 0.5f; + in.sliceFar = std::numeric_limits::max(); + CHECK(rejects(in)); + } +} + +// The receiver is TRUSTED here — only `CascadeReceiverFit::fit` can produce one — so the depth fit +// validates the one input that still arrives from outside. That trust is itself pinned below, in +// the type system rather than in a runtime check. +TEST_CASE("CascadeFit.DepthFitRejectsAnUnusableBackExtension", "[CascadeFit]") +{ + const Scenario s = scenarios().front(); + const auto good = CascadeReceiverFit::fit(inputFor(s, 1.0f, 12.0f)); + REQUIRE(good); + REQUIRE(fitLegacyCascadeDepth(*good, fire_engine::kShadowDepthBackExtend)); + + auto rejects = [&](float backExtend) + { return !fitLegacyCascadeDepth(*good, backExtend).has_value(); }; + + // Negative is the dangerous one, and it is dangerous at BOTH magnitudes — verified against the + // raw expressions: a small negative value keeps the range ordered but pulls both planes inside + // the fitted sphere (silently clipping the cascade's own contents), while anything past -radius + // reverses it. Both yield a fully finite matrix, so neither is caught downstream. + CHECK(rejects(-1.0f)); + CHECK(rejects(-(good->radius() + 10.0f))); + CHECK(rejects(std::numeric_limits::quiet_NaN())); + CHECK(rejects(std::numeric_limits::infinity())); + // Zero is legitimate — that is a cascade fitted exactly to its bounding sphere. + CHECK(fitLegacyCascadeDepth(*good, 0.0f).has_value()); + // A finite extension large enough to overflow the range still has to fail, since the failure + // shows up in the OUTPUT rather than the input. + CHECK(rejects(std::numeric_limits::max())); +} + +// The hole encapsulation closes, stated where it cannot rot: a `lightUp` equal to `lightDirection` +// is finite and passes every field-wise check worth writing, yet sends `Mat4::lookAt` to its own +// fallback up — manufacturing the plausible basis this API refuses. No runtime test can cover that +// now, because there is no longer a way to express it. These assertions are what say so. +TEST_CASE("CascadeFit.ReceiverFitCannotBeAssembledByHand", "[CascadeFit]") +{ + // Not default-constructible and not an aggregate: no `CascadeReceiverFit{}`, no designated + // initialisers, no assignment to a member after the fact. The factory is the only door. + STATIC_REQUIRE_FALSE(std::is_default_constructible_v); + STATIC_REQUIRE_FALSE(std::is_aggregate_v); + // A fitted receiver is an immutable value object and remains valid when copied — copying is not + // a way to bypass the factory. + STATIC_REQUIRE(std::is_copy_constructible_v); + + // And the invariant the type now guarantees, on a real fit: an orthonormal basis, so + // `Mat4::lookAt` never has cause to substitute an up vector of its own. + const auto fit = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(fit); + CHECK(std::abs(Vec3::dotProduct(fit->lightUp(), fit->lightDirection())) < 1e-5f); + CHECK(fit->lightUp().magnitude() == Approx(1.0f).margin(1e-5)); + CHECK(fit->radius() > 0.0f); + CHECK(fit->worldPerTexel() > 0.0f); +} + +// The equivalence check is only as good as its comparator, and the comparator's failure mode is +// certifying two identically broken matrices as a match. +TEST_CASE("CascadeFit.BitIdenticalRejectsPoisonedMatrices", "[CascadeFit]") +{ + const Mat4 identity = Mat4::identity(); + CHECK(bitIdentical(identity, identity)); + + Mat4 poisoned = Mat4::identity(); + poisoned[2, 2] = std::numeric_limits::quiet_NaN(); + Mat4 samePoison = Mat4::identity(); + samePoison[2, 2] = std::numeric_limits::quiet_NaN(); + // Same bits on both sides, and still not a match: NaN never certifies anything. + CHECK_FALSE(bitIdentical(poisoned, samePoison)); + CHECK_FALSE(bitIdentical(poisoned, identity)); + + Mat4 infinite = Mat4::identity(); + infinite[0, 3] = std::numeric_limits::infinity(); + CHECK_FALSE(bitIdentical(infinite, infinite)); + + // A one-ulp difference must still register — the whole reason the comparison is bitwise. + Mat4 nudged = Mat4::identity(); + nudged[1, 1] = std::nextafter(1.0f, 2.0f); + CHECK_FALSE(bitIdentical(identity, nudged)); +}