From 41d8951da0857ab5263ffd4aafcca21d09cbb615 Mon Sep 17 00:00:00 2001 From: Aevyrie Roessler Date: Wed, 11 Mar 2026 21:52:06 -0700 Subject: [PATCH 1/2] speed up nearest object queries using partitions --- src/camera.rs | 262 ++++++++++++++++++++++++++++++++++++++++++++------ src/plugin.rs | 2 +- 2 files changed, 234 insertions(+), 30 deletions(-) diff --git a/src/camera.rs b/src/camera.rs index 6d61cbb..c01a7ac 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -1,5 +1,8 @@ //! Provides a camera controller compatible with the floating origin plugin. +use crate::hash::map::CellLookup; +use crate::hash::SpatialHashFilter; +use crate::portable_par::PortableParallel; use crate::prelude::*; use bevy_app::prelude::*; use bevy_camera::{ @@ -14,10 +17,32 @@ use bevy_platform::prelude::*; use bevy_reflect::prelude::*; use bevy_time::prelude::*; use bevy_transform::{prelude::*, TransformSystems}; +use core::marker::PhantomData; /// Runs the [`big_space`](crate) [`BigSpaceCameraController`]. -pub struct BigSpaceCameraControllerPlugin; -impl Plugin for BigSpaceCameraControllerPlugin { +/// +/// The type parameter `F` is a [`SpatialHashFilter`] that determines which +/// [`PartitionLookup`] and [`CellLookup`] resources are used for the +/// partition-accelerated nearest-object search. When no matching resources +/// exist, the system falls back to a brute-force scan. +/// +/// Defaults to `()` (unfiltered) for backwards compatibility. +pub struct BigSpaceCameraControllerPlugin(PhantomData); + +impl BigSpaceCameraControllerPlugin { + /// Create a new instance of [`BigSpaceCameraControllerPlugin`] with the given filter. + pub fn new() -> Self { + Self(PhantomData) + } +} + +impl Default for BigSpaceCameraControllerPlugin<()> { + fn default() -> Self { + Self(PhantomData) + } +} + +impl Plugin for BigSpaceCameraControllerPlugin { fn build(&self, app: &mut App) { app.register_type::() .register_type::() @@ -28,7 +53,7 @@ impl Plugin for BigSpaceCameraControllerPlugin { default_camera_inputs .before(camera_controller) .run_if(|input: Res| !input.defaults_disabled), - nearest_objects_in_grid.before(camera_controller), + nearest_objects_in_grid::.before(camera_controller), camera_controller.before(TransformSystems::Propagate), ), ); @@ -214,7 +239,12 @@ pub fn default_camera_inputs( } /// Find the object nearest the camera, within the same grid as the camera. -pub fn nearest_objects_in_grid( +/// +/// When a [`PartitionLookup`] and [`CellLookup`] are available (via [`CellHashingPlugin`] + +/// [`PartitionPlugin`] with a matching [`SpatialHashFilter`]), the search is accelerated by +/// first finding the nearest partition AABB, then only checking entities inside that partition. +/// Otherwise, falls back to a parallel scan over all entities. +pub fn nearest_objects_in_grid( objects: Query<( Entity, &Transform, @@ -227,11 +257,15 @@ pub fn nearest_objects_in_grid( Entity, &mut BigSpaceCameraController, &GlobalTransform, + &CellCoord, Option<&RenderLayers>, )>, children: Query<&Children>, + grids: Query<&Grid>, + partitions: Option>>, + cell_lookup: Option>>, ) { - let Ok((cam_entity, mut camera, cam_pos, cam_layer)) = camera.single_mut() else { + let Ok((cam_entity, mut camera, cam_pos, cam_cell, cam_layer)) = camera.single_mut() else { return; }; if !camera.slow_near_objects { @@ -240,26 +274,197 @@ pub fn nearest_objects_in_grid( let cam_layer = cam_layer.to_owned().unwrap_or_default(); let cam_children: EntityHashSet = children.iter_descendants(cam_entity).collect(); - let nearest_object = objects - .iter() - .filter(|(entity, ..)| !cam_children.contains(entity)) - .filter(|(.., obj_layer, _)| { + let nearest_object = match (partitions, cell_lookup) { + (Some(partitions), Some(cell_lookup)) => nearest_via_partitions( + &objects, + &cam_children, + cam_layer, + cam_pos, + cam_cell, + &grids, + &partitions, + &cell_lookup, + ), + _ => nearest_brute_force(&objects, &cam_children, cam_layer, cam_pos), + }; + + // Only update when we found something. When nothing is visible (e.g., all + // entities have been render-culled), preserve the last known distance so the + // camera maintains its speed instead of snapping to the base speed. + if nearest_object.is_some() { + camera.nearest_object = nearest_object; + } +} + +/// Brute-force parallel scan over all entities. +fn nearest_brute_force( + objects: &Query<( + Entity, + &Transform, + &GlobalTransform, + &Aabb, + Option<&RenderLayers>, + &InheritedVisibility, + )>, + cam_children: &EntityHashSet, + cam_layer: &RenderLayers, + cam_pos: &GlobalTransform, +) -> Option<(Entity, f64)> { + let mut queue = PortableParallel::>::default(); + + objects.par_iter().for_each_init( + || queue.borrow_local_mut(), + |local_queue, (entity, object_local, obj_pos, aabb, obj_layer, visibility)| { let obj_layer = obj_layer.unwrap_or_default(); - cam_layer.intersects(obj_layer) - }) - .filter(|(.., visibility)| visibility.get()) - .map(|(entity, object_local, obj_pos, aabb, ..)| { - let center_distance = - obj_pos.translation().as_dvec3() - cam_pos.translation().as_dvec3(); - let nearest_distance = center_distance.length() - - (aabb.half_extents.as_dvec3() * object_local.scale.as_dvec3()) - .abs() - .min_element(); - (entity, nearest_distance) + if cam_children.contains(&entity) + || !cam_layer.intersects(obj_layer) + || !visibility.get() + { + return; + } + let nearest_distance = entity_nearest_distance(cam_pos, obj_pos, object_local, aabb); + if !nearest_distance.is_finite() { + return; + } + if nearest_distance < local_queue.map(|d| d.1).unwrap_or(f64::INFINITY) { + **local_queue = Some((entity, nearest_distance)); + } + }, + ); + + queue + .drain() + .reduce(|nearest, this| if this.1 < nearest.1 { this } else { nearest }) +} + +/// Partition-accelerated nearest object search. +/// +/// 1. O(partitions): find the partition whose cell AABB is nearest to the camera. +/// 2. O(entities in partition): check entities within that partition, then use the best +/// distance as a bound to skip all other partitions whose AABB is farther. +#[allow(clippy::too_many_arguments)] +fn nearest_via_partitions( + objects: &Query<( + Entity, + &Transform, + &GlobalTransform, + &Aabb, + Option<&RenderLayers>, + &InheritedVisibility, + )>, + cam_children: &EntityHashSet, + cam_layer: &RenderLayers, + cam_pos: &GlobalTransform, + cam_cell: &CellCoord, + grids: &Query<&Grid>, + partitions: &PartitionLookup, + cell_lookup: &CellLookup, +) -> Option<(Entity, f64)> { + // Compute cell-space distance from camera to each partition AABB, sorted nearest-first. + let cam = IVec3::new(cam_cell.x as i32, cam_cell.y as i32, cam_cell.z as i32); + let mut sorted: Vec<(&PartitionId, &Partition, f64)> = partitions + .iter() + .map(|(pid, partition)| { + let min = partition.min(); + let max = partition.max(); + let min_i = IVec3::new(min.x as i32, min.y as i32, min.z as i32); + let max_i = IVec3::new(max.x as i32, max.y as i32, max.z as i32); + // Squared distance from camera cell to AABB (clamped point) + let clamped = cam.clamp(min_i, max_i); + let diff = (clamped - cam).as_dvec3(); + (pid, partition, diff.length_squared()) }) - .filter(|v| v.1.is_finite()) - .reduce(|nearest, this| if this.1 < nearest.1 { this } else { nearest }); - camera.nearest_object = nearest_object; + .collect(); + sorted.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(core::cmp::Ordering::Equal)); + + // Get the grid's cell edge length for converting cell distance to world distance. + let cell_edge = sorted + .first() + .and_then(|(_, p, _)| grids.get(p.grid()).ok()) + .map(|g| g.cell_edge_length() as f64) + .unwrap_or(1.0); + + // If no entities match the query at all (e.g., all have had visibility components + // stripped by a render culling system), bail immediately to avoid an exhaustive + // O(partitions × cells) scan that can never find a result. + if objects.is_empty() { + return None; + } + + let mut best: Option<(Entity, f64)> = None; + // Track how many partitions we've scanned without finding any candidate. + // If we scan several nearby partitions and find nothing queryable, further + // partitions are unlikely to help. This caps the worst case when all entities + // have had visibility/AABB components stripped (e.g. render culling). + let mut empty_streak = 0u32; + const MAX_EMPTY_STREAK: u32 = 8; + + for (_pid, partition, aabb_dist_sq) in &sorted { + // Conservative lower bound on world-space distance to this partition. + // Subtract one cell_edge to account for entities within a cell being up to + // cell_edge away from the cell boundary used in the AABB distance calculation. + let aabb_world_dist = (aabb_dist_sq.sqrt() * cell_edge - cell_edge).max(0.0); + if let Some((_, best_dist)) = best { + if aabb_world_dist > best_dist { + break; + } + // Reset streak when we have a candidate - we're now pruning by distance. + empty_streak = 0; + } else if empty_streak >= MAX_EMPTY_STREAK { + // Scanned several nearby partitions without any queryable entity. + // Further partitions are increasingly unlikely to yield a result. + break; + } + + // Check all entities in this partition's cells. + let best_before = best; + for cell_id in partition.iter() { + let Some(entry) = cell_lookup.get(cell_id) else { + continue; + }; + for entity in entry.entities.iter() { + let Ok((_, object_local, obj_pos, aabb, obj_layer, visibility)) = + objects.get(*entity) + else { + continue; + }; + let obj_layer = obj_layer.unwrap_or_default(); + if cam_children.contains(entity) + || !cam_layer.intersects(obj_layer) + || !visibility.get() + { + continue; + } + let nearest_distance = + entity_nearest_distance(cam_pos, obj_pos, object_local, aabb); + if !nearest_distance.is_finite() { + continue; + } + if nearest_distance < best.map(|d| d.1).unwrap_or(f64::INFINITY) { + best = Some((*entity, nearest_distance)); + } + } + } + if best.is_none() && best_before.is_none() { + empty_streak += 1; + } + } + + best +} + +/// Compute the nearest distance from the camera to an entity's AABB surface. +fn entity_nearest_distance( + cam_pos: &GlobalTransform, + obj_pos: &GlobalTransform, + object_local: &Transform, + aabb: &Aabb, +) -> f64 { + let center_distance = obj_pos.translation().as_dvec3() - cam_pos.translation().as_dvec3(); + center_distance.length() + - (aabb.half_extents.as_dvec3() * object_local.scale.as_dvec3()) + .abs() + .min_element() } /// Uses [`BigSpaceCameraInput`] state to update the camera position. @@ -286,10 +491,10 @@ pub fn camera_controller( let [min, max] = controller.speed_bounds; let speed = speed.clamp(min, max); - let dt = time.delta_secs_f64(); - // Framerate-independent exponential smoothing. At 60fps (dt=1/60) the exponent - // is 1.0, reproducing the original per-frame behavior. At other framerates the - // decay scales correctly so the feel is consistent. + let dt = time.delta_secs_f64().min(0.1); // Clamp to 100ms to prevent flying on perf dips + // Framerate-independent exponential smoothing. At 60fps (dt=1/60) the exponent + // is 1.0, reproducing the original per-frame behavior. At other framerates the + // decay scales correctly so the feel is consistent. let lerp_translation = 1.0 - controller.smoothness.clamp(0.0, 0.999).powf(dt * 60.0); let lerp_rotation = 1.0 - controller @@ -298,8 +503,7 @@ pub fn camera_controller( .powf(dt * 60.0); let (vel_t_current, vel_r_current) = (controller.vel_translation, controller.vel_rotation); - let (vel_t_target, vel_r_target) = - input.target_velocity(&controller, speed, time.delta_secs_f64()); + let (vel_t_target, vel_r_target) = input.target_velocity(&controller, speed, dt); let cam_rot = transform.rotation.as_dquat(); let vel_t_next = cam_rot * vel_t_target; // Orients the translation to match the camera diff --git a/src/plugin.rs b/src/plugin.rs index 93250da..ae4831f 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -50,7 +50,7 @@ impl PluginGroup for BigSpaceDefaultPlugins { } #[cfg(feature = "camera")] { - group = group.add(BigSpaceCameraControllerPlugin); + group = group.add(BigSpaceCameraControllerPlugin::default()); } group } From f46401bf7d771fb67d6c47aeaa580716a51931f5 Mon Sep 17 00:00:00 2001 From: Aevyrie Roessler Date: Wed, 11 Mar 2026 21:59:12 -0700 Subject: [PATCH 2/2] self review --- src/camera.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/camera.rs b/src/camera.rs index c01a7ac..4e6317f 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -360,6 +360,12 @@ fn nearest_via_partitions( partitions: &PartitionLookup, cell_lookup: &CellLookup, ) -> Option<(Entity, f64)> { + // Bail early if no entities match the query (e.g., all have had visibility components + // stripped by a render culling system), avoiding an exhaustive partition scan. + if objects.is_empty() { + return None; + } + // Compute cell-space distance from camera to each partition AABB, sorted nearest-first. let cam = IVec3::new(cam_cell.x as i32, cam_cell.y as i32, cam_cell.z as i32); let mut sorted: Vec<(&PartitionId, &Partition, f64)> = partitions @@ -378,19 +384,14 @@ fn nearest_via_partitions( sorted.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(core::cmp::Ordering::Equal)); // Get the grid's cell edge length for converting cell distance to world distance. + // NOTE: this assumes all partitions share the same grid. Multi-grid scenes may need + // per-partition cell_edge values. let cell_edge = sorted .first() .and_then(|(_, p, _)| grids.get(p.grid()).ok()) .map(|g| g.cell_edge_length() as f64) .unwrap_or(1.0); - // If no entities match the query at all (e.g., all have had visibility components - // stripped by a render culling system), bail immediately to avoid an exhaustive - // O(partitions × cells) scan that can never find a result. - if objects.is_empty() { - return None; - } - let mut best: Option<(Entity, f64)> = None; // Track how many partitions we've scanned without finding any candidate. // If we scan several nearby partitions and find nothing queryable, further @@ -491,10 +492,11 @@ pub fn camera_controller( let [min, max] = controller.speed_bounds; let speed = speed.clamp(min, max); - let dt = time.delta_secs_f64().min(0.1); // Clamp to 100ms to prevent flying on perf dips - // Framerate-independent exponential smoothing. At 60fps (dt=1/60) the exponent - // is 1.0, reproducing the original per-frame behavior. At other framerates the - // decay scales correctly so the feel is consistent. + // Clamp to 100ms to prevent flying on perf dips. + let dt = time.delta_secs_f64().min(0.1); + // Framerate-independent exponential smoothing. At 60fps (dt=1/60) the exponent + // is 1.0, reproducing the original per-frame behavior. At other framerates the + // decay scales correctly so the feel is consistent. let lerp_translation = 1.0 - controller.smoothness.clamp(0.0, 0.999).powf(dt * 60.0); let lerp_rotation = 1.0 - controller