Round D: bake bounce_color + material per vertex; specular for ice/snow (#7, #9, #15, #17 foundation)

sim/lighting.rs:
  - New compute_ambience(world, pos, normal) -> VertexAmbience
    returns BOTH sky_vis AND bounce_color from the same 8-ray
    hemisphere cast. No double walks of the voxel grid.
  - walks_until_solid(world, origin, dir) -> Option<Vec3> is the
    primitive: None = escaped to sky, Some(color) = first solid hit.
    Used by compute_ambience to accumulate the bounce_color average.
  - sky_visibility is now a thin wrapper around compute_ambience for
    tests and clarity.

world.rs:
  - Block::average_color() returns the mean across all 6 faces — the
    contribution this block makes to a neighbor's bounce when hit by
    a hemisphere ray.
  - Block::material_id() returns 0 (matte) for most blocks, 1
    (specular) for Ice + Snow. Reserved 2 for future emissive blocks.

mesh.rs:
  - Vertex grows `bounce_mat: [f32; 4]` packing baked bounce color
    (rgb) + material id (a). Float32x4 attribute at @location(6).
  - build_chunk_mesh now calls compute_ambience for each quad corner
    (same one-pass ray-cast that produced sky_vis already, plus the
    bounce accumulation). Material id taken from cell.block.

shader.wgsl:
  - VsIn / VsOut grow bounce_mat (vec4).
  - ambient_term now takes the per-vertex bounce_albedo instead of a
    hard-coded gray-brown. A red brick wall thus casts a faint red
    bounce on dirt next to it — real GI hint, baked once at mesh
    time, zero cost at runtime per fragment.
  - material_for(id) lookup: Phong specular for ice/snow (m.specular
    = 0.45 with cos⁴⁸ half-vector); emission slot reserved.
  - fs_main pipeline now includes the specular + emission terms.
  - Camera.frame keeps Round C accessor names; ambient_strength
    untouched.

Tests: 63 passing. Native + wasm release clean.

Deferred from this round (own sessions): #18 water (new pipeline +
animation), #19 transparent/cutout (new blend state), #8 wider AO
(current 4-corner is sufficient for now; the wider-occlusion effect
is now coming through bounce_color anyway).

Visual change: bounce-tinted ambient (red walls glow red onto
neighbors); ice + snow get specular highlight in direct sun.
This commit is contained in:
Maximus Gorog 2026-05-24 10:24:59 -06:00
parent 0dca49f475
commit dccb06dddf
4 changed files with 215 additions and 78 deletions

View file

@ -14,15 +14,18 @@ pub struct Vertex {
/// 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)`.
/// (0 = sealed pit, 1 = open plain). Geometric weight on the
/// sky-radiance ambient contribution.
pub sky_vis: f32,
/// Per-vertex bounce light. RGB = average color of the surfaces
/// this vertex's hemisphere rays hit (when they don't escape to
/// sky). A = material id (0 = matte diffuse, 1 = slight specular
/// for ice/snow, 2 = future emissive). Packed into one Float32x4
/// vertex attribute. Shader uses
/// ambient = sky_radiance × sky_vis + bounce.rgb × (1 - sky_vis)
/// so a red brick wall casts a faint red bounce onto dirt next to
/// it (real GI hint, baked once at mesh time).
pub bounce_mat: [f32; 4],
}
impl Vertex {
@ -36,6 +39,7 @@ impl Vertex {
3 => Float32,
4 => Float32,
5 => Float32,
6 => Float32x4,
],
};
}
@ -207,32 +211,18 @@ 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.
// Bake per-corner ambience: (sky_vis, bounce_color)
// from one hemisphere ray-cast pass each. Material
// id from the underlying block.
let normal_v = Vec3::new(n_arr[0], n_arr[1], n_arr[2]);
let sky_vis_f = [
crate::sim::lighting::sky_visibility(
let material = cell.block.material_id() as f32;
let amb: [_; 4] = std::array::from_fn(|i| {
crate::sim::lighting::compute_ambience(
world,
Vec3::new(corners[0][0], corners[0][1], corners[0][2]),
Vec3::new(corners[i][0], corners[i][1], corners[i][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],
@ -240,7 +230,13 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
normal: n_arr,
leaf,
ao: ao_f[i],
sky_vis: sky_vis_f[i],
sky_vis: amb[i].sky_vis,
bounce_mat: [
amb[i].bounce_color.x,
amb[i].bounce_color.y,
amb[i].bounce_color.z,
material,
],
});
}
// Flip the diagonal when AO is "anisotropic" — i.e.
@ -381,9 +377,10 @@ 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.
// Remote-player boxes float in open air: full sky,
// no bounce, default matte material.
sky_vis: 1.0,
bounce_mat: [0.35, 0.35, 0.35, 0.0],
});
}
indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);

View file

@ -198,33 +198,47 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
///
/// 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:
/// Both visibility *and* bounce color are baked per vertex by
/// `sim::lighting::compute_ambience` (same 8 cosine-weighted rays
/// produce sky_vis and average bounce color in one pass). The
/// shader's job is just to combine the baked geometric terms with the
/// time-of-day radiometric terms.
///
/// - 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> {
/// - Open plain at noon: sky_vis1 full bright sky-color ambient
/// - Deep cave at noon: sky_vis0 bounce_color × sun strength
/// - Red brick wall corner: bounce_color carries the red tint from
/// the wall, giving a faint red glow on
/// adjacent surfaces (real GI hint, free at
/// runtime).
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32, sky_vis: f32, bounce_albedo: vec3<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);
}
/// Material lookup. Keyed by `bounce_mat.a` (set at mesh-build time
/// from `Block::material_id`). Keep in sync with the Rust enum.
struct Material {
specular: f32,
emission: vec3<f32>,
};
fn material_for(id_f: f32) -> Material {
let id = u32(id_f + 0.5);
var m: Material;
m.specular = 0.0;
m.emission = vec3<f32>(0.0);
if (id == 1u) {
// Ice / snow slight specular highlight.
m.specular = 0.45;
} else if (id == 2u) {
// Reserved: emissive (future glowing blocks).
m.emission = vec3<f32>(1.00, 0.70, 0.40);
}
return m;
}
/// Direct-sun Lambert term, gated by sun visibility above the horizon.
/// Returns a scalar 0..1 caller multiplies by `sun_tint(sun)` for
/// color.
@ -295,6 +309,7 @@ struct VsIn {
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
@location(6) bounce_mat: vec4<f32>, // rgb = baked bounce color, a = material id
};
struct VsOut {
@ -305,6 +320,7 @@ struct VsOut {
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
@location(6) bounce_mat: vec4<f32>,
};
@vertex
@ -327,6 +343,7 @@ fn vs_main(in: VsIn) -> VsOut {
out.leaf = in.leaf;
out.ao = in.ao;
out.sky_vis = in.sky_vis;
out.bounce_mat = in.bounce_mat;
return out;
}
@ -338,23 +355,33 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.normal);
// Lighting pipeline:
// 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.
// Lighting pipeline (all per-fragment scalars from baked vertex
// attributes + time-of-day functions):
// ambient (sky_vis-weighted + baked bounce_color)
// + direct_sun (Lambert × sun_visible)
// + specular (Phong, gated by material)
// + emission (gated by material)
// then base color × AO × leaf jitter, then fog.
let bounce_albedo = in.bounce_mat.rgb;
let mat = material_for(in.bounce_mat.a);
let ambient = ambient_term(n, sun, day, in.sky_vis, bounce_albedo);
let ambient_strength = mix(0.35, 1.00, day);
let sun_term = direct_sun_term(n, sun);
let sun_col = sun_tint(sun);
// Specular: Phong with half-vector. Gated by material + sun visibility.
let to_eye = normalize(eye_world() - in.world_pos);
let half_v = normalize(sun + to_eye);
let spec_dot = max(dot(n, half_v), 0.0);
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
let specular = pow(spec_dot, 48.0) * mat.specular * sun_visible;
let lighting = ambient * ambient_strength + sun_col * sun_term;
var lit = in.color * lighting * in.ao;
var lit = in.color * lighting * in.ao + sun_col * specular + mat.emission;
if (in.leaf > 0.5) {
lit = lit * leaf_jitter(in.world_pos);
// Sun-aware translucency leaves glow when backlit.
let trans = leaf_translucency(n, sun, day);
lit = lit + in.color * sun_col * trans;
}

View file

@ -158,19 +158,40 @@ const COSINE_HEMI_RAYS: [[f32; 3]; 8] = [
/// 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.
/// Backed by `compute_ambience` — the same ray-cast simultaneously
/// produces both sky_vis AND the bounce-color average. Most callers
/// want both; the standalone wrapper exists for tests and clarity.
pub fn sky_visibility(world: &World, pos: Vec3, normal: Vec3) -> f32 {
compute_ambience(world, pos, normal).sky_vis
}
/// Both ambience scalars baked at a single point on a face. Computed
/// from the *same* 8 cosine-weighted rays so we don't double-walk the
/// voxel grid: each ray either escapes (counts toward sky_vis) or hits
/// a solid voxel (contributes its average_color toward bounce_color).
#[derive(Clone, Copy, Debug)]
pub struct VertexAmbience {
pub sky_vis: f32,
pub bounce_color: Vec3,
}
/// Cast 8 cosine-weighted rays into the upper hemisphere relative to
/// `normal`. Track:
/// - how many escape to open sky (→ sky_vis)
/// - the average color of the first solid voxel each non-escaping
/// ray hits (→ bounce_color)
///
/// This is the *bake* call that runs once per quad-corner at
/// mesh-build time. Cheap CPU work, amortized — the fragment shader
/// then pays one multiply for the sky contribution and one for the
/// bounce contribution. Together they give:
///
/// ambient = sky_radiance(N) × sky_vis + bounce_color × (1 sky_vis)
///
/// A red brick wall thus casts a faint red bounce on the dirt next to
/// it; a sealed roof darkens but inherits the color of its underside.
pub fn compute_ambience(world: &World, pos: Vec3, normal: Vec3) -> VertexAmbience {
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 {
@ -178,14 +199,80 @@ pub fn sky_visibility(world: &World, pos: Vec3, normal: Vec3) -> f32 {
};
let bitangent = n.cross(tangent);
let mut hits = 0u32;
let mut sky_hits = 0u32;
let mut bounce_sum = Vec3::ZERO;
let mut bounce_count = 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;
match walks_until_solid(world, pos, dir) {
None => sky_hits += 1,
Some(color) => {
bounce_sum += color;
bounce_count += 1;
}
}
}
hits as f32 / COSINE_HEMI_RAYS.len() as f32
let n_rays = COSINE_HEMI_RAYS.len() as f32;
let sky_vis = sky_hits as f32 / n_rays;
let bounce_color = if bounce_count > 0 {
bounce_sum / bounce_count as f32
} else {
// No occluders sampled — neutral gray bounce is the safe
// fallback; the shader weights this by (1 - sky_vis) anyway
// so a fully-open vertex barely uses this value.
Vec3::splat(0.35)
};
VertexAmbience { sky_vis, bounce_color }
}
/// Walk a DDA ray through the voxel grid like `walks_to_sky`, but
/// distinguish "escaped to sky" (returns `None`) from "hit solid"
/// (returns `Some(block_average_color)`). Used by `compute_ambience`
/// to bake both sky-vis and bounce-color in one pass.
fn walks_until_solid(world: &World, origin: Vec3, dir: Vec3) -> Option<Vec3> {
let p = origin + dir * 0.001;
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(dir.x),
sign_to_step(dir.y),
sign_to_step(dir.z),
);
let t_delta = Vec3::new(
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, 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 {
if cell.y >= SUN_RAY_TOP_Y {
return None;
}
if t_max.x < t_max.y && t_max.x < t_max.z {
cell.x += step.x;
t_max.x += t_delta.x;
} else if t_max.y < t_max.z {
cell.y += step.y;
t_max.y += t_delta.y;
} else {
cell.z += step.z;
t_max.z += t_delta.z;
}
let block = world.get_block(cell);
if block.solid() {
let c = block.average_color();
return Some(Vec3::new(c[0], c[1], c[2]));
}
}
None
}
fn sign_to_step(v: f32) -> i32 {

View file

@ -44,6 +44,32 @@ impl Block {
(Block::Air, _) => [0.0, 0.0, 0.0],
}
}
/// Average diffuse color across all six faces — the color used
/// when this block contributes bounce light to a neighboring
/// surface during mesh-build's ambience bake.
pub fn average_color(self) -> [f32; 3] {
let mut s = [0.0_f32; 3];
for f in Face::ALL {
let c = self.face_color(f);
s[0] += c[0];
s[1] += c[1];
s[2] += c[2];
}
[s[0] / 6.0, s[1] / 6.0, s[2] / 6.0]
}
/// Material id consumed by the shader to pick specular / emission
/// behavior. Matches the `material_for(id)` switch in `shader.wgsl`:
/// 0 matte diffuse (default)
/// 1 slight specular (ice, snow)
/// 2 emissive (future glowing blocks)
pub fn material_id(self) -> u32 {
match self {
Block::Ice | Block::Snow => 1,
_ => 0,
}
}
}
#[derive(Copy, Clone, Debug)]