terainia/src/mesh.rs
Maximus Gorog 511798b6eb 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.
2026-05-24 00:00:04 -06:00

671 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
);
}
}