diff --git a/examples/csg_perf_fixture.rs b/examples/csg_perf_fixture.rs index 7485f94..bae6a45 100644 --- a/examples/csg_perf_fixture.rs +++ b/examples/csg_perf_fixture.rs @@ -337,11 +337,14 @@ fn ragnarok_lod_query(target_error: f32, triangle_budget: usize) -> DomainQuery DomainQuery { camera_position: Vec3::new(36.0, -42.0, 30.0), frustum: Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::splat(150.0)), + viewport_height_px: 1080.0, + vertical_fov_radians: std::f32::consts::FRAC_PI_3, target_error, triangle_budget, collider_budget: triangle_budget, semantic_filter: Vec::new(), requested_chunk_keys: Vec::new(), + dirty_domain_keys: Vec::new(), } } diff --git a/src/domain.rs b/src/domain.rs index 7200d16..c6dfcf7 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -525,11 +525,14 @@ impl DomainNode { pub struct DomainQuery { pub camera_position: Vec3, pub frustum: Aabb, + pub viewport_height_px: f32, + pub vertical_fov_radians: f32, pub target_error: f32, pub triangle_budget: usize, pub collider_budget: usize, pub semantic_filter: Vec, pub requested_chunk_keys: Vec, + pub dirty_domain_keys: Vec, } impl DomainQuery { @@ -543,10 +546,13 @@ pub struct ContributionRow { pub domain_key: DomainKey, pub kind: DomainKind, pub contribution: f32, + pub projected_screen_error: f32, + pub semantic_priority: f32, pub estimated_triangle_cost: usize, pub child_cost: usize, pub remaining_triangle_budget: usize, pub requested: bool, + pub dirty: bool, pub selected: bool, pub used_fallback: bool, pub deferred_by_budget: bool, @@ -1103,10 +1109,23 @@ fn domain_priority(kind: DomainKind) -> f32 { } fn contribution(node: &DomainNode, query: &DomainQuery) -> f32 { + let projected_error = projected_screen_error(node, query); + let stale_multiplier = if query.dirty_domain_keys.contains(&node.key) { + 1.5 + } else { + 1.0 + }; + let cost_pressure = selection_triangle_cost(node).max(1) as f32; + (projected_error * domain_priority(node.kind) * stale_multiplier) / cost_pressure.sqrt() +} + +fn projected_screen_error(node: &DomainNode, query: &DomainQuery) -> f32 { let center = node.summary.bounds.center(); let distance = center.distance(query.camera_position).max(1.0); let radius = node.summary.bounds.size().length() * 0.5; - (radius / distance) * node.summary.contribution_weight + let focal_pixels = query.viewport_height_px.max(1.0) + / (2.0 * (query.vertical_fov_radians.max(0.01) * 0.5).tan()); + (radius / distance) * focal_pixels } fn stable_cut_id(keys: &[DomainKey]) -> String { @@ -1147,14 +1166,19 @@ impl CutState<'_> { fn visit(&mut self, node: &DomainNode, force_visit: bool) { let requested_self = self.query.requested_chunk_keys.contains(&node.key); let requested_descendant = has_requested_descendant(node, &self.query.requested_chunk_keys); + let dirty_descendant = has_requested_descendant(node, &self.query.dirty_domain_keys); if !force_visit && !requested_self && !requested_descendant + && !dirty_descendant && !node.summary.bounds.intersects(self.query.frustum) { return; } let score = contribution(node, self.query); + let projected_error = projected_screen_error(node, self.query); + let semantic_priority = domain_priority(node.kind); + let dirty = self.query.dirty_domain_keys.contains(&node.key); let child_cost = node .children .iter() @@ -1169,17 +1193,21 @@ impl CutState<'_> { && 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 descend_for_dirty = dirty_descendant && budget_allows_children; let should_descend = node.summary.has_children && !requested_self - && (descend_for_request || descend_for_detail); + && (descend_for_request || descend_for_dirty || descend_for_detail); self.diagnostics.push(ContributionRow { domain_key: node.key.clone(), kind: node.kind, contribution: score, + projected_screen_error: projected_error, + semantic_priority, estimated_triangle_cost: node.summary.estimated_triangle_cost, child_cost, remaining_triangle_budget: self.remaining_triangles, requested: requested_self, + dirty, selected: !should_descend && self.query.accepts_kind(node.kind), used_fallback: !should_descend && node.summary.has_children, deferred_by_budget: node.summary.has_children @@ -1197,7 +1225,9 @@ impl CutState<'_> { for child in &node.children { 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); + let child_dirty = self.query.dirty_domain_keys.contains(&child.key) + || has_requested_descendant(child, &self.query.dirty_domain_keys); + self.visit(child, child_requested || child_dirty); } return; } @@ -1255,11 +1285,14 @@ mod tests { DomainQuery { camera_position: Vec3::new(36.0, -42.0, 30.0), frustum: Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::splat(150.0)), + viewport_height_px: 1080.0, + vertical_fov_radians: std::f32::consts::FRAC_PI_3, target_error, triangle_budget, collider_budget: triangle_budget, semantic_filter: Vec::new(), requested_chunk_keys: Vec::new(), + dirty_domain_keys: Vec::new(), } } @@ -1360,6 +1393,40 @@ mod tests { assert!(cut.diagnostics[0].deferred_by_budget); } + #[test] + fn selected_cut_reports_projected_error_and_dirty_pressure() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.key.clone(); + let mut request = query(10_000.0, 100); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + let row = cut + .diagnostics + .iter() + .find(|row| row.domain_key == dirty_key) + .unwrap(); + assert!(row.projected_screen_error > 0.0); + assert_eq!(row.semantic_priority, domain_priority(row.kind)); + assert!(row.dirty); + } + + #[test] + fn dirty_descendant_pulls_selection_down_when_budget_allows() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.children[0].children[0].children[0].children[0] + .key + .clone(); + let mut request = query(10_000.0, 10_000); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + assert!(cut.selected_nodes.contains(&dirty_key)); + assert!( + cut.diagnostics + .iter() + .any(|row| row.domain_key == dirty_key && row.dirty) + ); + } + #[test] fn missing_children_degrade_to_parent_fallback_claims() { let fixture = ragnarok_column_fixture();