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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ set(SHADER_INCLUDES
${PROJECT_SOURCE_DIR}/shaders/light_ubo.glsl
${PROJECT_SOURCE_DIR}/shaders/material.glsl
${PROJECT_SOURCE_DIR}/shaders/shadow_push.glsl
${PROJECT_SOURCE_DIR}/shaders/forward_push.glsl
${PROJECT_SOURCE_DIR}/shaders/shadow_depth.glsl
${PROJECT_SOURCE_DIR}/shaders/self_shadow_second.glsl
)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ I've no doubt these are all solved problems nowadays with the Unreal engine et a
- **Authored-camera adoption** — `GltfLoader::findFirstCamera` walks the default scene's node tree DFS and returns the first camera-bearing node's view; FireEngine reframes its runtime camera to match before the first frame
- **GPU particle system (compute-driven)** — `ParticleEmitter` is a scene component, gathered each frame like `Light` into a Vulkan-free `EmitterState`. A renderer-owned `ParticleSystem` simulates a pooled particle SSBO with a **compute shader** (spawn dead slots at the emitter up to a per-frame budget via an atomic spawn-claim; integrate the rest under gravity), then renders the pool as **instanced camera-facing billboards** (`cmd.draw(6, poolCount)`, per-instance data read from the SSBO by `gl_InstanceIndex`) blended **additively into the HDR target** so bloom catches the glow. **Soft particles**: the fragment shader fades against sampled scene depth so particles dissolve smoothly into geometry with no hard clip edge. Built on the compute-pipeline + synchronization2 buffer-barrier path
- **HDR offscreen forward pass + bloom + ACES post-process** — forward writes into an R16G16B16A16 target. **Dual-filter bloom** (6-mip RGBA16F chain at half-screen res, 13-tap CoD downsample with Karis-average on the first pass to suppress fireflies, 9-tap tent upsample with additive blend) produces a low-pass HDR contribution. Post-process mixes the HDR target with bloom mip 0 (`bloomStrength = 0.04` default; `0` is bit-identical to a no-bloom path), then ACES tonemap + gamma 2.2 before presenting
- **SSAO + contact shadows** — a **depth prepass** (reusing the forward vertex shader with `invariant gl_Position`; the forward pass loads it with `LESS_OR_EQUAL`) fills the shared depth buffer before lighting. A renderer-owned `Ssao` subsystem then reconstructs view-space position + normal from depth alone (no normal G-buffer — analytic unprojection from the projection matrix) and writes an **R8G8** target: R = hemisphere-kernel ambient occlusion, G = a sun-direction screen-space **contact-shadow** ray-march. The forward shader samples it to multiply SSAO into the IBL/ambient terms and the contact term into the **direct sun** (ambient stays on pure CSM). A **depth-aware bilateral blur** (5×5, view-space-Z edge-stop) smooths the per-pixel sampling/march noise without bleeding across silhouettes, with TAA carrying the temporal denoise. SSAO and contact shadows are on by default; contact shadows fill the CSM's short-range contact gap and use an N·L gate plus view-Z-scaled depth window and silhouette edge guard to avoid screen-space streaks. Live overlay sliders (radius / bias / intensity / power, contact length) and a `--debug-ssao` view
- **SSAO + contact shadows** — a **depth prepass** (reusing the forward vertex shader with `invariant gl_Position`; the forward pass loads it with `LESS_OR_EQUAL`) fills the shared depth buffer before lighting. It applies the material's **alpha cutout** through the same shared test the forward and shadow passes use, so a MASK material writes depth only where it is actually opaque — otherwise it occludes across its own holes, hiding geometry behind a leaf card and making the AO below treat it as a solid sheet. A renderer-owned `Ssao` subsystem then reconstructs view-space position + normal from depth alone (no normal G-buffer — analytic unprojection from the projection matrix) and writes an **R8G8** target: R = hemisphere-kernel ambient occlusion, G = a sun-direction screen-space **contact-shadow** ray-march. The forward shader samples it to multiply SSAO into the IBL/ambient terms and the contact term into the **direct sun** (ambient stays on pure CSM). A **depth-aware bilateral blur** (5×5, view-space-Z edge-stop) smooths the per-pixel sampling/march noise without bleeding across silhouettes, with TAA carrying the temporal denoise. SSAO and contact shadows are on by default; contact shadows fill the CSM's short-range contact gap and use an N·L gate plus view-Z-scaled depth window and silhouette edge guard to avoid screen-space streaks. Live overlay sliders (radius / bias / intensity / power, contact length) and a `--debug-ssao` view
- **GPU soft-body / cloth (XPBD)** — `-c` drops a cloth that simulates entirely on the GPU, or author one on any glTF mesh with `extras.Cloth` (samples: `assets/ClothSheet/ClothSheet.gltf`, `assets/ClothBanner/ClothBanner.gltf`). A renderer-owned `SoftBodySystem` runs an XPBD compute solver each substep: `cloth_predict` integrates gravity + wind, `cloth_solve` projects distance constraints **graph-coloured** into race-free batches (Gauss-Seidel by colour), `cloth_collide` pushes particles out of world colliders, and `cloth_finalize` writes solved positions + normals (recomputed from a per-vertex→triangle **CSR adjacency**, so arbitrary meshes work, not just grids) into a storage **vertex buffer** the forward/shadow passes read — so the cloth renders, lights, and casts shadows through the normal forward path (double-sided), no new render shaders. The solver is **descriptor-free**: every buffer reaches the shaders as a `bufferDeviceAddress` pointer. Collision primitives (plane / sphere / box / capsule) are gathered each frame from `PhysicsWorld` (`gatherColliders`) plus a ground plane. Constraint stiffness is authored per type (structural/shear stiff, bend soft); substeps, a global **compliance multiplier**, damping, gravity, and wind are **live overlay sliders**. Built on the same compute + buffer-barrier path as particles
- **Temporal anti-aliasing (TAA)** — sub-pixel Halton(2,3) projection jitter plus velocity-buffer history accumulation anti-aliases geometry edges *and* specular/shading shimmer (unlike MSAA, which only covers geometry edges). The forward + transmission passes write a screen-space motion-vector attachment; the resolve reprojects the previous frame's history along it (`historyUV = uv − velocity`), neighbourhood-clamps to the current 3×3 to suppress ghosting/disocclusion, and blends. Motion vectors are jitter-free so the jitter cancels in accumulation. Per-node previous-world-matrix tracking feeds rigid + animated motion (skinned deformation is camera-motion-only in v1); particles render after the resolve, kept out of history. `--no-taa` reverts to the raw image, `--debug-velocity` visualises the buffer
- **Frustum culling (camera + shadow casters)** — built on a reusable fat-AABB BVH (`AabbBvh<T>`, the same core the physics broadphase and static-mesh triangle index use). Two stages: a **persistent scene BVH** (`SceneCuller`, an `AabbBvh<Node*>` over rigid renderables) pre-culls each frame against the union of the camera frustum and every shadow caster's frustum, so off-screen nodes skip draw-building entirely (no UBO writes, no per-vertex bounds) — `O(log N + visible)` instead of `O(N)`; then a **precise per-pass cull** drops the survivors that fall outside a given pass's frustum (`buildDrawBuckets` for the camera, per-cascade/spot/point-face in the shadow pass). Frustums are 6 Gribb–Hartmann planes (Vulkan `[0,1]` depth) with a conservative positive-vertex AABB test (no false negatives). Deformable (skinned/morph) meshes skip the coarse BVH (bind-pose bounds under-cover the animated pose) and rely on the precise stage's exact per-frame world bounds. The coarse union is a strict superset of what any pass keeps, so culling never drops a visible draw. Live overlay toggle + tracked/visible/culled counts; off submits everything (A/B + regression escape hatch)
Expand Down
15 changes: 9 additions & 6 deletions cmake/check_shader_blocks.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ endif()

# <block name>|<the one file allowed to declare it>
#
# SH-05 added the last three. `ShadowPushConstants` had been hand-copied into three shadow stages
# (and would have been four the moment the masked path arrived), and push constants are a raw byte
# range with no reflection at all — strictly worse than a UBO, which at least has a declared size.
# `Materials` and `MaterialData` became shared the moment a shadow fragment path had to apply the
# VISIBLE material's alpha cutout: a second copy of that struct is a second cutoff, a second UV-set
# choice and a second transform, and a shadow disagreeing with its own surface reads as a bias bug.
# SH-05 added `Materials` and `ShadowPushConstants`; the cutout-aware depth prepass added
# `ForwardPushConstants`. The two push blocks are the worst case of the kinds guarded here: a raw byte
# range with no reflection at all — strictly worse than a UBO, which at least has a declared size —
# and `ShadowPushConstants` had already been hand-copied into three shadow stages before a fourth
# needed it. `Materials` and `MaterialData` became shared the moment a depth-only pass had to apply
# the VISIBLE material's alpha cutout: a second copy of that struct is a second cutoff, a second
# UV-set choice and a second transform, and a pass disagreeing with its own surface reads as a bias
# bug.
set(guarded_blocks
"LightUBO|light_ubo.glsl"
"Materials|material.glsl"
"ShadowPushConstants|shadow_push.glsl"
"ForwardPushConstants|forward_push.glsl"
)

set(offenders "")
Expand Down
6 changes: 5 additions & 1 deletion docs/acceptance-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,11 @@ this order:
- The cutout must cast a **perforated** shadow on the floor around x [−4.1, −0.3],
z [−10.8, −6.9] — the checker pattern of its own base-colour alpha, matching the perforation you
see on the surface. A solid rectangle there means the masked shadow path has stopped applying
(`shadow_masked.frag`, or the caster's `ShadowCasterAlpha` classification). It is authored
(`shadow_masked.frag`, or the caster's `ShadowCasterAlpha` classification).
- You must also **see through** the perforation: its holes show the white floor low down and the
dark sky higher up. Holes that read uniformly dark mean the DEPTH PREPASS has stopped applying
the cutout (`depth_prepass.frag`), so the forward pass is depth-rejecting everything behind
them — a separate fix from the shadow one, and easy to mistake for a material bug. It is authored
double-sided, and now for a milder reason than before: a single-sided caster no longer has to
put the sun on its back face to cast at all, but keeping it double-sided is what exercises the
masked *and* two-sided mode together.
Expand Down
23 changes: 21 additions & 2 deletions docs/codereview.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,29 @@ with the chosen no-alias contract rather than retaining that behaviour as an acc

---

## Out-of-tier finding — the depth prepass ignores the alpha cutout (2026-08-03)
## Out-of-tier finding — the depth prepass ignores the alpha cutout (2026-08-03) — ✅ CLEARED

Found while landing **SH-05** (material-aware shadow casters), in the same class of defect and in a
different pass, so it is recorded here rather than folded into that item.
different pass, so it was recorded here rather than folded into that item. **Fixed on branch
`depth-prepass-alpha-cutout`** (2026-08-04): the prepass pipeline opts into the bindless set, carries
`ForwardPushConstants`, and its fragment stage applies the same `material.glsl` cutout the forward and
shadow passes use — gated on the material's own `alphaCutoff`, which is exactly equivalent (a
non-MASK material publishes 0, and at 0 the test can never discard) and costs an opaque draw one
scalar SSBO read instead of a texture fetch. No CPU-side classification was added: the prepass needs
none, unlike the shadow path, whose LOD pin is a CPU decision.

Both decisions the finding flagged were settled rather than assumed. **BLEND** stays out of the
prepass by virtue of its bucket, and would be inert anyway (cutoff 0). **Depth equality** is
preserved: a fragment discard does not perturb `gl_Position`, and the two passes evaluate one
implementation on the same UVs from the same vertex path, so neither can keep what the other drops.

Measured effect, for the record: the ShadowLodDemo capture's cutout holes now show the floor and sky
behind them instead of reading uniformly dark, and the SH-03 sweep's measured shadowed area moved
12.07% -> 11.68% — the change localised (by differencing the two runs' area masks) to the cutout
quad's own footprint, with the absolute differing-pixel counts unchanged at every printed digit. See
`render/constants.hpp`.

The finding as originally written follows.

### High: `depth_prepass.frag` writes depth through a MASK material's holes

Expand Down
Binary file modified docs/images/shadow-lod-full-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/shadow-lod-selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 26 additions & 5 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,10 +929,29 @@ the same change — most have a test or guard that will catch you, but not all.
is visible in the panel, while the optimistic one silently restores a cutout casting its quad.
BLEND classifies as `Opaque` deliberately — its shadow semantics are an open design decision, and
the material authority publishes `alphaCutoff` 0 for it anyway.
- **The alpha-cutout test has ONE implementation, and the shadow pass uses the forward one**
(SH-05). `materialAlphaCutoutFails` / `materialBaseColourTexel` / `materialSlotUv` live in
[`shaders/material.glsl`](../shaders/material.glsl); `shader.frag`, `shadow_masked.frag` and
`self_shadow_second_masked.frag` all call them. A shadow that tested a different cutoff, UV set or
- **The GPU material's alpha and alphaCutoff ranges are enforced at the packing seam**, and one
optimisation depends on it. `toMaterialUBO` (`src/graphics/material_binding.cpp`) clamps the packed
alpha into glTF's [0,1] and the packed cutoff to >= 0, warning when it has to — `Material` is a
plain value type that accepts any float, so the guarantee is made where the value becomes GPU
truth. The two clamps differ in kind, and the difference is worth stating: the CUTOFF clamp is
behaviour-preserving (a negative cutoff already discarded nothing), while the ALPHA clamp
deliberately CHANGES what an invalid value does — a negative alpha used to discard, and clamped to 0
the fragment is kept. That is glTF-spec normalisation of nonsense input, not preservation of it. The
warning is emitted once per material from `Resources::registerMaterial`, never from the packing path,
which is reachable from `noexcept` variant queries. What breaks without the invariant:
`shader.frag` applies `alpha < alphaCutoff` to EVERY material, since a non-MASK one packs cutoff 0,
so a negative alpha would discard an OPAQUE surface — while `depth_prepass.frag`, which SKIPS that
test when the packed cutoff is 0, would keep the fragment and leave a depth-only occluder. Pinned by
`MaterialBinding.PackingEnforcesTheAlphaRangeInvariant`. If you add a packed material field that a
shader branches on, ask what the branch assumes about its range.
- **EVERY pass that writes depth applies the alpha cutout, and they all use ONE implementation**
(SH-05, extended to the depth prepass). `materialAlphaCutoutFails` / `materialBaseColourTexel` /
`materialSlotUv` live in [`shaders/material.glsl`](../shaders/material.glsl); `shader.frag`,
`shadow_masked.frag`, `self_shadow_second_masked.frag` and `depth_prepass.frag` all call them.
A depth-only pass that skips the test does not merely look wrong where the cutout is — it writes
occlusion across the holes, so the DEPTH PREPASS rejected everything behind a leaf card in the
forward pass and made SSAO treat it as a solid sheet, while the SHADOW pass cast the quad instead
of the leaf. If you add a pass that writes depth, that is the question to ask of it. A shadow that tested a different cutoff, UV set or
`KHR_texture_transform` from its own surface would cast a silhouette the surface does not have, and
the symptom reads as a shadow-bias artefact rather than as a mask bug. There is deliberately no
shadow-only material format: the shadow pass reaches the same bindless `materials[]` entry through
Expand Down Expand Up @@ -1015,7 +1034,9 @@ the same change — most have a test or guard that will catch you, but not all.
([`shaders/light_ubo.glsl`](../shaders/light_ubo.glsl)), the bindless `Materials` SSBO +
`MaterialData` struct ([`shaders/material.glsl`](../shaders/material.glsl), shared by `shader.frag`
and the SH-05 masked shadow paths) and the `ShadowPushConstants` push block
([`shaders/shadow_push.glsl`](../shaders/shadow_push.glsl), shared by four shadow stages). A PUSH
([`shaders/shadow_push.glsl`](../shaders/shadow_push.glsl), shared by four shadow stages) and
`ForwardPushConstants` ([`shaders/forward_push.glsl`](../shaders/forward_push.glsl), shared by
`shader.frag` and `depth_prepass.frag`). A PUSH
block is the worst case of the three: it is a raw byte range with no driver-side reflection at all,
so a member added to one copy silently reinterprets every field after it in the others — the C++
side is pinned by `offsetof` static_asserts on `ShadowPushConstants` in
Expand Down
Loading
Loading