Lights and Shadows #731

Merged
SakulFlee merged 147 commits from lights_and_shadows into main 2026-07-28 18:02:01 +00:00
Owner
No description provided.
cgmath::perspective/ortho produce OpenGL-style NDC (z in [-1,1]) which
wgpu clips below 0: geometry within ~2fn/(f+n) of the camera vanished
and half the depth range was wasted. All engine projections now come
from perspective_wgpu/ortho_wgpu. Frustum plane extraction updated to
the [0,1] near-plane convention (row2 instead of row3+row2).
- Spot lights no longer abuse the point-light cube path (6 wasteful 90°
  faces with SPOT_LIGHT_NEAR=1.0 clipping everything within ~2 units).
  A spot now renders ONE perspective depth map into the shared 2D array,
  covering exactly its outer cone (near 0.1, far 100).
- All shadow projections (spot/point/CSM) now use wgpu-convention
  matrices (z in [0,1]) instead of cgmath's OpenGL-convention ones —
  the root cause of the all-black shadows: stored depth never matched
  the shader's reference depth, and the near half of each frustum was
  hardware-clipped.
- CSM ortho and spot/point perspective are Y-flipped to match the
  shader's ndc.xy * 0.5 + 0.5 sampling (fixes mirrored CSM lookups).
- CSM frustum corner extraction now uses near plane z_ndc = 0 to match
  the migrated main camera projection.
- Fix latent per-slot matrix-offset mapping: point lights consume 6
  matrix-buffer entries, which shifted every subsequent slot's dynamic
  offset (a directional light after a point light read the wrong VP).
- Shadow samplers use Linear filtering (bilinear PCF per compare tap).
params.xy now carries precomputed cosine-domain scale/offset:
  angular = clamp(cos_theta * scale + offset, 0, 1)
matching the glTF sample viewer, instead of raw inner/outer angles
(the old shader-side angle-domain math divided by zero when
inner == outer). Swapped angles are sorted, the denominator is
epsilon-clamped. Unit tests cover plateau/edges, equal angles,
swapped angles and the 64-byte buffer layout.
Spot BRDF branch:
- glTF KHR_lights_punctual cosine-domain angular attenuation using the
  CPU-precomputed scale/offset (no more acos, no div-by-zero)
- clamped inverse-square distance attenuation (no singularity at d=0)

Shadow sampling:
- new SHADOW_TYPE_SPOT branch: projective sampling of the 2D depth
  array (single perspective map per spot, replaces the cube-map hack)
- sample_shadow_2d_pcf: real 3x3 PCF (9 hardware-compare taps) for the
  2D-array path (spot + directional), replacing the single-tap +
  'positional bias' hack that was misnamed PCF
- slope-scaled depth bias (1x-3x by NdotL) for all light types,
  replacing the constant-bias + dist^2 hack
- point cube path keeps its depth reconstruction (now exactly correct
  with the wgpu-convention face projection) and gains the slope bias

Also adds naga-based WGSL validation tests (same frontend wgpu uses)
so runtime-loaded shaders are verified by cargo test.
The near plane is at z_ndc = 0 with the migrated camera projection,
not -1 — the drawn/frozen frustum near corners were wrong otherwise.
Imported lights were parsed into GltfImportResult but dropped at the
ImportResult boundary and never became entities. ImportResult now
carries lights through, and sys_poll_importer spawns each with
LightDescriptorEcs + Position + LightDirty + a default ShadowCaster,
so glTF scenes with punctual lights (incl. spot) work end-to-end.
Delete shadow_debug_*.pgm dumps, shadow_trace_*.gfxr, RenderDoc.cap,
game-*.log and syncthing conflict files left over from the failed
shadow debugging saga. Correct the cascade_count comments in the
examples (the field is ignored for spot lights; spots always render a
single perspective depth map now).
The 4096 bump was made during the failed shadow debugging saga; with
correct projection conventions + 3x3 PCF it is no longer needed. 2048
halves depth memory (the 2D array starts at 16 layers) while staying
sharp for spotlight cones.
- World-space bias (same approach as Blender/Unreal): offsets the
  reference point toward the light before shadow projection,
  eliminating self-shadowing from the interpolation discrepancy
  between the fragment shader's world_pos and the GPU-rasterized
  shadow depth.  ~0.01 unit at grazing angles (~half a texel).

- Slope-scaled depth bias widened: [1x, 3x] -> [1x, 6x] to handle
  the larger depth range of perspective spot projections.

- Spot shadow near/far tightened: 0.1->1.0 near, 100->50 far.
  Improves depth precision near the light and matches the useful
  attenuation range (1/2500 at 50 units).

- Increased default bias: 0.0002 -> 0.001 (Blender's default).

- IBL ambient floor: clamp ambient to vec3(0.015) minimum, preventing
  pure-black (0,0,0,1) shadows when environment maps are unavailable
  or very dark.
0.8/1.2 rad (46/69 half-angle) was a debugging leftover that
flooded the entire scene. 0.3/0.5 (~17/~29) is a realistic
spotlight cone matching the gltf_pbr_damaged_helmet example.
- World-space bias: ×10.0 -> ×2.0. At 10x the offset was ~6
  shadow texels, visibly shifting the shadow away from the cube.
  At 2x (~1.2 texels) self-shadowing is still prevented without
  a noticeable gap between caster and shadow.

- Remove abs() from n_dot_l: use clamp(n_dot_l, 0, 1) instead of
  abs(). Backfaces were getting max bias, causing false self-
  shadow artifacts on surfaces facing away from the light
  (e.g. cube top face edge).

- Ambient floor: 0.015 -> 0.003. The 1.5% gray added to every
  pixel washed out the entire scene. 0.3% is still visible as
  fill in shadowed areas but imperceptible in lit regions after
  ACES tonemapping.
- Depth slope: [1x, 6x] -> [1x, 1.5x]. The 6x range was tuned
  when depth bias was the ONLY bias. Now that the world-space bias
  handles self-shadowing, this only needs to cover sub-texel
  residual.  The old combined 8-texel offset separated shadow from
  its caster and caused false shadowing on correctly-lit walls.

- Default bias: 0.001 -> 0.0005.  Halves the world-space offset
  too (from ~0.002 to ~0.001 units at grazing angles).
The back wall is black despite being inside the cone and having its
depth stored in the shadow map. This isolates whether the world-space
bias is pushing the reference toward a different texel (floor depth
instead of wall depth), causing false shadowing.
- R = shadow_coord.x  (0=left texture edge, 1=right)
- G = shadow_coord.y  (0=top, 1=bottom)
- B = ndc.z           (0=near, 1=far — brighter = farther)
- Red = fragment outside spot frustum (no SPOT slot found)

If floor and back wall show same RGB, they overlap in the shadow
map at those texels. If different, the stored depth should match
the wall's own depth, implying a matrix or buffer discrepancy.
The spot VP is uploaded through two paths:
1. matrix_bytes -> used by shadow depth pass (known: stored wall depth)
2. vp.into() -> stored in ShadowSlotData -> read by fragment shader

If they differ byte-for-byte, the fragment shader computes a different
projection than what the shadow pass stored, explaining the false
shadowing on the back wall. Logs 'OK' or 'MISMATCH' per frame.
Black (0.0) = near plane. White (1.0) = far plane.
Wall at ~10 units from light should show ~0.92 (bright gray).
Floor at ~6 units should show ~0.88 (medium gray).
If wall is black or white at extremes, projection is putting the
wall depth outside [0,1].
Phase 1 — Spot shadow diamond fix:
- Remove world-space bias from spot path; the per-fragment n_dot_l
  variation under a position-based to_light creates bias
  discontinuities at quad diagonals (the diamond artifact pattern).
  Directional/point paths keep world_bias (constant L = no seams).
- Perspective-correct depth bias scaling: SPOT_BIAS_SCALE/dist^2
  compensates for the non-linear 1/z depth distribution of the
  perspective projection, preventing peter-panning at distance and
  acne near the light.
- PCF boundary safety: clamp center UV to [texel, 1-texel] so all
  9 taps stay within the shadow map (prevents wraparound at edges).
- Cone margin: 2% wider shadow FOV than light cone prevents the
  bright-ring artifact at the cone silhouette.

Phase 2 — Shading correctness:
- Fresnel: use V·H (not N·V) for direct-lighting Fresnel term.
  IBL path already correctly uses N·V.
- Metallic diffuse: kD = (1 - kS) * (1 - metallic) so metals get
  zero diffuse contribution per PBR spec.
- Point light epsilon: 1/max(d^2, 0.01) matches spot branch,
  prevents Inf/NaN at exactly d=0.
3a. Normal re-normalization after model-matrix transform in vertex
    shader. Non-uniform scale still needs inverse-transpose, but no
    meshes currently use it; re-normalize handles uniform scale.

3b. Remove gamma-decoding from IBL diffuse/specular reads. The
    environment maps are HDR linear (Rgba16Float), not sRGB.
    Previously pow(sample, 2.2) incorrectly darkened ambient.

3c. BRDF LUT roughness: use roughness directly instead of
    1.0-roughness. The LUT was baked with raw roughness on the V
    axis (confirmed from ibl_brdf.wgsl). Was inverting it.

3d. Confirmed: surface is sRGB swapchain format; GPU auto-applies
    display gamma on write. No explicit pow(x, 1/gamma) needed.

3e. Cascade selection: use view-space depth (along camera forward)
    instead of Euclidean distance. CPU splits are view-space;
    Euclidean over-estimates at frustum edges causing premature
    cascade transitions.
The fragment shader recomputes ndc from interpolated world_pos,
but world_pos is perspective-correct w.r.t. the main camera's clip
space — the shadow pass uses the spot light's clip space.
This discrepancy caused surfaces at shallow angles (floor-to-wall
junction) to project to overlapping shadow texels, producing the
black diamond.

The vertex shader now computes slot.light_view_proj * world_pos
for up to 8 spot shadow slots and outputs these as sc0..sc7
inter-stage vec4s. The fragment receives perspective-correct
interpolation w.r.t. the spot light's clip space, matching the
rasterized shadow depth exactly. Beyond 8 spots, the old world_pos
recomputation path is used as fallback.
Red channel = abs(reference_ndc.z - stored_depth) * 10.
- Floor should be near-zero red (self-consistent: ref ≈ stored)
- Back wall should be bright red (stored depth is floor's ~0.91,
  not wall's ~0.93) -> proves geometric overlap in shadow space.
- Green = outside frustum. Blue = no spot slot found.
Each spot light now produces two shadow slots:
- Near map: 1.0-8.0 units, captures floor & close geometry
- Far  map: 8.0-50.0 units, captures walls & distant geometry

The split prevents floor and wall from overlapping in shadow space.
`cascade_split_depth` acts as the gate: fragments beyond the slot's
depth range skip to the next SPOT slot. The far map's near plane (8.0)
clips the floor entirely, so walls' depth is stored without
contamination.

Shader: added depth-gate check in SPOT branch; changed 'outside
frustum' from return-lit to continue (so far slot can be tried).
The dual-depth shadow fix is in place; revert the textureLoad
diagnostic and restore the full pipeline (IBL, light contribution,
emissive, ACES tonemap).
Left half of screen -> sc0 UV (near slot VP).
Right half         -> sc1 UV (far  slot VP).
If sc1 shows different colors from sc0: far VP differs (correct).
If sc1 shows black: vertex shader not populating sc1.
Remove Rooms 2, 3, 4 from the RON scene to test whether
other rooms' geometry contaminates the spot shadow map.
Only Room 1 walls (floor, back, left, right) + blue cube remain.
The spot light now oscillates left-right over ±3 units (~4s period)
so we can observe whether the black artifact moves with the light.

All files marked TEMPORARY — revert after diagnosis.
The shadow projections used flip_y=true (negating ndc.y in the matrix)
but the shader sampled with ndc.xy*0.5+0.5 (no Y inversion).
The stored depth landed at a different framebuffer V-position than
the texel the shader read — shadows were sampled from the wrong
surface. A shadow cast onto the floor appeared on the back wall,
and vice versa.

Fix: remove flip_y from all shadow projections (spot, point, CSM)
and instead flip Y in the shader's shadow-coord remap:
  ndc.y * 0.5 + 0.5  ->  0.5 - ndc.y * 0.5

This correctly maps NDC +Y (framebuffer top) to texture V=0 (top row).
Red    = <1.5 units (near-plane danger zone)
Red→Green gradient = 1.5-8 units (near map range)
Green→Blue gradient = 8-50 units (far map range)
White  = >50 units (beyond far plane)
Fragments with shadow_coord.z < 0.005 (extremely close to the near
plane) skip the PCF comparison and return lit. This prevents a sharp
brightness discontinuity at the clipped edge, which was visible as
a bright line when the spot light moved low (y~5).
The grace check (shadow_coord.z < 0.005 → lit) caused interference
patterns. Instead, eliminate the root cause: the 1.0 near plane was
clipping floor fragments near the light. At 0.1, nearly nothing is
clipped. The perspective bias formula already scales bias by
50/dist², which is generous near the light, preventing self-shadowing
even with the tighter near plane.
SPOT_BIAS_SCALE/dist^2 can reach 500× at 0.1 units from the light,
detaching the shadow from its caster and creating a bright ring.
Clamped at 10× (~0.003 ndc max) the bias stays effective against
acne without causing visible peter-panning.
- Restore unbounded perspective bias (50/dist^2). The cap created
  a hard discontinuity causing moire-like interference patterns.
- Remove 2% cone margin (* 1.02). After the Y-mirror fix, the
  shadow frustum should match the light cone exactly. The margin
  was causing a bright ring where the cone edge lit fragments
  that fell outside the shadow frustum.
This reverts commit 520198c7ec767fc2d690375ba7cdd74a0ec2c205.
This reverts commit 0037663df5d02d3ccce8bed4c265fe31da873963.
This reverts commit 2e85c80f212f8b56d6f98b5ec5554aa63e4cedc8.
The unbounded 50/dist^2 formula reached 500x at 0.3 units from the
light, forcing the PCF comparison to always pass (spot_bias >
entire depth range). This created a bright, artificially-lit disc
with a sharp circular boundary where the curve transitioned from
"oversized" to "reasonable."

Clamped to [1x, 8x]:
- max 8x at <2.5 units (~0.0024 ndc bias) — prevents blowout
- min 1x beyond ~7 units — ensures base bias remains effective
- No hard discontinuity like a bare min() cap because the clamp
  stays constant in the near zone (plateau, not cutoff)
When a fragment is closer to the light than the near plane, clip_pos.w
becomes near-zero or negative. Dividing by it scrambles ndc.xy and
ndc.z, causing the shadow coordinate to land in random texels —
sometimes lit (bright disc), sometimes shadowed (underlit models).

The fix checks clip_pos.w <= 0.001 BEFORE the divide and treats such
fragments as unshadowed. This replaces the broken shadow_coord.z < 0.005
grace check which was applied after-the-fact on already-scrambled values.
Bypasses all shadow evaluation. Run the scene with the light low.
- Disc still appears -> bug is in attenuation/tonemapping, NOT shadows
- Disc disappears  -> bug is in the shadow comparison
Attenuation (calculate_light_brdf, spot+point):
- max(d*d, 0.01) -> max(d, 0.5)^2
  Prevents radiance blowout near the light (100x at 0.1 units)
  that ACES tonemap compresses into a flat, hard-edged disc.
  Caps max attenuation at 4x for physically-plausible falloff.

Shadow (compute_shadow_for_light, SPOT branch):
- Remove 1/dist^2 bias scaling and its clamp — both caused
  interference/moire through hard bias discontinuities.
- Replace with normal-offset bias: push sample point along the
  geometric surface normal before projection (~1-2 shadow texels).
  Handles grazing-angle acne geometrically without inflating depth.
- Keep small constant depth bias (bias*2) for residual sub-texel.
- Roughness floor: 0.0001 -> 0.045 (Frostbite/Unreal convention)
  The GGX distribution term spikes toward 1e6+ at near-zero
  roughness when NdotH ~ 1, causing a saturated disc even after
  attenuation clamping.

- Specular safety clamp: min(..., vec3(50.0))
  Complements the roughness floor to catch any remaining fireflies
  from the specular BRDF at extreme mirror angles.
If the bright disc persists:
  -> cause is angular attenuation, not specular GGX blowup.
If the disc disappears:
  -> cause IS specular; roughness floor alone wasn't enough.
White = flat plateau (inner cone, cos_theta >= cos(inner))
Gray gradient = falloff zone (inner -> outer)
Black = outside cone

If the boundary between white and gray is visibly sharp/stair-stepped,
the angular scale/offset have numerical precision issues.
Angular attenuation confirmed smooth (no numerical issue).
The inner-cone plateau is correct glTF punctual behavior.
Restore full pipeline: IBL, lights, specular, ACES tonemap.
- inner_cone_angle: 0.3 -> 0.1 rad (17° -> 6° plateau)
  Minimizes the glTF inner-cone flat-brightness region.
- Ambient floor: 0.003 -> 0.015 (5x brighter)
  Compensates for the dim scene with no IBL loaded.
- Ambient floor: 0.015 -> 0.005 (removes gray wash, subtle fill)
- Directional fill: warm-white, straight down, intensity 2.0,
  no shadows. Provides consistent base brightness across the
  scene so the spot cone is a visible brightening, not the
  only lighting source.
- Animation system kept.
Direction: (-0.5, -0.8, -0.3) -> soft diagonal sun-like angle
Position: (0, 10, 0) above the scene instead of at floor level
The debug arrow now points where the light comes FROM, matching the
shader's computation: L = normalize(-light.direction.xyz). Spot
cone left unchanged (correctly shows emission direction).
Each room gets a white spot light at (room_x, 8, 6) pointing
toward (room_x, 0, -2), same cone (0.1/0.5) and shadow caster
as Room 1. 8 shadow slots total (4 spots x 2 dual-depth maps).
Green=lit, Red=black (near slot). Cyan=lit, Yellow=black (far).
Orange=on frustum edge. Blue=no slot. Magenta=clip_pos.w guard.

Check the wall between Room 3 and Room 2. If a single wall polygon
shows mixed colors, the dual-depth split is cutting through it.
Slot type determined by cascade_split_depth < 20 (near=8, far=50).
The dual-depth split (near+far maps at 8.0 boundary) caused the depth
gate to cut through wall polygons, creating the triangle-shaped
shadow artifact where near/far slots disagreed on the same surface.
Since the Y-mirror fix already eliminated the original overlap problem,
a single perspective map per spot is now correct and clean.
The normal-offset was pushing the shadow sample upward along
the floor normal, consuming the entire shadow footprint and
making cast shadows invisible.  Replaced with a clamped
perspective depth bias: bias_scale = clamp(50/dist^2, 0.5, 5.0).
Upper bound (5x) prevents the bright disc; lower bound (0.5x)
ensures anti-acne at far distances.

PCF now uses bias * bias_scale instead of bias * 2.0.
The vertex shader counts SPOT slots globally (across all lights)
but the fragment shader counted only per-light. With multiple
spot lights, this caused room 2/4 fragments to read room 3's
sc0 clip values, creating completely mismatched projection vs.
depth-map comparisons.

Fragment now increments spot_idx for EVERY SPOT slot in array
order (same as vertex), regardless of light_index.
4096^2 x 16 initial layers = 1 GB for the 2D depth array alone,
plus cube textures. 2048 cuts per-layer memory from 64 MB to
16 MB, total from ~1.4 GB to ~350 MB.
- Rooms 1&2 (metallic/non-metallic): point lights for diffuse+specular
- Room 3 (shadow test): animated spot keeps ShadowCaster
- Room 4 (helmet gallery): spot light kept for consistent lighting
- Room 5 (new helmet room at x=20): 3 spot lights in a ring around
  the helmet with ShadowCaster for multi-angle shadows
- HelmetAdjuster now places helmet at x=20 in Room 5
- Point lights now at room center (x, 5.0, 0.0)
- Room 4: 3-spot ring around helmet at x=10 (warm, shadows)
- Room 5: 8 colorful point lights in ring around helmet at x=20
- HelmetAdjuster spawns helmet at both x=10 and x=20
8 point lights x 6 cube faces each = 48 additional shadow render
passes. Tests the engine's cube shadow array capacity (max
16*6=96 faces total).
Cube shadow maps use direction-vector lookups, not UV-based
sampling. The cube-face framebuffer requires a Y-flip in the
projection to match the hardware cube-texture layout. This was
removed when all shadow projections were unified to flip_y=false
for the Y-mirror fix, but cube faces are the exception.
The sc0-sc7 vertex-clip path was added to fix the black diamond
artifact, but the diamond was caused by the Y-mirror bug (now
fixed).  The interstage variables (128 bytes/vertex + 10-line
vs_clip lookup) served no purpose.  Cleaned up:
- Removed sc0-sc7 from FragmentData
- Removed vertex-shader clip-population loop
- Removed vs_clip if/else chain from SPOT branch
- Removed spot_idx counter (only needed for vs_clip indexing)
- Removed fin: FragmentData param from shadow functions
- Spot branch now directly uses world_pos projection
2D depth array: 1u32.max(max_slots) -> 1 (16 MB initial, ~64 MB
with 4 spots). Cube array already starts at 1 cube. Both grow
via ensure_layers/ensure_cubes when needed.

Memory reduction: 256 MB -> 16 MB with no shadow-casting lights,
64 MB with 4 spots (vs 256 MB before).
Replaces the No-environment (pitch-black ambient) with the same
HDR environment used in the DamagedHelmet demo.  Provides
realistic diffuse + specular ambient from the environment probe.
The model pass's color attachment LoadOp::Clear(Color::BLACK) was
overwriting the skybox pass's output. Changed to Load to preserve
the skybox background. Models correctly occlude the skybox via
depth testing (depth cleared to 1.0, model fragments < 1.0).
Remove debug console spam: light listing + shadow slot log
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
37a28182f1
- Removed per-frame realtime light descriptor dump (log::info! loop)
- Shadow depth pass slot log: debug! -> trace! (only visible with
  RUST_LOG=trace)
Merge branch 'main' into lights_and_shadows
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
8e6306a24a
Author
Owner

Closes #732

Closes #732
style: auto-format code
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
d04ab9162c
fix: auto-fix clippy warnings
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
72ae7bae36
style: auto-format code
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
cb800388f4
fix: auto-fix clippy warnings
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
622d43b1e3
style: auto-format code
All checks were successful
ci/woodpecker/pr/linting Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
51b58a6b3a
Sign in to join this conversation.
No description provided.