makepad/libs/game/render/examples/ao_probe.rs
Admin d4c9392912 Baked AO for static props, the big world wired, and steering fixed at its source
AO — ZERO EXTRA BYTES. It lives in the alpha byte of the packed colour lane,
which was already dead weight: model.rs wrote the glTF baseColorFactor alpha
there, the skinned shader multiplied it into v_tint.w, and the pixel shader
threw it away by returning a hardcoded 1.0. We were paying for the channel and
never reading it. 24 bytes/vertex before and after.

The multiply scales AMBIENT ONLY — `albedo * (ambient * ao + direct)`. Folding
AO into direct as well would darken a sunlit wall twice, and direct light is
already zero where a surface faces away, which is precisely where occlusion is
the ambient term's job.

Static-only holds BY CONSTRUCTION without touching skin.rs: that file writes
pack_unorm8x4(1,1,1,1), so characters get ambient * 1.0 — an exact no-op
through the shared shader.

Cost over the real catalogue (4,442 models, 2.5M verts): 2.64 ms/model average,
102 ms worst case — down from 409 ms. Dense interior kits get a reduced ray
budget, and the hemisphere distributes over the ACTUAL ray count rather than
the nominal one; without that fix a reduced budget samples only near the normal
and reads as uniformly unoccluded. The 4x speedup moved the crevice share
14.7% -> 15.0%, i.e. cost nothing visually. Nothing in the library falls below
0.40 occlusion — the floor clamp is what keeps low-poly art out of the mud.

Contact AO needed one fix found by rendering it: an ellipse inscribed in a
square footprint pulls away from the corners, so a castle piece read as
standing in a spotlight rather than touching the ground. It is a squircle now
(|x|^4+|z|^4=1) with segments landing on the corners and edge midpoints.

STEERING FIXED ONCE, AT THE SOURCE. New libs/game/sim/heading.rs states the
convention in one place — forward is -Z, right is +X, POSITIVE YAW TURNS LEFT —
with heading_to_forward/right, forward_to_heading, steer_to_yaw_rate,
heading_delta. Seven tests read as statements of intent ("steering right
decreases heading") so a future sign flip fails loudly. The car's torque and
its autodrive route-follower both route through it and the inline atan2 calls
are gone. The inversion was exactly the trap the module now documents: positive
steer produced positive yaw, which turns left.

DOUBLE BRAINS, found by wiring: spawn_blocks ran unconditionally after
build_world, so every villager got a SECOND Npc block — two brains steering one
body — plus a second car. 28 NPCs for 14 entities; now 14.

The car is a real mesh (toy-car-kit/vehicle-truck) found by description and
scaled from its own bounds onto the chassis, box hidden. The rigid body stays
the physics.

BIG WORLD RENDERS: ARCADE_WORLD=big, street demo still default. 596 props, 217
colliders, 64 draw items (per-pack atlas batching working), 611 shadow casters,
14 NPCs, 63 of 64 models loaded, 15 ms to plan. 506,962 TRIANGLES — that will
not fit a Quest, and roads are 382 of 596 placements, so road decoration and
distant woods scatter are the first cuts a governor should make.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:34:14 +02:00

87 lines
3 KiB
Rust

//! Bake cost and AO distribution over the real Kenney catalogue.
//!
//! cargo run -p makepad-game-render --release --example ao_probe
//!
//! Reports parse+bake time per model and library-wide, and how much of each
//! model actually darkens — a bake that leaves everything at 1.0 is doing
//! nothing, and one that drives everything to the floor is mud.
use std::time::Instant;
use makepad_game_render::model::{StaticModel, MODEL_VERTEX_FLOATS};
fn main() {
let root = std::env::args()
.nth(1)
.unwrap_or_else(|| "apps/arcade/resources/models/kenney".to_string());
let mut files: Vec<std::path::PathBuf> = Vec::new();
collect(std::path::Path::new(&root), &mut files);
files.sort();
if files.is_empty() {
eprintln!("no .glb under {root} — run apps/arcade/download_assets.sh");
return;
}
let limit: usize = std::env::var("AO_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(usize::MAX);
let mut total_ms = 0.0f64;
let mut total_verts = 0usize;
let mut total_tris = 0usize;
let mut done = 0usize;
let mut worst: Vec<(f64, String, usize)> = Vec::new();
// Distribution of the baked AO term across every vertex in the library.
let mut buckets = [0usize; 5];
for f in files.iter().take(limit) {
let Ok(bytes) = std::fs::read(f) else { continue };
let t = Instant::now();
let Ok(m) = StaticModel::parse_glb(&bytes) else { continue };
let ms = t.elapsed().as_secs_f64() * 1000.0;
total_ms += ms;
total_verts += m.vertex_count();
total_tris += m.triangle_count();
done += 1;
worst.push((ms, f.file_name().unwrap().to_string_lossy().into(), m.vertex_count()));
for i in 0..m.vertex_count() {
let packed = m.vertices[i * MODEL_VERTEX_FLOATS + 5].to_bits();
let ao = ((packed >> 24) & 0xff) as f32 / 255.0;
let b = ((1.0 - ao) * 5.0).min(4.0) as usize;
buckets[b] += 1;
}
}
worst.sort_by(|a, b| b.0.total_cmp(&a.0));
println!("models {done}, verts {total_verts}, tris {total_tris}");
println!(
"parse+bake {:.1} ms total, {:.3} ms/model avg",
total_ms,
total_ms / done.max(1) as f64
);
println!("slowest:");
for (ms, name, v) in worst.iter().take(5) {
println!(" {ms:7.2} ms {v:6} verts {name}");
}
let tv = total_verts.max(1) as f32;
println!("AO spread (share of vertices):");
for (i, c) in buckets.iter().enumerate() {
let lo = 1.0 - (i as f32 + 1.0) * 0.2;
let hi = 1.0 - i as f32 * 0.2;
println!(" {lo:.2}-{hi:.2} {:5.1}%", *c as f32 / tv * 100.0);
}
}
fn collect(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect(&p, out);
} else if p.extension().map(|x| x == "glb").unwrap_or(false) {
out.push(p);
}
}
}