Add CSG demand frontier counters

This commit is contained in:
Meta Vens 2026-05-07 08:20:25 +01:00
commit 6bb8726a37
9 changed files with 301 additions and 25 deletions

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());

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,

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

@ -80,11 +80,11 @@ Repo-local Geometry Script clone notes live in
`.\tools\setup_geometry_script_clone.ps1` if `external/geometry-script` is
missing.
The current next action is to replace the ordered `vg_csg` dense rotated
subtraction path with the category-router kernel, then prototype a
demand-shaped affected-pair frontier before adding any generic BVH/grid spatial
index. Keep cached-vs-dirty output parity tests green and compare against the
C++ timing oracle.
The current next action is to use the new demand-frontier counters to replace
the ordered `vg_csg` dense rotated subtraction path with a category-router
kernel. Keep cached-vs-dirty output parity tests green, compare against the C++
timing oracle, and only add BVH/grid storage after frontier batching proves it
needs one.
Spatial-query correction from EpiphanyAquarium memory: GigaVoxels, froxels, and
Dreams all point away from static "better tree" thinking. Let the consumer
@ -93,6 +93,11 @@ tile/view, or visible surface set. BVHs, grids, sweep-and-prune, and pair caches
are candidate layouts only after that frontier exists and timing evidence says
they help.
First implementation slice: `vg_csg` exports `DemandFrontier`/`DemandPair`, and
`BuildReport` plus the JSONL perf fixture now expose `candidate_pairs` and
`rejected_pairs`. These counters are currently observational and follow existing
bounds gates; they are not yet a replacement router.
## Important Invariants
- Target Blender 5.0+ unless the task says otherwise.

View file

@ -43,6 +43,19 @@ not carry live next-action clutter.
## Near-Term Sequence
0. Make the Rust CSG machine meaner and leaner.
- Replace the dense rotated subtraction carver with a category-router path
that preserves source polygons, splits only crossings, routes categories,
and keeps cached-vs-dirty output parity green before performance claims.
- Add a demand-frontier prototype before any generic BVH/grid index. The
frontier should emit affected brush pairs from dirty brushes, branch
bounds, and requested output scope; indexes are candidate storage layouts,
not the idea.
- Add counters and fixtures for global rebuild, affected-brush rebuild, and
request/tile-scoped rebuild so timings explain what work was refused.
- Move hot polygon/category work toward reusable scratch arenas and compact
arrays only after the router proves exact output parity.
1. Promote the Bloom coordinate/noise lessons into reusable builder seams.
- Build on the new `vibegeometry/` helper library rather than adding more
one-off example functions.

View file

@ -62,3 +62,4 @@
{"ts": "2026-05-07T02:49:43+00:00", "type": "implementation", "status": "ok", "note": "CSG speed pass kept parity tests green while adding an axis-aligned box fast path, compiled brush geometry cache, owned convex splitting, clipping allocation reserves, and stable-generation output caching. Stable-tree fixture now beats or matches the direct C++ bridge on sampled scenarios, but dirty dense rotated subtraction still needs category-router work."}
{"ts": "2026-05-07T03:31:28+00:00", "type": "implementation", "status": "ok", "note": "Added explicit stable and dirty vg_csg perf modes, public rebuild() bypassing stable output cache, cached-vs-dirty output parity test, and convex containment shortcuts. Latest fixture shows dirty box-heavy and distant-rejection paths are respectable; dirty dense rotated subtraction remains the next category-router target."}
{"ts": "2026-05-07T06:57:45+00:00", "type": "research", "status": "accepted", "note": "EpiphanyAquarium persisted memory changes the vg_csg acceleration prior: before adding a generic BVH/grid/sweep index, prototype demand-shaped spatial frontiers driven by dirty brushes, branch bounds, requested output scope, and visible/tile surfaces. GigaVoxels/froxel/Dreams doctrine treats rays or consumers as the query oracle; indexes are storage layouts for that frontier, not the central idea."}
{"ts": "2026-05-07T07:19:04+00:00", "type": "implementation", "status": "ok", "note": "Started the meaner vg_csg pass: persisted the CSG roadmap, added exported DemandFrontier/DemandPair for ordered brush affected-pair planning, and added BuildReport/perf JSONL candidate_pairs and rejected_pairs counters along existing bounds gates. Tests/clippy pass; perf counters show rotated_cut_stack_64 still burns many rejected fragment/operator checks, so category-router plus frontier batching remains the next code target."}

View file

@ -111,7 +111,7 @@ data_flow:
current_status:
summary: VibeGeometry has verified Geometry Script translations for local samples, official Blender demos, and IRCSS French Houses footholds, plus from-scratch Aetheria Bloom and Lucent tether habitat builds. Durable lessons now driving the pipeline: Python orchestrates intent and batching; Geometry Script emits inspectable node groups; source-bound scenes need explicit coordinate contracts before ornament; spin-gravity habitats need ground planes orthogonal to their tether/rotation axis; utility petals, radiators, and solar arrays belong outside pressure glass and may need Citadel-scale presence; large habitat parts need visible load paths across coordinate frames; organic city maps need street fields, irregular parcels, parks, and land-use gradients rather than grids; dense detail must be batched; institutional spaces should model power as surfaces, rails, overlays, thresholds, shader behavior, and camera evidence; and visual acceptance needs domain-facing inspection renders plus verifier checks, not only overview renders. The first reusable `vibegeometry/` helper library now carries coordinate frames, fields, batching, Blender scene primitives, and verifier helpers used by the Bloom and Lucent builds. The Rust crate `vg_csg` now has a RealtimeCSG-shaped API skeleton plus a convex-brush stepping-stone kernel: CSG operation ordinals, branch vocabulary, tree/branch/brush handles, child and operation mutation, exact parity fixtures, ordered assembler compilation, AABB/oriented-box subtraction/intersection via convex plane splitting, crossing polygon category buckets, primitive/solid/tree bounds, additive cylinder/dome/floret primitives, JSONL performance fixtures with stable and dirty modes, mesh-array output, stable-generation output caching, tests, clippy, and examples. The `vg_grammar` crate is the Dream Machine authoring layer: semantic solid/void claims with tags compile into flat `vg_csg` brushes or generated CSG tree intent; it supports local frames, oriented claims, deterministic seeded variation, rule composition, and generated gallery chains. Public RealtimeCSG demo/source research points the target away from fragment carving and toward faster polygon classification through boolean trees. A standalone RealtimeCSG C++ P/Invoke bridge now mirrors the managed wrapper surface, verifies a native health cube, extracts generated mesh descriptions, and appends direct native timing records. Current perf pass: `vg_csg` has a fast axis-aligned path, cached compiled brush geometry, owned convex splitting, convex containment shortcuts, stable-tree output caching, and stable/dirty perf records; dirty box-heavy and distant-rejection paths are respectable, but dirty dense rotated subtraction still needs the category-router kernel. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Replace the ordered `vg_csg` dense rotated subtraction path with the category-router kernel, then prototype a demand-shaped affected-pair frontier before adding any generic BVH/grid spatial index; keep cached-vs-dirty output parity tests green and compare against the C++ timing oracle.
next_action: Use the new demand-frontier counters to replace the ordered `vg_csg` dense rotated subtraction path with a category-router kernel; keep cached-vs-dirty output parity tests green, compare against the C++ timing oracle, and only add BVH/grid storage after frontier batching proves it needs one.
remotes:
vibegeometry: https://github.com/GameCult/VibeGeometry
geometry_script_fork: https://github.com/GameCult/geometry-script