Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions assets/shadow_lod/ShadowDepthClipDemo.gltf
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
"name": "DepthClip_Caster",
"mesh": 1,
"translation": [
24.22,
28.14,
18.29
24.784,
28.855,
18.704
]
},
{
Expand Down
21 changes: 17 additions & 4 deletions assets/shadow_lod/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
23 changes: 23 additions & 0 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/review-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
38 changes: 33 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading