diff --git a/CMakeLists.txt b/CMakeLists.txt index 13decdb..786a8c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,7 @@ add_library(fireengine SHARED src/graphics/geometry.cpp src/graphics/mesh_simplifier.cpp src/graphics/mesh_topology.cpp + src/graphics/shadow_caster_bounds_frame.cpp src/graphics/shadow_identity.cpp src/graphics/shadow_lod_resolver.cpp src/graphics/shadow_caster_deformation.cpp @@ -415,6 +416,8 @@ add_executable(test_fire_engine tests/graphics/test_frame_info.cpp tests/graphics/test_shadow_diagnostics.cpp tests/graphics/test_shadow_view.cpp + tests/graphics/test_object_shadow_casters.cpp + tests/graphics/test_shadow_caster_bounds_frame.cpp tests/graphics/test_shadow_identity.cpp tests/graphics/test_shadow_lod_resolver.cpp tests/graphics/test_shadow_caster_deformation.cpp diff --git a/assets/shadow_lod/ShadowDepthClipDemo.gltf b/assets/shadow_lod/ShadowDepthClipDemo.gltf index b6a081b..765eee5 100644 --- a/assets/shadow_lod/ShadowDepthClipDemo.gltf +++ b/assets/shadow_lod/ShadowDepthClipDemo.gltf @@ -34,9 +34,9 @@ "name": "DepthClip_Caster", "mesh": 1, "translation": [ - 24.22, - 28.14, - 18.29 + 24.784, + 28.855, + 18.704 ] }, { diff --git a/assets/shadow_lod/generate.py b/assets/shadow_lod/generate.py index 6eec743..7a52ac1 100644 --- a/assets/shadow_lod/generate.py +++ b/assets/shadow_lod/generate.py @@ -133,11 +133,24 @@ def _morph_bulge(positions, axis=0, amount=0.6): # 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. +# Placement, CORRECTED 2026-08-01 after the first attempt produced a complete ellipse. The corrected +# placement is the SH-06 red test: the legacy fit renders this sphere's shadow 14% too small +# linearly and 26% too small by area (35253 -> 26166 shadow pixels, 306x152 -> 263x131), and the +# caster-aware depth fit must restore it. +# +# The shadow pass culls FRONT faces (`Pipeline::shadowConfig`), so the depth a caster writes is +# its BACK surface — the side facing away from the sun. That decides where a clipping plane has +# to fall to be visible at all. A sphere centred exactly on the near plane has its entire +# recorded surface (the downstream hemisphere) INSIDE the range, so nothing it contributes is +# clipped and the ellipse comes out whole — which is precisely what the first placement showed. +# The near plane must cut the RECORDED surface, so the centre sits one metre UPSTREAM of the +# plane (w ~ -42.34 against cascade 2's near plane at -41.340) and the downstream cap crosses it. +# +# Moving along the sun's own direction does not move the shadow: the caster still sits on the ray +# through the same floor point, so the target landing spot is unchanged and the earlier ray/landing +# checks still hold. DEPTH_CLIP_SHADOW_CENTRE = (2.0, 0.0, 2.0) -DEPTH_CLIP_CASTER_CENTRE = (24.22, 28.14, 18.29) +DEPTH_CLIP_CASTER_CENTRE = (24.784, 28.855, 18.704) DEPTH_CLIP_CASTER_RADIUS = 2.0 diff --git a/docs/onboarding.md b/docs/onboarding.md index bad6eea..967f86c 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -928,6 +928,29 @@ the same change — most have a test or guard that will catch you, but not all. is exhaustive over the combinations and over the materialised topology (both GPU-free, so CI runs them); `tests/core/test_gltf_node_decomposition.cpp` is the end-to-end confirmation through the real loader and is `[.][gpu]`, so it runs locally only. +- **The draw walk and the transform walks place a node with ONE function**: `Node::drawWorld`. + `update` / `resolve` treat a world-override (a ragdoll-driven body, whose pose physics owns) as + authoritative and bypass the parent chain and local transform; the draw walk used to recompute + `parentWorld * local` and ignore it. Since SH-06 that is not merely inconsistent — the caster + prepass measures bounds from `composedWorld_`, so an overridden node would have been drawn at a + different pose than its bounds described. The rule has two halves: `drawWorld` places the node + itself, and `childWorld` decides what its children inherit — an overridden node does NOT let its + component matrix move them, because `update` / `resolve` return early on the override and skip + that matrix too. An overridden Animator therefore hands its children the override, not + `override * animation`. If you add a third traversal, place nodes with the same two functions. +- **A shadow caster's world bounds are computed ONCE per frame, in the prepass** (SH-06). + `RenderableScene::gatherShadowCasters` fills a `ShadowCasterBoundsFrame` before the cascade fit + runs; the fit, the draw build and the diagnostics all read that record. `buildDrawCommands` + receives it explicitly and each shadow command looks its own binding up by + (`ShadowCasterId`, `ShadowCasterGeneration`) — a missing or duplicate key is terminal, never a + silent recompute or an empty box. The draw path used to compute an object-WIDE union of its own, + which cost a second skinning pass and handed every binding a box containing space no caster + occupied. There is no `Object::computeShadowBounds` any more; if you need bounds during the draw + walk, look them up. Two related rules ride with it: the coarse cull asks + `Object::localBoundsCoverDrawnGeometry()` (NOT `deformable()`, which answers a different question + and would misclassify a rigid sibling binding) so cloth is not culled by a bind-pose box; and a + `Stale` bound — cloth, whose vertices a compute pass rewrites — may never be used to EXCLUDE a + caster, so `ShadowDrawFilter` and any future cascade-candidate test must pass it through. - **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 diff --git a/docs/review-order.md b/docs/review-order.md index 3858ff2..a5bf5f1 100644 --- a/docs/review-order.md +++ b/docs/review-order.md @@ -197,7 +197,9 @@ 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. | +| `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. | | `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/roadmap.md b/docs/roadmap.md index b432e38..5aa2876 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -76,11 +76,39 @@ the plan; the priority order is its § Suggested priority. **Milestone 2 — shadow silhouette correctness** - **SH-05** — material-aware casters (alpha-mask cutout, double-sided sheets). -- **SH-06** — cascade caster fit (remove fixed-depth clipping, align candidate sets). **Has a - reproduction**: on `ShadowLodMotionDemo` the moving sphere loses the top third of its cast shadow - as it passes the detail cluster, with shadow LOD off — see [`shadowplans.md`](shadowplans.md) - § SH-06 for the capture command. -- **SH-07** — scale-derived bias & filtering tied to each map's actual texel footprint. +- ~~**SH-06** — cascade caster fit~~ ✅ **landed** (`shadow-cascade-caster-depth-fit`): the fixed + `kShadowDepthBackExtend` is retired as policy. The cascade fit is split into a stable receiver + half and a depth half; a Vulkan-free per-frame caster prepass + (`RenderableScene::gatherShadowCasters` → `ShadowCasterBoundsFrame`) is the single authority on + caster bounds for the fit, the draws and the diagnostics; and `fitCasterAwareCascadeDepth` places + the near plane at the furthest-upstream candidate caster and the far plane at the receiver volume. + Each cascade is fitted from the start of its predecessor's blend band, with the fraction uploaded + in `LightUBO::cascadeParams.x`. Acceptance on `ShadowDepthClipDemo`: 26166 → 35324 shadow pixels. + Cloth still forces a marked `LegacyStaleFallback` — see below. +- **SH-07** — scale-derived bias & filtering tied to each map's actual texel footprint. Better + positioned since SH-06: the per-view metrics it needs are already returned by the fit, and the + depth span is no longer a fixed constant. + +**Open questions and follow-ups left by the milestone-2 work** (each is its own branch): + +- **Suggested next: SH-05.** Self-contained, has visible symptoms in an existing acceptance scene + (the alpha-masked quad and the double-sided green sheet in `ShadowLodDemo` both cast nothing + today), and depends on nothing parked. +- **The historical half-ellipse is NOT SH-06's motivation and remains unexplained.** It was observed + on `ShadowLodMotionDemo` under the engine's FALLBACK sun (the glTF loader was dropping lights on + animated nodes), and measurement excluded depth clipping as the cause: zero `clippedNear` events + across a 676-row live trace, closest approach 20.7 m. Diagnosing it needs the symptom re-confirmed + under the repaired sun, the shadow pass's own per-cascade drawn verdict beside the placement + trace, and per-pixel cascade / blend factor / projected shadow U/V at the affected receivers — + see [`shadowplans.md`](shadowplans.md) § SH-06. +- **Cloth cannot be fitted to.** A storage-vertex caster's bounds are its bind pose, so any frame + containing one falls back to the legacy depth range for every directional cascade. Closing this + needs a conservative simulation or authored envelope for storage geometry; until then the + fallback is marked `LegacyStaleFallback` in the fit result and the panel, not silently taken. +- **GPU-timestamp diagnostics** — parked before SH-07 (invalid timestamps observed under both + MoltenVK and KosmicKrisp, so not driver-specific). SH-07's per-view cost claims want it working. +- **SH-04's proxy half** — `Object::shadowGeometry` was removed rather than documented as unsafe, so + there is currently no way to author a shadow proxy at all. **Milestone 3 — only if measured** - **SH-08** — shadow VIPM, *if* discrete transitions remain visibly popping. diff --git a/docs/shadowplans.md b/docs/shadowplans.md index 2b4f437..4aaf3cf 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -562,47 +562,81 @@ not only geometry error; VDPM's UV-deviation channel is a useful input, but it i proof that a binary alpha boundary is preserved. BLEND shadow semantics should be a separate design decision (opaque/dithered/transmittance), not accidentally treated as MASK. +**Where to start (2026-08-03).** Both symptoms are already visible in `ShadowLodDemo` and need no new +asset: the alpha-masked quad casts a solid rectangle (`shadow.frag` samples no texture), and the +double-sided sheet casts nothing at all (it is authored face-on to the sun, and `Pipeline:: +shadowConfig` fixes `cullMode = eFront`, so the shadow pass culls the only faces it has). Capture +them with the SH-01 runbook's `ShadowLodDemo` command; the pair sits centre-right in every frame. +Note the second one interacts with the first fix rather than being independent of it — making the +pass respect `doubleSided` changes which faces record depth, which changes what an alpha test then +discards, so land them together with one acceptance capture rather than in two passes. + +The measurement to re-run afterwards is the SH-03 budget sweep, which SH-05 changes by construction: +casters that currently contribute nothing to the shadow mask will start contributing, so the +shadowed area and every relative percentage move. Idle machine only — see `constants.hpp`. + Likely branch: `shadow-material-casters`. -#### SH-06: Receiver/caster-aware cascade depth fitting +#### SH-06: Receiver/caster-aware cascade depth fitting — ✅ landed (branch `shadow-cascade-caster-depth-fit`) The cascade XY fit is stable, but its light-space depth currently relies on the fixed `kShadowDepthBackExtend`. A caster farther behind the receiver slice than that constant can be clipped even though its shadow reaches the slice. -**QUALIFIED, 2026-08-01 — do not treat the paragraph below as an established depth-clip case.** Two -things came out of building the reproduction. First, `ShadowLodMotionDemo` was rendering under the -engine's FALLBACK sun: the glTF loader dropped lights on animated nodes, so the authored, swinging -sun never reached the scene (fixed on `gltf-component-decomposition`; see -[`onboarding.md`](onboarding.md) § Cross-File Invariants). Every observation on this scene, including -the one below, was made under lighting the asset did not author. Second, sweeping the caster's whole -animation range through `CascadeReceiverFit::fit` -> `fitLegacyCascadeDepth` -> `placeCaster` finds -NO pose where the moving caster is clipped by a cascade near plane — closest approach 20.7 m under -the fallback sun, 30.8 m under the authored one. The sweep reproduces the engine's own logged -cascade-0 fit to the printed digit and detects a deliberately planted behind-the-plane caster, so the -null is not vacuous. The symptom below is therefore real as an observation but MISATTRIBUTED as -fixed-depth clipping; re-diagnose it under corrected lighting with `placeCaster`, which separates a -depth clip from a footprint miss, before any fixture is frozen. - -**Observed, 2026-07-29** (reported from a live run during SH-03 slice 6, then reproduced). On -`ShadowLodMotionDemo`, as the moving sphere passes the detail cluster, the top third of its cast -shadow disappears: the shadow renders as a half-ellipse with a straight upper edge while the sphere -itself is fully lit and its neighbour a metre away casts a complete ellipse. It returns once the -sphere moves clear. Reproduce with: - -```bash -./fireEngineApp shadow_lod/ShadowLodMotionDemo.gltf nightbox.hdr --no-taa \ - --no-shadow-lod --capture-frame 300 --capture /tmp/sh06.png -``` +**RESOLVED, 2026-08-01 — SH-06 keeps its depth-fit scope, and now has a valid red test. The +historical half-ellipse is a SEPARATE open question.** + +Two things came out of building the reproduction. + +*The scene was mis-lit.* `ShadowLodMotionDemo` rendered under the engine's FALLBACK sun, because the +glTF loader dropped lights on animated nodes (fixed; see [`onboarding.md`](onboarding.md) +§ Cross-File Invariants). Every observation on this scene, including the half-ellipse, was made +under lighting the asset did not author. + +*The fixed back-extension does clip real casters, and the cost is now measured.* The first probe +placement — a sphere centred ON cascade 2's legacy near plane — produced a complete ellipse, which +is correct and not a null result: the shadow pass culls FRONT faces +(`Pipeline::shadowConfig`), so the surface a caster records is its far side, and a sphere centred on +the plane has that whole surface downstream of it. Moving the caster one metre UPSTREAM along the +sun — which does not move its shadow, since it stays on the same light ray — makes the recorded +surface straddle the plane. The trace then reports `clippedNear=true` (`W [-45.723, -38.953]` against +`depth W [-41.340, 33.535]`) and the shadow measurably shrinks: -`--no-shadow-lod` forces every caster to shadow LOD0 while the visible geometry keeps selecting -normally, which is what makes this evidence rather than an anecdote: **shadow LOD is not -involved**. (The original reproduction used `--shadow-budget 0.001`, which is nearly but not -exactly the same thing — selection still runs there.) The straight cut and the dependence on the -caster's position relative to the cluster are what a depth/candidate-set clip looks like — the -caster is behind the receiver slice it casts into. Note the frame number is only approximate: the -demo advances on wall-clock time, so the moment drifts between runs. Fix this and the same capture -must show a complete ellipse. +| probe placement | shadow pixels | bounding box | +|---|---|---| +| centre on the near plane | 35253 | 306 x 152 | +| recorded surface straddling | 26166 | 263 x 131 | + +That is 14% smaller linearly and 26% by area, against an analytic prediction of 13.4% / 25% for the +cap the plane removes from a sphere. **This is SH-06's acceptance gate**: the caster-aware depth fit +must restore the full-size ellipse, and the numbers above are the pre-fix baseline. + +Note the scope of the shape result. A plane perpendicular to the light removes a cap symmetric about +the light axis, so THIS fixture's sphere shrinks concentrically rather than acquiring a straight +edge. That is a statement about a sphere: asymmetric geometry can certainly present a straight +projected boundary under the same clip. What generalises is the mechanism, not the silhouette. + +*The half-ellipse itself is unexplained and is not SH-06's gate.* Sweeping the caster's whole +animation range through `CascadeReceiverFit::fit` -> `fitLegacyCascadeDepth` -> `placeCaster` finds +no pose where the moving caster is depth-clipped (closest approach 20.7 m under the fallback sun, +30.8 m under the authored one), and a 676-row live trace over a ~25 s run reports zero `clippedNear` +events for any caster. The sweep reproduces the engine's own logged cascade-0 fit to the printed +digit and the trace flags the probe scene, so neither null is vacuous. Diagnosing it needs, and does +not yet have: + +- the symptom CONFIRMED to still occur under the repaired authored sun (four sampled frames did not + show it, which is not a search); +- the pass's own per-cascade filter/drawn verdict beside the placement — the trace runs before + `ShadowDrawFilter`, so it proves where a caster is, not whether that cascade rasterised it; +- at the affected receiver pixels: the selected cascade, the blend factor, and the projected shadow + U/V, since a receiver sampling outside a map, or two cascades disagreeing across a blend, can + produce a straight boundary. + +A footprint classification (`CascadeFootprintRelation`: Invalid / Outside / Inside / Straddles, with +edge-touching conservatively Straddles) now rides on the placement to make those cases inspectable. +It is diagnostics, NOT an accusation: light rays in an orthographic directional map preserve U and V, +so the part of a straddling caster outside the rectangle cannot shadow any receiver inside it. 82 of +those 676 rows straddle, and that is ordinary. **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 @@ -686,6 +720,69 @@ and blend factor, caster U/V/W bounds against both cascades involved, and whethe offered and rasterised in each — enough to separate depth clipping from candidate rejection or cascade blending. +**Landed so far.** The two fit carriers and `placeCaster` (slice 2 + evidence tooling), and the +caster prepass: `RenderableScene::gatherShadowCasters` walks the scene before the fit and fills a +`ShadowCasterBoundsFrame` with each shadow-casting binding's world bounds in the current pose, with +cloth marked `Stale` because a compute pass rewrites its vertices. That record is the frame's ONLY +authority — `buildDrawCommands` receives it and every shadow command looks its own binding up rather +than anything recomputing, which both removes a second per-frame skinning walk and keeps the +per-binding precision the depth fit needs (the old path built an object-wide union). Two rules ride +with it: cloth is no longer coarse-culled by its bind-pose box +(`Object::localBoundsCoverDrawnGeometry`, kept separate from `deformable()` so a rigid sibling +binding is not misclassified), and a `Stale` bound may never EXCLUDE a caster — `ShadowDrawFilter` +passes it through, and the caster-aware candidate test must do the same until storage geometry +carries a conservative envelope of its own. It is a prepass and not a read of the draw list for an ordering +reason that cannot be worked around — the fitted matrices decide the shadow frustums, and those +frustums are what the draw walk culls against, so a cascade finalised after draw collection would +leave the frame's matrices describing a different fit than its draws were selected for. + +**The `Stale` rule for the depth policy, agreed before it is written.** "Stale cannot tighten" is not +the same as "ignore stale entries": fitting only the `Exact` casters can produce a range NARROWER +than one that covers the cloth, which clips it — the defect, arrived at from the other side. Stale +XY cannot even establish which cascade a cloth affects, so it cannot be excluded per view either. +Until storage geometry carries a conservative simulation or authored envelope, the honest interim +rule is: **if any stale caster exists in the frame, every directional cascade falls back to the +legacy depth fit**, marked as an unresolved correctness fallback rather than a policy. That mark +belongs in the pure depth-fit RESULT — a mode on `CascadeDepthFit`, not something the log or the +overlay reconstructs — so the displayed reason is tied to the matrix that was actually selected and +cannot drift from it. THREE values, not two: `fitLegacyCascadeDepth` still exists as its own +function and its direct result is neither caster-aware nor a fallback from anything, so it reports +`LegacyFixedExtension`. The policy reports `CasterAware` when it fitted the casters, and converts a +legacy result to `LegacyStaleFallback` only where stale geometry forced that choice. The probe scene contains only `Exact` casters, so its 26166 -> 35253 gate still validates the +new policy independently of that fallback. + +**LANDED (2026-08-03): the caster-aware depth fit.** `fitCasterAwareCascadeDepth` takes the receiver +fit and the frame's caster record and places the planes where the geometry is: the near plane reaches +the furthest-upstream CANDIDATE caster (footprint not `Outside`, since light rays preserve U/V), the +far plane covers the receiver volume and no further (geometry behind every receiver in the slice +cannot shadow one), and one texel of the fit's own `worldPerTexel` is allowed as slack rather than an +invented epsilon. The matrix is built with the same `lookAt` / `ortho` calls as the legacy fit, so +the ONLY difference between the policies is where the planes sit. + +The result carries its own mode — `LegacyFixedExtension` / `CasterAware` / `LegacyStaleFallback` — +so the log and the panel report the policy that produced the matrix rather than reconstructing it. + +**Gate met.** `ShadowDepthClipDemo`, whose caster the legacy fit clipped: + +| | shadow pixels | bounding box | +|---|---|---| +| legacy fixed extension | 26166 | 263 x 131 | +| caster-aware | 35324 | 305 x 152 | + +Restored, and checked rather than asserted. Against the geometrically unclipped baseline (35253 px, +306 x 152 — the earlier probe placement, whose shadow sits at the same floor point because the caster +only moved along the light ray) the two differ by 253 pixels, grouped into 185 HORIZONTAL SCANLINE +RUNS (maximal spans of differing pixels within one image row) whose longest is 5 px and whose median +is 1. Every difference is therefore a one-pixel-wide fringe following the silhouette; there is no +clip-sized interior region, which is what residual clipping would leave — the legacy row above is +exactly that, a concentric 26% loss of area. The remaining fringe is soft-edge rasterisation under a +different depth range. Cascade 2's range on that scene goes from +`[-41.340, 33.535]` (span 74.9) to `[-45.740, 8.292]` (span 54.0): it reaches further back to catch +the caster while giving up the empty space behind the receivers. + +The SH-03 budget calibration reproduces to every printed digit, as it must — the depth range does not +enter shadow-LOD selection. + Keep the stable receiver XY fit, then determine the Z range from candidate caster bounds: 1. Build/extract the receiver slice volume. @@ -846,10 +943,18 @@ The key success criteria for Milestone 1 are: | 3 | ~~SH-03 per-view discrete LOD~~ ✅ | Fixes the requested architectural mismatch. | | 4 | SH-04 deformation/proxy policy — **deformation half ✅**, proxy half open | Removes invalid error claims and defines safe extension points. | | 5 | SH-05 material-aware casters | Fixes visibly wrong cutout and two-sided silhouettes. | -| 6 | SH-06 cascade caster fit | Removes fixed-depth clipping and aligns candidate sets. | +| 6 | ~~SH-06 cascade caster fit~~ ✅ | Removed fixed-depth clipping; candidate alignment (per-view filtering from the same record) is what remains, and SH-07 consumes it. | | 7 | SH-07 scale-derived bias/filtering | Makes quality controls physically tied to each map. | | 8 | SH-08 shadow VIPM | Add only if measured popping remains. | | 9 | SH-09 shadow VDPM checkpoint | Highest complexity; require evidence before committing. | This ordering makes “correct LOD” a small, independently reviewable foundation rather than coupling it immediately to GPU-front scheduling, shadow caching, or a new filtering technique. + +**Next up (2026-08-03): SH-05**, per the order above and because it is self-contained, has visible +symptoms in an existing acceptance scene, and depends on nothing parked. Its starting point is +written up in § SH-05. Three things stay open behind it and are indexed in +[`roadmap.md`](roadmap.md) rather than blocking it: the historical half-ellipse (unexplained, and +NOT a depth clip — measured), the cloth `LegacyStaleFallback` (needs a conservative envelope for +storage geometry), and SH-04's proxy half. The GPU-timestamp diagnostics branch is still parked and +should land before SH-07's cost claims. diff --git a/include/fire_engine/graphics/bounds.hpp b/include/fire_engine/graphics/bounds.hpp index e6bed76..6b90a48 100644 --- a/include/fire_engine/graphics/bounds.hpp +++ b/include/fire_engine/graphics/bounds.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -31,6 +32,24 @@ struct Bounds3 max = {std::max(max.x(), p.x()), std::max(max.y(), p.y()), std::max(max.z(), p.z())}; } + // `expand` that cannot SWALLOW a corrupt point. Returns false when `p` is non-finite, and + // leaves the box unchanged. + // + // Plain `expand` uses std::min / std::max, which return the OTHER operand when one side is NaN + // — so a NaN vertex silently leaves finite bounds that do not contain the geometry they claim + // to. Anything that FITS to bounds (SH-06's cascade depth range) has to be able to tell "no + // vertices" from "a vertex nobody can bound": the first contributes nothing, the second must + // stop the fit, because a range tightened around geometry it never accounted for clips it. + [[nodiscard]] bool expandChecked(Vec3 p) noexcept + { + if (!std::isfinite(p.x()) || !std::isfinite(p.y()) || !std::isfinite(p.z())) + { + return false; + } + expand(p); + return true; + } + [[nodiscard]] Vec3 center() const noexcept { return (min + max) * 0.5f; diff --git a/include/fire_engine/graphics/draw_command.hpp b/include/fire_engine/graphics/draw_command.hpp index c98d40e..712986a 100644 --- a/include/fire_engine/graphics/draw_command.hpp +++ b/include/fire_engine/graphics/draw_command.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -120,6 +121,14 @@ struct DrawCommand // focused, or a focused view that did not resolve this caster. uint32_t shadowLodLevel{kNoShadowLod}; Bounds3 shadowBounds{}; + // Whether `shadowBounds` may be used to EXCLUDE this caster (SH-06). `Stale` bounds come from a + // bind pose whose vertices a compute pass rewrites (cloth): they are evidence of where the + // caster probably is, and no evidence at all about where it is not. A per-view frustum test or + // a cascade-candidate test that rejected on them would drop a caster that is genuinely in the + // view, so both must pass such a caster through until storage geometry carries a conservative + // envelope of its own. Defaults to `Stale`, the safe answer, so a producer that forgets the + // field over-includes rather than silently dropping shadows. + ShadowCasterBoundsKind shadowBoundsKind{ShadowCasterBoundsKind::Stale}; Mat4 selfShadowViewProj{Mat4::identity()}; // Indirect draw (rendering-spine #3, GPU-driven-front Stage A). When `indirectBuffer` is not // NullBuffer the renderer records `drawIndexedIndirect` from the `DrawIndexedIndirectCommand` diff --git a/include/fire_engine/graphics/object.hpp b/include/fire_engine/graphics/object.hpp index d4b3ecb..e934cc6 100644 --- a/include/fire_engine/graphics/object.hpp +++ b/include/fire_engine/graphics/object.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -81,9 +82,17 @@ class Object morphWeights_.assign(weights.begin(), weights.end()); } + // SH-06 prepass: record this object's shadow-casting bindings' world bounds — per binding, in + // the CURRENT pose (morph weights and skinning applied) — so the cascade depth fit can run + // BEFORE any draw command exists. Derives skin state exactly as `render` does, so the prepass + // and the draw cannot describe different poses. This is the ONLY place these bounds are + // computed; `render` looks them up rather than repeating the vertex walk. + void gatherShadowCasterBounds(const Mat4& world, ShadowCasterBoundsFrame& out) const; + [[nodiscard]] std::vector render(const FrameInfo& frame, const Mat4& world, - const Mat4& previousWorld); + const Mat4& previousWorld, + const ShadowCasterBoundsFrame& casterBounds); // Add this frame's VDPM repair work (vertices each pass pulled back in) across this object's // active fronts to the running totals. Valid after render() this frame; a diagnostic surfaced @@ -104,6 +113,18 @@ class Object return skin_ != nullptr || !morphWeights_.empty(); } + // Whether `localBounds()` can be trusted to contain what this object DRAWS, and therefore + // whether the coarse scene cull may reject it by that bound. + // + // Deliberately NOT `deformable()`. That predicate answers a different question — "does this + // instance carry a skin or morph weights" — and it is used to classify shadow-caster + // deformation per binding, where broadening it would misclassify a rigid sibling binding as + // deformable. Storage-vertex geometry (cloth) is a third case: nothing about the INSTANCE + // deforms, but a compute pass rewrites the vertex buffer, so the CPU-side local bound describes + // the bind pose and the drawn cloth can be anywhere. Culling by it drops cloth that is on + // screen. + [[nodiscard]] bool localBoundsCoverDrawnGeometry() const noexcept; + private: struct GeometryBindings { @@ -172,8 +193,10 @@ class Object std::array shadowBufs{NullBuffer, NullBuffer}; }; - [[nodiscard]] Bounds3 computeShadowBounds(std::span jointMatrices, bool hasSkin, - const Mat4& world) const noexcept; + [[nodiscard]] Bounds3 computeBindingShadowBounds(const GeometryBindings& binding, + std::span jointMatrices, + bool hasSkin, + const Mat4& world) const noexcept; // load() phases: createForwardBindings allocates the per-geometry vertex-stage // buffers; createShadowBindings allocates the per-object ShadowUBO buffers. @@ -196,9 +219,9 @@ class Object void writeForwardUniforms(const FrameInfo& frame, const Mat4& world, const Mat4& previousWorld, bool hasSkin, std::span jointMatrices); void writeShadowUniforms(const FrameInfo& frame, const Mat4& world, bool hasSkin); - [[nodiscard]] std::vector buildDrawCommands(const FrameInfo& frame, - const Mat4& world, bool hasSkin, - const Bounds3& shadowBounds) const; + [[nodiscard]] std::vector + buildDrawCommands(const FrameInfo& frame, const Mat4& world, bool hasSkin, + const ShadowCasterBoundsFrame& casterBounds) const; Skin* skin_{nullptr}; std::vector morphWeights_; diff --git a/include/fire_engine/graphics/renderable_scene.hpp b/include/fire_engine/graphics/renderable_scene.hpp index 779e9bc..a5fc290 100644 --- a/include/fire_engine/graphics/renderable_scene.hpp +++ b/include/fire_engine/graphics/renderable_scene.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace fire_engine @@ -90,13 +91,30 @@ class RenderableScene // Resolve this frame's active particle emitters into `out` (cleared first). virtual void gatherEmitters(std::vector& out) const = 0; + // SH-06: every shadow caster's world bounds for THIS frame, into `out` (reset first), before + // any draw command is built. + // + // A prepass rather than a read of the draw list, because of an ordering constraint that cannot + // be worked around: the cascade depth range is fitted from these bounds, the fitted matrices + // decide the shadow frustums, and those frustums are what the draw walk culls against. A + // cascade finalised after draw collection would leave the frame's matrices describing a + // different fit than the one its draws were selected for. + // + // `out` is then the frame's ONLY authority on caster bounds: `buildDrawCommands` receives it + // and each shadow command looks up its own binding's entry, rather than anything recomputing. + virtual void gatherShadowCasters(ShadowCasterBoundsFrame& out) const = 0; + // Build this frame's draw commands, appending them to `out`. Renderables are culled against // `frustums` (the camera frustum plus any shadow-caster frustums) using the scene's own spatial // bounds; an empty `frustums` span means culling is disabled and everything is drawn. // Per-object data (world matrix, skin/morph, material) is baked from `frame`. Returns // coarse-cull counts. + // `casterBounds` is this frame's prepass result, passed explicitly rather than stashed + // anywhere: every shadow draw takes its bounds from that record, so the geometry the cascade + // was fitted to and the geometry the pass culls are by construction the same measurement. [[nodiscard]] virtual CullStats buildDrawCommands(const FrameInfo& frame, std::span frustums, + const ShadowCasterBoundsFrame& casterBounds, std::vector& out) = 0; }; diff --git a/include/fire_engine/graphics/shadow_caster_bounds.hpp b/include/fire_engine/graphics/shadow_caster_bounds.hpp new file mode 100644 index 0000000..4ecf7df --- /dev/null +++ b/include/fire_engine/graphics/shadow_caster_bounds.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include +#include + +namespace fire_engine +{ + +// How much a caster's reported world bounds can be trusted to contain what will actually rasterise. +// +// The distinction exists because the shadow depth range is about to be FITTED to these bounds +// (SH-06). A range fitted to bounds that understate the geometry clips it, which is the defect the +// fixed extension was hiding — so a caster whose bounds are not authoritative must not be allowed +// to tighten the range as if they were. +enum class ShadowCasterBoundsKind : std::uint8_t +{ + // The bounds were computed from the vertices that will be drawn, in their current pose — + // including skinning and morph weights. Safe to fit against. + Exact, + // The CPU-side vertices are NOT what renders: a compute pass rewrites the storage vertex buffer + // (cloth). The bounds describe the bind pose, so the deformed geometry can leave them in any + // direction and by any amount. Usable as a hint, never as a bound. + Stale, +}; + +// The identity a caster's bounds are recorded under: the same pair the shadow LOD state is keyed +// on, so a reloaded or replaced caster cannot inherit the previous one's bounds. +// +// A real key rather than the two values packed into one integer. Both halves are 64-bit, so any +// packing is lossy — `(id << 32) | generation` silently equates (id 2, generation 2^32) with +// (id 3, generation 0) and discards the top half of every id. Equality decides membership here; +// the hash below only distributes. +struct ShadowCasterKey +{ + ShadowCasterId casterId{ShadowCasterId::Invalid}; + ShadowCasterGeneration generation{ShadowCasterGeneration::First}; + + [[nodiscard]] friend bool operator==(const ShadowCasterKey&, const ShadowCasterKey&) = default; +}; + +// One shadow caster's world-space extent, as the scene reports it BEFORE any draw commands exist. +// +// The prepass this belongs to is what lets the cascade depth range depend on the casters: the fit +// runs before draw construction (a cascade finalised afterwards would leave the frame's shadow +// matrices describing a different fit than the one the draws were culled against), so it cannot ask +// the draw list what it will contain. +struct ShadowCasterBounds +{ + Bounds3 world{}; + ShadowCasterId casterId{ShadowCasterId::Invalid}; + ShadowCasterGeneration generation{ShadowCasterGeneration::First}; + ShadowCasterBoundsKind kind{ShadowCasterBoundsKind::Stale}; + + [[nodiscard]] ShadowCasterKey key() const noexcept + { + return ShadowCasterKey{.casterId = casterId, .generation = generation}; + } +}; + +} // namespace fire_engine + +template <> +struct std::hash +{ + [[nodiscard]] std::size_t operator()(const fire_engine::ShadowCasterKey& k) const noexcept + { + // Both halves are hashed at FULL width and combined, rather than shifted into one word + // where the wider one would lose bits. Distribution only — equality decides membership, and + // two colliding keys still coexist and retrieve independently. + std::size_t seed = std::hash{}(static_cast(k.casterId)); + seed ^= std::hash{}(static_cast(k.generation)) + + 0x9E3779B97F4A7C15ULL + (seed << 6U) + (seed >> 2U); + return seed; + } +}; diff --git a/include/fire_engine/graphics/shadow_caster_bounds_frame.hpp b/include/fire_engine/graphics/shadow_caster_bounds_frame.hpp new file mode 100644 index 0000000..95b71b1 --- /dev/null +++ b/include/fire_engine/graphics/shadow_caster_bounds_frame.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace fire_engine +{ + +// One frame's shadow-caster bounds, and the single authority on them (SH-06). +// +// Built once per frame by `RenderableScene::gatherShadowCasters`, before the cascade fit; read by +// the fit, by the draw build, and by the diagnostics. That "once" is the point. Computing a +// caster's world bounds means walking its vertices with its current skin and morph weights, so a +// second computation is both expensive and an opportunity to disagree — the draw path used to +// recompute an object-WIDE union and stamp it onto every binding's command, which cost a full +// second pass and threw away exactly the per-binding precision the depth fit needs. +// +// Keyed by (`ShadowCasterId`, `ShadowCasterGeneration`), the same pair the shadow LOD state is +// keyed on, so a reloaded or replaced caster cannot inherit the previous one's bounds. +// +// Lifetime is one frame and nothing more: `reset()` at the start of the prepass, and no entry +// survives into the next frame. Nothing is cached on `Object`, so there is no per-object state +// whose correctness depends on which walk ran first. +class ShadowCasterBoundsFrame +{ +public: + // Start a frame. Keeps capacity — the caster set is stable frame to frame. + void reset() noexcept; + + // Record one shadow-casting binding. A DUPLICATE key is terminal: two bindings claiming one + // identity means the shadow state (hysteresis, drawn history, and now bounds) is being shared + // by casters that are not the same caster, and the resulting shadow would be fitted to one and + // drawn from the other. + // + // An entry whose bounds are invalid (a binding with no vertices) is still RECORDED, so the set + // of entries matches the set of shadow-casting bindings exactly. Consumers skip invalid bounds + // explicitly; they are not silently absent. + void add(const ShadowCasterBounds& caster); + + // The bounds for one caster, or terminal if the key is absent. Terminal rather than a recompute + // or an empty box, because absence means the prepass and the draw walk disagree about what the + // scene contains — and a silently empty bound would place a caster at the origin, where it + // would be fitted and culled against geometry it has nothing to do with. + [[nodiscard]] const ShadowCasterBounds& require(ShadowCasterId casterId, + ShadowCasterGeneration generation) const; + + // Non-terminal lookup, for diagnostics that legitimately ask about a caster that may not be in + // this frame's set. + [[nodiscard]] const ShadowCasterBounds* find(ShadowCasterId casterId, + ShadowCasterGeneration generation) const noexcept; + + [[nodiscard]] std::span entries() const noexcept + { + return entries_; + } + [[nodiscard]] std::size_t size() const noexcept + { + return entries_.size(); + } + [[nodiscard]] bool empty() const noexcept + { + return entries_.empty(); + } + +private: + std::vector entries_; + // Keyed by the real {id, generation} pair. Packing both 64-bit halves into one integer would + // make distinct casters collide, and a collision here is not a slow lookup — it is one caster + // silently receiving another's bounds. + std::unordered_map index_; +}; + +} // namespace fire_engine diff --git a/include/fire_engine/render/cascade_fit.hpp b/include/fire_engine/render/cascade_fit.hpp index 47b338a..ce76974 100644 --- a/include/fire_engine/render/cascade_fit.hpp +++ b/include/fire_engine/render/cascade_fit.hpp @@ -2,7 +2,11 @@ #include #include +#include +#include +#include +#include #include #include @@ -190,19 +194,96 @@ class CascadeReceiverFit // `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 +// Which policy produced a depth range. Part of the RESULT, not something a log or a panel +// reconstructs, so the reason shown is always the reason for the matrix actually selected. +// +// Three values, not two: `fitLegacyCascadeDepth` is still its own function with its own callers, +// and its direct result is neither caster-aware nor a fallback from anything — labelling it either +// would be false. Only the caster-aware policy can report a fallback, and only where stale geometry +// forced it. +enum class CascadeDepthFitMode : std::uint8_t +{ + // The pre-SH-06 fixed extension, asked for directly. + LegacyFixedExtension, + // Fitted to this frame's caster bounds and the receiver volume. + CasterAware, + // The caster-aware policy declined: the frame contains a caster whose bounds cannot bound it + // (cloth), so the legacy range was used instead. An unresolved correctness fallback, not a + // policy — see `ShadowCasterBoundsKind::Stale`. + LegacyStaleFallback, +}; + +// For diagnostics. Named beside the enum so a new mode cannot be added without a name, and so the +// panel and the log read the same words. +[[nodiscard]] constexpr std::string_view cascadeDepthFitModeName(CascadeDepthFitMode mode) noexcept +{ + switch (mode) + { + case CascadeDepthFitMode::CasterAware: + return "caster-aware"; + case CascadeDepthFitMode::LegacyStaleFallback: + return "legacy (stale-caster fallback)"; + case CascadeDepthFitMode::LegacyFixedExtension: + break; + } + return "legacy (fixed extension)"; +} + +// ENCAPSULATED, like `CascadeReceiverFit`, and for a reason that only appeared once the modes did: +// the mode must describe THIS matrix. As a public aggregate any caller could set +// `mode = CasterAware` on a legacy matrix, or move a plane without moving the projection, and the +// diagnostics would then report a policy that did not produce what rendered. `placeCaster` also +// consumes one, so it is an input to something else — the same test that made the receiver fit a +// class. Only the two fit functions can build one, and only they can convert a mode. +class CascadeDepthFit { +public: + [[nodiscard]] CascadeDepthFitMode mode() const noexcept + { + return mode_; + } // 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()}; + [[nodiscard]] float nearW() const noexcept + { + return nearW_; + } + [[nodiscard]] float farW() const noexcept + { + return farW_; + } + [[nodiscard]] const Vec3& lightPosition() const noexcept + { + return lightPosition_; + } + // `farW - nearW`. The depth range the shadow map has to spend its precision over. + [[nodiscard]] float viewDepthSpan() const noexcept + { + return viewDepthSpan_; + } + [[nodiscard]] const Mat4& viewProj() const noexcept + { + return viewProj_; + } + +private: + // The factories, and nothing else. `fitCasterAwareCascadeDepth` needs to relabel a legacy + // result as `LegacyStaleFallback`, which is the one mode conversion that exists — it happens + // there, on a matrix that function has in hand, not in a caller. + friend std::optional fitLegacyCascadeDepth(const CascadeReceiverFit&, + float) noexcept; + friend std::optional + fitCasterAwareCascadeDepth(const CascadeReceiverFit&, std::span, + float) noexcept; + + CascadeDepthFit() = default; + + CascadeDepthFitMode mode_{CascadeDepthFitMode::LegacyFixedExtension}; + float nearW_{0.0f}; + float farW_{0.0f}; + Vec3 lightPosition_{}; + 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 @@ -219,4 +300,115 @@ struct CascadeDepthFit [[nodiscard]] std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& receiver, float backExtend) noexcept; +// How a caster's light-space footprint relates to the cascade's snapped rectangle. +// +// A relation rather than a bool because the candidate query SH-06 will build must be able to reject +// `Outside` and only `Outside`. Edge-touching is classified conservatively as `Straddles`: a caster +// exactly on the boundary may still contribute, and the cost of over-including one is a wasted +// draw, while the cost of wrongly excluding it is a missing shadow nobody can trace. +// +// Note what `Straddles` does NOT mean. Light rays in an orthographic directional map preserve U and +// V, so the part of a caster outside the rectangle cannot shadow any receiver inside it — a +// straddling caster is ordinary, not defective. The classification exists to make cases +// INSPECTABLE, not to accuse them. +enum class CascadeFootprintRelation : std::uint8_t +{ + // The caster had no valid bounds; nothing was placed. + Invalid, + // No overlap with the rectangle at all. + Outside, + // Wholly within, and not touching an edge. + Inside, + // Overlaps, and reaches or crosses at least one edge. + Straddles, +}; + +// Where one caster sits relative to one cascade, in the cascade's own light space. +// +// This is the SH-06 evidence type, and deliberately not a verdict: it separates the two ways a +// shadow can go missing, which a single "was it drawn" boolean cannot — clipped in DEPTH by the +// fitted near/far planes, or outside the cascade's XY footprint entirely. +// +// The depth flags describe the CASTER, not the shadow it throws. What a depth clip costs is +// measurable and was measured (SH-06, `ShadowDepthClipDemo`): the shadow pass culls FRONT faces, so +// the surface a caster records is its far side, and the near plane removes a cap from that surface. +// For the fixture's sphere the projected silhouette then shrinks CONCENTRICALLY — 14% smaller +// linearly, 26% in area, matching the analytic cap prediction — rather than acquiring a straight +// edge. That result is about a sphere and a plane perpendicular to the light; asymmetric geometry +// can certainly present a straight projected boundary under the same clip, so do not generalise the +// shape, only the mechanism: a depth clip removes the part of the recorded surface beyond the +// plane. +struct CascadeCasterPlacement +{ + // The caster's world bounds projected onto the cascade's light basis. + float minU{0.0f}; + float maxU{0.0f}; + float minV{0.0f}; + float maxV{0.0f}; + float minW{0.0f}; + float maxW{0.0f}; + CascadeFootprintRelation footprint{CascadeFootprintRelation::Invalid}; + // Entirely within the fitted depth range. + bool insideDepth{false}; + // Extends nearer than `nearW` / further than `farW`. + bool clippedNear{false}; + bool clippedFar{false}; + // Entirely outside the depth range — the whole caster is missing from this map rather than part + // of it. Distinguished from partial clipping because the two look completely different on + // screen: a missing shadow versus a shrunken one. + bool outsideDepth{false}; +}; + +// Whether a caster's footprint overlaps the cascade's rectangle. DEPTH-INDEPENDENT, deliberately: +// the question is about U and V only, and the depth policy has to ask it BEFORE it has a depth +// range — it is choosing the range. Building a throwaway legacy fit to answer it would have made +// the caster-aware path depend on `kShadowDepthBackExtend`, the very thing it retires, and a bad +// extension would then have failed a fit that never used one. +// +// Non-finite or invalid bounds report `Invalid` rather than `Outside`: NaN comparisons are all +// false, so a corrupt box would otherwise look like a caster that is simply elsewhere, and the +// depth range would tighten around geometry it never accounted for. +[[nodiscard]] CascadeFootprintRelation classifyFootprint(const CascadeReceiverFit& receiver, + const Bounds3& casterBounds) noexcept; + +// Pure: no view set, no draw list, no GPU state — the geometric relationship only. Slice 4's +// candidate query is expected to be built from this same function, so a diagnostic and the policy +// it justifies cannot disagree about where a caster was. +// +// An INVALID or non-finite bounds yields a placement with every flag false, `Invalid` footprint and +// zero extents: a caster with no usable bounds has no position to report, and inventing one from +// the default min/max sentinels would place it at infinity. +[[nodiscard]] CascadeCasterPlacement placeCaster(const CascadeReceiverFit& receiver, + const CascadeDepthFit& depth, + const Bounds3& casterBounds) noexcept; + +// SH-06's depth policy: fit the light-space near/far planes to the casters that can actually shadow +// this cascade, instead of extending a fixed distance in both directions and hoping. +// +// The near plane is the one that matters. The shadow pass culls FRONT faces, so a caster records +// its far side; the near plane removes a cap from that surface, and the shadow shrinks by exactly +// what was removed (measured on `ShadowDepthClipDemo`: 14% linearly, 26% by area). Reaching back to +// the furthest-upstream caster is what stops that. The far plane only has to cover the RECEIVER +// volume — geometry beyond it is further from the light than every receiver in this slice, so it +// cannot shadow anything here, and pushing the plane out to include it would spend depth precision +// on nothing. +// +// Candidates are casters whose light-space footprint is not `Outside` this cascade's rectangle: +// light rays preserve U and V in an orthographic map, so a caster outside the rectangle cannot +// shadow a receiver inside it and must not widen the range. Casters with invalid bounds are +// skipped; they have no extent to fit to. +// +// STALE casters (cloth: a compute pass rewrites the vertices the bounds were measured from) make +// the whole thing undecidable — their box neither bounds the geometry nor establishes which cascade +// it affects — so a single one anywhere in the frame makes this return the LEGACY range, marked +// `LegacyStaleFallback`. "Ignore them and fit the rest" is not the safe reading: it can produce a +// range NARROWER than one covering the cloth, which clips it, arriving at the same defect from the +// other side. +// +// `backExtend` is only used for that fallback. Returns nullopt on the same terms as +// `fitLegacyCascadeDepth`. +[[nodiscard]] std::optional +fitCasterAwareCascadeDepth(const CascadeReceiverFit& receiver, + std::span casters, float backExtend) noexcept; + } // namespace fire_engine diff --git a/include/fire_engine/render/constants.hpp b/include/fire_engine/render/constants.hpp index 74e2b5f..e22bdcf 100644 --- a/include/fire_engine/render/constants.hpp +++ b/include/fire_engine/render/constants.hpp @@ -54,14 +54,36 @@ inline constexpr uint32_t kShadowMapExtent = 2048; // Past this distance, casters don't shadow — keeps the cascade ortho fits // tight. Anything in shadow range stays inside [kCameraNearPlane, kShadowFarPlane]. inline constexpr float kShadowFarPlane = 50.0f; -// Pulls the light-space near plane back along lightDir so casters behind the -// fitted bounding sphere still write to the shadow map. +// Pulls the light-space near plane back along lightDir so casters behind the fitted bounding sphere +// still write to the shadow map. +// +// SH-06 RETIRED THIS AS THE POLICY. The cascade depth range is now fitted to the frame's actual +// caster bounds (`fitCasterAwareCascadeDepth`), which is what a fixed distance could never do: too +// small clips a caster (measured — the probe's shadow came out 14% short linearly, 26% by area), +// too large spends depth precision on empty space, and no constant is right for an arbitrary scene. +// It survives for exactly two uses: `fitLegacyCascadeDepth`, which is what the caster-aware policy +// is measured against, and the fallback that policy takes when a frame contains a caster whose +// bounds cannot bound it (cloth). Retire it fully when storage geometry carries a conservative +// envelope. inline constexpr float kShadowDepthBackExtend = 20.0f; // Practical Split Scheme blend between linear and log-uniform cascade splits. // 0 = pure linear (cascades evenly spaced in view distance), 1 = pure log // (each cascade covers a constant ratio of the previous). 0.5 keeps close // cascades tight for near-camera detail while still covering kShadowFarPlane. inline constexpr float kShadowCascadeSplitLambda = 0.5f; +// The forward shader cross-fades into cascade i+1 over the last fraction of cascade i's view-depth +// range (`cascadeBlendFactor` in `shaders/shader.frag`), so receivers in that band sample BOTH +// maps. +// +// SH-06 made that a fitting constraint rather than a shader detail: cascade i+1 is now fitted from +// the start of the band, not from the hard split, or its tightly-fitted XY rectangle and depth +// range would not cover receivers that legitimately sample it — the fit would be exactly as wrong +// as the fixed extension was, in the other direction. +// +// THIS is the single authority. The value is uploaded in `LightUBO::cascadeParams.x` and the shader +// reads it from there (`cascadeBlendFactor` in `shaders/shader.frag`), so changing it here changes +// both the fitting and the blending together — there is no shader-side literal to keep in step. +inline constexpr float kShadowCascadeBlendFraction = 0.1f; inline constexpr float kShadowMinBias = 0.0008f; inline constexpr float kShadowSlopeBias = 0.0035f; inline constexpr float kShadowFilterRadius = 0.0f; @@ -155,6 +177,14 @@ inline constexpr float kPointShadowInfiniteRangeFallback = 100.0f; // 4 at 0.356%. Budget 2 becoming eligible once deformable error disappeared was a live possibility // worth checking, and it did not happen. // +// Re-run 2026-08-03 after SH-06's caster-aware depth fit, which changes the shadow matrices (a +// tighter depth range, and each cascade's slice expanded to cover the previous one's blend band): +// budget 2 moved 0.243% -> 0.289% and budget 4 0.356% -> 0.374%, with 8 and 16 within a hundredth. +// The 0.1% threshold still selects 1 by a wide margin. Measured on an IDLE machine — a run that +// overlapped a Docker CI build reported a 0.12% noise floor where an idle one reports 0.0000%, and +// eight identical-flag capture pairs differed by zero pixels, so contention (not the renderer) +// produced that sample. Discard any sweep whose noise-floor line is not zero. +// // Re-run 2026-08-01 after the glTF animated-light fix and reproduced to every printed digit. That // is the expected result rather than a lucky one: the budget half of the sweep runs on the STATIC // `ShadowLodDemo`, whose sun sits on a node with no animation and was therefore never dropped. The @@ -174,6 +204,10 @@ inline constexpr float kShadowLodPixelBudget = 1.0f; // ratio 0.75 0.16 transitions 0.00 REVERSALS // ratio 0.5 0.16 transitions 0.00 REVERSALS // +// Re-run again after SH-06's caster-aware depth fit (2026-08-03): 3, 1 and 1 raw events once more, +// over 1323 / 1250 / 642 frames, so the per-100 rates read 0.23 / 0.08 / 0.16 — the rate moves with +// the frame count, which is what "counting noise" means. Zero reversals at every ratio, unchanged. +// // RE-MEASURED 2026-08-01, after the glTF loader stopped dropping lights on animated nodes. Until // then `ShadowLodMotionDemo`'s authored sun was silently replaced by the engine's fallback // directional, so the scene's sun-swing animation did nothing and every dead-band figure taken on diff --git a/include/fire_engine/render/renderer.hpp b/include/fire_engine/render/renderer.hpp index 41c4227..9f5aef8 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -15,10 +15,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -367,6 +369,7 @@ class Renderer void submitAndPresent(Window& display, vk::CommandBuffer cmd, uint32_t imageIndex); void recordSkybox(Vec3 cameraPosition, Vec3 cameraTarget, std::vector& drawCommands); + void logShadowCasterPlacement(std::span shadowDraws) const; Device device_; Swapchain swapchain_; @@ -409,6 +412,19 @@ 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}; + // This frame's cascade fits, retained after `computeShadowCascades` so the caster placement + // diagnostic reasons about the SAME fit the matrices came from rather than refitting. SH-06's + // candidate query will consume these too, which is why they live here rather than inside the + // logging branch. + struct RetainedCascadeFit + { + CascadeReceiverFit receiver; + CascadeDepthFit depth; + }; + std::array, kShadowCascadeCount> cascadeFits_{}; + // Set where the fit is logged, consumed where the shadow draws exist: the two diagnostics must + // describe the same frame, and the draw list is not built yet when the fit is. + bool logShadowPlacementThisFrame_{false}; // 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. @@ -502,6 +518,11 @@ class Renderer Mat4 previousViewProj_{Mat4::identity()}; uint32_t taaJitterIndex_{0}; std::vector drawCommandScratch_; + // This frame's shadow casters, gathered before the cascade fit (SH-06) and then handed to the + // draw build as the single authority on caster bounds. Member for steady-state capacity, like + // every other per-frame scratch here; reset at the start of each prepass, never read across + // frames. + ShadowCasterBoundsFrame shadowCasterFrame_; DrawBuckets drawBucketsScratch_; std::vector frustumScratch_; std::vector lightScratch_; diff --git a/include/fire_engine/render/ubo.hpp b/include/fire_engine/render/ubo.hpp index 7b55de6..d2ba3b4 100644 --- a/include/fire_engine/render/ubo.hpp +++ b/include/fire_engine/render/ubo.hpp @@ -199,6 +199,13 @@ struct LightUBO // shader-backed values 0..8 are exactly this range. // w = disable all shadow-map visibility lookups when > 0.5. alignas(16) float environmentParams[4]{}; + // x = the cascade cross-fade fraction (`kShadowCascadeBlendFraction`). UPLOADED rather than + // duplicated as a shader literal because SH-06 made it a FITTING constraint as well as a + // shading one: the renderer expands each cascade's slice to cover its predecessor's blend band, + // and the shader decides which receivers fall in that band. Two hand-kept copies of that number + // would put receivers in a band the fit does not cover — the fixed extension's failure, in the + // other direction — so both sides now read one C++ value. y/z/w reserved. + alignas(16) float cascadeParams[4]{}; // Active light count and the packed light array. Convention: lights[0] is // the primary directional (CSM source) when one exists. The shader loops // 0..lightCount-1 and only applies CSM shadow at i==0 with type==0. @@ -224,10 +231,11 @@ static_assert(offsetof(LightUBO, iblParams) == 784, "LightUBO std140 layout"); static_assert(offsetof(LightUBO, shadowParams) == 800, "LightUBO std140 layout"); static_assert(offsetof(LightUBO, pointSpotShadowParams) == 816, "LightUBO std140 layout"); static_assert(offsetof(LightUBO, environmentParams) == 832, "LightUBO std140 layout"); -static_assert(offsetof(LightUBO, lightCount) == 848, "LightUBO std140 layout"); -static_assert(offsetof(LightUBO, lights) == 864, "LightUBO std140 layout"); +static_assert(offsetof(LightUBO, cascadeParams) == 848, "LightUBO std140 layout"); +static_assert(offsetof(LightUBO, lightCount) == 864, "LightUBO std140 layout"); +static_assert(offsetof(LightUBO, lights) == 880, "LightUBO std140 layout"); static_assert(sizeof(LightData) == 64, "LightData std140 size (4x vec4)"); -static_assert(sizeof(LightUBO) == 864 + 64 * kMaxLights, "LightUBO std140 size"); +static_assert(sizeof(LightUBO) == 880 + 64 * kMaxLights, "LightUBO std140 size"); static_assert(std::is_standard_layout_v); static_assert(std::is_trivially_copyable_v); diff --git a/include/fire_engine/scene/mesh.hpp b/include/fire_engine/scene/mesh.hpp index cbaf086..00aa07b 100644 --- a/include/fire_engine/scene/mesh.hpp +++ b/include/fire_engine/scene/mesh.hpp @@ -93,6 +93,9 @@ class Mesh [[nodiscard]] Mat4 render(const SceneDrawContext& ctx, const Mat4& world, const Mat4& previousWorld); + // SH-06 prepass: this mesh's shadow-casting bounds, in world space and in the current pose. + void gatherShadowCasters(const Mat4& world, ShadowCasterBoundsFrame& out) const; + void variantNames(std::vector names) noexcept { variantNames_ = std::move(names); diff --git a/include/fire_engine/scene/node.hpp b/include/fire_engine/scene/node.hpp index f232a91..25894b0 100644 --- a/include/fire_engine/scene/node.hpp +++ b/include/fire_engine/scene/node.hpp @@ -186,6 +186,35 @@ class Node return composedWorld_; } + // The world matrix a node is placed at, given its parent's. `update`, `resolve` and the draw + // walk all call it, so none of them can express a different rule — they did: a world-override + // is authoritative in the transform walks, while the draw walk used to recompute + // `parentWorld * local` and ignore it. That put a ragdoll-driven caster's SH-06 bounds + // (measured from `composedWorld_`) at a different pose than its draw. + // + // For a node with no component matrix — every Mesh — this equals `composedWorld()` exactly. + // Animator nodes differ by design: the component applies its own matrix on the way to the + // children, so this returns the world BEFORE it. + [[nodiscard]] Mat4 drawWorld(const Mat4& parentWorld) const noexcept + { + return worldOverride_ ? *worldOverride_ : parentWorld * transform_.local(); + } + + // The world this node hands its CHILDREN, given the one its own component produced. + // + // The second half of the same rule, and the case `drawWorld` alone does not cover. `update` and + // `resolve` return early on a world-override, which means they deliberately skip the component + // matrix too — an overridden Animator's children inherit the override itself, NOT + // `override * animation`. The draw walk applies the component matrix on the way down, so + // without this it would hand children `override * animation` while their cached bounds were + // measured at `override`: a mesh under a ragdoll-driven animator drawn at a pose nothing else + // agrees with. Physics owns an overridden node's pose outright; an animation channel underneath + // it does not get a second say. + [[nodiscard]] Mat4 childWorld(const Mat4& drawWorld, const Mat4& componentWorld) const noexcept + { + return worldOverride_ ? drawWorld : componentWorld; + } + // The node's composed world matrix from the previous update/resolve. Used // for motion vectors (TAA) and continuous-collision / constraint solving. // Equals composedWorld() on the first frame (zero motion). @@ -203,6 +232,11 @@ class Node void update(const InputState& input_state, const Mat4& parentComposedWorld); void resolve(const Mat4& parentComposedWorld); + + // SH-06 prepass: append every shadow caster in this subtree, using the world transforms the + // last `update` produced. Read-only — it must not advance or alter anything the draw walk + // depends on, since it runs before it. + void gatherShadowCasters(ShadowCasterBoundsFrame& out) const; void render(const SceneDrawContext& ctx, const Mat4& parentWorld); private: diff --git a/include/fire_engine/scene/scene_draw_context.hpp b/include/fire_engine/scene/scene_draw_context.hpp index ec5df58..038a407 100644 --- a/include/fire_engine/scene/scene_draw_context.hpp +++ b/include/fire_engine/scene/scene_draw_context.hpp @@ -20,6 +20,14 @@ class Node; struct SceneDrawContext { const FrameInfo& frame; + // This frame's caster-bounds authority (SH-06). Every shadow draw reads its own binding's entry + // from here; nothing recomputes bounds during the draw walk, so the cascade fit and the draws + // cannot describe different geometry. + // + // A REFERENCE, like `frame`: it is mandatory, and a nullable field would only move the + // requirement into a runtime check at every consumer. A traversal without it cannot be + // constructed. + const ShadowCasterBoundsFrame& shadowCasterBounds; const std::unordered_set* culledNodes{nullptr}; std::vector* drawCommands{nullptr}; // Per-frame VDPM repair accumulators (null ⇒ not gathered): the mesh render path adds each diff --git a/include/fire_engine/scene/scene_graph.hpp b/include/fire_engine/scene/scene_graph.hpp index c8d26ad..d05ff03 100644 --- a/include/fire_engine/scene/scene_graph.hpp +++ b/include/fire_engine/scene/scene_graph.hpp @@ -60,9 +60,11 @@ class SceneGraph : public RenderableScene // frustums) using the scene's own bounds, an empty span meaning "cull disabled, render all". // The culled-node set stays entirely internal; only DrawCommands cross the boundary. void gatherLights(std::vector& out) const override; + void gatherShadowCasters(ShadowCasterBoundsFrame& out) const override; void gatherEmitters(std::vector& out) const override; [[nodiscard]] CullStats buildDrawCommands(const FrameInfo& frame, std::span frustums, + const ShadowCasterBoundsFrame& casterBounds, std::vector& out) override; [[nodiscard]] CameraView activeCamera() const override; diff --git a/shaders/light_ubo.glsl b/shaders/light_ubo.glsl index d81a8b9..c0f99ca 100644 --- a/shaders/light_ubo.glsl +++ b/shaders/light_ubo.glsl @@ -51,6 +51,10 @@ layout(set = LIGHT_UBO_SET, binding = LIGHT_UBO_BINDING) uniform LightUBO { // 4=directional raw depth, 5=velocity, 6=SSAO, 7=LOD tint, 8=shadow-LOD tint), // w = disable all shadow-map visibility lookups when > 0.5. vec4 environmentParams; + // x = cascade cross-fade fraction (kShadowCascadeBlendFraction). Uploaded, not a literal here: + // the renderer expands each cascade's fitted slice to cover the previous cascade's blend band, + // so the number that decides the band and the number that fits for it must be one value. + vec4 cascadeParams; int lightCount; int _pad0; int _pad1; diff --git a/shaders/shader.frag b/shaders/shader.frag index b827450..269fea1 100644 --- a/shaders/shader.frag +++ b/shaders/shader.frag @@ -318,7 +318,7 @@ float cascadeBlendFactor(int cascade, float viewDepth) return 0.0; float cascadeStart = cascade == 0 ? 0.0 : light.cascadeSplits[cascade - 1]; float cascadeEnd = light.cascadeSplits[cascade]; - float blendBand = (cascadeEnd - cascadeStart) * 0.1; + float blendBand = (cascadeEnd - cascadeStart) * light.cascadeParams.x; float blendStart = cascadeEnd - blendBand; return clamp((viewDepth - blendStart) / blendBand, 0.0, 1.0); } diff --git a/src/graphics/object.cpp b/src/graphics/object.cpp index f394a49..98c240d 100644 --- a/src/graphics/object.cpp +++ b/src/graphics/object.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -374,45 +375,115 @@ const Bounds3& Object::localBounds() const noexcept return *localBounds_; } -Bounds3 Object::computeShadowBounds(std::span jointMatrices, bool hasSkin, - const Mat4& world) const noexcept +// One binding's world-space caster extent, in its CURRENT pose: morph weights applied, skinning +// applied. SH-06's depth fit is built on these, so the per-binding split matters — a union over an +// object's bindings would hand the fit a box containing space no caster occupies, and the range +// would be looser than the geometry justifies. +Bounds3 Object::computeBindingShadowBounds(const GeometryBindings& binding, + std::span jointMatrices, bool hasSkin, + const Mat4& world) const noexcept { Bounds3 bounds; - for (const auto& binding : bindings_) + // The caster IS the visible geometry. SH-04 removed the shadow-proxy setter, so there is no + // second geometry to prefer here; a validated proxy API would reintroduce that choice along + // with the rules that make it safe. + const Geometry* geometry = binding.geometry; + if (geometry == nullptr) { - // The caster IS the visible geometry. SH-04 removed the shadow-proxy setter, so there is - // no second geometry to prefer here; a validated proxy API would reintroduce that choice - // along with the rules that make it safe. - const Geometry* geometry = binding.geometry; - if (geometry == nullptr) - { - continue; - } + return bounds; + } - const auto& vertices = geometry->vertices(); - const auto& morphPositions = geometry->morphPositions(); - for (std::size_t v = 0; v < vertices.size(); ++v) + const auto& vertices = geometry->vertices(); + const auto& morphPositions = geometry->morphPositions(); + for (std::size_t v = 0; v < vertices.size(); ++v) + { + Vec3 position = vertices[v].position(); + for (std::size_t target = 0; + target < morphPositions.size() && target < morphWeights_.size(); ++target) { - Vec3 position = vertices[v].position(); - for (std::size_t target = 0; - target < morphPositions.size() && target < morphWeights_.size(); ++target) + if (v < morphPositions[target].size()) { - if (v < morphPositions[target].size()) - { - position += morphPositions[target][v] * morphWeights_[target]; - } + position += morphPositions[target][v] * morphWeights_[target]; } + } - Vec3 worldPosition = hasSkin ? skinnedPosition(vertices[v], position, jointMatrices) - : static_cast(world * Vec4{position}); - bounds.expand(worldPosition); + Vec3 worldPosition = hasSkin ? skinnedPosition(vertices[v], position, jointMatrices) + : static_cast(world * Vec4{position}); + if (!bounds.expandChecked(worldPosition)) + { + // A vertex nobody can bound — a NaN joint matrix, a corrupt morph delta, a degenerate + // transform. Report a box that is VALID but non-finite, which is exactly what it is: + // this caster has an extent and we cannot state it. Plain `expand` would have dropped + // the vertex and returned a finite box that does not contain the geometry, and SH-06's + // depth range would then be fitted tight around a caster it never accounted for and + // clip it. Downstream (`classifyFootprint`, the depth policy) treats a non-finite box + // as terminal rather than as a caster that is merely elsewhere. + const float nan = std::numeric_limits::quiet_NaN(); + Bounds3 corrupt{}; + corrupt.min = Vec3{nan, nan, nan}; + corrupt.max = Vec3{nan, nan, nan}; + corrupt.valid = true; + return corrupt; } } return bounds; } +bool Object::localBoundsCoverDrawnGeometry() const noexcept +{ + if (deformable()) + { + return false; + } + for (const auto& binding : bindings_) + { + if (binding.geometry != nullptr && binding.geometry->storageVertices()) + { + return false; + } + } + return true; +} + +// The SH-06 prepass: what this object contributes to the cascade depth fit, per shadow-casting +// binding, BEFORE any draw command exists — and the ONLY place a caster's world bounds are +// computed. `render` looks its bindings up in the same record rather than walking the vertices a +// second time. +// +// Non-casting bindings are omitted entirely: a binding the shadow pass will never rasterise must +// not widen the range it fits. Every casting binding is recorded even if its bounds came out +// invalid (no vertices), so the recorded set matches the set of shadow draws exactly and a lookup +// miss always means a real disagreement rather than a legitimately empty caster. +// +// A cloth binding reports `Stale`: its vertices live in a storage buffer a compute pass rewrites, +// so the CPU copy this walks is the bind pose and the drawn geometry can be anywhere. The fit is +// required to treat that as a hint rather than a bound; see `ShadowCasterBoundsKind`. +void Object::gatherShadowCasterBounds(const Mat4& world, ShadowCasterBoundsFrame& out) const +{ + // Same derivation as `render`, deliberately: the prepass must describe the pose the draw will. + const bool hasSkin = skin_ != nullptr && !skin_->empty(); + const std::vector emptyJointMatrices; + const std::vector& jointMatrices = + hasSkin ? skin_->cachedJointMatrices() : emptyJointMatrices; + + for (const auto& binding : bindings_) + { + if (!binding.castsShadow || binding.geometry == nullptr) + { + continue; + } + out.add(ShadowCasterBounds{ + .world = computeBindingShadowBounds(binding, jointMatrices, hasSkin, world), + .casterId = binding.shadowCasterId, + .generation = binding.shadowGeneration, + .kind = binding.geometry->storageVertices() ? ShadowCasterBoundsKind::Stale + : ShadowCasterBoundsKind::Exact}); + } +} + std::vector Object::render(const FrameInfo& frame, const Mat4& world, - const Mat4& previousWorld) + const Mat4& previousWorld, + const ShadowCasterBoundsFrame& casterBounds) { const bool hasSkin = skin_ != nullptr && !skin_->empty(); @@ -423,8 +494,10 @@ std::vector Object::render(const FrameInfo& frame, const Mat4& worl writeForwardUniforms(frame, world, previousWorld, hasSkin, jointMatrices); writeShadowUniforms(frame, world, hasSkin); - const Bounds3 shadowBounds = computeShadowBounds(jointMatrices, hasSkin, world); - return buildDrawCommands(frame, world, hasSkin, shadowBounds); + // NOT recomputed here. The prepass already walked these vertices with this pose, and each + // binding takes its OWN bounds from that record below — the object-wide union this used to + // build cost a second skinning pass and stamped one loose box onto every binding's command. + return buildDrawCommands(frame, world, hasSkin, casterBounds); } bool Object::vdpmGpuDrives(const FrameInfo& frame, const GeometryBindings& binding) @@ -657,8 +730,9 @@ void Object::writeShadowUniforms(const FrameInfo& frame, const Mat4& world, bool } } -std::vector Object::buildDrawCommands(const FrameInfo& frame, const Mat4& world, - bool hasSkin, const Bounds3& shadowBounds) const +std::vector +Object::buildDrawCommands(const FrameInfo& frame, const Mat4& world, bool hasSkin, + const ShadowCasterBoundsFrame& casterBounds) const { // Camera forward used to project draw centroids for back-to-front sort of // blend draws. Each mesh instance is taken as its world-translation origin @@ -755,7 +829,21 @@ std::vector Object::buildDrawCommands(const FrameInfo& frame, const cmd.sortDepth = depth; cmd.objectId = objectId_; cmd.hasSkin = hasSkin; - cmd.shadowBounds = shadowBounds; + // THIS binding's bounds, from the frame's prepass — not an object-wide union, and not a + // recomputation. `require` is terminal on a miss: an absent entry means the prepass and + // this walk disagree about what the scene contains, and a caster silently given empty + // bounds would be fitted and culled against geometry it has nothing to do with. Non-casting + // bindings never reach here (the shadow command below is gated on `castsShadow`). + // The condition mirrors the prepass's exactly, so "recorded" and "looked up" cannot drift. + if (binding.castsShadow && binding.geometry != nullptr) + { + const ShadowCasterBounds& recorded = + casterBounds.require(binding.shadowCasterId, binding.shadowGeneration); + cmd.shadowBounds = recorded.world; + // Carried, not re-derived: whether these bounds may EXCLUDE this caster is a property + // of how they were measured, and only the prepass knows that. + cmd.shadowBoundsKind = recorded.kind; + } // Bindless material index (idempotent registration — first sight assigns a // slot in the global materials[] SSBO; cached thereafter). cmd.materialIndex = resources_ != nullptr ? resources_->registerMaterial(mat) : 0; diff --git a/src/graphics/shadow_caster_bounds_frame.cpp b/src/graphics/shadow_caster_bounds_frame.cpp new file mode 100644 index 0000000..7e82544 --- /dev/null +++ b/src/graphics/shadow_caster_bounds_frame.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include +#include + +namespace fire_engine +{ + +void ShadowCasterBoundsFrame::reset() noexcept +{ + entries_.clear(); + index_.clear(); +} + +void ShadowCasterBoundsFrame::add(const ShadowCasterBounds& caster) +{ + if (caster.casterId == ShadowCasterId::Invalid) + { + throw std::runtime_error( + "shadow caster prepass: a binding reported bounds under an invalid caster id"); + } + const auto [it, inserted] = index_.emplace(caster.key(), entries_.size()); + if (!inserted) + { + throw std::runtime_error(std::format( + "shadow caster prepass: duplicate caster id {} generation {} — two bindings claim one " + "identity", + std::to_underlying(caster.casterId), std::to_underlying(caster.generation))); + } + entries_.push_back(caster); +} + +const ShadowCasterBounds* +ShadowCasterBoundsFrame::find(ShadowCasterId casterId, + ShadowCasterGeneration generation) const noexcept +{ + const auto it = index_.find(ShadowCasterKey{.casterId = casterId, .generation = generation}); + return it == index_.end() ? nullptr : &entries_[it->second]; +} + +const ShadowCasterBounds& ShadowCasterBoundsFrame::require(ShadowCasterId casterId, + ShadowCasterGeneration generation) const +{ + if (const ShadowCasterBounds* found = find(casterId, generation)) + { + return *found; + } + throw std::runtime_error(std::format( + "shadow caster prepass: no bounds recorded for caster id {} generation {} — the " + "prepass and the draw walk disagree about this frame's casters", + std::to_underlying(casterId), std::to_underlying(generation))); +} + +} // namespace fire_engine diff --git a/src/render/cascade_fit.cpp b/src/render/cascade_fit.cpp index 8f6bfa4..abfec5d 100644 --- a/src/render/cascade_fit.cpp +++ b/src/render/cascade_fit.cpp @@ -45,6 +45,91 @@ namespace } // namespace +namespace +{ + +// A caster box's extent on the cascade's light basis. One place computes this, so the footprint +// classification, the depth policy and the diagnostics cannot disagree about where a caster is. +// +// All eight corners, not a centre and a radius: an axis-aligned box is not a sphere, and the whole +// question is whether a FACE of it crosses a plane. A radius would round the box out and report +// clipping that is not there. +struct LightSpaceExtent +{ + float minU{0.0f}; + float maxU{0.0f}; + float minV{0.0f}; + float maxV{0.0f}; + float minW{0.0f}; + float maxW{0.0f}; + // False when the bounds were invalid or produced a non-finite projection. NOT the same as + // "elsewhere": NaN compares false against everything, so a corrupt box would otherwise look + // like a caster that simply sits outside the cascade. + bool usable{false}; +}; + +[[nodiscard]] LightSpaceExtent lightSpaceExtent(const CascadeReceiverFit& receiver, + const Bounds3& bounds) noexcept +{ + LightSpaceExtent extent{}; + if (!bounds.valid || !finite(bounds.min) || !finite(bounds.max)) + { + return extent; + } + const Vec3& lo = bounds.min; + const Vec3& hi = bounds.max; + for (int corner = 0; corner < 8; ++corner) + { + const Vec3 p{(corner & 1) != 0 ? hi.x() : lo.x(), (corner & 2) != 0 ? hi.y() : lo.y(), + (corner & 4) != 0 ? hi.z() : lo.z()}; + const float u = Vec3::dotProduct(p, receiver.lightRight()); + const float v = Vec3::dotProduct(p, receiver.lightUp()); + const float w = Vec3::dotProduct(p, receiver.lightDirection()); + if (corner == 0) + { + extent.minU = extent.maxU = u; + extent.minV = extent.maxV = v; + extent.minW = extent.maxW = w; + continue; + } + extent.minU = std::min(extent.minU, u); + extent.maxU = std::max(extent.maxU, u); + extent.minV = std::min(extent.minV, v); + extent.maxV = std::max(extent.maxV, v); + extent.minW = std::min(extent.minW, w); + extent.maxW = std::max(extent.maxW, w); + } + extent.usable = finite(extent.minU) && finite(extent.maxU) && finite(extent.minV) && + finite(extent.maxV) && finite(extent.minW) && finite(extent.maxW); + return extent; +} + +[[nodiscard]] CascadeFootprintRelation footprintOf(const CascadeReceiverFit& receiver, + const LightSpaceExtent& extent) noexcept +{ + if (!extent.usable) + { + return CascadeFootprintRelation::Invalid; + } + // Conservative on both boundaries: touching an edge counts as straddling, never as Outside + // (which a candidate query would reject) and never as Inside (which would claim the caster is + // wholly covered when a texel of it may not be). + const bool overlaps = extent.maxU >= receiver.minU() && extent.minU <= receiver.maxU() && + extent.maxV >= receiver.minV() && extent.minV <= receiver.maxV(); + if (!overlaps) + { + return CascadeFootprintRelation::Outside; + } + if (extent.minU > receiver.minU() && extent.maxU < receiver.maxU() && + extent.minV > receiver.minV() && extent.maxV < receiver.maxV()) + { + return CascadeFootprintRelation::Inside; + } + return CascadeFootprintRelation::Straddles; +} + +} // namespace + std::optional CascadeReceiverFit::fit(const CascadeReceiverInput& input) noexcept { @@ -189,19 +274,19 @@ std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& r 0.0f, 2.0f * receiver.radius() + 2.0f * backExtend); CascadeDepthFit depth{}; - depth.lightPosition = lightPosition; + 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; + 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)) + if (!finite(depth.nearW_) || !finite(depth.farW_) || !(depth.farW_ > depth.nearW_) || + !finite(depth.viewDepthSpan_) || !finite(depth.lightPosition_)) { return std::nullopt; } @@ -209,7 +294,7 @@ std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& r { for (int col = 0; col < 4; ++col) { - if (!finite(depth.viewProj[row, col])) + if (!finite(depth.viewProj_[row, col])) { return std::nullopt; } @@ -218,4 +303,148 @@ std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& r return depth; } +std::optional +fitCasterAwareCascadeDepth(const CascadeReceiverFit& receiver, + std::span casters, float backExtend) noexcept +{ + // One stale caster anywhere is enough to force the fallback below. Its box neither bounds the + // drawn geometry nor says which cascade that geometry affects, so it cannot be fitted to, + // excluded, or reasoned around — and fitting the rest without it can produce a range that + // clips it. + // + // The receiver volume is the floor: every receiver in this slice must be inside the range, or + // its depth comparison has nothing to compare against. + float nearW = receiver.receiverMinW(); + const float farW = receiver.receiverMaxW(); + + // ONE pass over every caster, validating as it goes and only DECIDING afterwards. Returning the + // stale fallback the moment a cloth is seen would skip validation of everything after it, so a + // frame containing both cloth and a corrupt Exact caster would silently take the fallback and + // never report the corruption — the diagnosis would name the wrong problem, and the caster with + // unknowable bounds would go unmentioned. + bool hasStale = false; + for (const ShadowCasterBounds& caster : casters) + { + if (caster.kind != ShadowCasterBoundsKind::Exact) + { + hasStale = true; + continue; + } + // A caster with no bounds contributes nothing; one with CORRUPT bounds is a different + // matter entirely and terminal. NaN comparisons are all false, so a non-finite box would + // classify as `Outside` and be skipped — the range would then tighten around a caster + // nobody accounted for, and clip it. That is the failure this policy exists to prevent, so + // it must not be reachable by silently ignoring bad input. + if (!caster.world.valid) + { + continue; + } + const CascadeFootprintRelation footprint = classifyFootprint(receiver, caster.world); + if (footprint == CascadeFootprintRelation::Invalid) + { + return std::nullopt; + } + if (footprint == CascadeFootprintRelation::Outside) + { + continue; + } + // Reach back to the furthest-upstream candidate. Only the NEAR side moves: a caster + // downstream of every receiver cannot shadow one, so extending the far plane to reach it + // would spend depth precision covering geometry that casts nothing into this slice. + nearW = std::min(nearW, lightSpaceExtent(receiver, caster.world).minW); + } + + if (hasStale) + { + std::optional fallback = fitLegacyCascadeDepth(receiver, backExtend); + if (fallback) + { + fallback->mode_ = CascadeDepthFitMode::LegacyStaleFallback; + } + return fallback; + } + + // Slack on BOTH planes, one shadow-texel's world size each — the fit's own unit rather than an + // invented epsilon. A caster or receiver sitting exactly on a plane is a boundary case in float + // arithmetic, and what this costs is a slightly wider depth span (two texel-widths of world + // space), not "a texel of depth precision" — the map's depth resolution is unrelated to its XY + // texel size. + nearW -= receiver.worldPerTexel(); + const float paddedFarW = farW + receiver.worldPerTexel(); + + if (!finite(nearW) || !finite(paddedFarW) || !(paddedFarW > nearW)) + { + return std::nullopt; + } + + // The matrix itself is built exactly as the legacy fit builds it — same `lookAt`, same `ortho`, + // same order — so the ONLY difference between the two policies is where the planes are. + const float halfDepth = 0.5f * (paddedFarW - nearW); + const float centreW = 0.5f * (paddedFarW + nearW); + const Vec3 rangeCentre = + receiver.snappedCentre() + receiver.lightDirection() * (centreW - receiver.centreW()); + const Vec3 lightPosition = rangeCentre - receiver.lightDirection() * halfDepth; + const Mat4 lightView = Mat4::lookAt(lightPosition, rangeCentre, receiver.lightUp()); + const Mat4 lightProj = Mat4::ortho(-receiver.radius(), receiver.radius(), -receiver.radius(), + receiver.radius(), 0.0f, paddedFarW - nearW); + + CascadeDepthFit depth{}; + depth.mode_ = CascadeDepthFitMode::CasterAware; + depth.nearW_ = nearW; + depth.farW_ = paddedFarW; + depth.lightPosition_ = lightPosition; + depth.viewDepthSpan_ = paddedFarW - nearW; + depth.viewProj_ = lightProj * lightView; + + if (!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; +} + +CascadeFootprintRelation classifyFootprint(const CascadeReceiverFit& receiver, + const Bounds3& casterBounds) noexcept +{ + return footprintOf(receiver, lightSpaceExtent(receiver, casterBounds)); +} + +CascadeCasterPlacement placeCaster(const CascadeReceiverFit& receiver, const CascadeDepthFit& depth, + const Bounds3& casterBounds) noexcept +{ + const LightSpaceExtent extent = lightSpaceExtent(receiver, casterBounds); + CascadeCasterPlacement placement{}; + placement.footprint = footprintOf(receiver, extent); + if (!extent.usable) + { + // Every flag false and zero extents. A caster with no usable bounds has no position to + // report, and inventing one from the sentinels would place it at infinity. + return placement; + } + + placement.minU = extent.minU; + placement.maxU = extent.maxU; + placement.minV = extent.minV; + placement.maxV = extent.maxV; + placement.minW = extent.minW; + placement.maxW = extent.maxW; + placement.clippedNear = placement.minW < depth.nearW(); + placement.clippedFar = placement.maxW > depth.farW(); + placement.insideDepth = !placement.clippedNear && !placement.clippedFar; + // Wholly on one side of the range. Checked against the OPPOSITE bound of each pair, so a caster + // straddling the range reports as clipped rather than outside. + placement.outsideDepth = placement.maxW < depth.nearW() || placement.minW > depth.farW(); + return placement; +} + } // namespace fire_engine diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 80a9158..3cd82a9 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -474,10 +474,52 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca kShadowCascadeSplitLambda * logSplit + (1.0f - kShadowCascadeSplitLambda) * linear; } + // Cleared with the views, for the same reason: a stale fit read next frame would describe a + // camera that has moved. + cascadeFits_.fill(std::nullopt); // Disengage every view before the first producer writes: an inactive slot must not be readable // as this frame's. This is the ONE reset, and it must stay ahead of every populate below. shadowViews_.reset(); + if (logShadowPlacementThisFrame_ || ((cascadeFitLogCounter_ % 120) == 0)) + { + std::size_t exact = 0; + std::size_t stale = 0; + std::size_t unbounded = 0; + Bounds3 union3{}; + for (const ShadowCasterBounds& caster : shadowCasterFrame_.entries()) + { + (caster.kind == ShadowCasterBoundsKind::Exact ? exact : stale)++; + if (!caster.world.valid) + { + ++unbounded; + continue; + } + union3.expand(caster.world.min); + union3.expand(caster.world.max); + } + // The union is only a coordinate when something contributed to it. An empty (or wholly + // unbounded) caster set leaves `Bounds3`'s max/lowest sentinels, and printing those reads + // as a measurement of a scene stretching to the float limits. + if (union3.valid) + { + log::debug( + log::category::render, + "shadow caster prepass: {} casters ({} exact, {} stale, {} without bounds) | " + "world union ({:.2f}, {:.2f}, {:.2f}) .. ({:.2f}, {:.2f}, {:.2f})", + shadowCasterFrame_.size(), exact, stale, unbounded, union3.min.x(), union3.min.y(), + union3.min.z(), union3.max.x(), union3.max.y(), union3.max.z()); + } + else + { + log::debug( + log::category::render, + "shadow caster prepass: {} casters ({} exact, {} stale, {} without bounds) | " + "no world union — nothing contributed bounds this frame", + shadowCasterFrame_.size(), exact, stale, unbounded); + } + } + // 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 @@ -490,6 +532,7 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // capture line, not the first. const bool captureFitFrame = captureWanted() && (framesRendered_ + 1) == captureFrame_; const bool logFit = ((++cascadeFitLogCounter_ % 120) == 1) || captureFitFrame; + logShadowPlacementThisFrame_ = logFit; float sliceNear = kCameraNearPlane; for (uint32_t i = 0; i < kShadowCascadeCount; ++i) @@ -510,8 +553,11 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca { rejectedCascadeFit(i); } - const std::optional depth = - fitLegacyCascadeDepth(*receiver, kShadowDepthBackExtend); + // SH-06: the depth range comes from this frame's casters, not from a fixed extension. The + // prepass ran before this — it has to, since these matrices decide the frustums the draw + // walk is culled against — so the caster set is already known. + const std::optional depth = fitCasterAwareCascadeDepth( + *receiver, shadowCasterFrame_.entries(), kShadowDepthBackExtend); if (!depth) { rejectedCascadeFit(i); @@ -519,7 +565,8 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // 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, + cascadeFits_[i] = RetainedCascadeFit{*receiver, *depth}; + if (!shadowViews_.setCascade(i, depth->viewProj(), ShadowView::orthographic(receiver->worldPerTexel()))) { rejectedShadowView(std::format("cascade {}", i)); @@ -529,22 +576,32 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // 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()); + 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(), + cascadeDepthFitModeName(depth->mode()), depth->nearW(), depth->farW(), + depth->viewDepthSpan(), depth->lightPosition().x(), depth->lightPosition().y(), + depth->lightPosition().z()); } out.cascadeSplits[i] = splits[i]; - sliceNear = splits[i]; + // The NEXT cascade starts inside this one's blend band, not at the hard split. The forward + // shader cross-fades into cascade i+1 over the last `kShadowCascadeBlendFraction` of + // cascade i's range, so those receivers sample i+1's map — and since SH-06 fits each + // cascade tightly to its own slice, starting i+1 at the split would leave exactly those + // receivers outside the rectangle and the depth range they are being sampled from. + // + // The band is measured from the SHADER's notion of where this cascade starts, which is 0 + // for cascade 0 rather than the camera near plane (`cascadeBlendFactor`). + const float shaderStart = i == 0 ? 0.0f : splits[i - 1]; + sliceNear = splits[i] - kShadowCascadeBlendFraction * (splits[i] - shaderStart); } // Derived, not copied alongside: the forward shader's cascade lookup reads back out of the set, @@ -640,6 +697,10 @@ void Renderer::writeIblAndDebugParams(LightUBO& out) const tunables_.debugView == DebugView::Joints ? DebugView::None : tunables_.debugView; out.environmentParams[2] = static_cast(shaderView); out.environmentParams[3] = tunables_.noShadows ? 1.0f : 0.0f; + // The SAME constant the cascade fit expands each slice by. Uploaded rather than duplicated as a + // shader literal: the fit covers the band, the shader decides who is in it, and two hand-kept + // copies would disagree about where it starts. + out.cascadeParams[0] = kShadowCascadeBlendFraction; } void Renderer::assignSelfShadowSlots(std::span drawCommands) @@ -932,6 +993,10 @@ void Renderer::updateFrameLighting(RenderableScene& scene, Vec3 cameraPosition, const float aspect = static_cast(extent.width) / static_cast(extent.height); scene.gatherLights(lightScratch_); + // SH-06 prepass, BEFORE the cascade fit: the fit is about to depend on where the casters are, + // and the draw list that would otherwise report them does not exist until after the fit has + // decided the frustums it will be culled against. + scene.gatherShadowCasters(shadowCasterFrame_); updateLightData(cameraPosition, cameraTarget, aspect, lightScratch_); } @@ -1023,7 +1088,8 @@ const Renderer::DrawBuckets& Renderer::collectDrawCommands(RenderableScene& scen pushGroup(ShadowViewGroup::Point); } - const CullStats cull = scene.buildDrawCommands(frame, frustumScratch_, drawCommandScratch_); + const CullStats cull = + scene.buildDrawCommands(frame, frustumScratch_, shadowCasterFrame_, drawCommandScratch_); stats_.trackedNodes = static_cast(cull.tracked); stats_.culledNodes = static_cast(cull.culled); stats_.vdpmFoldoversRepaired = static_cast(cull.vdpmFoldoversRepaired); @@ -1118,8 +1184,61 @@ const Renderer::DrawBuckets& Renderer::collectDrawCommands(RenderableScene& scen return drawBucketsScratch_; } +// SH-06 evidence: where every shadow caster sits relative to every cascade, in that cascade's own +// light space. Logged on the SAME frames as the cascade fit — periodic plus the `--capture-frame` +// frame — so a capture and its explanation describe one submitted frame. +// +// It separates a caster CLIPPED by the fitted depth range from one whose light-space footprint sits +// outside the cascade, which a picture cannot: the first shrinks a shadow (measured on +// `ShadowDepthClipDemo`: 14% linearly, 26% by area, exactly the cap the near plane removes from the +// recorded back-face surface), the second removes it. Both were candidate explanations for the +// half-ellipse reported against `ShadowLodMotionDemo`, and neither had been measured. +// +// PLACEMENT ONLY. This runs before `ShadowDrawFilter`, so it says where a caster IS, never whether +// this cascade rasterised it — those are different questions and a straddling caster is perfectly +// ordinary (light rays preserve U/V, so the part outside the rectangle cannot shadow anything +// inside it). Attributing a missing shadow needs the pass's own drawn verdict beside this. +void Renderer::logShadowCasterPlacement(std::span shadowDraws) const +{ + constexpr std::array kFootprintNames{"invalid", "outside", "inside", + "straddles"}; + for (std::uint32_t cascade = 0; cascade < kShadowCascadeCount; ++cascade) + { + const auto& fit = cascadeFits_[cascade]; + if (!fit) + { + continue; + } + for (const DrawCommand& dc : shadowDraws) + { + const CascadeCasterPlacement placement = + placeCaster(fit->receiver, fit->depth, dc.shadowBounds); + // The caster IDENTITY, not just the object: one object can hold several caster + // bindings, and the shadow state (hysteresis, drawn history) is keyed on the pair. + log::debug(log::category::render, + "cascade {} caster {}/{} (object {}): U [{:.3f}, {:.3f}] V [{:.3f}, {:.3f}] " + "W [{:.3f}, {:.3f}] | cascade U [{:.3f}, {:.3f}] V [{:.3f}, {:.3f}] depth W " + "[{:.3f}, {:.3f}] | footprint={} insideDepth={} clippedNear={} " + "clippedFar={} outsideDepth={}", + cascade, std::to_underlying(dc.shadowRequest.casterId), + std::to_underlying(dc.shadowRequest.generation), dc.objectId, placement.minU, + placement.maxU, placement.minV, placement.maxV, placement.minW, + placement.maxW, fit->receiver.minU(), fit->receiver.maxU(), + fit->receiver.minV(), fit->receiver.maxV(), fit->depth.nearW(), + fit->depth.farW(), + kFootprintNames[static_cast(placement.footprint)], + placement.insideDepth, placement.clippedNear, placement.clippedFar, + placement.outsideDepth); + } + } +} + void Renderer::recordShadowPass(vk::CommandBuffer cmd, const DrawBuckets& buckets) { + if (logShadowPlacementThisFrame_) + { + logShadowCasterPlacement(buckets.shadow); + } std::span pointCasterSpan{ pointCasters_.data(), static_cast(activePointCasters_)}; // Self-shadow slots are assigned densely (assignSelfShadowSlots), so the diff --git a/src/render/shadows.cpp b/src/render/shadows.cpp index b512ac8..190f6d5 100644 --- a/src/render/shadows.cpp +++ b/src/render/shadows.cpp @@ -57,7 +57,20 @@ struct ShadowDrawFilter { return false; } - return frustum == nullptr || frustum->intersects(dc.shadowBounds); + if (frustum == nullptr) + { + return true; + } + // A caster whose bounds are STALE (cloth: a compute pass rewrites the vertices this box was + // measured from) cannot be rejected by them. The box says roughly where the caster was in + // its bind pose and nothing about where the drawn geometry is, so a frustum test against it + // can only produce false rejections — a cloth that is genuinely in this view, dropped. It + // is admitted until storage geometry carries a conservative envelope of its own. + if (dc.shadowBoundsKind != ShadowCasterBoundsKind::Exact) + { + return true; + } + return frustum->intersects(dc.shadowBounds); } }; diff --git a/src/scene/mesh.cpp b/src/scene/mesh.cpp index 654ed93..0316adb 100644 --- a/src/scene/mesh.cpp +++ b/src/scene/mesh.cpp @@ -118,9 +118,14 @@ bool Mesh::isSelectableVariantState(int state) const noexcept return object_.wouldChangeVariant(candidate); } +void Mesh::gatherShadowCasters(const Mat4& world, ShadowCasterBoundsFrame& out) const +{ + object_.gatherShadowCasterBounds(world, out); +} + Mat4 Mesh::render(const SceneDrawContext& ctx, const Mat4& world, const Mat4& previousWorld) { - auto commands = object_.render(ctx.frame, world, previousWorld); + auto commands = object_.render(ctx.frame, world, previousWorld, ctx.shadowCasterBounds); if (ctx.drawCommands != nullptr) { ctx.drawCommands->insert(ctx.drawCommands->end(), commands.begin(), commands.end()); diff --git a/src/scene/node.cpp b/src/scene/node.cpp index 5a54bed..7fcc021 100644 --- a/src/scene/node.cpp +++ b/src/scene/node.cpp @@ -17,11 +17,15 @@ Node& Node::addChild(std::unique_ptr child) void Node::update(const InputState& input_state, const Mat4& parentComposedWorld) { - // A world-override (ragdoll drive) is authoritative: the physics body's world - // pose is the composed world, bypassing the parent chain and local transform. + // A world-override (ragdoll drive) is authoritative: the physics body's world pose IS the + // composed world, bypassing the parent chain, the local transform and the component matrix. + // The early return is about SIDE EFFECTS — an overridden node runs no controllable, no + // transform update and no component update, because physics has already decided where it is. + // The matrices themselves come from the shared helpers below, so this walk and the draw walk + // cannot express different rules. if (worldOverride_) { - setComposedWorld(*worldOverride_); + setComposedWorld(drawWorld(parentComposedWorld)); for (auto& child : children_) { child->update(input_state, composedWorld_); @@ -41,9 +45,10 @@ void Node::update(const InputState& input_state, const Mat4& parentComposedWorld visitComponent([&input_state, this](auto& component) { component.update(input_state, transform_); }); - // Composed world includes component effects (e.g. Animator's model matrix) - Mat4 componentMatrix = componentModelMatrix(component_); - setComposedWorld(parentComposedWorld * transform_.local() * componentMatrix); + // Composed world includes component effects (e.g. Animator's model matrix) — and is what + // children inherit, which is exactly what `childWorld` decides for the draw walk. + const Mat4 world = drawWorld(parentComposedWorld); + setComposedWorld(childWorld(world, world * componentModelMatrix(component_))); for (auto& child : children_) { @@ -51,11 +56,27 @@ void Node::update(const InputState& input_state, const Mat4& parentComposedWorld } } +void Node::gatherShadowCasters(ShadowCasterBoundsFrame& out) const +{ + // `composedWorld_` is what the last update left, which is the same matrix the draw walk will + // hand the object — the prepass and the draw therefore describe one pose, not two. + if (const auto* mesh = componentAs()) + { + mesh->gatherShadowCasters(composedWorld_, out); + } + for (const auto& child : children_) + { + child->gatherShadowCasters(out); + } +} + void Node::resolve(const Mat4& parentComposedWorld) { + // Same rule, same helpers as `update` and the draw walk — see `Node::drawWorld` / + // `Node::childWorld`. if (worldOverride_) { - setComposedWorld(*worldOverride_); + setComposedWorld(drawWorld(parentComposedWorld)); for (auto& child : children_) { child->resolve(composedWorld_); @@ -65,8 +86,8 @@ void Node::resolve(const Mat4& parentComposedWorld) transform_.update(parentComposedWorld); - Mat4 componentMatrix = componentModelMatrix(component_); - setComposedWorld(parentComposedWorld * transform_.local() * componentMatrix); + const Mat4 world = drawWorld(parentComposedWorld); + setComposedWorld(childWorld(world, world * componentModelMatrix(component_))); for (auto& child : children_) { @@ -91,17 +112,28 @@ void Node::setComposedWorld(const Mat4& newComposedWorld) noexcept void Node::render(const SceneDrawContext& ctx, const Mat4& parentWorld) { - Mat4 world = parentWorld * transform_.local(); - - // The scene culler may have found this node outside every frustum. Skip its - // draw-building (no UBO writes, no per-vertex shadow bounds) but still recurse — - // children have independent bounds. Mesh::render returns `world` for children, so - // skipping it leaves childWorld == world. + // ONE transform source, shared with `update` / `resolve` / the shadow-caster prepass — see + // `Node::drawWorld`. This walk used to recompute `parentWorld * local` and ignore a + // world-override, which drew a ragdoll-driven caster at a different pose than the one its + // SH-06 bounds were measured at. + Mat4 world = drawWorld(parentWorld); + + // The scene culler may have found this node outside every frustum. Skip its draw-building (no + // UBO writes, no draw commands) but still recurse — children have independent bounds. + // Shadow-caster bounds are NOT skipped by this: the prepass walks separately and + // unconditionally, because a caster outside the camera frustum still casts into it. + // + // The inherited transform is derived the SAME way as below, from the component matrix rather + // than from running the component. Handing `world` down directly would have been an escape from + // the shared rule: it happens to be identical today only because the culler marks Mesh nodes, + // whose component matrix is identity — a culler that ever tracked an Animator parent would + // silently reintroduce the mismatch this rule exists to remove. if (ctx.culledNodes != nullptr && ctx.culledNodes->contains(this)) { + const Mat4 inherited = childWorld(world, world * componentModelMatrix(component_)); for (auto& child : children_) { - child->render(ctx, world); + child->render(ctx, inherited); } return; } @@ -110,28 +142,36 @@ void Node::render(const SceneDrawContext& ctx, const Mat4& parentWorld) // render(ctx, world); the rest (Empty, Camera, Light) are no-ops that just // pass the world matrix down, handled here instead of each defining a // trivial render(). - Mat4 childWorld = visitComponent( - [&ctx, &world, this](auto& component) -> Mat4 - { - // Geometry components (Mesh) take the previous world too, for motion - // vectors (TAA). Others keep the 2-arg form; the rest are no-ops. - if constexpr (requires { component.render(ctx, world, previousComposedWorld_); }) - { - return component.render(ctx, world, previousComposedWorld_); - } - else if constexpr (requires { component.render(ctx, world); }) - { - return component.render(ctx, world); - } - else + // The component's world has NO NAME on purpose: it exists only as the argument to `childWorld`, + // so it cannot be handed to the children by accident. An overridden node still emits its + // component's render work — draws are recorded as usual — but does not let the result move + // anything below it, the same rule `update` and `resolve` apply by returning early. (Nothing is + // "advanced" here: `Animator::render` is pure, and an overridden node's `update` is skipped + // entirely, so its animation clock does not run.) + const Mat4 childWorldMatrix = childWorld( + world, + visitComponent( + [&ctx, &world, this](auto& component) -> Mat4 { - return world; - } - }); + // Geometry components (Mesh) take the previous world too, for motion + // vectors (TAA). Others keep the 2-arg form; the rest are no-ops. + if constexpr (requires { component.render(ctx, world, previousComposedWorld_); }) + { + return component.render(ctx, world, previousComposedWorld_); + } + else if constexpr (requires { component.render(ctx, world); }) + { + return component.render(ctx, world); + } + else + { + return world; + } + })); for (auto& child : children_) { - child->render(ctx, childWorld); + child->render(ctx, childWorldMatrix); } } diff --git a/src/scene/scene_culler.cpp b/src/scene/scene_culler.cpp index 162a168..babe9d8 100644 --- a/src/scene/scene_culler.cpp +++ b/src/scene/scene_culler.cpp @@ -32,12 +32,18 @@ namespace return AABB{lo, hi}; } -// A rigid renderable node is one carrying a Mesh whose geometry does not deform and has -// a valid local bound — exactly the nodes the BVH can cull by a transformed AABB. +// A cullable renderable node carries a Mesh whose LOCAL BOUND actually contains what it draws, and +// whose bound is valid — exactly the nodes the BVH can cull by a transformed AABB. +// +// The predicate is `localBoundsCoverDrawnGeometry`, not `deformable`: cloth is not "deformable" in +// the instance sense (no skin, no morph weights) yet a compute pass rewrites its vertex buffer +// every frame, so its bind-pose bound is not where the cloth is. Culling by it dropped cloth that +// was on screen. [[nodiscard]] const Mesh* cullableMesh(const Node& node) { const Mesh* mesh = node.componentAs(); - if (mesh == nullptr || mesh->object().deformable() || !mesh->object().localBounds().valid) + if (mesh == nullptr || !mesh->object().localBoundsCoverDrawnGeometry() || + !mesh->object().localBounds().valid) { return nullptr; } diff --git a/src/scene/scene_graph.cpp b/src/scene/scene_graph.cpp index 03ddb92..0418b2d 100644 --- a/src/scene/scene_graph.cpp +++ b/src/scene/scene_graph.cpp @@ -145,6 +145,7 @@ void SceneGraph::applyPhysics(const PhysicsWorld& physics, float alpha) } CullStats SceneGraph::buildDrawCommands(const FrameInfo& frame, std::span frustums, + const ShadowCasterBoundsFrame& casterBounds, std::vector& out) { // Cull only when the renderer supplied frustums; an empty span means culling is disabled, so @@ -160,6 +161,7 @@ CullStats SceneGraph::buildDrawCommands(const FrameInfo& frame, std::spangatherShadowCasters(out); + } +} + void SceneGraph::gatherLights(std::vector& out) const { out.clear(); diff --git a/tests/graphics/test_object_shadow_casters.cpp b/tests/graphics/test_object_shadow_casters.cpp new file mode 100644 index 0000000..b806378 --- /dev/null +++ b/tests/graphics/test_object_shadow_casters.cpp @@ -0,0 +1,262 @@ +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using Catch::Approx; +using fire_engine::Geometry; +using fire_engine::Mat4; +using fire_engine::Material; +using fire_engine::Object; +using fire_engine::ShadowCasterBoundsFrame; +using fire_engine::ShadowCasterBoundsKind; +using fire_engine::Vec3; +using fire_engine::Vertex; + +// The SH-06 prepass, at the level that actually decides what the depth fit sees. Headless: the walk +// reads CPU vertex data and world matrices only, so no GPU device is involved. +namespace +{ + +// A unit box of vertices centred on `centre`, so a binding's extent is known exactly. +[[nodiscard]] std::vector boxVertices(Vec3 centre) +{ + std::vector vertices; + for (int corner = 0; corner < 8; ++corner) + { + Vertex v{}; + v.position(centre + Vec3{(corner & 1) != 0 ? 1.0f : -1.0f, (corner & 2) != 0 ? 1.0f : -1.0f, + (corner & 4) != 0 ? 1.0f : -1.0f}); + vertices.push_back(v); + } + return vertices; +} + +struct Mesh +{ + Material material{}; + Geometry geometry{}; + + explicit Mesh(Vec3 centre) + { + geometry.material(&material); + geometry.vertices(boxVertices(centre)); + } +}; + +} // namespace + +TEST_CASE("ObjectShadowCasters.DisjointBindingsStaySeparate", "[Object][ShadowCasterBounds]") +{ + // The defect this replaces: the draw path computed an object-WIDE union and stamped it onto + // every binding's command. Two bindings ten metres apart would each claim a box spanning both. + Mesh left{Vec3{-10.0f, 0.0f, 0.0f}}; + Mesh right{Vec3{10.0f, 0.0f, 0.0f}}; + Object object; + object.addGeometry(left.geometry); + object.addGeometry(right.geometry); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 2u); + const auto entries = frame.entries(); + // Each entry spans its own box (2 units wide), not the 22-unit union of both. + for (const auto& entry : entries) + { + CHECK(entry.world.valid); + CHECK(entry.world.max.x() - entry.world.min.x() == Approx(2.0f)); + CHECK(entry.kind == ShadowCasterBoundsKind::Exact); + } + CHECK(entries[0].casterId != entries[1].casterId); +} + +TEST_CASE("ObjectShadowCasters.NonCastingBindingsAreAbsent", "[Object][ShadowCasterBounds]") +{ + // A binding the shadow pass will never rasterise must not widen the range the fit computes. + Mesh caster{Vec3{0.0f, 0.0f, 0.0f}}; + Mesh receiverOnly{Vec3{100.0f, 0.0f, 0.0f}}; + Object object; + object.addGeometry(caster.geometry, true); + object.addGeometry(receiverOnly.geometry, false); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 1u); + // The far-away receive-only box would have dragged the union out to x = 101. + CHECK(frame.entries()[0].world.max.x() == Approx(1.0f)); +} + +TEST_CASE("ObjectShadowCasters.BoundsFollowTheWorldTransform", "[Object][ShadowCasterBounds]") +{ + Mesh mesh{Vec3{0.0f, 0.0f, 0.0f}}; + Object object; + object.addGeometry(mesh.geometry); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::translate({5.0f, 0.0f, 0.0f}), frame); + + REQUIRE(frame.size() == 1u); + CHECK(frame.entries()[0].world.min.x() == Approx(4.0f)); + CHECK(frame.entries()[0].world.max.x() == Approx(6.0f)); +} + +TEST_CASE("ObjectShadowCasters.MorphedGeometryIsExactAtItsCurrentWeights", + "[Object][ShadowCasterBounds]") +{ + // Morph deltas are applied by the walk, so the bounds describe the pose that will DRAW — which + // is what makes them `Exact` even though the mesh is not in its bind pose. + Mesh mesh{Vec3{0.0f, 0.0f, 0.0f}}; + std::vector deltas(mesh.geometry.vertices().size(), Vec3{0.0f, 3.0f, 0.0f}); + mesh.geometry.morphPositions({deltas}); + Object object; + object.addGeometry(mesh.geometry); + object.morphWeights(std::vector{1.0f}); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 1u); + CHECK(frame.entries()[0].kind == ShadowCasterBoundsKind::Exact); + // Fully weighted, so the whole box moved up by the delta. + CHECK(frame.entries()[0].world.min.y() == Approx(2.0f)); + CHECK(frame.entries()[0].world.max.y() == Approx(4.0f)); +} + +TEST_CASE("ObjectShadowCasters.StorageVertexGeometryIsStale", "[Object][ShadowCasterBounds]") +{ + // Cloth. Nothing about the INSTANCE deforms — no skin, no morph weights — but a compute pass + // rewrites the vertex buffer every frame, so the CPU copy this walk reads is the bind pose. + // The bounds are still reported (they are useful evidence) and marked as what they are. + Mesh cloth{Vec3{0.0f, 0.0f, 0.0f}}; + cloth.geometry.storageVertices(true); + Object object; + object.addGeometry(cloth.geometry); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 1u); + CHECK(frame.entries()[0].kind == ShadowCasterBoundsKind::Stale); + CHECK(frame.entries()[0].world.valid); +} + +TEST_CASE("ObjectShadowCasters.ClothIsNotCulledByItsBindPoseBound", "[Object][ShadowCasterBounds]") +{ + // `deformable()` answers "does this instance carry a skin or morph weights", and cloth answers + // no — which is correct for classifying a sibling binding's shadow deformation, and wrong for + // deciding whether the coarse cull may reject the node. The separate predicate keeps both + // answers available instead of broadening one of them. + Mesh cloth{Vec3{0.0f, 0.0f, 0.0f}}; + cloth.geometry.storageVertices(true); + Object clothObject; + clothObject.addGeometry(cloth.geometry); + + CHECK_FALSE(clothObject.deformable()); + CHECK_FALSE(clothObject.localBoundsCoverDrawnGeometry()); + + Mesh rigid{Vec3{0.0f, 0.0f, 0.0f}}; + Object rigidObject; + rigidObject.addGeometry(rigid.geometry); + CHECK(rigidObject.localBoundsCoverDrawnGeometry()); +} + +TEST_CASE("ObjectShadowCasters.TwoObjectsSharingOneMeshGetTheirOwnEntries", + "[Object][ShadowCasterBounds]") +{ + // One Geometry, two instances at different places: the prepass must key on the BINDING, not the + // geometry, or the second instance would collide with the first and the frame would reject it. + Mesh shared{Vec3{0.0f, 0.0f, 0.0f}}; + Object first; + first.addGeometry(shared.geometry); + Object second; + second.addGeometry(shared.geometry); + + ShadowCasterBoundsFrame frame; + frame.reset(); + first.gatherShadowCasterBounds(Mat4::identity(), frame); + second.gatherShadowCasterBounds(Mat4::translate({20.0f, 0.0f, 0.0f}), frame); + + REQUIRE(frame.size() == 2u); + CHECK(frame.entries()[0].world.max.x() == Approx(1.0f)); + CHECK(frame.entries()[1].world.max.x() == Approx(21.0f)); +} + +TEST_CASE("ObjectShadowCasters.ACorruptVertexIsReportedNotSwallowed", + "[Object][ShadowCasterBounds]") +{ + // The prepass walks vertices with `Bounds3::expandChecked`, not `expand`, because std::min / + // std::max return the OTHER operand when one side is NaN: a corrupt vertex would otherwise + // leave a perfectly finite box that does not contain the geometry it claims to, and SH-06's + // depth range would be fitted tight around a caster it never accounted for — and clip it. + Mesh mesh{Vec3{0.0f, 0.0f, 0.0f}}; + auto vertices = mesh.geometry.vertices(); + REQUIRE(vertices.size() > 1u); + vertices[1].position(Vec3{std::numeric_limits::quiet_NaN(), 0.0f, 0.0f}); + mesh.geometry.vertices(vertices); + + Object object; + object.addGeometry(mesh.geometry); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 1u); + const auto& recorded = frame.entries()[0]; + // VALID but non-finite: this caster has an extent and the engine cannot state it. Distinct from + // a binding with no vertices, which is invalid and contributes nothing. + CHECK(recorded.world.valid); + CHECK_FALSE(std::isfinite(recorded.world.min.x())); + + // And that is terminal for the depth policy rather than a caster it quietly skips. + const fire_engine::CascadeReceiverInput input{.cameraPosition = Vec3{0.0f, 2.0f, 8.0f}, + .cameraTarget = Vec3{0.0f, 1.0f, 0.0f}, + .lightDirection = + Vec3::normalise(Vec3{1.0f, -1.0f, 1.0f}), + .fovRadians = fire_engine::kCameraFovRadians, + .aspect = 4.0f / 3.0f, + .sliceNear = 1.0f, + .sliceFar = 12.0f, + .shadowMapExtent = fire_engine::kShadowMapExtent}; + const auto receiver = fire_engine::CascadeReceiverFit::fit(input); + REQUIRE(receiver); + CHECK_FALSE(fire_engine::fitCasterAwareCascadeDepth(*receiver, frame.entries(), + fire_engine::kShadowDepthBackExtend) + .has_value()); +} + +TEST_CASE("ObjectShadowCasters.EmptyGeometryIsDistinctFromCorrupt", "[Object][ShadowCasterBounds]") +{ + // No vertices: no extent to state, invalid bounds, and the policy simply skips it — the + // difference the checked build exists to preserve. + Material material{}; + Geometry empty{}; + empty.material(&material); + Object object; + object.addGeometry(empty); + + ShadowCasterBoundsFrame frame; + frame.reset(); + object.gatherShadowCasterBounds(Mat4::identity(), frame); + + REQUIRE(frame.size() == 1u); + CHECK_FALSE(frame.entries()[0].world.valid); +} diff --git a/tests/graphics/test_shadow_caster_bounds_frame.cpp b/tests/graphics/test_shadow_caster_bounds_frame.cpp new file mode 100644 index 0000000..cb22f2f --- /dev/null +++ b/tests/graphics/test_shadow_caster_bounds_frame.cpp @@ -0,0 +1,166 @@ +#include + +#include + +using fire_engine::Bounds3; +using fire_engine::ShadowCasterBounds; +using fire_engine::ShadowCasterBoundsFrame; +using fire_engine::ShadowCasterBoundsKind; +using fire_engine::ShadowCasterGeneration; +using fire_engine::ShadowCasterId; +using fire_engine::Vec3; + +namespace +{ + +[[nodiscard]] Bounds3 boxAt(float x) +{ + Bounds3 b{}; + b.expand(Vec3{x - 1.0f, -1.0f, -1.0f}); + b.expand(Vec3{x + 1.0f, 1.0f, 1.0f}); + return b; +} + +[[nodiscard]] ShadowCasterBounds +caster(std::uint32_t id, float x, ShadowCasterBoundsKind kind = ShadowCasterBoundsKind::Exact, + ShadowCasterGeneration generation = ShadowCasterGeneration::First) +{ + return ShadowCasterBounds{.world = boxAt(x), + .casterId = static_cast(id), + .generation = generation, + .kind = kind}; +} + +} // namespace + +// The frame is the SH-06 authority on caster bounds: computed once per frame, read by the fit, the +// draw build and the diagnostics. Everything below is about that word "once" — the failure it +// replaces was a second, independent computation that produced a looser answer. +TEST_CASE("ShadowCasterBoundsFrame.KeepsEachCasterSeparateAndFindable", "[ShadowCasterBounds]") +{ + ShadowCasterBoundsFrame frame; + frame.reset(); + frame.add(caster(7, 10.0f)); + frame.add(caster(9, -4.0f)); + + REQUIRE(frame.size() == 2u); + // Disjoint bindings stay disjoint. An object-wide union — what the draw path used to build — + // would have handed both of these a box spanning from -5 to 11, containing space neither caster + // occupies, and the depth fit would then be looser than the geometry justifies. + const auto& first = + frame.require(static_cast(7), ShadowCasterGeneration::First); + const auto& second = + frame.require(static_cast(9), ShadowCasterGeneration::First); + CHECK(first.world.min.x() == 9.0f); + CHECK(first.world.max.x() == 11.0f); + CHECK(second.world.min.x() == -5.0f); + CHECK(second.world.max.x() == -3.0f); +} + +TEST_CASE("ShadowCasterBoundsFrame.GenerationIsPartOfTheIdentity", "[ShadowCasterBounds]") +{ + ShadowCasterBoundsFrame frame; + frame.reset(); + frame.add(caster(3, 0.0f, ShadowCasterBoundsKind::Exact, ShadowCasterGeneration::First)); + // Same slot, next generation: a DIFFERENT caster, and it must not find the previous one's box. + const auto nextGeneration = static_cast( + static_cast(ShadowCasterGeneration::First) + 1); + frame.add(caster(3, 20.0f, ShadowCasterBoundsKind::Exact, nextGeneration)); + + CHECK(frame.size() == 2u); + CHECK(frame.require(static_cast(3), ShadowCasterGeneration::First) + .world.max.x() == 1.0f); + CHECK(frame.require(static_cast(3), nextGeneration).world.max.x() == 21.0f); +} + +// The key is a real pair, not two values shifted into one integer. Both halves are 64-bit, so any +// packing loses information — and a collision here is not a slow lookup, it is one caster silently +// receiving another's bounds and being fitted and culled against them. +TEST_CASE("ShadowCasterBoundsFrame.DistinctIdentitiesNeverCollide", "[ShadowCasterBounds]") +{ + ShadowCasterBoundsFrame frame; + frame.reset(); + + // The adversarial pair for a `(id << 32) | generation` packing: both collapse to + // 0x0000'0003'0000'0000 under it — (2 << 32) | 2^32 and (3 << 32) | 0. + const auto highGeneration = static_cast(1ULL << 32); + frame.add(caster(2, 1.0f, ShadowCasterBoundsKind::Exact, highGeneration)); + CHECK_NOTHROW(frame.add(caster(3, 50.0f))); + REQUIRE(frame.size() == 2u); + CHECK(frame.require(static_cast(2), highGeneration).world.max.x() == 2.0f); + CHECK(frame.require(static_cast(3), ShadowCasterGeneration::First) + .world.max.x() == 51.0f); + + // And the top half of an id must survive: two ids differing only above bit 32 are different + // casters. A 32-bit shift would have truncated both to zero. + frame.reset(); + const auto highIdA = static_cast(1ULL << 33); + const auto highIdB = static_cast(1ULL << 34); + ShadowCasterBounds a = caster(1, 5.0f); + a.casterId = highIdA; + ShadowCasterBounds b = caster(1, 90.0f); + b.casterId = highIdB; + frame.add(a); + CHECK_NOTHROW(frame.add(b)); + REQUIRE(frame.size() == 2u); + CHECK(frame.require(highIdA, ShadowCasterGeneration::First).world.max.x() == 6.0f); + CHECK(frame.require(highIdB, ShadowCasterGeneration::First).world.max.x() == 91.0f); +} + +TEST_CASE("ShadowCasterBoundsFrame.RejectsAmbiguityRatherThanResolvingIt", "[ShadowCasterBounds]") +{ + ShadowCasterBoundsFrame frame; + frame.reset(); + frame.add(caster(5, 0.0f)); + + SECTION("a duplicate key is terminal") + { + // Two bindings claiming one identity means the shadow state — hysteresis, drawn history, + // and now bounds — is shared by casters that are not the same caster. + CHECK_THROWS(frame.add(caster(5, 50.0f))); + } + SECTION("an invalid caster id is terminal") + { + ShadowCasterBounds nameless = caster(5, 0.0f); + nameless.casterId = ShadowCasterId::Invalid; + CHECK_THROWS(frame.add(nameless)); + } + SECTION("a missing key is terminal, not an empty box") + { + // The dangerous alternative: returning a default Bounds3 would place the caster at the + // origin, where it would be fitted and culled against geometry it has nothing to do with. + CHECK_THROWS(frame.require(static_cast(6), ShadowCasterGeneration::First)); + // `find` is the non-terminal form, for diagnostics that may legitimately ask. + CHECK(frame.find(static_cast(6), ShadowCasterGeneration::First) == nullptr); + } +} + +TEST_CASE("ShadowCasterBoundsFrame.RecordsCastersWhoseBoundsAreInvalid", "[ShadowCasterBounds]") +{ + // A casting binding with no vertices still gets an entry, so the recorded set matches the set + // of shadow draws exactly and a lookup miss always means a real disagreement. Consumers skip + // invalid bounds explicitly rather than finding them absent. + ShadowCasterBoundsFrame frame; + frame.reset(); + ShadowCasterBounds empty = caster(11, 0.0f); + empty.world = Bounds3{}; + frame.add(empty); + + REQUIRE(frame.size() == 1u); + const auto& recorded = + frame.require(static_cast(11), ShadowCasterGeneration::First); + CHECK_FALSE(recorded.world.valid); +} + +TEST_CASE("ShadowCasterBoundsFrame.ResetEndsTheFrame", "[ShadowCasterBounds]") +{ + ShadowCasterBoundsFrame frame; + frame.reset(); + frame.add(caster(1, 0.0f)); + frame.reset(); + + CHECK(frame.empty()); + CHECK(frame.find(static_cast(1), ShadowCasterGeneration::First) == nullptr); + // And the same id may be recorded again — a frame is not a registry, it is one frame. + CHECK_NOTHROW(frame.add(caster(1, 4.0f))); +} diff --git a/tests/render/test_cascade_fit.cpp b/tests/render/test_cascade_fit.cpp index e6f88e5..a20b0d1 100644 --- a/tests/render/test_cascade_fit.cpp +++ b/tests/render/test_cascade_fit.cpp @@ -25,6 +25,7 @@ using fire_engine::CascadeReceiverFit; using fire_engine::CascadeReceiverInput; using fire_engine::fitLegacyCascadeDepth; using fire_engine::Mat4; +using fire_engine::placeCaster; using fire_engine::Vec3; using fire_engine::Vec4; @@ -214,7 +215,7 @@ TEST_CASE("CascadeFit.LegacyDepthPolicyIsBitIdentical", "[CascadeFit]") s.cameraPosition, s.cameraTarget, s.lightDirection, s.aspect, sliceNear, sliceFar, fire_engine::kShadowMapExtent, fire_engine::kShadowDepthBackExtend); - CHECK(bitIdentical(depth->viewProj, legacy.viewProj)); + CHECK(bitIdentical(depth->viewProj(), legacy.viewProj)); CHECK(receiver->worldPerTexel() == legacy.worldPerTexel); } } @@ -259,23 +260,23 @@ TEST_CASE("CascadeFit.ProjectionMapsFittedBoundsToClipEdges", "[CascadeFit]") { 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}; + 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); + 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)); + 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)); + CHECK(Vec3::dotProduct(depth->lightPosition(), receiver->lightDirection()) == + Approx(depth->nearW()).margin(1e-3)); } } @@ -638,3 +639,490 @@ TEST_CASE("CascadeFit.BitIdenticalRejectsPoisonedMatrices", "[CascadeFit]") nudged[1, 1] = std::nextafter(1.0f, 2.0f); CHECK_FALSE(bitIdentical(identity, nudged)); } + +namespace +{ + +// A box centred on a light-space (U, V, W) point, sized in light space, so a placement test can say +// where it wants the caster without solving for a world position by hand. +[[nodiscard]] fire_engine::Bounds3 boxAtUvw(const CascadeReceiverFit& fit, float u, float v, + float w, float halfExtent) +{ + const Vec3 centre = fit.lightRight() * u + fit.lightUp() * v + fit.lightDirection() * w; + fire_engine::Bounds3 bounds{}; + for (int corner = 0; corner < 8; ++corner) + { + bounds.expand(centre + Vec3{(corner & 1) != 0 ? halfExtent : -halfExtent, + (corner & 2) != 0 ? halfExtent : -halfExtent, + (corner & 4) != 0 ? halfExtent : -halfExtent}); + } + return bounds; +} + +} // namespace + +// The evidence type SH-06 turns on: a shadow can go missing because the caster was clipped by the +// depth range or because it was never in the cascade's footprint, and those need different fixes. +TEST_CASE("CascadeFit.PlacementSeparatesDepthClippingFromFootprintMisses", "[CascadeFit]") +{ + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const auto depth = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(depth); + + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + const float midW = 0.5f * (depth->nearW() + depth->farW()); + + SECTION("a caster the cascade fully contains") + { + const auto p = placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, midW, 0.5f)); + CHECK(p.footprint == fire_engine::CascadeFootprintRelation::Inside); + CHECK(p.insideDepth); + CHECK_FALSE(p.clippedNear); + CHECK_FALSE(p.clippedFar); + CHECK_FALSE(p.outsideDepth); + // Bounds are read from the box, not invented: a 0.5 half-extent box spans at most 2 * 0.5 * + // sqrt(3) along any light axis, and contains the point it was centred on. + CHECK(p.minW <= midW); + CHECK(p.maxW >= midW); + CHECK(p.maxW - p.minW <= 2.0f * 0.5f * std::sqrt(3.0f) + 1e-4f); + } + SECTION("the defect signature: straddling the near plane") + { + // Exactly the ShadowDepthClipDemo geometry — a caster centred on the near plane, half of it + // before the plane. Clipped, NOT outside: part of it still writes depth, which is why the + // shadow arrives cut rather than absent. + const auto p = + placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, depth->nearW(), 1.0f)); + CHECK(p.clippedNear); + CHECK_FALSE(p.clippedFar); + CHECK_FALSE(p.insideDepth); + CHECK_FALSE(p.outsideDepth); + CHECK(p.footprint == fire_engine::CascadeFootprintRelation::Inside); + } + SECTION("wholly behind the near plane") + { + const auto p = placeCaster(*receiver, *depth, + boxAtUvw(*receiver, midU, midV, depth->nearW() - 50.0f, 1.0f)); + CHECK(p.clippedNear); + CHECK(p.outsideDepth); + CHECK_FALSE(p.insideDepth); + } + SECTION("past the far plane") + { + const auto p = + placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, depth->farW(), 1.0f)); + CHECK(p.clippedFar); + CHECK_FALSE(p.clippedNear); + CHECK_FALSE(p.outsideDepth); + } + SECTION("outside the footprint but at a perfectly good depth") + { + // The other way a shadow goes missing, and the reason the two flags are separate: this + // caster is at a legal depth and still cannot appear in this cascade. + const auto p = placeCaster(*receiver, *depth, + boxAtUvw(*receiver, receiver->maxU() + 10.0f, midV, midW, 1.0f)); + CHECK(p.footprint == fire_engine::CascadeFootprintRelation::Outside); + CHECK(p.insideDepth); + } + SECTION("a caster with no bounds is not placed anywhere") + { + // The default Bounds3 carries max/lowest sentinels; projecting those would report a caster + // spanning infinity and overlapping everything. + const auto p = placeCaster(*receiver, *depth, fire_engine::Bounds3{}); + CHECK(p.footprint == fire_engine::CascadeFootprintRelation::Invalid); + CHECK_FALSE(p.insideDepth); + CHECK_FALSE(p.clippedNear); + CHECK_FALSE(p.clippedFar); + CHECK_FALSE(p.outsideDepth); + CHECK(p.minW == 0.0f); + CHECK(p.maxW == 0.0f); + } +} + +// The footprint relation exists for a candidate query that must reject `Outside` and ONLY +// `Outside`, so its boundary behaviour is the part worth pinning: a caster touching an edge is +// `Straddles`, never `Outside` (which would drop a possible contributor) and never `Inside` (which +// would claim full coverage it does not have). +TEST_CASE("CascadeFit.FootprintRelationIsConservativeAtTheEdges", "[CascadeFit]") +{ + using fire_engine::CascadeFootprintRelation; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const auto depth = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(depth); + + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + const float midW = 0.5f * (depth->nearW() + depth->farW()); + + const auto relationAt = [&](float u, float v, float halfExtent) + { + return placeCaster(*receiver, *depth, boxAtUvw(*receiver, u, v, midW, halfExtent)) + .footprint; + }; + + // Well inside. + CHECK(relationAt(midU, midV, 0.5f) == CascadeFootprintRelation::Inside); + // Centred on an edge: half in, half out. + CHECK(relationAt(receiver->maxU(), midV, 1.0f) == CascadeFootprintRelation::Straddles); + CHECK(relationAt(receiver->minU(), midV, 1.0f) == CascadeFootprintRelation::Straddles); + CHECK(relationAt(midU, receiver->maxV(), 1.0f) == CascadeFootprintRelation::Straddles); + // Far beyond it. + CHECK(relationAt(receiver->maxU() + 20.0f, midV, 1.0f) == CascadeFootprintRelation::Outside); + // A caster placed so its extreme corner lands ON the boundary. The box is axis-aligned in WORLD + // space, so its light-space extent is wider than the half-extent; nudging by that much puts a + // face against the edge rather than across it, and the conservative answer is still Straddles. + const auto onEdge = placeCaster( + *receiver, *depth, + boxAtUvw(*receiver, midU, midV, midW, 0.5f * (receiver->maxU() - receiver->minU()))); + CHECK(onEdge.footprint == CascadeFootprintRelation::Straddles); + // No bounds at all: not a footprint judgement, and not silently Outside either. + CHECK(placeCaster(*receiver, *depth, fire_engine::Bounds3{}).footprint == + CascadeFootprintRelation::Invalid); +} + +namespace +{ + +[[nodiscard]] fire_engine::ShadowCasterBounds +casterAt(const CascadeReceiverFit& fit, float u, float v, float w, float halfExtent, + fire_engine::ShadowCasterBoundsKind kind = fire_engine::ShadowCasterBoundsKind::Exact) +{ + return fire_engine::ShadowCasterBounds{.world = boxAtUvw(fit, u, v, w, halfExtent), + .casterId = static_cast(1), + .generation = fire_engine::ShadowCasterGeneration::First, + .kind = kind}; +} + +} // namespace + +// SH-06's reason for existing: the depth range is fitted to the casters that can shadow this +// cascade, so an upstream caster is no longer cut by a plane placed a fixed distance away. +TEST_CASE("CascadeFit.CasterAwareDepthReachesTheFurthestUpstreamCandidate", "[CascadeFit]") +{ + using fire_engine::CascadeDepthFitMode; + using fire_engine::fitCasterAwareCascadeDepth; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const auto legacy = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(legacy); + CHECK(legacy->mode() == CascadeDepthFitMode::LegacyFixedExtension); + + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + + SECTION("a caster far upstream pulls the near plane back to include it") + { + // Deliberately beyond the legacy near plane: under the fixed extension this caster was + // clipped, which is the defect measured on ShadowDepthClipDemo. + const float deepW = legacy->nearW() - 25.0f; + const auto casters = std::array{casterAt(*receiver, midU, midV, deepW, 1.0f)}; + const auto fit = + fitCasterAwareCascadeDepth(*receiver, casters, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + CHECK(fit->mode() == CascadeDepthFitMode::CasterAware); + CHECK(fit->nearW() < legacy->nearW()); + // Nothing of the caster is outside the range any more. + const auto placement = placeCaster(*receiver, *fit, casters[0].world); + CHECK_FALSE(placement.clippedNear); + CHECK(placement.insideDepth); + } + SECTION("the far plane covers the receiver volume and does not chase a downstream caster") + { + // A caster BEHIND every receiver in this slice cannot shadow one, so extending the far + // plane to reach it would spend depth precision on nothing. + const float behind = receiver->receiverMaxW() + 30.0f; + const auto casters = std::array{casterAt(*receiver, midU, midV, behind, 1.0f)}; + const auto fit = + fitCasterAwareCascadeDepth(*receiver, casters, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + // The far plane sits at the receiver volume plus the same one-texel slack the near side + // gets — the boundary receiver must be inside the range, not exactly on it. + CHECK(fit->farW() == Approx(receiver->receiverMaxW() + receiver->worldPerTexel())); + CHECK(fit->farW() < behind); + } + SECTION("a caster outside the cascade footprint does not widen the range") + { + // Light rays preserve U and V, so this caster cannot shadow anything inside the rectangle. + const float deepW = legacy->nearW() - 25.0f; + const auto outside = + std::array{casterAt(*receiver, receiver->maxU() + 40.0f, midV, deepW, 1.0f)}; + const auto fit = + fitCasterAwareCascadeDepth(*receiver, outside, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + CHECK(fit->mode() == CascadeDepthFitMode::CasterAware); + // Only the receiver volume (plus the texel of slack) decides the near plane here. + CHECK(fit->nearW() > deepW); + CHECK(fit->nearW() == Approx(receiver->receiverMinW() - receiver->worldPerTexel())); + } + SECTION("no casters at all still produces a usable range over the receivers") + { + const auto fit = + fitCasterAwareCascadeDepth(*receiver, {}, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + CHECK(fit->mode() == CascadeDepthFitMode::CasterAware); + CHECK(fit->nearW() < receiver->receiverMinW()); + CHECK(fit->farW() > receiver->receiverMaxW()); + } + SECTION("the fitted matrix still maps its own planes to Vulkan depth 0 and 1") + { + const auto casters = + std::array{casterAt(*receiver, midU, midV, legacy->nearW() - 25.0f, 1.0f)}; + const auto fit = + fitCasterAwareCascadeDepth(*receiver, casters, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + auto clipAt = [&](float w) + { + const Vec3 p = receiver->lightRight() * (0.5f * (receiver->minU() + receiver->maxU())) + + receiver->lightUp() * (0.5f * (receiver->minV() + receiver->maxV())) + + receiver->lightDirection() * w; + return fit->viewProj() * Vec4{p.x(), p.y(), p.z(), 1.0f}; + }; + CHECK(clipAt(fit->nearW()).z() == Approx(0.0f).margin(1e-4)); + CHECK(clipAt(fit->farW()).z() == Approx(1.0f).margin(1e-4)); + // The snapped U/V rectangle is untouched by the depth policy — the XY fit is stable. + CHECK(clipAt(fit->nearW()).x() == Approx(0.0f).margin(1e-3)); + } +} + +// The interim rule for geometry whose bounds cannot bound it. Fitting the rest and ignoring cloth +// would be the same defect from the other side: the range can come out NARROWER than one covering +// the cloth, and clip it. +TEST_CASE("CascadeFit.OneStaleCasterForcesTheLegacyRange", "[CascadeFit]") +{ + using fire_engine::CascadeDepthFitMode; + using fire_engine::fitCasterAwareCascadeDepth; + using fire_engine::ShadowCasterBoundsKind; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const auto legacy = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(legacy); + + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + auto casters = std::array{ + casterAt(*receiver, midU, midV, legacy->nearW() - 25.0f, 1.0f), + casterAt(*receiver, midU, midV, receiver->centreW(), 1.0f, ShadowCasterBoundsKind::Stale), + }; + // The stale caster is not even in the cascade's way — being anywhere in the frame is enough, + // because its stale U/V cannot establish which cascade it affects either. + casters[1] = casterAt(*receiver, receiver->maxU() + 50.0f, midV, receiver->centreW(), 1.0f, + ShadowCasterBoundsKind::Stale); + + const auto fit = + fitCasterAwareCascadeDepth(*receiver, casters, fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + // The MODE says which policy ran, so a panel or log cannot describe this as a caster-aware fit. + CHECK(fit->mode() == CascadeDepthFitMode::LegacyStaleFallback); + CHECK(fit->nearW() == legacy->nearW()); + CHECK(fit->farW() == legacy->farW()); + // Same matrix as the legacy fit, not merely a similar range. + CHECK(bitIdentical(fit->viewProj(), legacy->viewProj())); +} + +TEST_CASE("CascadeFit.CasterAwareDepthSkipsCastersWithoutBounds", "[CascadeFit]") +{ + using fire_engine::fitCasterAwareCascadeDepth; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + // A casting binding with no vertices is recorded by the prepass (so the recorded set matches + // the draws) but has no extent to fit to. + auto empty = casterAt(*receiver, 0.0f, 0.0f, 0.0f, 1.0f); + empty.world = fire_engine::Bounds3{}; + const auto fit = fitCasterAwareCascadeDepth(*receiver, std::array{empty}, + fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + CHECK(fit->nearW() == Approx(receiver->receiverMinW() - receiver->worldPerTexel())); +} + +// The shader cross-fades into cascade i+1 over the last `kShadowCascadeBlendFraction` of cascade i, +// so receivers in that band sample i+1's map. Once each cascade is fitted TIGHTLY to its own slice, +// that stops being a shader detail: a cascade fitted from the hard split would not cover the +// receivers already sampling it, which is the fixed extension's error in the other direction. +TEST_CASE("CascadeFit.NextCascadeCoversThePrecedingBlendBand", "[CascadeFit]") +{ + using fire_engine::fitCasterAwareCascadeDepth; + using fire_engine::kShadowCascadeBlendFraction; + + for (const Scenario& s : scenarios()) + { + INFO(s.name); + const auto slices = shippedSlices(); + for (std::size_t i = 0; i + 1 < slices.size(); ++i) + { + // The band the SHADER blends over, measured from its notion of the cascade start — + // which is 0 for cascade 0, not the camera near plane. + const float hardStart = i == 0 ? 0.0f : slices[i - 1].second; + const float hardEnd = slices[i].second; + const float blendStart = hardEnd - kShadowCascadeBlendFraction * (hardEnd - hardStart); + + // Cascade i+1 as the renderer now fits it: from the blend start, not the split. + const auto next = + CascadeReceiverFit::fit(inputFor(s, blendStart, slices[i + 1].second)); + REQUIRE(next); + const auto nextDepth = + fitCasterAwareCascadeDepth(*next, {}, fire_engine::kShadowDepthBackExtend); + REQUIRE(nextDepth); + + // Every corner of the blend band's own sub-frustum must be inside cascade i+1's + // rectangle AND its depth range — those receivers are being sampled from that map. + const fire_engine::ViewBasis basis = + fire_engine::makeViewBasis(s.cameraPosition, s.cameraTarget); + const float tanHalfFov = std::tan(fire_engine::kCameraFovRadians * 0.5f); + for (const float d : {blendStart, hardEnd}) + { + 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 u = Vec3::dotProduct(corner, next->lightRight()); + const float v = Vec3::dotProduct(corner, next->lightUp()); + const float cw = Vec3::dotProduct(corner, next->lightDirection()); + CHECK(u >= next->minU()); + CHECK(u <= next->maxU()); + CHECK(v >= next->minV()); + CHECK(v <= next->maxV()); + CHECK(cw >= nextDepth->nearW()); + CHECK(cw <= nextDepth->farW()); + } + } + } + } + } +} + +// Corrupt bounds must not look like a caster that is simply elsewhere. NaN compares false against +// everything, so an unchecked box would classify as `Outside`, be skipped, and let the range +// tighten around geometry nobody accounted for — the exact defect this policy exists to remove. +TEST_CASE("CascadeFit.NonFiniteCasterBoundsAreTerminalNotIgnored", "[CascadeFit]") +{ + using fire_engine::CascadeFootprintRelation; + using fire_engine::classifyFootprint; + using fire_engine::fitCasterAwareCascadeDepth; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const auto depth = fitLegacyCascadeDepth(*receiver, fire_engine::kShadowDepthBackExtend); + REQUIRE(depth); + + const float nan = std::numeric_limits::quiet_NaN(); + const float inf = std::numeric_limits::infinity(); + + // Set the fields directly rather than expanding with a poisoned vertex. `Bounds3::expand` runs + // std::min/std::max, which return the OTHER operand when one side is NaN, so it cannot be used + // to BUILD a non-finite box at all — which is precisely why the production prepass uses + // `Bounds3::expandChecked` and reports a corrupt vertex explicitly instead + // (`tests/graphics/test_object_shadow_casters.cpp` covers that path end to end). What this test + // pins is what the POLICY does once such a box reaches it. + auto poisoned = [&](float bad) + { + fire_engine::Bounds3 b{}; + b.min = Vec3{0.0f, 0.0f, 0.0f}; + b.max = Vec3{bad, 1.0f, 1.0f}; + b.valid = true; + return b; + }; + + SECTION("the footprint classification reports Invalid, never Outside") + { + CHECK(classifyFootprint(*receiver, poisoned(nan)) == CascadeFootprintRelation::Invalid); + CHECK(classifyFootprint(*receiver, poisoned(inf)) == CascadeFootprintRelation::Invalid); + CHECK(classifyFootprint(*receiver, fire_engine::Bounds3{}) == + CascadeFootprintRelation::Invalid); + } + SECTION("placement reports Invalid and no flags") + { + const auto placement = placeCaster(*receiver, *depth, poisoned(nan)); + CHECK(placement.footprint == CascadeFootprintRelation::Invalid); + CHECK_FALSE(placement.clippedNear); + CHECK_FALSE(placement.insideDepth); + CHECK(placement.minW == 0.0f); + } + SECTION("the depth policy fails rather than skipping the caster") + { + for (const float bad : {nan, inf, -inf}) + { + fire_engine::ShadowCasterBounds caster{ + .world = poisoned(bad), + .casterId = static_cast(1), + .generation = fire_engine::ShadowCasterGeneration::First, + .kind = fire_engine::ShadowCasterBoundsKind::Exact}; + CHECK_FALSE(fitCasterAwareCascadeDepth(*receiver, std::array{caster}, + fire_engine::kShadowDepthBackExtend) + .has_value()); + } + } +} + +// The caster-aware path must not depend on the constant it retires. Building a throwaway legacy fit +// for the footprint test made a bad `backExtend` reject an Exact-only fit that never used one. +TEST_CASE("CascadeFit.ExactOnlyFitDoesNotNeedAUsableBackExtension", "[CascadeFit]") +{ + using fire_engine::CascadeDepthFitMode; + using fire_engine::fitCasterAwareCascadeDepth; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + const auto casters = + std::array{casterAt(*receiver, midU, midV, receiver->centreW() - 30.0f, 1.0f)}; + + for (const float unusable : + {-1.0f, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) + { + const auto fit = fitCasterAwareCascadeDepth(*receiver, casters, unusable); + REQUIRE(fit); + CHECK(fit->mode() == CascadeDepthFitMode::CasterAware); + } + + // The stale branch is the one that genuinely needs it, and it still fails without one. + const auto stale = std::array{casterAt(*receiver, midU, midV, receiver->centreW(), 1.0f, + fire_engine::ShadowCasterBoundsKind::Stale)}; + CHECK_FALSE(fitCasterAwareCascadeDepth(*receiver, stale, -1.0f).has_value()); +} + +// Validation must not be short-circuited by the fallback. A frame containing BOTH cloth and a +// corrupt Exact caster used to take the stale branch on sight and never look at the corruption — +// the diagnosis would then name the wrong problem, and a caster with unknowable bounds would go +// unmentioned entirely. +TEST_CASE("CascadeFit.CorruptExactBoundsAreReportedEvenWhenClothIsPresent", "[CascadeFit]") +{ + using fire_engine::fitCasterAwareCascadeDepth; + using fire_engine::ShadowCasterBoundsKind; + + const auto receiver = CascadeReceiverFit::fit(inputFor(scenarios().front(), 1.0f, 12.0f)); + REQUIRE(receiver); + const float midU = 0.5f * (receiver->minU() + receiver->maxU()); + const float midV = 0.5f * (receiver->minV() + receiver->maxV()); + + auto corrupt = casterAt(*receiver, midU, midV, receiver->centreW(), 1.0f); + corrupt.world.max = Vec3{std::numeric_limits::quiet_NaN(), 1.0f, 1.0f}; + const auto stale = + casterAt(*receiver, midU, midV, receiver->centreW(), 1.0f, ShadowCasterBoundsKind::Stale); + + // Either order: the stale caster must not excuse the corrupt one. + CHECK_FALSE(fitCasterAwareCascadeDepth(*receiver, std::array{stale, corrupt}, + fire_engine::kShadowDepthBackExtend) + .has_value()); + CHECK_FALSE(fitCasterAwareCascadeDepth(*receiver, std::array{corrupt, stale}, + fire_engine::kShadowDepthBackExtend) + .has_value()); + + // And with no corruption present, the same pairing still takes the documented fallback. + const auto clean = casterAt(*receiver, midU, midV, receiver->centreW(), 1.0f); + const auto fit = fitCasterAwareCascadeDepth(*receiver, std::array{clean, stale}, + fire_engine::kShadowDepthBackExtend); + REQUIRE(fit); + CHECK(fit->mode() == fire_engine::CascadeDepthFitMode::LegacyStaleFallback); +} diff --git a/tests/scene/test_node.cpp b/tests/scene/test_node.cpp index ab224b2..f5ef371 100644 --- a/tests/scene/test_node.cpp +++ b/tests/scene/test_node.cpp @@ -1,5 +1,8 @@ #include +#include +#include + #include #include @@ -310,3 +313,123 @@ TEST_CASE("NodeMove.MoveConstructTransfersChildren", "[NodeMove]") REQUIRE(b.children().size() == 1u); CHECK(b.children()[0]->name() == "Child"); } + +// --------------------------------------------------------------------------- +// One transform source for the draw walk and the transform walks (SH-06). +// +// `update` / `resolve` treat a world-override — a ragdoll-driven body, whose pose the physics +// solver owns — as authoritative, bypassing the parent chain and the local transform. The draw walk +// used to recompute `parentWorld * local` and ignore it, so an overridden node drew at a pose that +// was not the one its shadow-caster bounds were measured at. +// --------------------------------------------------------------------------- + +TEST_CASE("NodeDrawWorld.MatchesComposedWorldForAnOrdinaryNode", "[Node]") +{ + Node node("Crate"); + node.transform().position({3.0f, 0.0f, 0.0f}); + + const Mat4 parent = Mat4::translate({0.0f, 5.0f, 0.0f}); + node.resolve(parent); + + const Mat4 drawWorld = node.drawWorld(parent); + // A node with no component matrix draws exactly where its composed world puts it, which is what + // makes the prepass's bounds and the draw's geometry the same thing. + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + CHECK(drawWorld[row, col] == + Catch::Approx(node.composedWorld()[row, col]).margin(1e-5f)); + } + } +} + +TEST_CASE("NodeDrawWorld.HonoursAWorldOverrideLikeTheTransformWalksDo", "[Node]") +{ + Node node("RagdollLimb"); + // A local transform that would place it somewhere else entirely, to make the two answers + // unmistakably different if the override were ignored. + node.transform().position({100.0f, 0.0f, 0.0f}); + const Mat4 physicsPose = Mat4::translate({-7.0f, 2.0f, 1.0f}); + node.worldOverride(physicsPose); + + const Mat4 parent = Mat4::translate({0.0f, 50.0f, 0.0f}); + node.resolve(parent); + + // Both walks agree, and both agree with the physics pose rather than the parent chain. + CHECK(node.composedWorld()[0, 3] == Catch::Approx(-7.0f).margin(1e-5f)); + CHECK(node.drawWorld(parent)[0, 3] == Catch::Approx(-7.0f).margin(1e-5f)); + CHECK(node.drawWorld(parent)[1, 3] == Catch::Approx(2.0f).margin(1e-5f)); + + // And clearing it returns both to the parent chain. + node.clearWorldOverride(); + node.resolve(parent); + CHECK(node.drawWorld(parent)[0, 3] == Catch::Approx(100.0f).margin(1e-5f)); + CHECK(node.composedWorld()[0, 3] == Catch::Approx(100.0f).margin(1e-5f)); +} + +TEST_CASE("NodeDrawWorld.AnOverriddenAnimatorDoesNotMoveItsChildren", "[Node]") +{ + // The case `drawWorld` alone does not cover. `update` / `resolve` return early on a + // world-override, so they skip the component matrix as well as the parent chain: an overridden + // Animator's children inherit the override ITSELF. The draw walk applies the component matrix + // on the way down, so without the shared rule it would hand children `override * animation` + // while their cached shadow bounds were measured at `override`. + // + // SCOPE, stated plainly: this pins the ALGEBRA of the rule by calling the helpers directly. It + // does NOT prove that `Node::render` calls them, and it would still pass if the visitor stopped + // using `childWorld`. Proving the call site needs a component that records the world it was + // rendered with, and the only one that does is `Mesh`, whose draw path needs a GPU-backed + // `Object` — so that half is covered by the render smoke rather than here. The call site is + // instead constrained structurally: in `Node::render` the component's world is an unnamed + // argument to `childWorld`, so it cannot reach the children by any other route. + Node rig("RagdollRig"); + auto& animator = rig.component().emplace(); + fire_engine::Animation spin; + // 90 degrees about Y over 2 s, so the animation matrix is unmistakably not identity. + const float s45 = std::sin(fire_engine::pi / 4.0f); + const float c45 = std::cos(fire_engine::pi / 4.0f); + spin.rotationKeyframes({ + {0.0f, 0.0f, 0.0f, 0.0f, 1.0f}, + {2.0f, 0.0f, s45, 0.0f, c45}, + }); + animator.addAnimation(&spin); + + fire_engine::InputState state; + state.time(0.0); + animator.update(state, rig.transform()); + state.time(1.0); + animator.update(state, rig.transform()); + // The animator really is rotating — otherwise this test could not tell the two rules apart. + const Mat4 animated = animator.render(Mat4::identity()); + REQUIRE(animated[0, 0] == Catch::Approx(c45).margin(1e-4f)); + + const Mat4 physicsPose = Mat4::translate({4.0f, 0.0f, 0.0f}); + rig.worldOverride(physicsPose); + auto& child = rig.addChild(std::make_unique("Limb_Mesh")); + rig.resolve(Mat4::identity()); + + // What the transform walk gave the child, and what the draw walk must give it: the override, + // with no animation folded in. + const Mat4 drawWorld = rig.drawWorld(Mat4::identity()); + const Mat4 componentWorld = animator.render(drawWorld); + const Mat4 handedToChildren = rig.childWorld(drawWorld, componentWorld); + + CHECK(child.composedWorld()[0, 3] == Catch::Approx(4.0f).margin(1e-5f)); + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + CHECK(handedToChildren[row, col] == + Catch::Approx(child.composedWorld()[row, col]).margin(1e-5f)); + } + } + // And the animated world genuinely differs, so the check above is not vacuous. + CHECK(componentWorld[0, 0] != Catch::Approx(handedToChildren[0, 0]).margin(1e-4f)); + + // Without the override, the animation is exactly what children inherit. + rig.clearWorldOverride(); + const Mat4 unOverridden = rig.drawWorld(Mat4::identity()); + CHECK(rig.childWorld(unOverridden, animator.render(unOverridden))[0, 0] == + Catch::Approx(c45).margin(1e-4f)); +} diff --git a/tests/scene/test_scene_graph.cpp b/tests/scene/test_scene_graph.cpp index f3f4254..522a920 100644 --- a/tests/scene/test_scene_graph.cpp +++ b/tests/scene/test_scene_graph.cpp @@ -188,7 +188,10 @@ TEST_CASE("SceneGraphDraw.FullyCulledMeshNodesEmitNoDrawsAndReportCullStats", "[ const FrameInfo frame{}; // never consumed — culled nodes emit nothing const std::array frustums{forwardFrustum()}; std::vector out; - const CullStats stats = sg.buildDrawCommands(frame, frustums, out); + // The prepass authority the draw walk requires. These cases exercise the CULLED path, where no + // object emits a draw, so an empty record is the correct input rather than a shortcut. + fire_engine::ShadowCasterBoundsFrame casterBounds; + const CullStats stats = sg.buildDrawCommands(frame, frustums, casterBounds, out); CHECK(out.empty()); // culled → no draws (and no GPU touched) CHECK(stats.tracked == 2u); @@ -206,7 +209,8 @@ TEST_CASE("SceneGraphDraw.EmptyFrustumSpanDisablesCulling", "[SceneGraphDraw]") const FrameInfo frame{}; std::vector out; - const CullStats stats = sg.buildDrawCommands(frame, {}, out); + fire_engine::ShadowCasterBoundsFrame casterBounds; + const CullStats stats = sg.buildDrawCommands(frame, {}, casterBounds, out); CHECK(out.empty()); CHECK(stats.tracked == 0u); @@ -225,7 +229,10 @@ TEST_CASE("SceneGraphDraw.NonRenderableNodesAreNotTracked", "[SceneGraphDraw]") const FrameInfo frame{}; const std::array frustums{forwardFrustum()}; std::vector out; - const CullStats stats = sg.buildDrawCommands(frame, frustums, out); + // The prepass authority the draw walk requires. These cases exercise the CULLED path, where no + // object emits a draw, so an empty record is the correct input rather than a shortcut. + fire_engine::ShadowCasterBoundsFrame casterBounds; + const CullStats stats = sg.buildDrawCommands(frame, frustums, casterBounds, out); CHECK(out.empty()); CHECK(stats.tracked == 1u); // only the cube