Add CSG demand frontier counters
This commit is contained in:
parent
587408c6fa
commit
84371de2a2
5 changed files with 276 additions and 19 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
167
src/frontier.rs
Normal 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,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue