diff --git a/docs/csg-performance-fixtures.md b/docs/csg-performance-fixtures.md index 0446c50..96bb8bc 100644 --- a/docs/csg-performance-fixtures.md +++ b/docs/csg-performance-fixtures.md @@ -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. diff --git a/examples/csg_perf_fixture.rs b/examples/csg_perf_fixture.rs index 64225ba..7922ae6 100644 --- a/examples/csg_perf_fixture.rs +++ b/examples/csg_perf_fixture.rs @@ -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, diff --git a/src/assembler.rs b/src/assembler.rs index 47fe75e..777f888 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -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, cutter: &ConvexSolid) -> Vec, cutter: &ConvexSolid) -> Vec { 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); + } } diff --git a/src/convex.rs b/src/convex.rs index c813419..5bf3f1c 100644 --- a/src/convex.rs +++ b/src/convex.rs @@ -177,6 +177,13 @@ impl ConvexSolid { } pub fn intersect_convex_owned(self, cutter: &Self) -> Option { + 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 {