Add box CSG incremental dirty rebuild
This commit is contained in:
parent
a2a4d54626
commit
5733e823a5
7 changed files with 391 additions and 80 deletions
|
|
@ -41,6 +41,7 @@ fn main() {
|
|||
for case in cases {
|
||||
run_case(case, PerfMode::Stable);
|
||||
run_case(case, PerfMode::Dirty);
|
||||
run_case(case, PerfMode::IncrementalDirty);
|
||||
run_case(case, PerfMode::Routed);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +57,7 @@ struct PerfCase {
|
|||
enum PerfMode {
|
||||
Stable,
|
||||
Dirty,
|
||||
IncrementalDirty,
|
||||
Routed,
|
||||
}
|
||||
|
||||
|
|
@ -64,29 +66,37 @@ impl PerfMode {
|
|||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Dirty => "dirty",
|
||||
Self::IncrementalDirty => "incremental_dirty",
|
||||
Self::Routed => "routed",
|
||||
}
|
||||
}
|
||||
|
||||
fn build(self, assembler: &Assembler) -> vg_csg::BuildOutput {
|
||||
fn build(self, assembler: &mut Assembler, iteration: usize) -> vg_csg::BuildOutput {
|
||||
match self {
|
||||
Self::Stable => assembler.build(),
|
||||
Self::Dirty => assembler.rebuild(),
|
||||
Self::Dirty => {
|
||||
nudge_tail_brush(assembler, iteration);
|
||||
assembler.rebuild()
|
||||
}
|
||||
Self::IncrementalDirty => {
|
||||
nudge_tail_brush(assembler, iteration);
|
||||
assembler.build_incremental()
|
||||
}
|
||||
Self::Routed => assembler.rebuild_routed_surfaces(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_case(case: PerfCase, mode: PerfMode) {
|
||||
let assembler = (case.build)();
|
||||
let mut assembler = (case.build)();
|
||||
if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() {
|
||||
return;
|
||||
}
|
||||
let warmup = mode.build(&assembler);
|
||||
let warmup = assembler.build();
|
||||
black_box(warmup.mesh.triangle_count());
|
||||
|
||||
for _ in 0..WARMUP_ITERS {
|
||||
let output = mode.build(&assembler);
|
||||
let output = mode.build(&mut assembler, 0);
|
||||
black_box(output.mesh.triangle_count());
|
||||
}
|
||||
|
||||
|
|
@ -97,9 +107,9 @@ fn run_case(case: PerfCase, mode: PerfMode) {
|
|||
let mut candidate_pairs = 0;
|
||||
let mut rejected_pairs = 0;
|
||||
|
||||
for _ in 0..MEASURE_ITERS {
|
||||
for iteration in 0..MEASURE_ITERS {
|
||||
let start = Instant::now();
|
||||
let output = mode.build(&assembler);
|
||||
let output = mode.build(&mut assembler, iteration);
|
||||
let elapsed = start.elapsed();
|
||||
triangles = output.mesh.triangle_count();
|
||||
fragments = output.report.emitted_convex_fragments;
|
||||
|
|
@ -146,6 +156,44 @@ fn run_case(case: PerfCase, mode: PerfMode) {
|
|||
);
|
||||
}
|
||||
|
||||
fn nudge_tail_brush(assembler: &mut Assembler, iteration: usize) {
|
||||
let Some(brush) = assembler.brushes().last().cloned() else {
|
||||
return;
|
||||
};
|
||||
let phase = if iteration.is_multiple_of(2) {
|
||||
0.0
|
||||
} else {
|
||||
0.01
|
||||
};
|
||||
match brush.primitive {
|
||||
Primitive::Box { bounds } => {
|
||||
let center = bounds.center();
|
||||
let size = bounds.size() + Vec3::splat(phase);
|
||||
assembler.set_brush_primitive(
|
||||
brush.id,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(center, size),
|
||||
},
|
||||
);
|
||||
}
|
||||
Primitive::OrientedBox {
|
||||
center,
|
||||
size,
|
||||
rotation,
|
||||
} => {
|
||||
assembler.set_brush_primitive(
|
||||
brush.id,
|
||||
Primitive::OrientedBox {
|
||||
center,
|
||||
size: size + Vec3::splat(phase),
|
||||
rotation,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn percentile_ns(values: &[Duration], percentile: usize) -> u128 {
|
||||
if values.is_empty() {
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ pub struct Assembler {
|
|||
brushes: Vec<Brush>,
|
||||
compiled: Vec<CompiledBrush>,
|
||||
cache: RefCell<Option<BuildOutput>>,
|
||||
box_cache: RefCell<Option<BoxBuildCache>>,
|
||||
first_dirty_index: RefCell<Option<usize>>,
|
||||
next_id: u32,
|
||||
generation: u64,
|
||||
}
|
||||
|
|
@ -78,6 +80,18 @@ enum CompiledGeometry {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct BoxBuildCache {
|
||||
generation: u64,
|
||||
checkpoints: Vec<BoxBuildState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct BoxBuildState {
|
||||
boxes: Vec<(Aabb, MaterialId)>,
|
||||
report: BuildReport,
|
||||
}
|
||||
|
||||
impl Assembler {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
|
|
@ -122,11 +136,13 @@ impl Assembler {
|
|||
) -> BrushId {
|
||||
let id = BrushId(self.next_id);
|
||||
self.next_id += 1;
|
||||
let index = self.brushes.len();
|
||||
let brush = Brush::new(id, name, op, primitive, material);
|
||||
self.compiled.push(CompiledBrush::from_brush(&brush));
|
||||
self.brushes.push(brush);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.cache.borrow_mut().take();
|
||||
self.mark_dirty_index(index);
|
||||
id
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +155,7 @@ impl Assembler {
|
|||
self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.cache.borrow_mut().take();
|
||||
self.mark_dirty_index(index);
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +168,7 @@ impl Assembler {
|
|||
self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]);
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.cache.borrow_mut().take();
|
||||
self.mark_dirty_index(index);
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -223,9 +241,51 @@ impl Assembler {
|
|||
|
||||
let output = self.build_uncached();
|
||||
*self.cache.borrow_mut() = Some(output.clone());
|
||||
*self.first_dirty_index.borrow_mut() = None;
|
||||
output
|
||||
}
|
||||
|
||||
pub fn build_incremental(&self) -> BuildOutput {
|
||||
if let Some(output) = self
|
||||
.cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.filter(|output| output.generation == self.generation)
|
||||
{
|
||||
return output.clone();
|
||||
}
|
||||
|
||||
let first_dirty_index = *self.first_dirty_index.borrow();
|
||||
let output = self
|
||||
.try_build_axis_aligned_boxes_from_cache(first_dirty_index)
|
||||
.unwrap_or_else(|| self.build_uncached());
|
||||
*self.cache.borrow_mut() = Some(output.clone());
|
||||
*self.first_dirty_index.borrow_mut() = None;
|
||||
output
|
||||
}
|
||||
|
||||
pub fn rebuild_incremental_for_indices(
|
||||
&self,
|
||||
dirty_indices: impl IntoIterator<Item = usize>,
|
||||
) -> BuildOutput {
|
||||
let first_dirty_index = self
|
||||
.dirty_frontier_for_indices(dirty_indices)
|
||||
.first_dirty_index;
|
||||
self.try_build_axis_aligned_boxes_from_cache(first_dirty_index)
|
||||
.unwrap_or_else(|| self.build_uncached())
|
||||
}
|
||||
|
||||
pub fn rebuild_incremental_for_brushes(
|
||||
&self,
|
||||
dirty_brushes: impl IntoIterator<Item = BrushId>,
|
||||
) -> BuildOutput {
|
||||
let indices = dirty_brushes
|
||||
.into_iter()
|
||||
.filter_map(|id| self.brush_index(id))
|
||||
.collect::<Vec<_>>();
|
||||
self.rebuild_incremental_for_indices(indices)
|
||||
}
|
||||
|
||||
pub fn rebuild(&self) -> BuildOutput {
|
||||
self.build_uncached()
|
||||
}
|
||||
|
|
@ -239,6 +299,11 @@ impl Assembler {
|
|||
self.supports_routed_surface_subset()
|
||||
}
|
||||
|
||||
fn mark_dirty_index(&self, index: usize) {
|
||||
let mut first_dirty_index = self.first_dirty_index.borrow_mut();
|
||||
*first_dirty_index = Some(first_dirty_index.map_or(index, |dirty| dirty.min(index)));
|
||||
}
|
||||
|
||||
fn try_build_routed_surfaces(&self) -> Option<BuildOutput> {
|
||||
if !self.supports_routed_surface_subset() {
|
||||
return None;
|
||||
|
|
@ -470,75 +535,72 @@ impl Assembler {
|
|||
}
|
||||
|
||||
fn build_axis_aligned_boxes(&self) -> BuildOutput {
|
||||
let mut report = BuildReport {
|
||||
input_brushes: self.brushes.len(),
|
||||
..BuildReport::default()
|
||||
self.try_build_axis_aligned_boxes_from_cache(Some(0))
|
||||
.expect("box-only build path received a non-box brush")
|
||||
}
|
||||
|
||||
fn try_build_axis_aligned_boxes_from_cache(
|
||||
&self,
|
||||
first_dirty_index: Option<usize>,
|
||||
) -> Option<BuildOutput> {
|
||||
if !self
|
||||
.compiled
|
||||
.iter()
|
||||
.all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let brush_count = self.compiled.len();
|
||||
let rebuild_from = match first_dirty_index {
|
||||
Some(index) => index.min(brush_count),
|
||||
None => self
|
||||
.box_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.filter(|cache| {
|
||||
cache.generation == self.generation
|
||||
&& cache.checkpoints.len() == brush_count + 1
|
||||
})
|
||||
.map_or(0, |_| brush_count),
|
||||
};
|
||||
let cached_state = self
|
||||
.box_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.checkpoints.get(rebuild_from).cloned());
|
||||
let (mut state, start_index) = if let Some(state) = cached_state {
|
||||
(state, rebuild_from)
|
||||
} else {
|
||||
(BoxBuildState::default(), 0)
|
||||
};
|
||||
let mut boxes: Vec<(Aabb, MaterialId)> = Vec::new();
|
||||
|
||||
for brush in &self.compiled {
|
||||
let CompiledGeometry::Box(bounds) = brush.geometry else {
|
||||
unreachable!("box-only build path received a non-box brush");
|
||||
};
|
||||
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)
|
||||
.into_iter()
|
||||
.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)| {
|
||||
if let Some(hit) = solid.intersection(bounds) {
|
||||
report.candidate_pairs += 1;
|
||||
Some((hit, material))
|
||||
} else {
|
||||
report.rejected_pairs += 1;
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
let mut checkpoints = self
|
||||
.box_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|cache| {
|
||||
cache
|
||||
.checkpoints
|
||||
.iter()
|
||||
.take(start_index + 1)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|checkpoints| checkpoints.len() == start_index + 1)
|
||||
.unwrap_or_else(|| vec![BoxBuildState::default()]);
|
||||
|
||||
for brush in &self.compiled[start_index..] {
|
||||
state.apply_compiled_box(brush);
|
||||
checkpoints.push(state.clone());
|
||||
}
|
||||
|
||||
let mut mesh = TriangleMesh::new();
|
||||
report.emitted_convex_fragments = boxes.len();
|
||||
for (bounds, material) in boxes {
|
||||
mesh.append_box(bounds, material);
|
||||
}
|
||||
|
||||
BuildOutput {
|
||||
mesh,
|
||||
report,
|
||||
*self.box_cache.borrow_mut() = Some(BoxBuildCache {
|
||||
generation: self.generation,
|
||||
}
|
||||
checkpoints,
|
||||
});
|
||||
|
||||
Some(state.into_output(self.brushes.len(), self.generation))
|
||||
}
|
||||
|
||||
pub fn sample_room_with_door() -> Self {
|
||||
|
|
@ -639,6 +701,79 @@ impl CompiledBrush {
|
|||
}
|
||||
}
|
||||
|
||||
impl BoxBuildState {
|
||||
fn apply_compiled_box(&mut self, brush: &CompiledBrush) {
|
||||
let CompiledGeometry::Box(bounds) = brush.geometry else {
|
||||
unreachable!("box build state received a non-box brush");
|
||||
};
|
||||
|
||||
match brush.op {
|
||||
BrushOp::Add => self.boxes.push((bounds, brush.material)),
|
||||
BrushOp::Subtract => {
|
||||
self.report.operator_brushes += 1;
|
||||
if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) {
|
||||
self.report.rejected_pairs += self.boxes.len();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(self.boxes.len() + 4);
|
||||
for (solid, material) in self.boxes.drain(..) {
|
||||
if solid.intersects(bounds) {
|
||||
self.report.candidate_pairs += 1;
|
||||
out.extend(
|
||||
solid
|
||||
.subtract_box(bounds)
|
||||
.into_iter()
|
||||
.map(|piece| (piece, material)),
|
||||
);
|
||||
} else {
|
||||
self.report.rejected_pairs += 1;
|
||||
out.push((solid, material));
|
||||
}
|
||||
}
|
||||
self.boxes = out;
|
||||
}
|
||||
BrushOp::Intersect => {
|
||||
self.report.operator_brushes += 1;
|
||||
if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) {
|
||||
self.report.rejected_pairs += self.boxes.len();
|
||||
self.boxes.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
self.boxes = self
|
||||
.boxes
|
||||
.drain(..)
|
||||
.filter_map(|(solid, material)| {
|
||||
if let Some(hit) = solid.intersection(bounds) {
|
||||
self.report.candidate_pairs += 1;
|
||||
Some((hit, material))
|
||||
} else {
|
||||
self.report.rejected_pairs += 1;
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_output(mut self, input_brushes: usize, generation: u64) -> BuildOutput {
|
||||
let mut mesh = TriangleMesh::new();
|
||||
self.report.input_brushes = input_brushes;
|
||||
self.report.emitted_convex_fragments = self.boxes.len();
|
||||
for (bounds, material) in self.boxes {
|
||||
mesh.append_box(bounds, material);
|
||||
}
|
||||
|
||||
BuildOutput {
|
||||
mesh,
|
||||
report: self.report,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subtract_from_solids(
|
||||
solids: Vec<ConvexSolid>,
|
||||
cutter: &ConvexSolid,
|
||||
|
|
@ -986,6 +1121,94 @@ mod tests {
|
|||
assert_eq!(asm.brush_index(cutter), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_box_rebuild_reuses_prefix_and_matches_full_rebuild() {
|
||||
let mut asm = Assembler::new();
|
||||
asm.solid_box(
|
||||
"source",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
|
||||
MaterialId(1),
|
||||
);
|
||||
asm.cut_box(
|
||||
"early",
|
||||
Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)),
|
||||
);
|
||||
let dirty = asm.cut_box(
|
||||
"dirty",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
|
||||
);
|
||||
asm.cut_box(
|
||||
"late",
|
||||
Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)),
|
||||
);
|
||||
let before = asm.build();
|
||||
|
||||
assert!(asm.set_brush_primitive(
|
||||
dirty,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)),
|
||||
},
|
||||
));
|
||||
let incremental = asm.build_incremental();
|
||||
let full = asm.rebuild();
|
||||
|
||||
assert_eq!(incremental.mesh.positions, full.mesh.positions);
|
||||
assert_eq!(incremental.mesh.indices, full.mesh.indices);
|
||||
assert_eq!(
|
||||
incremental.report.candidate_pairs,
|
||||
full.report.candidate_pairs
|
||||
);
|
||||
assert_eq!(
|
||||
incremental.report.rejected_pairs,
|
||||
full.report.rejected_pairs
|
||||
);
|
||||
assert_eq!(incremental.generation, full.generation);
|
||||
assert_ne!(incremental.mesh.positions, before.mesh.positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_incremental_box_rebuild_matches_full_rebuild() {
|
||||
let mut asm = Assembler::new();
|
||||
asm.solid_box(
|
||||
"source",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
|
||||
MaterialId(1),
|
||||
);
|
||||
asm.cut_box(
|
||||
"early",
|
||||
Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)),
|
||||
);
|
||||
let dirty = asm.cut_box(
|
||||
"dirty",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
|
||||
);
|
||||
asm.cut_box(
|
||||
"late",
|
||||
Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)),
|
||||
);
|
||||
asm.build();
|
||||
|
||||
assert!(asm.set_brush_primitive(
|
||||
dirty,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)),
|
||||
},
|
||||
));
|
||||
let incremental = asm.rebuild_incremental_for_brushes([dirty]);
|
||||
let full = asm.rebuild();
|
||||
|
||||
assert_eq!(incremental.mesh.positions, full.mesh.positions);
|
||||
assert_eq!(incremental.mesh.indices, full.mesh.indices);
|
||||
assert_eq!(
|
||||
incremental.report.candidate_pairs,
|
||||
full.report.candidate_pairs
|
||||
);
|
||||
assert_eq!(
|
||||
incremental.report.rejected_pairs,
|
||||
full.report.rejected_pairs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutating_brush_operation_changes_ordered_output() {
|
||||
let mut asm = Assembler::new();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
//! Lean constructive geometry tools for VibeGeometry.
|
||||
//!
|
||||
//! This crate borrows RealtimeCSG's useful public shape: ordered brushes,
|
||||
//! operation types, dirtied generations, and a rebuildable output mesh. The
|
||||
//! hidden native RealtimeCSG kernel is not public, so this starts with the
|
||||
//! smallest honest CSG organ: exact box subtraction against additive boxes,
|
||||
//! plus additive procedural primitives for the habitat forms we keep needing.
|
||||
//! operation types, dirtied generations, prefix checkpoints, and rebuildable
|
||||
//! output meshes. The hidden native RealtimeCSG kernel is not public, so this
|
||||
//! starts with the smallest honest CSG organ: exact box subtraction against
|
||||
//! additive boxes, plus additive procedural primitives for the habitat forms we
|
||||
//! keep needing.
|
||||
|
||||
mod assembler;
|
||||
mod brush;
|
||||
|
|
|
|||
|
|
@ -119,8 +119,16 @@ Realtime editing seam: `Assembler` can now mutate brush primitives and
|
|||
operations by `BrushId`, invalidating cached output and incrementing generation.
|
||||
`DirtyDemandFrontier` computes the conservative ordered suffix after the first
|
||||
dirty brush. The prefix before that index is the cacheable region; the suffix is
|
||||
the live region because ordered CSG decisions propagate forward. This is still a
|
||||
planning surface, not an incremental mesh rebuild yet.
|
||||
the live region because ordered CSG decisions propagate forward. The first
|
||||
implementation now uses that boundary only for box-only prefix checkpoints; the
|
||||
convex/router path still needs the same treatment.
|
||||
|
||||
Incremental edit seam: box-only ordered CSG now stores prefix checkpoints after
|
||||
each brush. A tail edit can resume from the checkpoint immediately before the
|
||||
dirty brush and replay only the live suffix. The result is parity-checked
|
||||
against a full rebuild. This is a real cache boundary, but final mesh emission is
|
||||
still whole-output, so the current win appears in larger tail-edit cases rather
|
||||
than tiny scenes.
|
||||
|
||||
## Latest Local Baseline
|
||||
|
||||
|
|
@ -154,3 +162,30 @@ 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.
|
||||
|
||||
## Incremental Box-Only Smoke Run
|
||||
|
||||
Captured on 2026-05-07 with
|
||||
`cargo run -p vg_csg --release --example csg_perf_fixture`. Dirty modes mutate
|
||||
the tail brush each iteration. `incremental_dirty` uses the box-prefix
|
||||
checkpoint cache when the scene is box-only and falls back for non-box/rotated
|
||||
cases.
|
||||
|
||||
```jsonl
|
||||
{"kernel":"vg_csg","mode":"stable","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":518,"min_ns":500,"p50_ns":500,"p95_ns":600,"max_ns":700,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
|
||||
{"kernel":"vg_csg","mode":"dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":4287,"min_ns":3800,"p50_ns":4000,"p95_ns":4700,"max_ns":14000,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
|
||||
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"single_center_cut","brushes":2,"iterations":64,"warmup_iterations":8,"mean_ns":4617,"min_ns":4100,"p50_ns":4200,"p95_ns":6600,"max_ns":10600,"triangles":72,"fragments":6,"warnings":0,"candidate_pairs":1,"rejected_pairs":0}
|
||||
{"kernel":"vg_csg","mode":"stable","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":46415,"min_ns":41500,"p50_ns":42200,"p95_ns":52400,"max_ns":195100,"triangles":3072,"fragments":256,"warnings":0,"candidate_pairs":64,"rejected_pairs":8128}
|
||||
{"kernel":"vg_csg","mode":"dirty","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":825753,"min_ns":464400,"p50_ns":715400,"p95_ns":1257500,"max_ns":3452200,"triangles":3108,"fragments":259,"warnings":0,"candidate_pairs":65,"rejected_pairs":8127}
|
||||
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"room_grid_8x8_doors","brushes":192,"iterations":64,"warmup_iterations":8,"mean_ns":516351,"min_ns":112000,"p50_ns":363900,"p95_ns":983900,"max_ns":4876400,"triangles":3108,"fragments":259,"warnings":0,"candidate_pairs":65,"rejected_pairs":8127}
|
||||
{"kernel":"vg_csg","mode":"stable","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":240,"min_ns":200,"p50_ns":200,"p95_ns":300,"max_ns":500,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
|
||||
{"kernel":"vg_csg","mode":"dirty","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":52087,"min_ns":35900,"p50_ns":44800,"p95_ns":56700,"max_ns":281000,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
|
||||
{"kernel":"vg_csg","mode":"incremental_dirty","scenario":"distant_cutters_512","brushes":513,"iterations":64,"warmup_iterations":8,"mean_ns":35528,"min_ns":32200,"p50_ns":32500,"p95_ns":47000,"max_ns":50000,"triangles":12,"fragments":1,"warnings":0,"candidate_pairs":0,"rejected_pairs":512}
|
||||
```
|
||||
|
||||
Tail-edit lesson: prefix caching cuts the big box-room dirty mean by roughly a
|
||||
third and distant-cutter dirty mean by roughly a third. The single-cut case is
|
||||
slower because cache machinery dominates, and rotated/non-box cases still use
|
||||
the full path. The next hard target is cached mesh emission or dirty output
|
||||
patching; replaying less CSG work is not enough if every edit still serializes
|
||||
the whole result mesh.
|
||||
|
|
|
|||
|
|
@ -118,8 +118,11 @@ Current realtime-editing seam: `Assembler` can mutate brush primitives and
|
|||
operations by `BrushId`, invalidating cached output and incrementing generation.
|
||||
`DirtyDemandFrontier` computes the conservative ordered suffix after the first
|
||||
dirty brush. The prefix before that index is the future cache boundary; the
|
||||
suffix is live because ordered CSG decisions propagate forward. There is not yet
|
||||
an incremental mesh rebuild from that plan.
|
||||
suffix is live because ordered CSG decisions propagate forward. Box-only ordered
|
||||
CSG now has a real prefix checkpoint cache, and `build_incremental()` resumes
|
||||
from the checkpoint before the first dirty brush. Output parity is tested against
|
||||
full rebuilds. Mesh emission is still whole-output, and rotated/non-box paths
|
||||
still fall back.
|
||||
|
||||
## Important Invariants
|
||||
|
||||
|
|
|
|||
|
|
@ -66,3 +66,4 @@
|
|||
{"ts": "2026-05-07T07:30:39+00:00", "type": "implementation", "status": "ok", "note": "Added the first experimental vg_csg routed-surface path: single convex source plus exactly one candidate subtractive convex cutter emits surviving source boundary polygons plus reversed cutter cap polygons, reducing centered-cut triangles from 72 fragment-carver triangles to 48 boundary triangles. Dense repeated cutters are deliberately not routed yet: naive surface-list routing caused surface-piece explosion, so the next dense-kernel move must use compact category/frontier batching and scratch storage rather than unbounded polygon Vec churn."}
|
||||
{"ts": "2026-05-07T07:37:47+00:00", "type": "implementation", "status": "ok", "note": "Added PolygonRouteScratch for routed polygon classification and wired it into rebuild_routed_surfaces. An attempted in-place fragment frontier sped nothing safely: it changed rotated_cut_stack_64 output shape, so it was reverted and a regression now pins that scenario at 280 fragments, 3404 triangles, 804 candidate pairs, and 9417 rejected pairs. Future dense speed work must preserve ordered output or introduce an explicit accepted-output transition, not silently mutate semantics."}
|
||||
{"ts": "2026-05-07T07:47:54+00:00", "type": "implementation", "status": "ok", "note": "Added the first realtime-editing seam to vg_csg: brushes can now mutate primitive/op by BrushId, invalidating cached output and incrementing generation; DirtyDemandFrontier computes the conservative ordered suffix after the first dirty brush, identifying the future prefix-cache boundary. This is a planning/cache seam, not yet incremental mesh rebuild."}
|
||||
{"ts": "2026-05-07T14:05:07+00:00", "type": "implementation", "status": "ok", "note": "Added box-only dirty prefix checkpoints to vg_csg. build_incremental resumes from the checkpoint before the first dirty brush and parity tests compare incremental output against full rebuilds. Perf fixture now includes incremental_dirty; tail edits improve in large box-only scenarios, but final mesh emission remains whole-output and non-box paths fall back."}
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ data_flow:
|
|||
- If state grows faster than understanding, cut it back.
|
||||
|
||||
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: Turn `DirtyDemandFrontier` from a conservative suffix plan into an incremental rebuild cache: preserve the prefix before the first dirty brush, rebuild the live suffix through the compact category/router path, keep cached-vs-dirty output parity green, and only add BVH/grid storage after batching proves it needs one.
|
||||
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, dirty, and incremental dirty modes, mesh-array output, stable-generation output caching, box-only dirty prefix checkpoints, 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 box-only incremental dirty prefix caching; tail edits in large box-only scenes are faster, but mesh emission is still whole-output and 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: Extend the dirty-prefix cache beyond box-only state: either cache emitted mesh ranges for box scenes or move the same checkpoint/live-suffix model into the convex/category-router path, then benchmark against full dirty rebuilds without relaxing output parity.
|
||||
remotes:
|
||||
vibegeometry: https://github.com/GameCult/VibeGeometry
|
||||
geometry_script_fork: https://github.com/GameCult/geometry-script
|
||||
|
|
|
|||
Loading…
Reference in a new issue