Expose dirty CSG perf mode

This commit is contained in:
Meta Vens 2026-05-07 04:31:59 +01:00
commit 76764870e9
4 changed files with 121 additions and 22 deletions

View file

@ -90,19 +90,26 @@ the performance target.
Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`:
```jsonl
{"kernel":"vg_csg","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":743,"min_ns":600,"p50_ns":700,"p95_ns":800,"max_ns":1300,"triangles":72,"fragments":6,"warnings":0}
{"kernel":"vg_csg","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":106331,"min_ns":78000,"p50_ns":89500,"p95_ns":161600,"max_ns":328900,"triangles":3072,"fragments":256,"warnings":0}
{"kernel":"vg_csg","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":141673,"min_ns":117600,"p50_ns":128900,"p95_ns":185900,"max_ns":296900,"triangles":3404,"fragments":280,"warnings":0}
{"kernel":"vg_csg","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":940,"min_ns":800,"p50_ns":900,"p95_ns":1000,"max_ns":1000,"triangles":45,"fragments":1,"warnings":0}
{"kernel":"vg_csg","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":717,"min_ns":500,"p50_ns":500,"p95_ns":600,"max_ns":12000,"triangles":12,"fragments":1,"warnings":0}
{"kernel":"realtime_csg_cpp","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":9500,"min_ns":6600,"p50_ns":7800,"p95_ns":13500,"max_ns":24900,"triangles":72,"vertices":144,"mesh_descriptions":3}
{"kernel":"realtime_csg_cpp","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":1167600,"min_ns":493200,"p50_ns":944700,"p95_ns":2337700,"max_ns":3141500,"triangles":12800,"vertices":20992,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":272400,"min_ns":122800,"p50_ns":200600,"p95_ns":519800,"max_ns":1843100,"triangles":3498,"vertices":5102,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":63600,"min_ns":44900,"p50_ns":59100,"p95_ns":88000,"max_ns":259700,"triangles":1536,"vertices":2412,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":573000,"min_ns":316200,"p50_ns":423000,"p95_ns":1235000,"max_ns":2374500,"triangles":6180,"vertices":12360,"mesh_descriptions":4}
{"kernel":"vg_csg","mode":"stable","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":948,"min_ns":800,"p50_ns":900,"p95_ns":1100,"max_ns":1200,"triangles":72,"fragments":6,"warnings":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":6123,"min_ns":4300,"p50_ns":5500,"p95_ns":6000,"max_ns":44700,"triangles":72,"fragments":6,"warnings":0}
{"kernel":"vg_csg","mode":"stable","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":85546,"min_ns":66100,"p50_ns":84900,"p95_ns":103300,"max_ns":114800,"triangles":3072,"fragments":256,"warnings":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":322628,"min_ns":231400,"p50_ns":301600,"p95_ns":428500,"max_ns":841700,"triangles":3072,"fragments":256,"warnings":0}
{"kernel":"vg_csg","mode":"stable","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":162057,"min_ns":107500,"p50_ns":139600,"p95_ns":271700,"max_ns":518200,"triangles":3404,"fragments":280,"warnings":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":4835134,"min_ns":2799000,"p50_ns":4260700,"p95_ns":7080200,"max_ns":10841300,"triangles":3404,"fragments":280,"warnings":0}
{"kernel":"vg_csg","mode":"stable","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":937,"min_ns":700,"p50_ns":900,"p95_ns":1100,"max_ns":9700,"triangles":45,"fragments":1,"warnings":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":700876,"min_ns":396000,"p50_ns":662400,"p95_ns":963600,"max_ns":1582700,"triangles":45,"fragments":1,"warnings":0}
{"kernel":"vg_csg","mode":"stable","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":278,"min_ns":200,"p50_ns":300,"p95_ns":300,"max_ns":400,"triangles":12,"fragments":1,"warnings":0}
{"kernel":"vg_csg","mode":"dirty","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":4762,"min_ns":4500,"p50_ns":4700,"p95_ns":5000,"max_ns":5200,"triangles":12,"fragments":1,"warnings":0}
{"kernel":"realtime_csg_cpp","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":7900,"min_ns":5900,"p50_ns":6300,"p95_ns":12100,"max_ns":16400,"triangles":72,"vertices":144,"mesh_descriptions":3}
{"kernel":"realtime_csg_cpp","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":775500,"min_ns":376100,"p50_ns":670500,"p95_ns":1451600,"max_ns":2108000,"triangles":12800,"vertices":20992,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":154300,"min_ns":106500,"p50_ns":132900,"p95_ns":240000,"max_ns":380100,"triangles":3498,"vertices":5102,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":64000,"min_ns":45000,"p50_ns":61400,"p95_ns":90500,"max_ns":224800,"triangles":1536,"vertices":2412,"mesh_descriptions":4}
{"kernel":"realtime_csg_cpp","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":740400,"min_ns":340900,"p50_ns":641200,"p95_ns":1403100,"max_ns":2258600,"triangles":6180,"vertices":12360,"mesh_descriptions":4}
```
Read these as smoke timings, not final benchmark gospel. Stable unchanged
output is no longer embarrassing. Dirty rebuilds for dense rotated subtraction
still need the category-router kernel; cached output is a guardrail, not a
replacement for the real engine.
output is no longer embarrassing. Dirty rebuilds are now explicit: box-heavy
dirty paths are respectable, distant bounds rejection is excellent, and dense
rotated subtraction is still the open wound. The category-router kernel remains
the next architectural move; cached output is a guardrail, not a replacement
for the real engine.

View file

@ -39,7 +39,8 @@ fn main() {
];
for case in cases {
run_case(case);
run_case(case, PerfMode::Stable);
run_case(case, PerfMode::Dirty);
}
}
@ -50,13 +51,35 @@ struct PerfCase {
build: fn() -> Assembler,
}
fn run_case(case: PerfCase) {
#[derive(Clone, Copy)]
enum PerfMode {
Stable,
Dirty,
}
impl PerfMode {
fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Dirty => "dirty",
}
}
fn build(self, assembler: &Assembler) -> vg_csg::BuildOutput {
match self {
Self::Stable => assembler.build(),
Self::Dirty => assembler.rebuild(),
}
}
}
fn run_case(case: PerfCase, mode: PerfMode) {
let assembler = (case.build)();
let warmup = assembler.build();
let warmup = mode.build(&assembler);
black_box(warmup.mesh.triangle_count());
for _ in 0..WARMUP_ITERS {
let output = assembler.build();
let output = mode.build(&assembler);
black_box(output.mesh.triangle_count());
}
@ -67,7 +90,7 @@ fn run_case(case: PerfCase) {
for _ in 0..MEASURE_ITERS {
let start = Instant::now();
let output = assembler.build();
let output = mode.build(&assembler);
let elapsed = start.elapsed();
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
@ -87,7 +110,8 @@ fn run_case(case: PerfCase) {
let p95_ns = percentile_ns(&timings, 95);
println!(
"{{\"kernel\":\"vg_csg\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{}}}",
"{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{}}}",
mode.as_str(),
case.name,
case.brushes,
MEASURE_ITERS,

View file

@ -176,6 +176,10 @@ impl Assembler {
output
}
pub fn rebuild(&self) -> BuildOutput {
self.build_uncached()
}
fn build_uncached(&self) -> BuildOutput {
if self
.compiled
@ -474,9 +478,10 @@ fn subtract_from_solids(solids: Vec<ConvexSolid>, cutter: &ConvexSolid) -> Vec<C
fn intersect_solids(solids: Vec<ConvexSolid>, cutter: &ConvexSolid) -> Vec<ConvexSolid> {
let mut out = Vec::with_capacity(solids.len());
for solid in solids {
if solid.bounds.intersects(cutter.bounds)
&& let Some(fragment) = solid.intersect_convex_owned(cutter)
{
if !solid.bounds.intersects(cutter.bounds) {
continue;
}
if let Some(fragment) = solid.intersect_convex_owned(cutter) {
out.push(fragment);
}
}
@ -587,4 +592,37 @@ mod tests {
assert_eq!(output.report.warnings.len(), 0);
assert_eq!(output.mesh.triangle_count(), 12);
}
#[test]
fn cached_build_and_dirty_rebuild_emit_same_output() {
let mut asm = Assembler::new();
asm.solid_box(
"slab",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(8.0, 8.0, 2.0)),
MaterialId(1),
);
asm.cut_box(
"center void",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
);
asm.cut_oriented_box(
"angled void",
Vec3::new(1.0, 1.0, 0.0),
Vec3::new(1.25, 4.0, 2.5),
bevy_math::Quat::from_rotation_z(0.35),
);
let stable = asm.build();
let cached = asm.build();
let dirty = asm.rebuild();
assert_eq!(stable.report.emitted_convex_fragments, dirty.report.emitted_convex_fragments);
assert_eq!(stable.report.warnings, dirty.report.warnings);
assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count());
assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count());
assert_eq!(stable.mesh.positions, dirty.mesh.positions);
assert_eq!(stable.mesh.indices, dirty.mesh.indices);
assert_eq!(cached.mesh.positions, stable.mesh.positions);
assert_eq!(cached.mesh.indices, stable.mesh.indices);
}
}

View file

@ -177,6 +177,13 @@ impl ConvexSolid {
}
pub fn intersect_convex_owned(self, cutter: &Self) -> Option<Self> {
if cutter.contains_solid(&self) {
return Some(self);
}
if self.contains_solid(cutter) {
return Some(cutter.clone_with_material(self.material));
}
let mut remainder = self;
for plane in &cutter.clip_planes {
@ -190,6 +197,29 @@ impl ConvexSolid {
Some(remainder)
}
pub fn contains_point(&self, point: Vec3) -> bool {
self.clip_planes
.iter()
.all(|plane| plane.signed_distance(point) <= EPSILON)
}
pub fn contains_solid(&self, other: &Self) -> bool {
other
.polygons
.iter()
.flat_map(|polygon| polygon.vertices.iter())
.all(|point| self.contains_point(*point))
}
pub fn clone_with_material(&self, material: MaterialId) -> Self {
let mut solid = self.clone();
solid.material = material;
for polygon in &mut solid.polygons {
polygon.material = material;
}
solid
}
pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) {
for polygon in &self.polygons {
if !polygon.visible || polygon.vertices.len() < 3 {