Add CSG perf fixtures and polygon category splitting

This commit is contained in:
Meta Vens 2026-05-07 01:50:49 +01:00
commit f7df565813
7 changed files with 537 additions and 5 deletions

View file

@ -0,0 +1,72 @@
# CSG Performance Fixtures
The timing surface is a fixture, not a victory poster.
Run our current kernel:
```powershell
.\tools\run_csg_perf.ps1
```
The script builds `vg_csg` in release mode and writes JSONL to
`experiments/generated/csg-perf-latest.jsonl`. Each record carries:
- `kernel`
- `scenario`
- `brushes`
- warmup and measured iteration counts
- `mean_ns`, `min_ns`, `p50_ns`, `p95_ns`, `max_ns`
- emitted triangle, fragment, and warning counts
## Scenarios
- `single_center_cut`: the current exact centered box-subtraction parity seam.
- `room_grid_8x8_doors`: repeated additive floors/walls and repeated door
voids, matching the level-editing workload we care about.
- `rotated_cut_stack_64`: one large slab cut by many oriented boxes.
- `common_box_chain_64`: repeated convex intersections, exercising `Common`
branch behavior through the ordered assembler.
- `distant_cutters_512`: many irrelevant cutters, proving bounds early-outs
stay cheap.
## Reference Kernel Slot
The same wrapper can append reference timings:
```powershell
$env:VIBEGEOMETRY_REFERENCE_CSG_PERF = "path\to\reference-perf.exe"
.\tools\run_csg_perf.ps1
```
or:
```powershell
.\tools\run_csg_perf.ps1 -ReferenceCommand "path\to\reference-perf.exe"
```
The reference executable must emit JSONL with the same scenario names. Until
that harness exists, the script appends a `kernel=reference,status=missing`
record instead of faking a comparison. Sad, but hygienic.
The public `LogicalError/Realtime-CSG-demo` source already exposes the relevant
algorithmic surface: half-edge control meshes, polygon bounds, visible flags,
inside/outside/aligned/reverse-aligned categories, crossing polygon splits, and
logical routing tables. A reference benchmark should call that public demo
kernel headlessly or through a small public-source harness, not the closed Unity
native plugin.
## Latest Local Baseline
Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1`:
```jsonl
{"kernel":"vg_csg","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":34295,"min_ns":21200,"p50_ns":35400,"p95_ns":43000,"max_ns":52500,"triangles":72,"fragments":6,"warnings":0}
{"kernel":"vg_csg","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":11564459,"min_ns":4988300,"p50_ns":9626400,"p95_ns":20738900,"max_ns":33797500,"triangles":3072,"fragments":256,"warnings":0}
{"kernel":"vg_csg","scenario":"rotated_cut_stack_64","brushes":65,"iterations":64,"warmup_iterations":8,"mean_ns":12646675,"min_ns":8271900,"p50_ns":12512900,"p95_ns":16605400,"max_ns":19622900,"triangles":3404,"fragments":280,"warnings":0}
{"kernel":"vg_csg","scenario":"common_box_chain_64","brushes":64,"iterations":64,"warmup_iterations":8,"mean_ns":829393,"min_ns":609900,"p50_ns":751800,"p95_ns":1082100,"max_ns":1478800,"triangles":45,"fragments":1,"warnings":0}
{"kernel":"vg_csg","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":534860,"min_ns":461700,"p50_ns":469400,"p95_ns":754700,"max_ns":1031900,"triangles":12,"fragments":1,"warnings":0}
{"status":"missing","reason":"Set VIBEGEOMETRY_REFERENCE_CSG_PERF or pass -ReferenceCommand with an executable that emits the same JSONL scenario records.","kernel":"reference"}
```
Read these as smoke timings, not final benchmark gospel. The fixtures are stable
enough to catch regressions and compare against a reference harness once wired.

View file

@ -75,10 +75,15 @@ angled box cuts, real `Common` branch output for convex boxes, and gives us the
correct shape for a future general convex brush kernel.
Polygons now carry the first routing metadata the mature kernel needs:
category, visibility, reversal, and bounds. Whole polygons can already be
classified as inside, outside, aligned, or reverse-aligned against a convex
brush when they do not cross cutter planes. That is deliberately not sold as
the full router yet. It is the labeled wire we will plug the splitter into.
category, visibility, reversal, and bounds. Source polygons can be classified
as inside, outside, aligned, or reverse-aligned against a convex brush. When a
polygon crosses a cutter plane, it is split first and each piece is routed to a
category bucket. That matches the public demo's important shape: split crossing
polygons before final category decisions.
That is deliberately not sold as full parity yet. The current assembler still
emits meshes through convex fragment decomposition. The next parity step is to
make those category buckets drive boolean-tree mesh emission directly.
The important doctrinal adjustment from the public demo and blog series is that
real-time CSG should think in **classification**, not only carving. A polygon is
@ -160,6 +165,10 @@ kernel: split crossing polygons, route classified source polygons through
boolean branches, preserve material/surface metadata, and then add a BVH or
spatial hash so cutter cost does not scale like a punishment.
Performance fixtures live in `docs/research/csg-performance-fixtures.md` and
`tools/run_csg_perf.ps1`. They time our release fixture now and reserve a JSONL
slot for a public-demo/reference harness when one is available.
## Limits
`vg_csg` is not yet a full RealtimeCSG replacement.
@ -172,6 +181,8 @@ spatial hash so cutter cost does not scale like a punishment.
- current subtraction emits split fragments with generated cap polygons; the
target kernel should move toward article/demo-style category routing over
brush polygons for speed and cleaner material behavior
- no reference timing executable is wired yet; the perf wrapper records this
explicitly instead of inventing comparison numbers
Those limits are explicit because invisible ambition is how you get haunted by
your own abstractions. The useful loop exists now: script brushes, assemble,

View file

@ -0,0 +1,234 @@
use std::{
hint::black_box,
time::{Duration, Instant},
};
use bevy_math::{Quat, Vec3};
use vg_csg::{Aabb, Assembler, BrushOp, MaterialId, Primitive};
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,
},
];
for case in cases {
run_case(case);
}
}
#[derive(Clone, Copy)]
struct PerfCase {
name: &'static str,
brushes: usize,
build: fn() -> Assembler,
}
fn run_case(case: PerfCase) {
let assembler = (case.build)();
let warmup = assembler.build();
black_box(warmup.mesh.triangle_count());
for _ in 0..WARMUP_ITERS {
let output = assembler.build();
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;
for _ in 0..MEASURE_ITERS {
let start = Instant::now();
let output = assembler.build();
let elapsed = start.elapsed();
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
warnings = output.report.warnings.len();
black_box((triangles, fragments, warnings));
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\":\"vg_csg\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{}}}",
case.name,
case.brushes,
MEASURE_ITERS,
WARMUP_ITERS,
mean_ns,
min_ns,
p50_ns,
p95_ns,
max_ns,
triangles,
fragments,
warnings
);
}
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 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
}

View file

@ -81,6 +81,14 @@ pub struct ConvexSolid {
pub bounds: Aabb,
}
#[derive(Clone, Debug, Default)]
pub struct CategorizedPolygons {
pub inside: Vec<ConvexPolygon>,
pub outside: Vec<ConvexPolygon>,
pub aligned: Vec<ConvexPolygon>,
pub reverse_aligned: Vec<ConvexPolygon>,
}
impl ConvexSolid {
pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self {
Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material)
@ -209,6 +217,42 @@ impl ConvexSolid {
}
}
pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons {
let mut categorized = CategorizedPolygons::default();
for polygon in &self.polygons {
let mut pieces = vec![CategorizationPiece {
polygon: polygon.clone(),
aligned_category: None,
}];
for plane in &cutter.clip_planes {
let mut inside_pieces = Vec::new();
for piece in pieces {
classify_piece_against_plane(
piece,
*plane,
&mut inside_pieces,
&mut categorized,
);
}
pieces = inside_pieces;
}
for piece in pieces {
match piece.aligned_category {
Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon),
Some(PolygonCategory::ReverseAligned) => {
categorized.reverse_aligned.push(piece.polygon);
}
_ => categorized.inside.push(piece.polygon),
}
}
}
categorized
}
fn split(&self, plane: Plane) -> SplitResult {
let mut saw_front = false;
let mut saw_back = false;
@ -276,12 +320,84 @@ enum SplitResult {
Coplanar(ConvexSolid),
}
struct CategorizationPiece {
polygon: ConvexPolygon,
aligned_category: Option<PolygonCategory>,
}
#[derive(Clone, Copy)]
enum KeepSide {
Front,
Back,
}
fn classify_piece_against_plane(
mut piece: CategorizationPiece,
plane: Plane,
inside_pieces: &mut Vec<CategorizationPiece>,
categorized: &mut CategorizedPolygons,
) {
let piece_plane = piece
.polygon
.vertices
.first()
.map(|point| Plane::from_point_normal(*point, piece.polygon.normal));
if piece_plane.is_some_and(|piece_plane| piece_plane.is_coplanar_with(plane)) {
piece.aligned_category = Some(if piece.polygon.normal.dot(plane.normal) >= 0.0 {
PolygonCategory::Aligned
} else {
PolygonCategory::ReverseAligned
});
inside_pieces.push(piece);
return;
}
let mut saw_front = false;
let mut saw_back = false;
for vertex in &piece.polygon.vertices {
let distance = plane.signed_distance(*vertex);
saw_front |= distance > EPSILON;
saw_back |= distance < -EPSILON;
}
match (saw_front, saw_back) {
(true, false) => {
let mut polygon = piece.polygon;
polygon.category = PolygonCategory::Outside;
categorized.outside.push(polygon);
}
(false, true) | (false, false) => inside_pieces.push(piece),
(true, true) => {
let mut cap_points = Vec::new();
let outside = clip_polygon(
&piece.polygon.vertices,
plane,
KeepSide::Front,
&mut cap_points,
);
if outside.len() >= 3 {
let mut outside_polygon = piece.polygon.with_vertices(outside);
outside_polygon.category = PolygonCategory::Outside;
categorized.outside.push(outside_polygon);
}
let inside = clip_polygon(
&piece.polygon.vertices,
plane,
KeepSide::Back,
&mut cap_points,
);
if inside.len() >= 3 {
inside_pieces.push(CategorizationPiece {
polygon: piece.polygon.with_vertices(inside),
aligned_category: piece.aligned_category,
});
}
}
}
}
fn clip_polygon(
vertices: &[Vec3],
plane: Plane,
@ -518,6 +634,41 @@ mod tests {
assert!(!common.bounds.contains_point(Vec3::new(-2.0, 0.0, 0.0)));
}
#[test]
fn categorize_crossing_coplanar_face_splits_visible_categories() {
let source = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(4.0),
Quat::IDENTITY,
MaterialId(1),
);
let cutter = ConvexSolid::box_from_center_size(
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(2.0, 2.0, 2.0),
Quat::IDENTITY,
MaterialId(0),
);
let categorized = source.categorize_polygons_against(&cutter);
assert_eq!(categorized.aligned.len(), 1);
assert!(categorized.outside.len() > 6);
assert!(categorized.inside.is_empty());
assert!(categorized.reverse_aligned.is_empty());
assert!(
categorized
.aligned
.iter()
.all(|polygon| polygon.category == PolygonCategory::Aligned)
);
assert!(
categorized
.outside
.iter()
.all(|polygon| polygon.category == PolygonCategory::Outside)
);
}
#[test]
fn subtract_rotated_box_creates_non_axis_aligned_faces() {
let source = ConvexSolid::box_from_center_size(

View file

@ -16,7 +16,7 @@ mod tree;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
pub use convex::{ConvexPolygon, ConvexSolid, Plane};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane};
pub use dsl::LevelDsl;
pub use mesh::TriangleMesh;
pub use primitives::{

View file

@ -141,3 +141,26 @@ fn parity_outer_box_polygons_classify_as_outside_inner_box() {
.all(|polygon| polygon.category == PolygonCategory::Outside)
);
}
#[test]
fn parity_crossing_polygons_split_before_final_category() {
let source = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(4.0),
Quat::IDENTITY,
MaterialId(1),
);
let cutter = ConvexSolid::box_from_center_size(
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(2.0, 2.0, 2.0),
Quat::IDENTITY,
MaterialId(0),
);
let categorized = source.categorize_polygons_against(&cutter);
assert_eq!(categorized.aligned.len(), 1);
assert!(categorized.outside.len() > source.polygons.len());
assert_eq!(categorized.inside.len(), 0);
assert_eq!(categorized.reverse_aligned.len(), 0);
}

41
tools/run_csg_perf.ps1 Normal file
View file

@ -0,0 +1,41 @@
param(
[string]$OutputPath = ".\experiments\generated\csg-perf-latest.jsonl",
[string]$ReferenceCommand = $env:VIBEGEOMETRY_REFERENCE_CSG_PERF
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$resolvedOutput = Join-Path $root $OutputPath
$outputDir = Split-Path -Parent $resolvedOutput
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
Push-Location $root
try {
cargo build --release -p vg_csg --example csg_perf_fixture | Out-Host
$exe = Join-Path $root "target\release\examples\csg_perf_fixture.exe"
if (!(Test-Path $exe)) {
throw "Missing vg_csg perf fixture executable: $exe"
}
& $exe | Set-Content -Path $resolvedOutput -Encoding utf8
if ($ReferenceCommand) {
$referenceOutput = & cmd /c $ReferenceCommand
foreach ($line in $referenceOutput) {
Add-Content -Path $resolvedOutput -Value $line -Encoding utf8
}
} else {
$missing = @{
kernel = "reference"
status = "missing"
reason = "Set VIBEGEOMETRY_REFERENCE_CSG_PERF or pass -ReferenceCommand with an executable that emits the same JSONL scenario records."
} | ConvertTo-Json -Compress
Add-Content -Path $resolvedOutput -Value $missing -Encoding utf8
}
Get-Content -Path $resolvedOutput
} finally {
Pop-Location
}