GameCult.Geometry.Csg/examples/csg_perf_fixture.rs
2026-08-26 17:38:13 +02:00

497 lines
15 KiB
Rust

use std::{
hint::black_box,
time::{Duration, Instant},
};
use bevy_math::{Quat, Vec3};
use gamecult_geometry_csg::{
Aabb, Assembler, BrushOp, DomainQuery, MaterialId, Primitive, lower_selected_cut_chunks,
ragnarok_column_fixture, select_domain_cut,
};
const WARMUP_ITERS: usize = 8;
const MEASURE_ITERS: usize = 64;
fn main() {
let cases = [
PerfCase {
name: "single_center_cut",
brushes: 2,
build: single_center_cut,
},
PerfCase {
name: "room_grid_8x8_doors",
brushes: 192,
build: room_grid_8x8_doors,
},
PerfCase {
name: "rotated_cut_stack_64",
brushes: 65,
build: rotated_cut_stack_64,
},
PerfCase {
name: "common_box_chain_64",
brushes: 64,
build: common_box_chain_64,
},
PerfCase {
name: "distant_cutters_512",
brushes: 513,
build: distant_cutters_512,
},
PerfCase {
name: "distant_oriented_cutters_128",
brushes: 129,
build: distant_oriented_cutters_128,
},
];
for case in cases {
run_case(case, PerfMode::Stable);
run_case(case, PerfMode::Dirty);
run_case(case, PerfMode::IncrementalDirty);
run_case(case, PerfMode::Routed);
}
run_lod_case(
"ragnarok_column_lod_tight",
ragnarok_lod_query(10_000.0, 100),
);
run_lod_case("ragnarok_column_lod_medium", ragnarok_lod_query(0.01, 560));
run_lod_case("ragnarok_column_lod_high", ragnarok_lod_query(0.01, 10_000));
}
#[derive(Clone, Copy)]
struct PerfCase {
name: &'static str,
brushes: usize,
build: fn() -> Assembler,
}
#[derive(Clone, Copy)]
enum PerfMode {
Stable,
Dirty,
IncrementalDirty,
Routed,
}
impl PerfMode {
fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Dirty => "dirty",
Self::IncrementalDirty => "incremental_dirty",
Self::Routed => "routed",
}
}
fn build(
self,
assembler: &mut Assembler,
iteration: usize,
) -> gamecult_geometry_csg::BuildOutput {
match self {
Self::Stable => assembler.build(),
Self::Dirty => {
nudge_tail_brush(assembler, iteration);
assembler.rebuild()
}
Self::IncrementalDirty => {
nudge_tail_brush(assembler, iteration);
assembler.build_incremental()
}
Self::Routed => assembler.rebuild_routed_surfaces(),
}
}
}
fn run_case(case: PerfCase, mode: PerfMode) {
let mut assembler = (case.build)();
if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() {
return;
}
let warmup = if matches!(mode, PerfMode::IncrementalDirty) {
assembler.build_incremental()
} else {
assembler.build()
};
black_box(warmup.mesh.triangle_count());
for _ in 0..WARMUP_ITERS {
let output = mode.build(&mut assembler, 0);
black_box(output.mesh.triangle_count());
}
let mut timings = Vec::with_capacity(MEASURE_ITERS);
let mut triangles = 0;
let mut fragments = 0;
let mut warnings = 0;
let mut candidate_pairs = 0;
let mut rejected_pairs = 0;
let mut reused_mesh = false;
let mut used_checkpoint = false;
let mut checkpoint_rebuild_from = None::<usize>;
let mut direct_dirty_rebuild = false;
for iteration in 0..MEASURE_ITERS {
let start = Instant::now();
let output = mode.build(&mut assembler, iteration);
let elapsed = start.elapsed();
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
warnings = output.report.warnings.len();
candidate_pairs = output.report.candidate_pairs;
rejected_pairs = output.report.rejected_pairs;
reused_mesh = output.report.reused_mesh;
used_checkpoint = output.report.used_checkpoint;
checkpoint_rebuild_from = output.report.checkpoint_rebuild_from;
direct_dirty_rebuild = output.report.direct_dirty_rebuild;
black_box((
triangles,
fragments,
warnings,
candidate_pairs,
rejected_pairs,
reused_mesh,
used_checkpoint,
checkpoint_rebuild_from,
direct_dirty_rebuild,
));
timings.push(elapsed);
}
timings.sort_unstable();
let total = timings
.iter()
.fold(Duration::ZERO, |sum, value| sum + *value);
let mean_ns = total.as_nanos() / timings.len() as u128;
let min_ns = timings.first().map_or(0, Duration::as_nanos);
let max_ns = timings.last().map_or(0, Duration::as_nanos);
let p50_ns = percentile_ns(&timings, 50);
let p95_ns = percentile_ns(&timings, 95);
println!(
"{{\"kernel\":\"gamecult_geometry_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{},\"candidate_pairs\":{},\"rejected_pairs\":{},\"reused_mesh\":{},\"used_checkpoint\":{},\"checkpoint_rebuild_from\":{},\"direct_dirty_rebuild\":{}}}",
mode.as_str(),
case.name,
case.brushes,
MEASURE_ITERS,
WARMUP_ITERS,
mean_ns,
min_ns,
p50_ns,
p95_ns,
max_ns,
triangles,
fragments,
warnings,
candidate_pairs,
rejected_pairs,
reused_mesh,
used_checkpoint,
checkpoint_rebuild_from.map_or_else(|| "null".to_string(), |index| index.to_string()),
direct_dirty_rebuild
);
}
fn nudge_tail_brush(assembler: &mut Assembler, iteration: usize) {
let Some(brush) = assembler.brushes().last().cloned() else {
return;
};
let phase = if iteration.is_multiple_of(2) {
0.0
} else {
0.01
};
match brush.primitive {
Primitive::Box { bounds } => {
let center = bounds.center();
let size = bounds.size() + Vec3::splat(phase);
assembler.set_brush_primitive(
brush.id,
Primitive::Box {
bounds: Aabb::from_center_size(center, size),
},
);
}
Primitive::OrientedBox {
center,
size,
rotation,
} => {
assembler.set_brush_primitive(
brush.id,
Primitive::OrientedBox {
center,
size: size + Vec3::splat(phase),
rotation,
},
);
}
_ => {}
}
}
fn percentile_ns(values: &[Duration], percentile: usize) -> u128 {
if values.is_empty() {
return 0;
}
let index = ((values.len() - 1) * percentile) / 100;
values[index].as_nanos()
}
fn run_lod_case(name: &'static str, query: DomainQuery) {
let fixture = ragnarok_column_fixture();
let warmup_cut = select_domain_cut(&fixture, &query);
let warmup_chunks = lower_selected_cut_chunks(&warmup_cut);
black_box(
warmup_chunks
.iter()
.map(|chunk| chunk.mesh.triangle_count())
.sum::<usize>(),
);
for _ in 0..WARMUP_ITERS {
let cut = select_domain_cut(&fixture, &query);
let chunks = lower_selected_cut_chunks(&cut);
black_box(
chunks
.iter()
.map(|chunk| chunk.mesh.triangle_count())
.sum::<usize>(),
);
}
let mut timings = Vec::with_capacity(MEASURE_ITERS);
let mut selected_nodes = 0;
let mut emitted_claims = 0;
let mut triangles = 0;
let mut collider_triangles = 0;
let mut brush_count = 0;
let mut candidate_pairs = 0;
let mut rejected_pairs = 0;
for _ in 0..MEASURE_ITERS {
let start = Instant::now();
let cut = select_domain_cut(&fixture, &query);
let chunks = lower_selected_cut_chunks(&cut);
let elapsed = start.elapsed();
selected_nodes = cut.selected_nodes.len();
emitted_claims = cut.emitted_claims.len();
triangles = chunks.iter().map(|chunk| chunk.mesh.triangle_count()).sum();
collider_triangles = chunks
.iter()
.map(|chunk| {
chunk
.collider_mesh
.as_ref()
.map_or(0, |mesh| mesh.triangle_count())
})
.sum();
brush_count = chunks.iter().map(|chunk| chunk.report.input_brushes).sum();
candidate_pairs = chunks
.iter()
.map(|chunk| chunk.report.candidate_pairs)
.sum();
rejected_pairs = chunks.iter().map(|chunk| chunk.report.rejected_pairs).sum();
black_box((
selected_nodes,
emitted_claims,
triangles,
collider_triangles,
brush_count,
candidate_pairs,
rejected_pairs,
));
timings.push(elapsed);
}
timings.sort_unstable();
let total = timings
.iter()
.fold(Duration::ZERO, |sum, value| sum + *value);
let mean_ns = total.as_nanos() / timings.len() as u128;
let min_ns = timings.first().map_or(0, Duration::as_nanos);
let max_ns = timings.last().map_or(0, Duration::as_nanos);
let p50_ns = percentile_ns(&timings, 50);
let p95_ns = percentile_ns(&timings, 95);
println!(
"{{\"kernel\":\"gamecult_geometry_csg\",\"mode\":\"lod_query\",\"scenario\":\"{}\",\"selected_nodes\":{},\"emitted_claims\":{},\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"collider_triangles\":{},\"candidate_pairs\":{},\"rejected_pairs\":{}}}",
name,
selected_nodes,
emitted_claims,
brush_count,
MEASURE_ITERS,
WARMUP_ITERS,
mean_ns,
min_ns,
p50_ns,
p95_ns,
max_ns,
triangles,
collider_triangles,
candidate_pairs,
rejected_pairs
);
}
fn ragnarok_lod_query(target_error: f32, triangle_budget: usize) -> DomainQuery {
DomainQuery {
camera_position: Vec3::new(36.0, -42.0, 30.0),
frustum: Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::splat(150.0)),
viewport_height_px: 1080.0,
vertical_fov_radians: std::f32::consts::FRAC_PI_3,
target_error,
triangle_budget,
collider_budget: triangle_budget,
semantic_filter: Vec::new(),
requested_chunk_keys: Vec::new(),
dirty_domain_keys: Vec::new(),
}
}
fn single_center_cut() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
MaterialId(1),
);
asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)));
asm
}
fn room_grid_8x8_doors() -> Assembler {
let mut asm = Assembler::new();
let wall = MaterialId(1);
let floor = MaterialId(2);
let cell = 6.0;
for y in 0..8 {
for x in 0..8 {
let center = Vec3::new((x as f32 - 3.5) * cell, (y as f32 - 3.5) * cell, 0.0);
asm.solid_box(
format!("floor_{x}_{y}"),
Aabb::from_center_size(
center + Vec3::new(0.0, 0.0, -0.1),
Vec3::new(5.6, 5.6, 0.2),
),
floor,
);
asm.solid_box(
format!("north_wall_{x}_{y}"),
Aabb::from_center_size(
center + Vec3::new(0.0, 2.8, 1.5),
Vec3::new(5.6, 0.25, 3.0),
),
wall,
);
asm.cut_box(
format!("door_{x}_{y}"),
Aabb::from_center_size(center + Vec3::new(0.0, 2.8, 1.0), Vec3::new(1.2, 0.5, 2.0)),
);
}
}
asm
}
fn rotated_cut_stack_64() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"slab",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 32.0, 4.0)),
MaterialId(1),
);
for index in 0..64 {
let angle = index as f32 * 0.173;
let radius = 11.0 + (index % 7) as f32 * 0.35;
let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0);
asm.cut_oriented_box(
format!("rotated_void_{index}"),
center,
Vec3::new(1.0 + (index % 3) as f32 * 0.3, 8.0, 5.0),
Quat::from_rotation_z(angle),
);
}
asm
}
fn common_box_chain_64() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 16.0, 8.0)),
MaterialId(1),
);
for index in 1..64 {
let t = index as f32 / 63.0;
let center = Vec3::new(
(t - 0.5) * 6.0,
(t * std::f32::consts::TAU).sin() * 1.5,
0.0,
);
asm.add_brush(
format!("common_{index}"),
BrushOp::Intersect,
Primitive::OrientedBox {
center,
size: Vec3::new(30.0 - t * 8.0, 14.0, 7.0),
rotation: Quat::from_rotation_z(t * 0.2),
},
MaterialId(1),
);
}
asm
}
fn distant_cutters_512() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
for index in 0..512 {
let row = index / 32;
let col = index % 32;
asm.cut_box(
format!("far_void_{index}"),
Aabb::from_center_size(
Vec3::new(1000.0 + col as f32 * 4.0, 1000.0 + row as f32 * 4.0, 0.0),
Vec3::splat(1.0),
),
);
}
asm
}
fn distant_oriented_cutters_128() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
for index in 0..128 {
let row = index / 16;
let col = index % 16;
asm.cut_oriented_box(
format!("far_oriented_void_{index}"),
Vec3::new(1000.0 + col as f32 * 4.0, 1000.0 + row as f32 * 4.0, 0.0),
Vec3::splat(1.0),
Quat::from_rotation_z(index as f32 * 0.037),
);
}
asm
}