Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions goud_engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ harness = false
name = "spatial_grid_benchmarks"
harness = false

[[bench]]
name = "scene_culling_benchmarks"
harness = false

[[test]]
name = "native_main_thread"
path = "tests/native_main_thread.rs"
Expand Down
103 changes: 103 additions & 0 deletions goud_engine/benches/scene_culling_benchmarks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Scene-Culling Scaling Benchmarks (#678)
//!
//! Drives the [`Renderer3D`] frustum-cull path with 1k / 10k / 50k registered
//! 3D objects and asserts (via the per-frame `Renderer3DStats`) that the
//! candidate set fed into the frustum sphere test grows sub-linearly when the
//! spatial index is enabled.
//!
//! Run with: `cargo bench --bench scene_culling_benchmarks`
//!
//! The benchmark emits the spatial-index candidate count to stdout each
//! sample so before/after comparisons are easy to eyeball without a baseline
//! file.

use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use goud_engine::libs::graphics::backend::null::NullBackend;
use goud_engine::libs::graphics::renderer3d::config::SpatialIndexConfig;
use goud_engine::libs::graphics::renderer3d::{PrimitiveCreateInfo, PrimitiveType, Renderer3D};

fn make_renderer() -> Renderer3D {
Renderer3D::new(Box::new(NullBackend::new()), 800, 600)
.expect("renderer should initialize against the null backend")
}

fn populate_grid(renderer: &mut Renderer3D, side: i32, spacing: f32) {
for x in 0..side {
for z in 0..side {
let id = renderer.create_primitive(PrimitiveCreateInfo {
primitive_type: PrimitiveType::Cube,
width: 1.0,
height: 1.0,
depth: 1.0,
segments: 1,
texture_id: 0,
});
assert_ne!(id, 0);
renderer.set_object_position(id, x as f32 * spacing, 0.0, z as f32 * spacing);
}
}
}

fn aim_camera_at_origin(renderer: &mut Renderer3D) {
renderer.set_camera_position(5.0, 5.0, 5.0);
renderer.set_camera_rotation(-30.0, -135.0, 0.0);
}

fn bench_render_with_spatial_index(c: &mut Criterion) {
let mut group = c.benchmark_group("scene_culling_spatial_index_on");

// 1k / 10k / 30k / 50k matches the scaling table in #678 so the bench
// output lines up with the issue's three named checkpoints.
for &(side, label) in &[
(32i32, 1_024usize),
(100, 10_000),
(174, 30_276),
(224, 50_176),
] {
group.bench_with_input(BenchmarkId::from_parameter(label), &side, |b, &side| {
let mut renderer = make_renderer();
populate_grid(&mut renderer, side, 4.0);
aim_camera_at_origin(&mut renderer);
b.iter(|| {
renderer.render(None);
black_box(renderer.stats().spatial_index_candidates);
});
});
}
group.finish();
}

fn bench_render_with_linear_scan(c: &mut Criterion) {
let mut group = c.benchmark_group("scene_culling_spatial_index_off");

for &(side, label) in &[
(32i32, 1_024usize),
(100, 10_000),
(174, 30_276),
(224, 50_176),
] {
group.bench_with_input(BenchmarkId::from_parameter(label), &side, |b, &side| {
let mut renderer = make_renderer();
let mut config = renderer.render_config().clone();
config.spatial_index = SpatialIndexConfig {
enabled: false,
cell_size: config.spatial_index.cell_size,
};
renderer.set_render_config(config);
populate_grid(&mut renderer, side, 4.0);
aim_camera_at_origin(&mut renderer);
b.iter(|| {
renderer.render(None);
black_box(renderer.stats().spatial_index_candidates);
});
});
}
group.finish();
}

criterion_group!(
benches,
bench_render_with_spatial_index,
bench_render_with_linear_scan
);
criterion_main!(benches);
39 changes: 39 additions & 0 deletions goud_engine/src/libs/graphics/renderer3d/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
pub struct Render3DConfig {
/// Frustum culling settings.
pub frustum_culling: FrustumCullingConfig,
/// Spatial index settings used to accelerate frustum culling.
pub spatial_index: SpatialIndexConfig,
/// Draw call batching and instancing settings.
pub batching: BatchingConfig,
/// Skeletal animation skinning settings.
Expand All @@ -19,6 +21,35 @@ pub struct Render3DConfig {
pub default_material_color: [f32; 4],
}

/// Spatial index configuration. The renderer uses a sparse uniform grid to
/// shrink the per-frame frustum-cull candidate set from "every scene object"
/// down to "objects whose grid cell touches the frustum AABB".
#[derive(Debug, Clone)]
pub struct SpatialIndexConfig {
/// Whether the spatial index is consulted during frustum culling
/// (default: `true`). When `false` the renderer falls back to a linear
/// scan over every registered object — same behavior as before #678.
pub enabled: bool,
/// Grid cell size in world units (default: `32.0`). Cells smaller than
/// `0.5` are clamped up to keep grid coordinates finite. Tune this for
/// scenes where most objects fit inside one cell.
///
/// Note: changing this at runtime forces a rebuild of the spatial index
/// over every registered scene object. That is cheap for small scenes
/// but is `O(n)` work over the whole `objects` registry, so prefer to
/// set it once at startup for large scenes.
pub cell_size: f32,
}

impl Default for SpatialIndexConfig {
fn default() -> Self {
Self {
enabled: true,
cell_size: 32.0,
}
}
}

/// Frustum culling configuration.
#[derive(Debug, Clone)]
pub struct FrustumCullingConfig {
Expand Down Expand Up @@ -148,6 +179,7 @@ impl Default for Render3DConfig {
fn default() -> Self {
Self {
frustum_culling: FrustumCullingConfig::default(),
spatial_index: SpatialIndexConfig::default(),
batching: BatchingConfig::default(),
skinning: SkinningConfig::default(),
shadows: ShadowConfig::default(),
Expand Down Expand Up @@ -217,6 +249,13 @@ mod tests {
assert_eq!(config.default_material_color, [0.8, 0.8, 0.8, 1.0]);
}

#[test]
fn test_spatial_index_config_default() {
let si = SpatialIndexConfig::default();
assert!(si.enabled);
assert!((si.cell_size - 32.0).abs() < f32::EPSILON);
}

#[test]
fn test_frustum_culling_config_default() {
let fc = FrustumCullingConfig::default();
Expand Down
13 changes: 13 additions & 0 deletions goud_engine/src/libs/graphics/renderer3d/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,20 @@ pub struct Renderer3D {
/// Object IDs that were actually included in the static batch (not overflowed).
/// Used to distinguish batched objects from overflow objects that need individual draws.
pub(in crate::libs::graphics::renderer3d) static_batched_ids: FxHashSet<u32>,
/// Sparse uniform-grid spatial index over scene objects, used to shrink
/// the per-frame frustum-cull candidate set. Maintained incrementally
/// alongside `objects` so culling does not have to scan the full registry.
pub(in crate::libs::graphics::renderer3d) spatial_index: super::spatial_index::SpatialIndex,
/// Reusable scratch buffer of cull candidate IDs gathered from the
/// spatial index each frame; reset at the start of every render.
pub(in crate::libs::graphics::renderer3d) scratch_cull_candidates: Vec<u32>,
}

// StaticBatchGroup is defined in core_static_batch.rs
pub(in crate::libs::graphics::renderer3d) use super::core_static_batch::StaticBatchGroup;

mod spatial;

#[allow(missing_docs)]
impl Renderer3D {
#[rustfmt::skip]
Expand Down Expand Up @@ -364,6 +373,10 @@ impl Renderer3D {
static_batch_groups: Vec::new(),
static_batch_vertex_count: 0,
static_batched_ids: FxHashSet::default(),
spatial_index: super::spatial_index::SpatialIndex::new(
super::spatial_index::DEFAULT_CELL_SIZE,
),
scratch_cull_candidates: Vec::with_capacity(1024),
})
}
}
22 changes: 18 additions & 4 deletions goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,22 @@ impl Renderer3D {
let ok = self.mutate_object(id, |obj| {
obj.position = Vector3::new(x, y, z);
});
if ok && self.objects.get(&id).is_some_and(|o| o.is_static) {
self.static_batch_dirty = true;
if ok {
if self.objects.get(&id).is_some_and(|o| o.is_static) {
self.static_batch_dirty = true;
}
self.spatial_index_refresh(id);
}
ok
}
pub fn set_object_rotation(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool {
// Rotation does not change the bounding-sphere AABB, so no spatial-index
// refresh is needed here.
//
// INVARIANT: only safe while the per-object bound is a sphere
// (uniform radius around `bounds.center`). If `Object3D::bounds` is
// ever switched to an axis-aligned or oriented box, this skip must
// be removed and `spatial_index_refresh` called below.
let ok = self.mutate_object(id, |obj| obj.rotation = Vector3::new(x, y, z));
if ok && self.objects.get(&id).is_some_and(|o| o.is_static) {
self.static_batch_dirty = true;
Expand All @@ -27,8 +37,11 @@ impl Renderer3D {
let ok = self.mutate_object(id, |obj| {
obj.scale = Vector3::new(x, y, z);
});
if ok && self.objects.get(&id).is_some_and(|o| o.is_static) {
self.static_batch_dirty = true;
if ok {
if self.objects.get(&id).is_some_and(|o| o.is_static) {
self.static_batch_dirty = true;
}
self.spatial_index_refresh(id);
}
ok
}
Expand Down Expand Up @@ -61,6 +74,7 @@ impl Renderer3D {
if obj.is_static {
self.static_batch_dirty = true;
}
self.spatial_index_remove(id);
self.backend.destroy_buffer(obj.buffer);
true
} else {
Expand Down
34 changes: 34 additions & 0 deletions goud_engine/src/libs/graphics/renderer3d/core/spatial.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! Spatial-index helpers for [`Renderer3D`].
//!
//! These methods keep the renderer's `spatial_index` in sync with the
//! `objects` map and translate per-object [`super::super::types::Object3D`]
//! state into the world-space AABB the index expects. They are deliberately
//! tiny so the call sites in `core_primitives.rs`, `core_models/mod.rs`,
//! `core_model_instances.rs`, and `object_transforms.rs` only have to add a
//! one-line hook after each insert/update/remove.

use super::super::spatial_index::world_aabb_from_sphere;
use super::Renderer3D;

impl Renderer3D {
/// Refresh `id` in the spatial index by reading the current state of the
/// object out of `self.objects`. No-op when the ID is not registered.
pub(in crate::libs::graphics::renderer3d) fn spatial_index_refresh(&mut self, id: u32) {
let Some(obj) = self.objects.get(&id) else {
return;
};
let (min, max) = world_aabb_from_sphere(
obj.position,
obj.bounds.center,
obj.bounds.radius,
obj.scale,
);
self.spatial_index.insert(id, min, max);
}

/// Drop `id` from the spatial index. Returns `true` when the entry
/// existed.
pub(in crate::libs::graphics::renderer3d) fn spatial_index_remove(&mut self, id: u32) -> bool {
self.spatial_index.remove(id)
}
}
53 changes: 37 additions & 16 deletions goud_engine/src/libs/graphics/renderer3d/core_model_instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,40 @@ impl Renderer3D {
///
/// Returns the instance handle, or `None` if the source model does not exist.
pub fn instantiate_model(&mut self, source_id: u32) -> Option<u32> {
let source = self.models.get(&source_id)?;
let has_skeleton = source.skeleton.is_some();

// Pre-collect bind-pose data we may need for creating instance buffers.
let bind_poses: Vec<Vec<f32>> = if has_skeleton {
source.bind_pose_vertices.clone()
} else {
Vec::new()
// Copy out everything we need from `source` up front so we do not have
// to keep a long-lived `&self.models` borrow across the loop body
// (which mutates `self.objects`, `self.spatial_index`, etc.).
struct SourceSnapshot {
has_skeleton: bool,
is_skinned: bool,
skeleton_bone_count: Option<usize>,
mesh_object_ids: Vec<u32>,
mesh_material_ids: Vec<u32>,
bind_poses: Vec<Vec<f32>>,
}
let snapshot = {
let source = self.models.get(&source_id)?;
let has_skeleton = source.skeleton.is_some();
SourceSnapshot {
has_skeleton,
is_skinned: source.is_skinned,
skeleton_bone_count: source.skeleton.as_ref().map(|s| s.bones.len()),
mesh_object_ids: source.mesh_object_ids.clone(),
mesh_material_ids: source.mesh_material_ids.clone(),
bind_poses: if has_skeleton {
source.bind_pose_vertices.clone()
} else {
Vec::new()
},
}
};
let has_skeleton = snapshot.has_skeleton;
let bind_poses = snapshot.bind_poses;

let mut instance_object_ids = Vec::with_capacity(source.mesh_object_ids.len());
let mut instance_material_ids = Vec::with_capacity(source.mesh_material_ids.len());
let mut instance_object_ids = Vec::with_capacity(snapshot.mesh_object_ids.len());
let mut instance_material_ids = Vec::with_capacity(snapshot.mesh_material_ids.len());

for (i, &src_obj_id) in source.mesh_object_ids.iter().enumerate() {
for (i, &src_obj_id) in snapshot.mesh_object_ids.iter().enumerate() {
let (src_buffer, vertex_count, texture_id, src_bounds, src_vertices) =
match self.objects.get(&src_obj_id) {
Some(o) => (
Expand All @@ -43,7 +63,7 @@ impl Renderer3D {

// CPU-skinned instances need their own dynamic buffer for per-frame re-upload.
// GPU-skinned instances share the source buffer (GPU deforms via shader).
let buffer = if has_skeleton && !source.is_skinned {
let buffer = if has_skeleton && !snapshot.is_skinned {
if let Some(bp) = bind_poses.get(i) {
use crate::libs::graphics::backend::{BufferType, BufferUsage};
match self.backend.create_buffer(
Expand Down Expand Up @@ -83,9 +103,10 @@ impl Renderer3D {
is_static: false,
},
);
self.spatial_index_refresh(new_obj_id);

// Clone the material (cheap -- just a few floats).
let src_mat_id = source
let src_mat_id = snapshot
.mesh_material_ids
.get(i)
.and_then(|id| self.materials.get(id));
Expand Down Expand Up @@ -114,8 +135,8 @@ impl Renderer3D {
}

// Create animation player if the source model has a skeleton.
if let Some(ref skeleton) = source.skeleton {
let player = AnimationPlayer::new(skeleton.bones.len());
if let Some(bone_count) = snapshot.skeleton_bone_count {
let player = AnimationPlayer::new(bone_count);
self.animation_players.insert(instance_id, player);
}

Expand All @@ -129,7 +150,7 @@ impl Renderer3D {
);

// Update skinned object ID set if the source model is skinned.
if source.is_skinned {
if snapshot.is_skinned {
self.skinned_object_ids
.extend(instance_object_ids.iter().copied());
}
Expand Down
Loading
Loading