From 13906c57d865a18a5c812779d3595b6e61280735 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 4 Apr 2026 11:03:15 -0600 Subject: [PATCH 1/3] perf(renderer3d): optimize 3D rendering pipeline for 44% FPS improvement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimize the renderer3d hot paths to improve character_sandbox performance from ~40 FPS to ~59 FPS with 280 animated agents, shadows enabled. Key changes: - Replace O(total_vertices) shadow bounds computation with O(num_objects) bounding sphere approach — reduces shadow_build from 11.4ms to 0.1ms - Eliminate per-frame heap allocations: bone matrix clones, uniform struct clones, lights Vec allocation, animation player ID collection - Migrate renderer3d HashMaps to FxHashMap (rustc-hash) for faster integer key lookups in hot paths - Pre-cache material sort keys and visible object draw data to reduce HashMap lookups per draw call - Add #[inline] to hot animation math, unroll mat4_mul for auto-vectorization, optimize quaternion normalization (reciprocal multiply) - Cache skinned mesh model matrices with dirty flag - Reuse wgpu pipeline key Vecs across frames via scratch buffers - Expose FramePhaseTimings to C# SDK for per-phase profiling - Add animation timing instrumentation (anim_eval, bone_pack, bone_upload) Regression fixes included: - Fix memory leak: revert uniform buffer growth from *2 to next_power_of_two() and deduplicate per-shader buffer recreation - Fix frustum culling: remove broken cached bounds (initialized in local space), revert to correct inline world-space computation - Add regression tests for both buffer growth stability and culling correctness Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + codegen/generated/goud_engine.h | 12 + codegen/goud_sdk.schema.json | 37 +++ examples/csharp/character_sandbox/Program.cs | 28 +- goud_engine/Cargo.toml | 1 + goud_engine/src/core/mod.rs | 5 + goud_engine/src/ffi/renderer/metrics.rs | 3 + goud_engine/src/ffi/types.rs | 6 + goud_engine/src/jni/generated.rs | 71 +++++ .../graphics/backend/wgpu_backend/frame.rs | 21 +- .../graphics/backend/wgpu_backend/init.rs | 2 + .../libs/graphics/backend/wgpu_backend/mod.rs | 6 + .../graphics/backend/wgpu_backend/sdl_init.rs | 2 + .../backend/wgpu_backend/shadow_pass.rs | 22 +- .../backend/wgpu_backend/switch_init.rs | 2 + .../graphics/backend/wgpu_backend/uniforms.rs | 51 +++- .../backend/wgpu_backend/xbox_init.rs | 2 + goud_engine/src/libs/graphics/frame_timing.rs | 15 + .../libs/graphics/renderer3d/animation/mod.rs | 28 +- .../graphics/renderer3d/animation_sampling.rs | 29 +- .../src/libs/graphics/renderer3d/core/mod.rs | 88 +++--- .../libs/graphics/renderer3d/core_config.rs | 6 +- .../renderer3d/core_model_animation/mod.rs | 18 +- .../renderer3d/core_models/lifecycle.rs | 22 +- .../libs/graphics/renderer3d/core_skinned.rs | 7 + .../src/libs/graphics/renderer3d/frustum.rs | 71 +++++ .../libs/graphics/renderer3d/render/mod.rs | 277 +++++++++++------- .../renderer3d/render/shadow_render.rs | 66 ++++- .../graphics/renderer3d/render_helpers.rs | 2 +- .../renderer3d/render_instanced_skinned.rs | 56 ++-- .../src/libs/graphics/renderer3d/scene.rs | 14 +- .../src/libs/graphics/renderer3d/shaders.rs | 2 +- .../src/libs/graphics/renderer3d/shadow.rs | 114 +++++-- .../libs/graphics/renderer3d/skinned_mesh.rs | 5 + .../src/libs/graphics/renderer3d/types.rs | 8 +- .../internal/FramePhaseTimings.java | 34 +++ .../goudengine/internal/GoudGameNative.java | 1 + sdks/csharp/generated/GoudGame.g.cs | 8 + .../generated/Math/FramePhaseTimings.g.cs | 12 +- sdks/csharp/generated/NativeMethods.g.cs | 4 + sdks/csharp/include/goud_engine.h | 12 + sdks/go/goud/game.go | 5 + sdks/go/goud/types.go | 10 +- sdks/go/include/goud_engine.h | 12 + .../internal/FramePhaseTimings.java | 34 +++ .../goudengine/internal/GoudGameNative.java | 1 + .../kotlin/com/goudengine/core/GoudGame.kt | 5 + .../com/goudengine/types/FramePhaseTimings.kt | 2 +- sdks/python/goudengine/generated/_ffi.py | 6 +- sdks/python/goudengine/generated/_game.py | 6 + sdks/python/goudengine/generated/_types.py | 8 +- sdks/python/goudengine/include/goud_engine.h | 12 + .../Sources/CGoudEngine/include/goud_engine.h | 12 + .../GoudEngine/generated/ValueTypes.g.swift | 24 +- sdks/typescript/src/generated/node/index.g.ts | 5 + .../src/generated/types/engine.g.ts | 2 + 56 files changed, 1053 insertions(+), 262 deletions(-) create mode 100644 goud_engine/tests/jni/java/com/goudengine/internal/FramePhaseTimings.java create mode 100644 sdks/kotlin/src/main/java/com/goudengine/internal/FramePhaseTimings.java diff --git a/Cargo.lock b/Cargo.lock index 5dcd5d04f..fe91e8960 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,6 +1870,7 @@ dependencies = [ "rayon", "rcgen", "rodio", + "rustc-hash 2.1.1", "rustls", "rustybuzz", "sdl2", diff --git a/codegen/generated/goud_engine.h b/codegen/generated/goud_engine.h index f5392336d..e1a5ff840 100644 --- a/codegen/generated/goud_engine.h +++ b/codegen/generated/goud_engine.h @@ -1293,6 +1293,18 @@ typedef struct FfiFramePhaseTimings { * Surface present / vsync wait time (us). */ uint64_t surface_present_us; + /** + * Animation evaluation time (us). + */ + uint64_t anim_eval_us; + /** + * Bone matrix packing time (us). + */ + uint64_t bone_pack_us; + /** + * Bone matrix GPU upload time (us). + */ + uint64_t bone_upload_us; } FfiFramePhaseTimings; /** diff --git a/codegen/goud_sdk.schema.json b/codegen/goud_sdk.schema.json index 0871dca9f..d17814a0c 100644 --- a/codegen/goud_sdk.schema.json +++ b/codegen/goud_sdk.schema.json @@ -2329,6 +2329,11 @@ "type": "u64", "doc": "Time to acquire the next surface texture (us)" }, + { + "name": "shadowPassUs", + "type": "u64", + "doc": "GPU shadow depth pass recording and execution time (us)" + }, { "name": "shadowBuildUs", "type": "u64", @@ -2363,6 +2368,21 @@ "name": "surfacePresentUs", "type": "u64", "doc": "Surface present / vsync wait time (us)" + }, + { + "name": "animEvalUs", + "type": "u64", + "doc": "Animation evaluation time (us)" + }, + { + "name": "bonePackUs", + "type": "u64", + "doc": "Bone matrix packing time (us)" + }, + { + "name": "boneUploadUs", + "type": "u64", + "doc": "Bone matrix GPU upload time (us)" } ] }, @@ -5947,6 +5967,12 @@ "params": [], "returns": "i32" }, + { + "name": "getFramePhaseTimings", + "doc": "Returns per-frame phase timings for performance diagnosis (all values in microseconds)", + "params": [], + "returns": "FramePhaseTimings" + }, { "name": "render3D", "doc": "Renders all 3D objects", @@ -13309,6 +13335,17 @@ "getAnimationEvaluationSavedCount": { "ffi": "goud_renderer3d_get_animation_evaluation_saved_count" }, + "getFramePhaseTimings": { + "ffi": "goud_renderer_get_frame_phase_timings", + "no_context": true, + "out_params": [ + { + "name": "out_timings", + "type": "FramePhaseTimings" + } + ], + "returns_struct": "FramePhaseTimings" + }, "render3D": { "ffi": "goud_renderer3d_render" }, diff --git a/examples/csharp/character_sandbox/Program.cs b/examples/csharp/character_sandbox/Program.cs index 122bdee0a..159fbc891 100644 --- a/examples/csharp/character_sandbox/Program.cs +++ b/examples/csharp/character_sandbox/Program.cs @@ -107,12 +107,16 @@ static void Main(string[] args) int animEval = game.GetAnimationEvaluationCount(); int animSaved = game.GetAnimationEvaluationSavedCount(); int boneUploads = game.GetBoneMatrixUploadCount(); + var pt = game.GetFramePhaseTimings(); Console.Write( $"\rFPS: {fpsFrames / fpsTimer:F1} Agents: {crowd.Count} " + $"Draws: {draws} Visible: {visible}/{visible + culled} " + $"Instanced: {instanced} Instances: {activeInst} " + - $"AnimEval: {animEval} Saved: {animSaved} Bones: {boneUploads} " + $"AnimEval: {animEval} Saved: {animSaved} Bones: {boneUploads} " + + $"[anim={pt.AnimEvalUs}us bone={pt.BonePackUs}+{pt.BoneUploadUs}us " + + $"shadow={pt.ShadowPassUs}us render={pt.RenderPassUs}us " + + $"present={pt.SurfacePresentUs}us] " ); fpsFrames = 0; @@ -206,6 +210,7 @@ static void RunProfile(GoudGame game, CrowdSystem crowd, GameConfig config) int instanced = game.GetInstancedDrawCalls(); int animEval = game.GetAnimationEvaluationCount(); int animSaved = game.GetAnimationEvaluationSavedCount(); + var pt = game.GetFramePhaseTimings(); // Console summary Console.WriteLine(); @@ -217,6 +222,11 @@ static void RunProfile(GoudGame game, CrowdSystem crowd, GameConfig config) Console.WriteLine($" Frame Time: Min {minFrame:F2}ms / Max {maxFrame:F2}ms / Avg {avgFrame:F2}ms"); Console.WriteLine($" Draws: {draws} Visible: {visible}/{visible + culled} Instanced: {instanced}"); Console.WriteLine($" AnimEval: {animEval} Saved: {animSaved} Agents: {crowd.Count}"); + Console.WriteLine($" Phase Timings (last frame, us):"); + Console.WriteLine($" surface_acquire={pt.SurfaceAcquireUs} shadow_pass={pt.ShadowPassUs} shadow_build={pt.ShadowBuildUs}"); + Console.WriteLine($" render3d_scene={pt.Render3dSceneUs} uniform_upload={pt.UniformUploadUs} render_pass={pt.RenderPassUs}"); + Console.WriteLine($" gpu_submit={pt.GpuSubmitUs} readback_stall={pt.ReadbackStallUs} surface_present={pt.SurfacePresentUs}"); + Console.WriteLine($" anim_eval={pt.AnimEvalUs} bone_pack={pt.BonePackUs} bone_upload={pt.BoneUploadUs}"); // Write markdown report string profilingDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "profiling"); @@ -246,6 +256,22 @@ static void RunProfile(GoudGame game, CrowdSystem crowd, GameConfig config) md.AppendLine($"- Visible: {visible}/{visible + culled}"); md.AppendLine($"- Instanced: {instanced}"); md.AppendLine($"- AnimEval: {animEval}, Saved: {animSaved}"); + md.AppendLine(); + md.AppendLine("## Phase Timings (last frame, microseconds)"); + md.AppendLine($"| Phase | Time (us) |"); + md.AppendLine($"|-------|-----------|"); + md.AppendLine($"| surface_acquire | {pt.SurfaceAcquireUs} |"); + md.AppendLine($"| shadow_pass | {pt.ShadowPassUs} |"); + md.AppendLine($"| shadow_build | {pt.ShadowBuildUs} |"); + md.AppendLine($"| render3d_scene | {pt.Render3dSceneUs} |"); + md.AppendLine($"| uniform_upload | {pt.UniformUploadUs} |"); + md.AppendLine($"| render_pass | {pt.RenderPassUs} |"); + md.AppendLine($"| gpu_submit | {pt.GpuSubmitUs} |"); + md.AppendLine($"| readback_stall | {pt.ReadbackStallUs} |"); + md.AppendLine($"| surface_present | {pt.SurfacePresentUs} |"); + md.AppendLine($"| anim_eval | {pt.AnimEvalUs} |"); + md.AppendLine($"| bone_pack | {pt.BonePackUs} |"); + md.AppendLine($"| bone_upload | {pt.BoneUploadUs} |"); File.WriteAllText(mdPath, md.ToString()); Console.WriteLine($"\nReport written to: {mdPath}"); diff --git a/goud_engine/Cargo.toml b/goud_engine/Cargo.toml index dcf0083b6..a2146253b 100644 --- a/goud_engine/Cargo.toml +++ b/goud_engine/Cargo.toml @@ -90,6 +90,7 @@ web-sys = { version = "0.3", optional = true, features = [ sdl2 = { version = "0.38", optional = true, features = ["bundled", "raw-window-handle"] } bumpalo = "3.20.2" smallvec = "1.15.1" +rustc-hash = "2" [target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dependencies] interprocess = { git = "https://github.com/kotauskas/interprocess", rev = "41facdd4e7517cae4b99a44b24671603bc65041e", optional = true } diff --git a/goud_engine/src/core/mod.rs b/goud_engine/src/core/mod.rs index 8a874ec36..2460ff8d0 100644 --- a/goud_engine/src/core/mod.rs +++ b/goud_engine/src/core/mod.rs @@ -26,5 +26,10 @@ pub mod pool; pub mod providers; pub mod serialization; +/// Fast hash map using `FxHashMap` — ideal for integer keys on hot paths. +pub type FastMap = rustc_hash::FxHashMap; +/// Fast hash set using `FxHashSet` — ideal for integer keys on hot paths. +pub type FastSet = rustc_hash::FxHashSet; + #[cfg(feature = "native")] pub mod input_manager; diff --git a/goud_engine/src/ffi/renderer/metrics.rs b/goud_engine/src/ffi/renderer/metrics.rs index d4a9cfffa..c9ef42a29 100644 --- a/goud_engine/src/ffi/renderer/metrics.rs +++ b/goud_engine/src/ffi/renderer/metrics.rs @@ -104,6 +104,9 @@ pub unsafe extern "C" fn goud_renderer_get_frame_phase_timings( gpu_submit_us: timings.gpu_submit_us, readback_stall_us: timings.readback_stall_us, surface_present_us: timings.surface_present_us, + anim_eval_us: timings.anim_eval_us, + bone_pack_us: timings.bone_pack_us, + bone_upload_us: timings.bone_upload_us, }; 0 } diff --git a/goud_engine/src/ffi/types.rs b/goud_engine/src/ffi/types.rs index 5f46d3329..c402b6592 100644 --- a/goud_engine/src/ffi/types.rs +++ b/goud_engine/src/ffi/types.rs @@ -65,6 +65,12 @@ pub struct FfiFramePhaseTimings { pub readback_stall_us: u64, /// Surface present / vsync wait time (us). pub surface_present_us: u64, + /// Animation evaluation time (us). + pub anim_eval_us: u64, + /// Bone matrix packing time (us). + pub bone_pack_us: u64, + /// Bone matrix GPU upload time (us). + pub bone_upload_us: u64, } // ============================================================================ diff --git a/goud_engine/src/jni/generated.rs b/goud_engine/src/jni/generated.rs index 395636b62..94b6a1eb5 100644 --- a/goud_engine/src/jni/generated.rs +++ b/goud_engine/src/jni/generated.rs @@ -269,6 +269,54 @@ pub(crate) fn write_back_FpsStats<'local>(env: &mut jni::JNIEnv<'local>, obj: &j set_FpsStats_fields(env, obj, value) } +pub(crate) fn set_FramePhaseTimings_fields<'local>(env: &mut jni::JNIEnv<'local>, obj: &jni::objects::JObject<'local>, value: crate::ffi::types::FfiFramePhaseTimings) -> crate::jni::helpers::JniCallResult<()> { + crate::jni::helpers::ensure_no_pending_exception(env)?; + crate::jni::helpers::set_long_field(env, obj, "surfaceAcquireUs", value.surface_acquire_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "shadowPassUs", value.shadow_build_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "shadowBuildUs", value.render3d_scene_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "render3dSceneUs", value.uniform_upload_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "uniformUploadUs", value.render_pass_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "renderPassUs", value.gpu_submit_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "gpuSubmitUs", value.readback_stall_us as i64)?; + crate::jni::helpers::set_long_field(env, obj, "readbackStallUs", value.surface_present_us as i64)?; + Ok(()) +} + +pub(crate) fn new_FramePhaseTimings<'local>(env: &mut jni::JNIEnv<'local>, value: crate::ffi::types::FfiFramePhaseTimings) -> crate::jni::helpers::JniCallResult> { + let obj = crate::jni::helpers::new_object(env, "com/goudengine/internal/FramePhaseTimings")?; + set_FramePhaseTimings_fields(env, &obj, value)?; + Ok(obj) +} + +pub(crate) fn read_FramePhaseTimings<'local>(env: &mut jni::JNIEnv<'local>, obj: &jni::objects::JObject<'local>, param_name: &str) -> crate::jni::helpers::JniCallResult { + if obj.is_null() { + crate::jni::helpers::throw_null_pointer(env, format!("{param_name} is null"))?; + return Err(()); + } + let field_surfaceAcquireUs = crate::jni::helpers::get_long_field(env, obj, "surfaceAcquireUs")? as u64; + let field_shadowPassUs = crate::jni::helpers::get_long_field(env, obj, "shadowPassUs")? as u64; + let field_shadowBuildUs = crate::jni::helpers::get_long_field(env, obj, "shadowBuildUs")? as u64; + let field_render3dSceneUs = crate::jni::helpers::get_long_field(env, obj, "render3dSceneUs")? as u64; + let field_uniformUploadUs = crate::jni::helpers::get_long_field(env, obj, "uniformUploadUs")? as u64; + let field_renderPassUs = crate::jni::helpers::get_long_field(env, obj, "renderPassUs")? as u64; + let field_gpuSubmitUs = crate::jni::helpers::get_long_field(env, obj, "gpuSubmitUs")? as u64; + let field_readbackStallUs = crate::jni::helpers::get_long_field(env, obj, "readbackStallUs")? as u64; + Ok(crate::ffi::types::FfiFramePhaseTimings { + surface_acquire_us: field_surfaceAcquireUs, + shadow_build_us: field_shadowPassUs, + render3d_scene_us: field_shadowBuildUs, + uniform_upload_us: field_render3dSceneUs, + render_pass_us: field_uniformUploadUs, + gpu_submit_us: field_renderPassUs, + readback_stall_us: field_gpuSubmitUs, + surface_present_us: field_readbackStallUs, + }) +} + +pub(crate) fn write_back_FramePhaseTimings<'local>(env: &mut jni::JNIEnv<'local>, obj: &jni::objects::JObject<'local>, value: crate::ffi::types::FfiFramePhaseTimings) -> crate::jni::helpers::JniCallResult<()> { + set_FramePhaseTimings_fields(env, obj, value) +} + pub(crate) fn set_InputCapabilities_fields<'local>(env: &mut jni::JNIEnv<'local>, obj: &jni::objects::JObject<'local>, value: crate::core::providers::input_types::InputCapabilities) -> crate::jni::helpers::JniCallResult<()> { crate::jni::helpers::ensure_no_pending_exception(env)?; crate::jni::helpers::set_boolean_field(env, obj, "supportsGamepad", value.supports_gamepad)?; @@ -4708,6 +4756,29 @@ pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_getAnimationE }) } +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_getFramePhaseTimings<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, +) -> jni::sys::jobject { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_getFramePhaseTimings", crate::jni::helpers::null_object(), |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let mut out_out_timings = unsafe { // SAFETY: zeroed out-parameter storage is only used before the FFI call writes it. + std::mem::zeroed() + }; + let status = unsafe { // SAFETY: out-parameter storage and marshaled inputs remain valid for the duration of the FFI call. + crate::ffi::renderer::goud_renderer_get_frame_phase_timings(&mut out_out_timings as _) + }; + if crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_renderer_get_frame_phase_timings", None); + return Err(()); + } + Ok(new_FramePhaseTimings(env, out_out_timings)?.into_raw()) + }) +} + #[allow(non_snake_case)] #[no_mangle] pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_render3D<'local>( diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs index 66af16a07..e66e8a93f 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/frame.rs @@ -1,7 +1,7 @@ //! Sub-trait and RenderBackend implementations for `WgpuBackend`. use super::{ super::types::BufferUsage, BlendFactor, BufferHandle, BufferOps, BufferType, CullFace, - DepthFunc, DrawType, FrameOps, FrameState, FrontFace, PipelineKey, StateOps, WgpuBackend, + DepthFunc, DrawType, FrameOps, FrameState, FrontFace, StateOps, WgpuBackend, }; use crate::libs::error::{GoudError, GoudResult}; @@ -78,12 +78,16 @@ impl FrameOps for WgpuBackend { wgpu::LoadOp::Load }; - // Collect pipeline keys and ensure pipelines exist before the render pass borrow - let cmd_keys: Vec = self - .draw_commands - .iter() - .map(|cmd| self.make_pipeline_key(cmd)) - .collect(); + // Reuse the persistent scratch buffer for pipeline keys to avoid + // allocating a new Vec every frame. We temporarily take ownership + // to satisfy the borrow checker, then put it back. + let mut cmd_keys = std::mem::take(&mut self.scratch_pipeline_keys); + cmd_keys.clear(); + cmd_keys.extend( + self.draw_commands + .iter() + .map(|cmd| self.make_pipeline_key(cmd)), + ); self.build_missing_pipelines(&cmd_keys); @@ -226,6 +230,9 @@ impl FrameOps for WgpuBackend { let render_pass_us = render_pass_start.elapsed().as_micros() as u64; frame_timing::record_phase("render_pass", render_pass_us); + // Return the scratch Vec so it is reused next frame. + self.scratch_pipeline_keys = cmd_keys; + if let Some(readback) = readback { encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs index 133acd6a1..59801049f 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/init.rs @@ -303,6 +303,8 @@ impl WgpuBackend { shadow_map_size: 0, shadow_pipeline_cache: HashMap::new(), readback_requested: false, + scratch_pipeline_keys: Vec::new(), + scratch_shadow_pipeline_keys: Vec::new(), }) } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs index ed67879e7..91cdbc5d2 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/mod.rs @@ -172,6 +172,12 @@ pub struct WgpuBackend { shadow_pipeline_cache: HashMap, /// Whether a readback has been requested for the current frame. readback_requested: bool, + + /// Scratch buffer reused each frame to collect main-pass pipeline keys, + /// avoiding a per-frame `Vec` allocation. + scratch_pipeline_keys: Vec, + /// Scratch buffer reused each frame to collect shadow-pass pipeline keys. + scratch_shadow_pipeline_keys: Vec, } // SAFETY: wgpu Device and Queue are Send+Sync. Surface is Send. diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs index 14dadf4d5..15d7bf91e 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/sdl_init.rs @@ -316,6 +316,8 @@ impl WgpuBackend { shadow_map_size: 0, shadow_pipeline_cache: HashMap::new(), readback_requested: false, + scratch_pipeline_keys: Vec::new(), + scratch_shadow_pipeline_keys: Vec::new(), }) } } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs index 5091fcd57..cdd608e49 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/shadow_pass.rs @@ -305,8 +305,12 @@ impl WgpuBackend { .map(|i| (i * shadow_slot_size) as u32) .collect(); - // Grow uniform buffers if needed. + // Grow uniform buffers if needed (deduplicate by shader to avoid redundant recreation). + let mut grown_shaders = rustc_hash::FxHashSet::default(); for cmd in &self.shadow_draw_commands { + if grown_shaders.contains(&cmd.shader) { + continue; + } if let Some(meta) = self.shaders.get_mut(&cmd.shader) { if shadow_total > meta.uniform_buffer.size() as usize { let new_size = shadow_total.next_power_of_two().max(shadow_slot_size); @@ -330,6 +334,7 @@ impl WgpuBackend { }], }); } + grown_shaders.insert(cmd.shader); } } @@ -349,11 +354,14 @@ impl WgpuBackend { } } - let shadow_keys: Vec = self - .shadow_draw_commands - .iter() - .map(|cmd| self.make_pipeline_key(cmd)) - .collect(); + // Reuse the persistent scratch buffer for shadow pipeline keys. + let mut shadow_keys = std::mem::take(&mut self.scratch_shadow_pipeline_keys); + shadow_keys.clear(); + shadow_keys.extend( + self.shadow_draw_commands + .iter() + .map(|cmd| self.make_pipeline_key(cmd)), + ); self.build_missing_shadow_pipelines(&shadow_keys); // SAFETY: shadow_depth_view is confirmed Some above. @@ -425,5 +433,7 @@ impl WgpuBackend { } } self.shadow_draw_commands.clear(); + // Return the scratch Vec so it is reused next frame. + self.scratch_shadow_pipeline_keys = shadow_keys; } } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs index 749b06939..82b7c60bd 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/switch_init.rs @@ -316,6 +316,8 @@ impl WgpuBackend { shadow_map_size: 0, shadow_pipeline_cache: HashMap::new(), readback_requested: false, + scratch_pipeline_keys: Vec::new(), + scratch_shadow_pipeline_keys: Vec::new(), }) } } diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/uniforms.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/uniforms.rs index d7d62c18e..8319700c8 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/uniforms.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/uniforms.rs @@ -26,7 +26,7 @@ impl WgpuBackend { /// nested `Vec`s per draw command. pub(super) fn make_pipeline_key(&self, cmd: &DrawCommand) -> PipelineKey { use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut hasher = rustc_hash::FxHasher::default(); for binding in &cmd.vertex_bindings { binding.layout.stride.hash(&mut hasher); (binding.step_mode as u8).hash(&mut hasher); @@ -145,7 +145,12 @@ impl WgpuBackend { .collect(); // Grow uniform buffers up-front before any writes. + // Collect unique shaders first to avoid redundant buffer recreation. + let mut grown_shaders = rustc_hash::FxHashSet::default(); for cmd in &self.draw_commands { + if grown_shaders.contains(&cmd.shader) { + continue; + } if let Some(meta) = self.shaders.get_mut(&cmd.shader) { if total_needed > meta.uniform_buffer.size() as usize { let new_size = total_needed.next_power_of_two().max(slot_size); @@ -169,6 +174,7 @@ impl WgpuBackend { }], }); } + grown_shaders.insert(cmd.shader); } } @@ -208,3 +214,46 @@ impl WgpuBackend { } } } + +#[cfg(test)] +mod tests { + /// Regression test: uniform buffer growth must use `next_power_of_two()` + /// to produce stable sizes. Using `* 2` caused the 62 GB memory leak + /// because fluctuating draw counts triggered buffer recreation every frame. + #[test] + fn uniform_buffer_growth_is_power_of_two() { + let slot_size = 256usize; + + // Simulate varying draw counts across frames (the bug scenario). + let draw_counts = [50, 200, 50, 200, 100, 300, 100, 300, 47, 280, 47, 280]; + let mut current_buffer_size = slot_size as u64; // initial 256 bytes + let mut realloc_count = 0u32; + + for &count in &draw_counts { + let total_needed = count * slot_size; + if total_needed > current_buffer_size as usize { + // This is the growth formula — must match uniforms.rs:151 + let new_size = total_needed.next_power_of_two().max(slot_size); + current_buffer_size = new_size as u64; + realloc_count += 1; + } + } + + // With power-of-two growth, buffer should stabilize quickly. + // The max draw count is 300 * 256 = 76,800 → next_power_of_two = 131,072. + assert_eq!( + current_buffer_size, 131072, + "Buffer should stabilize at 128 KB" + ); + assert!( + realloc_count <= 4, + "Should need at most 4 reallocations, got {realloc_count}" + ); + + // Verify that power-of-two sizes are actually used. + assert!( + current_buffer_size.is_power_of_two(), + "Buffer size {current_buffer_size} must be a power of two" + ); + } +} diff --git a/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs b/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs index 1d7732b40..2169ef6c3 100644 --- a/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs +++ b/goud_engine/src/libs/graphics/backend/wgpu_backend/xbox_init.rs @@ -316,6 +316,8 @@ impl WgpuBackend { shadow_map_size: 0, shadow_pipeline_cache: HashMap::new(), readback_requested: false, + scratch_pipeline_keys: Vec::new(), + scratch_shadow_pipeline_keys: Vec::new(), }) } } diff --git a/goud_engine/src/libs/graphics/frame_timing.rs b/goud_engine/src/libs/graphics/frame_timing.rs index 3eaef9428..5d020bfb0 100644 --- a/goud_engine/src/libs/graphics/frame_timing.rs +++ b/goud_engine/src/libs/graphics/frame_timing.rs @@ -27,6 +27,12 @@ pub struct FramePhaseTimings { pub shadow_build_us: u64, /// 3D scene render time. pub render3d_scene_us: u64, + /// Animation evaluation time. + pub anim_eval_us: u64, + /// Bone matrix packing time. + pub bone_pack_us: u64, + /// Bone matrix GPU upload time. + pub bone_upload_us: u64, } thread_local! { @@ -47,6 +53,9 @@ pub fn record_timing(field: &str, value: u64) { "shadow_pass" => t.shadow_pass_us = value, "shadow_build" => t.shadow_build_us = value, "render3d_scene" => t.render3d_scene_us = value, + "anim_eval" => t.anim_eval_us = value, + "bone_pack" => t.bone_pack_us = value, + "bone_upload" => t.bone_upload_us = value, _ => {} } }); @@ -86,6 +95,9 @@ mod tests { record_timing("gpu_submit", 600); record_timing("readback_stall", 700); record_timing("surface_present", 800); + record_timing("anim_eval", 900); + record_timing("bone_pack", 1000); + record_timing("bone_upload", 1100); let t = latest_timings(); assert_eq!(t.surface_acquire_us, 100); @@ -97,6 +109,9 @@ mod tests { assert_eq!(t.gpu_submit_us, 600); assert_eq!(t.readback_stall_us, 700); assert_eq!(t.surface_present_us, 800); + assert_eq!(t.anim_eval_us, 900); + assert_eq!(t.bone_pack_us, 1000); + assert_eq!(t.bone_upload_us, 1100); } #[test] diff --git a/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs b/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs index e01178ec4..1ee333417 100644 --- a/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs @@ -64,6 +64,10 @@ pub struct AnimationPlayer { /// When true, this player uses a global shared clock instead of per-instance /// time, guaranteeing G5 cache hits for all instances of the same model+clip. pub phase_locked: bool, + /// Cached fallback `BonePropertyNames` for the `update()` path that lacks + /// pre-built channel maps. Built lazily on first use and reused across frames + /// to avoid per-frame `format!()` string allocations. + cached_fallback_names: Option>, } impl AnimationPlayer { @@ -78,6 +82,7 @@ impl AnimationPlayer { scratch_local: vec![IDENTITY_MAT4; bone_count], scratch_global: vec![IDENTITY_MAT4; bone_count], phase_locked: false, + cached_fallback_names: None, } } @@ -181,11 +186,24 @@ impl AnimationPlayer { /// Advance animation time and compute bone matrices. pub fn update(&mut self, dt: f32, skeleton: &SkeletonData, animations: &[KeyframeAnimation]) { - // Fallback: build property names per call when no cached names available. - let fallback: Vec = (0..skeleton.bones.len()) - .map(BonePropertyNames::new) - .collect(); - self.update_with_names(dt, skeleton, animations, &fallback); + // Lazily build and cache fallback property names to avoid per-frame + // `format!()` string allocations. + let bone_count = skeleton.bones.len(); + let needs_rebuild = self + .cached_fallback_names + .as_ref() + .is_none_or(|names| names.len() != bone_count); + if needs_rebuild { + self.cached_fallback_names = + Some((0..bone_count).map(BonePropertyNames::new).collect()); + } + // SAFETY: we just ensured `cached_fallback_names` is `Some`. + let names = self.cached_fallback_names.as_ref().unwrap(); + // Take a raw pointer to avoid the borrow conflict with `&mut self`. + let names_ptr = names as *const Vec; + // SAFETY: `update_with_names` does not modify `cached_fallback_names`. + let names_ref = unsafe { &*names_ptr }; + self.update_with_names(dt, skeleton, animations, names_ref); } /// Advance animation time and compute bone matrices using pre-cached diff --git a/goud_engine/src/libs/graphics/renderer3d/animation_sampling.rs b/goud_engine/src/libs/graphics/renderer3d/animation_sampling.rs index 7061e427f..14504a38d 100644 --- a/goud_engine/src/libs/graphics/renderer3d/animation_sampling.rs +++ b/goud_engine/src/libs/graphics/renderer3d/animation_sampling.rs @@ -83,6 +83,7 @@ impl BoneChannelMap { } /// Compute bone matrices using pre-cached property names (zero per-frame allocation). +#[inline] pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_with_names( skeleton: &SkeletonData, anim: &KeyframeAnimation, @@ -116,6 +117,7 @@ pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_with_names( } /// Compute bone matrices into pre-allocated scratch buffers (zero per-frame allocation). +#[inline] pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_into( skeleton: &SkeletonData, anim: &KeyframeAnimation, @@ -146,6 +148,7 @@ pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_into( } /// Compute bone matrices using pre-built [`BoneChannelMap`] (zero string lookups). +#[inline] pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_into_fast( skeleton: &SkeletonData, anim: &KeyframeAnimation, @@ -181,10 +184,11 @@ pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_into_fast( let sy = sample(cm[8], 1.0); let sz = sample(cm[9], 1.0); - // Normalize quaternion. + // Normalize quaternion (1 reciprocal + 4 multiplications instead of 4 divisions). let len = (rx * rx + ry * ry + rz * rz + rw * rw).sqrt(); let (rx, ry, rz, rw) = if len > f32::EPSILON { - (rx / len, ry / len, rz / len, rw / len) + let inv_len = 1.0 / len; + (rx * inv_len, ry * inv_len, rz * inv_len, rw * inv_len) } else { (0.0, 0.0, 0.0, 1.0) }; @@ -232,6 +236,7 @@ pub(in crate::libs::graphics::renderer3d) fn compute_bone_matrices_with_channel_ // --------------------------------------------------------------------------- /// Walk the bone hierarchy to compute global transforms from local transforms. +#[inline] fn walk_hierarchy( skeleton: &SkeletonData, local_transforms: &[[f32; 16]], @@ -269,10 +274,11 @@ fn sample_rotation_indexed( let y = sample_channel(anim, &names.rotation[1], time).unwrap_or(0.0); let z = sample_channel(anim, &names.rotation[2], time).unwrap_or(0.0); let w = sample_channel(anim, &names.rotation[3], time).unwrap_or(1.0); - // Normalize quaternion. + // Normalize quaternion (1 reciprocal + 4 multiplications instead of 4 divisions). let len = (x * x + y * y + z * z + w * w).sqrt(); if len > f32::EPSILON { - (x / len, y / len, z / len, w / len) + let inv_len = 1.0 / len; + (x * inv_len, y * inv_len, z * inv_len, w * inv_len) } else { (0.0, 0.0, 0.0, 1.0) } @@ -295,6 +301,7 @@ fn sample_channel(anim: &KeyframeAnimation, property: &str, time: f32) -> Option } /// Build a column-major 4x4 TRS matrix from translation, quaternion rotation, and scale. +#[inline] pub(crate) fn build_trs_matrix( tx: f32, ty: f32, @@ -338,15 +345,19 @@ pub(crate) fn build_trs_matrix( } /// Multiply two column-major 4x4 matrices. +/// +/// Unrolled inner loop to help the compiler auto-vectorize. +#[inline] pub(crate) fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + // Column-major: out[col*4+row] = sum_k(a[k*4+row] * b[col*4+k]) let mut out = [0.0f32; 16]; for col in 0..4 { + let b0 = b[col * 4]; + let b1 = b[col * 4 + 1]; + let b2 = b[col * 4 + 2]; + let b3 = b[col * 4 + 3]; for row in 0..4 { - let mut sum = 0.0f32; - for k in 0..4 { - sum += a[k * 4 + row] * b[col * 4 + k]; - } - out[col * 4 + row] = sum; + out[col * 4 + row] = a[row] * b0 + a[4 + row] * b1 + a[8 + row] * b2 + a[12 + row] * b3; } } out diff --git a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs index 8635f766f..7ec98f854 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs @@ -2,7 +2,7 @@ mod drop; -use std::collections::HashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use super::config::Render3DConfig; use super::scene::Scene3D; @@ -51,10 +51,10 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) debug_draw_buffer: BufferHandle, pub(in crate::libs::graphics::renderer3d) debug_draw_buffer_capacity_bytes: usize, pub(in crate::libs::graphics::renderer3d) debug_draw_vertex_count: i32, - pub(in crate::libs::graphics::renderer3d) objects: HashMap, - pub(in crate::libs::graphics::renderer3d) instanced_meshes: HashMap, - pub(in crate::libs::graphics::renderer3d) particle_emitters: HashMap, - pub(in crate::libs::graphics::renderer3d) lights: HashMap, + pub(in crate::libs::graphics::renderer3d) objects: FxHashMap, + pub(in crate::libs::graphics::renderer3d) instanced_meshes: FxHashMap, + pub(in crate::libs::graphics::renderer3d) particle_emitters: FxHashMap, + pub(in crate::libs::graphics::renderer3d) lights: FxHashMap, pub(in crate::libs::graphics::renderer3d) next_object_id: u32, pub(in crate::libs::graphics::renderer3d) next_instanced_mesh_id: u32, pub(in crate::libs::graphics::renderer3d) next_light_id: u32, @@ -81,18 +81,18 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) postprocess_texture_size: (u32, u32), pub(in crate::libs::graphics::renderer3d) shadow_texture: Option, - pub(in crate::libs::graphics::renderer3d) materials: HashMap, - pub(in crate::libs::graphics::renderer3d) object_materials: HashMap, + pub(in crate::libs::graphics::renderer3d) materials: FxHashMap, + pub(in crate::libs::graphics::renderer3d) object_materials: FxHashMap, pub(in crate::libs::graphics::renderer3d) next_material_id: u32, - pub(in crate::libs::graphics::renderer3d) skinned_meshes: HashMap, + pub(in crate::libs::graphics::renderer3d) skinned_meshes: FxHashMap, pub(in crate::libs::graphics::renderer3d) next_skinned_mesh_id: u32, pub(in crate::libs::graphics::renderer3d) skinned_shader_handle: ShaderHandle, pub(in crate::libs::graphics::renderer3d) skinned_uniforms: SkinnedUniforms, pub(in crate::libs::graphics::renderer3d) skinned_layout: VertexLayout, - pub(in crate::libs::graphics::renderer3d) models: HashMap, - pub(in crate::libs::graphics::renderer3d) model_instances: HashMap, + pub(in crate::libs::graphics::renderer3d) models: FxHashMap, + pub(in crate::libs::graphics::renderer3d) model_instances: FxHashMap, pub(in crate::libs::graphics::renderer3d) next_model_id: u32, - pub(in crate::libs::graphics::renderer3d) animation_players: HashMap, + pub(in crate::libs::graphics::renderer3d) animation_players: FxHashMap, /// Shader and uniforms for instanced skinned rendering. pub(in crate::libs::graphics::renderer3d) instanced_skinned_shader_handle: ShaderHandle, pub(in crate::libs::graphics::renderer3d) instanced_skinned_uniforms: InstancedSkinnedUniforms, @@ -111,12 +111,12 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) stats: Renderer3DStats, pub(in crate::libs::graphics::renderer3d) anti_aliasing_mode: AntiAliasingMode, pub(in crate::libs::graphics::renderer3d) msaa_samples: u32, - pub(in crate::libs::graphics::renderer3d) scenes: HashMap, + pub(in crate::libs::graphics::renderer3d) scenes: FxHashMap, pub(in crate::libs::graphics::renderer3d) next_scene_id: u32, pub(in crate::libs::graphics::renderer3d) current_scene: Option, /// Object IDs belonging to skinned models/instances -- maintained /// incrementally to avoid per-frame recomputation. - pub(in crate::libs::graphics::renderer3d) skinned_object_ids: std::collections::HashSet, + pub(in crate::libs::graphics::renderer3d) skinned_object_ids: FxHashSet, /// Game-developer-controlled configuration for the 3D renderer. pub(in crate::libs::graphics::renderer3d) config: Render3DConfig, /// Reusable scratch buffer for CPU skinning output (avoids per-submesh allocation). @@ -125,14 +125,14 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) frame_counter: u64, /// Global animation clocks for phase-locked playback. /// Key: (source_model_id, clip_index). Value: elapsed time in seconds. - pub(in crate::libs::graphics::renderer3d) phase_lock_clocks: HashMap<(u32, usize), f32>, + pub(in crate::libs::graphics::renderer3d) phase_lock_clocks: FxHashMap<(u32, usize), f32>, /// Pool of per-group instance buffers for instanced skinned rendering. /// Each group gets its own buffer to avoid wgpu write-staging overwrites. pub(in crate::libs::graphics::renderer3d) instanced_skinned_instance_buffers: Vec<(BufferHandle, usize)>, /// Reusable G5 shared animation evaluation cache -- cleared each frame. pub(in crate::libs::graphics::renderer3d) bone_eval_cache: - HashMap<(u32, usize, u32), Vec<[f32; 16]>>, + FxHashMap<(u32, usize, u32), Vec<[f32; 16]>>, /// Depth-only shader handle for the GPU shadow pre-pass. pub(in crate::libs::graphics::renderer3d) depth_only_shader_handle: ShaderHandle, /// Cached uniform locations for the depth-only shadow shader. @@ -142,6 +142,18 @@ pub struct Renderer3D { /// Pre-allocated buffer of visible object IDs, reused across frames to avoid /// per-frame Vec allocation during the render snapshot phase. pub(in crate::libs::graphics::renderer3d) visible_object_ids: Vec, + /// Reusable scratch buffer for filtered lights each frame. + pub(in crate::libs::graphics::renderer3d) scratch_filtered_lights: Vec, + /// Reusable scratch buffer for animation player IDs each frame. + pub(in crate::libs::graphics::renderer3d) scratch_player_ids: Vec, + /// Reusable scratch buffer for packed bone floats (skinned mesh pass). + pub(in crate::libs::graphics::renderer3d) scratch_packed_bones: Vec, + /// Reusable scratch buffer for per-mesh bone offsets (skinned mesh pass). + pub(in crate::libs::graphics::renderer3d) scratch_bone_offsets: Vec, + /// Reusable scratch buffer for instanced skinned packed bones. + pub(in crate::libs::graphics::renderer3d) scratch_inst_packed_bones: Vec, + /// Reusable scratch buffer for instanced skinned per-group instance data. + pub(in crate::libs::graphics::renderer3d) scratch_inst_data: Vec, /// Whether the static batch VBO needs rebuilding (set when `set_object_static` changes). pub(in crate::libs::graphics::renderer3d) static_batch_dirty: bool, /// Pre-baked VBO containing all static objects' transformed vertices. @@ -152,7 +164,7 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) static_batch_vertex_count: u32, /// 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: std::collections::HashSet, + pub(in crate::libs::graphics::renderer3d) static_batched_ids: FxHashSet, } // StaticBatchGroup is defined in core_static_batch.rs @@ -276,10 +288,10 @@ impl Renderer3D { debug_draw_buffer: BufferHandle::INVALID, debug_draw_buffer_capacity_bytes: 0, debug_draw_vertex_count: 0, - objects: HashMap::new(), - instanced_meshes: HashMap::new(), - particle_emitters: HashMap::new(), - lights: HashMap::new(), + objects: FxHashMap::default(), + instanced_meshes: FxHashMap::default(), + particle_emitters: FxHashMap::default(), + lights: FxHashMap::default(), next_object_id: 1, next_instanced_mesh_id: 1, next_light_id: 1, @@ -304,18 +316,18 @@ impl Renderer3D { postprocess_texture: None, postprocess_texture_size: (0, 0), shadow_texture: None, - materials: HashMap::new(), - object_materials: HashMap::new(), + materials: FxHashMap::default(), + object_materials: FxHashMap::default(), next_material_id: 1, - skinned_meshes: HashMap::new(), + skinned_meshes: FxHashMap::default(), next_skinned_mesh_id: 1, skinned_shader_handle, skinned_uniforms, skinned_layout: skinned_vertex_layout(), - models: HashMap::new(), - model_instances: HashMap::new(), + models: FxHashMap::default(), + model_instances: FxHashMap::default(), next_model_id: 1, - animation_players: HashMap::new(), + animation_players: FxHashMap::default(), instanced_skinned_shader_handle, instanced_skinned_uniforms, instanced_skinned_instance_layout: instanced_skinned_instance_layout(), @@ -327,29 +339,37 @@ impl Renderer3D { stats: Renderer3DStats::default(), anti_aliasing_mode: AntiAliasingMode::Off, msaa_samples: 1, - scenes: HashMap::new(), + scenes: FxHashMap::default(), next_scene_id: 1, current_scene: None, - skinned_object_ids: std::collections::HashSet::new(), + skinned_object_ids: FxHashSet::default(), config: Render3DConfig::default(), skin_scratch_buffer: Vec::new(), frame_counter: 0, - phase_lock_clocks: HashMap::new(), + phase_lock_clocks: FxHashMap::default(), instanced_skinned_instance_buffers: Vec::new(), - bone_eval_cache: HashMap::new(), + bone_eval_cache: FxHashMap::default(), depth_only_shader_handle, depth_only_uniforms, depth_only_layout: depth_only_vertex_layout(), visible_object_ids: Vec::with_capacity(1024), + scratch_filtered_lights: Vec::with_capacity(16), + scratch_player_ids: Vec::with_capacity(64), + scratch_packed_bones: Vec::with_capacity(4096), + scratch_bone_offsets: Vec::with_capacity(64), + scratch_inst_packed_bones: Vec::with_capacity(4096), + scratch_inst_data: Vec::with_capacity(1024), static_batch_dirty: false, static_batch_buffer: None, static_batch_groups: Vec::new(), static_batch_vertex_count: 0, - static_batched_ids: std::collections::HashSet::new(), + static_batched_ids: FxHashSet::default(), }) } pub fn set_object_position(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { - let ok = self.mutate_object(id, |obj| obj.position = Vector3::new(x, y, z)); + 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; } @@ -363,7 +383,9 @@ impl Renderer3D { ok } pub fn set_object_scale(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { - let ok = self.mutate_object(id, |obj| obj.scale = Vector3::new(x, y, z)); + 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; } diff --git a/goud_engine/src/libs/graphics/renderer3d/core_config.rs b/goud_engine/src/libs/graphics/renderer3d/core_config.rs index 3fc9895af..4585d03f0 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_config.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_config.rs @@ -20,7 +20,7 @@ impl Renderer3D { self.grid_vertex_count = count; } } - self.grid_config = config.clone(); + self.grid_config = config; if let Some(scene_id) = self.current_scene { if let Some(scene) = self.scenes.get_mut(&scene_id) { scene.grid = config; @@ -51,7 +51,7 @@ impl Renderer3D { } pub fn configure_skybox(&mut self, config: SkyboxConfig) { - self.skybox_config = config.clone(); + self.skybox_config = config; if let Some(scene_id) = self.current_scene { if let Some(scene) = self.scenes.get_mut(&scene_id) { scene.skybox = config; @@ -60,7 +60,7 @@ impl Renderer3D { } pub fn configure_fog(&mut self, config: FogConfig) { - self.fog_config = config.clone(); + self.fog_config = config; if let Some(scene_id) = self.current_scene { if let Some(scene) = self.scenes.get_mut(&scene_id) { scene.fog = config; diff --git a/goud_engine/src/libs/graphics/renderer3d/core_model_animation/mod.rs b/goud_engine/src/libs/graphics/renderer3d/core_model_animation/mod.rs index 3bb70293e..2f9b53f10 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_model_animation/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_model_animation/mod.rs @@ -33,7 +33,7 @@ struct SkinUpload { fn gather_skin_uploads( model: &Model3D, obj_ids: &[u32], - objects: &std::collections::HashMap, + objects: &rustc_hash::FxHashMap, ) -> Vec { let sub_count = model.bind_pose_vertices.len().min(obj_ids.len()); if model.bind_pose_vertices.len() != obj_ids.len() { @@ -134,6 +134,8 @@ impl Renderer3D { /// - **G6 (Animation LOD)**: Skips or half-rates animation updates for /// models that are far from the camera. pub fn update_animations(&mut self, dt: f32) { + let anim_eval_start = std::time::Instant::now(); + // Advance all phase-lock global clocks. for clock in self.phase_lock_clocks.values_mut() { *clock += dt; @@ -144,8 +146,12 @@ impl Renderer3D { } } - // Collect model IDs and instance IDs that have animation players. - let player_ids: Vec = self.animation_players.keys().copied().collect(); + // Collect model IDs and instance IDs that have animation players, + // reusing the scratch buffer to avoid per-frame allocation. + self.scratch_player_ids.clear(); + self.scratch_player_ids + .extend(self.animation_players.keys().copied()); + let player_ids = std::mem::take(&mut self.scratch_player_ids); // Phase 1: advance animation time and compute bone matrices. // @@ -337,6 +343,9 @@ impl Renderer3D { // Return the cache to self so it can be reused next frame. self.bone_eval_cache = bone_cache; + let anim_eval_us = anim_eval_start.elapsed().as_micros() as u64; + crate::libs::graphics::frame_timing::record_phase("anim_eval", anim_eval_us); + // Phase 2: CPU skinning let gpu_skinning = matches!(self.config.skinning.mode, super::config::SkinningMode::Gpu) && self.backend.supports_storage_buffers(); @@ -393,6 +402,9 @@ impl Renderer3D { } } } + + // Return the scratch buffer so it can be reused next frame. + self.scratch_player_ids = player_ids; } /// Returns the number of animations in a model. diff --git a/goud_engine/src/libs/graphics/renderer3d/core_models/lifecycle.rs b/goud_engine/src/libs/graphics/renderer3d/core_models/lifecycle.rs index 6a16ed2f5..c35b127e7 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_models/lifecycle.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_models/lifecycle.rs @@ -83,18 +83,16 @@ impl Renderer3D { } } - let source_buffers: std::collections::HashSet< - crate::libs::graphics::backend::BufferHandle, - > = self - .models - .get(&inst.source_model_id) - .map(|m| { - m.mesh_object_ids - .iter() - .filter_map(|&oid| self.objects.get(&oid).map(|o| o.buffer)) - .collect() - }) - .unwrap_or_default(); + let source_buffers: rustc_hash::FxHashSet = + self.models + .get(&inst.source_model_id) + .map(|m| { + m.mesh_object_ids + .iter() + .filter_map(|&oid| self.objects.get(&oid).map(|o| o.buffer)) + .collect() + }) + .unwrap_or_default(); for &obj_id in &inst.mesh_object_ids { self.skinned_object_ids.remove(&obj_id); diff --git a/goud_engine/src/libs/graphics/renderer3d/core_skinned.rs b/goud_engine/src/libs/graphics/renderer3d/core_skinned.rs index fc7fc0984..dce9923ea 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_skinned.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_skinned.rs @@ -40,6 +40,10 @@ impl Renderer3D { rotation: cgmath::Vector3::new(0.0, 0.0, 0.0), scale: cgmath::Vector3::new(1.0, 1.0, 1.0), color: self.config.default_material_color, + cached_model_matrix: [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + transform_dirty: true, }, ); id @@ -59,6 +63,7 @@ impl Renderer3D { pub fn set_skinned_mesh_position(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { if let Some(mesh) = self.skinned_meshes.get_mut(&id) { mesh.position = cgmath::Vector3::new(x, y, z); + mesh.transform_dirty = true; true } else { false @@ -69,6 +74,7 @@ impl Renderer3D { pub fn set_skinned_mesh_rotation(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { if let Some(mesh) = self.skinned_meshes.get_mut(&id) { mesh.rotation = cgmath::Vector3::new(x, y, z); + mesh.transform_dirty = true; true } else { false @@ -79,6 +85,7 @@ impl Renderer3D { pub fn set_skinned_mesh_scale(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { if let Some(mesh) = self.skinned_meshes.get_mut(&id) { mesh.scale = cgmath::Vector3::new(x, y, z); + mesh.transform_dirty = true; true } else { false diff --git a/goud_engine/src/libs/graphics/renderer3d/frustum.rs b/goud_engine/src/libs/graphics/renderer3d/frustum.rs index fb2888126..75dad77f9 100644 --- a/goud_engine/src/libs/graphics/renderer3d/frustum.rs +++ b/goud_engine/src/libs/graphics/renderer3d/frustum.rs @@ -152,4 +152,75 @@ mod tests { // Object beyond far plane should be culled. assert!(!frustum.intersects_sphere(Vector3::new(0.0, 0.0, -200.0), 1.0)); } + + /// Regression test: objects placed away from origin must remain visible + /// when the camera is near them. Previously, cached bounding spheres + /// were initialized in local space (near origin) causing objects to be + /// culled when the camera moved away from the origin. + #[test] + fn test_world_space_bounds_near_camera() { + // Camera at (50, 5, 50) looking at (50, 0, 45) — far from origin, + // looking roughly along -Z. Simulates walking toward a building + // at (50, 0, 45). + let proj = perspective(Deg(60.0), 16.0 / 9.0, 0.1, 200.0); + let view = Matrix4::look_at_rh( + Point3::new(50.0, 5.0, 50.0), + Point3::new(50.0, 0.0, 45.0), + Vector3::new(0.0, 1.0, 0.0), + ); + let frustum = Frustum::from_view_projection(&(proj * view)); + + // Object at (50, 0, 45) with local bounds center (0, 1, 0) and radius 2.0. + let obj_position = Vector3::new(50.0, 0.0, 45.0); + let bounds_center = Vector3::new(0.0, 1.0, 0.0); + let bounds_radius = 2.0f32; + + // CORRECT: world-space center = position + local bounds center + let world_center = obj_position + bounds_center; + assert!( + frustum.intersects_sphere(world_center, bounds_radius), + "Object at world ({}, {}, {}) should be visible when camera looks at it", + world_center.x, + world_center.y, + world_center.z, + ); + + // BUG REGRESSION: if we used local-space center (near origin) instead, + // the object would be incorrectly placed at (0, 1, 0) — far from + // the camera at (50, 5, 50). It should be culled. + let stale_local_center = bounds_center; + assert!( + !frustum.intersects_sphere(stale_local_center, bounds_radius), + "Local-space center at origin should NOT be visible from camera at (50, 5, 50)" + ); + } + + /// Regression test: objects with non-unit scale must have their bounding + /// radius scaled correctly. + #[test] + fn test_scaled_object_bounds() { + let proj = perspective(Deg(60.0), 1.0, 0.1, 100.0); + let view = Matrix4::look_at_rh( + Point3::new(0.0, 0.0, 20.0), + Point3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 1.0, 0.0), + ); + let frustum = Frustum::from_view_projection(&(proj * view)); + + // Small object at origin with large scale should still be visible. + let position = Vector3::new(0.0, 0.0, 0.0); + let bounds_center = Vector3::new(0.0, 0.0, 0.0); + let bounds_radius = 0.1f32; + let scale = Vector3::new(50.0f32, 50.0, 50.0); + + let world_center = position + bounds_center; + let max_scale = scale.x.max(scale.y).max(scale.z); + let world_radius = bounds_radius * max_scale; + + assert!(frustum.intersects_sphere(world_center, world_radius)); + + // Without scale factor, the tiny radius should be visible (it's at origin + // which is in view), but this verifies the math is correct. + assert!(frustum.intersects_sphere(world_center, bounds_radius)); + } } diff --git a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs index c24aed1a9..a25af749c 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs @@ -27,6 +27,16 @@ impl Renderer3D { self.rebuild_static_batch(); } + // Recompute cached model matrices for any dirty skinned meshes, before + // the shadow pass and main render pass that both read them. + for sm in self.skinned_meshes.values_mut() { + if sm.transform_dirty { + let model = Self::create_model_matrix(sm.position, sm.rotation, sm.scale); + sm.cached_model_matrix = mat4_to_array(&model); + sm.transform_dirty = false; + } + } + self.frame_counter = self.frame_counter.wrapping_add(1); let anim_evals = self.stats.animation_evaluations; let anim_saved = self.stats.animation_evaluations_saved; @@ -47,20 +57,12 @@ impl Renderer3D { let (eff_fog, eff_skybox, eff_grid) = if let Some(scene_id) = self.current_scene { if let Some(scene) = self.scenes.get(&scene_id) { - (scene.fog.clone(), scene.skybox.clone(), scene.grid.clone()) + (scene.fog, scene.skybox, scene.grid) } else { - ( - self.fog_config.clone(), - self.skybox_config.clone(), - self.grid_config.clone(), - ) + (self.fog_config, self.skybox_config, self.grid_config) } } else { - ( - self.fog_config.clone(), - self.skybox_config.clone(), - self.grid_config.clone(), - ) + (self.fog_config, self.skybox_config, self.grid_config) }; if eff_skybox.enabled { @@ -197,6 +199,22 @@ impl Renderer3D { let has_static_batch = self.static_batch_buffer.is_some() && self.config.batching.static_batching_enabled; + // Pre-resolved draw data for visible objects, avoiding repeated HashMap + // lookups in the draw loop. + #[derive(Clone)] + struct VisibleDrawData { + mat_id: u32, + texture_id: u32, + buffer: crate::libs::graphics::backend::BufferHandle, + vertex_count: i32, + position: cgmath::Vector3, + rotation: cgmath::Vector3, + scale: cgmath::Vector3, + color: [f32; 4], + } + + let default_color = self.config.default_material_color; + let mut visible_draw_data: Vec = Vec::new(); self.visible_object_ids.clear(); for (&id, obj) in &self.objects { if skinned_obj_ids.contains(&id) { @@ -218,20 +236,49 @@ impl Renderer3D { continue; } } + // Resolve material and color at visibility-check time. + let mat_id = self.object_materials.get(&id).copied().unwrap_or(0); + let color = if let Some(mat) = self.materials.get(&mat_id) { + let c = &mat.color; + [c.x, c.y, c.z, c.w] + } else if obj.texture_id > 0 { + [1.0, 1.0, 1.0, 1.0] + } else { + default_color + }; self.visible_object_ids.push(id); + visible_draw_data.push(VisibleDrawData { + mat_id, + texture_id: obj.texture_id, + buffer: obj.buffer, + vertex_count: obj.vertex_count, + position: obj.position, + rotation: obj.rotation, + scale: obj.scale, + color, + }); } let material_sorting = self.config.batching.material_sorting_enabled; if material_sorting { - let objects = &self.objects; - let object_materials = &self.object_materials; - self.visible_object_ids.sort_by(|&a, &b| { - let mat_a = object_materials.get(&a).copied().unwrap_or(0); - let mat_b = object_materials.get(&b).copied().unwrap_or(0); - let tex_a = objects.get(&a).map_or(0, |o| o.texture_id); - let tex_b = objects.get(&b).map_or(0, |o| o.texture_id); - (mat_a, tex_a).cmp(&(mat_b, tex_b)) + // Build index-based sort keys to avoid moving the full VisibleDrawData during sort. + let mut indices: Vec = (0..visible_draw_data.len()).collect(); + indices.sort_unstable_by_key(|&i| { + (visible_draw_data[i].mat_id, visible_draw_data[i].texture_id) }); + // Reorder visible_draw_data and visible_object_ids in-place via the + // sorted indices. + let mut sorted_data: Vec = Vec::with_capacity(visible_draw_data.len()); + let mut sorted_ids: Vec = Vec::with_capacity(self.visible_object_ids.len()); + for &i in &indices { + // SAFETY: each index in `indices` is unique and in-bounds. + sorted_ids.push(self.visible_object_ids[i]); + } + for &i in &indices { + sorted_data.push(visible_draw_data[i].clone()); + } + visible_draw_data = sorted_data; + self.visible_object_ids = sorted_ids; } self.stats.visible_objects = self.visible_object_ids.len() as u32; @@ -240,12 +287,15 @@ impl Renderer3D { .total_objects .saturating_sub(self.stats.visible_objects); - let filtered_lights: Vec = self - .lights - .iter() - .filter(|(&id, _)| scene_light_filter.is_none_or(|set| set.contains(&id))) - .map(|(_, l)| l.clone()) - .collect(); + self.scratch_filtered_lights.clear(); + for (&id, l) in &self.lights { + if scene_light_filter.is_none_or(|set| set.contains(&id)) { + self.scratch_filtered_lights.push(*l); + } + } + // Take ownership of the scratch buffer for the duration of the frame, + // returning it at the end to avoid borrow conflicts with `&mut self`. + let filtered_lights = std::mem::take(&mut self.scratch_filtered_lights); let _ = self.backend.bind_shader(self.shader_handle); let uniforms = self.uniforms.clone(); @@ -264,41 +314,20 @@ impl Renderer3D { } let mut last_texture_id = u32::MAX; - for i in 0..self.visible_object_ids.len() { - let obj_id = self.visible_object_ids[i]; - let obj = match self.objects.get(&obj_id) { - Some(o) => o, - None => continue, - }; - let buffer = obj.buffer; - let vertex_count = obj.vertex_count; - let position = obj.position; - let rotation = obj.rotation; - let scale = obj.scale; - let texture_id = obj.texture_id; - let mat_id = self.object_materials.get(&obj_id).copied().unwrap_or(0); - let color = if let Some(mat) = self.materials.get(&mat_id) { - let c = &mat.color; - [c.x, c.y, c.z, c.w] - } else if texture_id > 0 { - [1.0, 1.0, 1.0, 1.0] - } else { - self.config.default_material_color - }; - - let model = Self::create_model_matrix(position, rotation, scale); + for draw in &visible_draw_data { + let model = Self::create_model_matrix(draw.position, draw.rotation, draw.scale); let model_arr = mat4_to_array(&model); self.backend .set_uniform_mat4(self.uniforms.model, &model_arr); - if texture_id > 0 { - if texture_id != last_texture_id { + if draw.texture_id > 0 { + if draw.texture_id != last_texture_id { if let Some(tm) = texture_manager { - tm.bind_texture(texture_id, 0); + tm.bind_texture(draw.texture_id, 0); } else { - let texture_handle = TextureHandle::new(texture_id, 1); + let texture_handle = TextureHandle::new(draw.texture_id, 1); let _ = self.backend.bind_texture(texture_handle, 0); } - last_texture_id = texture_id; + last_texture_id = draw.texture_id; self.stats.texture_binds += 1; } self.backend.set_uniform_int(self.uniforms.use_texture, 1); @@ -307,16 +336,16 @@ impl Renderer3D { } self.backend.set_uniform_vec4( self.uniforms.object_color, - color[0], - color[1], - color[2], - color[3], + draw.color[0], + draw.color[1], + draw.color[2], + draw.color[3], ); - let _ = self.backend.bind_buffer(buffer); + let _ = self.backend.bind_buffer(draw.buffer); self.backend.set_vertex_attributes(&self.object_layout); - let _ = self - .backend - .draw_arrays(PrimitiveTopology::Triangles, 0, vertex_count as u32); + let _ = + self.backend + .draw_arrays(PrimitiveTopology::Triangles, 0, draw.vertex_count as u32); self.stats.draw_calls += 1; } @@ -338,41 +367,42 @@ impl Renderer3D { &filtered_lights, ); - let skinned_snaps: Vec<( - crate::libs::graphics::backend::BufferHandle, - i32, - cgmath::Vector3, - cgmath::Vector3, - cgmath::Vector3, - Vec<[f32; 16]>, - [f32; 4], - )> = self - .skinned_meshes - .values() - .map(|sm| { - ( - sm.buffer, - sm.vertex_count, - sm.position, - sm.rotation, - sm.scale, - sm.bone_matrices.clone(), - sm.color, - ) - }) - .collect(); - - let mut bone_offsets: Vec = Vec::new(); + // Lightweight per-mesh metadata (no bone data cloned). + struct SkinnedSnap { + buffer: crate::libs::graphics::backend::BufferHandle, + vertex_count: i32, + model_matrix: [f32; 16], + color: [f32; 4], + } + + // Pass 1: pack bones into scratch buffer and collect metadata. + let mut scratch_packed = std::mem::take(&mut self.scratch_packed_bones); + scratch_packed.clear(); + let mut scratch_offsets = std::mem::take(&mut self.scratch_bone_offsets); + scratch_offsets.clear(); + + let mut skinned_snaps: Vec = Vec::with_capacity(self.skinned_meshes.len()); + if gpu_skinning { - let mut packed_bones: Vec = Vec::new(); - for (_buffer, _vc, _pos, _rot, _scl, bone_mats, _color) in &skinned_snaps { - bone_offsets.push((packed_bones.len() / 16) as i32); - for mat in bone_mats.iter() { - packed_bones.extend_from_slice(mat); + let bone_pack_start = std::time::Instant::now(); + for sm in self.skinned_meshes.values() { + scratch_offsets.push((scratch_packed.len() / 16) as i32); + for mat in &sm.bone_matrices { + scratch_packed.extend_from_slice(mat); } + skinned_snaps.push(SkinnedSnap { + buffer: sm.buffer, + vertex_count: sm.vertex_count, + model_matrix: sm.cached_model_matrix, + color: sm.color, + }); } - if !packed_bones.is_empty() { - let bone_data: &[u8] = bytemuck::cast_slice(&packed_bones); + let bone_pack_us = bone_pack_start.elapsed().as_micros() as u64; + crate::libs::graphics::frame_timing::record_phase("bone_pack", bone_pack_us); + + if !scratch_packed.is_empty() { + let bone_upload_start = std::time::Instant::now(); + let bone_data: &[u8] = bytemuck::cast_slice(&scratch_packed); self.ensure_bone_storage_buffer(bone_data.len()); if let Some(storage_handle) = self.bone_storage_buffer { if let Err(e) = @@ -383,33 +413,51 @@ impl Renderer3D { } let _ = self.backend.bind_storage_buffer(storage_handle, 0); } + let bone_upload_us = bone_upload_start.elapsed().as_micros() as u64; + crate::libs::graphics::frame_timing::record_phase( + "bone_upload", + bone_upload_us, + ); self.stats.bone_matrix_uploads += 1; } + } else { + // CPU skinning path: collect metadata only (bones uploaded per-mesh below). + for sm in self.skinned_meshes.values() { + skinned_snaps.push(SkinnedSnap { + buffer: sm.buffer, + vertex_count: sm.vertex_count, + model_matrix: sm.cached_model_matrix, + color: sm.color, + }); + } } - for (snap_idx, (buffer, vc, pos, rot, scl, bone_mats, color)) in - skinned_snaps.iter().enumerate() - { - let model = Self::create_model_matrix(*pos, *rot, *scl); - let model_arr = mat4_to_array(&model); + // Pass 2: draw each skinned mesh using metadata. + // For CPU skinning we need to read bone_matrices from skinned_meshes + // again, but only to upload uniforms (no clone). + let skinned_mesh_keys: Vec = self.skinned_meshes.keys().copied().collect(); + for (snap_idx, snap) in skinned_snaps.iter().enumerate() { self.backend - .set_uniform_mat4(skinned_unis.main.model, &model_arr); + .set_uniform_mat4(skinned_unis.main.model, &snap.model_matrix); self.backend .set_uniform_int(skinned_unis.main.use_texture, 0); self.backend.set_uniform_vec4( skinned_unis.main.object_color, - color[0], - color[1], - color[2], - color[3], + snap.color[0], + snap.color[1], + snap.color[2], + snap.color[3], ); self.stats.skinned_instances += 1; if gpu_skinning { self.backend - .set_uniform_int(skinned_unis.bone_offset, bone_offsets[snap_idx]); - } else { - for (i, mat) in bone_mats.iter().enumerate() { + .set_uniform_int(skinned_unis.bone_offset, scratch_offsets[snap_idx]); + } else if let Some(sm) = skinned_mesh_keys + .get(snap_idx) + .and_then(|k| self.skinned_meshes.get(k)) + { + for (i, mat) in sm.bone_matrices.iter().enumerate() { if i < skinned_unis.bone_matrices.len() { self.backend .set_uniform_mat4(skinned_unis.bone_matrices[i], mat); @@ -418,14 +466,20 @@ impl Renderer3D { self.stats.bone_matrix_uploads += 1; } - let _ = self.backend.bind_buffer(*buffer); + let _ = self.backend.bind_buffer(snap.buffer); self.backend.set_vertex_attributes(&self.skinned_layout); - let _ = self - .backend - .draw_arrays(PrimitiveTopology::Triangles, 0, *vc as u32); + let _ = self.backend.draw_arrays( + PrimitiveTopology::Triangles, + 0, + snap.vertex_count as u32, + ); self.stats.draw_calls += 1; } + // Return scratch buffers. + self.scratch_packed_bones = scratch_packed; + self.scratch_bone_offsets = scratch_offsets; + if gpu_skinning { self.backend.unbind_storage_buffer(); } @@ -477,6 +531,9 @@ impl Renderer3D { self.render_debug_draw(&view_arr, &proj_arr, &eff_fog); self.backend.disable_culling(); + // Return the scratch buffer so it can be reused next frame. + self.scratch_filtered_lights = filtered_lights; + let render3d_us = render3d_start.elapsed().as_micros() as u64; crate::libs::graphics::frame_timing::record_phase("render3d_scene", render3d_us); } diff --git a/goud_engine/src/libs/graphics/renderer3d/render/shadow_render.rs b/goud_engine/src/libs/graphics/renderer3d/render/shadow_render.rs index 251f9d826..d8c54f819 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render/shadow_render.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render/shadow_render.rs @@ -5,8 +5,8 @@ use super::mat4_to_array; use crate::libs::graphics::backend::PrimitiveTopology; use crate::libs::graphics::renderer3d::core::Renderer3D; -use crate::libs::graphics::renderer3d::shadow::compute_light_space_matrix; -use cgmath::Matrix4; +use crate::libs::graphics::renderer3d::shadow::compute_light_space_matrix_with_skinned; +use cgmath::{Matrix4, Vector3}; impl Renderer3D { /// Records the GPU shadow pre-pass draw commands into the backend's @@ -16,7 +16,11 @@ impl Renderer3D { pub(in crate::libs::graphics::renderer3d) fn record_gpu_shadow_pre_pass( &mut self, ) -> ([f32; 16], bool) { - let Some((lsm, _dir)) = compute_light_space_matrix(&self.objects, &self.lights) else { + let Some((lsm, _dir)) = compute_light_space_matrix_with_skinned( + &self.objects, + &self.lights, + Some(&self.skinned_meshes), + ) else { return (mat4_to_array(&Matrix4::from_scale(1.0)), false); }; let lsm_arr = mat4_to_array(&lsm); @@ -49,6 +53,15 @@ impl Renderer3D { continue; } } + // Shadow frustum culling: project the bounding sphere center into + // light clip space and reject if it is entirely outside the ortho + // frustum (NDC range [-1,1] on each axis), accounting for the radius. + let world_center = obj.position + obj.bounds.center; + let max_scale = obj.scale.x.max(obj.scale.y).max(obj.scale.z); + let world_radius = obj.bounds.radius * max_scale; + if !sphere_in_light_frustum(&lsm, world_center, world_radius) { + continue; + } let model = Self::create_model_matrix(obj.position, obj.rotation, obj.scale); let mvp = lsm * model; let mvp_arr = mat4_to_array(&mvp); @@ -61,9 +74,17 @@ impl Renderer3D { .draw_arrays(PrimitiveTopology::Triangles, 0, obj.vertex_count as u32); } - // Also render skinned meshes to the shadow map. + // Also render skinned meshes to the shadow map using cached model matrices. for sm in self.skinned_meshes.values() { - let model = Self::create_model_matrix(sm.position, sm.rotation, sm.scale); + // Use the cached model matrix (recomputed at the top of render() when dirty). + let a = &sm.cached_model_matrix; + let cols: [[f32; 4]; 4] = [ + [a[0], a[1], a[2], a[3]], + [a[4], a[5], a[6], a[7]], + [a[8], a[9], a[10], a[11]], + [a[12], a[13], a[14], a[15]], + ]; + let model: Matrix4 = cols.into(); let mvp = lsm * model; let mvp_arr = mat4_to_array(&mvp); self.backend @@ -79,3 +100,38 @@ impl Renderer3D { (lsm_arr, true) } } + +/// Returns `true` if a world-space bounding sphere is at least partially +/// inside the orthographic light frustum encoded by `lsm`. +/// +/// Projects the sphere center into clip space and checks whether the sphere +/// (expanded by its radius projected into clip space) overlaps the NDC cube +/// `[-1, 1]` on each axis. +fn sphere_in_light_frustum(lsm: &Matrix4, center: Vector3, radius: f32) -> bool { + let clip = *lsm * center.extend(1.0); + // For orthographic projection w is always 1, but guard against edge cases. + let w = clip.w.abs().max(f32::EPSILON); + let ndc_x = clip.x / w; + let ndc_y = clip.y / w; + let ndc_z = clip.z / w; + + // Approximate the clip-space radius by scaling by the largest axis scale + // of the light-space-matrix (conservative upper bound). + let sx = (lsm.x.x * lsm.x.x + lsm.x.y * lsm.x.y + lsm.x.z * lsm.x.z).sqrt(); + let sy = (lsm.y.x * lsm.y.x + lsm.y.y * lsm.y.y + lsm.y.z * lsm.y.z).sqrt(); + let sz = (lsm.z.x * lsm.z.x + lsm.z.y * lsm.z.y + lsm.z.z * lsm.z.z).sqrt(); + let max_scale = sx.max(sy).max(sz); + let clip_radius = radius * max_scale / w; + + // Reject if the sphere is entirely outside any face of the NDC cube. + if ndc_x - clip_radius > 1.0 || ndc_x + clip_radius < -1.0 { + return false; + } + if ndc_y - clip_radius > 1.0 || ndc_y + clip_radius < -1.0 { + return false; + } + if ndc_z - clip_radius > 1.0 || ndc_z + clip_radius < -1.0 { + return false; + } + true +} diff --git a/goud_engine/src/libs/graphics/renderer3d/render_helpers.rs b/goud_engine/src/libs/graphics/renderer3d/render_helpers.rs index 190ec2f74..0cc45731c 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render_helpers.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render_helpers.rs @@ -153,7 +153,7 @@ impl Renderer3D { fog: &super::types::FogConfig, lights: &[super::types::Light], texture_manager: Option<&dyn super::texture::TextureManagerTrait>, - instanced_ids: &std::collections::HashSet, + instanced_ids: &rustc_hash::FxHashSet, ) { let scene_model_filter = self .current_scene diff --git a/goud_engine/src/libs/graphics/renderer3d/render_instanced_skinned.rs b/goud_engine/src/libs/graphics/renderer3d/render_instanced_skinned.rs index 96089122f..9d5e89da5 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render_instanced_skinned.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render_instanced_skinned.rs @@ -24,16 +24,16 @@ impl Renderer3D { fog: &super::types::FogConfig, lights: &[super::types::Light], _texture_manager: Option<&dyn super::texture::TextureManagerTrait>, - ) -> std::collections::HashSet { + ) -> rustc_hash::FxHashSet { let gpu_skinning = matches!(self.config.skinning.mode, super::config::SkinningMode::Gpu) && self.backend.supports_storage_buffers(); if !gpu_skinning { - return std::collections::HashSet::new(); + return rustc_hash::FxHashSet::default(); } // Group instances by source_model_id. - let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + let mut groups: rustc_hash::FxHashMap> = rustc_hash::FxHashMap::default(); for (&inst_id, inst) in &self.model_instances { match self.models.get(&inst.source_model_id) { Some(m) if m.is_skinned => {} @@ -60,11 +60,11 @@ impl Renderer3D { .collect(); if groups.is_empty() { - return std::collections::HashSet::new(); + return rustc_hash::FxHashSet::default(); } // Collect all IDs that will be rendered via instancing. - let mut handled_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut handled_ids: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); for (_, ids) in &groups { for &id in ids { handled_ids.insert(id); @@ -118,18 +118,24 @@ impl Renderer3D { // Each instance's bone_offset in the instance data includes the // group's base offset into the packed buffer. struct GroupRenderData { - instance_data: Vec, instance_count: u32, mesh_object_ids: Vec, mesh_material_ids: Vec, + /// Byte offset into scratch_inst_data for this group's instance data. + inst_data_start: usize, + inst_data_end: usize, } - let mut all_packed_bones: Vec = Vec::new(); + let mut all_packed_bones = std::mem::take(&mut self.scratch_inst_packed_bones); + all_packed_bones.clear(); + let mut scratch_inst_data = std::mem::take(&mut self.scratch_inst_data); + scratch_inst_data.clear(); let mut group_render_data: Vec = Vec::new(); + let mut group_data = group_data; - for gd in &group_data { + for gd in &mut group_data { let group_bone_offset = all_packed_bones.len() / 16; - let mut instance_data: Vec = Vec::new(); + let inst_data_start = scratch_inst_data.len(); for (inst_idx, &inst_id) in gd.instance_ids.iter().enumerate() { let bone_offset = (group_bone_offset + inst_idx * gd.bone_count) as f32; @@ -147,15 +153,15 @@ impl Renderer3D { } } - let obj_ids = if let Some(m) = self.models.get(&inst_id) { - m.mesh_object_ids.clone() + let first_obj_id = if let Some(m) = self.models.get(&inst_id) { + m.mesh_object_ids.first().copied() } else if let Some(inst) = self.model_instances.get(&inst_id) { - inst.mesh_object_ids.clone() + inst.mesh_object_ids.first().copied() } else { continue; }; - let (pos, rot, scl) = if let Some(&first_oid) = obj_ids.first() { + let (pos, rot, scl) = if let Some(first_oid) = first_obj_id { if let Some(obj) = self.objects.get(&first_oid) { (obj.position, obj.rotation, obj.scale) } else { @@ -167,17 +173,18 @@ impl Renderer3D { let model_mat = Self::create_model_matrix(pos, rot, scl); let model_arr = super::render::mat4_to_array(&model_mat); - instance_data.extend_from_slice(&model_arr); - instance_data.push(bone_offset); - instance_data.extend_from_slice(&[0.0, 0.0, 0.0]); // padding - instance_data.extend_from_slice(&[1.0, 1.0, 1.0, 1.0]); // color + scratch_inst_data.extend_from_slice(&model_arr); + scratch_inst_data.push(bone_offset); + scratch_inst_data.extend_from_slice(&[0.0, 0.0, 0.0]); // padding + scratch_inst_data.extend_from_slice(&[1.0, 1.0, 1.0, 1.0]); // color } group_render_data.push(GroupRenderData { - instance_data, instance_count: gd.instance_ids.len() as u32, - mesh_object_ids: gd.mesh_object_ids.clone(), - mesh_material_ids: gd.mesh_material_ids.clone(), + mesh_object_ids: std::mem::take(&mut gd.mesh_object_ids), + mesh_material_ids: std::mem::take(&mut gd.mesh_material_ids), + inst_data_start, + inst_data_end: scratch_inst_data.len(), }); } @@ -191,6 +198,8 @@ impl Renderer3D { .update_storage_buffer(storage_handle, 0, bone_data) { log::error!("Instanced skinning storage buffer upload failed: {e}"); + self.scratch_inst_packed_bones = all_packed_bones; + self.scratch_inst_data = scratch_inst_data; return handled_ids; } let _ = self.backend.bind_storage_buffer(storage_handle, 0); @@ -207,7 +216,8 @@ impl Renderer3D { // write_buffer calls and only the last write to a given offset // survives into the render pass. Reuse a pool of per-group buffers. for (group_idx, grd) in group_render_data.iter().enumerate() { - let instance_bytes: &[u8] = bytemuck::cast_slice(&grd.instance_data); + let instance_slice = &scratch_inst_data[grd.inst_data_start..grd.inst_data_end]; + let instance_bytes: &[u8] = bytemuck::cast_slice(instance_slice); let required_size = instance_bytes.len(); // Grow the pool if needed. @@ -312,6 +322,10 @@ impl Renderer3D { } } + // Return scratch buffers so they can be reused next frame. + self.scratch_inst_packed_bones = all_packed_bones; + self.scratch_inst_data = scratch_inst_data; + self.backend.unbind_storage_buffer(); self.backend.unbind_shader(); diff --git a/goud_engine/src/libs/graphics/renderer3d/scene.rs b/goud_engine/src/libs/graphics/renderer3d/scene.rs index adc58f860..0356bf05e 100644 --- a/goud_engine/src/libs/graphics/renderer3d/scene.rs +++ b/goud_engine/src/libs/graphics/renderer3d/scene.rs @@ -1,6 +1,6 @@ //! Named 3D scene containing a subset of renderer objects, models, lights, and environment config. -use std::collections::HashSet; +use rustc_hash::FxHashSet; use super::types::{FogConfig, GridConfig, SkyboxConfig}; @@ -13,11 +13,11 @@ pub struct Scene3D { /// Human-readable scene name. pub name: String, /// [`Object3D`](super::types::Object3D) IDs belonging to this scene. - pub objects: HashSet, + pub objects: FxHashSet, /// [`Model3D`](super::model::Model3D) / [`ModelInstance3D`](super::model::ModelInstance3D) IDs. - pub models: HashSet, + pub models: FxHashSet, /// [`Light`](super::types::Light) IDs belonging to this scene. - pub lights: HashSet, + pub lights: FxHashSet, /// Per-scene fog configuration. pub fog: FogConfig, /// Per-scene skybox configuration. @@ -31,9 +31,9 @@ impl Scene3D { pub fn new(name: String) -> Self { Self { name, - objects: HashSet::new(), - models: HashSet::new(), - lights: HashSet::new(), + objects: FxHashSet::default(), + models: FxHashSet::default(), + lights: FxHashSet::default(), fog: FogConfig::default(), skybox: SkyboxConfig::default(), grid: GridConfig::default(), diff --git a/goud_engine/src/libs/graphics/renderer3d/shaders.rs b/goud_engine/src/libs/graphics/renderer3d/shaders.rs index 118e29f0f..f78b34e59 100644 --- a/goud_engine/src/libs/graphics/renderer3d/shaders.rs +++ b/goud_engine/src/libs/graphics/renderer3d/shaders.rs @@ -10,7 +10,7 @@ use super::types::MAX_LIGHTS; include!("shader_sources.in"); -#[derive(Clone)] +#[derive(Clone, Copy)] pub(super) struct LightUniforms { pub(super) light_type: i32, pub(super) position: i32, diff --git a/goud_engine/src/libs/graphics/renderer3d/shadow.rs b/goud_engine/src/libs/graphics/renderer3d/shadow.rs index e6365d18a..15aa94138 100644 --- a/goud_engine/src/libs/graphics/renderer3d/shadow.rs +++ b/goud_engine/src/libs/graphics/renderer3d/shadow.rs @@ -1,6 +1,7 @@ +use super::skinned_mesh::SkinnedMesh3D; use super::types::{Light, LightType, Object3D}; use cgmath::{EuclideanSpace, InnerSpace, Matrix4, Point3, Vector3, Vector4}; -use std::collections::HashMap; +use rustc_hash::FxHashMap; /// CPU-built directional shadow map used as a compatibility fallback. /// @@ -25,8 +26,21 @@ const MAX_SHADOW_VERTICES: usize = 10_000; /// directional light. Returns `None` if no directional light is present or /// the scene has no geometry. pub(super) fn compute_light_space_matrix( - objects: &HashMap, - lights: &HashMap, + objects: &FxHashMap, + lights: &FxHashMap, +) -> Option<(Matrix4, Vector3)> { + compute_light_space_matrix_with_skinned(objects, lights, None) +} + +/// Computes the light-space matrix considering both regular objects (via +/// cached world-space bounding spheres) and optionally skinned meshes. +/// +/// Using bounding spheres instead of transforming every vertex reduces the +/// cost from O(total_vertices) to O(num_objects) per frame. +pub(super) fn compute_light_space_matrix_with_skinned( + objects: &FxHashMap, + lights: &FxHashMap, + skinned_meshes: Option<&FxHashMap>, ) -> Option<(Matrix4, Vector3)> { let light = lights .values() @@ -37,21 +51,51 @@ pub(super) fn compute_light_space_matrix( Vector3::new(0.0, -1.0, 0.0) }; - let world_positions: Vec> = objects - .values() - .filter(|o| !o.vertices.is_empty()) - .flat_map(|o| { - let model = - super::core::Renderer3D::create_model_matrix(o.position, o.rotation, o.scale); - world_positions(&o.vertices, model) - }) - .collect(); + // Use cached world-space bounding spheres instead of transforming every vertex. + // This reduces cost from O(total_vertices) to O(num_objects). + let mut scene_min = Vector3::new(f32::MAX, f32::MAX, f32::MAX); + let mut scene_max = Vector3::new(f32::MIN, f32::MIN, f32::MIN); + let mut has_geometry = false; + + for obj in objects.values() { + if obj.vertices.is_empty() { + continue; + } + let center = obj.position + obj.bounds.center; + let max_scale = obj.scale.x.max(obj.scale.y).max(obj.scale.z); + let r = obj.bounds.radius * max_scale; + scene_min.x = scene_min.x.min(center.x - r); + scene_min.y = scene_min.y.min(center.y - r); + scene_min.z = scene_min.z.min(center.z - r); + scene_max.x = scene_max.x.max(center.x + r); + scene_max.y = scene_max.y.max(center.y + r); + scene_max.z = scene_max.z.max(center.z + r); + has_geometry = true; + } - if world_positions.is_empty() { + // Include skinned meshes in scene bounds. They lack a dedicated bounding + // sphere, so approximate with position and max-axis scale as radius. + if let Some(skinned) = skinned_meshes { + for sm in skinned.values() { + if sm.vertices.is_empty() { + continue; + } + let center = sm.position; + let r = sm.scale.x.max(sm.scale.y).max(sm.scale.z); + scene_min.x = scene_min.x.min(center.x - r); + scene_min.y = scene_min.y.min(center.y - r); + scene_min.z = scene_min.z.min(center.z - r); + scene_max.x = scene_max.x.max(center.x + r); + scene_max.y = scene_max.y.max(center.y + r); + scene_max.z = scene_max.z.max(center.z + r); + has_geometry = true; + } + } + + if !has_geometry { return None; } - let (scene_min, scene_max) = bounds(&world_positions); let center = (scene_min + scene_max) * 0.5; let extent = scene_max - scene_min; let scene_radius = @@ -67,14 +111,30 @@ pub(super) fn compute_light_space_matrix( }; let light_view = Matrix4::look_at_rh(light_origin, light_target, up); - let light_space_points: Vec> = world_positions - .iter() - .map(|p| { - let proj = light_view * p.extend(1.0); - proj.truncate() - }) - .collect(); - let (light_min, light_max) = bounds(&light_space_points); + // Project the eight corners of the scene AABB into light space to compute + // a tight orthographic frustum. This is O(8) instead of O(total_vertices). + let corners = [ + Vector3::new(scene_min.x, scene_min.y, scene_min.z), + Vector3::new(scene_min.x, scene_min.y, scene_max.z), + Vector3::new(scene_min.x, scene_max.y, scene_min.z), + Vector3::new(scene_min.x, scene_max.y, scene_max.z), + Vector3::new(scene_max.x, scene_min.y, scene_min.z), + Vector3::new(scene_max.x, scene_min.y, scene_max.z), + Vector3::new(scene_max.x, scene_max.y, scene_min.z), + Vector3::new(scene_max.x, scene_max.y, scene_max.z), + ]; + let mut light_min = Vector3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY); + let mut light_max = Vector3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for c in &corners { + let proj = (light_view * c.extend(1.0)).truncate(); + light_min.x = light_min.x.min(proj.x); + light_min.y = light_min.y.min(proj.y); + light_min.z = light_min.z.min(proj.z); + light_max.x = light_max.x.max(proj.x); + light_max.y = light_max.y.max(proj.y); + light_max.z = light_max.z.max(proj.z); + } + // Pad the near/far planes proportionally to the scene size. let ortho_pad = scene_radius * 0.25; let projection = cgmath::ortho( @@ -90,8 +150,8 @@ pub(super) fn compute_light_space_matrix( } pub(super) fn build_directional_shadow_map( - objects: &HashMap, - lights: &HashMap, + objects: &FxHashMap, + lights: &FxHashMap, size: u32, ) -> Option { let (light_space_matrix, direction) = compute_light_space_matrix(objects, lights)?; @@ -257,6 +317,9 @@ fn rasterize_shadow_map( } } +/// Transforms all vertices into world space. Retained for the software shadow +/// rasterizer test path (`build_shadow_map_from_meshes`). +#[allow(dead_code)] fn world_positions(vertices: &[f32], model: Matrix4) -> Vec> { vertices .chunks_exact(8) @@ -267,6 +330,9 @@ fn world_positions(vertices: &[f32], model: Matrix4) -> Vec> { .collect() } +/// Computes axis-aligned bounding box from a set of points. Retained for the +/// software shadow rasterizer test path. +#[allow(dead_code)] fn bounds(points: &[Vector3]) -> (Vector3, Vector3) { let mut min = Vector3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY); let mut max = Vector3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); diff --git a/goud_engine/src/libs/graphics/renderer3d/skinned_mesh.rs b/goud_engine/src/libs/graphics/renderer3d/skinned_mesh.rs index b4d4f3c7f..e3fb2030f 100644 --- a/goud_engine/src/libs/graphics/renderer3d/skinned_mesh.rs +++ b/goud_engine/src/libs/graphics/renderer3d/skinned_mesh.rs @@ -74,4 +74,9 @@ pub struct SkinnedMesh3D { pub(in crate::libs::graphics::renderer3d) scale: Vector3, /// Base color for the skinned mesh (RGBA). pub(in crate::libs::graphics::renderer3d) color: [f32; 4], + /// Cached model matrix (column-major 4x4). Recomputed only when + /// position/rotation/scale change (`transform_dirty` is set). + pub(in crate::libs::graphics::renderer3d) cached_model_matrix: [f32; 16], + /// Whether the cached model matrix needs recomputation. + pub(in crate::libs::graphics::renderer3d) transform_dirty: bool, } diff --git a/goud_engine/src/libs/graphics/renderer3d/types.rs b/goud_engine/src/libs/graphics/renderer3d/types.rs index 2479b65ea..33e5691bb 100644 --- a/goud_engine/src/libs/graphics/renderer3d/types.rs +++ b/goud_engine/src/libs/graphics/renderer3d/types.rs @@ -283,7 +283,7 @@ pub(in crate::libs::graphics::renderer3d) struct ParticleEmitter { } /// A light in the scene -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] #[allow(missing_docs)] pub struct Light { /// Type of light @@ -320,7 +320,7 @@ impl Default for Light { } /// Grid configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] #[allow(missing_docs)] pub struct GridConfig { /// Whether grid is enabled @@ -360,7 +360,7 @@ impl Default for GridConfig { } /// Skybox configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] #[allow(missing_docs)] pub struct SkyboxConfig { /// Whether skybox is enabled @@ -396,7 +396,7 @@ pub enum FogMode { } /// Fog configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] #[allow(missing_docs)] pub struct FogConfig { /// Whether fog is enabled diff --git a/goud_engine/tests/jni/java/com/goudengine/internal/FramePhaseTimings.java b/goud_engine/tests/jni/java/com/goudengine/internal/FramePhaseTimings.java new file mode 100644 index 000000000..eeb4d5cb7 --- /dev/null +++ b/goud_engine/tests/jni/java/com/goudengine/internal/FramePhaseTimings.java @@ -0,0 +1,34 @@ +// This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. +package com.goudengine.internal; + +public final class FramePhaseTimings { + public long surfaceAcquireUs; + public long shadowPassUs; + public long shadowBuildUs; + public long render3dSceneUs; + public long uniformUploadUs; + public long renderPassUs; + public long gpuSubmitUs; + public long readbackStallUs; + public long surfacePresentUs; + public long animEvalUs; + public long bonePackUs; + public long boneUploadUs; + + public FramePhaseTimings() {} + + public FramePhaseTimings(long surfaceAcquireUs, long shadowPassUs, long shadowBuildUs, long render3dSceneUs, long uniformUploadUs, long renderPassUs, long gpuSubmitUs, long readbackStallUs, long surfacePresentUs, long animEvalUs, long bonePackUs, long boneUploadUs) { + this.surfaceAcquireUs = surfaceAcquireUs; + this.shadowPassUs = shadowPassUs; + this.shadowBuildUs = shadowBuildUs; + this.render3dSceneUs = render3dSceneUs; + this.uniformUploadUs = uniformUploadUs; + this.renderPassUs = renderPassUs; + this.gpuSubmitUs = gpuSubmitUs; + this.readbackStallUs = readbackStallUs; + this.surfacePresentUs = surfacePresentUs; + this.animEvalUs = animEvalUs; + this.bonePackUs = bonePackUs; + this.boneUploadUs = boneUploadUs; + } +} diff --git a/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java b/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java index e5cef2653..b759da5b5 100644 --- a/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java +++ b/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java @@ -141,6 +141,7 @@ private GoudGameNative() {} public static native int getBoneMatrixUploadCount(long contextId); public static native int getAnimationEvaluationCount(long contextId); public static native int getAnimationEvaluationSavedCount(long contextId); + public static native FramePhaseTimings getFramePhaseTimings(); public static native boolean render3D(long contextId); public static native int createMaterial(long contextId, int materialType, float r, float g, float b, float a, float shininess, float metallic, float roughness, float ao); public static native boolean updateMaterial(long contextId, int materialId, int materialType, float r, float g, float b, float a, float shininess, float metallic, float roughness, float ao); diff --git a/sdks/csharp/generated/GoudGame.g.cs b/sdks/csharp/generated/GoudGame.g.cs index 3a6b518b4..65565ccae 100644 --- a/sdks/csharp/generated/GoudGame.g.cs +++ b/sdks/csharp/generated/GoudGame.g.cs @@ -1110,6 +1110,14 @@ public int GetAnimationEvaluationSavedCount() return NativeMethods.goud_renderer3d_get_animation_evaluation_saved_count(_ctx); } + /// Returns per-frame phase timings for performance diagnosis (all values in microseconds) + public FramePhaseTimings GetFramePhaseTimings() + { + FfiFramePhaseTimings _out_timings = default; + NativeMethods.goud_renderer_get_frame_phase_timings(ref _out_timings); + return new FramePhaseTimings(_out_timings.SurfaceAcquireUs, _out_timings.ShadowPassUs, _out_timings.ShadowBuildUs, _out_timings.Render3dSceneUs, _out_timings.UniformUploadUs, _out_timings.RenderPassUs, _out_timings.GpuSubmitUs, _out_timings.ReadbackStallUs, _out_timings.SurfacePresentUs, _out_timings.AnimEvalUs, _out_timings.BonePackUs, _out_timings.BoneUploadUs); + } + /// Renders all 3D objects public bool Render3D() { diff --git a/sdks/csharp/generated/Math/FramePhaseTimings.g.cs b/sdks/csharp/generated/Math/FramePhaseTimings.g.cs index dc6c7390f..0e99a9b6d 100644 --- a/sdks/csharp/generated/Math/FramePhaseTimings.g.cs +++ b/sdks/csharp/generated/Math/FramePhaseTimings.g.cs @@ -8,6 +8,7 @@ namespace GoudEngine public struct FramePhaseTimings { public ulong SurfaceAcquireUs; + public ulong ShadowPassUs; public ulong ShadowBuildUs; public ulong Render3dSceneUs; public ulong UniformUploadUs; @@ -15,10 +16,14 @@ public struct FramePhaseTimings public ulong GpuSubmitUs; public ulong ReadbackStallUs; public ulong SurfacePresentUs; + public ulong AnimEvalUs; + public ulong BonePackUs; + public ulong BoneUploadUs; - public FramePhaseTimings(ulong surfaceacquireus, ulong shadowbuildus, ulong render3dsceneus, ulong uniformuploadus, ulong renderpassus, ulong gpusubmitus, ulong readbackstallus, ulong surfacepresentus) + public FramePhaseTimings(ulong surfaceacquireus, ulong shadowpassus, ulong shadowbuildus, ulong render3dsceneus, ulong uniformuploadus, ulong renderpassus, ulong gpusubmitus, ulong readbackstallus, ulong surfacepresentus, ulong animevalus, ulong bonepackus, ulong boneuploadus) { SurfaceAcquireUs = surfaceacquireus; + ShadowPassUs = shadowpassus; ShadowBuildUs = shadowbuildus; Render3dSceneUs = render3dsceneus; UniformUploadUs = uniformuploadus; @@ -26,10 +31,13 @@ public FramePhaseTimings(ulong surfaceacquireus, ulong shadowbuildus, ulong rend GpuSubmitUs = gpusubmitus; ReadbackStallUs = readbackstallus; SurfacePresentUs = surfacepresentus; + AnimEvalUs = animevalus; + BonePackUs = bonepackus; + BoneUploadUs = boneuploadus; } - public override string ToString() => $"FramePhaseTimings({SurfaceAcquireUs}, {ShadowBuildUs}, {Render3dSceneUs}, {UniformUploadUs}, {RenderPassUs}, {GpuSubmitUs}, {ReadbackStallUs}, {SurfacePresentUs})"; + public override string ToString() => $"FramePhaseTimings({SurfaceAcquireUs}, {ShadowPassUs}, {ShadowBuildUs}, {Render3dSceneUs}, {UniformUploadUs}, {RenderPassUs}, {GpuSubmitUs}, {ReadbackStallUs}, {SurfacePresentUs}, {AnimEvalUs}, {BonePackUs}, {BoneUploadUs})"; } } \ No newline at end of file diff --git a/sdks/csharp/generated/NativeMethods.g.cs b/sdks/csharp/generated/NativeMethods.g.cs index 7ce265de8..356afa2af 100644 --- a/sdks/csharp/generated/NativeMethods.g.cs +++ b/sdks/csharp/generated/NativeMethods.g.cs @@ -113,6 +113,7 @@ public struct FfiRenderMetrics public struct FfiFramePhaseTimings { public ulong SurfaceAcquireUs; + public ulong ShadowPassUs; public ulong ShadowBuildUs; public ulong Render3dSceneUs; public ulong UniformUploadUs; @@ -120,6 +121,9 @@ public struct FfiFramePhaseTimings public ulong GpuSubmitUs; public ulong ReadbackStallUs; public ulong SurfacePresentUs; + public ulong AnimEvalUs; + public ulong BonePackUs; + public ulong BoneUploadUs; } [StructLayout(LayoutKind.Sequential)] diff --git a/sdks/csharp/include/goud_engine.h b/sdks/csharp/include/goud_engine.h index f5392336d..e1a5ff840 100644 --- a/sdks/csharp/include/goud_engine.h +++ b/sdks/csharp/include/goud_engine.h @@ -1293,6 +1293,18 @@ typedef struct FfiFramePhaseTimings { * Surface present / vsync wait time (us). */ uint64_t surface_present_us; + /** + * Animation evaluation time (us). + */ + uint64_t anim_eval_us; + /** + * Bone matrix packing time (us). + */ + uint64_t bone_pack_us; + /** + * Bone matrix GPU upload time (us). + */ + uint64_t bone_upload_us; } FfiFramePhaseTimings; /** diff --git a/sdks/go/goud/game.go b/sdks/go/goud/game.go index 8be9ecf49..f2fcbfda7 100644 --- a/sdks/go/goud/game.go +++ b/sdks/go/goud/game.go @@ -814,6 +814,11 @@ func (g *Game) GetAnimationEvaluationSavedCount() int32 { return 0 } +// GetFramePhaseTimings Returns per-frame phase timings for performance diagnosis (all values in microseconds) +func (g *Game) GetFramePhaseTimings() FramePhaseTimings { + return FramePhaseTimings{} +} + // Render3D Renders all 3D objects func (g *Game) Render3D() bool { return false diff --git a/sdks/go/goud/types.go b/sdks/go/goud/types.go index f29285212..7fc582473 100644 --- a/sdks/go/goud/types.go +++ b/sdks/go/goud/types.go @@ -427,6 +427,7 @@ func NewRenderMetrics(drawCallCount uint32, spritesSubmitted uint32, spritesDraw // FramePhaseTimings Per-frame phase timings for performance diagnosis. All values in microseconds. type FramePhaseTimings struct { SurfaceAcquireUs uint64 + ShadowPassUs uint64 ShadowBuildUs uint64 Render3dSceneUs uint64 UniformUploadUs uint64 @@ -434,12 +435,16 @@ type FramePhaseTimings struct { GpuSubmitUs uint64 ReadbackStallUs uint64 SurfacePresentUs uint64 + AnimEvalUs uint64 + BonePackUs uint64 + BoneUploadUs uint64 } // NewFramePhaseTimings creates a new FramePhaseTimings. -func NewFramePhaseTimings(surfaceAcquireUs uint64, shadowBuildUs uint64, render3dSceneUs uint64, uniformUploadUs uint64, renderPassUs uint64, gpuSubmitUs uint64, readbackStallUs uint64, surfacePresentUs uint64) FramePhaseTimings { +func NewFramePhaseTimings(surfaceAcquireUs uint64, shadowPassUs uint64, shadowBuildUs uint64, render3dSceneUs uint64, uniformUploadUs uint64, renderPassUs uint64, gpuSubmitUs uint64, readbackStallUs uint64, surfacePresentUs uint64, animEvalUs uint64, bonePackUs uint64, boneUploadUs uint64) FramePhaseTimings { return FramePhaseTimings{ SurfaceAcquireUs: surfaceAcquireUs, + ShadowPassUs: shadowPassUs, ShadowBuildUs: shadowBuildUs, Render3dSceneUs: render3dSceneUs, UniformUploadUs: uniformUploadUs, @@ -447,6 +452,9 @@ func NewFramePhaseTimings(surfaceAcquireUs uint64, shadowBuildUs uint64, render3 GpuSubmitUs: gpuSubmitUs, ReadbackStallUs: readbackStallUs, SurfacePresentUs: surfacePresentUs, + AnimEvalUs: animEvalUs, + BonePackUs: bonePackUs, + BoneUploadUs: boneUploadUs, } } diff --git a/sdks/go/include/goud_engine.h b/sdks/go/include/goud_engine.h index f5392336d..e1a5ff840 100644 --- a/sdks/go/include/goud_engine.h +++ b/sdks/go/include/goud_engine.h @@ -1293,6 +1293,18 @@ typedef struct FfiFramePhaseTimings { * Surface present / vsync wait time (us). */ uint64_t surface_present_us; + /** + * Animation evaluation time (us). + */ + uint64_t anim_eval_us; + /** + * Bone matrix packing time (us). + */ + uint64_t bone_pack_us; + /** + * Bone matrix GPU upload time (us). + */ + uint64_t bone_upload_us; } FfiFramePhaseTimings; /** diff --git a/sdks/kotlin/src/main/java/com/goudengine/internal/FramePhaseTimings.java b/sdks/kotlin/src/main/java/com/goudengine/internal/FramePhaseTimings.java new file mode 100644 index 000000000..eeb4d5cb7 --- /dev/null +++ b/sdks/kotlin/src/main/java/com/goudengine/internal/FramePhaseTimings.java @@ -0,0 +1,34 @@ +// This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. +package com.goudengine.internal; + +public final class FramePhaseTimings { + public long surfaceAcquireUs; + public long shadowPassUs; + public long shadowBuildUs; + public long render3dSceneUs; + public long uniformUploadUs; + public long renderPassUs; + public long gpuSubmitUs; + public long readbackStallUs; + public long surfacePresentUs; + public long animEvalUs; + public long bonePackUs; + public long boneUploadUs; + + public FramePhaseTimings() {} + + public FramePhaseTimings(long surfaceAcquireUs, long shadowPassUs, long shadowBuildUs, long render3dSceneUs, long uniformUploadUs, long renderPassUs, long gpuSubmitUs, long readbackStallUs, long surfacePresentUs, long animEvalUs, long bonePackUs, long boneUploadUs) { + this.surfaceAcquireUs = surfaceAcquireUs; + this.shadowPassUs = shadowPassUs; + this.shadowBuildUs = shadowBuildUs; + this.render3dSceneUs = render3dSceneUs; + this.uniformUploadUs = uniformUploadUs; + this.renderPassUs = renderPassUs; + this.gpuSubmitUs = gpuSubmitUs; + this.readbackStallUs = readbackStallUs; + this.surfacePresentUs = surfacePresentUs; + this.animEvalUs = animEvalUs; + this.bonePackUs = bonePackUs; + this.boneUploadUs = boneUploadUs; + } +} diff --git a/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java b/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java index e5cef2653..b759da5b5 100644 --- a/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java +++ b/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java @@ -141,6 +141,7 @@ private GoudGameNative() {} public static native int getBoneMatrixUploadCount(long contextId); public static native int getAnimationEvaluationCount(long contextId); public static native int getAnimationEvaluationSavedCount(long contextId); + public static native FramePhaseTimings getFramePhaseTimings(); public static native boolean render3D(long contextId); public static native int createMaterial(long contextId, int materialType, float r, float g, float b, float a, float shininess, float metallic, float roughness, float ao); public static native boolean updateMaterial(long contextId, int materialId, int materialType, float r, float g, float b, float a, float shininess, float metallic, float roughness, float ao); diff --git a/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt b/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt index 3834b5a31..57090c12e 100644 --- a/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt @@ -460,6 +460,11 @@ class GoudGame internal constructor(internal val contextId: Long) : AutoCloseabl fun getAnimationEvaluationSavedCount(): Int = GoudGameNative.getAnimationEvaluationSavedCount(contextId) + fun getFramePhaseTimings(): FramePhaseTimings { + val r = GoudGameNative.getFramePhaseTimings() + return com.goudengine.types.FramePhaseTimings(r.surfaceAcquireUs, r.shadowPassUs, r.shadowBuildUs, r.render3dSceneUs, r.uniformUploadUs, r.renderPassUs, r.gpuSubmitUs, r.readbackStallUs, r.surfacePresentUs, r.animEvalUs, r.bonePackUs, r.boneUploadUs) + } + fun render3D(): Boolean = GoudGameNative.render3D(contextId) diff --git a/sdks/kotlin/src/main/kotlin/com/goudengine/types/FramePhaseTimings.kt b/sdks/kotlin/src/main/kotlin/com/goudengine/types/FramePhaseTimings.kt index 7dbfbd57d..e23cc8d66 100644 --- a/sdks/kotlin/src/main/kotlin/com/goudengine/types/FramePhaseTimings.kt +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/types/FramePhaseTimings.kt @@ -2,5 +2,5 @@ package com.goudengine.types /** Per-frame phase timings for performance diagnosis. All values in microseconds. */ -data class FramePhaseTimings(val surfaceAcquireUs: Long, val shadowBuildUs: Long, val render3dSceneUs: Long, val uniformUploadUs: Long, val renderPassUs: Long, val gpuSubmitUs: Long, val readbackStallUs: Long, val surfacePresentUs: Long) { +data class FramePhaseTimings(val surfaceAcquireUs: Long, val shadowPassUs: Long, val shadowBuildUs: Long, val render3dSceneUs: Long, val uniformUploadUs: Long, val renderPassUs: Long, val gpuSubmitUs: Long, val readbackStallUs: Long, val surfacePresentUs: Long, val animEvalUs: Long, val bonePackUs: Long, val boneUploadUs: Long) { } diff --git a/sdks/python/goudengine/generated/_ffi.py b/sdks/python/goudengine/generated/_ffi.py index 43c253b03..9d88a1c95 100644 --- a/sdks/python/goudengine/generated/_ffi.py +++ b/sdks/python/goudengine/generated/_ffi.py @@ -161,13 +161,17 @@ class FfiRenderMetrics(ctypes.Structure): class FfiFramePhaseTimings(ctypes.Structure): _fields_ = [ ("surface_acquire_us", ctypes.c_uint64), + ("shadow_pass_us", ctypes.c_uint64), ("shadow_build_us", ctypes.c_uint64), ("render3d_scene_us", ctypes.c_uint64), ("uniform_upload_us", ctypes.c_uint64), ("render_pass_us", ctypes.c_uint64), ("gpu_submit_us", ctypes.c_uint64), ("readback_stall_us", ctypes.c_uint64), - ("surface_present_us", ctypes.c_uint64) + ("surface_present_us", ctypes.c_uint64), + ("anim_eval_us", ctypes.c_uint64), + ("bone_pack_us", ctypes.c_uint64), + ("bone_upload_us", ctypes.c_uint64) ] class GoudDebuggerConfig(ctypes.Structure): diff --git a/sdks/python/goudengine/generated/_game.py b/sdks/python/goudengine/generated/_game.py index 6c3bd6aa2..1bac7ef2a 100644 --- a/sdks/python/goudengine/generated/_game.py +++ b/sdks/python/goudengine/generated/_game.py @@ -728,6 +728,12 @@ def get_animation_evaluation_saved_count(self): """Returns the number of animation evaluations avoided last frame""" return self._lib.goud_renderer3d_get_animation_evaluation_saved_count(self._ctx) + def get_frame_phase_timings(self): + """Returns per-frame phase timings for performance diagnosis (all values in microseconds)""" + _out_timings = FfiFramePhaseTimings() + self._lib.goud_renderer_get_frame_phase_timings(ctypes.byref(_out_timings)) + return FramePhaseTimings(_out_timings.surface_acquire_us, _out_timings.shadow_pass_us, _out_timings.shadow_build_us, _out_timings.render3d_scene_us, _out_timings.uniform_upload_us, _out_timings.render_pass_us, _out_timings.gpu_submit_us, _out_timings.readback_stall_us, _out_timings.surface_present_us, _out_timings.anim_eval_us, _out_timings.bone_pack_us, _out_timings.bone_upload_us) + def render3_d(self): """Renders all 3D objects""" return self._lib.goud_renderer3d_render(self._ctx) diff --git a/sdks/python/goudengine/generated/_types.py b/sdks/python/goudengine/generated/_types.py index 70486688e..019a3f97a 100644 --- a/sdks/python/goudengine/generated/_types.py +++ b/sdks/python/goudengine/generated/_types.py @@ -1413,8 +1413,9 @@ def __repr__(self): class FramePhaseTimings: """Per-frame phase timings for performance diagnosis. All values in microseconds.""" - def __init__(self, surface_acquire_us: int = 0, shadow_build_us: int = 0, render3d_scene_us: int = 0, uniform_upload_us: int = 0, render_pass_us: int = 0, gpu_submit_us: int = 0, readback_stall_us: int = 0, surface_present_us: int = 0): + def __init__(self, surface_acquire_us: int = 0, shadow_pass_us: int = 0, shadow_build_us: int = 0, render3d_scene_us: int = 0, uniform_upload_us: int = 0, render_pass_us: int = 0, gpu_submit_us: int = 0, readback_stall_us: int = 0, surface_present_us: int = 0, anim_eval_us: int = 0, bone_pack_us: int = 0, bone_upload_us: int = 0): self.surface_acquire_us = surface_acquire_us + self.shadow_pass_us = shadow_pass_us self.shadow_build_us = shadow_build_us self.render3d_scene_us = render3d_scene_us self.uniform_upload_us = uniform_upload_us @@ -1422,9 +1423,12 @@ def __init__(self, surface_acquire_us: int = 0, shadow_build_us: int = 0, render self.gpu_submit_us = gpu_submit_us self.readback_stall_us = readback_stall_us self.surface_present_us = surface_present_us + self.anim_eval_us = anim_eval_us + self.bone_pack_us = bone_pack_us + self.bone_upload_us = bone_upload_us def __repr__(self): - return f"FramePhaseTimings(surface_acquire_us={self.surface_acquire_us}, shadow_build_us={self.shadow_build_us}, render3d_scene_us={self.render3d_scene_us}, uniform_upload_us={self.uniform_upload_us}, render_pass_us={self.render_pass_us}, gpu_submit_us={self.gpu_submit_us}, readback_stall_us={self.readback_stall_us}, surface_present_us={self.surface_present_us})" + return f"FramePhaseTimings(surface_acquire_us={self.surface_acquire_us}, shadow_pass_us={self.shadow_pass_us}, shadow_build_us={self.shadow_build_us}, render3d_scene_us={self.render3d_scene_us}, uniform_upload_us={self.uniform_upload_us}, render_pass_us={self.render_pass_us}, gpu_submit_us={self.gpu_submit_us}, readback_stall_us={self.readback_stall_us}, surface_present_us={self.surface_present_us}, anim_eval_us={self.anim_eval_us}, bone_pack_us={self.bone_pack_us}, bone_upload_us={self.bone_upload_us})" class DebuggerConfig: """Pre-init debugger runtime configuration for desktop contexts.""" diff --git a/sdks/python/goudengine/include/goud_engine.h b/sdks/python/goudengine/include/goud_engine.h index f5392336d..e1a5ff840 100644 --- a/sdks/python/goudengine/include/goud_engine.h +++ b/sdks/python/goudengine/include/goud_engine.h @@ -1293,6 +1293,18 @@ typedef struct FfiFramePhaseTimings { * Surface present / vsync wait time (us). */ uint64_t surface_present_us; + /** + * Animation evaluation time (us). + */ + uint64_t anim_eval_us; + /** + * Bone matrix packing time (us). + */ + uint64_t bone_pack_us; + /** + * Bone matrix GPU upload time (us). + */ + uint64_t bone_upload_us; } FfiFramePhaseTimings; /** diff --git a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h index f5392336d..e1a5ff840 100644 --- a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h +++ b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h @@ -1293,6 +1293,18 @@ typedef struct FfiFramePhaseTimings { * Surface present / vsync wait time (us). */ uint64_t surface_present_us; + /** + * Animation evaluation time (us). + */ + uint64_t anim_eval_us; + /** + * Bone matrix packing time (us). + */ + uint64_t bone_pack_us; + /** + * Bone matrix GPU upload time (us). + */ + uint64_t bone_upload_us; } FfiFramePhaseTimings; /** diff --git a/sdks/swift/Sources/GoudEngine/generated/ValueTypes.g.swift b/sdks/swift/Sources/GoudEngine/generated/ValueTypes.g.swift index 5fda2fb66..d383c0959 100644 --- a/sdks/swift/Sources/GoudEngine/generated/ValueTypes.g.swift +++ b/sdks/swift/Sources/GoudEngine/generated/ValueTypes.g.swift @@ -474,6 +474,8 @@ public struct RenderMetrics: Equatable { public struct FramePhaseTimings: Equatable { /// Time to acquire the next surface texture (us) public var surfaceAcquireUs: UInt64 + /// GPU shadow depth pass recording and execution time (us) + public var shadowPassUs: UInt64 /// Shadow map build time (us) public var shadowBuildUs: UInt64 /// 3D scene render time (us) @@ -488,9 +490,16 @@ public struct FramePhaseTimings: Equatable { public var readbackStallUs: UInt64 /// Surface present / vsync wait time (us) public var surfacePresentUs: UInt64 - - public init(surfaceAcquireUs: UInt64 = 0, shadowBuildUs: UInt64 = 0, render3dSceneUs: UInt64 = 0, uniformUploadUs: UInt64 = 0, renderPassUs: UInt64 = 0, gpuSubmitUs: UInt64 = 0, readbackStallUs: UInt64 = 0, surfacePresentUs: UInt64 = 0) { + /// Animation evaluation time (us) + public var animEvalUs: UInt64 + /// Bone matrix packing time (us) + public var bonePackUs: UInt64 + /// Bone matrix GPU upload time (us) + public var boneUploadUs: UInt64 + + public init(surfaceAcquireUs: UInt64 = 0, shadowPassUs: UInt64 = 0, shadowBuildUs: UInt64 = 0, render3dSceneUs: UInt64 = 0, uniformUploadUs: UInt64 = 0, renderPassUs: UInt64 = 0, gpuSubmitUs: UInt64 = 0, readbackStallUs: UInt64 = 0, surfacePresentUs: UInt64 = 0, animEvalUs: UInt64 = 0, bonePackUs: UInt64 = 0, boneUploadUs: UInt64 = 0) { self.surfaceAcquireUs = surfaceAcquireUs + self.shadowPassUs = shadowPassUs self.shadowBuildUs = shadowBuildUs self.render3dSceneUs = render3dSceneUs self.uniformUploadUs = uniformUploadUs @@ -498,10 +507,14 @@ public struct FramePhaseTimings: Equatable { self.gpuSubmitUs = gpuSubmitUs self.readbackStallUs = readbackStallUs self.surfacePresentUs = surfacePresentUs + self.animEvalUs = animEvalUs + self.bonePackUs = bonePackUs + self.boneUploadUs = boneUploadUs } internal init(ffi: FfiFramePhaseTimings) { self.surfaceAcquireUs = ffi.surface_acquire_us + self.shadowPassUs = ffi.shadow_pass_us self.shadowBuildUs = ffi.shadow_build_us self.render3dSceneUs = ffi.render3d_scene_us self.uniformUploadUs = ffi.uniform_upload_us @@ -509,11 +522,15 @@ public struct FramePhaseTimings: Equatable { self.gpuSubmitUs = ffi.gpu_submit_us self.readbackStallUs = ffi.readback_stall_us self.surfacePresentUs = ffi.surface_present_us + self.animEvalUs = ffi.anim_eval_us + self.bonePackUs = ffi.bone_pack_us + self.boneUploadUs = ffi.bone_upload_us } internal func toFFI() -> FfiFramePhaseTimings { var ffi = FfiFramePhaseTimings() ffi.surface_acquire_us = surfaceAcquireUs + ffi.shadow_pass_us = shadowPassUs ffi.shadow_build_us = shadowBuildUs ffi.render3d_scene_us = render3dSceneUs ffi.uniform_upload_us = uniformUploadUs @@ -521,6 +538,9 @@ public struct FramePhaseTimings: Equatable { ffi.gpu_submit_us = gpuSubmitUs ffi.readback_stall_us = readbackStallUs ffi.surface_present_us = surfacePresentUs + ffi.anim_eval_us = animEvalUs + ffi.bone_pack_us = bonePackUs + ffi.bone_upload_us = boneUploadUs return ffi } diff --git a/sdks/typescript/src/generated/node/index.g.ts b/sdks/typescript/src/generated/node/index.g.ts index 40ab96e4a..32e78082b 100644 --- a/sdks/typescript/src/generated/node/index.g.ts +++ b/sdks/typescript/src/generated/node/index.g.ts @@ -910,6 +910,11 @@ export class GoudGame implements IGoudGame { return (this.native as any).getAnimationEvaluationSavedCount(); } + /** Returns per-frame phase timings for performance diagnosis (all values in microseconds) */ + getFramePhaseTimings(): FramePhaseTimings { + return (this.native as any).getFramePhaseTimings(); + } + /** Renders all 3D objects */ render3D(): boolean { return this.native.render3D(); diff --git a/sdks/typescript/src/generated/types/engine.g.ts b/sdks/typescript/src/generated/types/engine.g.ts index 9753894c1..20da9df9c 100644 --- a/sdks/typescript/src/generated/types/engine.g.ts +++ b/sdks/typescript/src/generated/types/engine.g.ts @@ -448,6 +448,8 @@ export interface IGoudGame { getAnimationEvaluationCount(): number; /** Returns the number of animation evaluations avoided last frame */ getAnimationEvaluationSavedCount(): number; + /** Returns per-frame phase timings for performance diagnosis (all values in microseconds) */ + getFramePhaseTimings(): FramePhaseTimings; /** Renders all 3D objects */ render3D(): boolean; /** Creates a 3D material */ From 7efa066dbf831a0a2238484fd905a783be6cf5cd Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 4 Apr 2026 11:09:40 -0600 Subject: [PATCH 2/3] refactor(renderer3d): split oversized files to meet 500-line limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract object_transforms.rs from core/mod.rs (516→369 lines) and skinned_render.rs from render/mod.rs (540→413 lines) to satisfy CI file size check. Pure structural change, no logic modifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/libs/graphics/renderer3d/core/mod.rs | 149 +---------------- .../renderer3d/core/object_transforms.rs | 157 ++++++++++++++++++ .../libs/graphics/renderer3d/render/mod.rs | 147 ++-------------- .../renderer3d/render/skinned_render.rs | 157 ++++++++++++++++++ 4 files changed, 325 insertions(+), 285 deletions(-) create mode 100644 goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs create mode 100644 goud_engine/src/libs/graphics/renderer3d/render/skinned_render.rs diff --git a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs index 7ec98f854..610be3874 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs @@ -1,6 +1,7 @@ //! Core [`Renderer3D`] struct, constructor, and object/light/camera manipulation. mod drop; +mod object_transforms; use rustc_hash::{FxHashMap, FxHashSet}; @@ -34,7 +35,6 @@ use super::types::{ ParticleEmitter, PostProcessPipeline, Renderer3DStats, SkinnedMesh3D, SkyboxConfig, }; use crate::libs::graphics::backend::ShaderHandle; -use cgmath::Vector3; /// Core 3D renderer. All GPU operations go through the backend trait; no direct /// graphics API calls are made outside the backend. @@ -366,151 +366,4 @@ impl Renderer3D { static_batched_ids: FxHashSet::default(), }) } - pub fn set_object_position(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { - 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; - } - ok - } - pub fn set_object_rotation(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { - 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; - } - ok - } - pub fn set_object_scale(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { - 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; - } - ok - } - - /// Mark an object as static (transform never changes) or dynamic. - /// - /// Static objects are batched into a single VBO when - /// [`BatchingConfig::static_batching_enabled`] is `true`, reducing draw calls. - pub fn set_object_static(&mut self, id: u32, is_static: bool) -> bool { - if let Some(obj) = self.objects.get_mut(&id) { - obj.is_static = is_static; - self.static_batch_dirty = true; - true - } else { - false - } - } - - fn mutate_object(&mut self, id: u32, f: impl FnOnce(&mut Object3D)) -> bool { - if let Some(obj) = self.objects.get_mut(&id) { - f(obj); - true - } else { - false - } - } - - pub fn remove_object(&mut self, id: u32) -> bool { - if let Some(obj) = self.objects.remove(&id) { - if obj.is_static { - self.static_batch_dirty = true; - } - self.backend.destroy_buffer(obj.buffer); - true - } else { - false - } - } - - pub fn add_light(&mut self, light: Light) -> u32 { - let id = self.next_light_id; - self.next_light_id += 1; - self.lights.insert(id, light); - id - } - - pub fn update_light(&mut self, id: u32, light: Light) -> bool { - use std::collections::hash_map::Entry; - if let Entry::Occupied(mut e) = self.lights.entry(id) { - e.insert(light); - true - } else { - false - } - } - - pub fn remove_light(&mut self, id: u32) -> bool { - self.lights.remove(&id).is_some() - } - - pub fn set_camera_position(&mut self, x: f32, y: f32, z: f32) { - self.camera.position = Vector3::new(x, y, z); - } - - pub fn resize(&mut self, width: u32, height: u32) { - self.window_width = width.max(1); - self.window_height = height.max(1); - } - - pub fn set_viewport(&mut self, x: i32, y: i32, width: u32, height: u32) { - self.viewport = (x, y, width.max(1), height.max(1)); - } - - pub fn set_camera_rotation(&mut self, pitch: f32, yaw: f32, roll: f32) { - self.camera.rotation = Vector3::new(pitch, yaw, roll); - } - - pub fn stats(&self) -> Renderer3DStats { - self.stats - } - - pub fn anti_aliasing_mode(&self) -> AntiAliasingMode { - self.anti_aliasing_mode - } - - pub fn msaa_samples(&self) -> u32 { - self.msaa_samples - } - - pub fn set_anti_aliasing_mode(&mut self, mode: AntiAliasingMode) -> Result<(), String> { - self.anti_aliasing_mode = mode; - self.backend.set_multisampling_enabled(mode.uses_msaa()); - Ok(()) - } - - pub fn set_msaa_samples(&mut self, samples: u32) { - self.msaa_samples = match samples { - 2 | 4 | 8 => samples, - _ => 1, - }; - } - - pub fn set_shadow_bias(&mut self, bias: f32) { - self.config.shadows.bias = bias.max(0.0); - } - - pub fn shadow_bias(&self) -> f32 { - self.config.shadows.bias - } - - pub fn set_shadows_enabled(&mut self, enabled: bool) { - self.config.shadows.enabled = enabled; - } - - pub fn shadows_enabled(&self) -> bool { - self.config.shadows.enabled - } - - pub fn render_config(&self) -> &Render3DConfig { - &self.config - } - - pub fn set_render_config(&mut self, config: Render3DConfig) { - self.config = config; - } } diff --git a/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs new file mode 100644 index 000000000..a0eb24e1a --- /dev/null +++ b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs @@ -0,0 +1,157 @@ +//! Object, light, camera, and config manipulation methods for [`Renderer3D`]. + +use super::super::config::Render3DConfig; +use super::super::types::{AntiAliasingMode, Light, Object3D, Renderer3DStats}; +use super::Renderer3D; +use cgmath::Vector3; + +#[allow(missing_docs)] +impl Renderer3D { + pub fn set_object_position(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + 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; + } + ok + } + pub fn set_object_rotation(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + 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; + } + ok + } + pub fn set_object_scale(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + 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; + } + ok + } + + /// Mark an object as static (transform never changes) or dynamic. + /// + /// Static objects are batched into a single VBO when + /// [`BatchingConfig::static_batching_enabled`] is `true`, reducing draw calls. + pub fn set_object_static(&mut self, id: u32, is_static: bool) -> bool { + if let Some(obj) = self.objects.get_mut(&id) { + obj.is_static = is_static; + self.static_batch_dirty = true; + true + } else { + false + } + } + + fn mutate_object(&mut self, id: u32, f: impl FnOnce(&mut Object3D)) -> bool { + if let Some(obj) = self.objects.get_mut(&id) { + f(obj); + true + } else { + false + } + } + + pub fn remove_object(&mut self, id: u32) -> bool { + if let Some(obj) = self.objects.remove(&id) { + if obj.is_static { + self.static_batch_dirty = true; + } + self.backend.destroy_buffer(obj.buffer); + true + } else { + false + } + } + + pub fn add_light(&mut self, light: Light) -> u32 { + let id = self.next_light_id; + self.next_light_id += 1; + self.lights.insert(id, light); + id + } + + pub fn update_light(&mut self, id: u32, light: Light) -> bool { + use std::collections::hash_map::Entry; + if let Entry::Occupied(mut e) = self.lights.entry(id) { + e.insert(light); + true + } else { + false + } + } + + pub fn remove_light(&mut self, id: u32) -> bool { + self.lights.remove(&id).is_some() + } + + pub fn set_camera_position(&mut self, x: f32, y: f32, z: f32) { + self.camera.position = Vector3::new(x, y, z); + } + + pub fn resize(&mut self, width: u32, height: u32) { + self.window_width = width.max(1); + self.window_height = height.max(1); + } + + pub fn set_viewport(&mut self, x: i32, y: i32, width: u32, height: u32) { + self.viewport = (x, y, width.max(1), height.max(1)); + } + + pub fn set_camera_rotation(&mut self, pitch: f32, yaw: f32, roll: f32) { + self.camera.rotation = Vector3::new(pitch, yaw, roll); + } + + pub fn stats(&self) -> Renderer3DStats { + self.stats + } + + pub fn anti_aliasing_mode(&self) -> AntiAliasingMode { + self.anti_aliasing_mode + } + + pub fn msaa_samples(&self) -> u32 { + self.msaa_samples + } + + pub fn set_anti_aliasing_mode(&mut self, mode: AntiAliasingMode) -> Result<(), String> { + self.anti_aliasing_mode = mode; + self.backend.set_multisampling_enabled(mode.uses_msaa()); + Ok(()) + } + + pub fn set_msaa_samples(&mut self, samples: u32) { + self.msaa_samples = match samples { + 2 | 4 | 8 => samples, + _ => 1, + }; + } + + pub fn set_shadow_bias(&mut self, bias: f32) { + self.config.shadows.bias = bias.max(0.0); + } + + pub fn shadow_bias(&self) -> f32 { + self.config.shadows.bias + } + + pub fn set_shadows_enabled(&mut self, enabled: bool) { + self.config.shadows.enabled = enabled; + } + + pub fn shadows_enabled(&self) -> bool { + self.config.shadows.enabled + } + + pub fn render_config(&self) -> &Render3DConfig { + &self.config + } + + pub fn set_render_config(&mut self, config: Render3DConfig) { + self.config = config; + } +} diff --git a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs index a25af749c..a0631529b 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs @@ -1,6 +1,7 @@ //! Frame rendering logic for [`Renderer3D`]. mod shadow_render; +mod skinned_render; mod util; use super::core::Renderer3D; @@ -349,143 +350,15 @@ impl Renderer3D { self.stats.draw_calls += 1; } - // Skinned mesh rendering pass. - if !self.skinned_meshes.is_empty() { - let gpu_skinning = - matches!(self.config.skinning.mode, super::config::SkinningMode::Gpu) - && self.backend.supports_storage_buffers(); - - let _ = self.backend.bind_shader(self.skinned_shader_handle); - let skinned_unis = self.skinned_uniforms.clone(); - self.apply_main_uniforms( - &view_arr, - &proj_arr, - &shadow_matrix, - shadow_active, - &skinned_unis.main, - &eff_fog, - &filtered_lights, - ); - - // Lightweight per-mesh metadata (no bone data cloned). - struct SkinnedSnap { - buffer: crate::libs::graphics::backend::BufferHandle, - vertex_count: i32, - model_matrix: [f32; 16], - color: [f32; 4], - } - - // Pass 1: pack bones into scratch buffer and collect metadata. - let mut scratch_packed = std::mem::take(&mut self.scratch_packed_bones); - scratch_packed.clear(); - let mut scratch_offsets = std::mem::take(&mut self.scratch_bone_offsets); - scratch_offsets.clear(); - - let mut skinned_snaps: Vec = Vec::with_capacity(self.skinned_meshes.len()); - - if gpu_skinning { - let bone_pack_start = std::time::Instant::now(); - for sm in self.skinned_meshes.values() { - scratch_offsets.push((scratch_packed.len() / 16) as i32); - for mat in &sm.bone_matrices { - scratch_packed.extend_from_slice(mat); - } - skinned_snaps.push(SkinnedSnap { - buffer: sm.buffer, - vertex_count: sm.vertex_count, - model_matrix: sm.cached_model_matrix, - color: sm.color, - }); - } - let bone_pack_us = bone_pack_start.elapsed().as_micros() as u64; - crate::libs::graphics::frame_timing::record_phase("bone_pack", bone_pack_us); - - if !scratch_packed.is_empty() { - let bone_upload_start = std::time::Instant::now(); - let bone_data: &[u8] = bytemuck::cast_slice(&scratch_packed); - self.ensure_bone_storage_buffer(bone_data.len()); - if let Some(storage_handle) = self.bone_storage_buffer { - if let Err(e) = - self.backend - .update_storage_buffer(storage_handle, 0, bone_data) - { - log::error!("Failed to upload bone matrices: {e}"); - } - let _ = self.backend.bind_storage_buffer(storage_handle, 0); - } - let bone_upload_us = bone_upload_start.elapsed().as_micros() as u64; - crate::libs::graphics::frame_timing::record_phase( - "bone_upload", - bone_upload_us, - ); - self.stats.bone_matrix_uploads += 1; - } - } else { - // CPU skinning path: collect metadata only (bones uploaded per-mesh below). - for sm in self.skinned_meshes.values() { - skinned_snaps.push(SkinnedSnap { - buffer: sm.buffer, - vertex_count: sm.vertex_count, - model_matrix: sm.cached_model_matrix, - color: sm.color, - }); - } - } - - // Pass 2: draw each skinned mesh using metadata. - // For CPU skinning we need to read bone_matrices from skinned_meshes - // again, but only to upload uniforms (no clone). - let skinned_mesh_keys: Vec = self.skinned_meshes.keys().copied().collect(); - for (snap_idx, snap) in skinned_snaps.iter().enumerate() { - self.backend - .set_uniform_mat4(skinned_unis.main.model, &snap.model_matrix); - self.backend - .set_uniform_int(skinned_unis.main.use_texture, 0); - self.backend.set_uniform_vec4( - skinned_unis.main.object_color, - snap.color[0], - snap.color[1], - snap.color[2], - snap.color[3], - ); - self.stats.skinned_instances += 1; - - if gpu_skinning { - self.backend - .set_uniform_int(skinned_unis.bone_offset, scratch_offsets[snap_idx]); - } else if let Some(sm) = skinned_mesh_keys - .get(snap_idx) - .and_then(|k| self.skinned_meshes.get(k)) - { - for (i, mat) in sm.bone_matrices.iter().enumerate() { - if i < skinned_unis.bone_matrices.len() { - self.backend - .set_uniform_mat4(skinned_unis.bone_matrices[i], mat); - } - } - self.stats.bone_matrix_uploads += 1; - } - - let _ = self.backend.bind_buffer(snap.buffer); - self.backend.set_vertex_attributes(&self.skinned_layout); - let _ = self.backend.draw_arrays( - PrimitiveTopology::Triangles, - 0, - snap.vertex_count as u32, - ); - self.stats.draw_calls += 1; - } - - // Return scratch buffers. - self.scratch_packed_bones = scratch_packed; - self.scratch_bone_offsets = scratch_offsets; - - if gpu_skinning { - self.backend.unbind_storage_buffer(); - } - self.backend.unbind_shader(); - let _ = self.backend.bind_shader(self.shader_handle); - } + // Skinned mesh rendering pass (extracted to skinned_render.rs). + self.render_skinned_mesh_pass( + &view_arr, + &proj_arr, + &shadow_matrix, + shadow_active, + &eff_fog, + &filtered_lights, + ); let instanced_skinned_ids = self.render_instanced_skinned_models( &view_arr, diff --git a/goud_engine/src/libs/graphics/renderer3d/render/skinned_render.rs b/goud_engine/src/libs/graphics/renderer3d/render/skinned_render.rs new file mode 100644 index 000000000..920e56fcb --- /dev/null +++ b/goud_engine/src/libs/graphics/renderer3d/render/skinned_render.rs @@ -0,0 +1,157 @@ +//! Skinned mesh rendering pass extracted from the main render loop. + +use super::super::core::Renderer3D; +use super::super::types::FogConfig; +use super::super::types::Light; +use crate::libs::graphics::backend::PrimitiveTopology; + +impl Renderer3D { + /// Renders all standalone skinned meshes (not model-instanced). + /// + /// This is called from the main `render()` method after the static/dynamic + /// object pass. It handles both GPU and CPU skinning paths. + pub(super) fn render_skinned_mesh_pass( + &mut self, + view_arr: &[f32; 16], + proj_arr: &[f32; 16], + shadow_matrix: &[f32; 16], + shadow_active: bool, + eff_fog: &FogConfig, + filtered_lights: &[Light], + ) { + if self.skinned_meshes.is_empty() { + return; + } + + let gpu_skinning = matches!( + self.config.skinning.mode, + super::super::config::SkinningMode::Gpu + ) && self.backend.supports_storage_buffers(); + + let _ = self.backend.bind_shader(self.skinned_shader_handle); + let skinned_unis = self.skinned_uniforms.clone(); + self.apply_main_uniforms( + view_arr, + proj_arr, + shadow_matrix, + shadow_active, + &skinned_unis.main, + eff_fog, + filtered_lights, + ); + + // Lightweight per-mesh metadata (no bone data cloned). + struct SkinnedSnap { + buffer: crate::libs::graphics::backend::BufferHandle, + vertex_count: i32, + model_matrix: [f32; 16], + color: [f32; 4], + } + + // Pass 1: pack bones into scratch buffer and collect metadata. + let mut scratch_packed = std::mem::take(&mut self.scratch_packed_bones); + scratch_packed.clear(); + let mut scratch_offsets = std::mem::take(&mut self.scratch_bone_offsets); + scratch_offsets.clear(); + + let mut skinned_snaps: Vec = Vec::with_capacity(self.skinned_meshes.len()); + + if gpu_skinning { + let bone_pack_start = std::time::Instant::now(); + for sm in self.skinned_meshes.values() { + scratch_offsets.push((scratch_packed.len() / 16) as i32); + for mat in &sm.bone_matrices { + scratch_packed.extend_from_slice(mat); + } + skinned_snaps.push(SkinnedSnap { + buffer: sm.buffer, + vertex_count: sm.vertex_count, + model_matrix: sm.cached_model_matrix, + color: sm.color, + }); + } + let bone_pack_us = bone_pack_start.elapsed().as_micros() as u64; + crate::libs::graphics::frame_timing::record_phase("bone_pack", bone_pack_us); + + if !scratch_packed.is_empty() { + let bone_upload_start = std::time::Instant::now(); + let bone_data: &[u8] = bytemuck::cast_slice(&scratch_packed); + self.ensure_bone_storage_buffer(bone_data.len()); + if let Some(storage_handle) = self.bone_storage_buffer { + if let Err(e) = self + .backend + .update_storage_buffer(storage_handle, 0, bone_data) + { + log::error!("Failed to upload bone matrices: {e}"); + } + let _ = self.backend.bind_storage_buffer(storage_handle, 0); + } + let bone_upload_us = bone_upload_start.elapsed().as_micros() as u64; + crate::libs::graphics::frame_timing::record_phase("bone_upload", bone_upload_us); + self.stats.bone_matrix_uploads += 1; + } + } else { + // CPU skinning path: collect metadata only (bones uploaded per-mesh below). + for sm in self.skinned_meshes.values() { + skinned_snaps.push(SkinnedSnap { + buffer: sm.buffer, + vertex_count: sm.vertex_count, + model_matrix: sm.cached_model_matrix, + color: sm.color, + }); + } + } + + // Pass 2: draw each skinned mesh using metadata. + // For CPU skinning we need to read bone_matrices from skinned_meshes + // again, but only to upload uniforms (no clone). + let skinned_mesh_keys: Vec = self.skinned_meshes.keys().copied().collect(); + for (snap_idx, snap) in skinned_snaps.iter().enumerate() { + self.backend + .set_uniform_mat4(skinned_unis.main.model, &snap.model_matrix); + self.backend + .set_uniform_int(skinned_unis.main.use_texture, 0); + self.backend.set_uniform_vec4( + skinned_unis.main.object_color, + snap.color[0], + snap.color[1], + snap.color[2], + snap.color[3], + ); + self.stats.skinned_instances += 1; + + if gpu_skinning { + self.backend + .set_uniform_int(skinned_unis.bone_offset, scratch_offsets[snap_idx]); + } else if let Some(sm) = skinned_mesh_keys + .get(snap_idx) + .and_then(|k| self.skinned_meshes.get(k)) + { + for (i, mat) in sm.bone_matrices.iter().enumerate() { + if i < skinned_unis.bone_matrices.len() { + self.backend + .set_uniform_mat4(skinned_unis.bone_matrices[i], mat); + } + } + self.stats.bone_matrix_uploads += 1; + } + + let _ = self.backend.bind_buffer(snap.buffer); + self.backend.set_vertex_attributes(&self.skinned_layout); + let _ = + self.backend + .draw_arrays(PrimitiveTopology::Triangles, 0, snap.vertex_count as u32); + self.stats.draw_calls += 1; + } + + // Return scratch buffers. + self.scratch_packed_bones = scratch_packed; + self.scratch_bone_offsets = scratch_offsets; + + if gpu_skinning { + self.backend.unbind_storage_buffer(); + } + self.backend.unbind_shader(); + let _ = self.backend.bind_shader(self.shader_handle); + } +} From 5d830a0919b8efae2adb8df00abad332f58b1568 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 4 Apr 2026 11:26:47 -0600 Subject: [PATCH 3/3] fix: address Claude Code review warnings (W3, W4, W5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W3: Replace unsafe raw pointer in animation/mod.rs with safe take/restore pattern for cached_fallback_names - W4: Fix TypeScript SDK — add IFramePhaseTimings interface to codegen (ts_node_shared.py, ts_node_interface.py, ts_node_wrapper.py, gen_ts_web.py). All SDK tests now pass. - W5: Add debug_assert for vertex buffer alignment in shadow.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- codegen/gen_ts_web.py | 5 +++-- codegen/ts_node_interface.py | 5 +++++ codegen/ts_node_shared.py | 1 + codegen/ts_node_wrapper.py | 6 ++++-- .../src/libs/graphics/renderer3d/animation/mod.rs | 11 ++++------- goud_engine/src/libs/graphics/renderer3d/shadow.rs | 5 +++++ sdks/typescript/src/generated/node/index.g.ts | 8 ++++---- sdks/typescript/src/generated/types/engine.g.ts | 4 +++- sdks/typescript/src/generated/web/index.g.ts | 5 +++-- 9 files changed, 32 insertions(+), 18 deletions(-) diff --git a/codegen/gen_ts_web.py b/codegen/gen_ts_web.py index 88cb42dc5..9948224fd 100644 --- a/codegen/gen_ts_web.py +++ b/codegen/gen_ts_web.py @@ -156,7 +156,7 @@ def gen_web_wrapper(): lines = [ f"// {HEADER_COMMENT}", "", - "import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js';", + "import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js';", "import { Color, Vec2, Vec3 } from '../types/math.g.js';", "import { PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js';", "import { attachInputHandlers } from './input.g.js';", @@ -164,7 +164,7 @@ def gen_web_wrapper(): "export { Color, Vec2, Vec3 } from '../types/math.g.js';", "export { Key, MouseButton, PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js';", "export { Rect } from '../types/math.g.js';", - "export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind } from '../types/engine.g.js';", + "export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind } from '../types/engine.g.js';", "", "const PRELOAD_TEXTURE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tga', 'dds']);", "const PRELOAD_FONT_EXTENSIONS = new Set(['ttf', 'otf', 'woff', 'woff2', 'fnt']);", @@ -1204,6 +1204,7 @@ def gen_web_wrapper(): lines.append(" // TODO: wasm FPS overlay -- these stub methods satisfy the IGoudGame interface") lines.append(" getFpsStats(): IFpsStats { return { currentFps: this.handle.fps, minFps: 0, maxFps: 0, avgFps: 0, frameTimeMs: 0 }; }") lines.append(" getRenderMetrics(): IRenderMetrics { return { drawCallCount: 0, spritesSubmitted: 0, spritesDrawn: 0, spritesCulled: 0, batchesSubmitted: 0, avgSpritesPerBatch: 0, spriteRenderMs: 0, textRenderMs: 0, uiRenderMs: 0, totalRenderMs: 0, textDrawCalls: 0, textGlyphCount: 0, uiDrawCalls: 0 }; }") + lines.append(" getFramePhaseTimings(): IFramePhaseTimings { return { surfaceAcquireUs: 0, shadowPassUs: 0, shadowBuildUs: 0, render3dSceneUs: 0, uniformUploadUs: 0, renderPassUs: 0, gpuSubmitUs: 0, readbackStallUs: 0, surfacePresentUs: 0, animEvalUs: 0, bonePackUs: 0, boneUploadUs: 0 }; }") lines.append(" setFpsOverlayEnabled(_enabled: boolean): void {}") lines.append(" setFpsUpdateInterval(_interval: number): void {}") lines.append(" setFpsOverlayCorner(_corner: number): void {}") diff --git a/codegen/ts_node_interface.py b/codegen/ts_node_interface.py index c29ab7b3f..519c95e12 100644 --- a/codegen/ts_node_interface.py +++ b/codegen/ts_node_interface.py @@ -60,6 +60,11 @@ def gen_interface(): if schema["types"]["RenderMetrics"].get("doc"): lines.append(f"/** {schema['types']['RenderMetrics']['doc']} */") lines.append(f"export interface IRenderMetrics {{ {rm_str}; }}") + fpt_fields = schema["types"]["FramePhaseTimings"]["fields"] + fpt_str = "; ".join(f"{to_camel(f['name'])}: number" for f in fpt_fields) + if schema["types"]["FramePhaseTimings"].get("doc"): + lines.append(f"/** {schema['types']['FramePhaseTimings']['doc']} */") + lines.append(f"export interface IFramePhaseTimings {{ {fpt_str}; }}") dbg_cfg_fields = [] for f in schema["types"]["DebuggerConfig"]["fields"]: ts_ft = "boolean" if f["type"] == "bool" else "string" diff --git a/codegen/ts_node_shared.py b/codegen/ts_node_shared.py index 526e228c2..06d48415a 100644 --- a/codegen/ts_node_shared.py +++ b/codegen/ts_node_shared.py @@ -34,6 +34,7 @@ "RenderStats": "IRenderStats", "FpsStats": "IFpsStats", "RenderMetrics": "IRenderMetrics", + "FramePhaseTimings": "IFramePhaseTimings", "DebuggerConfig": "IDebuggerConfig", "ContextConfig": "IContextConfig", "MemoryCategoryStats": "IMemoryCategoryStats", diff --git a/codegen/ts_node_wrapper.py b/codegen/ts_node_wrapper.py index e36df66af..7918f39ba 100644 --- a/codegen/ts_node_wrapper.py +++ b/codegen/ts_node_wrapper.py @@ -86,12 +86,12 @@ def gen_node_wrapper(): lines = [ f"// {HEADER_COMMENT}", "", - "import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js';", + "import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js';", "import { PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js';", "import { Color, Vec2, Vec3 } from '../types/math.g.js';", "export { Color, Vec2, Vec3 } from '../types/math.g.js';", "export { Key, MouseButton, PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js';", - "export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult } from '../types/engine.g.js';", + "export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult } from '../types/engine.g.js';", "", "export interface INetworkConnectResult { handle: number; peerId: number; }", "export interface INetworkPacket { peerId: number; data: Uint8Array; }", @@ -360,6 +360,8 @@ def gen_node_wrapper(): lines.append(" return this.native.getFpsStats() as unknown as IFpsStats;") elif mn == "getRenderMetrics": lines.append(" return this.native.getRenderMetrics() as unknown as IRenderMetrics;") + elif mn == "getFramePhaseTimings": + lines.append(" return this.native.getFramePhaseTimings() as unknown as IFramePhaseTimings;") elif mn == "getMemorySummary": lines.append(" return mapMemorySummary(this.native.getMemorySummary());") elif mn == "getNetworkStats": diff --git a/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs b/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs index 1ee333417..264d46553 100644 --- a/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/animation/mod.rs @@ -197,13 +197,10 @@ impl AnimationPlayer { self.cached_fallback_names = Some((0..bone_count).map(BonePropertyNames::new).collect()); } - // SAFETY: we just ensured `cached_fallback_names` is `Some`. - let names = self.cached_fallback_names.as_ref().unwrap(); - // Take a raw pointer to avoid the borrow conflict with `&mut self`. - let names_ptr = names as *const Vec; - // SAFETY: `update_with_names` does not modify `cached_fallback_names`. - let names_ref = unsafe { &*names_ptr }; - self.update_with_names(dt, skeleton, animations, names_ref); + // Take the cached names out of self to avoid borrow conflict with &mut self. + let names = self.cached_fallback_names.take().unwrap(); + self.update_with_names(dt, skeleton, animations, &names); + self.cached_fallback_names = Some(names); } /// Advance animation time and compute bone matrices using pre-cached diff --git a/goud_engine/src/libs/graphics/renderer3d/shadow.rs b/goud_engine/src/libs/graphics/renderer3d/shadow.rs index 15aa94138..f57de9c20 100644 --- a/goud_engine/src/libs/graphics/renderer3d/shadow.rs +++ b/goud_engine/src/libs/graphics/renderer3d/shadow.rs @@ -285,6 +285,11 @@ fn rasterize_shadow_map( ) -> SoftwareShadowMap { let mut depth_values = vec![1.0f32; (size * size) as usize]; for mesh in meshes { + debug_assert!( + mesh.vertices.len() % 24 == 0, + "mesh vertex buffer length {} is not a multiple of 24 (stride: 8 floats × 3 verts)", + mesh.vertices.len() + ); for triangle in mesh.vertices.chunks_exact(24) { let v0 = mesh.model * Vector4::new(triangle[0], triangle[1], triangle[2], 1.0); let v1 = mesh.model * Vector4::new(triangle[8], triangle[9], triangle[10], 1.0); diff --git a/sdks/typescript/src/generated/node/index.g.ts b/sdks/typescript/src/generated/node/index.g.ts index 32e78082b..37158301e 100644 --- a/sdks/typescript/src/generated/node/index.g.ts +++ b/sdks/typescript/src/generated/node/index.g.ts @@ -1,11 +1,11 @@ // This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. -import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js'; +import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js'; import { PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js'; import { Color, Vec2, Vec3 } from '../types/math.g.js'; export { Color, Vec2, Vec3 } from '../types/math.g.js'; export { Key, MouseButton, PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js'; -export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult } from '../types/engine.g.js'; +export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, IVec3, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, IPhysicsWorld2D, IPhysicsWorld3D, IP2pMeshConfig, IRollbackConfig, IBoundingBox3D, ICharacterMoveResult } from '../types/engine.g.js'; export interface INetworkConnectResult { handle: number; peerId: number; } export interface INetworkPacket { peerId: number; data: Uint8Array; } @@ -911,8 +911,8 @@ export class GoudGame implements IGoudGame { } /** Returns per-frame phase timings for performance diagnosis (all values in microseconds) */ - getFramePhaseTimings(): FramePhaseTimings { - return (this.native as any).getFramePhaseTimings(); + getFramePhaseTimings(): IFramePhaseTimings { + return this.native.getFramePhaseTimings() as unknown as IFramePhaseTimings; } /** Renders all 3D objects */ diff --git a/sdks/typescript/src/generated/types/engine.g.ts b/sdks/typescript/src/generated/types/engine.g.ts index 20da9df9c..82f4707d0 100644 --- a/sdks/typescript/src/generated/types/engine.g.ts +++ b/sdks/typescript/src/generated/types/engine.g.ts @@ -22,6 +22,8 @@ export interface IPhysicsCollisionEvent2D { bodyA: number; bodyB: number; kind: export interface IFpsStats { currentFps: number; minFps: number; maxFps: number; avgFps: number; frameTimeMs: number; } /** Per-frame render metrics: draw call counts, culling stats, batch efficiency, and per-category timing */ export interface IRenderMetrics { drawCallCount: number; spritesSubmitted: number; spritesDrawn: number; spritesCulled: number; batchesSubmitted: number; avgSpritesPerBatch: number; spriteRenderMs: number; textRenderMs: number; uiRenderMs: number; totalRenderMs: number; textDrawCalls: number; textGlyphCount: number; uiDrawCalls: number; } +/** Per-frame phase timings for performance diagnosis. All values in microseconds. */ +export interface IFramePhaseTimings { surfaceAcquireUs: number; shadowPassUs: number; shadowBuildUs: number; render3dSceneUs: number; uniformUploadUs: number; renderPassUs: number; gpuSubmitUs: number; readbackStallUs: number; surfacePresentUs: number; animEvalUs: number; bonePackUs: number; boneUploadUs: number; } /** Pre-init debugger runtime configuration for desktop contexts. */ export interface IDebuggerConfig { enabled: boolean; publishLocalAttach: boolean; routeLabel: string; } /** Pre-init configuration for headless context creation. */ @@ -449,7 +451,7 @@ export interface IGoudGame { /** Returns the number of animation evaluations avoided last frame */ getAnimationEvaluationSavedCount(): number; /** Returns per-frame phase timings for performance diagnosis (all values in microseconds) */ - getFramePhaseTimings(): FramePhaseTimings; + getFramePhaseTimings(): IFramePhaseTimings; /** Renders all 3D objects */ render3D(): boolean; /** Creates a 3D material */ diff --git a/sdks/typescript/src/generated/web/index.g.ts b/sdks/typescript/src/generated/web/index.g.ts index 612f4d38f..07cd9ff3e 100644 --- a/sdks/typescript/src/generated/web/index.g.ts +++ b/sdks/typescript/src/generated/web/index.g.ts @@ -1,6 +1,6 @@ // This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. -import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js'; +import type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind, ISpriteCmd, ITextCmd } from '../types/engine.g.js'; import { Color, Vec2, Vec3 } from '../types/math.g.js'; import { PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js'; import { attachInputHandlers } from './input.g.js'; @@ -8,7 +8,7 @@ import { attachInputHandlers } from './input.g.js'; export { Color, Vec2, Vec3 } from '../types/math.g.js'; export { Key, MouseButton, PhysicsBackend2D, RenderBackendKind, WindowBackendKind } from '../types/input.g.js'; export { Rect } from '../types/math.g.js'; -export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind } from '../types/engine.g.js'; +export type { IGoudGame, IUiManager, IUiStyle, IUiEvent, UiNodeId, IEntity, IColor, IVec2, ITransform2DData, ISpriteData, IRenderStats, IContact, IFpsStats, IRenderMetrics, IFramePhaseTimings, IDebuggerConfig, IContextConfig, IMemoryCategoryStats, IMemorySummary, IDebuggerCapture, IDebuggerReplayArtifact, IPhysicsRaycastHit2D, IPhysicsCollisionEvent2D, IAnimationEventData, IPreloadAssetRequest, IPreloadOptions, IPreloadProgress, IRenderCapabilities, IPhysicsCapabilities, IAudioCapabilities, IInputCapabilities, INetworkCapabilities, INetworkStats, INetworkSimulationConfig, INetworkConnectResult, INetworkPacket, IP2pMeshConfig, IRollbackConfig, PreloadAssetInput, PreloadAssetKind } from '../types/engine.g.js'; const PRELOAD_TEXTURE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tga', 'dds']); const PRELOAD_FONT_EXTENSIONS = new Set(['ttf', 'otf', 'woff', 'woff2', 'fnt']); @@ -1089,6 +1089,7 @@ export class GoudGame implements IGoudGame { // TODO: wasm FPS overlay -- these stub methods satisfy the IGoudGame interface getFpsStats(): IFpsStats { return { currentFps: this.handle.fps, minFps: 0, maxFps: 0, avgFps: 0, frameTimeMs: 0 }; } getRenderMetrics(): IRenderMetrics { return { drawCallCount: 0, spritesSubmitted: 0, spritesDrawn: 0, spritesCulled: 0, batchesSubmitted: 0, avgSpritesPerBatch: 0, spriteRenderMs: 0, textRenderMs: 0, uiRenderMs: 0, totalRenderMs: 0, textDrawCalls: 0, textGlyphCount: 0, uiDrawCalls: 0 }; } + getFramePhaseTimings(): IFramePhaseTimings { return { surfaceAcquireUs: 0, shadowPassUs: 0, shadowBuildUs: 0, render3dSceneUs: 0, uniformUploadUs: 0, renderPassUs: 0, gpuSubmitUs: 0, readbackStallUs: 0, surfacePresentUs: 0, animEvalUs: 0, bonePackUs: 0, boneUploadUs: 0 }; } setFpsOverlayEnabled(_enabled: boolean): void {} setFpsUpdateInterval(_interval: number): void {} setFpsOverlayCorner(_corner: number): void {}