From 5fd919a623b546b0f31a0aecb246b3f6035de7d4 Mon Sep 17 00:00:00 2001 From: Nick Newson Date: Sat, 1 Aug 2026 16:15:10 +0100 Subject: [PATCH 1/3] SH-06 evidence: measure what the fixed depth extension actually costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the caster-aware depth fit, and the measurement that justifies it. No production behaviour changes here: the cascade matrices are untouched, and everything added is diagnostics, a test fixture, and a classification the candidate query will consume. placeCaster (render/cascade_fit) answers where one caster sits relative to one cascade in that cascade's light space: its U/V/W bounds, whether the fitted near/far planes clip it, and how its footprint relates to the cascade rectangle. It is pure and headless, and it is deliberately not a verdict — it separates a caster CLIPPED in depth from one whose footprint sits outside the cascade, which a picture cannot. CascadeFootprintRelation is Invalid / Outside / Inside / Straddles, with edge-touching classified conservatively as Straddles: the candidate query must reject Outside and only Outside, because over-including a caster costs a draw while wrongly excluding one costs a shadow nobody can trace. Straddling is ordinary, not defective — 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. The renderer retains each frame's two fit carriers and logs, on the same frames as the cascade fit (periodic plus the --capture-frame frame), where every shadow caster sits in every cascade — keyed by ShadowCasterId and generation, since one object can hold several caster bindings. The log states its own limit: it runs before ShadowDrawFilter, so it proves placement, never rasterisation. ShadowDepthClipDemo's caster is repositioned, and this is the substance of the branch. The first placement — a sphere centred ON cascade 2's legacy near plane — produced a complete ellipse, which is correct rather than a null result: the shadow pass culls FRONT faces, so the surface a caster records is its far side, and a sphere centred on the plane has that entire surface downstream of it. Moving the caster one metre upstream along the sun does not move its shadow (it stays on the same light ray) but makes the recorded surface straddle the plane. The trace then reports clippedNear with W [-45.723, -38.953] against depth W [-41.340, 33.535], and the shadow shrinks from 35253 to 26166 pixels, 306x152 to 263x131 — 14% linearly, 26% by area, against an analytic prediction of 13.4% / 25% for the cap the plane removes from a sphere. That is now SH-06's acceptance gate, recorded pre-fix: the caster-aware depth fit must restore the full-size ellipse. Scope the shape result carefully, and the comments do. 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; asymmetric geometry can present a straight projected boundary under the same clip. What generalises is the mechanism, not the silhouette. The half-ellipse originally reported against ShadowLodMotionDemo is filed as a separate open question rather than SH-06's motivation. Sweeping the caster's whole animation range finds no pose where it is depth-clipped (closest approach 20.7 m under the fallback sun the scene used to render with, 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. Neither null is vacuous: the sweep reproduces the engine's own logged cascade-0 fit to the printed digit, and the trace flags the probe scene. Diagnosing it needs the symptom re-confirmed under the repaired sun, the pass's own drawn verdict beside the placement, and per-pixel cascade / blend factor / projected shadow U/V at the affected receivers — none of which exist yet. --- assets/shadow_lod/ShadowDepthClipDemo.gltf | 6 +- assets/shadow_lod/generate.py | 21 ++- docs/shadowplans.md | 85 +++++++----- include/fire_engine/render/cascade_fit.hpp | 71 ++++++++++ include/fire_engine/render/renderer.hpp | 15 +++ src/render/cascade_fit.cpp | 65 +++++++++ src/render/renderer.cpp | 58 +++++++++ tests/render/test_cascade_fit.cpp | 145 +++++++++++++++++++++ 8 files changed, 427 insertions(+), 39 deletions(-) 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/shadowplans.md b/docs/shadowplans.md index 2b4f437..691166f 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -570,39 +570,60 @@ The cascade XY fit is stable, but its light-space depth currently relies on the `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 +**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: + +| 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 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 -``` - -`--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. +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 diff --git a/include/fire_engine/render/cascade_fit.hpp b/include/fire_engine/render/cascade_fit.hpp index 47b338a..fb3610f 100644 --- a/include/fire_engine/render/cascade_fit.hpp +++ b/include/fire_engine/render/cascade_fit.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -219,4 +220,74 @@ 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}; +}; + +// 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 bounds (`Bounds3::valid == false`) yields a placement with every flag false and zero +// extents: a caster with no 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; + } // namespace fire_engine diff --git a/include/fire_engine/render/renderer.hpp b/include/fire_engine/render/renderer.hpp index 41c4227..5c60f04 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -367,6 +368,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 +411,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. diff --git a/src/render/cascade_fit.cpp b/src/render/cascade_fit.cpp index 8f6bfa4..eea0d38 100644 --- a/src/render/cascade_fit.cpp +++ b/src/render/cascade_fit.cpp @@ -218,4 +218,69 @@ std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& r return depth; } +CascadeCasterPlacement placeCaster(const CascadeReceiverFit& receiver, const CascadeDepthFit& depth, + const Bounds3& casterBounds) noexcept +{ + CascadeCasterPlacement placement{}; + if (!casterBounds.valid) + { + return placement; + } + + // All eight corners, not the centre and a radius: an axis-aligned box is not a sphere, and the + // whole question here is whether a FACE of it crosses a plane. A radius would round the box out + // and report clipping that is not there. + const Vec3& lo = casterBounds.min; + const Vec3& hi = casterBounds.max; + bool first = true; + 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 (first) + { + placement.minU = placement.maxU = u; + placement.minV = placement.maxV = v; + placement.minW = placement.maxW = w; + first = false; + continue; + } + placement.minU = std::min(placement.minU, u); + placement.maxU = std::max(placement.maxU, u); + placement.minV = std::min(placement.minV, v); + placement.maxV = std::max(placement.maxV, v); + placement.minW = std::min(placement.minW, w); + placement.maxW = std::max(placement.maxW, w); + } + + // 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 = placement.maxU >= receiver.minU() && placement.minU <= receiver.maxU() && + placement.maxV >= receiver.minV() && placement.minV <= receiver.maxV(); + if (!overlaps) + { + placement.footprint = CascadeFootprintRelation::Outside; + } + else if (placement.minU > receiver.minU() && placement.maxU < receiver.maxU() && + placement.minV > receiver.minV() && placement.maxV < receiver.maxV()) + { + placement.footprint = CascadeFootprintRelation::Inside; + } + else + { + placement.footprint = CascadeFootprintRelation::Straddles; + } + 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..c8ffc59 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -474,6 +474,9 @@ 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(); @@ -490,6 +493,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) @@ -519,6 +523,7 @@ 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. + cascadeFits_[i] = RetainedCascadeFit{*receiver, *depth}; if (!shadowViews_.setCascade(i, depth->viewProj, ShadowView::orthographic(receiver->worldPerTexel()))) { @@ -1118,8 +1123,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/tests/render/test_cascade_fit.cpp b/tests/render/test_cascade_fit.cpp index e6f88e5..90e40cd 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; @@ -638,3 +639,147 @@ 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); +} From 8ff29aa929444b310ade14adb4d1a073e61aecf1 Mon Sep 17 00:00:00 2001 From: Nick Newson Date: Mon, 3 Aug 2026 19:56:39 +0100 Subject: [PATCH 2/3] SH-06: fit each cascade's depth range to its casters, not to a fixed distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directional cascade depth range was `centre ± (radius + kShadowDepthBackExtend)`: a fixed 20-world-unit extension in both directions, standing in for "how far behind the slice a caster might be". No constant can answer that for an arbitrary scene. Too small clips a caster; too large spends depth precision on empty space. `fitCasterAwareCascadeDepth` fits the planes to the frame's actual geometry. The near plane reaches back to the furthest-upstream CANDIDATE caster — footprint not `Outside`, since light rays preserve U and V in an orthographic map, so a caster outside the rectangle cannot shadow a receiver inside it. The far plane covers the receiver volume and no further: geometry behind every receiver in the slice cannot shadow one, and reaching it would spend precision on nothing. Both planes get one `worldPerTexel` of slack — the fit's own unit rather than an invented epsilon — which widens the depth span by an XY texel's world size and has nothing to do with depth resolution. 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 a `CascadeDepthFitMode`: `LegacyFixedExtension` for `fitLegacyCascadeDepth` asked for directly, `CasterAware` for a fitted range, and `LegacyStaleFallback` where the policy declined. Three values, not two, because the legacy function still has its own callers and its direct result is neither caster-aware nor a fallback from anything. `CascadeDepthFit` became an encapsulated class for the same reason `CascadeReceiverFit` is one: a mode has to describe THIS matrix, and a public aggregate would let a caller pair either with the other. Cloth is the unresolved case and is marked as such rather than papered over. A storage-vertex caster's bounds are its bind pose — a compute pass rewrites the vertices — so the box neither bounds the geometry nor establishes which cascade that geometry affects. Ignoring stale entries and fitting 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. So a single stale caster anywhere in the frame makes every directional cascade fall back to the legacy range, marked `LegacyStaleFallback`. Validation of every Exact bound completes BEFORE that choice, so a frame containing both cloth and a corrupt caster still reports the corruption rather than silently taking the fallback. Cascade blending became a fitting constraint. The shader cross-fades into cascade i+1 over the last tenth of cascade i's view-depth range, so those receivers sample i+1's map — and a cascade fitted tightly to its own slice would not cover them. Each cascade is now fitted from the start of its predecessor's blend band, and the fraction lives in `LightUBO::cascadeParams.x` so the value that decides the band and the value that fits for it are one C++ constant, not a constant plus a shader literal. Corrupt bounds are terminal end to end. `Bounds3::expandChecked` refuses a non-finite point instead of letting std::min/std::max drop it (they return the other operand against NaN, so the old path produced a finite box that did not contain its geometry); the prepass reports such a caster as valid-but-non-finite, which is what it is — this caster has an extent and the engine cannot state it — and the policy fails rather than skipping it, because a range tightened around a caster nobody accounted for is the exact defect being fixed. ACCEPTANCE, on `ShadowDepthClipDemo`, whose caster the legacy fit clipped: 26166 -> 35324 shadow pixels, bounding box 263x131 -> 305x152. Against the geometrically unclipped baseline (35253 px, 306x152) the two differ by 253 pixels grouped into 185 horizontal scanline runs, longest 5 px, median 1 — a one-pixel fringe following the silhouette with no clip-sized interior region. Residual clipping leaves a chunk; the legacy row is exactly that, a concentric 26% loss of area. Evidence re-run on an idle machine: the SH-03 budget table moves slightly with the changed matrices (budget 2 0.243% -> 0.289%, budget 4 0.356% -> 0.374%) and the 0.1% threshold still selects budget 1 by a wide margin; the dead-band sweep again shows zero reversals at every ratio. A sweep 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 — that sample was contention, and `constants.hpp` now says to discard any sweep whose noise-floor line is not zero. --- CMakeLists.txt | 3 + docs/onboarding.md | 23 ++ docs/review-order.md | 4 +- docs/shadowplans.md | 63 +++ include/fire_engine/graphics/bounds.hpp | 19 + include/fire_engine/graphics/draw_command.hpp | 9 + include/fire_engine/graphics/object.hpp | 35 +- .../fire_engine/graphics/renderable_scene.hpp | 18 + .../graphics/shadow_caster_bounds.hpp | 78 ++++ .../graphics/shadow_caster_bounds_frame.hpp | 77 ++++ include/fire_engine/render/cascade_fit.hpp | 147 ++++++- include/fire_engine/render/constants.hpp | 38 +- include/fire_engine/render/renderer.hpp | 6 + include/fire_engine/render/ubo.hpp | 14 +- include/fire_engine/scene/mesh.hpp | 3 + include/fire_engine/scene/node.hpp | 34 ++ .../fire_engine/scene/scene_draw_context.hpp | 8 + include/fire_engine/scene/scene_graph.hpp | 2 + shaders/light_ubo.glsl | 4 + shaders/shader.frag | 2 +- src/graphics/object.cpp | 148 +++++-- src/graphics/shadow_caster_bounds_frame.cpp | 55 +++ src/render/cascade_fit.cpp | 268 ++++++++++--- src/render/renderer.cpp | 101 ++++- src/render/shadows.cpp | 15 +- src/scene/mesh.cpp | 7 +- src/scene/node.cpp | 108 +++-- src/scene/scene_culler.cpp | 12 +- src/scene/scene_graph.cpp | 11 + tests/graphics/test_object_shadow_casters.cpp | 262 +++++++++++++ .../test_shadow_caster_bounds_frame.cpp | 166 ++++++++ tests/render/test_cascade_fit.cpp | 369 +++++++++++++++++- tests/scene/test_node.cpp | 123 ++++++ tests/scene/test_scene_graph.cpp | 13 +- 34 files changed, 2062 insertions(+), 183 deletions(-) create mode 100644 include/fire_engine/graphics/shadow_caster_bounds.hpp create mode 100644 include/fire_engine/graphics/shadow_caster_bounds_frame.hpp create mode 100644 src/graphics/shadow_caster_bounds_frame.cpp create mode 100644 tests/graphics/test_object_shadow_casters.cpp create mode 100644 tests/graphics/test_shadow_caster_bounds_frame.cpp 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/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/shadowplans.md b/docs/shadowplans.md index 691166f..70fa992 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -707,6 +707,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. 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 fb3610f..ce76974 100644 --- a/include/fire_engine/render/cascade_fit.hpp +++ b/include/fire_engine/render/cascade_fit.hpp @@ -2,8 +2,11 @@ #include #include +#include +#include #include +#include #include #include @@ -191,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 @@ -279,15 +359,56 @@ struct CascadeCasterPlacement 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 bounds (`Bounds3::valid == false`) yields a placement with every flag false and zero -// extents: a caster with no bounds has no position to report, and inventing one from the default -// min/max sentinels would place it at infinity. +// 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 5c60f04..9f5aef8 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -517,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 eea0d38..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,68 +303,147 @@ std::optional fitLegacyCascadeDepth(const CascadeReceiverFit& r return depth; } -CascadeCasterPlacement placeCaster(const CascadeReceiverFit& receiver, const CascadeDepthFit& depth, - const Bounds3& casterBounds) noexcept +std::optional +fitCasterAwareCascadeDepth(const CascadeReceiverFit& receiver, + std::span casters, float backExtend) noexcept { - CascadeCasterPlacement placement{}; - if (!casterBounds.valid) + // 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) { - return placement; + 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); } - // All eight corners, not the centre and a radius: an axis-aligned box is not a sphere, and the - // whole question here is whether a FACE of it crosses a plane. A radius would round the box out - // and report clipping that is not there. - const Vec3& lo = casterBounds.min; - const Vec3& hi = casterBounds.max; - bool first = true; - for (int corner = 0; corner < 8; ++corner) + if (hasStale) { - 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 (first) + std::optional fallback = fitLegacyCascadeDepth(receiver, backExtend); + if (fallback) { - placement.minU = placement.maxU = u; - placement.minV = placement.maxV = v; - placement.minW = placement.maxW = w; - first = false; - continue; + fallback->mode_ = CascadeDepthFitMode::LegacyStaleFallback; } - placement.minU = std::min(placement.minU, u); - placement.maxU = std::max(placement.maxU, u); - placement.minV = std::min(placement.minV, v); - placement.maxV = std::max(placement.maxV, v); - placement.minW = std::min(placement.minW, w); - placement.maxW = std::max(placement.maxW, w); + return fallback; } - // 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 = placement.maxU >= receiver.minU() && placement.minU <= receiver.maxU() && - placement.maxV >= receiver.minV() && placement.minV <= receiver.maxV(); - if (!overlaps) + // 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)) { - placement.footprint = CascadeFootprintRelation::Outside; + return std::nullopt; } - else if (placement.minU > receiver.minU() && placement.maxU < receiver.maxU() && - placement.minV > receiver.minV() && placement.maxV < receiver.maxV()) + + // 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_)) { - placement.footprint = CascadeFootprintRelation::Inside; + return std::nullopt; } - else + 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) { - placement.footprint = CascadeFootprintRelation::Straddles; + // 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.clippedNear = placement.minW < depth.nearW; - placement.clippedFar = placement.maxW > depth.farW; + + 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; + placement.outsideDepth = placement.maxW < depth.nearW() || placement.minW > depth.farW(); return placement; } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index c8ffc59..3cd82a9 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -481,6 +481,45 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // as this frame's. This is the ONE reset, and it must stay ahead of every populate below. shadowViews_.reset(); + 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 @@ -514,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); @@ -524,7 +566,7 @@ void Renderer::computeShadowCascades(LightUBO& out, Vec3 cameraPosition, Vec3 ca // here: SH-02 selection reasons about it, and a second derivation would drift the moment // the fit changes. cascadeFits_[i] = RetainedCascadeFit{*receiver, *depth}; - if (!shadowViews_.setCascade(i, depth->viewProj, + if (!shadowViews_.setCascade(i, depth->viewProj(), ShadowView::orthographic(receiver->worldPerTexel()))) { rejectedShadowView(std::format("cascade {}", i)); @@ -534,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, @@ -645,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) @@ -937,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_); } @@ -1028,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); @@ -1163,8 +1224,8 @@ void Renderer::logShadowCasterPlacement(std::span shadowDraws 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, + 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); 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 90e40cd..a20b0d1 100644 --- a/tests/render/test_cascade_fit.cpp +++ b/tests/render/test_cascade_fit.cpp @@ -215,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); } } @@ -260,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)); } } @@ -672,7 +672,7 @@ TEST_CASE("CascadeFit.PlacementSeparatesDepthClippingFromFootprintMisses", "[Cas 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 float midW = 0.5f * (depth->nearW() + depth->farW()); SECTION("a caster the cascade fully contains") { @@ -694,7 +694,7 @@ TEST_CASE("CascadeFit.PlacementSeparatesDepthClippingFromFootprintMisses", "[Cas // 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)); + placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, depth->nearW(), 1.0f)); CHECK(p.clippedNear); CHECK_FALSE(p.clippedFar); CHECK_FALSE(p.insideDepth); @@ -704,7 +704,7 @@ TEST_CASE("CascadeFit.PlacementSeparatesDepthClippingFromFootprintMisses", "[Cas SECTION("wholly behind the near plane") { const auto p = placeCaster(*receiver, *depth, - boxAtUvw(*receiver, midU, midV, depth->nearW - 50.0f, 1.0f)); + boxAtUvw(*receiver, midU, midV, depth->nearW() - 50.0f, 1.0f)); CHECK(p.clippedNear); CHECK(p.outsideDepth); CHECK_FALSE(p.insideDepth); @@ -712,7 +712,7 @@ TEST_CASE("CascadeFit.PlacementSeparatesDepthClippingFromFootprintMisses", "[Cas SECTION("past the far plane") { const auto p = - placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, depth->farW, 1.0f)); + placeCaster(*receiver, *depth, boxAtUvw(*receiver, midU, midV, depth->farW(), 1.0f)); CHECK(p.clippedFar); CHECK_FALSE(p.clippedNear); CHECK_FALSE(p.outsideDepth); @@ -756,7 +756,7 @@ TEST_CASE("CascadeFit.FootprintRelationIsConservativeAtTheEdges", "[CascadeFit]" 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 float midW = 0.5f * (depth->nearW() + depth->farW()); const auto relationAt = [&](float u, float v, float halfExtent) { @@ -783,3 +783,346 @@ TEST_CASE("CascadeFit.FootprintRelationIsConservativeAtTheEdges", "[CascadeFit]" 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 From 09e18af3fd58d4a0c184eb6e7c539b96bdf3c73a Mon Sep 17 00:00:00 2001 From: Nick Newson Date: Mon, 3 Aug 2026 20:03:13 +0100 Subject: [PATCH 3/3] Minor update the plan with the current status To roadmap and shadowplans --- docs/roadmap.md | 38 +++++++++++++++++++++++++++++++++----- docs/shadowplans.md | 25 +++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) 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 70fa992..4aaf3cf 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -562,9 +562,22 @@ 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 @@ -930,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.