makepad/libs/sim/tests/voxel_world.rs
Admin 6d708d9391 Clean the public tree for a checkout other people can use.
Drop the archived old/ tree, root AI notes, splashgame.md (Arcade lives
in the private sandbox), widgets-dll leftovers, and LAN/oracle helper
scripts. Move download_*.sh into tools/, and move sim/math out of
libs/game/ so the public repo no longer has a game/ directory.

Also quiet first-party compile noise and skip asset-ui tests that need
sandbox kits or uncommitted fixtures.
2026-08-18 15:23:19 +02:00

466 lines
13 KiB
Rust
Raw Permalink 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.

//! Voxel terrain in a full stepped world (mix.md T4 gates): movers stand on
//! blocky builds, walk through carved tunnels without heightfield snap-back,
//! rigids rest across collider hot-swaps, and the whole thing snapshots
//! bit-exactly.
use makepad_game_sim::voxel::REMESH_BUDGET_PER_TICK;
use makepad_game_sim::*;
use makepad_math::*;
/// A 65×65 heightfield with a ridge across the middle: h = 6 for |z| ≤ 4,
/// ramping to 0 at |z| = 8.
fn ridge_terrain() -> Terrain {
let cells = 65;
let mut heights = vec![0.0f32; cells * cells];
let mut colors = vec![vec4f(0.4, 0.6, 0.4, 1.0); cells * cells];
for gz in 0..cells {
for gx in 0..cells {
let z = -32.0 + gz as f32;
let az = z.abs();
let h = if az <= 4.0 {
6.0
} else if az < 8.0 {
6.0 * (8.0 - az) / 4.0
} else {
0.0
};
heights[gz * cells + gx] = h;
if h > 0.5 {
colors[gz * cells + gx] = vec4f(0.5, 0.45, 0.4, 1.0);
}
}
}
Terrain {
cells,
cell_size: 1.0,
origin: -32.0,
heights,
colors,
revision: 1,
}
}
fn flat_terrain() -> Terrain {
let cells = 65;
Terrain {
cells,
cell_size: 1.0,
origin: -32.0,
heights: vec![0.0; cells * cells],
colors: vec![vec4f(0.4, 0.6, 0.4, 1.0); cells * cells],
revision: 1,
}
}
fn world_with(terrain: Terrain) -> GameWorld {
let mut w = GameWorld::new();
w.gravity = 30.0;
w.terrain = Some(terrain);
w
}
fn declare_volume(w: &mut GameWorld, min: Vec3f, max: Vec3f, mode: VoxelMode) {
let voxel = w
.voxel
.get_or_insert_with(|| Box::new(VoxelField::new(0.5)));
voxel.declare_volume(min, max, mode);
}
fn dig(w: &mut GameWorld, pos: Vec3f, r: f32, mode: DigMode) {
w.apply_voxel_op(VoxelOp::Dig {
pos,
r,
mode,
material: 1,
});
}
fn set_block(w: &mut GameWorld, x: i32, y: i32, z: i32, material: u8) {
w.apply_voxel_op(VoxelOp::SetBlock { x, y, z, material });
}
fn mover(w: &mut GameWorld, pos: Vec3f) -> u64 {
w.next_id += 1;
let id = w.next_id;
w.push_entity(Entity {
id,
kind: BodyKind::Mover,
pos,
half: vec3f(0.35, 0.9, 0.35),
collide: true,
gravity_scale: 1.0,
push_mass: 1.0,
speed_mult: 1.0,
scale: vec3f(1.0, 1.0, 1.0),
scale_target: vec3f(1.0, 1.0, 1.0),
density: 1.0,
friction: 0.6,
..Default::default()
});
id
}
fn rigid_ball(w: &mut GameWorld, pos: Vec3f, r: f32) -> u64 {
w.next_id += 1;
let id = w.next_id;
w.push_entity(Entity {
id,
kind: BodyKind::Rigid,
shape: Shape::Sphere,
pos,
half: vec3f(r, r, r),
collide: true,
gravity_scale: 1.0,
push_mass: 1.0,
speed_mult: 1.0,
scale: vec3f(1.0, 1.0, 1.0),
scale_target: vec3f(1.0, 1.0, 1.0),
density: 1.0,
friction: 0.5,
restitution: 0.1,
..Default::default()
});
id
}
/// Drain the remesh queue: run world ticks until no chunk is dirty. Bounded
/// so a scheduling bug fails the test instead of hanging it.
fn settle_meshes(w: &mut GameWorld) {
for _ in 0..64 {
step_world(w);
if w.voxel.as_ref().map_or(true, |v| v.dirty_len() == 0) {
return;
}
}
panic!(
"remesh queue never drained: {} dirty",
w.voxel.as_ref().unwrap().dirty_len()
);
}
/// Carve a tunnel through the ridge along z at x=0: spheres every metre from
/// well before the ridge to well after, at roof-safe depth.
fn carve_tunnel(w: &mut GameWorld) {
for i in -9..=9 {
dig(w, vec3f(0.0, 1.0, i as f32), 1.6, DigMode::Carve);
}
}
#[test]
fn a_mover_walks_through_a_tunnel_under_the_ridge() {
let mut w = world_with(ridge_terrain());
declare_volume(
&mut w,
vec3f(-6.0, -3.0, -12.0),
vec3f(6.0, 12.0, 12.0),
VoxelMode::Smooth,
);
carve_tunnel(&mut w);
settle_meshes(&mut w);
let id = mover(&mut w, vec3f(0.0, 1.0, -10.5));
let mut max_y = f32::MIN;
for _ in 0..600 {
if let Some(e) = w.entity_mut(id) {
e.vel.x = 0.0;
e.vel.z = 3.0;
}
step_world(&mut w);
let e = w.entity(id).unwrap();
max_y = max_y.max(e.pos.y);
}
let e = w.entity(id).unwrap();
assert!(
e.pos.z > 9.0,
"mover stuck at z={:.2} — never crossed the ridge through the tunnel",
e.pos.z
);
// The ridge tops at 6; a mover teleported onto it would peak ≥ 6.9.
assert!(
max_y < 4.0,
"mover peaked at y={max_y:.2} — it went OVER the ridge, not through it"
);
}
#[test]
fn a_blocky_tower_holds_a_mover_and_blocks_walking() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(8.0, -2.0, 8.0),
vec3f(24.0, 14.0, 24.0),
VoxelMode::Blocky,
);
// A 2×2 tower, 8 blocks tall, on the ground at (20..21, 20..21) sites →
// world x/z 10..11, top at site y=8 → world y 4.0.
for x in 20..22 {
for z in 20..22 {
for y in 0..8 {
set_block(&mut w, x, y, z, 3);
}
}
}
settle_meshes(&mut w);
let top = 8.0 * 0.5;
// Stand on it: drop a mover over the tower.
let id = mover(&mut w, vec3f(10.5, top + 3.0, 10.5));
for _ in 0..120 {
step_world(&mut w);
}
let e = w.entity(id).unwrap();
assert!(e.on_floor, "mover never landed on the tower");
assert!(
(e.pos.y - (top + 0.9)).abs() < 0.1,
"mover rests at y={:.2}, expected ≈ {:.2} (tower top {top})",
e.pos.y,
top + 0.9
);
// Walk into it: a mover on the ground cannot pass through the tower.
let walker = mover(&mut w, vec3f(7.5, 0.9, 10.5));
for _ in 0..180 {
if let Some(e) = w.entity_mut(walker) {
e.vel.x = 3.0;
e.vel.z = 0.0;
}
step_world(&mut w);
}
let e = w.entity(walker).unwrap();
assert!(
e.pos.x < 10.0,
"walker at x={:.2} passed through the tower wall at x=10",
e.pos.x
);
assert!(e.pos.y < 2.0, "walker climbed the tower to y={:.2}", e.pos.y);
}
#[test]
fn a_rigid_ball_falls_into_a_dug_crater() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-12.0, -6.0, -12.0),
vec3f(12.0, 8.0, 12.0),
VoxelMode::Smooth,
);
dig(&mut w, vec3f(4.0, 0.0, 4.0), 3.0, DigMode::Carve);
settle_meshes(&mut w);
assert!(
w.dynamics.voxel_body_count() > 0,
"no voxel colliders after the dig"
);
let ball = rigid_ball(&mut w, vec3f(4.0, 3.0, 4.0), 0.4);
for _ in 0..300 {
step_world(&mut w);
}
let e = w.entity(ball).unwrap();
assert!(
e.pos.y < -0.5,
"ball rests at y={:.2} — it never fell below the old ground into the crater",
e.pos.y
);
}
#[test]
fn collider_hot_swap_never_drops_a_resting_rigid() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-12.0, -6.0, -12.0),
vec3f(12.0, 8.0, 12.0),
VoxelMode::Smooth,
);
// Materialize the chunk (and punch its heightfield cells) with a small
// crater in one corner, then rest a ball on the UNDUG voxel surface of
// the same chunk.
dig(&mut w, vec3f(2.0, 0.0, 2.0), 1.5, DigMode::Carve);
settle_meshes(&mut w);
let ball = rigid_ball(&mut w, vec3f(10.0, 1.0, 10.0), 0.4);
for _ in 0..240 {
step_world(&mut w);
}
let settled = w.entity(ball).unwrap().pos;
assert!(
settled.y > 0.2 && settled.y < 0.7,
"ball settled at y={:.2}, expected ≈ 0.4 on the voxel ground",
settled.y
);
// Remesh the SAME chunk (another dig far from the ball) — the collider
// hot-swaps. The ball must neither drop nor pop across the swap.
let mut min_y = f32::MAX;
for i in 0..90 {
if i % 30 == 0 {
dig(
&mut w,
vec3f(2.0, 0.0, 6.0 + i as f32 * 0.02),
1.5,
DigMode::Carve,
);
}
step_world(&mut w);
min_y = min_y.min(w.entity(ball).unwrap().pos.y);
}
let after = w.entity(ball).unwrap().pos;
assert!(
min_y > settled.y - 0.05,
"ball dipped to y={min_y:.3} during collider swaps (settled {:.3}) — a frame ran without collision",
settled.y
);
assert!(
(after.y - settled.y).abs() < 0.05,
"ball moved from {:.3} to {:.3} across remeshes",
settled.y,
after.y
);
}
#[test]
fn voxel_worlds_snapshot_and_step_bit_identically() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-12.0, -6.0, -12.0),
vec3f(12.0, 8.0, 12.0),
VoxelMode::Smooth,
);
dig(&mut w, vec3f(4.0, 0.0, 4.0), 3.0, DigMode::Carve);
settle_meshes(&mut w);
let ball = rigid_ball(&mut w, vec3f(4.5, 2.0, 3.5), 0.4);
for _ in 0..30 {
step_world(&mut w);
}
// Snapshot mid-flight (the Clone the rollback ring performs) and run
// both worlds forward: bit-identical, voxel colliders included.
let mut fork = w.clone();
for _ in 0..60 {
step_world(&mut w);
step_world(&mut fork);
}
let a = w.entity(ball).unwrap();
let b = fork.entity(ball).unwrap();
assert_eq!(a.pos, b.pos, "snapshot fork diverged");
assert_eq!(a.vel, b.vel);
assert_eq!(
w.voxel.as_ref().unwrap().field_hash(),
fork.voxel.as_ref().unwrap().field_hash()
);
}
#[test]
fn hot_reload_keeps_edits_and_a_redeclared_volume_remeshes_them() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-12.0, -6.0, -12.0),
vec3f(12.0, 8.0, 12.0),
VoxelMode::Smooth,
);
dig(&mut w, vec3f(0.0, 0.0, 0.0), 3.0, DigMode::Carve);
settle_meshes(&mut w);
let hash = w.voxel.as_ref().unwrap().field_hash();
assert!(w.voxel.as_ref().unwrap().is_carved_air(vec3f(0.0, -1.0, 0.0)));
// The script hot-reload path: content resets, script re-runs.
w.reset_content();
assert_eq!(
w.voxel.as_ref().unwrap().field_hash(),
hash,
"edits did not survive reset_content"
);
w.terrain = Some(flat_terrain());
declare_volume(
&mut w,
vec3f(-12.0, -6.0, -12.0),
vec3f(12.0, 8.0, 12.0),
VoxelMode::Smooth,
);
settle_meshes(&mut w);
assert!(
w.voxel.as_ref().unwrap().is_carved_air(vec3f(0.0, -1.0, 0.0)),
"crater gone after reload"
);
assert!(
w.dynamics.voxel_body_count() > 0,
"voxel colliders did not come back after reload"
);
}
/// T7 measurement: ms per chunk remesh, printed for the report. The bound is
/// deliberately loose — this is a regression tripwire, not a benchmark.
#[test]
fn remesh_cost_per_chunk_is_bounded() {
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-30.0, -6.0, -30.0),
vec3f(30.0, 8.0, 30.0),
VoxelMode::Smooth,
);
// Materialize a wide area.
for ix in -1..=1 {
for iz in -1..=1 {
dig(
&mut w,
vec3f(ix as f32 * 16.0, 0.0, iz as f32 * 16.0),
3.0,
DigMode::Carve,
);
}
}
let voxel = w.voxel.as_mut().unwrap();
let dirty = voxel.dirty_len();
assert!(dirty >= 9);
let start = std::time::Instant::now();
let mut done = 0;
while voxel.dirty_len() > 0 {
done += voxel.update_meshes(BaseSample::World(w.terrain.as_ref()), REMESH_BUDGET_PER_TICK);
}
let elapsed = start.elapsed();
let per_chunk = elapsed.as_secs_f64() * 1000.0 / done as f64;
println!(
"remesh: {done} chunks in {:.2} ms — {per_chunk:.3} ms/chunk (budget {REMESH_BUDGET_PER_TICK}/tick)",
elapsed.as_secs_f64() * 1000.0
);
assert!(
per_chunk < 25.0,
"remesh cost exploded: {per_chunk:.2} ms per chunk"
);
// The full in-world path: budgeted meshing + hole punches + box3d mesh
// collider builds, as step_world runs it. This is the number a tick
// actually pays while someone digs.
let mut w = world_with(flat_terrain());
declare_volume(
&mut w,
vec3f(-30.0, -6.0, -30.0),
vec3f(30.0, 8.0, 30.0),
VoxelMode::Smooth,
);
for ix in -1..=1 {
for iz in -1..=1 {
dig(
&mut w,
vec3f(ix as f32 * 16.0, 0.0, iz as f32 * 16.0),
3.0,
DigMode::Carve,
);
}
}
let dirty = w.voxel.as_ref().unwrap().dirty_len();
let start = std::time::Instant::now();
let mut ticks = 0;
while w.voxel.as_ref().unwrap().dirty_len() > 0 && ticks < 64 {
step_world(&mut w);
ticks += 1;
}
let elapsed = start.elapsed();
println!(
"remesh+colliders: {dirty} chunks over {ticks} ticks in {:.2} ms — {:.3} ms/chunk, {:.3} ms/tick at budget {REMESH_BUDGET_PER_TICK}",
elapsed.as_secs_f64() * 1000.0,
elapsed.as_secs_f64() * 1000.0 / dirty as f64,
elapsed.as_secs_f64() * 1000.0 / ticks as f64,
);
}