Extend domain CSG chunk runtime

This commit is contained in:
Meta Vens 2026-05-29 15:55:45 +01:00
commit a466b8ddc3
3 changed files with 466 additions and 56 deletions

View file

@ -5,7 +5,7 @@ use std::{
use bevy_math::{Quat, Vec3};
use vg_csg::{
Aabb, Assembler, BrushOp, DomainQuery, MaterialId, Primitive, lower_selected_cut,
Aabb, Assembler, BrushOp, DomainQuery, MaterialId, Primitive, lower_selected_cut_chunks,
ragnarok_column_fixture, select_domain_cut,
};
@ -240,13 +240,23 @@ fn percentile_ns(values: &[Duration], percentile: usize) -> u128 {
fn run_lod_case(name: &'static str, query: DomainQuery) {
let fixture = ragnarok_column_fixture();
let warmup_cut = select_domain_cut(&fixture, &query);
let warmup_chunk = lower_selected_cut(&warmup_cut);
black_box(warmup_chunk.mesh.triangle_count());
let warmup_chunks = lower_selected_cut_chunks(&warmup_cut);
black_box(
warmup_chunks
.iter()
.map(|chunk| chunk.mesh.triangle_count())
.sum::<usize>(),
);
for _ in 0..WARMUP_ITERS {
let cut = select_domain_cut(&fixture, &query);
let chunk = lower_selected_cut(&cut);
black_box(chunk.mesh.triangle_count());
let chunks = lower_selected_cut_chunks(&cut);
black_box(
chunks
.iter()
.map(|chunk| chunk.mesh.triangle_count())
.sum::<usize>(),
);
}
let mut timings = Vec::with_capacity(MEASURE_ITERS);
@ -261,18 +271,26 @@ fn run_lod_case(name: &'static str, query: DomainQuery) {
for _ in 0..MEASURE_ITERS {
let start = Instant::now();
let cut = select_domain_cut(&fixture, &query);
let chunk = lower_selected_cut(&cut);
let chunks = lower_selected_cut_chunks(&cut);
let elapsed = start.elapsed();
selected_nodes = cut.selected_nodes.len();
emitted_claims = cut.emitted_claims.len();
triangles = chunk.mesh.triangle_count();
collider_triangles = chunk
.collider_mesh
.as_ref()
.map_or(0, |mesh| mesh.triangle_count());
brush_count = chunk.report.input_brushes;
candidate_pairs = chunk.report.candidate_pairs;
rejected_pairs = chunk.report.rejected_pairs;
triangles = chunks.iter().map(|chunk| chunk.mesh.triangle_count()).sum();
collider_triangles = chunks
.iter()
.map(|chunk| {
chunk
.collider_mesh
.as_ref()
.map_or(0, |mesh| mesh.triangle_count())
})
.sum();
brush_count = chunks.iter().map(|chunk| chunk.report.input_brushes).sum();
candidate_pairs = chunks
.iter()
.map(|chunk| chunk.report.candidate_pairs)
.sum();
rejected_pairs = chunks.iter().map(|chunk| chunk.report.rejected_pairs).sum();
black_box((
selected_nodes,
emitted_claims,

View file

@ -1,6 +1,9 @@
use bevy_math::{Quat, Vec3};
use crate::{Aabb, Assembler, BrushOp, BuildReport, MaterialId, Primitive, TriangleMesh};
use crate::{
Aabb, BrushOp, BuildReport, CsgBranchOp, CsgNodeId, CsgOperationType, CsgTreeArena, MaterialId,
Primitive, TriangleMesh,
};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DomainKey(pub String);
@ -122,18 +125,18 @@ impl FeatureClaimKind {
}
}
fn brush_op(self) -> BrushOp {
pub fn brush_op(self) -> BrushOp {
match self {
Self::VoidBrush | Self::ClearanceVolume => BrushOp::Subtract,
_ => BrushOp::Add,
}
}
fn emits_render(self) -> bool {
pub fn emits_render(self) -> bool {
!matches!(self, Self::ColliderProxy | Self::ClearanceVolume)
}
fn emits_collider(self) -> bool {
pub fn emits_collider(self) -> bool {
matches!(
self,
Self::SolidBrush | Self::RoadSurfaceSlab | Self::SupportShell | Self::ColliderProxy
@ -156,7 +159,7 @@ impl FeatureClaim {
self.frame.transform_bounds(self.support)
}
fn primitive(&self) -> Primitive {
pub fn primitive(&self) -> Primitive {
Primitive::OrientedBox {
center: self.frame.transform_point(self.support.center()),
size: self.support.size(),
@ -278,8 +281,12 @@ pub struct ContributionRow {
pub kind: DomainKind,
pub contribution: f32,
pub estimated_triangle_cost: usize,
pub child_cost: usize,
pub remaining_triangle_budget: usize,
pub requested: bool,
pub selected: bool,
pub used_fallback: bool,
pub deferred_by_budget: bool,
}
#[derive(Clone, Debug, PartialEq)]
@ -304,6 +311,21 @@ pub struct TriangleChunk {
pub report: BuildReport,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ClaimLoweringTarget {
Render,
Collider,
}
#[derive(Clone, Debug)]
pub struct CsgClaimLowering {
pub arena: CsgTreeArena,
pub root: Option<CsgNodeId>,
pub bounds: Aabb,
pub source_domain_keys: Vec<DomainKey>,
pub source_claim_keys: Vec<String>,
}
pub fn select_domain_cut(root: &DomainNode, query: &DomainQuery) -> SelectedCut {
let mut state = CutState {
query,
@ -313,6 +335,7 @@ pub fn select_domain_cut(root: &DomainNode, query: &DomainQuery) -> SelectedCut
fallback: Vec::new(),
diagnostics: Vec::new(),
remaining_triangles: query.triangle_budget,
remaining_colliders: query.collider_budget,
};
state.visit(root, true);
SelectedCut {
@ -326,44 +349,103 @@ pub fn select_domain_cut(root: &DomainNode, query: &DomainQuery) -> SelectedCut
}
pub fn lower_selected_cut(cut: &SelectedCut) -> TriangleChunk {
let mut render_assembler = Assembler::new();
let mut collider_assembler = Assembler::new();
lower_claims_to_chunk(
DomainKey::new(format!("chunk/{}", cut.id)),
&cut.id,
&cut.emitted_claims,
)
}
pub fn lower_selected_cut_chunks(cut: &SelectedCut) -> Vec<TriangleChunk> {
let mut chunks = Vec::new();
for domain_key in &cut.selected_nodes {
let claims = cut
.emitted_claims
.iter()
.filter(|claim| &claim.domain_key == domain_key)
.cloned()
.collect::<Vec<_>>();
if claims.is_empty() {
continue;
}
chunks.push(lower_claims_to_chunk(
DomainKey::new(format!("chunk/{}/{}", cut.id, domain_key.0)),
&cut.id,
&claims,
));
}
chunks
}
pub fn lower_feature_claims_to_csg_tree(
claims: &[FeatureClaim],
target: ClaimLoweringTarget,
) -> CsgClaimLowering {
let mut arena = CsgTreeArena::new();
let mut additive_nodes = Vec::new();
let mut subtractive_nodes = Vec::new();
let mut source_domain_keys = Vec::<DomainKey>::new();
let mut source_claim_keys = Vec::<String>::new();
let mut bounds = Aabb::empty();
for claim in &cut.emitted_claims {
for claim in claims.iter().filter(|claim| claim_in_target(claim, target)) {
source_claim_keys.push(claim.key.clone());
if !source_domain_keys.contains(&claim.domain_key) {
source_domain_keys.push(claim.domain_key.clone());
}
bounds = bounds.union(claim.world_bounds());
if claim.kind.emits_render() || matches!(claim.kind, FeatureClaimKind::VoidBrush) {
render_assembler.add_brush(
claim.key.clone(),
claim.kind.brush_op(),
claim.primitive(),
claim.material,
);
}
if claim.kind.emits_collider() || matches!(claim.kind, FeatureClaimKind::VoidBrush) {
collider_assembler.add_brush(
claim.key.clone(),
claim.kind.brush_op(),
claim.primitive(),
claim.material,
);
let brush = arena.generate_brush(
claim.key.clone(),
CsgOperationType::Additive,
claim.primitive(),
claim.material,
);
match claim.kind.brush_op() {
BrushOp::Add => additive_nodes.push(brush.node),
BrushOp::Subtract => subtractive_nodes.push(brush.node),
BrushOp::Intersect => additive_nodes.push(brush.node),
}
}
let output = render_assembler.build();
let collider_output =
(!collider_assembler.brushes().is_empty()).then(|| collider_assembler.build());
let root = build_claim_tree(&mut arena, additive_nodes, subtractive_nodes);
CsgClaimLowering {
arena,
root,
bounds,
source_domain_keys,
source_claim_keys,
}
}
fn lower_claims_to_chunk(
key: DomainKey,
selected_cut_id: &str,
claims: &[FeatureClaim],
) -> TriangleChunk {
let render = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Render);
let collider = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Collider);
let (source_domain_keys, source_claim_keys) = claim_metadata(claims);
let output = render
.root
.map(|root| {
render
.arena
.compile_tree_to_assembler(render.arena.generate_tree(root))
.build()
})
.unwrap_or_else(|| crate::Assembler::new().build());
let collider_output = collider.root.map(|root| {
collider
.arena
.compile_tree_to_assembler(collider.arena.generate_tree(root))
.build()
});
TriangleChunk {
key: DomainKey::new(format!("chunk/{}", cut.id)),
selected_cut_id: cut.id.clone(),
bounds,
key,
selected_cut_id: selected_cut_id.to_owned(),
bounds: render.bounds.union(collider.bounds),
mesh: output.mesh,
collider_mesh: collider_output.map(|output| output.mesh),
source_domain_keys,
@ -372,6 +454,85 @@ pub fn lower_selected_cut(cut: &SelectedCut) -> TriangleChunk {
}
}
fn claim_metadata(claims: &[FeatureClaim]) -> (Vec<DomainKey>, Vec<String>) {
let mut source_domain_keys = Vec::<DomainKey>::new();
let mut source_claim_keys = Vec::<String>::new();
for claim in claims {
source_claim_keys.push(claim.key.clone());
if !source_domain_keys.contains(&claim.domain_key) {
source_domain_keys.push(claim.domain_key.clone());
}
}
(source_domain_keys, source_claim_keys)
}
fn claim_in_target(claim: &FeatureClaim, target: ClaimLoweringTarget) -> bool {
match target {
ClaimLoweringTarget::Render => {
claim.kind.emits_render()
|| matches!(
claim.kind,
FeatureClaimKind::VoidBrush | FeatureClaimKind::ClearanceVolume
)
}
ClaimLoweringTarget::Collider => {
claim.kind.emits_collider()
|| matches!(
claim.kind,
FeatureClaimKind::VoidBrush | FeatureClaimKind::ClearanceVolume
)
}
}
}
fn build_claim_tree(
arena: &mut CsgTreeArena,
additive_nodes: Vec<CsgNodeId>,
subtractive_nodes: Vec<CsgNodeId>,
) -> Option<CsgNodeId> {
let additive_root = match additive_nodes.len() {
0 => None,
1 => additive_nodes.first().copied(),
_ => Some(
arena
.generate_branch(
"domain-additive-claims",
CsgBranchOp::Addition,
additive_nodes,
)
.node,
),
}?;
if subtractive_nodes.is_empty() {
return Some(additive_root);
}
let subtractive_root = match subtractive_nodes.len() {
0 => unreachable!(),
1 => subtractive_nodes[0],
_ => {
arena
.generate_branch(
"domain-subtractive-claims",
CsgBranchOp::Addition,
subtractive_nodes,
)
.node
}
};
Some(
arena
.generate_branch(
"domain-claims",
CsgBranchOp::Subtraction,
[additive_root, subtractive_root],
)
.node,
)
}
pub fn ragnarok_column_fixture() -> DomainNode {
let root_key = DomainKey::new("ragnarok-column");
let column_key = root_key.child("stellarator-column-00");
@ -467,14 +628,26 @@ fn ragnarok_branch(parent: &DomainKey, band: usize, lane: usize) -> DomainNode {
FeatureClaimKind::ClearanceVolume,
MaterialId(0),
);
let rib = claim(
&key,
"coarse-support-rib",
frame,
Aabb::from_center_size(Vec3::new(7.0, 0.0, -1.0), Vec3::new(18.5, 1.0, 1.5)),
FeatureClaimKind::SupportShell,
MaterialId(12),
);
let mut children = Vec::new();
for segment in 0..3 {
children.push(ragnarok_road_chunk(&key, band, lane, segment, frame));
}
DomainNode::new(
key,
Some(parent.clone()),
DomainKind::BranchRoad,
frame,
0xA11E_0000 + (band * 10 + lane) as u64,
vec![road, void],
Vec::new(),
vec![road, void, rib],
children,
)
}
@ -497,6 +670,10 @@ fn ragnarok_roundabout(parent: &DomainKey, band: usize) -> DomainNode {
FeatureClaimKind::RoadSurfaceSlab,
MaterialId(60 + band as u32),
);
let mut children = Vec::new();
for quadrant in 0..4 {
children.push(ragnarok_roundabout_chunk(&key, band, quadrant));
}
DomainNode::new(
key,
Some(parent.clone()),
@ -504,6 +681,97 @@ fn ragnarok_roundabout(parent: &DomainKey, band: usize) -> DomainNode {
frame,
0xF00D_0000 + band as u64,
vec![crossing_a, crossing_b],
children,
)
}
fn ragnarok_road_chunk(
parent: &DomainKey,
band: usize,
lane: usize,
segment: usize,
frame: DomainFrame,
) -> DomainNode {
let key = parent.child(format!("chunk-{segment}"));
let x = 1.5 + segment as f32 * 5.5;
let road = claim(
&key,
"road-slab",
frame,
Aabb::from_center_size(Vec3::new(x, 0.0, 0.0), Vec3::new(6.5, 4.0, 0.75)),
FeatureClaimKind::RoadSurfaceSlab,
MaterialId(80 + band as u32),
);
let clearance = claim(
&key,
"hover-clearance",
frame,
Aabb::from_center_size(Vec3::new(x, 0.0, 2.1), Vec3::new(6.0, 3.0, 1.9)),
FeatureClaimKind::ClearanceVolume,
MaterialId(0),
);
let support = claim(
&key,
"support-rib",
frame,
Aabb::from_center_size(Vec3::new(x, 0.0, -1.05), Vec3::new(6.8, 0.9, 1.4)),
FeatureClaimKind::SupportShell,
MaterialId(90 + lane as u32),
);
let collider = claim(
&key,
"collider-proxy",
frame,
Aabb::from_center_size(Vec3::new(x, 0.0, 0.35), Vec3::new(6.8, 4.2, 0.35)),
FeatureClaimKind::ColliderProxy,
MaterialId(0),
);
DomainNode::new(
key,
Some(parent.clone()),
DomainKind::Chunk,
frame,
0xC40B_0000 + (band * 100 + lane * 10 + segment) as u64,
vec![road, clearance, support, collider],
Vec::new(),
)
}
fn ragnarok_roundabout_chunk(parent: &DomainKey, band: usize, quadrant: usize) -> DomainNode {
let key = parent.child(format!("chunk-{quadrant}"));
let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2;
let frame = DomainFrame::rotated_z(Vec3::ZERO, angle);
let road = claim(
&key,
"arc-road-slab",
frame,
Aabb::from_center_size(Vec3::new(5.0, 5.0, 0.0), Vec3::new(12.0, 4.0, 0.8)),
FeatureClaimKind::RoadSurfaceSlab,
MaterialId(100 + band as u32),
);
let clearance = claim(
&key,
"arc-clearance",
frame,
Aabb::from_center_size(Vec3::new(5.0, 5.0, 2.1), Vec3::new(11.0, 3.0, 1.8)),
FeatureClaimKind::ClearanceVolume,
MaterialId(0),
);
let support = claim(
&key,
"arc-support",
frame,
Aabb::from_center_size(Vec3::new(5.0, 5.0, -1.0), Vec3::new(12.5, 0.8, 1.2)),
FeatureClaimKind::SupportShell,
MaterialId(90),
);
DomainNode::new(
key,
Some(parent.clone()),
DomainKind::Chunk,
frame,
0xC10C_0000 + (band * 10 + quadrant) as u64,
vec![road, clearance, support],
Vec::new(),
)
}
@ -589,36 +857,64 @@ struct CutState<'a> {
fallback: Vec<DomainKey>,
diagnostics: Vec<ContributionRow>,
remaining_triangles: usize,
remaining_colliders: usize,
}
impl CutState<'_> {
fn visit(&mut self, node: &DomainNode, force_visit: bool) {
if !force_visit && !node.summary.bounds.intersects(self.query.frustum) {
let requested_self = self.query.requested_chunk_keys.contains(&node.key);
let requested_descendant = has_requested_descendant(node, &self.query.requested_chunk_keys);
if !force_visit
&& !requested_self
&& !requested_descendant
&& !node.summary.bounds.intersects(self.query.frustum)
{
return;
}
let score = contribution(node, self.query);
let child_cost = node
.children
.iter()
.map(|child| child.summary.estimated_triangle_cost)
.map(selection_triangle_cost)
.sum::<usize>();
let requested = self.query.requested_chunk_keys.contains(&node.key);
let child_collider_cost = node
.children
.iter()
.map(selection_collider_cost)
.sum::<usize>();
let budget_allows_children = child_cost <= self.remaining_triangles
&& child_collider_cost <= self.remaining_colliders;
let descend_for_detail = score >= self.query.target_error && budget_allows_children;
let descend_for_request = requested_descendant && !requested_self;
let should_descend = node.summary.has_children
&& (requested || score >= self.query.target_error)
&& child_cost <= self.remaining_triangles;
&& !requested_self
&& (descend_for_request || descend_for_detail);
self.diagnostics.push(ContributionRow {
domain_key: node.key.clone(),
kind: node.kind,
contribution: score,
estimated_triangle_cost: node.summary.estimated_triangle_cost,
child_cost,
remaining_triangle_budget: self.remaining_triangles,
requested: requested_self,
selected: !should_descend && self.query.accepts_kind(node.kind),
used_fallback: !should_descend && node.summary.has_children,
deferred_by_budget: node.summary.has_children
&& !requested_self
&& score >= self.query.target_error
&& !budget_allows_children,
});
if should_descend {
self.remaining_triangles = self.remaining_triangles.saturating_sub(child_cost);
if budget_allows_children {
self.remaining_triangles = self.remaining_triangles.saturating_sub(child_cost);
self.remaining_colliders =
self.remaining_colliders.saturating_sub(child_collider_cost);
}
for child in &node.children {
self.visit(child, false);
let child_requested = self.query.requested_chunk_keys.contains(&child.key)
|| has_requested_descendant(child, &self.query.requested_chunk_keys);
self.visit(child, child_requested);
}
return;
}
@ -639,6 +935,41 @@ impl CutState<'_> {
}
}
fn selection_triangle_cost(node: &DomainNode) -> usize {
let claim_count = if node.summary.has_children {
node.summary.fallback_claims.len()
} else {
node.claims.len()
};
claim_count.max(1) * 12
}
fn selection_collider_cost(node: &DomainNode) -> usize {
let claims = if node.summary.has_children {
&node.summary.fallback_claims
} else {
&node.claims
};
claims
.iter()
.filter(|claim| {
claim.kind.emits_collider()
|| matches!(
claim.kind,
FeatureClaimKind::VoidBrush | FeatureClaimKind::ClearanceVolume
)
})
.count()
.max(1)
* 12
}
fn has_requested_descendant(node: &DomainNode, requested: &[DomainKey]) -> bool {
node.children
.iter()
.any(|child| requested.contains(&child.key) || has_requested_descendant(child, requested))
}
#[cfg(test)]
mod tests {
use super::*;
@ -697,6 +1028,16 @@ mod tests {
);
}
#[test]
fn selected_cut_honors_collider_budget() {
let fixture = ragnarok_column_fixture();
let mut collider_tight = query(0.01, 10_000);
collider_tight.collider_budget = 1;
let cut = select_domain_cut(&fixture, &collider_tight);
assert_eq!(cut.selected_nodes, vec![fixture.key.clone()]);
assert!(cut.diagnostics[0].deferred_by_budget);
}
#[test]
fn missing_children_degrade_to_parent_fallback_claims() {
let fixture = ragnarok_column_fixture();
@ -726,6 +1067,49 @@ mod tests {
assert!(chunk.collider_mesh.is_some());
}
#[test]
fn feature_claims_lower_through_csg_tree_branches() {
let fixture = ragnarok_column_fixture();
let cut = select_domain_cut(&fixture, &query(0.01, 10_000));
let lowering =
lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Render);
assert!(lowering.root.is_some());
assert!(lowering.arena.brush_count() > 0);
assert!(lowering.arena.branch_count() > 0);
}
#[test]
fn selected_cut_chunks_emit_per_selected_domain() {
let fixture = ragnarok_column_fixture();
let cut = select_domain_cut(&fixture, &query(0.01, 10_000));
let chunks = lower_selected_cut_chunks(&cut);
assert_eq!(chunks.len(), cut.selected_nodes.len());
for chunk in chunks {
assert_eq!(chunk.source_domain_keys.len(), 1);
assert!(cut.selected_nodes.contains(&chunk.source_domain_keys[0]));
assert!(chunk.mesh.triangle_count() > 0);
}
}
#[test]
fn requested_chunk_bypasses_frustum_and_budget() {
let fixture = ragnarok_column_fixture();
let requested = fixture.children[0].children[2].children[0].children[1]
.key
.clone();
let mut request = query(0.01, 1);
request.frustum =
Aabb::from_center_size(Vec3::new(10_000.0, 10_000.0, 10_000.0), Vec3::splat(1.0));
request.requested_chunk_keys.push(requested.clone());
let cut = select_domain_cut(&fixture, &request);
assert_eq!(cut.selected_nodes, vec![requested.clone()]);
assert!(
cut.diagnostics
.iter()
.any(|row| row.domain_key == requested && row.requested)
);
}
#[test]
fn ragnarok_fixture_emits_lod_chunks_and_preserves_parent_for_transition() {
let fixture = ragnarok_column_fixture();
@ -738,9 +1122,16 @@ mod tests {
assert!(tight.mesh.triangle_count() > 0);
assert!(medium.mesh.triangle_count() > tight.mesh.triangle_count());
assert!(relaxed.mesh.triangle_count() > medium.mesh.triangle_count());
assert_eq!(medium_cut.selected_nodes.len(), 3);
assert!(medium_cut.selected_nodes.len() > tight_cut.selected_nodes.len());
assert!(relaxed_cut.selected_nodes.len() > medium_cut.selected_nodes.len());
assert!(!tight_cut.fallback_nodes.is_empty());
assert!(!relaxed_cut.selected_nodes.is_empty());
assert!(
relaxed_cut
.selected_nodes
.iter()
.any(|key| key.0.contains("/chunk-"))
);
}
#[test]

View file

@ -21,9 +21,10 @@ pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane, PolygonRouteScratch};
pub use domain::{
ContributionRow, DomainFrame, DomainKey, DomainKind, DomainNode, DomainQuery, DomainSummary,
FeatureClaim, FeatureClaimKind, FieldEncoding, FieldLayer, SelectedCut, TriangleChunk,
lower_selected_cut, ragnarok_column_fixture, select_domain_cut,
ClaimLoweringTarget, ContributionRow, CsgClaimLowering, DomainFrame, DomainKey, DomainKind,
DomainNode, DomainQuery, DomainSummary, FeatureClaim, FeatureClaimKind, FieldEncoding,
FieldLayer, SelectedCut, TriangleChunk, lower_feature_claims_to_csg_tree, lower_selected_cut,
lower_selected_cut_chunks, ragnarok_column_fixture, select_domain_cut,
};
pub use dsl::LevelDsl;
pub use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier};