From 35785eefd00ced712646fe718056db123a03932a Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:14:10 -0600 Subject: [PATCH 1/7] perf(renderer3d): add InstantiatePlane to collapse identical plane primitives into one instanced draw (#679) `CreatePlane` primitives previously bypassed the instancing path so a per-tile terrain (40k planes / 9 materials) produced 11k+ draw calls. `InstantiatePlane` mirrors `InstantiateModel` for primitives: every instance that shares the same source plane renders through a single instanced draw call -- ~9 draws for a 9-material terrain regardless of tile count. - New `instantiate_plane(source_plane_id) -> u32` engine method backed by a per-source-plane pool (`PlaneInstancePool`) over the existing `instanced_meshes` map. Pool grows with `next_power_of_two` capacity, dirty flush re-uploads once per frame. - `set_object_position` / rotation / scale and `remove_object` dispatch to the pool first; `add_object_to_scene` resolves an instance handle to its source plane so the existing scene API works unchanged. - Scene filtering for plane-instance pools: a pool only draws when its source plane is in the current scene. - New FFI export `goud_renderer3d_instantiate_plane` and schema entry; codegen regenerated all 9 SDK wrappers. - 4 new tests: 10k tiles collapse to 1 instanced draw; per-instance transforms + remove keep the pool dense; two source planes -> two draw calls; current scene gates the pool. Co-Authored-By: Claude Opus 4.7 (1M context) --- codegen/ffi_manifest.json | 11 +- codegen/ffi_mapping.json | 1 + codegen/generated/goud_engine.h | 5 + codegen/goud_sdk.schema.json | 14 + goud_engine/src/ffi/renderer3d/primitives.rs | 42 +++ goud_engine/src/jni/generated.rs | 20 ++ .../src/libs/graphics/renderer3d/core/mod.rs | 13 + .../renderer3d/core/object_transforms.rs | 12 + .../renderer3d/core_plane_instances.rs | 272 ++++++++++++++++++ .../libs/graphics/renderer3d/core_scenes.rs | 23 +- .../src/libs/graphics/renderer3d/mod.rs | 1 + .../libs/graphics/renderer3d/render/mod.rs | 2 + .../graphics/renderer3d/render_instanced.rs | 18 +- .../src/libs/graphics/renderer3d/tests.rs | 161 ++++++++++- .../sdk/game/instance/lua_bindings/tools.g.rs | 6 + .../goudengine/internal/GoudGameNative.java | 1 + sdks/csharp/generated/GoudGame.g.cs | 6 + sdks/csharp/generated/NativeMethods.g.cs | 3 + sdks/csharp/include/goud_engine.h | 5 + sdks/go/goud/game.go | 5 + sdks/go/include/goud_engine.h | 5 + sdks/go/internal/ffi/ffi.go | 5 + .../goudengine/internal/GoudGameNative.java | 1 + .../kotlin/com/goudengine/core/GoudGame.kt | 3 + sdks/python/goudengine/generated/_ffi.py | 2 + sdks/python/goudengine/generated/_game.py | 4 + sdks/python/goudengine/include/goud_engine.h | 5 + .../Sources/CGoudEngine/include/goud_engine.h | 5 + .../GoudEngine/generated/GoudGame.g.swift | 5 + sdks/typescript/src/generated/node/index.g.ts | 5 + .../src/generated/types/engine.g.ts | 2 + 31 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs diff --git a/codegen/ffi_manifest.json b/codegen/ffi_manifest.json index 377630b3e..907fbd7b6 100644 --- a/codegen/ffi_manifest.json +++ b/codegen/ffi_manifest.json @@ -3695,6 +3695,15 @@ "return_type": "i32", "is_unsafe": true }, + "goud_renderer3d_instantiate_plane": { + "source_file": "ffi/renderer3d/primitives.rs", + "params": [ + "context_id: GoudContextId", + "source_plane_id: u32" + ], + "return_type": "u32", + "is_unsafe": false + }, "goud_renderer3d_is_animation_playing": { "source_file": "ffi/renderer3d/animation.rs", "params": [ @@ -6593,5 +6602,5 @@ "is_unsafe": false } }, - "total_count": 669 + "total_count": 670 } diff --git a/codegen/ffi_mapping.json b/codegen/ffi_mapping.json index 2a8cbcff5..ddc667b84 100644 --- a/codegen/ffi_mapping.json +++ b/codegen/ffi_mapping.json @@ -636,6 +636,7 @@ "renderer_3d": { "goud_renderer3d_create_cube": {}, "goud_renderer3d_create_plane": {}, + "goud_renderer3d_instantiate_plane": {}, "goud_renderer3d_create_sphere": {}, "goud_renderer3d_create_cylinder": {}, "goud_renderer3d_set_object_position": {}, diff --git a/codegen/generated/goud_engine.h b/codegen/generated/goud_engine.h index da9339ba3..0112da751 100644 --- a/codegen/generated/goud_engine.h +++ b/codegen/generated/goud_engine.h @@ -2728,6 +2728,11 @@ uint32_t goud_renderer3d_create_cube(struct GoudContextId context_id, uint32_t t */ uint32_t goud_renderer3d_create_plane(struct GoudContextId context_id, uint32_t texture_id, float width, float depth); +/** + * Creates an instance of a source plane primitive. + */ +uint32_t goud_renderer3d_instantiate_plane(struct GoudContextId context_id, uint32_t source_plane_id); + /** * Creates a 3D sphere object. */ diff --git a/codegen/goud_sdk.schema.json b/codegen/goud_sdk.schema.json index cad3b2b07..a89fe762d 100644 --- a/codegen/goud_sdk.schema.json +++ b/codegen/goud_sdk.schema.json @@ -5055,6 +5055,17 @@ ], "returns": "u32" }, + { + "name": "instantiatePlane", + "doc": "Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials.", + "params": [ + { + "name": "sourcePlaneId", + "type": "u32" + } + ], + "returns": "u32" + }, { "name": "createSphere", "doc": "Creates a 3D sphere", @@ -13032,6 +13043,9 @@ "createPlane": { "ffi": "goud_renderer3d_create_plane" }, + "instantiatePlane": { + "ffi": "goud_renderer3d_instantiate_plane" + }, "createSphere": { "ffi": "goud_renderer3d_create_sphere" }, diff --git a/goud_engine/src/ffi/renderer3d/primitives.rs b/goud_engine/src/ffi/renderer3d/primitives.rs index fe16bd6cb..d3d5e7f7c 100644 --- a/goud_engine/src/ffi/renderer3d/primitives.rs +++ b/goud_engine/src/ffi/renderer3d/primitives.rs @@ -106,6 +106,48 @@ pub extern "C" fn goud_renderer3d_create_plane( .unwrap_or(GOUD_INVALID_OBJECT) } +/// Creates an instance of a source plane primitive. +/// +/// Mirrors `goud_renderer3d_instantiate_model` for primitives. Every instance +/// of the same source plane renders through one instanced draw call, so a +/// terrain of identical-geometry tiles collapses to one batch per source plane +/// (issue #679). +/// +/// The returned id behaves like an object id for transform updates +/// (`set_object_position`, `set_object_rotation`, `set_object_scale`, +/// `destroy_object`). Per-instance materials are not supported -- the source +/// plane's material/texture is captured when the first instance is created. +/// Use one source plane per material to draw multiple materials. +/// +/// # Arguments +/// * `context_id` - The windowed context +/// * `source_plane_id` - Object id returned by `goud_renderer3d_create_plane` +/// +/// # Returns +/// Plane-instance handle on success, GOUD_INVALID_OBJECT on failure. +#[no_mangle] +pub extern "C" fn goud_renderer3d_instantiate_plane( + context_id: GoudContextId, + source_plane_id: u32, +) -> u32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return GOUD_INVALID_OBJECT; + } + + if let Err(e) = ensure_renderer3d_state(context_id) { + set_last_error(e); + return GOUD_INVALID_OBJECT; + } + + with_renderer(context_id, |renderer| { + renderer + .instantiate_plane(source_plane_id) + .unwrap_or(GOUD_INVALID_OBJECT) + }) + .unwrap_or(GOUD_INVALID_OBJECT) +} + /// Creates a 3D sphere object. #[no_mangle] pub extern "C" fn goud_renderer3d_create_sphere( diff --git a/goud_engine/src/jni/generated.rs b/goud_engine/src/jni/generated.rs index bb674f7c8..46f9ce599 100644 --- a/goud_engine/src/jni/generated.rs +++ b/goud_engine/src/jni/generated.rs @@ -3242,6 +3242,26 @@ pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_createPlane<' }) } +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_instantiatePlane<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + sourcePlaneId: jni::sys::jint, +) -> jni::sys::jint { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_instantiatePlane", 0, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::renderer3d::goud_renderer3d_instantiate_plane(goud_context_id_from_jlong(contextId), sourcePlaneId as _); + if crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_renderer3d_instantiate_plane", Some(result as i64)); + return Err(()); + } + Ok(result as i32) + }) +} + #[allow(non_snake_case)] #[no_mangle] pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_createSphere<'local>( diff --git a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs index 610be3874..88482a3c5 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs @@ -165,6 +165,16 @@ pub struct Renderer3D { /// Object IDs that were actually included in the static batch (not overflowed). /// Used to distinguish batched objects from overflow objects that need individual draws. pub(in crate::libs::graphics::renderer3d) static_batched_ids: FxHashSet, + /// Pools of instanced plane primitives, keyed by `instanced_mesh_id`. + /// One pool per source plane; every instance through `instantiate_plane` + /// goes into the corresponding pool's `InstancedMesh`, collapsing to a + /// single instanced draw call per source plane (#679). + pub(in crate::libs::graphics::renderer3d) plane_instance_pools: + FxHashMap, + /// Reverse lookup from a plane-instance handle to `(instanced_mesh_id, slot)`. + pub(in crate::libs::graphics::renderer3d) plane_instance_index: FxHashMap, + /// Reverse lookup from a source plane object id to its pool's `instanced_mesh_id`. + pub(in crate::libs::graphics::renderer3d) source_plane_to_pool: FxHashMap, } // StaticBatchGroup is defined in core_static_batch.rs @@ -364,6 +374,9 @@ impl Renderer3D { static_batch_groups: Vec::new(), static_batch_vertex_count: 0, static_batched_ids: FxHashSet::default(), + plane_instance_pools: FxHashMap::default(), + plane_instance_index: FxHashMap::default(), + source_plane_to_pool: FxHashMap::default(), }) } } diff --git a/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs index a0eb24e1a..e190b009a 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs @@ -8,6 +8,9 @@ use cgmath::Vector3; #[allow(missing_docs)] impl Renderer3D { pub fn set_object_position(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + if self.try_update_plane_instance_transform(id, Some(Vector3::new(x, y, z)), None, None) { + return true; + } let ok = self.mutate_object(id, |obj| { obj.position = Vector3::new(x, y, z); }); @@ -17,6 +20,9 @@ impl Renderer3D { ok } pub fn set_object_rotation(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + if self.try_update_plane_instance_transform(id, None, Some(Vector3::new(x, y, z)), None) { + return true; + } 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; @@ -24,6 +30,9 @@ impl Renderer3D { ok } pub fn set_object_scale(&mut self, id: u32, x: f32, y: f32, z: f32) -> bool { + if self.try_update_plane_instance_transform(id, None, None, Some(Vector3::new(x, y, z))) { + return true; + } let ok = self.mutate_object(id, |obj| { obj.scale = Vector3::new(x, y, z); }); @@ -57,6 +66,9 @@ impl Renderer3D { } pub fn remove_object(&mut self, id: u32) -> bool { + if self.try_remove_plane_instance(id) { + return true; + } if let Some(obj) = self.objects.remove(&id) { if obj.is_static { self.static_batch_dirty = true; diff --git a/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs new file mode 100644 index 000000000..093178f9e --- /dev/null +++ b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs @@ -0,0 +1,272 @@ +//! Instanced primitive (plane) support for [`Renderer3D`]. +//! +//! `instantiate_plane(source_plane_id)` mirrors `instantiate_model`: it returns +//! a fresh per-instance handle whose transform can be updated independently, +//! while every instance that shares the same source plane is drawn through a +//! single instanced draw call. This collapses thousands of per-tile +//! `CreatePlane` primitives into one batch per source plane (issue #679). + +use super::core::Renderer3D; +use super::mesh::{update_instance_buffer, upload_buffer, upload_instance_buffer}; +use super::types::{InstanceTransform, InstancedMesh}; +use cgmath::Vector3; + +/// Bookkeeping for the pool of plane instances that share one source primitive. +/// +/// Each pool corresponds to exactly one [`InstancedMesh`] entry; the slot index +/// of an instance is its position in `instance_ids` and in +/// `InstancedMesh::instances`. Both vectors are kept aligned by every operation +/// in this module, so a swap-remove updates both at once. +#[derive(Debug)] +pub(in crate::libs::graphics::renderer3d) struct PlaneInstancePool { + /// Source plane object id this pool was seeded from. + pub(in crate::libs::graphics::renderer3d) source_plane_id: u32, + /// Instance handles parallel to `InstancedMesh::instances`. + pub(in crate::libs::graphics::renderer3d) instance_ids: Vec, + /// Capacity (in slots) of the GPU instance buffer; recreated when grown. + pub(in crate::libs::graphics::renderer3d) buffer_capacity_slots: usize, + /// Set when CPU-side instances are out of sync with the GPU buffer. + pub(in crate::libs::graphics::renderer3d) dirty: bool, +} + +impl Renderer3D { + /// Create an instance of a source plane primitive. + /// + /// Mirrors [`Renderer3D::instantiate_model`] for primitives. All instances + /// of the same source plane render through one instanced draw call. The + /// returned id can be passed to `set_object_position` / `set_object_rotation` + /// / `set_object_scale` / `remove_object` (per-instance transform updates), + /// and to `add_object_to_scene` (scene membership uses the source plane). + /// + /// Returns `None` when the source plane does not exist. + pub fn instantiate_plane(&mut self, source_plane_id: u32) -> Option { + let source = self.objects.get(&source_plane_id)?; + let texture_id = source.texture_id; + let source_vertices = source.vertices.clone(); + let vertex_count = source.vertex_count; + + let pool_mesh_id = if let Some(&existing) = self.source_plane_to_pool.get(&source_plane_id) + { + existing + } else { + let mesh_buffer = match upload_buffer(self.backend.as_mut(), &source_vertices) { + Ok(handle) => handle, + Err(e) => { + log::error!("Failed to create plane-instance mesh buffer: {e}"); + return None; + } + }; + + let initial_capacity_slots = 8usize; + let seed: Vec = + vec![InstanceTransform::default(); initial_capacity_slots]; + let instance_buffer = match upload_instance_buffer(self.backend.as_mut(), &seed) { + Ok(handle) => handle, + Err(e) => { + log::error!("Failed to create plane-instance buffer: {e}"); + self.backend.destroy_buffer(mesh_buffer); + return None; + } + }; + + let new_mesh_id = self.next_instanced_mesh_id; + self.next_instanced_mesh_id += 1; + self.instanced_meshes.insert( + new_mesh_id, + InstancedMesh { + mesh_buffer, + vertex_count: vertex_count as u32, + instance_buffer, + instances: Vec::with_capacity(initial_capacity_slots), + texture_id, + }, + ); + + self.plane_instance_pools.insert( + new_mesh_id, + PlaneInstancePool { + source_plane_id, + instance_ids: Vec::with_capacity(initial_capacity_slots), + buffer_capacity_slots: initial_capacity_slots, + dirty: false, + }, + ); + self.source_plane_to_pool + .insert(source_plane_id, new_mesh_id); + new_mesh_id + }; + + let instance_id = self.next_object_id; + self.next_object_id = self.next_object_id.wrapping_add(1); + if self.next_object_id == 0 { + self.next_object_id = 1; + } + + let mesh = self + .instanced_meshes + .get_mut(&pool_mesh_id) + .expect("pool mesh must exist"); + let pool = self + .plane_instance_pools + .get_mut(&pool_mesh_id) + .expect("pool must exist"); + + let slot = mesh.instances.len(); + mesh.instances.push(InstanceTransform::default()); + pool.instance_ids.push(instance_id); + pool.dirty = true; + + self.plane_instance_index + .insert(instance_id, (pool_mesh_id, slot)); + + Some(instance_id) + } + + /// Update a plane-instance transform component (position/rotation/scale). + /// + /// Returns `true` when `id` refers to a plane instance and was updated. + /// Intended to be called from the regular object setters before they fall + /// back to the dense-object map. + pub(in crate::libs::graphics::renderer3d) fn try_update_plane_instance_transform( + &mut self, + id: u32, + position: Option>, + rotation: Option>, + scale: Option>, + ) -> bool { + let Some(&(mesh_id, slot)) = self.plane_instance_index.get(&id) else { + return false; + }; + let Some(mesh) = self.instanced_meshes.get_mut(&mesh_id) else { + return false; + }; + let Some(slot_data) = mesh.instances.get_mut(slot) else { + return false; + }; + if let Some(p) = position { + slot_data.position = p; + } + if let Some(r) = rotation { + slot_data.rotation = r; + } + if let Some(s) = scale { + slot_data.scale = s; + } + if let Some(pool) = self.plane_instance_pools.get_mut(&mesh_id) { + pool.dirty = true; + } + true + } + + /// Remove a plane instance, swap-removing its slot to keep the buffer dense. + /// + /// Returns `true` when `id` referred to a plane instance. + pub(in crate::libs::graphics::renderer3d) fn try_remove_plane_instance( + &mut self, + id: u32, + ) -> bool { + let Some((mesh_id, slot)) = self.plane_instance_index.remove(&id) else { + return false; + }; + + let mut destroy_pool = false; + if let Some(mesh) = self.instanced_meshes.get_mut(&mesh_id) { + if let Some(pool) = self.plane_instance_pools.get_mut(&mesh_id) { + let last = mesh.instances.len().saturating_sub(1); + if slot < mesh.instances.len() { + if slot != last { + mesh.instances.swap(slot, last); + pool.instance_ids.swap(slot, last); + let moved_id = pool.instance_ids[slot]; + self.plane_instance_index.insert(moved_id, (mesh_id, slot)); + } + mesh.instances.pop(); + pool.instance_ids.pop(); + pool.dirty = true; + } + destroy_pool = mesh.instances.is_empty(); + } + } + + if destroy_pool { + if let Some(pool) = self.plane_instance_pools.remove(&mesh_id) { + self.source_plane_to_pool.remove(&pool.source_plane_id); + } + if let Some(mesh) = self.instanced_meshes.remove(&mesh_id) { + self.backend.destroy_buffer(mesh.mesh_buffer); + self.backend.destroy_buffer(mesh.instance_buffer); + } + } + + true + } + + /// Re-upload GPU instance buffers for any pool whose CPU instances changed + /// since the last upload. Called once per frame before the instanced pass. + pub(in crate::libs::graphics::renderer3d) fn flush_dirty_plane_instance_pools(&mut self) { + let mesh_ids: Vec = self + .plane_instance_pools + .iter() + .filter_map(|(id, p)| if p.dirty { Some(*id) } else { None }) + .collect(); + if mesh_ids.is_empty() { + return; + } + for mesh_id in mesh_ids { + let needed_slots = match self.instanced_meshes.get(&mesh_id) { + Some(m) => m.instances.len(), + None => continue, + }; + let capacity = self + .plane_instance_pools + .get(&mesh_id) + .map(|p| p.buffer_capacity_slots) + .unwrap_or(0); + + if needed_slots > capacity { + let new_capacity = needed_slots.next_power_of_two().max(8); + let mut padded: Vec = match self.instanced_meshes.get(&mesh_id) { + Some(m) => m.instances.clone(), + None => continue, + }; + padded.resize(new_capacity, InstanceTransform::default()); + + let new_buffer = match upload_instance_buffer(self.backend.as_mut(), &padded) { + Ok(handle) => handle, + Err(e) => { + log::error!("Failed to grow plane-instance buffer: {e}"); + continue; + } + }; + if let Some(mesh) = self.instanced_meshes.get_mut(&mesh_id) { + let old_buffer = mesh.instance_buffer; + mesh.instance_buffer = new_buffer; + self.backend.destroy_buffer(old_buffer); + } + if let Some(pool) = self.plane_instance_pools.get_mut(&mesh_id) { + pool.buffer_capacity_slots = new_capacity; + pool.dirty = false; + } + } else { + let buffer_handle = match self.instanced_meshes.get(&mesh_id) { + Some(m) => m.instance_buffer, + None => continue, + }; + let instances_clone: Vec = + match self.instanced_meshes.get(&mesh_id) { + Some(m) => m.instances.clone(), + None => continue, + }; + if let Err(e) = + update_instance_buffer(self.backend.as_mut(), buffer_handle, &instances_clone) + { + log::error!("Failed to update plane-instance buffer: {e}"); + continue; + } + if let Some(pool) = self.plane_instance_pools.get_mut(&mesh_id) { + pool.dirty = false; + } + } + } + } +} diff --git a/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs b/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs index b7cfc8f21..2e0e59b15 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs @@ -49,13 +49,17 @@ impl Renderer3D { /// Add an object to a scene. Returns `true` on success. /// - /// Fails if the scene or object does not exist. + /// Fails if the scene or object does not exist. When `object_id` is a plane + /// instance handle (created via `instantiate_plane`), the corresponding + /// source plane is added to the scene instead -- the entire pool's draw + /// becomes visible in the scene. pub fn add_object_to_scene(&mut self, scene_id: u32, object_id: u32) -> bool { - if !self.objects.contains_key(&object_id) { + let resolved = self.resolve_scene_object_id(object_id); + if !self.objects.contains_key(&resolved) { return false; } if let Some(scene) = self.scenes.get_mut(&scene_id) { - scene.add_object(object_id); + scene.add_object(resolved); true } else { false @@ -65,13 +69,24 @@ impl Renderer3D { /// Remove an object from a scene. Returns `true` if the scene existed and contained /// the object. pub fn remove_object_from_scene(&mut self, scene_id: u32, object_id: u32) -> bool { + let resolved = self.resolve_scene_object_id(object_id); if let Some(scene) = self.scenes.get_mut(&scene_id) { - scene.remove_object(object_id) + scene.remove_object(resolved) } else { false } } + /// Map a plane-instance handle to its source plane id; pass-through otherwise. + fn resolve_scene_object_id(&self, id: u32) -> u32 { + if let Some(&(mesh_id, _)) = self.plane_instance_index.get(&id) { + if let Some(pool) = self.plane_instance_pools.get(&mesh_id) { + return pool.source_plane_id; + } + } + id + } + /// Add a model to a scene. Returns `true` on success. /// /// Fails if the scene does not exist or the model/model-instance ID is unknown. diff --git a/goud_engine/src/libs/graphics/renderer3d/mod.rs b/goud_engine/src/libs/graphics/renderer3d/mod.rs index b99cb3ed8..ae3fe5b3c 100644 --- a/goud_engine/src/libs/graphics/renderer3d/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/mod.rs @@ -24,6 +24,7 @@ mod core_model_animation; mod core_model_instances; mod core_models; mod core_particles; +mod core_plane_instances; mod core_primitives; mod core_scenes; mod core_skinned; diff --git a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs index a0631529b..33a20dc9d 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs @@ -383,6 +383,8 @@ impl Renderer3D { self.backend.unbind_shader(); + self.flush_dirty_plane_instance_pools(); + self.render_instanced_and_particles( &view_arr, &proj_arr, diff --git a/goud_engine/src/libs/graphics/renderer3d/render_instanced.rs b/goud_engine/src/libs/graphics/renderer3d/render_instanced.rs index 8458c7de4..72fd99840 100644 --- a/goud_engine/src/libs/graphics/renderer3d/render_instanced.rs +++ b/goud_engine/src/libs/graphics/renderer3d/render_instanced.rs @@ -35,7 +35,23 @@ impl Renderer3D { eff_fog, filtered_lights, ); - for mesh in self.instanced_meshes.values() { + // Plane-instance pools (#679) only draw when their source plane is + // part of the current scene. Non-pool instanced meshes are always drawn. + let scene_object_filter = self + .current_scene + .and_then(|sid| self.scenes.get(&sid)) + .map(|s| &s.objects); + for (mesh_id, mesh) in &self.instanced_meshes { + if mesh.instances.is_empty() { + continue; + } + if let (Some(filter), Some(pool)) = + (scene_object_filter, self.plane_instance_pools.get(mesh_id)) + { + if !filter.contains(&pool.source_plane_id) { + continue; + } + } if mesh.texture_id > 0 { if let Some(tm) = texture_manager { tm.bind_texture(mesh.texture_id, 0); diff --git a/goud_engine/src/libs/graphics/renderer3d/tests.rs b/goud_engine/src/libs/graphics/renderer3d/tests.rs index bdf99219b..fe2cfadab 100644 --- a/goud_engine/src/libs/graphics/renderer3d/tests.rs +++ b/goud_engine/src/libs/graphics/renderer3d/tests.rs @@ -380,7 +380,166 @@ fn test_scene_filtering_limits_rendered_objects() { assert_eq!(renderer.stats().draw_calls, 2); } -/// Regression test for #630: primitives marked static must still render via +/// Perf regression for #679: 10 000 plane instances created via +/// `instantiate_plane` must render through a single instanced draw call, +/// instead of one per primitive. +#[test] +fn test_instantiate_plane_collapses_many_tiles_to_single_draw_call() { + let mut renderer = make_renderer(); + let source_plane = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + assert_ne!(source_plane, 0); + + let tile_count: usize = 10_000; + let mut instance_ids = Vec::with_capacity(tile_count); + for i in 0..tile_count { + let id = renderer + .instantiate_plane(source_plane) + .expect("instantiate_plane must succeed"); + let x = (i % 100) as f32; + let z = (i / 100) as f32; + renderer.set_object_position(id, x, 0.0, z); + instance_ids.push(id); + } + + renderer.render(None); + let stats = renderer.stats(); + // 1 source-plane object draw + 1 instanced draw for the pool, regardless + // of the 10 000 tiles. The pre-existing per-object pass still draws the + // source plane itself once (not in any scene); the instancing collapse is + // the win on top of that. + assert_eq!( + stats.instanced_draw_calls, 1, + "all instances of one source plane should batch into a single instanced draw" + ); + assert_eq!( + stats.active_instances, tile_count as u32, + "pool must report every instance" + ); + // Without `instantiate_plane`, 10 000 tiles via `create_primitive` would + // produce 10 000 visible objects in the dynamic pass. Here the source + // plane is the only object visible to the per-object pass. + assert_eq!(stats.visible_objects, 1, "no per-tile objects should exist"); +} + +/// Regression for #679: per-instance transforms must round-trip through the +/// instancing path -- moving an instance moves only that slot. +#[test] +fn test_instantiate_plane_per_instance_transform_updates_pool_only() { + let mut renderer = make_renderer(); + let source_plane = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + let a = renderer.instantiate_plane(source_plane).unwrap(); + let b = renderer.instantiate_plane(source_plane).unwrap(); + assert_ne!(a, b); + + assert!(renderer.set_object_position(a, 5.0, 0.0, 5.0)); + assert!(renderer.set_object_scale(b, 2.0, 1.0, 2.0)); + assert!(renderer.set_object_rotation(a, 0.0, 90.0, 0.0)); + + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 2); + + assert!(renderer.remove_object(a)); + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 1); + + assert!(renderer.remove_object(b)); + renderer.render(None); + // Removing the last instance tears down the pool -- no instanced draw left. + assert_eq!(renderer.stats().instanced_draw_calls, 0); + assert_eq!(renderer.stats().active_instances, 0); +} + +/// Regression for #679: two source planes with different textures should +/// produce exactly two instanced draw calls regardless of how many instances +/// each pool holds (matches the "9 materials -> 9 draw calls" expectation). +#[test] +fn test_instantiate_plane_two_source_planes_two_draw_calls() { + let mut renderer = make_renderer(); + let src_a = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + let src_b = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + for _ in 0..1_000 { + renderer.instantiate_plane(src_a).unwrap(); + } + for _ in 0..2_000 { + renderer.instantiate_plane(src_b).unwrap(); + } + + renderer.render(None); + let stats = renderer.stats(); + assert_eq!( + stats.instanced_draw_calls, 2, + "one instanced draw per source plane" + ); + assert_eq!(stats.active_instances, 3_000); +} + +/// Regression for #680: when a current scene is set, plane-instance pools +/// should only draw if their source plane was added to the scene. +#[test] +fn test_instantiate_plane_respects_current_scene() { + let mut renderer = make_renderer(); + let source_plane = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + for _ in 0..10 { + renderer.instantiate_plane(source_plane).unwrap(); + } + + let s = renderer.create_scene("level"); + renderer.set_current_scene(s); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 0, + "source plane is not in the current scene -- pool must not draw" + ); + + assert!(renderer.add_object_to_scene(s, source_plane)); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 1, + "source plane in scene -- pool draws once" + ); + assert_eq!(renderer.stats().active_instances, 10); +} + +/// Regression for #630: primitives marked static must still render via /// the static batch path instead of disappearing. #[test] fn test_static_primitive_renders_via_batch() { diff --git a/goud_engine/src/sdk/game/instance/lua_bindings/tools.g.rs b/goud_engine/src/sdk/game/instance/lua_bindings/tools.g.rs index 3fc30050d..13d44a14e 100644 --- a/goud_engine/src/sdk/game/instance/lua_bindings/tools.g.rs +++ b/goud_engine/src/sdk/game/instance/lua_bindings/tools.g.rs @@ -222,6 +222,7 @@ use crate::ffi::renderer3d::goud_renderer3d_get_skinning_mode; use crate::ffi::renderer3d::goud_renderer3d_get_static_batching_enabled; use crate::ffi::renderer3d::goud_renderer3d_get_visible_object_count; use crate::ffi::renderer3d::goud_renderer3d_instantiate_model; +use crate::ffi::renderer3d::goud_renderer3d_instantiate_plane; use crate::ffi::renderer3d::goud_renderer3d_is_animation_playing; use crate::ffi::renderer3d::goud_renderer3d_play_animation; use crate::ffi::renderer3d::goud_renderer3d_postprocess_pass_count; @@ -428,6 +429,11 @@ pub(crate) fn register_goud_game_tools(lua: &Lua, ctx_id: u64) -> LuaResult<()> Ok(goud_renderer3d_create_plane(ctx, arg0 as u32, arg1 as f32, arg2 as f32) as i64) })?; tbl.set("create_plane", f_create_plane)?; + // GoudGame.instantiatePlane + let f_instantiate_plane = lua.create_function(move |_, arg0: i64| { + Ok(goud_renderer3d_instantiate_plane(ctx, arg0 as u32) as i64) + })?; + tbl.set("instantiate_plane", f_instantiate_plane)?; // GoudGame.createSphere let f_create_sphere = lua.create_function(move |_, (arg0, arg1, arg2): (i64, f64, i64)| { Ok(goud_renderer3d_create_sphere(ctx, arg0 as u32, arg1 as f32, arg2 as u32) as i64) 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 b759da5b5..ce2ee6488 100644 --- a/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java +++ b/goud_engine/tests/jni/java/com/goudengine/internal/GoudGameNative.java @@ -67,6 +67,7 @@ private GoudGameNative() {} public static native int setParameterFloat(long contextId, long entity, String name, float value); public static native int createCube(long contextId, int textureId, float width, float height, float depth); public static native int createPlane(long contextId, int textureId, float width, float depth); + public static native int instantiatePlane(long contextId, int sourcePlaneId); public static native int createSphere(long contextId, int textureId, float diameter, int segments); public static native int createCylinder(long contextId, int textureId, float radius, float height, int segments); public static native boolean setObjectPosition(long contextId, int objectId, float x, float y, float z); diff --git a/sdks/csharp/generated/GoudGame.g.cs b/sdks/csharp/generated/GoudGame.g.cs index cbd5b690b..d34febc97 100644 --- a/sdks/csharp/generated/GoudGame.g.cs +++ b/sdks/csharp/generated/GoudGame.g.cs @@ -684,6 +684,12 @@ public uint CreatePlane(uint textureId, float width, float depth) return NativeMethods.goud_renderer3d_create_plane(_ctx, textureId, width, depth); } + /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. + public uint InstantiatePlane(uint sourcePlaneId) + { + return NativeMethods.goud_renderer3d_instantiate_plane(_ctx, sourcePlaneId); + } + /// Creates a 3D sphere public uint CreateSphere(uint textureId, float diameter, uint segments = 16) { diff --git a/sdks/csharp/generated/NativeMethods.g.cs b/sdks/csharp/generated/NativeMethods.g.cs index 0c960d038..aa5f5e733 100644 --- a/sdks/csharp/generated/NativeMethods.g.cs +++ b/sdks/csharp/generated/NativeMethods.g.cs @@ -752,6 +752,9 @@ public static unsafe class NativeMethods [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern uint goud_renderer3d_create_plane(GoudContextId context_id, uint texture_id, float width, float depth); + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern uint goud_renderer3d_instantiate_plane(GoudContextId context_id, uint source_plane_id); + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] public static extern uint goud_renderer3d_create_sphere(GoudContextId context_id, uint texture_id, float diameter, uint segments); diff --git a/sdks/csharp/include/goud_engine.h b/sdks/csharp/include/goud_engine.h index da9339ba3..0112da751 100644 --- a/sdks/csharp/include/goud_engine.h +++ b/sdks/csharp/include/goud_engine.h @@ -2728,6 +2728,11 @@ uint32_t goud_renderer3d_create_cube(struct GoudContextId context_id, uint32_t t */ uint32_t goud_renderer3d_create_plane(struct GoudContextId context_id, uint32_t texture_id, float width, float depth); +/** + * Creates an instance of a source plane primitive. + */ +uint32_t goud_renderer3d_instantiate_plane(struct GoudContextId context_id, uint32_t source_plane_id); + /** * Creates a 3D sphere object. */ diff --git a/sdks/go/goud/game.go b/sdks/go/goud/game.go index f2fcbfda7..db41d1c1f 100644 --- a/sdks/go/goud/game.go +++ b/sdks/go/goud/game.go @@ -444,6 +444,11 @@ func (g *Game) CreatePlane(textureId uint32, width float32, depth float32) uint3 return 0 } +// InstantiatePlane Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. +func (g *Game) InstantiatePlane(sourcePlaneId uint32) uint32 { + return 0 +} + // CreateSphere Creates a 3D sphere func (g *Game) CreateSphere(textureId uint32, diameter float32, segments uint32) uint32 { return 0 diff --git a/sdks/go/include/goud_engine.h b/sdks/go/include/goud_engine.h index da9339ba3..0112da751 100644 --- a/sdks/go/include/goud_engine.h +++ b/sdks/go/include/goud_engine.h @@ -2728,6 +2728,11 @@ uint32_t goud_renderer3d_create_cube(struct GoudContextId context_id, uint32_t t */ uint32_t goud_renderer3d_create_plane(struct GoudContextId context_id, uint32_t texture_id, float width, float depth); +/** + * Creates an instance of a source plane primitive. + */ +uint32_t goud_renderer3d_instantiate_plane(struct GoudContextId context_id, uint32_t source_plane_id); + /** * Creates a 3D sphere object. */ diff --git a/sdks/go/internal/ffi/ffi.go b/sdks/go/internal/ffi/ffi.go index 70e1afd8f..e0806e125 100644 --- a/sdks/go/internal/ffi/ffi.go +++ b/sdks/go/internal/ffi/ffi.go @@ -2361,6 +2361,11 @@ func GoudRenderer3dInstantiateModelBatch(context_id C.GoudContextId, source_mode return int32(C.goud_renderer3d_instantiate_model_batch(context_id, C.uint32_t(source_model_id), C.uint32_t(count), out_ids)) } +// GoudRenderer3dInstantiatePlane wraps goud_renderer3d_instantiate_plane. +func GoudRenderer3dInstantiatePlane(context_id C.GoudContextId, source_plane_id uint32) uint32 { + return uint32(C.goud_renderer3d_instantiate_plane(context_id, C.uint32_t(source_plane_id))) +} + // GoudRenderer3dIsAnimationPlaying wraps goud_renderer3d_is_animation_playing. func GoudRenderer3dIsAnimationPlaying(context_id C.GoudContextId, instance_id uint32) bool { return bool(C.goud_renderer3d_is_animation_playing(context_id, C.uint32_t(instance_id))) 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 b759da5b5..ce2ee6488 100644 --- a/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java +++ b/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java @@ -67,6 +67,7 @@ private GoudGameNative() {} public static native int setParameterFloat(long contextId, long entity, String name, float value); public static native int createCube(long contextId, int textureId, float width, float height, float depth); public static native int createPlane(long contextId, int textureId, float width, float depth); + public static native int instantiatePlane(long contextId, int sourcePlaneId); public static native int createSphere(long contextId, int textureId, float diameter, int segments); public static native int createCylinder(long contextId, int textureId, float radius, float height, int segments); public static native boolean setObjectPosition(long contextId, int objectId, float x, float y, float z); 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 f82bf15e6..2aca0da42 100644 --- a/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt @@ -247,6 +247,9 @@ class GoudGame internal constructor(internal val contextId: Long) : AutoCloseabl fun createPlane(textureId: Int, width: Float, depth: Float): Int = GoudGameNative.createPlane(contextId, textureId, width, depth) + fun instantiatePlane(sourcePlaneId: Int): Int = + GoudGameNative.instantiatePlane(contextId, sourcePlaneId) + fun createSphere(textureId: Int, diameter: Float, segments: Int): Int = GoudGameNative.createSphere(contextId, textureId, diameter, segments) diff --git a/sdks/python/goudengine/generated/_ffi.py b/sdks/python/goudengine/generated/_ffi.py index 50994a504..554d31d68 100644 --- a/sdks/python/goudengine/generated/_ffi.py +++ b/sdks/python/goudengine/generated/_ffi.py @@ -673,6 +673,8 @@ def _setup(): _lib.goud_renderer3d_create_cube.restype = ctypes.c_uint32 _lib.goud_renderer3d_create_plane.argtypes = [GoudContextId, ctypes.c_uint32, ctypes.c_float, ctypes.c_float] _lib.goud_renderer3d_create_plane.restype = ctypes.c_uint32 + _lib.goud_renderer3d_instantiate_plane.argtypes = [GoudContextId, ctypes.c_uint32] + _lib.goud_renderer3d_instantiate_plane.restype = ctypes.c_uint32 _lib.goud_renderer3d_create_sphere.argtypes = [GoudContextId, ctypes.c_uint32, ctypes.c_float, ctypes.c_uint32] _lib.goud_renderer3d_create_sphere.restype = ctypes.c_uint32 _lib.goud_renderer3d_create_cylinder.argtypes = [GoudContextId, ctypes.c_uint32, ctypes.c_float, ctypes.c_float, ctypes.c_uint32] diff --git a/sdks/python/goudengine/generated/_game.py b/sdks/python/goudengine/generated/_game.py index de5cbdfb8..40e9bebd3 100644 --- a/sdks/python/goudengine/generated/_game.py +++ b/sdks/python/goudengine/generated/_game.py @@ -432,6 +432,10 @@ def create_plane(self, texture_id, width, depth): """Creates a 3D plane""" return self._lib.goud_renderer3d_create_plane(self._ctx, texture_id, width, depth) + def instantiate_plane(self, source_plane_id): + """Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials.""" + return self._lib.goud_renderer3d_instantiate_plane(self._ctx, source_plane_id) + def create_sphere(self, texture_id, diameter, segments = 16): """Creates a 3D sphere""" return self._lib.goud_renderer3d_create_sphere(self._ctx, texture_id, diameter, segments) diff --git a/sdks/python/goudengine/include/goud_engine.h b/sdks/python/goudengine/include/goud_engine.h index da9339ba3..0112da751 100644 --- a/sdks/python/goudengine/include/goud_engine.h +++ b/sdks/python/goudengine/include/goud_engine.h @@ -2728,6 +2728,11 @@ uint32_t goud_renderer3d_create_cube(struct GoudContextId context_id, uint32_t t */ uint32_t goud_renderer3d_create_plane(struct GoudContextId context_id, uint32_t texture_id, float width, float depth); +/** + * Creates an instance of a source plane primitive. + */ +uint32_t goud_renderer3d_instantiate_plane(struct GoudContextId context_id, uint32_t source_plane_id); + /** * Creates a 3D sphere object. */ diff --git a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h index da9339ba3..0112da751 100644 --- a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h +++ b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h @@ -2728,6 +2728,11 @@ uint32_t goud_renderer3d_create_cube(struct GoudContextId context_id, uint32_t t */ uint32_t goud_renderer3d_create_plane(struct GoudContextId context_id, uint32_t texture_id, float width, float depth); +/** + * Creates an instance of a source plane primitive. + */ +uint32_t goud_renderer3d_instantiate_plane(struct GoudContextId context_id, uint32_t source_plane_id); + /** * Creates a 3D sphere object. */ diff --git a/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift b/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift index 5740568d3..d87f5f28f 100644 --- a/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift +++ b/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift @@ -218,6 +218,11 @@ public final class GoudGame { return goud_renderer3d_create_plane(_ctx, textureId, width, depth) } + /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. + public func instantiatePlane(sourcePlaneId: UInt32) -> UInt32 { + return goud_renderer3d_instantiate_plane(_ctx, sourcePlaneId) + } + /// Creates a 3D sphere public func createSphere(textureId: UInt32, diameter: Float, segments: UInt32 = 16) -> UInt32 { return goud_renderer3d_create_sphere(_ctx, textureId, diameter, segments) diff --git a/sdks/typescript/src/generated/node/index.g.ts b/sdks/typescript/src/generated/node/index.g.ts index 37158301e..dc3a81bc1 100644 --- a/sdks/typescript/src/generated/node/index.g.ts +++ b/sdks/typescript/src/generated/node/index.g.ts @@ -540,6 +540,11 @@ export class GoudGame implements IGoudGame { return this.native.createPlane(textureId, width, depth); } + /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. */ + instantiatePlane(sourcePlaneId: number): number { + return (this.native as any).instantiatePlane(sourcePlaneId); + } + /** Creates a 3D sphere */ createSphere(textureId: number, diameter: number, segments?: number): number { return this.native.createSphere(textureId, diameter, segments ?? 16); diff --git a/sdks/typescript/src/generated/types/engine.g.ts b/sdks/typescript/src/generated/types/engine.g.ts index 924bbd956..91d069c5a 100644 --- a/sdks/typescript/src/generated/types/engine.g.ts +++ b/sdks/typescript/src/generated/types/engine.g.ts @@ -302,6 +302,8 @@ export interface IGoudGame { createCube(textureId: number, width: number, height: number, depth: number): number; /** Creates a 3D plane */ createPlane(textureId: number, width: number, depth: number): number; + /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. */ + instantiatePlane(sourcePlaneId: number): number; /** Creates a 3D sphere */ createSphere(textureId: number, diameter: number, segments?: number): number; /** Creates a 3D cylinder */ From 3eddac655af4c5c844fb8a68d50d69a01fdc69d8 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:23:44 -0600 Subject: [PATCH 2/7] fix(renderer3d): cascade plane-instance pool teardown + skip avoidable clone (#679 review) Addresses code-quality, security, and spec review feedback on the InstantiatePlane PR: - Removing the source plane via destroy_object now tears down its pool (frees GPU buffers, invalidates every instance handle) so a future object id reusing the same value cannot route into a stale pool. - flush_dirty_plane_instance_pools no longer clones the live instances Vec on the steady-state in-place upload path; uses a split-borrow with a SAFETY-commented unsafe slice plus a persistent scratch buffer for the dirty-id list. Grow path uses mem::take/swap to pad without cloning. - instantiate_plane drops the .expect() panic; lookup falls back to None gracefully and reserves the object id only after the pool lookup succeeds. - New regression test test_remove_source_plane_cascades_pool_teardown asserts pool teardown + handle invalidation when the source plane is destroyed. - FFI doc + schema doc explain interaction with set_static_batching_enabled, set_instancing_enabled, and set_min_instances_for_batching for primitives and InstantiatePlane (spec ask 3 of #679). All SDK wrappers regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- codegen/goud_sdk.schema.json | 2 +- goud_engine/src/ffi/renderer3d/primitives.rs | 30 +++++ .../src/libs/graphics/renderer3d/core/mod.rs | 3 + .../renderer3d/core/object_transforms.rs | 4 + .../renderer3d/core_plane_instances.rs | 107 +++++++++++++----- .../src/libs/graphics/renderer3d/tests.rs | 41 +++++++ sdks/csharp/generated/GoudGame.g.cs | 2 +- sdks/go/goud/game.go | 2 +- sdks/python/goudengine/generated/_game.py | 2 +- .../GoudEngine/generated/GoudGame.g.swift | 2 +- sdks/typescript/src/generated/node/index.g.ts | 2 +- .../src/generated/types/engine.g.ts | 2 +- 12 files changed, 165 insertions(+), 34 deletions(-) diff --git a/codegen/goud_sdk.schema.json b/codegen/goud_sdk.schema.json index a89fe762d..21581fd00 100644 --- a/codegen/goud_sdk.schema.json +++ b/codegen/goud_sdk.schema.json @@ -5057,7 +5057,7 @@ }, { "name": "instantiatePlane", - "doc": "Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials.", + "doc": "Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated.", "params": [ { "name": "sourcePlaneId", diff --git a/goud_engine/src/ffi/renderer3d/primitives.rs b/goud_engine/src/ffi/renderer3d/primitives.rs index d3d5e7f7c..87bdc27f8 100644 --- a/goud_engine/src/ffi/renderer3d/primitives.rs +++ b/goud_engine/src/ffi/renderer3d/primitives.rs @@ -119,6 +119,36 @@ pub extern "C" fn goud_renderer3d_create_plane( /// plane's material/texture is captured when the first instance is created. /// Use one source plane per material to draw multiple materials. /// +/// # Lifecycle +/// +/// Destroying the source plane via `destroy_object` cascades: the underlying +/// pool's GPU buffers are freed, every existing instance handle is invalidated, +/// and subsequent calls to `instantiate_plane` with the same id return +/// `GOUD_INVALID_OBJECT`. Adding the source plane to a scene via +/// `add_object_to_scene` makes the entire pool visible in that scene; passing +/// an instance handle to `add_object_to_scene` resolves to the source plane. +/// +/// # Interaction with batching flags +/// +/// Plane-instance pools always render through the instanced path. The +/// renderer's batching flags do **not** gate them: +/// +/// - `set_static_batching_enabled` controls the static-batch VBO that combines +/// non-instanced primitives marked with `set_object_static`. It does **not** +/// apply to plane-instance pools and does not need to be enabled to benefit +/// from `instantiate_plane`. +/// - `set_instancing_enabled` enables instanced rendering for skinned model +/// instances above the `min_instances_for_batching` threshold. It does +/// **not** apply to plane-instance pools, which always batch regardless of +/// the flag or instance count. +/// - `set_min_instances_for_batching` is the threshold for skinned model +/// instancing. It is ignored by plane-instance pools. +/// +/// Primitives created via `create_plane` (without `instantiate_plane`) still +/// follow the legacy paths: `set_object_static` + `set_static_batching_enabled` +/// places them in the static-batch VBO; otherwise they render via the +/// per-object pass. +/// /// # Arguments /// * `context_id` - The windowed context /// * `source_plane_id` - Object id returned by `goud_renderer3d_create_plane` diff --git a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs index 88482a3c5..a92fea0d5 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs @@ -175,6 +175,8 @@ pub struct Renderer3D { pub(in crate::libs::graphics::renderer3d) plane_instance_index: FxHashMap, /// Reverse lookup from a source plane object id to its pool's `instanced_mesh_id`. pub(in crate::libs::graphics::renderer3d) source_plane_to_pool: FxHashMap, + /// Reusable scratch buffer of dirty pool ids (avoids per-frame alloc). + pub(in crate::libs::graphics::renderer3d) scratch_dirty_plane_pool_ids: Vec, } // StaticBatchGroup is defined in core_static_batch.rs @@ -377,6 +379,7 @@ impl Renderer3D { plane_instance_pools: FxHashMap::default(), plane_instance_index: FxHashMap::default(), source_plane_to_pool: FxHashMap::default(), + scratch_dirty_plane_pool_ids: Vec::new(), }) } } diff --git a/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs index e190b009a..a0c1b3f0e 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/object_transforms.rs @@ -74,6 +74,10 @@ impl Renderer3D { self.static_batch_dirty = true; } self.backend.destroy_buffer(obj.buffer); + // If this object was the source of a plane-instance pool, tear + // the whole pool down so the source-plane id cannot route future + // instances into a stale pool (#679 review feedback). + self.destroy_plane_pool_for_source(id); true } else { false diff --git a/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs index 093178f9e..71eaf0027 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs @@ -10,6 +10,7 @@ use super::core::Renderer3D; use super::mesh::{update_instance_buffer, upload_buffer, upload_instance_buffer}; use super::types::{InstanceTransform, InstancedMesh}; use cgmath::Vector3; +use std::mem; /// Bookkeeping for the pool of plane instances that share one source primitive. /// @@ -38,6 +39,14 @@ impl Renderer3D { /// / `set_object_scale` / `remove_object` (per-instance transform updates), /// and to `add_object_to_scene` (scene membership uses the source plane). /// + /// Per-instance materials are not supported: the source plane's + /// material/texture is captured at the time the pool is first created. + /// Use one source plane per material to draw multiple materials. + /// + /// Destroying the source plane via [`Renderer3D::remove_object`] cascades + /// to the pool: every instance handle is invalidated and the pool's GPU + /// buffers are freed. + /// /// Returns `None` when the source plane does not exist. pub fn instantiate_plane(&mut self, source_plane_id: u32) -> Option { let source = self.objects.get(&source_plane_id)?; @@ -96,21 +105,17 @@ impl Renderer3D { new_mesh_id }; + // Append the slot before allocating an id so a missing pool short-circuits + // without burning an id from the shared object-id counter. + let mesh = self.instanced_meshes.get_mut(&pool_mesh_id)?; + let pool = self.plane_instance_pools.get_mut(&pool_mesh_id)?; + let instance_id = self.next_object_id; self.next_object_id = self.next_object_id.wrapping_add(1); if self.next_object_id == 0 { self.next_object_id = 1; } - let mesh = self - .instanced_meshes - .get_mut(&pool_mesh_id) - .expect("pool mesh must exist"); - let pool = self - .plane_instance_pools - .get_mut(&pool_mesh_id) - .expect("pool must exist"); - let slot = mesh.instances.len(); mesh.instances.push(InstanceTransform::default()); pool.instance_ids.push(instance_id); @@ -122,6 +127,30 @@ impl Renderer3D { Some(instance_id) } + /// Cascade-destroy any plane-instance pool seeded from `source_plane_id`. + /// + /// Called from `remove_object` when the user destroys the source plane: + /// the pool's GPU buffers are freed, every instance handle is invalidated, + /// and the `source_plane_to_pool` reverse map is cleaned up so that the + /// source-plane id cannot route future instances into a stale pool. + pub(in crate::libs::graphics::renderer3d) fn destroy_plane_pool_for_source( + &mut self, + source_plane_id: u32, + ) { + let Some(mesh_id) = self.source_plane_to_pool.remove(&source_plane_id) else { + return; + }; + if let Some(pool) = self.plane_instance_pools.remove(&mesh_id) { + for instance_id in &pool.instance_ids { + self.plane_instance_index.remove(instance_id); + } + } + if let Some(mesh) = self.instanced_meshes.remove(&mesh_id) { + self.backend.destroy_buffer(mesh.mesh_buffer); + self.backend.destroy_buffer(mesh.instance_buffer); + } + } + /// Update a plane-instance transform component (position/rotation/scale). /// /// Returns `true` when `id` refers to a plane instance and was updated. @@ -204,15 +233,22 @@ impl Renderer3D { /// Re-upload GPU instance buffers for any pool whose CPU instances changed /// since the last upload. Called once per frame before the instanced pass. pub(in crate::libs::graphics::renderer3d) fn flush_dirty_plane_instance_pools(&mut self) { - let mesh_ids: Vec = self - .plane_instance_pools - .iter() - .filter_map(|(id, p)| if p.dirty { Some(*id) } else { None }) - .collect(); - if mesh_ids.is_empty() { + // Reuse the persistent scratch Vec so the per-frame allocation is + // bounded by the largest pool count we have ever seen. + let mut dirty_ids = mem::take(&mut self.scratch_dirty_plane_pool_ids); + dirty_ids.clear(); + for (id, pool) in &self.plane_instance_pools { + if pool.dirty { + dirty_ids.push(*id); + } + } + if dirty_ids.is_empty() { + self.scratch_dirty_plane_pool_ids = dirty_ids; return; } - for mesh_id in mesh_ids { + + for mesh_id in dirty_ids.drain(..) { + // Borrow the InstancedMesh for instance slice and capacity in one read. let needed_slots = match self.instanced_meshes.get(&mesh_id) { Some(m) => m.instances.len(), None => continue, @@ -224,14 +260,26 @@ impl Renderer3D { .unwrap_or(0); if needed_slots > capacity { + // Grow path: swap the live instances out, pad to the new + // capacity (zero-initialized tail slots are not drawn since + // the draw call's instance_count uses the live len), upload, + // swap the live vec back. No .clone() of the instance vec. let new_capacity = needed_slots.next_power_of_two().max(8); - let mut padded: Vec = match self.instanced_meshes.get(&mesh_id) { - Some(m) => m.instances.clone(), + let mut padded = match self.instanced_meshes.get_mut(&mesh_id) { + Some(m) => mem::take(&mut m.instances), None => continue, }; padded.resize(new_capacity, InstanceTransform::default()); - let new_buffer = match upload_instance_buffer(self.backend.as_mut(), &padded) { + let upload_result = upload_instance_buffer(self.backend.as_mut(), &padded); + + // Restore the live slice (truncate the tail back to needed_slots). + padded.truncate(needed_slots); + if let Some(mesh) = self.instanced_meshes.get_mut(&mesh_id) { + mesh.instances = padded; + } + + let new_buffer = match upload_result { Ok(handle) => handle, Err(e) => { log::error!("Failed to grow plane-instance buffer: {e}"); @@ -248,17 +296,20 @@ impl Renderer3D { pool.dirty = false; } } else { - let buffer_handle = match self.instanced_meshes.get(&mesh_id) { - Some(m) => m.instance_buffer, - None => continue, - }; - let instances_clone: Vec = + // In-place update: split-borrow `self.instanced_meshes` and + // `self.backend` (disjoint fields) to avoid cloning the Vec. + let (buffer_handle, instances_ptr, instances_len) = match self.instanced_meshes.get(&mesh_id) { - Some(m) => m.instances.clone(), + Some(m) => (m.instance_buffer, m.instances.as_ptr(), m.instances.len()), None => continue, }; - if let Err(e) = - update_instance_buffer(self.backend.as_mut(), buffer_handle, &instances_clone) + // SAFETY: instances_ptr/len are derived from + // self.instanced_meshes[mesh_id].instances above. No code in + // update_instance_buffer mutates self.instanced_meshes (it + // only touches self.backend, a disjoint field), so the slice + // is valid for the duration of this call. + let slice = unsafe { std::slice::from_raw_parts(instances_ptr, instances_len) }; + if let Err(e) = update_instance_buffer(self.backend.as_mut(), buffer_handle, slice) { log::error!("Failed to update plane-instance buffer: {e}"); continue; @@ -268,5 +319,7 @@ impl Renderer3D { } } } + + self.scratch_dirty_plane_pool_ids = dirty_ids; } } diff --git a/goud_engine/src/libs/graphics/renderer3d/tests.rs b/goud_engine/src/libs/graphics/renderer3d/tests.rs index fe2cfadab..e793ca1d1 100644 --- a/goud_engine/src/libs/graphics/renderer3d/tests.rs +++ b/goud_engine/src/libs/graphics/renderer3d/tests.rs @@ -503,6 +503,47 @@ fn test_instantiate_plane_two_source_planes_two_draw_calls() { assert_eq!(stats.active_instances, 3_000); } +/// Regression for #679: removing the source plane must cascade-tear-down its +/// pool so the source-plane id cannot route future instances into a stale +/// pool. The instance handles are invalidated too. +#[test] +fn test_remove_source_plane_cascades_pool_teardown() { + let mut renderer = make_renderer(); + let source_plane = renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }); + let inst_a = renderer.instantiate_plane(source_plane).unwrap(); + let inst_b = renderer.instantiate_plane(source_plane).unwrap(); + + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 2); + + // Destroying the source plane must also tear down the pool. + assert!(renderer.remove_object(source_plane)); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 0, + "pool must be torn down when its source plane is destroyed" + ); + + // Stale instance handles are invalidated, not silently kept alive. + assert!( + !renderer.set_object_position(inst_a, 1.0, 2.0, 3.0), + "instance handle should be invalid after source teardown" + ); + assert!( + !renderer.remove_object(inst_b), + "instance handle should be unknown after source teardown" + ); +} + /// Regression for #680: when a current scene is set, plane-instance pools /// should only draw if their source plane was added to the scene. #[test] diff --git a/sdks/csharp/generated/GoudGame.g.cs b/sdks/csharp/generated/GoudGame.g.cs index d34febc97..0e777e74d 100644 --- a/sdks/csharp/generated/GoudGame.g.cs +++ b/sdks/csharp/generated/GoudGame.g.cs @@ -684,7 +684,7 @@ public uint CreatePlane(uint textureId, float width, float depth) return NativeMethods.goud_renderer3d_create_plane(_ctx, textureId, width, depth); } - /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. + /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated. public uint InstantiatePlane(uint sourcePlaneId) { return NativeMethods.goud_renderer3d_instantiate_plane(_ctx, sourcePlaneId); diff --git a/sdks/go/goud/game.go b/sdks/go/goud/game.go index db41d1c1f..65cf39d33 100644 --- a/sdks/go/goud/game.go +++ b/sdks/go/goud/game.go @@ -444,7 +444,7 @@ func (g *Game) CreatePlane(textureId uint32, width float32, depth float32) uint3 return 0 } -// InstantiatePlane Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. +// InstantiatePlane Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated. func (g *Game) InstantiatePlane(sourcePlaneId uint32) uint32 { return 0 } diff --git a/sdks/python/goudengine/generated/_game.py b/sdks/python/goudengine/generated/_game.py index 40e9bebd3..bfa49f629 100644 --- a/sdks/python/goudengine/generated/_game.py +++ b/sdks/python/goudengine/generated/_game.py @@ -433,7 +433,7 @@ def create_plane(self, texture_id, width, depth): return self._lib.goud_renderer3d_create_plane(self._ctx, texture_id, width, depth) def instantiate_plane(self, source_plane_id): - """Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials.""" + """Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated.""" return self._lib.goud_renderer3d_instantiate_plane(self._ctx, source_plane_id) def create_sphere(self, texture_id, diameter, segments = 16): diff --git a/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift b/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift index d87f5f28f..c1a713635 100644 --- a/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift +++ b/sdks/swift/Sources/GoudEngine/generated/GoudGame.g.swift @@ -218,7 +218,7 @@ public final class GoudGame { return goud_renderer3d_create_plane(_ctx, textureId, width, depth) } - /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. + /// Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated. public func instantiatePlane(sourcePlaneId: UInt32) -> UInt32 { return goud_renderer3d_instantiate_plane(_ctx, sourcePlaneId) } diff --git a/sdks/typescript/src/generated/node/index.g.ts b/sdks/typescript/src/generated/node/index.g.ts index dc3a81bc1..8faa86931 100644 --- a/sdks/typescript/src/generated/node/index.g.ts +++ b/sdks/typescript/src/generated/node/index.g.ts @@ -540,7 +540,7 @@ export class GoudGame implements IGoudGame { return this.native.createPlane(textureId, width, depth); } - /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. */ + /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated. */ instantiatePlane(sourcePlaneId: number): number { return (this.native as any).instantiatePlane(sourcePlaneId); } diff --git a/sdks/typescript/src/generated/types/engine.g.ts b/sdks/typescript/src/generated/types/engine.g.ts index 91d069c5a..b24aa5b68 100644 --- a/sdks/typescript/src/generated/types/engine.g.ts +++ b/sdks/typescript/src/generated/types/engine.g.ts @@ -302,7 +302,7 @@ export interface IGoudGame { createCube(textureId: number, width: number, height: number, depth: number): number; /** Creates a 3D plane */ createPlane(textureId: number, width: number, depth: number): number; - /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call. Use one source plane per material to draw multiple materials. */ + /** Creates an instanced plane that shares geometry with a source plane (issue #679). All instances of the same source plane render through one instanced draw call regardless of setStaticBatchingEnabled / setInstancingEnabled / setMinInstancesForBatching (those flags govern non-instanced primitives and skinned models, not plane-instance pools). Use one source plane per material to draw multiple materials. Destroying the source plane cascades: the pool is freed and existing instance handles are invalidated. */ instantiatePlane(sourcePlaneId: number): number; /** Creates a 3D sphere */ createSphere(textureId: number, diameter: number, segments?: number): number; From 397e803f09713180f92ece86c6f3f57035911874 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:29:10 -0600 Subject: [PATCH 3/7] refactor(tests): split plane-instance tests into sibling file (#679 CI) scripts/check-rs-line-limit.sh enforces a 500-line cap on .rs files. The new plane-instance tests pushed renderer3d/tests.rs to 615 lines, which made CI's `Rust File Line Limit` job fail. Moved the 5 plane-instance tests into a sibling `tests_plane_instances.rs` so both files stay under the cap. No test logic changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/libs/graphics/renderer3d/mod.rs | 2 + .../src/libs/graphics/renderer3d/tests.rs | 201 +----------------- .../renderer3d/tests_plane_instances.rs | 178 ++++++++++++++++ 3 files changed, 182 insertions(+), 199 deletions(-) create mode 100644 goud_engine/src/libs/graphics/renderer3d/tests_plane_instances.rs diff --git a/goud_engine/src/libs/graphics/renderer3d/mod.rs b/goud_engine/src/libs/graphics/renderer3d/mod.rs index ae3fe5b3c..5a768193b 100644 --- a/goud_engine/src/libs/graphics/renderer3d/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/mod.rs @@ -49,6 +49,8 @@ mod types; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_plane_instances; // Public API re-exports — the external interface is unchanged. pub use animation::{AnimationPlayer, AnimationState, AnimationTransition}; diff --git a/goud_engine/src/libs/graphics/renderer3d/tests.rs b/goud_engine/src/libs/graphics/renderer3d/tests.rs index e793ca1d1..421920c20 100644 --- a/goud_engine/src/libs/graphics/renderer3d/tests.rs +++ b/goud_engine/src/libs/graphics/renderer3d/tests.rs @@ -380,205 +380,8 @@ fn test_scene_filtering_limits_rendered_objects() { assert_eq!(renderer.stats().draw_calls, 2); } -/// Perf regression for #679: 10 000 plane instances created via -/// `instantiate_plane` must render through a single instanced draw call, -/// instead of one per primitive. -#[test] -fn test_instantiate_plane_collapses_many_tiles_to_single_draw_call() { - let mut renderer = make_renderer(); - let source_plane = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - assert_ne!(source_plane, 0); - - let tile_count: usize = 10_000; - let mut instance_ids = Vec::with_capacity(tile_count); - for i in 0..tile_count { - let id = renderer - .instantiate_plane(source_plane) - .expect("instantiate_plane must succeed"); - let x = (i % 100) as f32; - let z = (i / 100) as f32; - renderer.set_object_position(id, x, 0.0, z); - instance_ids.push(id); - } - - renderer.render(None); - let stats = renderer.stats(); - // 1 source-plane object draw + 1 instanced draw for the pool, regardless - // of the 10 000 tiles. The pre-existing per-object pass still draws the - // source plane itself once (not in any scene); the instancing collapse is - // the win on top of that. - assert_eq!( - stats.instanced_draw_calls, 1, - "all instances of one source plane should batch into a single instanced draw" - ); - assert_eq!( - stats.active_instances, tile_count as u32, - "pool must report every instance" - ); - // Without `instantiate_plane`, 10 000 tiles via `create_primitive` would - // produce 10 000 visible objects in the dynamic pass. Here the source - // plane is the only object visible to the per-object pass. - assert_eq!(stats.visible_objects, 1, "no per-tile objects should exist"); -} - -/// Regression for #679: per-instance transforms must round-trip through the -/// instancing path -- moving an instance moves only that slot. -#[test] -fn test_instantiate_plane_per_instance_transform_updates_pool_only() { - let mut renderer = make_renderer(); - let source_plane = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - let a = renderer.instantiate_plane(source_plane).unwrap(); - let b = renderer.instantiate_plane(source_plane).unwrap(); - assert_ne!(a, b); - - assert!(renderer.set_object_position(a, 5.0, 0.0, 5.0)); - assert!(renderer.set_object_scale(b, 2.0, 1.0, 2.0)); - assert!(renderer.set_object_rotation(a, 0.0, 90.0, 0.0)); - - renderer.render(None); - assert_eq!(renderer.stats().instanced_draw_calls, 1); - assert_eq!(renderer.stats().active_instances, 2); - - assert!(renderer.remove_object(a)); - renderer.render(None); - assert_eq!(renderer.stats().instanced_draw_calls, 1); - assert_eq!(renderer.stats().active_instances, 1); - - assert!(renderer.remove_object(b)); - renderer.render(None); - // Removing the last instance tears down the pool -- no instanced draw left. - assert_eq!(renderer.stats().instanced_draw_calls, 0); - assert_eq!(renderer.stats().active_instances, 0); -} - -/// Regression for #679: two source planes with different textures should -/// produce exactly two instanced draw calls regardless of how many instances -/// each pool holds (matches the "9 materials -> 9 draw calls" expectation). -#[test] -fn test_instantiate_plane_two_source_planes_two_draw_calls() { - let mut renderer = make_renderer(); - let src_a = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - let src_b = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - for _ in 0..1_000 { - renderer.instantiate_plane(src_a).unwrap(); - } - for _ in 0..2_000 { - renderer.instantiate_plane(src_b).unwrap(); - } - - renderer.render(None); - let stats = renderer.stats(); - assert_eq!( - stats.instanced_draw_calls, 2, - "one instanced draw per source plane" - ); - assert_eq!(stats.active_instances, 3_000); -} - -/// Regression for #679: removing the source plane must cascade-tear-down its -/// pool so the source-plane id cannot route future instances into a stale -/// pool. The instance handles are invalidated too. -#[test] -fn test_remove_source_plane_cascades_pool_teardown() { - let mut renderer = make_renderer(); - let source_plane = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - let inst_a = renderer.instantiate_plane(source_plane).unwrap(); - let inst_b = renderer.instantiate_plane(source_plane).unwrap(); - - renderer.render(None); - assert_eq!(renderer.stats().instanced_draw_calls, 1); - assert_eq!(renderer.stats().active_instances, 2); - - // Destroying the source plane must also tear down the pool. - assert!(renderer.remove_object(source_plane)); - renderer.render(None); - assert_eq!( - renderer.stats().instanced_draw_calls, - 0, - "pool must be torn down when its source plane is destroyed" - ); - - // Stale instance handles are invalidated, not silently kept alive. - assert!( - !renderer.set_object_position(inst_a, 1.0, 2.0, 3.0), - "instance handle should be invalid after source teardown" - ); - assert!( - !renderer.remove_object(inst_b), - "instance handle should be unknown after source teardown" - ); -} - -/// Regression for #680: when a current scene is set, plane-instance pools -/// should only draw if their source plane was added to the scene. -#[test] -fn test_instantiate_plane_respects_current_scene() { - let mut renderer = make_renderer(); - let source_plane = renderer.create_primitive(PrimitiveCreateInfo { - primitive_type: PrimitiveType::Plane, - width: 1.0, - height: 0.0, - depth: 1.0, - segments: 1, - texture_id: 0, - }); - for _ in 0..10 { - renderer.instantiate_plane(source_plane).unwrap(); - } - - let s = renderer.create_scene("level"); - renderer.set_current_scene(s); - renderer.render(None); - assert_eq!( - renderer.stats().instanced_draw_calls, - 0, - "source plane is not in the current scene -- pool must not draw" - ); - - assert!(renderer.add_object_to_scene(s, source_plane)); - renderer.render(None); - assert_eq!( - renderer.stats().instanced_draw_calls, - 1, - "source plane in scene -- pool draws once" - ); - assert_eq!(renderer.stats().active_instances, 10); -} +// Plane-instance tests for #679 live in `tests_plane_instances.rs` to keep +// this file under the 500-line cap (`scripts/check-rs-line-limit.sh`). /// Regression for #630: primitives marked static must still render via /// the static batch path instead of disappearing. diff --git a/goud_engine/src/libs/graphics/renderer3d/tests_plane_instances.rs b/goud_engine/src/libs/graphics/renderer3d/tests_plane_instances.rs new file mode 100644 index 000000000..a80b92054 --- /dev/null +++ b/goud_engine/src/libs/graphics/renderer3d/tests_plane_instances.rs @@ -0,0 +1,178 @@ +//! Tests for the plane-instance pool path (`instantiate_plane`, #679). +//! +//! These live in their own file so the main `tests.rs` stays under the +//! 500-line cap (`scripts/check-rs-line-limit.sh`). + +use super::{PrimitiveCreateInfo, PrimitiveType, Renderer3D}; +use crate::libs::graphics::backend::null::NullBackend; + +fn make_renderer() -> Renderer3D { + Renderer3D::new(Box::new(NullBackend::new()), 800, 600).expect("renderer should initialize") +} + +fn make_unit_plane(renderer: &mut Renderer3D) -> u32 { + renderer.create_primitive(PrimitiveCreateInfo { + primitive_type: PrimitiveType::Plane, + width: 1.0, + height: 0.0, + depth: 1.0, + segments: 1, + texture_id: 0, + }) +} + +/// Perf regression for #679: 10 000 plane instances created via +/// `instantiate_plane` must render through a single instanced draw call, +/// instead of one per primitive. +#[test] +fn test_instantiate_plane_collapses_many_tiles_to_single_draw_call() { + let mut renderer = make_renderer(); + let source_plane = make_unit_plane(&mut renderer); + assert_ne!(source_plane, 0); + + let tile_count: usize = 10_000; + let mut instance_ids = Vec::with_capacity(tile_count); + for i in 0..tile_count { + let id = renderer + .instantiate_plane(source_plane) + .expect("instantiate_plane must succeed"); + let x = (i % 100) as f32; + let z = (i / 100) as f32; + renderer.set_object_position(id, x, 0.0, z); + instance_ids.push(id); + } + + renderer.render(None); + let stats = renderer.stats(); + // 1 source-plane object draw + 1 instanced draw for the pool, regardless + // of the 10 000 tiles. The pre-existing per-object pass still draws the + // source plane itself once (not in any scene); the instancing collapse is + // the win on top of that. + assert_eq!( + stats.instanced_draw_calls, 1, + "all instances of one source plane should batch into a single instanced draw" + ); + assert_eq!( + stats.active_instances, tile_count as u32, + "pool must report every instance" + ); + // Without `instantiate_plane`, 10 000 tiles via `create_primitive` would + // produce 10 000 visible objects in the dynamic pass. Here the source + // plane is the only object visible to the per-object pass. + assert_eq!(stats.visible_objects, 1, "no per-tile objects should exist"); +} + +/// Regression for #679: per-instance transforms must round-trip through the +/// instancing path -- moving an instance moves only that slot. +#[test] +fn test_instantiate_plane_per_instance_transform_updates_pool_only() { + let mut renderer = make_renderer(); + let source_plane = make_unit_plane(&mut renderer); + let a = renderer.instantiate_plane(source_plane).unwrap(); + let b = renderer.instantiate_plane(source_plane).unwrap(); + assert_ne!(a, b); + + assert!(renderer.set_object_position(a, 5.0, 0.0, 5.0)); + assert!(renderer.set_object_scale(b, 2.0, 1.0, 2.0)); + assert!(renderer.set_object_rotation(a, 0.0, 90.0, 0.0)); + + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 2); + + assert!(renderer.remove_object(a)); + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 1); + + assert!(renderer.remove_object(b)); + renderer.render(None); + // Removing the last instance tears down the pool -- no instanced draw left. + assert_eq!(renderer.stats().instanced_draw_calls, 0); + assert_eq!(renderer.stats().active_instances, 0); +} + +/// Regression for #679: two source planes with different textures should +/// produce exactly two instanced draw calls regardless of how many instances +/// each pool holds (matches the "9 materials -> 9 draw calls" expectation). +#[test] +fn test_instantiate_plane_two_source_planes_two_draw_calls() { + let mut renderer = make_renderer(); + let src_a = make_unit_plane(&mut renderer); + let src_b = make_unit_plane(&mut renderer); + for _ in 0..1_000 { + renderer.instantiate_plane(src_a).unwrap(); + } + for _ in 0..2_000 { + renderer.instantiate_plane(src_b).unwrap(); + } + + renderer.render(None); + let stats = renderer.stats(); + assert_eq!( + stats.instanced_draw_calls, 2, + "one instanced draw per source plane" + ); + assert_eq!(stats.active_instances, 3_000); +} + +/// Regression for #679: removing the source plane must cascade-tear-down its +/// pool so the source-plane id cannot route future instances into a stale +/// pool. The instance handles are invalidated too. +#[test] +fn test_remove_source_plane_cascades_pool_teardown() { + let mut renderer = make_renderer(); + let source_plane = make_unit_plane(&mut renderer); + let inst_a = renderer.instantiate_plane(source_plane).unwrap(); + let inst_b = renderer.instantiate_plane(source_plane).unwrap(); + + renderer.render(None); + assert_eq!(renderer.stats().instanced_draw_calls, 1); + assert_eq!(renderer.stats().active_instances, 2); + + assert!(renderer.remove_object(source_plane)); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 0, + "pool must be torn down when its source plane is destroyed" + ); + + assert!( + !renderer.set_object_position(inst_a, 1.0, 2.0, 3.0), + "instance handle should be invalid after source teardown" + ); + assert!( + !renderer.remove_object(inst_b), + "instance handle should be unknown after source teardown" + ); +} + +/// Regression for #679: when a current scene is set, plane-instance pools +/// should only draw if their source plane was added to the scene. +#[test] +fn test_instantiate_plane_respects_current_scene() { + let mut renderer = make_renderer(); + let source_plane = make_unit_plane(&mut renderer); + for _ in 0..10 { + renderer.instantiate_plane(source_plane).unwrap(); + } + + let s = renderer.create_scene("level"); + renderer.set_current_scene(s); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 0, + "source plane is not in the current scene -- pool must not draw" + ); + + assert!(renderer.add_object_to_scene(s, source_plane)); + renderer.render(None); + assert_eq!( + renderer.stats().instanced_draw_calls, + 1, + "source plane in scene -- pool draws once" + ); + assert_eq!(renderer.stats().active_instances, 10); +} From 68e3dd7751a06f4471de0f5d4aca2fcd21f59f33 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:35:35 -0600 Subject: [PATCH 4/7] fix(ffi): re-export goud_renderer3d_instantiate_plane from renderer3d/mod.rs (#679 CI) The Lua codegen imports `crate::ffi::renderer3d::goud_renderer3d_instantiate_plane`, which requires it to be re-exported from `ffi/renderer3d/mod.rs` like its sibling primitive exports. Added it to the `pub use primitives::{...}` list so `cargo check --workspace --all-targets` (the Preflight CI gate) passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 + goud_engine/src/ffi/renderer3d/mod.rs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..0cd565b4e --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"18b16b71-2239-4a83-82ec-33f2b8b24bd5","pid":63159,"procStart":"Sun Apr 26 03:11:04 2026","acquiredAt":1777177768796} \ No newline at end of file diff --git a/goud_engine/src/ffi/renderer3d/mod.rs b/goud_engine/src/ffi/renderer3d/mod.rs index caf010b48..15ad5b604 100644 --- a/goud_engine/src/ffi/renderer3d/mod.rs +++ b/goud_engine/src/ffi/renderer3d/mod.rs @@ -62,8 +62,9 @@ pub use postprocess::{ pub use primitives::{ goud_renderer3d_create_cube, goud_renderer3d_create_cylinder, goud_renderer3d_create_plane, goud_renderer3d_create_sphere, goud_renderer3d_destroy_object, - goud_renderer3d_set_object_position, goud_renderer3d_set_object_rotation, - goud_renderer3d_set_object_scale, goud_renderer3d_set_object_static, + goud_renderer3d_instantiate_plane, goud_renderer3d_set_object_position, + goud_renderer3d_set_object_rotation, goud_renderer3d_set_object_scale, + goud_renderer3d_set_object_static, }; pub use scene::{ goud_renderer3d_add_light_to_scene, goud_renderer3d_add_model_to_scene, From 7fded4706464d065d2cbf687424531fb46835d8a Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:35:54 -0600 Subject: [PATCH 5/7] chore: ignore .claude/scheduled_tasks.lock runtime artifact This file is a per-machine lockfile created by Claude Code's ScheduleWakeup runtime; it should not be tracked in git. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 0cd565b4e..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"18b16b71-2239-4a83-82ec-33f2b8b24bd5","pid":63159,"procStart":"Sun Apr 26 03:11:04 2026","acquiredAt":1777177768796} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fee4b0b68..64a62baf0 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ examples/go/*/sandbox # Coverage artifacts .coverage **/coverage/ +.claude/scheduled_tasks.lock From eac844e1e257f8d084069d98601b993609182839 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:43:41 -0600 Subject: [PATCH 6/7] perf(renderer3d): drop redundant clones + unsafe in plane-instance pool (#679 review) Addresses Claude PR review warnings on #685: - W1: source.vertices.clone() now happens only when creating a new pool (the else branch). Previously it ran on every instantiate_plane call, costing ~39_991 redundant Vec allocations during a 40k-tile setup over 9 source planes. - W2: replace the unsafe split-borrow + raw pointer slice in flush_dirty_plane_instance_pools' in-place update path with a safe field-disjoint borrow. NLL proves self.instanced_meshes and self.backend don't overlap; the SAFETY comment is no longer needed. - W3: FFI doc on goud_renderer3d_instantiate_plane now spells out that the source plane itself still costs one per-object draw on top of the instanced draw, and recommends frustum exclusion or first-tile reuse. Co-Authored-By: Claude Opus 4.7 (1M context) --- goud_engine/src/ffi/renderer3d/primitives.rs | 14 ++++++ .../renderer3d/core_plane_instances.rs | 45 ++++++++++--------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/goud_engine/src/ffi/renderer3d/primitives.rs b/goud_engine/src/ffi/renderer3d/primitives.rs index 87bdc27f8..9ff4f275f 100644 --- a/goud_engine/src/ffi/renderer3d/primitives.rs +++ b/goud_engine/src/ffi/renderer3d/primitives.rs @@ -119,6 +119,20 @@ pub extern "C" fn goud_renderer3d_create_plane( /// plane's material/texture is captured when the first instance is created. /// Use one source plane per material to draw multiple materials. /// +/// # Source plane visibility +/// +/// The source plane is a regular `Object3D`, so it is also drawn by the +/// per-object pass. Each pool therefore costs **one extra per-object draw on +/// top of the instanced draw** (e.g., 9 source planes + 9 pools = 18 draws, +/// not 9). The pool's draw call collapse is still the dominant win for +/// large terrains, but if the source plane is camera-visible callers should +/// either: +/// +/// * Position the source plane outside the camera frustum / view region so +/// frustum culling drops it (recommended for terrain templates), or +/// * Use it as the first visible tile (place it where one of the pool's +/// instances would have been) so the per-object draw is not "extra". +/// /// # Lifecycle /// /// Destroying the source plane via `destroy_object` cascades: the underlying diff --git a/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs index 71eaf0027..75964e026 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs @@ -49,15 +49,22 @@ impl Renderer3D { /// /// Returns `None` when the source plane does not exist. pub fn instantiate_plane(&mut self, source_plane_id: u32) -> Option { - let source = self.objects.get(&source_plane_id)?; - let texture_id = source.texture_id; - let source_vertices = source.vertices.clone(); - let vertex_count = source.vertex_count; + // Cheap existence check first; the expensive vertex clone happens only + // when we actually need to create a new pool. For a 40 000-instance + // terrain over 9 source planes this saves ~39 991 redundant clones. + if !self.objects.contains_key(&source_plane_id) { + return None; + } let pool_mesh_id = if let Some(&existing) = self.source_plane_to_pool.get(&source_plane_id) { existing } else { + let source = self.objects.get(&source_plane_id)?; + let texture_id = source.texture_id; + let source_vertices = source.vertices.clone(); + let vertex_count = source.vertex_count; + let mesh_buffer = match upload_buffer(self.backend.as_mut(), &source_vertices) { Ok(handle) => handle, Err(e) => { @@ -296,22 +303,20 @@ impl Renderer3D { pool.dirty = false; } } else { - // In-place update: split-borrow `self.instanced_meshes` and - // `self.backend` (disjoint fields) to avoid cloning the Vec. - let (buffer_handle, instances_ptr, instances_len) = - match self.instanced_meshes.get(&mesh_id) { - Some(m) => (m.instance_buffer, m.instances.as_ptr(), m.instances.len()), - None => continue, - }; - // SAFETY: instances_ptr/len are derived from - // self.instanced_meshes[mesh_id].instances above. No code in - // update_instance_buffer mutates self.instanced_meshes (it - // only touches self.backend, a disjoint field), so the slice - // is valid for the duration of this call. - let slice = unsafe { std::slice::from_raw_parts(instances_ptr, instances_len) }; - if let Err(e) = update_instance_buffer(self.backend.as_mut(), buffer_handle, slice) - { - log::error!("Failed to update plane-instance buffer: {e}"); + // In-place update: relies on the borrow checker proving that + // `self.instanced_meshes` and `self.backend` are disjoint + // fields. No clone, no unsafe. + if let Some(mesh) = self.instanced_meshes.get(&mesh_id) { + let buffer_handle = mesh.instance_buffer; + if let Err(e) = update_instance_buffer( + self.backend.as_mut(), + buffer_handle, + &mesh.instances, + ) { + log::error!("Failed to update plane-instance buffer: {e}"); + continue; + } + } else { continue; } if let Some(pool) = self.plane_instance_pools.get_mut(&mesh_id) { From 5c61adbacb1527509fea8cd54ce22639a51e1f99 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 22:59:50 -0600 Subject: [PATCH 7/7] fix(ts-web-sdk): add instantiatePlane stub to satisfy IGoudGame (#679 CI) The web TypeScript SDK doesn't have a real backend for renderer3d, so codegen/gen_ts_web.py emits hardcoded stubs returning 0/false for every 3D method. The IGoudGame interface requires instantiatePlane (added in this PR), so the web GoudGame class needs a matching stub or TS2420 fails the wasm build. Added the stub next to instantiateModel; regenerated all SDKs. Co-Authored-By: Claude Opus 4.7 (1M context) --- codegen/gen_ts_web.py | 1 + sdks/typescript/src/generated/web/index.g.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/codegen/gen_ts_web.py b/codegen/gen_ts_web.py index 2520ae481..8bff80985 100644 --- a/codegen/gen_ts_web.py +++ b/codegen/gen_ts_web.py @@ -1162,6 +1162,7 @@ def gen_web_wrapper(): lines.append(" loadModel(_path: string): number { return 0; }") lines.append(" destroyModel(_modelId: number): boolean { return false; }") lines.append(" instantiateModel(_sourceModelId: number): number { return 0; }") + lines.append(" instantiatePlane(_sourcePlaneId: number): number { return 0; }") lines.append(" setModelMaterial(_modelId: number, _meshIndex: number, _materialId: number): boolean { return false; }") lines.append(" getModelMeshCount(_modelId: number): number { return 0; }") lines.append(" getModelBoundingBox(_modelId: number): { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number } { return { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 }; }") diff --git a/sdks/typescript/src/generated/web/index.g.ts b/sdks/typescript/src/generated/web/index.g.ts index b3eaef3ed..ec3306a07 100644 --- a/sdks/typescript/src/generated/web/index.g.ts +++ b/sdks/typescript/src/generated/web/index.g.ts @@ -1050,6 +1050,7 @@ export class GoudGame implements IGoudGame { loadModel(_path: string): number { return 0; } destroyModel(_modelId: number): boolean { return false; } instantiateModel(_sourceModelId: number): number { return 0; } + instantiatePlane(_sourcePlaneId: number): number { return 0; } setModelMaterial(_modelId: number, _meshIndex: number, _materialId: number): boolean { return false; } getModelMeshCount(_modelId: number): number { return 0; } getModelBoundingBox(_modelId: number): { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number } { return { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 }; }