diff --git a/.gitignore b/.gitignore index fee4b0b6..64a62baf 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ examples/go/*/sandbox # Coverage artifacts .coverage **/coverage/ +.claude/scheduled_tasks.lock diff --git a/codegen/ffi_manifest.json b/codegen/ffi_manifest.json index 377630b3..907fbd7b 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 2a8cbcff..ddc667b8 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/gen_ts_web.py b/codegen/gen_ts_web.py index 2520ae48..8bff8098 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/codegen/generated/goud_engine.h b/codegen/generated/goud_engine.h index da9339ba..0112da75 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 cad3b2b0..21581fd0 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 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", + "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/mod.rs b/goud_engine/src/ffi/renderer3d/mod.rs index caf010b4..15ad5b60 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, diff --git a/goud_engine/src/ffi/renderer3d/primitives.rs b/goud_engine/src/ffi/renderer3d/primitives.rs index fe16bd6c..9ff4f275 100644 --- a/goud_engine/src/ffi/renderer3d/primitives.rs +++ b/goud_engine/src/ffi/renderer3d/primitives.rs @@ -106,6 +106,92 @@ 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. +/// +/// # 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 +/// 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` +/// +/// # 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 bb674f7c..46f9ce59 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 610be387..a92fea0d 100644 --- a/goud_engine/src/libs/graphics/renderer3d/core/mod.rs +++ b/goud_engine/src/libs/graphics/renderer3d/core/mod.rs @@ -165,6 +165,18 @@ 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, + /// 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 @@ -364,6 +376,10 @@ 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(), + 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 a0eb24e1..a0c1b3f0 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,11 +66,18 @@ 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; } 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 new file mode 100644 index 00000000..75964e02 --- /dev/null +++ b/goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs @@ -0,0 +1,330 @@ +//! 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; +use std::mem; + +/// 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). + /// + /// 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 { + // 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) => { + 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 + }; + + // 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 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) + } + + /// 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. + /// 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) { + // 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 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, + }; + let capacity = self + .plane_instance_pools + .get(&mesh_id) + .map(|p| p.buffer_capacity_slots) + .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 = match self.instanced_meshes.get_mut(&mesh_id) { + Some(m) => mem::take(&mut m.instances), + None => continue, + }; + padded.resize(new_capacity, InstanceTransform::default()); + + 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}"); + 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 { + // 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) { + pool.dirty = false; + } + } + } + + self.scratch_dirty_plane_pool_ids = dirty_ids; + } +} diff --git a/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs b/goud_engine/src/libs/graphics/renderer3d/core_scenes.rs index b7cfc8f2..2e0e59b1 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 b99cb3ed..5a768193 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; @@ -48,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/render/mod.rs b/goud_engine/src/libs/graphics/renderer3d/render/mod.rs index a0631529..33a20dc9 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 8458c7de..72fd9984 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 bdf99219..421920c2 100644 --- a/goud_engine/src/libs/graphics/renderer3d/tests.rs +++ b/goud_engine/src/libs/graphics/renderer3d/tests.rs @@ -380,7 +380,10 @@ 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 +// 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. #[test] fn test_static_primitive_renders_via_batch() { 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 00000000..a80b9205 --- /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); +} 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 3fc30050..13d44a14 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 b759da5b..ce2ee648 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 cbd5b690..0e777e74 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 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); + } + /// 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 0c960d03..aa5f5e73 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 da9339ba..0112da75 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 f2fcbfda..65cf39d3 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 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 +} + // 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 da9339ba..0112da75 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 70e1afd8..e0806e12 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 b759da5b..ce2ee648 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 f82bf15e..2aca0da4 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 50994a50..554d31d6 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 de5cbdfb..bfa49f62 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 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): """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 da9339ba..0112da75 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 da9339ba..0112da75 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 5740568d..c1a71363 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 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) + } + /// 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 37158301..8faa8693 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 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); + } + /** 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 924bbd95..b24aa5b6 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 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; /** Creates a 3D cylinder */ diff --git a/sdks/typescript/src/generated/web/index.g.ts b/sdks/typescript/src/generated/web/index.g.ts index b3eaef3e..ec3306a0 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 }; }