Phase 1 lighting: bake per-vertex sky visibility from voxel construction

The "side faces don't adjust to daytime ambiance" bug was caused by
conflating geometric sky visibility with radiometric sky intensity
into a single ambient_strength knob. This commit separates them.

sim/lighting.rs:
  - Refactor: extract walks_to_sky(world, origin, dir) -> bool as the
    shared DDA primitive. is_in_direct_sun now calls it.
  - New: sky_visibility(world, pos, normal) -> f32. Casts 8 cosine-
    weighted hemisphere rays through the voxel grid (Hammersley(2)
    distribution, deterministic). Returns fraction that escape to
    the world top. Pure function of (world, position, normal) — depends
    only on the surrounding voxel construction. Architecturally, this
    means a player-built sealed roof on the surface produces the same
    sky_vis as the same enclosure underground; no "is this the surface
    or underground?" hack anywhere.
  - 4 new tests including the construction-invariance test that pins
    "same enclosure geometry → same sky_vis regardless of world Y".

mesh.rs:
  - Vertex gains a sky_vis: f32 field at @location(5).
  - build_chunk_mesh computes sky_vis per quad corner via
    sky_visibility (one ray-walk per vertex; amortized at mesh build,
    free at runtime).
  - emit_oriented_box sets sky_vis = 1.0 for remote-player boxes
    (they float in open air).
  - 2 new tests: open-top has high sky_vis, slab-covered top has low.

shader.wgsl:
  - VsIn / VsOut grow @location(5) sky_vis: f32.
  - ambient_term rewritten as the principled split:
        sky_radiance(N) × sky_vis  +  bounce × (1 − sky_vis)
    where bounce is sun-tinted ground albedo scaled by day. No more
    `face_up = normal.y * 0.5 + 0.5` flat hemisphere assumption — the
    per-vertex geometric weight does the work.
  - ambient_strength bumped from mix(0.25, 0.85, day) to mix(0.35,
    1.00, day) since sky_vis now carries the geometric attenuation.

Result: side faces in the open get bright sky-colored ambient at noon;
side faces in pits dim correctly; player-built shelters darken by
construction without any time-of-day weirdness. Same model also sets
up the bounce-color bake (Phase 2) and the CSM shadow infrastructure
(Phase 3) — both extend the per-vertex visibility-attribute pattern
introduced here.

Tests: 61 passing (up from 53). Native + wasm release + server clean.
This commit is contained in:
Maximus Gorog 2026-05-24 00:00:04 -06:00
parent 8aafc7a939
commit 511798b6eb
3 changed files with 328 additions and 44 deletions

View file

@ -10,9 +10,19 @@ pub struct Vertex {
pub normal: [f32; 3],
pub leaf: f32,
/// Per-vertex ambient occlusion baked at mesh-build time, 0..1
/// (0 = fully occluded crevice, 1 = open). Computed once on the CPU so
/// the fragment shader pays one multiply.
/// (0 = fully occluded crevice, 1 = open). Pays for one multiply
/// in the fragment shader.
pub ao: f32,
/// Per-vertex sky visibility baked at mesh-build time, 0..1
/// (0 = sealed pit, 1 = open plain). Computed by
/// `sim::lighting::sky_visibility` via 8 cosine-weighted hemisphere
/// rays against the voxel grid — depends only on the surrounding
/// voxel construction, so a sealed roof on the surface produces
/// the same value as the same enclosure underground.
///
/// The fragment shader uses this as the geometric weight on
/// sky-dome ambient: `sky × sky_vis + bounce × (1 sky_vis)`.
pub sky_vis: f32,
}
impl Vertex {
@ -25,6 +35,7 @@ impl Vertex {
2 => Float32x3,
3 => Float32,
4 => Float32,
5 => Float32,
],
};
}
@ -196,6 +207,32 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
];
let base_idx = vertices.len() as u32;
let corners = [c0, c1, c2, c3];
// Bake per-corner sky visibility against the world's
// surrounding voxel construction. 8 rays per vertex;
// amortized at mesh-build time, free at runtime.
let normal_v = Vec3::new(n_arr[0], n_arr[1], n_arr[2]);
let sky_vis_f = [
crate::sim::lighting::sky_visibility(
world,
Vec3::new(corners[0][0], corners[0][1], corners[0][2]),
normal_v,
),
crate::sim::lighting::sky_visibility(
world,
Vec3::new(corners[1][0], corners[1][1], corners[1][2]),
normal_v,
),
crate::sim::lighting::sky_visibility(
world,
Vec3::new(corners[2][0], corners[2][1], corners[2][2]),
normal_v,
),
crate::sim::lighting::sky_visibility(
world,
Vec3::new(corners[3][0], corners[3][1], corners[3][2]),
normal_v,
),
];
for i in 0..4 {
vertices.push(Vertex {
pos: corners[i],
@ -203,6 +240,7 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
normal: n_arr,
leaf,
ao: ao_f[i],
sky_vis: sky_vis_f[i],
});
}
// Flip the diagonal when AO is "anisotropic" — i.e.
@ -343,6 +381,9 @@ pub fn emit_oriented_box(
normal: n_world,
leaf: 0.0,
ao: 1.0,
// Remote-player boxes float in open air, so they
// receive full sky illumination.
sky_vis: 1.0,
});
}
indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
@ -574,4 +615,57 @@ mod tests {
assert_eq!(a, name_hash("alice"));
assert_ne!(a, b);
}
// ---------- sky_vis baking through build_chunk_mesh ----------
#[test]
fn top_face_of_open_isolated_block_has_high_sky_vis() {
let world = single_chunk_world(|c| c.set(8, 4, 8, Block::Stone));
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
let top_verts: Vec<&Vertex> =
mesh.vertices.iter().filter(|v| v.normal[1] > 0.5).collect();
assert!(!top_verts.is_empty());
for v in &top_verts {
assert!(
v.sky_vis > 0.6,
"open top-face vertex should have high sky_vis, got {}",
v.sky_vis
);
}
}
#[test]
fn covered_top_face_has_low_sky_vis() {
// Stone block at (8,4,8) with a stone slab covering its top.
let world = single_chunk_world(|c| {
c.set(8, 4, 8, Block::Stone);
for x in 6..=10 {
for z in 6..=10 {
c.set(x, 6, z, Block::Stone);
}
}
});
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
// Find the +Y face of (8,4,8) — its corners are around (8..9, 5, 8..9).
let mut min_sky_vis: f32 = 1.0;
for v in &mesh.vertices {
if v.normal[1] > 0.5
&& v.pos[1] > 4.9
&& v.pos[1] < 5.1
&& v.pos[0] > 7.9
&& v.pos[0] < 9.1
&& v.pos[2] > 7.9
&& v.pos[2] < 9.1
{
min_sky_vis = min_sky_vis.min(v.sky_vis);
}
}
assert!(
min_sky_vis < 0.4,
"vertex beneath a stone slab should have low sky_vis, got {}",
min_sky_vis
);
}
}

View file

@ -172,17 +172,35 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
// × ao × leaf_jitter
// final = apply_fog( lit, dist, view_dir )
/// Hemisphere ambient samples the sky dome in the surface-normal
/// direction so vertical faces inherit the bright horizon during day.
/// Bottom faces fade toward an earth-bounce color so they're not dead
/// black.
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> vec3<f32> {
let sky_in_normal = sky_dome(normal, sun);
let earth_day = vec3<f32>(0.20, 0.18, 0.14);
let earth_night = vec3<f32>(0.03, 0.03, 0.04);
let earth = mix(earth_night, earth_day, day);
let face_up = clamp(normal.y * 0.5 + 0.5, 0.0, 1.0);
return mix(earth, sky_in_normal, face_up);
/// Physically-decoupled hemisphere ambient:
///
/// ambient = sky_radiance(N) × sky_vis + bounce × (1 sky_vis)
///
/// The two terms separate the *geometric* visibility of the sky
/// (baked into `sky_vis` per vertex from a ray-cast against the actual
/// voxel construction) from the *radiometric* sky color (computed
/// fresh each frame from sun position). Multiplying them gives the
/// correct behavior in every condition without a single-knob hack:
///
/// - Open plain at noon: sky_vis1 full bright sky-color ambient.
/// - Deep cave at noon: sky_vis0 tiny sun-bounce only.
/// - Player-built roof: sky_vis0 same as deep cave, by virtue
/// of the surrounding voxels no "is this
/// the surface or underground?" hack.
/// - Side face in a notch: sky_vis tracks the *fraction* of sky the
/// vertex actually sees, so it dims smoothly
/// as the player walls things in.
///
/// `bounce` approximates the contribution from light bouncing off
/// neighboring voxels we don't sample neighbor colors yet (that's
/// Phase 2: bake bounce color per vertex), so a constant gray-brown
/// tinted by the sun is the cheapest reasonable stand-in.
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32, sky_vis: f32) -> vec3<f32> {
let sky = sky_dome(normal, sun);
let bounce_albedo = vec3<f32>(0.42, 0.40, 0.34);
let bounce_strength = mix(0.04, 1.0, day);
let bounce = bounce_albedo * bounce_strength * sun_tint(sun);
return sky * sky_vis + bounce * (1.0 - sky_vis);
}
/// Direct-sun Lambert term, gated by sun visibility above the horizon.
@ -234,6 +252,7 @@ struct VsIn {
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
};
struct VsOut {
@ -243,6 +262,7 @@ struct VsOut {
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
};
@vertex
@ -264,6 +284,7 @@ fn vs_main(in: VsIn) -> VsOut {
out.normal = in.normal;
out.leaf = in.leaf;
out.ao = in.ao;
out.sky_vis = in.sky_vis;
return out;
}
@ -276,9 +297,13 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.normal);
// Lighting pipeline:
// ambient + direct_sun modulated by base color AO leaf jitter fog
let ambient = ambient_term(n, sun, day);
let ambient_strength = mix(0.25, 0.85, day);
// ambient (sky_vis-weighted) + direct_sun modulated by base color
// AO leaf jitter fog
let ambient = ambient_term(n, sun, day, in.sky_vis);
// With sky_vis carrying the per-vertex geometric weight, the global
// ambient_strength is just an overall brightness knob; we can push
// it to ~1.0 at full day without washing out caves.
let ambient_strength = mix(0.35, 1.00, day);
let sun_term = direct_sun_term(n, sun);
let sun_col = sun_tint(sun);

View file

@ -73,39 +73,35 @@ impl LightingFrame {
}
}
/// Is point `p` directly lit by the sun in `world` at time `t`?
/// Walk a 3D DDA ray through the voxel grid from `origin` along `dir`.
/// Returns `true` if the ray escapes upward through `SUN_RAY_TOP_Y`
/// (or exhausts its step budget without hitting solid); `false` if it
/// hits a solid voxel first.
///
/// 3D DDA from `p` toward the sun until the ray either escapes through
/// the world top (lit) or hits a solid voxel (occluded). The
/// mechanical sunlight test — consumed by mob burn checks, plant
/// growth, daylight sensors, shade pathfinding. Visual shadows in the
/// shader should approximate the *same* predicate.
pub fn is_in_direct_sun(world: &World, p: Vec3, t: f32) -> bool {
let sun = sun_direction(t);
if sun.y <= 0.0 {
return false;
}
/// This is the single ray-march primitive shared by both
/// `is_in_direct_sun` (mechanical sun query) and `sky_visibility`
/// (per-vertex bake). Same code → identical visibility semantics
/// across the renderer and game-logic layers, by construction.
pub fn walks_to_sky(world: &World, origin: Vec3, dir: Vec3) -> bool {
// Nudge off the face we're sampling on so the first DDA cell isn't
// the surface itself. 1 mm is more than enough on f32.
let p = origin + dir * 0.001;
// DDA setup. Walking from `p` along `sun` through unit voxels.
let mut cell = IVec3::new(
p.x.floor() as i32,
p.y.floor() as i32,
p.z.floor() as i32,
);
let mut cell = IVec3::new(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let step = IVec3::new(
sign_to_step(sun.x),
sign_to_step(sun.y),
sign_to_step(sun.z),
sign_to_step(dir.x),
sign_to_step(dir.y),
sign_to_step(dir.z),
);
let t_delta = Vec3::new(
axis_t_delta(sun.x),
axis_t_delta(sun.y),
axis_t_delta(sun.z),
axis_t_delta(dir.x),
axis_t_delta(dir.y),
axis_t_delta(dir.z),
);
let mut t_max = Vec3::new(
first_t_max(p.x, sun.x, cell.x),
first_t_max(p.y, sun.y, cell.y),
first_t_max(p.z, sun.z, cell.z),
first_t_max(p.x, dir.x, cell.x),
first_t_max(p.y, dir.y, cell.y),
first_t_max(p.z, dir.z, cell.z),
);
for _ in 0..SUN_RAY_MAX_STEPS {
@ -126,11 +122,72 @@ pub fn is_in_direct_sun(world: &World, p: Vec3, t: f32) -> bool {
return false;
}
}
// Ran out of steps — treat as open sky (the alternative is "hangs
// forever on degenerate rays", which is worse).
// Ran out of steps without hitting solid → degenerate near-
// horizontal ray over open terrain. Count as sky.
true
}
/// Is point `p` directly lit by the sun in `world` at time `t`?
/// Mechanical sunlight test — consumed by mob burn checks, plant
/// growth, daylight sensors, shade pathfinding.
pub fn is_in_direct_sun(world: &World, p: Vec3, t: f32) -> bool {
let sun = sun_direction(t);
if sun.y <= 0.0 {
return false;
}
walks_to_sky(world, p, sun)
}
/// 8 cosine-weighted hemisphere directions in a local frame where the
/// surface normal is +Z. Pre-computed via Hammersley(2) → cosine
/// projection so the sample distribution is deterministic; the same
/// mesh built twice always produces identical sky_vis values.
///
/// (x, y) tangent-plane components; z is the normal-aligned axis.
const COSINE_HEMI_RAYS: [[f32; 3]; 8] = [
[ 0.000, 0.000, 1.000],
[-0.354, 0.000, 0.935],
[ 0.000, 0.500, 0.866],
[ 0.000, -0.612, 0.791],
[ 0.500, 0.500, 0.707],
[-0.559, -0.559, 0.612],
[-0.612, 0.612, 0.500],
[ 0.661, -0.661, 0.354],
];
/// Fraction of the upper hemisphere (relative to `normal`) that has an
/// unobstructed view of the sky. 0.0 = sealed pit, 1.0 = open plain.
///
/// Cast 8 cosine-weighted rays through the world's voxels via
/// `walks_to_sky` and count escapes. Pure function of (world, position,
/// normal) — depends only on the *surrounding voxel construction*, so a
/// player-built roof on the surface produces the same sky_vis as
/// equivalently-enclosed geometry deep underground. No special cases.
///
/// Computed once at mesh-build time and baked per vertex; the fragment
/// shader pays one multiply at runtime.
pub fn sky_visibility(world: &World, pos: Vec3, normal: Vec3) -> f32 {
let n = normal.normalize();
// Build an orthonormal tangent basis. The "if x is small, cross
// with X; else cross with Y" trick avoids the degeneracy when the
// normal is itself ±X.
let tangent = if n.x.abs() < 0.9 {
Vec3::X.cross(n).normalize()
} else {
Vec3::Y.cross(n).normalize()
};
let bitangent = n.cross(tangent);
let mut hits = 0u32;
for ray in COSINE_HEMI_RAYS.iter() {
let dir = tangent * ray[0] + bitangent * ray[1] + n * ray[2];
if walks_to_sky(world, pos, dir) {
hits += 1;
}
}
hits as f32 / COSINE_HEMI_RAYS.len() as f32
}
fn sign_to_step(v: f32) -> i32 {
if v > 0.0 {
1
@ -225,4 +282,112 @@ mod tests {
let s = sun_direction(0.0);
assert!(s.y > 0.9, "noon sun y should be ~1, got {}", s.y);
}
// ---------- sky_visibility ----------
#[test]
fn sky_visibility_open_top_face_is_high() {
// High above any natural terrain (trees, hills) — nothing should
// occlude. sky_vis must be ~1.
let world = World::new();
let pos = Vec3::new(0.5, 100.0, 0.5);
let vis = sky_visibility(&world, pos, Vec3::Y);
assert!(vis > 0.95, "fully open top should be ~1.0, got {}", vis);
}
#[test]
fn sky_visibility_at_natural_surface_is_partial() {
// A point on the natural terrain sees most of the sky but is
// partially occluded by nearby trees / hills. This is the
// baseline against which roof-building should *reduce* it.
let world = World::new();
let surface = natural_surface_y(0, 0);
let pos = Vec3::new(0.5, (surface + 1) as f32, 0.5);
let vis = sky_visibility(&world, pos, Vec3::Y);
assert!(vis > 0.3, "natural surface should still see meaningful sky, got {}", vis);
}
#[test]
fn sky_visibility_zero_under_sealed_ceiling() {
// Build a 7×7 stone roof a few blocks above the surface, then
// sample the underside.
let mut world = World::new();
let surface = natural_surface_y(0, 0);
for x in -3..=3 {
for z in -3..=3 {
for y in (surface + 3)..(surface + 8) {
world.set_block(IVec3::new(x, y, z), Block::Stone);
}
}
}
let pos = Vec3::new(0.5, (surface + 1) as f32, 0.5);
let vis = sky_visibility(&world, pos, Vec3::Y);
assert!(vis < 0.15, "sealed roof should occlude sky, got {}", vis);
}
#[test]
fn voxel_construction_dependent_not_world_position_dependent() {
// Architectural invariant: sky_visibility must depend only on
// the surrounding voxel construction, not on the world location.
// A player-built sealed chamber on the surface must produce the
// same sky_vis as the identical chamber wrapped around a higher
// point in the air.
let surface = natural_surface_y(0, 0);
fn seal_chamber(w: &mut World, cy: i32) {
// Floor + ceiling (5×5 each)
for x in -2..=2 {
for z in -2..=2 {
w.set_block(IVec3::new(x, cy, z), Block::Stone);
w.set_block(IVec3::new(x, cy + 2, z), Block::Stone);
}
}
// Walls (5-block perimeter)
for x in -2..=2 {
w.set_block(IVec3::new(x, cy + 1, -2), Block::Stone);
w.set_block(IVec3::new(x, cy + 1, 2), Block::Stone);
}
for z in -2..=2 {
w.set_block(IVec3::new(-2, cy + 1, z), Block::Stone);
w.set_block(IVec3::new(2, cy + 1, z), Block::Stone);
}
}
let mut world_a = World::new();
let mut world_b = World::new();
seal_chamber(&mut world_a, surface + 3);
seal_chamber(&mut world_b, surface + 30);
let inside_a = Vec3::new(0.5, (surface + 4) as f32 + 0.1, 0.5);
let inside_b = Vec3::new(0.5, (surface + 31) as f32 + 0.1, 0.5);
let va = sky_visibility(&world_a, inside_a, Vec3::Y);
let vb = sky_visibility(&world_b, inside_b, Vec3::Y);
assert!(
(va - vb).abs() < 0.05,
"identical surrounding voxel construction must give equivalent sky_vis \
regardless of world position; got {} vs {}",
va, vb
);
// Both should also be very low because the chamber is sealed.
assert!(va < 0.15, "sealed chamber A should have low sky_vis, got {}", va);
assert!(vb < 0.15, "sealed chamber B should have low sky_vis, got {}", vb);
}
#[test]
fn walks_to_sky_straight_up_through_air_escapes() {
let world = World::new();
let pos = Vec3::new(0.5, 200.0, 0.5);
assert!(walks_to_sky(&world, pos, Vec3::Y));
}
#[test]
fn walks_to_sky_into_solid_returns_false() {
let mut world = World::new();
let surface = natural_surface_y(0, 0);
for y in (surface + 5)..(surface + 10) {
world.set_block(IVec3::new(0, y, 0), Block::Stone);
}
let pos = Vec3::new(0.5, (surface + 1) as f32, 0.5);
assert!(!walks_to_sky(&world, pos, Vec3::Y));
}
}