From a153d89f2869c1315305759f8979765f5aaeab8c Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 11 Sep 2026 11:32:07 +0100
Subject: [PATCH] =?UTF-8?q?MONKEY:=20outdoor=20torch=20shadows=20step=20(b?=
=?UTF-8?q?)=20=E2=80=94=20terrain=20receives?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
TerrainExtension binds the shared torch depth array, comparison sampler and table
(91/92/93); terrain.wgsl re-evaluates the vertex-picked exterior point entries per
fragment with the cube-map shadow at night (sampled 0.15 yd along the terrain normal),
blended by 1 - sun strength only inside a night branch so daylight is bit-identical.
interiorDebug 2 paints the terrain torch factor. Whole-file naga probe for terrain.wgsl.
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-assets/src/materials.rs | 85 ++++++
.../benilla-assets/src/shaders/terrain.wgsl | 275 +++++++++++++++++-
crates/benilla-world/src/terrain_stream.rs | 22 +-
3 files changed, 372 insertions(+), 10 deletions(-)
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 4b93f1da..685f24a9 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -663,6 +663,24 @@ pub struct TerrainExtension {
/// `terrain.wgsl` reads it as `var wow_light` (rows 0-5: light + fog + farclip).
#[storage(90, read_only, buffer)]
pub light_buf: Buffer,
+ /// MONKEY (outdoor torch shadows: terrain): the torch receiver's three bindings, at the SAME
+ /// numbers [`WowModelExt`] uses (91 depth array / 92 comparison sampler / 93 table) and for the
+ /// same reason - a Bevy material draw sets groups 0/1/2 only, so a receiver's shadow inputs have
+ /// to ride its own material group. Both resources are created ONCE at startup
+ /// (`benilla_world::assets`, beside the shared light buffer) and are ALWAYS bound: a missing
+ /// image would stall the tile's bind group on `RetryNextUpdate` and blank the GROUND, so the
+ /// spawn site idles until `TorchShared::binds()` is `Some` rather than baking a default.
+ ///
+ /// Fragment-only, like the splat/alpha/shadow arrays above (Bevy 0.18 narrows textures and
+ /// samplers but not uniforms) - the vertex stage only picks WHICH lights, it never samples.
+ #[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
+ #[sampler(92, sampler_type = "comparison", visibility(fragment))]
+ pub torch_depth: Handle,
+ /// The 6416-byte `TorchTableUniform` bytes (count / positions[16] / view_projs[96]) as the same
+ /// shared raw storage buffer the model lane binds, rewritten in place each frame - read by
+ /// `terrain.wgsl` as the byte-identical `var torch_table: TorchTable`.
+ #[storage(93, read_only, buffer, visibility(fragment))]
+ pub torch_buf: Buffer,
}
impl MaterialExtension for TerrainExtension {
@@ -678,6 +696,73 @@ impl MaterialExtension for TerrainExtension {
#[cfg(test)]
mod tests {
+ /// MONKEY (outdoor torch shadows: terrain): the GROUND receiver's three contracts with the
+ /// model lane, all of which fail SILENTLY at runtime rather than at compile time.
+ ///
+ /// 1. **The bindings.** `TerrainExtension` and [`WowModelExt`] must claim the SAME numbers
+ /// (91 depth / 92 comparison sampler / 93 table). The Rust attributes and the WGSL `@binding`
+ /// lines are two independent declarations of one layout, and a mismatch is not a build error
+ /// — it is a bind-group validation failure at first tile spawn, i.e. no ground.
+ /// 2. **The table layout.** All three receivers (`terrain.wgsl`, `wow_model.wgsl`, and
+ /// `static_gx.wgsl`'s std140 twin) mirror ONE 6416-byte buffer. Comments differ per lane on
+ /// purpose; the member lines must not, or terrain reads matrices at the wrong offsets and
+ /// projects shadows to the wrong place with nothing in any log.
+ /// 3. **Daytime identity.** The night lane must stay behind `ext_night_w > 0.0`. `fog_params.z`
+ /// saturates to exactly 1.0 above the daylight threshold, so that guard is what makes the
+ /// day render the same instructions AND the same bits it was before this feature — the whole
+ /// "cost zero by day" claim, in one line of shader.
+ #[test]
+ fn the_terrain_lane_receives_the_torch_maps() {
+ let terrain = include_str!("shaders/terrain.wgsl");
+ let model = include_str!("shaders/wow_model.wgsl");
+
+ for binding in [
+ "@binding(91) var torch_depth: texture_depth_2d_array;",
+ "@binding(92) var torch_samp: sampler_comparison;",
+ "@binding(93) var torch_table: TorchTable;",
+ ] {
+ assert!(
+ terrain.contains(binding) && model.contains(binding),
+ "the terrain and model torch receivers disagree on `{binding}` — the WGSL and the \
+ AsBindGroup attributes are two declarations of one layout, and this one blanks \
+ the ground at the first tile spawn"
+ );
+ }
+
+ // The struct's MEMBER lines only: each copy carries its own prose about its own lane.
+ let layout = |src: &str| -> Vec {
+ let body = src
+ .split_once("struct TorchTable {")
+ .expect("every torch receiver declares the table")
+ .1;
+ body.split_once("\n}")
+ .expect("the struct is closed")
+ .0
+ .lines()
+ .map(str::trim)
+ .filter(|l| !l.is_empty() && !l.starts_with("//"))
+ .map(str::to_owned)
+ .collect()
+ };
+ assert_eq!(
+ layout(terrain),
+ layout(model),
+ "the terrain and model `TorchTable` copies have drifted — they mirror ONE 6416-byte \
+ buffer (count / positions[16] / view_projs[96]); a member added, reordered or resized \
+ on one side moves every matrix offset on that side only"
+ );
+
+ assert!(
+ terrain.contains(
+ "let ext_night_w = select(0.0, clamp(1.0 - sun_shadow_strength, 0.0, 1.0), \
+ torch_ext_on());"
+ ) && terrain.contains("if (ext_night_w > 0.0) {"),
+ "terrain's exterior torch lane is no longer gated on `ext_night_w > 0.0` — daylight is \
+ now paying for (and possibly rendering) the night blend, and the day look is free to \
+ drift by a rounding step"
+ );
+ }
+
/// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox
/// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in
/// `benilla_world::sky_order::every_sky_shader_forces_the_far_depth` — this half lives here
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 7b718ef0..23131737 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -78,6 +78,142 @@ struct WowLight {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight;
+// MONKEY (outdoor torch shadows: terrain): the GROUND receiver's torch bindings - the SAME depth
+// array static_gx's group 3 samples and the SAME 6416-byte table, riding this material's own group
+// (`TerrainExtension` bindings 91/92/93) because a Bevy material draw sets groups 0/1/2 only.
+// ALWAYS bound (the image and buffer exist from startup), so no shader-def guards this block; only
+// the fragment stage reads it. The struct text is COPIED VERBATIM from wow_model.wgsl - std430 here,
+// std140 in static_gx, identical bytes because every member is 16-aligned - and a drift between the
+// three copies would not be a compile error, it would be terrain sampling the wrong matrices.
+struct TorchTable {
+ // MONKEY (static torch cache): byte-identical in ALL THREE shaders and TorchTableUniform.
+ // count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w flags.
+ // positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
+ // MONKEY (live bank rank): 96 static layers + 48 live layers; matrices stay slot-addressed.
+ count: vec4,
+ positions: array, 16>,
+ view_projs: array, 96>,
+}
+@group(#{MATERIAL_BIND_GROUP}) @binding(91) var torch_depth: texture_depth_2d_array;
+@group(#{MATERIAL_BIND_GROUP}) @binding(92) var torch_samp: sampler_comparison;
+@group(#{MATERIAL_BIND_GROUP}) @binding(93) var torch_table: TorchTable;
+// Reverse-Z receiver bias - KEEP IN SYNC with static_gx.wgsl / wow_model.wgsl's TORCH_BIAS (0.001,
+// the known-good value; larger detaches every shadow far from the torch).
+const TORCH_BIAS: f32 = 0.001;
+// NORMAL-OFFSET for the GROUND (yd): sample the map a hand's width above the surface along its own
+// normal. Terrain is the one receiver whose casters REST on it - a crate, a fence post, a standing
+// NPC - so the contact point is where receiver and caster depths agree to within a texel, i.e.
+// exactly where a plain compare produces acne. Offsetting along the terrain normal (not straight
+// up) is what keeps a SLOPE clean: on a 30 deg bank a vertical offset buys only `0.15*cos(30)` of
+// separation while the depth gradient across a texel has grown by `1/cos(30)`, so the margin
+// collapses on precisely the surfaces that need it most. The same 0.15 the entity lane uses, so a
+// character and the ground under it are offset alike and their shadows meet without a gap.
+const TORCH_NORMAL_OFFSET: f32 = 0.15;
+// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale,
+// unpacked from the table's `count.y` (stored x100 - the row is `vec4`).
+fn torch_soft() -> f32 {
+ return max(f32(torch_table.count.y) * 0.01, 0.05);
+}
+// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl - keep in sync): the EXTERIOR lane's
+// world-distance fade radius (yd) and the CPU's one-bit lane gate in `count.w`. See the static_gx
+// copies for the full rationale (the interior `ndc.z` fade is already down to 5 % at 10 yd, which
+// erases a campfire's 15-25 yd pool).
+const TORCH_EXT_FADE_YD: f32 = 44.0;
+const TORCH_EXT_LANE: u32 = 1u;
+// MONKEY (torch lane perf, MIRRORED from static_gx.wgsl): below THIS much unshadowed direct
+// contribution a fragment skips the torch table scan and its four comparison taps entirely, because
+// the shadow factor multiplies that contribution and can therefore only ever subtract less than this
+// from the frame. Terrain's combine is a plain modulate rather than the interior exposure rolloff,
+// so the worst possible step across the guard's boundary is 1e-4 of a fully-lit texel - two orders
+// under one 8-bit code. Most of the skips are exact zeros anyway (`nl` IS 0 on ground facing away
+// from the fire), so the threshold only has to be small enough to be invisible.
+const TORCH_SKIP_EPS: f32 = 1e-4;
+// The CPU's one-bit verdict: `exteriorShadows` AND night AND at least one promoted exterior
+// fixture. EVERYTHING this lane costs terrain hangs off this single uniform read.
+fn torch_ext_on() -> bool {
+ return (torch_table.count.w & TORCH_EXT_LANE) != 0u;
+}
+
+// MONKEY (Phase 5, COPIED VERBATIM from wow_model.wgsl - keep in sync): the cube face that contains
+// direction `d` (fixture -> fragment) - the major axis, signed. Face order is the contract with
+// `benilla_app::torch_shadow::cube_view_projs`: 0 +X, 1 -X, 2 +Y, 3 -Y, 4 +Z, 5 -Z.
+fn torch_face(d: vec3) -> u32 {
+ let a = abs(d);
+ if (a.x >= a.y && a.x >= a.z) {
+ return select(1u, 0u, d.x > 0.0);
+ }
+ if (a.y >= a.z) {
+ return select(3u, 2u, d.y > 0.0);
+ }
+ return select(5u, 4u, d.z > 0.0);
+}
+
+// MONKEY (outdoor torch shadows: terrain): this fixture's OWN cast shadow on a GROUND fragment -
+// `wow_model.wgsl`'s `torch_entity_shadow_at` with the exterior fade already chosen, because terrain
+// has no interior lane to share the body with (an MCNK cell is outdoors by definition, and the
+// interior half of the light table is skipped before ranking). Correlate the `wow_light` fixture at
+// `light_pos` to a promoted torch (position match within 1 yd), pick the cube face facing the
+// fragment, and sample that layer through the shared projector. 1.0 (unshadowed) when no map matches
+// - the lane is off, or the fixture was not among the promoted slots.
+fn torch_terrain_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32 {
+ // MONKEY (torch lane perf): the empty table is the common case, so answer it before the normal
+ // offset and the loop set-up.
+ if (torch_table.count.x == 0u) {
+ return 1.0;
+ }
+ // Normal-offset the sample point off the ground (see TORCH_NORMAL_OFFSET).
+ let Ps = P + N * TORCH_NORMAL_OFFSET;
+ for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
+ // MONKEY (static torch cache): holes and pending uploads never sample stale layers.
+ if (torch_table.positions[i].w <= 0.0) { continue; }
+ let fixture = torch_table.positions[i].xyz;
+ if (distance(fixture, light_pos) < 1.0) {
+ let face = torch_face(Ps - fixture);
+ let layer = i * 6u + face;
+ // MONKEY (live bank rank): count.z is CPU-ready-filtered; holes must not consume live
+ // cubes. Keep the projection on the static slot while compacting depth only.
+ let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u));
+ let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u);
+ // The EXTERIOR world-distance fade, measured from the FIXTURE (not the camera): this is
+ // "how far does this fire's shadow carry", a property of the fire. Chosen just inside the
+ // cube projection's own 48 yd far plane so the frustum edge is never a hard cut.
+ let fade = 1.0 - smoothstep(0.8 * TORCH_EXT_FADE_YD, TORCH_EXT_FADE_YD, distance(fixture, Ps));
+ let s = shadow_hook::torch_map_shadow(
+ torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS,
+ torch_soft(), fade);
+ // MONKEY (torch caster selection): `.w` is the slot's FADE WEIGHT, so a promoted fixture's
+ // shadow ramps in over ~1/3 s and a demoted one ramps out. The ground and the NPC standing
+ // on it MUST use the same weight or one shadow would pop while the other faded.
+ return mix(1.0, s, torch_table.positions[i].w);
+ }
+ }
+ return 1.0;
+}
+
+// MONKEY (torch debug, interiorDebug 2 on TERRAIN): the MIN raw cube-map shadow factor over every
+// promoted fixture (ignoring the fixture match) - mirrors static_gx's `torch_debug_factor` and
+// wow_model's `torch_entity_debug_factor`, so the GROUND's sampling can be SEEN as greyscale:
+// all-white = the table is empty / the projection misses the ground; shaped dark = the depth map
+// reaches the terrain and any remaining fault is downstream of sampling (correlation, weighting).
+fn torch_terrain_debug_factor(P: vec3, N: vec3) -> f32 {
+ let Ps = P + N * TORCH_NORMAL_OFFSET;
+ var s = 1.0;
+ for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
+ if (torch_table.positions[i].w <= 0.0) { continue; }
+ let face = torch_face(Ps - torch_table.positions[i].xyz);
+ let layer = i * 6u + face;
+ // MONKEY (live bank rank): debug samples the same compact bank as the lit path.
+ let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u));
+ let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u);
+ let raw = shadow_hook::torch_map_shadow(
+ torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS,
+ torch_soft(), -1.0);
+ // MONKEY (torch caster selection): the WEIGHTED factor, matching the real render.
+ s = min(s, mix(1.0, raw, torch_table.positions[i].w));
+ }
+ return s;
+}
+
// Custom vertex→fragment payload: the standard fields the splat fragment needs, PLUS `specular` — the
// per-vertex-clamped sun glint that must be Gouraud-interpolated (Q14). Same struct on both stages, so
// `@builtin(position)` is clip-position out / frag-coord in (`world_position` is unused until Step 5 fog).
@@ -100,6 +236,22 @@ struct TerrainVsOut {
// The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
// bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
@location(7) world_normal: vec3,
+ // MONKEY (outdoor torch shadows: terrain): WHICH <=3 exterior table entries `primary`'s point
+ // term was summed from, packed 10 bits each (see `EXT_SEL_EMPTY`). FLAT, because it is a CHOICE
+ // and not a quantity - interpolating three packed indices across a triangle would produce a
+ // fourth, meaningless one. It lets the fragment stage re-shadow the VERTEX's own selection
+ // instead of re-ranking, which is what keeps the selection popping at chunk granularity (the
+ // authored behaviour) rather than drawing a hard line mid-cell.
+ @location(8) @interpolate(flat) ext_sel: u32,
+ // MONKEY (outdoor torch shadows: terrain): `primary` WITHOUT the point term and WITHOUT the
+ // clamp - just `ambient + sun*max(N.L,0)`. `primary` is clamped per VERTEX (the GL T&L locus,
+ // see its note above) and that clamp is lossy: once a hot torch has saturated a vertex there is
+ // no way to subtract the point term back out in the fragment stage. So the sun/ambient half
+ // rides its own slot and the night lane rebuilds `clamp(base + shadowed_points)` from it. This
+ // costs 3 interstage components and is read ONLY inside the night branch; the day path still
+ // reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
+ // linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
+ @location(9) base_lit: vec3,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -135,7 +287,19 @@ const TERRAIN_REACH: f32 = 33.570166;
// The M2 lane in wow_model.wgsl still uses the packed per-light range — its own `w` is 0 (a ±10 yd
// box, a *different* byte-pinned number), a change that would move the approved interior look, so it
// is the director's to weigh. The two lanes diverge on purpose; do not "fix" them back into one.
-fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
+// MONKEY (outdoor torch shadows: terrain, MIRRORED from wow_model.wgsl / static_gx.wgsl - keep in
+// sync): the <=3-nearest selection packed into ONE u32, three 10-bit indices (rank 0 in the low
+// bits) with `EXT_SEL_EMPTY` for an unfilled rank. The point table is capped at 256 entries, so ten
+// bits leaves two bits of headroom. It exists so the per-FRAGMENT shadowed term below can
+// re-evaluate the VERTEX stage's choice rather than making its own - re-ranking per fragment would
+// draw a hard line wherever the ranking flips, and on terrain that line would run straight through
+// the middle of an MCNK cell, which is exactly the chunk-shaped popping the 0285 anchor removed.
+const EXT_SEL_EMPTY: u32 = 1023u;
+
+// The RANKING half of the old `point_light_sum`, split out verbatim: the same interior-lane skip,
+// the same Chebyshev candidacy box, the same tie order, so the set a chunk selects is bit-for-bit
+// what it was and the day render cannot move.
+fn point_light_pick(anchor: vec3) -> u32 {
let count = u32(wow_light.point_count.x);
var sel = array(0u, 0u, 0u);
var sd = array(1e30, 1e30, 1e30);
@@ -169,17 +333,62 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
sd[2] = d2; sel[2] = i;
}
}
+ return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
+ | (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
+ | (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
+}
+
+// The EVALUATION half - the byte-verified falloff `1/(0.7d + 0.03d^2)` x `max(N.L, 0)` x the
+// committed colour, in rank order, stopping at the first empty rank exactly as the old
+// `sd[s] > 9.9e29` break did (an unfilled rank packs as `EXT_SEL_EMPTY`, and no real index can
+// collide with it: the table caps at 256).
+fn point_light_eval(sel: u32, P: vec3, N: vec3) -> vec3 {
var sum = vec3(0.0);
for (var s = 0u; s < 3u; s = s + 1u) {
- if (sd[s] > 9.9e29) {
+ let idx = (sel >> (10u * s)) & 1023u;
+ if (idx == EXT_SEL_EMPTY) {
break;
}
- let pos_range = wow_light.points[2u * sel[s]];
- let to_light = pos_range.xyz - P;
+ let to_light = wow_light.points[2u * idx].xyz - P;
let d = length(to_light);
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
- sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
+ sum += wow_light.points[2u * idx + 1u].rgb * (atten * nl);
+ }
+ return sum;
+}
+
+// MONKEY (outdoor torch shadows: terrain): the SHADOWED evaluation - the SAME three entries the
+// vertex picked, each multiplied by its OWN fixture's cube-map occlusion, so a crate between the
+// campfire and this patch of grass darkens the campfire's term while the brazier across the road is
+// untouched. This is what step (a) already did for WMO exteriors and models; without it a
+// character's shadow stopped dead at the grass.
+//
+// Bounded at THREE iterations (the FFP commit limit), each costing a <=16-slot table scan and four
+// comparison taps, and reached only under `torch_ext_on()` - so it is night-only and dead with
+// `exteriorShadows 0`. `TORCH_SKIP_EPS` drops the scan entirely wherever the unshadowed term is
+// already invisible, which on terrain is most of the frame (ground facing away from the fire, or
+// far enough out that the falloff has taken the term below a code).
+fn point_light_eval_shadowed(sel: u32, P: vec3, N: vec3) -> vec3 {
+ var sum = vec3(0.0);
+ for (var s = 0u; s < 3u; s = s + 1u) {
+ let idx = (sel >> (10u * s)) & 1023u;
+ if (idx == EXT_SEL_EMPTY) {
+ break;
+ }
+ let fixture = wow_light.points[2u * idx].xyz;
+ let to_light = fixture - P;
+ let d = length(to_light);
+ let atten = 1.0 / (0.7 * d + 0.03 * d * d);
+ let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
+ // MONKEY (torch lane perf): the occlusion is a factor on a term that is already zero on any
+ // ground facing away from the fire.
+ let ext_w = atten * nl;
+ var occ = 1.0;
+ if (ext_w > TORCH_SKIP_EPS) {
+ occ = torch_terrain_shadow(fixture, P, N);
+ }
+ sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * occ);
}
return sum;
}
@@ -237,8 +446,16 @@ fn vertex(in: Vertex) -> TerrainVsOut {
// committed lights of this vertex's MCNK chunk cell — the terrain draw unit (0285) — at the
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
- let points = point_light_sum(out.world_position.xyz, n, mcnk_cell_anchor(out.world_position.xyz));
+ // MONKEY (outdoor torch shadows: terrain): the selection is PUBLISHED so the fragment stage can
+ // shadow these same three entries at night without re-ranking. One table walk still, not two -
+ // the sum was always `eval(pick(..))`, it is just no longer inlined.
+ let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz));
+ out.ext_sel = sel;
+ let points = point_light_eval(sel, out.world_position.xyz, n);
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
+ // The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
+ // back to a `primary` that does not already have the unshadowed point term baked into it.
+ out.base_lit = wow_light.light_ambient.rgb + sun_lighting;
out.primary = clamp(
wow_light.light_ambient.rgb + sun_lighting + points,
vec3(0.0),
@@ -318,15 +535,45 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let sun_shadow_strength = wow_light.fog_params.z;
+ // Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
+ // below needs the same normal for its normal-offset sample.
+ let n_lit = normalize(in.world_normal);
let world_shadow = shadow_hook::realtime_shadow(
in.world_position,
- normalize(in.world_normal),
+ n_lit,
view_z,
cam_dist,
wow_light._wmo_fog[1].z,
sun_shadow_strength,
);
- let primary = in.primary;
+ // MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
+ //
+ // `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
+ // thing this lane reads by day. After dark the same three entries the vertex picked
+ // (`in.ext_sel`) are re-evaluated PER FRAGMENT with each fixture's own cube-map occlusion folded
+ // in, re-summed onto the sun/ambient half (`in.base_lit`) and re-clamped here - the clamp has to
+ // move to the fragment because the shadow is a per-fragment quantity, and clamping the sum is
+ // still the faithful GL order (saturate the light sum, then modulate the texture).
+ //
+ // Written as a BLEND rather than a swap, for the same two reasons the model and WMO lanes are:
+ // - `fog_params.z` is EXACTLY 1.0 whenever the sun is above the daylight threshold
+ // (`global_light::sun_shadow_strength` is a smoothstep that saturates), so `ext_night_w` is
+ // exactly 0, the branch is not entered, and DAYTIME IS THE SAME BITS - not "a mix that ought
+ // to round back to `in.primary`". Nothing here is evaluated by day at any cost.
+ // - at dusk the shadow, and the Gouraud->per-fragment change of the term itself, arrive on the
+ // same clock the sun shadows leave on instead of snapping at a threshold.
+ // `torch_ext_on()` is the CPU's one-bit verdict (`exteriorShadows` AND night AND at least one
+ // promoted exterior fixture), so with the cvar off this is dead too.
+ var primary = in.primary;
+ let ext_night_w = select(0.0, clamp(1.0 - sun_shadow_strength, 0.0, 1.0), torch_ext_on());
+ if (ext_night_w > 0.0) {
+ let primary_night = clamp(
+ in.base_lit + point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
+ vec3(0.0),
+ vec3(1.0),
+ );
+ primary = mix(in.primary, primary_night, ext_night_w);
+ }
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
@@ -365,6 +612,18 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
let spec_term = in.specular * specmask * spec_gate;
var tuned = clamp(diffuse_term + spec_term, vec3(0.0), vec3(1.0));
+ // MONKEY (torch debug, interiorDebug 2 on TERRAIN): the ground's OWN cube-map sampling as
+ // greyscale, the same instrument static_gx and wow_model already paint on walls and bodies. The
+ // mode rides `wmo_fog_params.w` packed as `1 + debug` (rows 18-19 here, hence `_wmo_fog[1]`), so
+ // a zero - the interior lane off entirely - decodes to mode 0 and this is dead. Only mode 2 is
+ // answered: 1/3/4 are questions about the INTERIOR lane and the WMO group classes, and terrain
+ // belongs to neither, so leaving it normally shaded is what makes those three views readable.
+ // Placed before the fog so a distant reading still fogs like the surfaces beside it.
+ let idbg = u32(max(wow_light._wmo_fog[1].w - 1.0, 0.0) + 0.5);
+ if (idbg == 2u) {
+ tuned = vec3(torch_terrain_debug_factor(in.world_position.xyz, n_lit));
+ }
+
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
// the unfogged lit pixel) and BEFORE the viz bypass + raw output. The fog coordinate is
// PLANAR eye-Z (view-space depth along the camera forward axis), NOT radial distance: the
diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs
index b561cee6..7e8e194a 100644
--- a/crates/benilla-world/src/terrain_stream.rs
+++ b/crates/benilla-world/src/terrain_stream.rs
@@ -616,6 +616,11 @@ fn stream_terrain(
Res>,
Res