A POST-PASS, not a sweep change. The sweep carries the 0.55 step-up, CONTACT_SKIN and the terrain-cliff logic, and every existing contract was written against it, so it is untouched. separate_movers runs after the whole integration loop — which also makes the result independent of who stepped first — and before rider pinning, which stays authoritative. HORIZONTAL ONLY, resolving the least-penetration axis of x/z. Resolving vertically is exactly how characters end up standing on each other's heads; an overlapping pair is pushed apart on the ground plane and a stack unpicks itself. Three FIXED relaxation passes, deliberately not convergence-based: an early-exit on "nothing moved" makes the result depend on iteration order, and this has to be bit-reproducible. Broad phase is a uniform grid sized 2x the widest half, with buckets as a sorted (cell_key, index) array rather than a hash map — allocation-light AND ordered without a second sort. That replaced a hash map of per-cell Vecs and took allocations from 617/tick to ~15. Each shove is clamped by sweep_axis against the solid world. Without that, a crowd pressed against a wall squeezes its outermost members straight through. push_mass weights the split by the OTHER body's mass, so equals each give half and a player at 4.0 shoulders through NPCs at 1.0. 0.0 — the Default — READS AS 1.0, not as weightless: a literal zero would make every default-constructed mover infinitely shovable and divide by zero when two met. Same discipline as `hidden` over `visible`. Projectiles are excluded, and that is CORRECTNESS not taste: collect_touches reports a strike from the overlap itself, so separating projectiles would mean a bullet could never touch anyone. Sensors, collide:false decor and attached riders are skipped too. 50 packed movers 0.023 ms/tick 200 packed movers 0.123 12 villagers + 500 static 0.107 200 movers + 500 static 2.020 Packed crowds where everyone overlaps a neighbour — the honest worst case. The 200-among-500-statics figure is dominated by the per-shove static clamp; at the realistic 12-50 NPCs it is 0.1-0.25 ms. The fix if 200+ becomes normal is accumulating pushes and clamping once per mover per pass, deliberately not done because it changes Gauss-Seidel to Jacobi and the numbers don't justify it. THE GOLDEN HASHES DID NOT CHANGE, and that is genuine rather than lucky: mover_scene's walkers start 1.7 apart with 0.4 halves and diverge, and its only other mover is an attached rider, so no pair ever overlaps and the pass is inert. Nine new tests prove separation works; the unchanged goldens prove it does nothing where movers never meet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
9.1 KiB
Rust
293 lines
9.1 KiB
Rust
//! Engine weight harness: per-tick CPU, allocations and bytes, for
|
|
//! representative worlds. Run with `--release` for meaningful numbers.
|
|
//!
|
|
//! cargo run -p makepad-game-sim --release --example weigh
|
|
//!
|
|
//! Allocation counting uses a wrapping global allocator, so the numbers
|
|
//! include everything the tick touches, not just what this file allocates.
|
|
|
|
use std::alloc::{GlobalAlloc, Layout, System};
|
|
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
|
use std::time::Instant;
|
|
|
|
use makepad_game_sim::*;
|
|
use makepad_math::*;
|
|
|
|
static ALLOCS: AtomicU64 = AtomicU64::new(0);
|
|
static BYTES: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct Counting;
|
|
|
|
unsafe impl GlobalAlloc for Counting {
|
|
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
|
ALLOCS.fetch_add(1, Relaxed);
|
|
BYTES.fetch_add(l.size() as u64, Relaxed);
|
|
System.alloc(l)
|
|
}
|
|
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
|
System.dealloc(p, l)
|
|
}
|
|
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
|
|
ALLOCS.fetch_add(1, Relaxed);
|
|
BYTES.fetch_add(new as u64, Relaxed);
|
|
System.realloc(p, l, new)
|
|
}
|
|
}
|
|
|
|
#[global_allocator]
|
|
static A: Counting = Counting;
|
|
|
|
fn ent(id: u64, kind: BodyKind, pos: Vec3f, half: Vec3f) -> Entity {
|
|
Entity {
|
|
id,
|
|
kind,
|
|
pos,
|
|
half,
|
|
collide: true,
|
|
gravity_scale: 1.0,
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
speed_mult: 1.0,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Square heightfield with a gentle hill, the shape a game's ground has.
|
|
fn terrain(cells: usize) -> Terrain {
|
|
let cell_size = 1.0;
|
|
let origin = -(cells as f32 * cell_size) * 0.5;
|
|
let mut heights = Vec::with_capacity(cells * cells);
|
|
let mut colors = Vec::with_capacity(cells * cells);
|
|
for z in 0..cells {
|
|
for x in 0..cells {
|
|
let fx = x as f32 / cells as f32 - 0.5;
|
|
let fz = z as f32 / cells as f32 - 0.5;
|
|
heights.push(3.0 * (1.0 - (fx * fx + fz * fz) * 4.0).max(0.0));
|
|
colors.push(vec4f(0.4, 0.6, 0.3, 1.0));
|
|
}
|
|
}
|
|
Terrain { cells, cell_size, origin, heights, colors, revision: 1 }
|
|
}
|
|
|
|
struct Scene {
|
|
name: &'static str,
|
|
world: GameWorld,
|
|
}
|
|
|
|
fn scene(name: &'static str, statics: usize, movers: usize, rigids: usize, cells: usize) -> Scene {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
if cells > 0 {
|
|
w.terrain = Some(terrain(cells));
|
|
}
|
|
let mut id = 0u64;
|
|
id += 1;
|
|
w.push_entity(ent(id, BodyKind::Static, vec3f(0.0, -0.5, 0.0), vec3f(60.0, 0.5, 60.0)));
|
|
for i in 0..statics {
|
|
id += 1;
|
|
let a = i as f32 * 0.7;
|
|
w.push_entity(ent(
|
|
id,
|
|
BodyKind::Static,
|
|
vec3f(a.cos() * (8.0 + i as f32 * 0.3), 1.0, a.sin() * (8.0 + i as f32 * 0.3)),
|
|
vec3f(0.5, 1.0, 0.5),
|
|
));
|
|
}
|
|
for i in 0..movers {
|
|
id += 1;
|
|
let mut e = ent(
|
|
id,
|
|
BodyKind::Mover,
|
|
vec3f((i % 20) as f32 - 10.0, 6.0, (i / 20) as f32 - 5.0),
|
|
vec3f(0.4, 0.8, 0.4),
|
|
);
|
|
e.vel = vec3f(1.0, 0.0, 0.6);
|
|
e.auto_face = true;
|
|
e.turn_rate = 6.0;
|
|
w.push_entity(e);
|
|
}
|
|
for i in 0..rigids {
|
|
id += 1;
|
|
let mut e = ent(
|
|
id,
|
|
BodyKind::Rigid,
|
|
vec3f((i % 8) as f32 * 1.1, 2.0 + (i / 8) as f32 * 1.1, -6.0),
|
|
vec3f(0.5, 0.5, 0.5),
|
|
);
|
|
e.restitution = 0.05;
|
|
w.push_entity(e);
|
|
}
|
|
Scene { name, world: w }
|
|
}
|
|
|
|
fn measure(s: &mut Scene, ticks: usize) {
|
|
// Warm: first ticks build the box3d mirror and any lazy caches.
|
|
for _ in 0..30 {
|
|
step_world(&mut s.world);
|
|
}
|
|
let a0 = ALLOCS.load(Relaxed);
|
|
let b0 = BYTES.load(Relaxed);
|
|
let t0 = Instant::now();
|
|
for _ in 0..ticks {
|
|
step_world(&mut s.world);
|
|
}
|
|
let dt = t0.elapsed();
|
|
let allocs = ALLOCS.load(Relaxed) - a0;
|
|
let bytes = BYTES.load(Relaxed) - b0;
|
|
let statics = s
|
|
.world
|
|
.entities
|
|
.iter()
|
|
.filter(|e| matches!(e.kind, BodyKind::Static | BodyKind::Kinematic))
|
|
.count();
|
|
println!(
|
|
"{:<22} ents {:>5} static {:>5} terrain {:>6} | {:>8.3} ms/tick \
|
|
{:>7} allocs/tick {:>9} B/tick",
|
|
s.name,
|
|
s.world.entities.len(),
|
|
statics,
|
|
s.world.terrain.as_ref().map(|t| t.heights.len()).unwrap_or(0),
|
|
dt.as_secs_f64() * 1000.0 / ticks as f64,
|
|
allocs / ticks as u64,
|
|
bytes / ticks as u64,
|
|
);
|
|
}
|
|
|
|
/// Resident set size in bytes (macOS `ps`), for the soak check.
|
|
fn rss() -> u64 {
|
|
let pid = std::process::id();
|
|
std::process::Command::new("ps")
|
|
.args(["-o", "rss=", "-p", &pid.to_string()])
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.and_then(|s| s.trim().parse::<u64>().ok())
|
|
.map(|kb| kb * 1024)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// 10 simulated minutes of a busy world: RSS must be flat at the end, or the
|
|
/// tick path is leaking. Spawns and despawns projectiles throughout so the
|
|
/// entity list churns rather than sitting still.
|
|
fn soak(minutes: usize) {
|
|
let mut s = scene("soak", 200, 20, 20, 129);
|
|
let ticks = minutes * 60 * 60;
|
|
let mut next_id = s.world.entities.iter().map(|e| e.id).max().unwrap_or(0) + 1;
|
|
let mut samples: Vec<(usize, u64)> = Vec::new();
|
|
for t in 0..ticks {
|
|
// Churn: a short-lived projectile every 30 ticks.
|
|
if t % 30 == 0 {
|
|
let mut e = ent(
|
|
next_id,
|
|
BodyKind::Mover,
|
|
vec3f(0.0, 8.0, 0.0),
|
|
vec3f(0.2, 0.2, 0.2),
|
|
);
|
|
e.vel = vec3f(3.0, 2.0, 1.0);
|
|
e.life = 0.75;
|
|
e.hits = true;
|
|
s.world.push_entity(e);
|
|
next_id += 1;
|
|
}
|
|
step_world(&mut s.world);
|
|
let _ = collect_touches(&s.world);
|
|
if t % (ticks / 10).max(1) == 0 {
|
|
samples.push((t, rss()));
|
|
}
|
|
}
|
|
samples.push((ticks, rss()));
|
|
println!("\nsoak: {minutes} simulated minutes ({ticks} ticks), busy world");
|
|
for (t, r) in &samples {
|
|
println!(" tick {t:>7} RSS {:>7.1} MB", *r as f64 / 1048576.0);
|
|
}
|
|
let first = samples[1].1 as f64;
|
|
let last = samples.last().unwrap().1 as f64;
|
|
let drift = (last - first) / first * 100.0;
|
|
println!(
|
|
" drift after warmup: {drift:+.1}% ({} entities alive at end)",
|
|
s.world.entities.len()
|
|
);
|
|
}
|
|
|
|
/// Movers packed tightly enough that the separation pass actually engages,
|
|
/// all walking at a common point — the bench-crowd case. A scene with the same
|
|
/// mover count spread out costs far less, so this is the honest worst case.
|
|
fn crowd(name: &'static str, movers: usize, statics: usize) -> Scene {
|
|
let mut w = GameWorld::new();
|
|
w.reset_content();
|
|
let mut id = 0u64;
|
|
id += 1;
|
|
w.push_entity(ent(id, BodyKind::Static, vec3f(0.0, -0.5, 0.0), vec3f(60.0, 0.5, 60.0)));
|
|
for i in 0..statics {
|
|
id += 1;
|
|
let a = i as f32 * 0.7;
|
|
w.push_entity(ent(
|
|
id,
|
|
BodyKind::Static,
|
|
vec3f(a.cos() * (14.0 + i as f32 * 0.2), 1.0, a.sin() * (14.0 + i as f32 * 0.2)),
|
|
vec3f(0.5, 1.0, 0.5),
|
|
));
|
|
}
|
|
// A disc of bodies half a metre apart: everyone overlaps a neighbour.
|
|
let per_row = (movers as f32).sqrt().ceil() as usize;
|
|
for i in 0..movers {
|
|
id += 1;
|
|
let (gx, gz) = (i % per_row, i / per_row);
|
|
let mut e = ent(
|
|
id,
|
|
BodyKind::Mover,
|
|
vec3f(
|
|
gx as f32 * 0.5 - per_row as f32 * 0.25,
|
|
0.8,
|
|
gz as f32 * 0.5 - per_row as f32 * 0.25,
|
|
),
|
|
vec3f(0.4, 0.8, 0.4),
|
|
);
|
|
e.vel = vec3f(0.0, 0.0, 0.0);
|
|
e.auto_face = true;
|
|
e.turn_rate = 6.0;
|
|
w.push_entity(e);
|
|
}
|
|
Scene { name, world: w }
|
|
}
|
|
|
|
fn main() {
|
|
if std::env::args().any(|a| a == "--soak") {
|
|
soak(10);
|
|
return;
|
|
}
|
|
if std::env::args().any(|a| a == "--crowd") {
|
|
println!("mover separation cost (packed crowds, release recommended)\n");
|
|
for s in [
|
|
crowd("crowd 50", 50, 0),
|
|
crowd("crowd 200", 200, 0),
|
|
crowd("crowd 12 (village)", 12, 500),
|
|
crowd("crowd 50 + 500 static", 50, 500),
|
|
crowd("crowd 200 + 500 static", 200, 500),
|
|
]
|
|
.iter_mut()
|
|
{
|
|
measure(s, 600);
|
|
}
|
|
return;
|
|
}
|
|
let ticks: usize = std::env::args()
|
|
.nth(1)
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(600);
|
|
println!("step_world weight, {ticks} ticks each (release recommended)\n");
|
|
let mut scenes = vec![
|
|
scene("demo (arcade)", 20, 2, 8, 0),
|
|
scene("demo + terrain 65", 20, 2, 8, 65),
|
|
scene("racing-ish", 40, 4, 0, 129),
|
|
scene("terrain 129 only", 4, 2, 0, 129),
|
|
scene("terrain 257 only", 4, 2, 0, 257),
|
|
scene("large (500 static)", 500, 20, 20, 129),
|
|
scene("stress (2000 static)", 2000, 50, 50, 257),
|
|
];
|
|
for s in scenes.iter_mut() {
|
|
measure(s, ticks);
|
|
}
|
|
}
|