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.
671 lines
26 KiB
Rust
671 lines
26 KiB
Rust
use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE};
|
||
use bytemuck::{Pod, Zeroable};
|
||
use glam::{IVec3, Vec3};
|
||
|
||
#[repr(C)]
|
||
#[derive(Copy, Clone, Pod, Zeroable, Debug)]
|
||
pub struct Vertex {
|
||
pub pos: [f32; 3],
|
||
pub color: [f32; 3],
|
||
pub normal: [f32; 3],
|
||
pub leaf: f32,
|
||
/// Per-vertex ambient occlusion baked at mesh-build time, 0..1
|
||
/// (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 {
|
||
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||
array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
|
||
step_mode: wgpu::VertexStepMode::Vertex,
|
||
attributes: &wgpu::vertex_attr_array![
|
||
0 => Float32x3,
|
||
1 => Float32x3,
|
||
2 => Float32x3,
|
||
3 => Float32,
|
||
4 => Float32,
|
||
5 => Float32,
|
||
],
|
||
};
|
||
}
|
||
|
||
pub struct ChunkMesh {
|
||
pub vertices: Vec<Vertex>,
|
||
pub indices: Vec<u32>,
|
||
}
|
||
|
||
/// One mask cell — block type plus the four per-corner AO levels.
|
||
/// Cells with the same block but different AO can't be greedy-merged, since
|
||
/// they would otherwise share corner vertices that disagree on shading.
|
||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||
struct MaskCell {
|
||
block: Block,
|
||
/// AO at the four corners in (min-u, min-v) (max-u, min-v) (max-u, max-v) (min-u, max-v)
|
||
/// order, each value 0..=3.
|
||
ao: [u8; 4],
|
||
}
|
||
|
||
/// Classic Minecraft 4-corner AO. `face_plane` is the *air* block one step
|
||
/// past the face in the normal direction. `du` and `dv` are unit vectors
|
||
/// in the face plane pointing toward the corner of interest. Returns 0..=3
|
||
/// (0 = darkest crevice, 3 = open). Special case: if both adjacent edge
|
||
/// blocks are solid the corner is fully dark regardless of the diagonal.
|
||
fn corner_ao(world: &World, face_plane: IVec3, du: IVec3, dv: IVec3) -> u8 {
|
||
let s1 = world.get_block(face_plane + du).solid() as u8;
|
||
let s2 = world.get_block(face_plane + dv).solid() as u8;
|
||
let c = world.get_block(face_plane + du + dv).solid() as u8;
|
||
if s1 == 1 && s2 == 1 {
|
||
0
|
||
} else {
|
||
3 - (s1 + s2 + c)
|
||
}
|
||
}
|
||
|
||
const AO_TABLE: [f32; 4] = [0.45, 0.65, 0.80, 1.00];
|
||
|
||
fn unit_axis(a: usize) -> IVec3 {
|
||
match a {
|
||
0 => IVec3::X,
|
||
1 => IVec3::Y,
|
||
2 => IVec3::Z,
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
|
||
/// Greedy meshing with baked AO. For each face direction we build a 2-D
|
||
/// mask of `(block, ao[4])` cells, then merge contiguous cells that match
|
||
/// exactly in both block-type and AO tuple. The output mesh carries one AO
|
||
/// value per vertex; the fragment shader multiplies it into the lit color
|
||
/// so crevices darken naturally.
|
||
pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
||
let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
|
||
let mut indices: Vec<u32> = Vec::with_capacity(3072);
|
||
let base_x = chunk.coord.x * CHUNK_SIZE;
|
||
let base_z = chunk.coord.z * CHUNK_SIZE;
|
||
|
||
let dims = [CHUNK_SIZE, CHUNK_HEIGHT, CHUNK_SIZE];
|
||
|
||
for face in Face::ALL {
|
||
let normal = face.normal();
|
||
let positive = matches!(face, Face::PosX | Face::PosY | Face::PosZ);
|
||
let axis: usize = match face {
|
||
Face::PosX | Face::NegX => 0,
|
||
Face::PosY | Face::NegY => 1,
|
||
Face::PosZ | Face::NegZ => 2,
|
||
};
|
||
let u_axis = (axis + 1) % 3;
|
||
let v_axis = (axis + 2) % 3;
|
||
let size_a = dims[axis];
|
||
let size_u = dims[u_axis];
|
||
let size_v = dims[v_axis];
|
||
let n_arr = [normal.x as f32, normal.y as f32, normal.z as f32];
|
||
|
||
let a_unit = unit_axis(u_axis);
|
||
let b_unit = unit_axis(v_axis);
|
||
|
||
let mut mask: Vec<Option<MaskCell>> = vec![None; (size_u * size_v) as usize];
|
||
|
||
for d in 0..size_a {
|
||
for cell in mask.iter_mut() {
|
||
*cell = None;
|
||
}
|
||
for v in 0..size_v {
|
||
for u in 0..size_u {
|
||
let mut p = [0i32; 3];
|
||
p[axis] = d;
|
||
p[u_axis] = u;
|
||
p[v_axis] = v;
|
||
let block = chunk.get(p[0], p[1], p[2]);
|
||
if !block.solid() {
|
||
continue;
|
||
}
|
||
let nx = p[0] + normal.x;
|
||
let ny = p[1] + normal.y;
|
||
let nz = p[2] + normal.z;
|
||
let neighbor_solid = if ny < 0 || ny >= CHUNK_HEIGHT {
|
||
false
|
||
} else if nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE {
|
||
chunk.get(nx, ny, nz).solid()
|
||
} else {
|
||
world
|
||
.get_block(IVec3::new(base_x + nx, ny, base_z + nz))
|
||
.solid()
|
||
};
|
||
if neighbor_solid {
|
||
continue;
|
||
}
|
||
// World-space block position.
|
||
let block_world = IVec3::new(base_x + p[0], p[1], base_z + p[2]);
|
||
let face_plane = block_world + normal;
|
||
// 4 corner AO values, ordered (min-u min-v), (max-u min-v),
|
||
// (max-u max-v), (min-u max-v).
|
||
let ao = [
|
||
corner_ao(world, face_plane, -a_unit, -b_unit),
|
||
corner_ao(world, face_plane, a_unit, -b_unit),
|
||
corner_ao(world, face_plane, a_unit, b_unit),
|
||
corner_ao(world, face_plane, -a_unit, b_unit),
|
||
];
|
||
mask[(v * size_u + u) as usize] = Some(MaskCell { block, ao });
|
||
}
|
||
}
|
||
|
||
for v0 in 0..size_v {
|
||
let mut u0 = 0;
|
||
while u0 < size_u {
|
||
let head = mask[(v0 * size_u + u0) as usize];
|
||
if let Some(cell) = head {
|
||
// Greedy extend in u as long as cells match exactly.
|
||
let mut w = 1;
|
||
while u0 + w < size_u
|
||
&& mask[(v0 * size_u + u0 + w) as usize] == Some(cell)
|
||
{
|
||
w += 1;
|
||
}
|
||
// Greedy extend in v as long as every cell in the
|
||
// candidate row matches the head's (block, ao).
|
||
let mut h = 1;
|
||
'row: while v0 + h < size_v {
|
||
for k in 0..w {
|
||
if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(cell) {
|
||
break 'row;
|
||
}
|
||
}
|
||
h += 1;
|
||
}
|
||
|
||
let slice = if positive { d + 1 } else { d };
|
||
let to_world = |u_val: i32, v_val: i32| -> [f32; 3] {
|
||
let mut p = [0f32; 3];
|
||
p[axis] = slice as f32;
|
||
p[u_axis] = u_val as f32;
|
||
p[v_axis] = v_val as f32;
|
||
[p[0] + base_x as f32, p[1], p[2] + base_z as f32]
|
||
};
|
||
let c0 = to_world(u0, v0);
|
||
let c1 = to_world(u0 + w, v0);
|
||
let c2 = to_world(u0 + w, v0 + h);
|
||
let c3 = to_world(u0, v0 + h);
|
||
|
||
let color = cell.block.face_color(face);
|
||
let leaf = if cell.block == Block::Leaves { 1.0 } else { 0.0 };
|
||
let ao_f = [
|
||
AO_TABLE[cell.ao[0] as usize],
|
||
AO_TABLE[cell.ao[1] as usize],
|
||
AO_TABLE[cell.ao[2] as usize],
|
||
AO_TABLE[cell.ao[3] as usize],
|
||
];
|
||
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],
|
||
color,
|
||
normal: n_arr,
|
||
leaf,
|
||
ao: ao_f[i],
|
||
sky_vis: sky_vis_f[i],
|
||
});
|
||
}
|
||
// Flip the diagonal when AO is "anisotropic" — i.e.
|
||
// when ao[0]+ao[2] < ao[1]+ao[3]. This stops the
|
||
// visible diagonal gradient artifact across quads
|
||
// where the four corners disagree.
|
||
let flip = ao_f[0] + ao_f[2] < ao_f[1] + ao_f[3];
|
||
if positive {
|
||
if flip {
|
||
indices.extend_from_slice(&[
|
||
base_idx,
|
||
base_idx + 1,
|
||
base_idx + 3,
|
||
base_idx + 1,
|
||
base_idx + 2,
|
||
base_idx + 3,
|
||
]);
|
||
} else {
|
||
indices.extend_from_slice(&[
|
||
base_idx,
|
||
base_idx + 1,
|
||
base_idx + 2,
|
||
base_idx,
|
||
base_idx + 2,
|
||
base_idx + 3,
|
||
]);
|
||
}
|
||
} else if flip {
|
||
indices.extend_from_slice(&[
|
||
base_idx,
|
||
base_idx + 3,
|
||
base_idx + 1,
|
||
base_idx + 1,
|
||
base_idx + 3,
|
||
base_idx + 2,
|
||
]);
|
||
} else {
|
||
indices.extend_from_slice(&[
|
||
base_idx,
|
||
base_idx + 2,
|
||
base_idx + 1,
|
||
base_idx,
|
||
base_idx + 3,
|
||
base_idx + 2,
|
||
]);
|
||
}
|
||
|
||
for hh in 0..h {
|
||
for ww in 0..w {
|
||
mask[((v0 + hh) * size_u + u0 + ww) as usize] = None;
|
||
}
|
||
}
|
||
u0 += w;
|
||
} else {
|
||
u0 += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ChunkMesh { vertices, indices }
|
||
}
|
||
|
||
/// Emit 6 faces of a box centered at `center`, rotated around Y by
|
||
/// `yaw`, extending `half_extents` in each local-space axis. Each face
|
||
/// winds CCW outward — verified by `oriented_box_winds_correctly`.
|
||
///
|
||
/// Used by the renderer to draw remote players (body + head boxes).
|
||
/// Lives in `mesh.rs` because the output is `Vertex`es with normals and
|
||
/// AO, same as the chunk meshes.
|
||
pub fn emit_oriented_box(
|
||
center: Vec3,
|
||
half_extents: Vec3,
|
||
yaw: f32,
|
||
color: [f32; 3],
|
||
verts: &mut Vec<Vertex>,
|
||
indices: &mut Vec<u32>,
|
||
) {
|
||
let cos_y = yaw.cos();
|
||
let sin_y = yaw.sin();
|
||
let rotate_xz = |x: f32, z: f32| (x * cos_y - z * sin_y, x * sin_y + z * cos_y);
|
||
let world_pt = |lx: f32, ly: f32, lz: f32| {
|
||
let (rx, rz) = rotate_xz(lx, lz);
|
||
[center.x + rx, center.y + ly, center.z + rz]
|
||
};
|
||
let world_normal = |nx: f32, ny: f32, nz: f32| {
|
||
let (rx, rz) = rotate_xz(nx, nz);
|
||
[rx, ny, rz]
|
||
};
|
||
|
||
let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z);
|
||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||
([1.0, 0.0, 0.0], [
|
||
[ hx, -hy, -hz],
|
||
[ hx, -hy, hz],
|
||
[ hx, hy, hz],
|
||
[ hx, hy, -hz],
|
||
]),
|
||
([-1.0, 0.0, 0.0], [
|
||
[-hx, -hy, hz],
|
||
[-hx, -hy, -hz],
|
||
[-hx, hy, -hz],
|
||
[-hx, hy, hz],
|
||
]),
|
||
([0.0, 1.0, 0.0], [
|
||
[-hx, hy, -hz],
|
||
[ hx, hy, -hz],
|
||
[ hx, hy, hz],
|
||
[-hx, hy, hz],
|
||
]),
|
||
([0.0, -1.0, 0.0], [
|
||
[-hx, -hy, hz],
|
||
[ hx, -hy, hz],
|
||
[ hx, -hy, -hz],
|
||
[-hx, -hy, -hz],
|
||
]),
|
||
([0.0, 0.0, 1.0], [
|
||
[ hx, -hy, hz],
|
||
[-hx, -hy, hz],
|
||
[-hx, hy, hz],
|
||
[ hx, hy, hz],
|
||
]),
|
||
([0.0, 0.0, -1.0], [
|
||
[-hx, -hy, -hz],
|
||
[ hx, -hy, -hz],
|
||
[ hx, hy, -hz],
|
||
[-hx, hy, -hz],
|
||
]),
|
||
];
|
||
for (n_local, corners_local) in faces {
|
||
let n_world = world_normal(n_local[0], n_local[1], n_local[2]);
|
||
let base = verts.len() as u32;
|
||
for c in corners_local {
|
||
verts.push(Vertex {
|
||
pos: world_pt(c[0], c[1], c[2]),
|
||
color,
|
||
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]);
|
||
}
|
||
}
|
||
|
||
/// FNV-1a 32-bit hash of a string. Used to colorize remote-player
|
||
/// boxes deterministically by display name.
|
||
pub fn name_hash(s: &str) -> u32 {
|
||
let mut h: u32 = 2166136261;
|
||
for b in s.bytes() {
|
||
h ^= b as u32;
|
||
h = h.wrapping_mul(16777619);
|
||
}
|
||
h
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::world::{Block, Chunk, World, CHUNK_HEIGHT, CHUNK_SIZE};
|
||
|
||
/// A world containing exactly one chunk at the origin with all blocks
|
||
/// you put into it via the closure, and nothing else.
|
||
fn single_chunk_world(fill: impl FnOnce(&mut Chunk)) -> World {
|
||
let mut world = World {
|
||
chunks: std::collections::HashMap::new(),
|
||
};
|
||
let mut chunk = Chunk::new(IVec3::ZERO);
|
||
fill(&mut chunk);
|
||
world.chunks.insert(IVec3::ZERO, chunk);
|
||
world
|
||
}
|
||
|
||
fn cross_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
|
||
// Cross product of (b - a) x (c - a)
|
||
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||
[
|
||
u[1] * v[2] - u[2] * v[1],
|
||
u[2] * v[0] - u[0] * v[2],
|
||
u[0] * v[1] - u[1] * v[0],
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn single_block_produces_six_quads() {
|
||
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);
|
||
assert_eq!(mesh.vertices.len(), 6 * 4, "6 faces × 4 verts");
|
||
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces × 2 triangles × 3 indices");
|
||
}
|
||
|
||
#[test]
|
||
fn winding_is_ccw_with_outward_normal() {
|
||
// For every triangle the cross product of its first two edges must
|
||
// point the same way as the stored vertex normal. This catches the
|
||
// back-face culling bug we already shipped once.
|
||
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);
|
||
for tri in mesh.indices.chunks_exact(3) {
|
||
let a = mesh.vertices[tri[0] as usize].pos;
|
||
let b = mesh.vertices[tri[1] as usize].pos;
|
||
let c = mesh.vertices[tri[2] as usize].pos;
|
||
let n = mesh.vertices[tri[0] as usize].normal;
|
||
let geo = cross_normal(a, b, c);
|
||
let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2];
|
||
assert!(
|
||
dot > 0.0,
|
||
"triangle [{},{},{}] winds opposite its stored normal {:?} (cross={:?})",
|
||
tri[0], tri[1], tri[2], n, geo
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fully_solid_interior_emits_no_internal_faces() {
|
||
// Fill a 3×3×3 block of solids in the middle of the chunk. Only the
|
||
// outer faces of the cube should appear in the mesh; interior shared
|
||
// faces must cull each other.
|
||
let world = single_chunk_world(|c| {
|
||
for x in 6..9 {
|
||
for y in 4..7 {
|
||
for z in 6..9 {
|
||
c.set(x, y, z, Block::Stone);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
|
||
let mesh = build_chunk_mesh(&world, chunk);
|
||
// 3×3 = 9 quads per outward face × 6 faces = 54 quads at most.
|
||
// With greedy meshing these merge to one big quad per side: 6 quads.
|
||
assert!(
|
||
mesh.vertices.len() <= 6 * 4,
|
||
"greedy meshing should merge a 3x3x3 cube into 6 single quads, got {} verts",
|
||
mesh.vertices.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn isolated_block_has_full_ao() {
|
||
// A single block in empty space has no occluders, so every vertex
|
||
// should report fully-open AO (1.0).
|
||
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);
|
||
for v in &mesh.vertices {
|
||
assert!((v.ao - 1.0).abs() < 1e-6, "isolated face vertex ao={} (expected 1.0)", v.ao);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn neighboring_blocks_darken_shared_corner() {
|
||
// Two stones with a third sitting above the corner where their +Y
|
||
// faces meet — that corner must be darker than the corners far
|
||
// from the occluder.
|
||
//
|
||
// y+1: [occ at (5,5,4)]
|
||
// y: stone@(4,4,4) stone@(5,4,4)
|
||
//
|
||
// The corner at world (5, 5, 4) of stone(4,4,4)'s +Y face has an
|
||
// occluding block touching it from above on the +X side.
|
||
let world = single_chunk_world(|c| {
|
||
c.set(4, 4, 4, Block::Stone);
|
||
c.set(5, 4, 4, Block::Stone);
|
||
c.set(5, 5, 4, Block::Stone); // occluder above the right stone
|
||
});
|
||
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
|
||
let mesh = build_chunk_mesh(&world, chunk);
|
||
// The +Y face of stone(4,4,4) — find a vertex near (5,5,4)/(5,5,5)
|
||
// (the edge touching the occluder) and confirm its ao < 1.0.
|
||
let mut min_ao_near: f32 = 1.0;
|
||
let mut min_ao_far: f32 = 1.0;
|
||
for v in &mesh.vertices {
|
||
// restrict to top faces (normal.y > 0.5)
|
||
if v.normal[1] < 0.5 { continue; }
|
||
// is this vertex on stone(4,4,4) — within its quad bounds?
|
||
let x = v.pos[0];
|
||
let z = v.pos[2];
|
||
if x >= 4.0 - 0.01 && x <= 5.0 + 0.01 && z >= 4.0 - 0.01 && z <= 5.0 + 0.01 {
|
||
// vertices on the +X edge are close to the occluder
|
||
if (x - 5.0).abs() < 0.01 {
|
||
if v.ao < min_ao_near { min_ao_near = v.ao; }
|
||
}
|
||
// vertices on the -X edge are far from the occluder
|
||
if (x - 4.0).abs() < 0.01 {
|
||
if v.ao < min_ao_far { min_ao_far = v.ao; }
|
||
}
|
||
}
|
||
}
|
||
assert!(min_ao_near < 1.0, "corner adjacent to occluder must be darkened, was {}", min_ao_near);
|
||
assert!(min_ao_far > min_ao_near, "open corner must be brighter than the occluded one ({} vs {})", min_ao_far, min_ao_near);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_chunk_produces_no_geometry() {
|
||
let world = single_chunk_world(|_| {});
|
||
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
|
||
let mesh = build_chunk_mesh(&world, chunk);
|
||
assert!(mesh.vertices.is_empty());
|
||
assert!(mesh.indices.is_empty());
|
||
}
|
||
|
||
// Touch the consts so the test compiles cleanly even if everything else
|
||
// is being rearranged.
|
||
#[test]
|
||
fn world_constants_are_sane() {
|
||
assert!(CHUNK_SIZE > 0);
|
||
assert!(CHUNK_HEIGHT > 0);
|
||
}
|
||
|
||
// ---------- emit_oriented_box ----------
|
||
|
||
#[test]
|
||
fn oriented_box_emits_six_quads() {
|
||
let mut v = vec![];
|
||
let mut i = vec![];
|
||
emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), 0.0, [1.0; 3], &mut v, &mut i);
|
||
assert_eq!(v.len(), 24);
|
||
assert_eq!(i.len(), 36);
|
||
}
|
||
|
||
#[test]
|
||
fn oriented_box_winds_correctly_at_any_yaw() {
|
||
for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] {
|
||
let mut v = vec![];
|
||
let mut i = vec![];
|
||
emit_oriented_box(
|
||
Vec3::new(5.0, 7.0, -3.0),
|
||
Vec3::new(0.3, 0.6, 0.2),
|
||
yaw,
|
||
[1.0; 3],
|
||
&mut v,
|
||
&mut i,
|
||
);
|
||
for tri in i.chunks_exact(3) {
|
||
let a = v[tri[0] as usize].pos;
|
||
let b = v[tri[1] as usize].pos;
|
||
let c = v[tri[2] as usize].pos;
|
||
let n = v[tri[0] as usize].normal;
|
||
let geo = cross_normal(a, b, c);
|
||
let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2];
|
||
assert!(dot > 0.0, "yaw {} tri winds opposite normal {:?}", yaw, n);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn oriented_box_normal_rotates_with_yaw() {
|
||
let mut v = vec![];
|
||
let mut i = vec![];
|
||
let yaw = std::f32::consts::FRAC_PI_2;
|
||
emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i);
|
||
let n = v[0].normal;
|
||
assert!(
|
||
(n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5,
|
||
"+X normal at yaw 90° expected (0,0,1), got {:?}",
|
||
n
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn name_hash_is_deterministic_and_distinct() {
|
||
let a = name_hash("alice");
|
||
let b = name_hash("bob");
|
||
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
|
||
);
|
||
}
|
||
}
|