Add CSG demand frontier counters

This commit is contained in:
Meta Vens 2026-05-07 08:20:25 +01:00
commit 84371de2a2
5 changed files with 276 additions and 19 deletions

View file

@ -17,6 +17,7 @@ The script builds `vg_csg` in release mode and writes JSONL to
- warmup and measured iteration counts
- `mean_ns`, `min_ns`, `p50_ns`, `p95_ns`, `max_ns`
- emitted triangle, fragment, and warning counts
- demand-frontier `candidate_pairs` and `rejected_pairs`
The `vg_csg` fixture measures the stable-tree path after warmup. `Assembler`
caches evaluated output by generation, so repeated `build()` calls on an
@ -85,10 +86,19 @@ inside/outside/aligned/reverse-aligned categories, crossing polygon splits, and
logical routing tables. Use it as readable doctrine. Use the native bridge as
the performance target.
`vg_csg` now reports the first demand-frontier counters. These are not yet a
new kernel path; they expose the ordered brush stream as affected source/operator
pairs and rejected bounds pairs so future router/index work can prove it is
refusing real work rather than merely rearranging it.
## Latest Local Baseline
Captured on 2026-05-07 with `.\tools\run_csg_perf.ps1 -UseRealtimeCsgCpp`:
This baseline predates the `candidate_pairs`/`rejected_pairs` fields. New
fixture runs include them; keep the old block until a fresh C++ comparison is
captured rather than mixing timing environments.
```jsonl
{"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}

View file

@ -87,6 +87,8 @@ fn run_case(case: PerfCase, mode: PerfMode) {
let mut triangles = 0;
let mut fragments = 0;
let mut warnings = 0;
let mut candidate_pairs = 0;
let mut rejected_pairs = 0;
for _ in 0..MEASURE_ITERS {
let start = Instant::now();
@ -95,7 +97,15 @@ fn run_case(case: PerfCase, mode: PerfMode) {
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
warnings = output.report.warnings.len();
black_box((triangles, fragments, warnings));
candidate_pairs = output.report.candidate_pairs;
rejected_pairs = output.report.rejected_pairs;
black_box((
triangles,
fragments,
warnings,
candidate_pairs,
rejected_pairs,
));
timings.push(elapsed);
}
@ -110,7 +120,7 @@ fn run_case(case: PerfCase, mode: PerfMode) {
let p95_ns = percentile_ns(&timings, 95);
println!(
"{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"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\":{},\"candidate_pairs\":{},\"rejected_pairs\":{}}}",
mode.as_str(),
case.name,
case.brushes,
@ -123,7 +133,9 @@ fn run_case(case: PerfCase, mode: PerfMode) {
max_ns,
triangles,
fragments,
warnings
warnings,
candidate_pairs,
rejected_pairs
);
}

View file

@ -18,6 +18,9 @@ pub enum BuildWarning {
pub struct BuildReport {
pub input_brushes: usize,
pub emitted_convex_fragments: usize,
pub operator_brushes: usize,
pub candidate_pairs: usize,
pub rejected_pairs: usize,
pub warnings: Vec<BuildWarning>,
}
@ -256,38 +259,49 @@ impl Assembler {
),
},
BrushOp::Subtract => {
report.operator_brushes += 1;
if !brush
.bounds
.intersects_any(solids.iter().map(|solid| solid.bounds))
{
report.rejected_pairs += solids.len();
continue;
}
if let Some(cutter) = brush.convex_cutter() {
solids = subtract_from_solids(solids, &cutter);
solids = subtract_from_solids(solids, &cutter, &mut report);
} else {
report
.warnings
.push(BuildWarning::SubtractIgnoredForNonBox {
brush: brush.name.clone(),
});
let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report);
if has_candidate {
report
.warnings
.push(BuildWarning::SubtractIgnoredForNonBox {
brush: brush.name.clone(),
});
}
}
}
BrushOp::Intersect => {
report.operator_brushes += 1;
if !brush
.bounds
.intersects_any(solids.iter().map(|solid| solid.bounds))
{
report.rejected_pairs += solids.len();
solids.clear();
continue;
}
if let Some(cutter) = brush.convex_cutter() {
solids = intersect_solids(solids, &cutter);
solids = intersect_solids(solids, &cutter, &mut report);
} else {
report
.warnings
.push(BuildWarning::IntersectIgnoredForNonBox {
brush: brush.name.clone(),
});
let has_candidate = record_solid_pairs(&solids, brush.bounds, &mut report);
solids.clear();
if has_candidate {
report
.warnings
.push(BuildWarning::IntersectIgnoredForNonBox {
brush: brush.name.clone(),
});
}
}
}
}
@ -319,12 +333,15 @@ impl Assembler {
match brush.op {
BrushOp::Add => boxes.push((bounds, brush.material)),
BrushOp::Subtract => {
report.operator_brushes += 1;
if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) {
report.rejected_pairs += boxes.len();
continue;
}
let mut out = Vec::with_capacity(boxes.len() + 4);
for (solid, material) in boxes {
if solid.intersects(bounds) {
report.candidate_pairs += 1;
out.extend(
solid
.subtract_box(bounds)
@ -332,20 +349,29 @@ impl Assembler {
.map(|piece| (piece, material)),
);
} else {
report.rejected_pairs += 1;
out.push((solid, material));
}
}
boxes = out;
}
BrushOp::Intersect => {
report.operator_brushes += 1;
if !bounds.intersects_any(boxes.iter().map(|(bounds, _)| *bounds)) {
report.rejected_pairs += boxes.len();
boxes.clear();
continue;
}
boxes = boxes
.into_iter()
.filter_map(|(solid, material)| {
solid.intersection(bounds).map(|hit| (hit, material))
if let Some(hit) = solid.intersection(bounds) {
report.candidate_pairs += 1;
Some((hit, material))
} else {
report.rejected_pairs += 1;
None
}
})
.collect();
}
@ -463,24 +489,36 @@ impl CompiledBrush {
}
}
fn subtract_from_solids(solids: Vec<ConvexSolid>, cutter: &ConvexSolid) -> Vec<ConvexSolid> {
fn subtract_from_solids(
solids: Vec<ConvexSolid>,
cutter: &ConvexSolid,
report: &mut BuildReport,
) -> Vec<ConvexSolid> {
let mut out = Vec::with_capacity(solids.len() + 4);
for solid in solids {
if solid.bounds.intersects(cutter.bounds) {
report.candidate_pairs += 1;
out.extend(solid.subtract_convex_owned(cutter));
} else {
report.rejected_pairs += 1;
out.push(solid);
}
}
out
}
fn intersect_solids(solids: Vec<ConvexSolid>, cutter: &ConvexSolid) -> Vec<ConvexSolid> {
fn intersect_solids(
solids: Vec<ConvexSolid>,
cutter: &ConvexSolid,
report: &mut BuildReport,
) -> Vec<ConvexSolid> {
let mut out = Vec::with_capacity(solids.len());
for solid in solids {
if !solid.bounds.intersects(cutter.bounds) {
report.rejected_pairs += 1;
continue;
}
report.candidate_pairs += 1;
if let Some(fragment) = solid.intersect_convex_owned(cutter) {
out.push(fragment);
}
@ -488,6 +526,19 @@ fn intersect_solids(solids: Vec<ConvexSolid>, cutter: &ConvexSolid) -> Vec<Conve
out
}
fn record_solid_pairs(solids: &[ConvexSolid], bounds: Aabb, report: &mut BuildReport) -> bool {
let mut has_candidate = false;
for solid in solids {
if solid.bounds.intersects(bounds) {
report.candidate_pairs += 1;
has_candidate = true;
} else {
report.rejected_pairs += 1;
}
}
has_candidate
}
#[cfg(test)]
mod tests {
use bevy_math::Vec3;
@ -567,6 +618,9 @@ mod tests {
let output = asm.build();
assert_eq!(output.report.emitted_convex_fragments, 1);
assert_eq!(output.report.operator_brushes, 1);
assert_eq!(output.report.candidate_pairs, 0);
assert_eq!(output.report.rejected_pairs, 1);
assert_eq!(output.mesh.triangle_count(), 12);
}
@ -589,6 +643,9 @@ mod tests {
let output = asm.build();
assert_eq!(output.report.emitted_convex_fragments, 1);
assert_eq!(output.report.operator_brushes, 1);
assert_eq!(output.report.candidate_pairs, 1);
assert_eq!(output.report.rejected_pairs, 0);
assert_eq!(output.report.warnings.len(), 0);
assert_eq!(output.mesh.triangle_count(), 12);
}
@ -616,7 +673,16 @@ mod tests {
let cached = asm.build();
let dirty = asm.rebuild();
assert_eq!(stable.report.emitted_convex_fragments, dirty.report.emitted_convex_fragments);
assert_eq!(
stable.report.emitted_convex_fragments,
dirty.report.emitted_convex_fragments
);
assert_eq!(
stable.report.operator_brushes,
dirty.report.operator_brushes
);
assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs);
assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs);
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());

167
src/frontier.rs Normal file
View file

@ -0,0 +1,167 @@
use crate::{Brush, BrushOp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DemandPair {
pub source_index: usize,
pub operator_index: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DemandFrontier {
pub pairs: Vec<DemandPair>,
pub rejected_pairs: usize,
pub operator_brushes: usize,
}
impl DemandFrontier {
pub fn from_ordered_brushes(brushes: &[Brush]) -> Self {
let mut frontier = Self::default();
let mut active_sources = Vec::<usize>::new();
for (operator_index, brush) in brushes.iter().enumerate() {
match brush.op {
BrushOp::Add => active_sources.push(operator_index),
BrushOp::Subtract => {
frontier.operator_brushes += 1;
frontier.collect_pairs(brushes, &active_sources, operator_index);
}
BrushOp::Intersect => {
frontier.operator_brushes += 1;
let retained = frontier.collect_pairs(brushes, &active_sources, operator_index);
active_sources = retained;
}
}
}
frontier
}
pub fn candidate_pairs(&self) -> usize {
self.pairs.len()
}
fn collect_pairs(
&mut self,
brushes: &[Brush],
active_sources: &[usize],
operator_index: usize,
) -> Vec<usize> {
let operator_bounds = brushes[operator_index].bounds();
let mut retained = Vec::new();
for &source_index in active_sources {
if brushes[source_index].bounds().intersects(operator_bounds) {
self.pairs.push(DemandPair {
source_index,
operator_index,
});
retained.push(source_index);
} else {
self.rejected_pairs += 1;
}
}
retained
}
}
#[cfg(test)]
mod tests {
use bevy_math::{Quat, Vec3};
use super::*;
use crate::{Aabb, Brush, MaterialId, Primitive};
fn brush(name: &str, op: BrushOp, bounds: Aabb) -> Brush {
Brush::new(
crate::BrushId(0),
name,
op,
Primitive::Box { bounds },
MaterialId(1),
)
}
#[test]
fn frontier_keeps_only_touching_ordered_pairs() {
let brushes = vec![
brush(
"source",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
),
brush(
"far cutter",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::splat(20.0), Vec3::ONE),
),
brush(
"near cutter",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
),
];
let frontier = DemandFrontier::from_ordered_brushes(&brushes);
assert_eq!(frontier.operator_brushes, 2);
assert_eq!(frontier.rejected_pairs, 1);
assert_eq!(
frontier.pairs,
vec![DemandPair {
source_index: 0,
operator_index: 2,
}]
);
}
#[test]
fn intersection_frontier_reduces_active_sources() {
let brushes = vec![
brush(
"a",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
),
brush(
"b",
BrushOp::Add,
Aabb::from_center_size(Vec3::new(20.0, 0.0, 0.0), Vec3::splat(2.0)),
),
Brush::new(
crate::BrushId(2),
"common",
BrushOp::Intersect,
Primitive::OrientedBox {
center: Vec3::ZERO,
size: Vec3::splat(3.0),
rotation: Quat::IDENTITY,
},
MaterialId(1),
),
brush(
"cut",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
),
];
let frontier = DemandFrontier::from_ordered_brushes(&brushes);
assert_eq!(frontier.operator_brushes, 2);
assert_eq!(frontier.rejected_pairs, 1);
assert_eq!(
frontier.pairs,
vec![
DemandPair {
source_index: 0,
operator_index: 2,
},
DemandPair {
source_index: 0,
operator_index: 3,
},
]
);
}
}

View file

@ -10,6 +10,7 @@ mod assembler;
mod brush;
mod convex;
mod dsl;
mod frontier;
mod mesh;
mod primitives;
mod tree;
@ -18,6 +19,7 @@ pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane};
pub use dsl::LevelDsl;
pub use frontier::{DemandFrontier, DemandPair};
pub use mesh::TriangleMesh;
pub use primitives::{
DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,