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:
parent
0dca49f475
commit
dccb06dddf
4 changed files with 215 additions and 78 deletions
65
src/mesh.rs
65
src/mesh.rs
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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_vis≈1 → full bright sky-color ambient.
|
||||
/// - Deep cave at noon: sky_vis≈0 → tiny sun-bounce only.
|
||||
/// - Player-built roof: sky_vis≈0 → 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_vis≈1 → full bright sky-color ambient
|
||||
/// - Deep cave at noon: sky_vis≈0 → 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
26
src/world.rs
26
src/world.rs
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue