From 3a38ed2e582d2652a27381d636d9496bfdbf747d Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 17:04:02 -0600 Subject: [PATCH 1/3] test(eng2): expand renderer bench suite --- goud_engine/Cargo.toml | 4 + .../benches/baselines/criterion_baseline.json | 62 +++-- goud_engine/benches/helpers/scene3d.rs | 253 ++++++++++++++++-- .../benches/renderer3d_frame_benchmarks.rs | 61 ++++- .../renderer3d_real_wgpu_shadow_benchmarks.rs | 64 +++++ goud_engine/tests/renderer3d_frame_counts.rs | 36 +++ goud_engine/tests/spec.rs | 2 + .../spec/eng2_p0_03_bench_suite_expansion.rs | 78 ++++++ scripts/bench-gate.py | 9 +- 9 files changed, 529 insertions(+), 40 deletions(-) create mode 100644 goud_engine/benches/renderer3d_real_wgpu_shadow_benchmarks.rs create mode 100644 goud_engine/tests/spec.rs create mode 100644 goud_engine/tests/spec/eng2_p0_03_bench_suite_expansion.rs diff --git a/goud_engine/Cargo.toml b/goud_engine/Cargo.toml index c0b38fba..53de4629 100644 --- a/goud_engine/Cargo.toml +++ b/goud_engine/Cargo.toml @@ -152,6 +152,10 @@ harness = false name = "renderer3d_frame_benchmarks" harness = false +[[bench]] +name = "renderer3d_real_wgpu_shadow_benchmarks" +harness = false + [[bench]] name = "engine_tick_benchmarks" harness = false diff --git a/goud_engine/benches/baselines/criterion_baseline.json b/goud_engine/benches/baselines/criterion_baseline.json index 16b9938e..970efb6c 100644 --- a/goud_engine/benches/baselines/criterion_baseline.json +++ b/goud_engine/benches/baselines/criterion_baseline.json @@ -1,48 +1,68 @@ { "entries": { + "cull_scaling/visible_5k_total_100k": { + "mean_ns": 917174.9174338623, + "ratio": 1.9668823098740116 + }, + "cull_scaling/visible_5k_total_10k": { + "mean_ns": 350246.5993994201, + "ratio": 0.7511040995099229 + }, + "cull_scaling/visible_5k_total_30k": { + "mean_ns": 481366.94020676694, + "ratio": 1.0322917703635788 + }, "engine_tick/tick_10k": { - "mean_ns": 1290507.8221354913, + "mean_ns": 466308.99715225556, "ratio": 1.0 }, "engine_tick/tick_50k": { - "mean_ns": 7128460.0525, - "ratio": 5.523763537290344 + "mean_ns": 2479772.321044973, + "ratio": 5.31787363355397 }, "frame_scan/dynamic_10k": { - "mean_ns": 624087.3319795008, - "ratio": 0.48359825587634253 + "mean_ns": 623367.6022023809, + "ratio": 1.3368122983028008 }, "frame_scan/dynamic_30k": { - "mean_ns": 1945237.8166673414, - "ratio": 1.5073429105206226 + "mean_ns": 1920766.813579365, + "ratio": 4.119085896496677 }, "frame_scan/dynamic_moving_10k": { - "mean_ns": 663907.4022640231, - "ratio": 0.5144543805750904 + "mean_ns": 674277.5649485596, + "ratio": 1.445988752235891 }, "frame_scan/static_10k": { - "mean_ns": 31800.088089412693, - "ratio": 0.024641530678048057 + "mean_ns": 31411.31926250892, + "ratio": 0.0673615981127054 }, "frame_scan/static_30k": { - "mean_ns": 99591.88689086288, - "ratio": 0.0771726332708789 + "mean_ns": 95881.97488221213, + "ratio": 0.2056189682544459 }, "material_sort/off_30k": { - "mean_ns": 1516749.6166958064, - "ratio": 1.1753122225837713 + "mean_ns": 1538245.871517857, + "ratio": 3.2987694445355107 }, "material_sort/on_30k": { - "mean_ns": 1905435.7084748035, - "ratio": 1.4765007044449752 + "mean_ns": 1975946.3773650795, + "ratio": 4.237418513115047 + }, + "primitive_draw_calls/cube_10k": { + "mean_ns": 625983.7367566137, + "ratio": 1.3424226008494158 + }, + "primitive_draw_calls/plane_10k": { + "mean_ns": 625987.1186746032, + "ratio": 1.342429853375123 }, "shadow_record/casters_1400": { - "mean_ns": 155227.8765182749, - "ratio": 0.12028433602317012 + "mean_ns": 170131.63051175504, + "ratio": 0.3648474113747477 }, "shadow_record/casters_5k": { - "mean_ns": 554229.6660463082, - "ratio": 0.4294663360731798 + "mean_ns": 612422.163079365, + "ratio": 1.3133397957565072 } }, "reference_bench": "engine_tick/tick_10k" diff --git a/goud_engine/benches/helpers/scene3d.rs b/goud_engine/benches/helpers/scene3d.rs index eddea4ed..639a259a 100644 --- a/goud_engine/benches/helpers/scene3d.rs +++ b/goud_engine/benches/helpers/scene3d.rs @@ -19,11 +19,42 @@ #![allow(dead_code)] +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +use goud_engine::core::input_manager::InputManager; use goud_engine::libs::graphics::backend::null::NullBackend; use goud_engine::libs::graphics::backend::ShaderLanguage; use goud_engine::libs::graphics::renderer3d::{ Light, LightType, Material3D, PrimitiveCreateInfo, PrimitiveType, Render3DConfig, Renderer3D, }; +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +use goud_engine::libs::graphics::{ + backend::{ + native_backend::{NativeRenderBackend, SharedNativeRenderBackend}, + wgpu_backend::WgpuBackend, + FrameOps, + }, + renderer3d::Renderer3D as RealWgpuRenderer3D, +}; +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +use goud_engine::libs::platform::{winit_platform::WinitPlatform, PlatformBackend, WindowConfig}; +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +use std::time::Duration; /// Default number of distinct materials shared across a scene's primitives. pub const DEFAULT_MATERIALS: usize = 8; @@ -40,12 +71,16 @@ pub struct SceneOptions { pub object_count: usize, /// Number of distinct materials, assigned round-robin across the objects. pub material_count: usize, + /// Primitive geometry to create for every object. + pub primitive_type: PrimitiveType, /// Mark every primitive static (exercises the static-batch path). pub static_objects: bool, /// Enable the GPU shadow pre-pass (requires a Wgsl backend + directional light). pub shadows: bool, /// Enable material sorting of the visible draw list. pub material_sorting: bool, + /// Enable real frustum culling instead of forcing every object visible. + pub frustum_culling_enabled: bool, } impl SceneOptions { @@ -55,9 +90,11 @@ impl SceneOptions { Self { object_count, material_count: DEFAULT_MATERIALS, + primitive_type: PrimitiveType::Plane, static_objects: false, shadows: false, material_sorting: true, + frustum_culling_enabled: false, } } } @@ -65,21 +102,24 @@ impl SceneOptions { /// Builds a [`Renderer3D`] over a Wgsl [`NullBackend`], populated per `opts`. /// /// Determinism guarantees relied on by the companion `#[test]` assertions: -/// * Frustum culling is **disabled**, so every object is "visible" and the -/// per-object counts are pinned to `object_count`. +/// * Frustum culling is disabled by default, so every object is "visible" and +/// the per-object counts are pinned to `object_count`. /// * The grid/axis overlay is disabled so it does not add draw commands. /// * `material_count` distinct materials (ids `1..=material_count`) are assigned -/// round-robin; all primitives share the same unit-plane geometry. +/// round-robin; all primitives share the same geometry. /// * Transforms vary only by index. pub fn build_scene(opts: SceneOptions) -> Renderer3D { let backend = Box::new(NullBackend::with_shader_language(ShaderLanguage::Wgsl)); let mut renderer = Renderer3D::new(backend, WIDTH, HEIGHT).expect("Renderer3D::new over NullBackend"); + populate_scene(&mut renderer, opts); + renderer +} +fn populate_scene(renderer: &mut Renderer3D, opts: SceneOptions) { // Deterministic, benchmark-friendly configuration. let mut config = Render3DConfig::default(); - // Pin visible == object_count regardless of camera/positions. - config.frustum_culling.enabled = false; + config.frustum_culling.enabled = opts.frustum_culling_enabled; config.batching.material_sorting_enabled = opts.material_sorting; // Static batching is only meaningful when the objects are static. config.batching.static_batching_enabled = opts.static_objects; @@ -95,15 +135,8 @@ pub fn build_scene(opts: SceneOptions) -> Renderer3D { // draw counters clean. renderer.set_grid_enabled(false); - // Shared geometry: a unit plane, reused for every primitive. - let plane = PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 1.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }; + // Shared geometry: reused for every primitive in the scene. + let primitive = primitive_create_info(opts.primitive_type); // Create the shared material palette; `create_material` allocates ids // sequentially from 1, so the palette occupies `1..=material_count`. @@ -113,7 +146,7 @@ pub fn build_scene(opts: SceneOptions) -> Renderer3D { } for i in 0..opts.object_count { - let id = renderer.create_primitive(plane.clone()); + let id = renderer.create_primitive(primitive.clone()); let (x, y, z) = object_position(i, 0); renderer.set_object_position(id, x, y, z); let material_id = ((i % material_count) as u32) + 1; @@ -131,8 +164,6 @@ pub fn build_scene(opts: SceneOptions) -> Renderer3D { ..Light::default() }); } - - renderer } /// Deterministic world position for object `index` at frame `frame`. @@ -147,6 +178,45 @@ fn object_position(index: usize, frame: u64) -> (f32, f32, f32) { (x, 0.0, z) } +fn primitive_create_info(primitive_type: PrimitiveType) -> PrimitiveCreateInfo { + match primitive_type { + PrimitiveType::Plane => PrimitiveCreateInfo { + primitive_type, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }, + PrimitiveType::Cube => PrimitiveCreateInfo { + primitive_type, + width: 1.0, + height: 1.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }, + PrimitiveType::Sphere | PrimitiveType::Cylinder => PrimitiveCreateInfo { + primitive_type, + width: 1.0, + height: 1.0, + depth: 1.0, + segments: 8, + texture_id: 0, + }, + } +} + +fn cull_visible_position(index: usize) -> (f32, f32, f32) { + let x = ((index % 100) as f32) * 0.05 - 2.5; + let z = ((index / 100) as f32) * 0.05; + (x, 0.0, z) +} + +fn cull_hidden_position(index: usize) -> (f32, f32, f32) { + (10_000.0 + index as f32, 0.0, 0.0) +} + /// A static scene of `n` plane primitives (exercises the static-batch path). pub fn static_scene(n: usize) -> Renderer3D { build_scene(SceneOptions { @@ -160,6 +230,14 @@ pub fn dynamic_scene(n: usize) -> Renderer3D { build_scene(SceneOptions::new(n)) } +/// A dynamic scene with `primitive_type` for every object. +pub fn dynamic_primitive_scene(n: usize, primitive_type: PrimitiveType) -> Renderer3D { + build_scene(SceneOptions { + primitive_type, + ..SceneOptions::new(n) + }) +} + /// A dynamic scene with material sorting toggled explicitly. pub fn dynamic_scene_sorting(n: usize, material_sorting: bool) -> Renderer3D { build_scene(SceneOptions { @@ -176,6 +254,147 @@ pub fn shadow_scene(n: usize) -> Renderer3D { }) } +/// A culling-enabled scene with `visible_count` objects kept in front of the +/// default camera and the remainder placed far outside the frustum. +pub fn cull_scaling_scene(total_count: usize, visible_count: usize) -> Renderer3D { + let mut renderer = build_scene(SceneOptions { + frustum_culling_enabled: true, + ..SceneOptions::new(total_count) + }); + + for i in 0..total_count { + let id = (i as u32) + 1; + let (x, y, z) = if i < visible_count { + cull_visible_position(i) + } else { + cull_hidden_position(i - visible_count) + }; + renderer.set_object_position(id, x, y, z); + } + + renderer +} + +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +pub struct RealWgpuScene { + platform: WinitPlatform, + input: InputManager, + backend: SharedNativeRenderBackend, + pub renderer: RealWgpuRenderer3D, +} + +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +impl RealWgpuScene { + pub fn shadow_scene(n: usize) -> Result { + let platform = WinitPlatform::new(&WindowConfig { + width: WIDTH, + height: HEIGHT, + title: "renderer3d-real-wgpu-bench".to_string(), + vsync: false, + resizable: false, + ..WindowConfig::default() + }) + .map_err(|e| format!("WinitPlatform::new failed: {e}"))?; + + let backend = SharedNativeRenderBackend::new(NativeRenderBackend::Wgpu(Box::new( + WgpuBackend::new(platform.window().clone(), false) + .map_err(|e| format!("WgpuBackend::new failed: {e}"))?, + ))); + let renderer = Self::build_shadow_renderer(&backend, n)?; + + Ok(Self { + platform, + input: InputManager::new(), + backend, + renderer, + }) + } + + pub fn reset_shadow_scene(&mut self, n: usize) -> Result<(), String> { + self.renderer = Self::build_shadow_renderer(&self.backend, n)?; + Ok(()) + } + + fn build_shadow_renderer( + backend: &SharedNativeRenderBackend, + n: usize, + ) -> Result { + let mut renderer = RealWgpuRenderer3D::new(Box::new(backend.clone()), WIDTH, HEIGHT) + .map_err(|e| format!("Renderer3D::new failed: {e}"))?; + populate_scene( + &mut renderer, + SceneOptions { + shadows: true, + ..SceneOptions::new(n) + }, + ); + Ok(renderer) + } + + pub fn render_frame(&mut self) -> Result<(), String> { + for attempt in 0..5 { + self.platform.window().request_redraw(); + let _ = self.platform.poll_events(&mut self.input); + self.backend + .begin_frame() + .map_err(|e| format!("begin_frame failed: {e}"))?; + self.renderer.render(None); + + match self.backend.end_frame() { + Ok(()) => return Ok(()), + Err(err) if attempt < 4 && err.to_string().contains("No active frame") => { + std::thread::sleep(Duration::from_millis(16)); + } + Err(err) => return Err(format!("end_frame failed: {err}")), + } + } + + Err("end_frame failed: no active frame after retries".to_string()) + } +} + +pub fn real_wgpu_shadow_bench_env_var() -> &'static str { + "GOUD_BENCH_REAL_WGPU_SHADOW" +} + +pub fn real_wgpu_shadow_bench_skip_reason() -> Option { + if std::env::var_os(real_wgpu_shadow_bench_env_var()).is_none() { + return Some(format!( + "set {}=1 to opt into the real-GPU shadow bench", + real_wgpu_shadow_bench_env_var() + )); + } + if std::env::var_os("CI").is_some() { + return Some("real-GPU shadow bench is disabled in CI".to_string()); + } + + #[cfg(target_os = "linux")] + { + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { + return Some("real-GPU shadow bench requires a desktop display server".to_string()); + } + } + + #[cfg(not(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") + )))] + { + return Some("real-GPU shadow bench requires the native wgpu desktop backend".to_string()); + } + + None +} + /// Moves every object by a small deterministic per-frame delta, simulating a /// frame in which all objects changed transform. /// diff --git a/goud_engine/benches/renderer3d_frame_benchmarks.rs b/goud_engine/benches/renderer3d_frame_benchmarks.rs index d251638e..ca6aee8c 100644 --- a/goud_engine/benches/renderer3d_frame_benchmarks.rs +++ b/goud_engine/benches/renderer3d_frame_benchmarks.rs @@ -14,18 +14,28 @@ //! object's transform changes each frame. //! - `material_sort/{on,off}_30k` — cost of sorting the visible draw list by //! material vs. leaving it unsorted. +//! - `cull_scaling/visible_5k_total_*` — fixed 5k visible objects with total +//! scene size growing from 10k to 100k to keep #678 measurable. +//! - `primitive_draw_calls/{plane,cube}_10k` — per-frame draw-record cost for +//! the legacy `CreatePlane` / `CreateCube` primitive paths. //! - `shadow_record/casters_{1400,5k}` — cost of recording the GPU shadow //! pre-pass (requires the Wgsl NullBackend + a directional light). +//! The opt-in native-wgpu shadow bench lives in +//! `renderer3d_real_wgpu_shadow_benchmarks.rs` so the default suite stays +//! CPU-safe in CI and headless environments. //! //! The scenes are deterministic; the companion assertions that pin the exact //! draw-call / culled counts live in //! `goud_engine/tests/renderer3d_frame_counts.rs`. use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use goud_engine::libs::graphics::renderer3d::PrimitiveType; #[path = "helpers/scene3d.rs"] mod scene3d; +const CULL_VISIBLE_COUNT: usize = 5_000; +const CULL_SCALING_SIZES: [usize; 3] = [10_000, 30_000, 100_000]; const FRAME_SCAN_SIZES: [usize; 2] = [10_000, 30_000]; // ================================================================================================ @@ -91,7 +101,54 @@ fn bench_material_sort(c: &mut Criterion) { } // ================================================================================================ -// Group 3: Shadow pre-pass recording +// Group 3: Frustum-culling scaling (fixed visible set, growing total) +// ================================================================================================ + +fn bench_cull_scaling(c: &mut Criterion) { + let mut group = c.benchmark_group("cull_scaling"); + + for &total in &CULL_SCALING_SIZES { + group.throughput(Throughput::Elements(total as u64)); + group.bench_function( + format!( + "visible_{}k_total_{}", + CULL_VISIBLE_COUNT / 1000, + label(total) + ), + |b| { + let mut renderer = scene3d::cull_scaling_scene(total, CULL_VISIBLE_COUNT); + b.iter(|| renderer.render(black_box(None))); + }, + ); + } + + group.finish(); +} + +// ================================================================================================ +// Group 4: Legacy primitive draw-call scaling +// ================================================================================================ + +fn bench_primitive_draw_calls(c: &mut Criterion) { + let mut group = c.benchmark_group("primitive_draw_calls"); + const N: usize = 10_000; + group.throughput(Throughput::Elements(N as u64)); + + for (label, primitive_type) in [ + ("plane_10k", PrimitiveType::Plane), + ("cube_10k", PrimitiveType::Cube), + ] { + group.bench_function(label, |b| { + let mut renderer = scene3d::dynamic_primitive_scene(N, primitive_type); + b.iter(|| renderer.render(black_box(None))); + }); + } + + group.finish(); +} + +// ================================================================================================ +// Group 5: Shadow pre-pass recording // ================================================================================================ fn bench_shadow_record(c: &mut Criterion) { @@ -123,6 +180,8 @@ criterion_group!( renderer3d_frame_benches, bench_frame_scan, bench_material_sort, + bench_cull_scaling, + bench_primitive_draw_calls, bench_shadow_record, ); criterion_main!(renderer3d_frame_benches); diff --git a/goud_engine/benches/renderer3d_real_wgpu_shadow_benchmarks.rs b/goud_engine/benches/renderer3d_real_wgpu_shadow_benchmarks.rs new file mode 100644 index 00000000..b798341f --- /dev/null +++ b/goud_engine/benches/renderer3d_real_wgpu_shadow_benchmarks.rs @@ -0,0 +1,64 @@ +//! Optional real-wgpu shadow benchmark. +//! +//! This stays separate from `renderer3d_frame_benchmarks.rs` so the default +//! NullBackend suite remains CPU-only and CI-safe. The opt-in run creates a +//! small native window because hidden/occluded wgpu surfaces do not produce +//! presentable frames. Opt in with: +//! +//! `GOUD_BENCH_REAL_WGPU_SHADOW=1 cargo bench --bench renderer3d_real_wgpu_shadow_benchmarks` +//! +//! In software-rasterized environments, combine it with +//! `GOUD_WGPU_FORCE_FALLBACK=1` and the usual Xvfb/lavapipe setup. + +use criterion::{criterion_group, criterion_main, Criterion, Throughput}; + +#[path = "helpers/scene3d.rs"] +mod scene3d; + +#[cfg(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] +fn bench_real_wgpu_shadow_record(c: &mut Criterion) { + if let Some(reason) = scene3d::real_wgpu_shadow_bench_skip_reason() { + eprintln!("Skipping shadow_record_real_gpu bench: {reason}"); + return; + } + + let mut scene = scene3d::RealWgpuScene::shadow_scene(1_400) + .unwrap_or_else(|e| panic!("failed to create real-wgpu shadow scene: {e}")); + + let mut group = c.benchmark_group("shadow_record_real_gpu"); + for (label, n) in [("casters_1400", 1_400usize), ("casters_5k", 5_000usize)] { + group.throughput(Throughput::Elements(n as u64)); + group.bench_function(label, |b| { + scene + .reset_shadow_scene(n) + .unwrap_or_else(|e| panic!("failed to reset real-wgpu shadow scene: {e}")); + scene + .render_frame() + .unwrap_or_else(|e| panic!("failed to warm real-wgpu shadow scene: {e}")); + b.iter(|| { + scene + .render_frame() + .unwrap_or_else(|e| panic!("failed to render real-wgpu shadow frame: {e}")) + }); + }); + } + + group.finish(); +} + +#[cfg(not(all( + feature = "native", + feature = "wgpu-backend", + any(target_os = "linux", target_os = "macos", target_os = "windows") +)))] +fn bench_real_wgpu_shadow_record(_c: &mut Criterion) {} + +criterion_group!( + renderer3d_real_wgpu_shadow_benches, + bench_real_wgpu_shadow_record, +); +criterion_main!(renderer3d_real_wgpu_shadow_benches); diff --git a/goud_engine/tests/renderer3d_frame_counts.rs b/goud_engine/tests/renderer3d_frame_counts.rs index 157d470b..68f71571 100644 --- a/goud_engine/tests/renderer3d_frame_counts.rs +++ b/goud_engine/tests/renderer3d_frame_counts.rs @@ -73,6 +73,42 @@ fn material_sorting_does_not_change_draw_count() { assert_eq!(sorted.stats().draw_calls, unsorted.stats().draw_calls); } +/// The cull-scaling scene keeps exactly 5k objects inside the frustum while +/// parking the rest far outside it so bug #678 stays measurable as total scene +/// size grows. +#[test] +fn cull_scaling_scene_pins_visible_and_culled_counts() { + let total = 10_000usize; + let visible = 5_000usize; + let mut renderer = scene3d::cull_scaling_scene(total, visible); + renderer.render(None); + let stats = renderer.stats(); + + assert_eq!(stats.total_objects, total as u32); + assert_eq!(stats.visible_objects, visible as u32); + assert_eq!(stats.culled_objects, (total - visible) as u32); + assert_eq!(stats.draw_calls, visible as u32); +} + +/// The primitive draw-call bench scenes pin one draw per visible object for +/// both legacy primitive constructors. +#[test] +fn primitive_draw_scenes_pin_plane_and_cube_counts() { + for primitive in [ + goud_engine::libs::graphics::renderer3d::PrimitiveType::Plane, + goud_engine::libs::graphics::renderer3d::PrimitiveType::Cube, + ] { + let mut renderer = scene3d::dynamic_primitive_scene(1_000, primitive); + renderer.render(None); + let stats = renderer.stats(); + + assert_eq!(stats.total_objects, 1_000); + assert_eq!(stats.visible_objects, 1_000); + assert_eq!(stats.culled_objects, 0); + assert_eq!(stats.draw_calls, 1_000); + } +} + /// The shadow scene records the GPU shadow pre-pass (Wgsl backend + directional /// light) and still draws every object in the main pass. Shadow-pass draw /// commands are depth-only and are not reflected in `Renderer3DStats`; the main diff --git a/goud_engine/tests/spec.rs b/goud_engine/tests/spec.rs new file mode 100644 index 00000000..f0bd2534 --- /dev/null +++ b/goud_engine/tests/spec.rs @@ -0,0 +1,2 @@ +#[path = "spec/eng2_p0_03_bench_suite_expansion.rs"] +mod eng2_p0_03_bench_suite_expansion; diff --git a/goud_engine/tests/spec/eng2_p0_03_bench_suite_expansion.rs b/goud_engine/tests/spec/eng2_p0_03_bench_suite_expansion.rs new file mode 100644 index 00000000..a228849a --- /dev/null +++ b/goud_engine/tests/spec/eng2_p0_03_bench_suite_expansion.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use goud_engine::libs::graphics::renderer3d::PrimitiveType; + +#[path = "../../benches/helpers/scene3d.rs"] +mod scene3d; + +#[test] +fn eng2_p0_03_registers_cull_primitive_and_real_gpu_shadow_benches() { + let frame_bench_source = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("benches/renderer3d_frame_benchmarks.rs"), + ) + .expect("renderer3d frame bench source should exist"); + let real_gpu_bench_source = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("benches/renderer3d_real_wgpu_shadow_benchmarks.rs"), + ) + .expect("real-wgpu shadow bench source should exist"); + let cargo_toml = + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("Cargo.toml should exist"); + let bench_gate_source = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("goud_engine should live under the repository root") + .join("scripts/bench-gate.py"), + ) + .expect("bench-gate.py should exist"); + + assert!(frame_bench_source.contains("benchmark_group(\"cull_scaling\")")); + assert!(frame_bench_source.contains("benchmark_group(\"primitive_draw_calls\")")); + assert!( + bench_gate_source.contains("\"cull_scaling\""), + "expected bench-gate.py to track the cull_scaling group by default" + ); + assert!( + bench_gate_source.contains("\"primitive_draw_calls\""), + "expected bench-gate.py to track the primitive_draw_calls group by default" + ); + assert!( + cargo_toml.contains("name = \"renderer3d_real_wgpu_shadow_benchmarks\""), + "expected Cargo.toml to register the opt-in real-GPU shadow bench target" + ); + assert!( + real_gpu_bench_source.contains("benchmark_group(\"shadow_record_real_gpu\")"), + "expected an opt-in real-GPU shadow bench group in renderer3d_real_wgpu_shadow_benchmarks.rs" + ); + assert!( + real_gpu_bench_source.contains("GOUD_BENCH_REAL_WGPU_SHADOW"), + "expected the real-GPU shadow bench to be guarded by an explicit opt-in env var" + ); +} + +#[test] +fn eng2_p0_03_cull_scaling_scene_keeps_visible_count_fixed() { + let mut renderer = scene3d::cull_scaling_scene(10_000, 5_000); + renderer.render(None); + let stats = renderer.stats(); + + assert_eq!(stats.total_objects, 10_000); + assert_eq!(stats.visible_objects, 5_000); + assert_eq!(stats.culled_objects, 5_000); + assert_eq!(stats.draw_calls, 5_000); +} + +#[test] +fn eng2_p0_03_plane_and_cube_scenes_pin_draw_calls() { + for primitive in [PrimitiveType::Plane, PrimitiveType::Cube] { + let mut renderer = scene3d::dynamic_primitive_scene(512, primitive); + renderer.render(None); + let stats = renderer.stats(); + + assert_eq!(stats.total_objects, 512); + assert_eq!(stats.visible_objects, 512); + assert_eq!(stats.culled_objects, 0); + assert_eq!(stats.draw_calls, 512); + } +} diff --git a/scripts/bench-gate.py b/scripts/bench-gate.py index 666ee00c..21d43923 100644 --- a/scripts/bench-gate.py +++ b/scripts/bench-gate.py @@ -70,7 +70,14 @@ # Benchmark groups this gate tracks. A "bench name" is "/". # Groups are also read from an existing baseline so the two stay in sync. -DEFAULT_GROUPS = ["engine_tick", "frame_scan", "material_sort", "shadow_record"] +DEFAULT_GROUPS = [ + "engine_tick", + "frame_scan", + "material_sort", + "cull_scaling", + "primitive_draw_calls", + "shadow_record", +] DEFAULT_THRESHOLD = 0.10 # 10% QUICK_THRESHOLD = 0.15 # 15% From b52c462b5a3c7aef003ef0ecf12791900f0f6879 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 17:10:14 -0600 Subject: [PATCH 2/3] chore(security): refresh audited transitive deps --- Cargo.lock | 8 ++++---- deny.toml | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fcc335c..19c52a30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,9 +1059,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -2627,9 +2627,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] diff --git a/deny.toml b/deny.toml index 10f1cf37..10ae2e2b 100644 --- a/deny.toml +++ b/deny.toml @@ -77,8 +77,6 @@ ignore = [ { crate = "core2@0.4.0", reason = "yanked upstream; transitive via image→ravif→rav1e→bitstream-io. No replacement version published." }, # Triaged advisories tracked for upstream upgrade in #704. Accepted risk with # rationale below; revisit each when a patched upstream release is available. - { id = "RUSTSEC-2026-0186", reason = "memmap2 offset/len validation; transitive via asset loaders, offsets come from internal callers not untrusted input. Pending upstream patch — tracked #704." }, - { id = "RUSTSEC-2026-0189", reason = "rmcp Streamable-HTTP DNS rebinding affects the localhost-only MCP debug server (dev tooling, not shipped in the engine). Upgrade rmcp when 1.4.0+ integrates — tracked #704." }, { id = "RUSTSEC-2026-0190", reason = "anyhow downcast_mut-after-context UB; pending patched anyhow release — tracked #704." }, { id = "RUSTSEC-2026-0192", reason = "ttf-parser unmaintained (not a vulnerability); transitive via rustybuzz text shaping. No maintained replacement adopted upstream — tracked #704." }, { id = "RUSTSEC-2026-0194", reason = "quick-xml quadratic attribute check; transitive XML parsing of trusted local asset files, not untrusted network input. Pending upstream patch — tracked #704." }, From 462e2ed1fbea92a4426a8ad7dd69b09e97e0d3df Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Tue, 7 Jul 2026 17:16:29 -0600 Subject: [PATCH 3/3] fix(ci): satisfy bench doc lint --- goud_engine/benches/renderer3d_frame_benchmarks.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/goud_engine/benches/renderer3d_frame_benchmarks.rs b/goud_engine/benches/renderer3d_frame_benchmarks.rs index ca6aee8c..600dbff8 100644 --- a/goud_engine/benches/renderer3d_frame_benchmarks.rs +++ b/goud_engine/benches/renderer3d_frame_benchmarks.rs @@ -20,6 +20,7 @@ //! the legacy `CreatePlane` / `CreateCube` primitive paths. //! - `shadow_record/casters_{1400,5k}` — cost of recording the GPU shadow //! pre-pass (requires the Wgsl NullBackend + a directional light). +//! //! The opt-in native-wgpu shadow bench lives in //! `renderer3d_real_wgpu_shadow_benchmarks.rs` so the default suite stays //! CPU-safe in CI and headless environments.