perf(renderer3d): InstantiatePlane collapses identical plane primitives into one instanced draw (#679) - #685
Conversation
…imitives 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) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
|
Claude finished @aram-devdocs's task in 4m 34s —— View job PR Review:
|
| Item | Result |
|---|---|
#[no_mangle] extern "C" on new export |
✅ present |
unsafe blocks with // SAFETY: |
✅ one block, comment present |
| FFI changes → C# + Python updated | ✅ all SDKs regenerated |
todo!() / unimplemented!() in new code |
✅ none |
unwrap() / expect() in library code |
✅ none (all errors surface via ? or log::error!) |
| Duplicated logic between Rust and SDKs | ✅ no duplication |
Phase 4 — Quality Assessment
Tests are thorough — five new unit tests in tests.rs cover the stated assertions:
- Collapse to 1 draw call
- Per-instance transform isolation
- Two source planes → two draw calls
- Scene filtering
- Cascade teardown
Documentation on goud_renderer3d_instantiate_plane is excellent — lifecycle, batching-flag interaction, and per-material guidance are clearly documented.
Naming is consistent with the existing instantiate_model mirror.
Phase 5 — Security Review
goud_renderer3d_instantiate_planetakes no pointer parameters — no null-check required. ✅- Memory ownership is documented: the pool's GPU buffers are freed on source-plane destruction. ✅
- The single
unsafeblock is inflush_dirty_plane_instance_pools. The SAFETY comment accurately identifies the disjoint-field invariant. ✅
BLOCKERS
None.
WARNINGS
W1 — Unnecessary vertex clone on every instantiate_plane call after the first
File: goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs:51-56
pub fn instantiate_plane(&mut self, source_plane_id: u32) -> Option<u32> {
let source = self.objects.get(&source_plane_id)?;
let texture_id = source.texture_id;
let source_vertices = source.vertices.clone(); // ← allocated unconditionally
let vertex_count = source.vertex_count;
let pool_mesh_id = if let Some(&existing) = self.source_plane_to_pool.get(&source_plane_id)
{
existing // source_vertices never used in this branch
} else {
// source_vertices used here only
let mesh_buffer = match upload_buffer(self.backend.as_mut(), &source_vertices) { …source.vertices.clone() is called on every invocation of instantiate_plane, but source_vertices is only consumed inside the else branch (pool creation — first call per source plane). For the throne_ge terrain with ~40,000 instances per 9 source planes, this produces ~39,991 unnecessary Vec<f32> allocations and immediate drops during scene setup.
Fix: move the clone inside the else branch. Since texture_id and vertex_count are Copy, they can be read before the branch without keeping the borrow alive:
pub fn instantiate_plane(&mut self, source_plane_id: u32) -> Option<u32> {
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(); // only when creating pool
let vertex_count = source.vertex_count;
// … pool creation unchanged …
};
// …
}W2 — Unsafe block may be avoidable with safe split-field borrows
File: goud_engine/src/libs/graphics/renderer3d/core_plane_instances.rs:300-316
The in-place update path captures a raw pointer + length to sidestep the borrow checker, then reconstructs a slice. This is sound (the SAFETY comment correctly identifies the disjoint backend / instanced_meshes fields). However, the same effect can be achieved safely because Rust NLL can prove field disjointness when fields are accessed via distinct named paths:
// Safe alternative (no unsafe needed):
if let Some(m) = self.instanced_meshes.get(&mesh_id) {
// m borrows self.instanced_meshes; self.backend is a separate field —
// NLL can prove disjointness here
if let Err(e) = update_instance_buffer(self.backend.as_mut(), buffer_handle, &m.instances) {
log::error!("…");
continue;
}
}If the safe version compiles, prefer it: it removes the need for the SAFETY comment and can't silently break if update_instance_buffer's internals ever touch instanced_meshes. Not a blocker — the current unsafe is correct.
W3 — Source plane drawn twice when no scene is active
The source plane object is stored in self.objects and is rendered by the per-object pass on every frame (as noted in the test comment at tests.rs:413). Without a scene filter, a caller using instantiate_plane for all tiles will get:
- 1 per-object draw for the source plane (from
self.objects) - 1 instanced draw for all instances (from the pool)
This is documented in the FFI doc-comment, but it's easy to miss. A usage note in the SDK wrappers or a more prominent callout in the FFI doc (e.g., "to suppress the source-plane's own draw call, add it to a scene but not to the current scene") would help callers avoid unexpected draw calls in production.
Positive Callouts
scratch_dirty_plane_pool_idspattern is excellent — reusing a persistent Vec for the dirty-pool scan is the right call and is already used consistently elsewhere in this renderer.- Grow path using
mem::takeinflush_dirty_plane_instance_poolsis clean: no extra allocation, the live Vec is restored with correct length after the padded upload. - Cascade teardown (
destroy_plane_pool_for_source) is thorough — all three maps (plane_instance_pools,instanced_meshes,plane_instance_index,source_plane_to_pool) are cleaned up correctly, and GPU buffers are freed. - Test
test_remove_source_plane_cascades_pool_teardownis a strong regression guard — it verifies both the pool teardown and handle invalidation paths. - 5 new tests, all pure (no GL context) — correct use of
NullBackend, consistent with the testing conventions.
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) <noreply@anthropic.com>
…/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ol (#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<f32> 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) <noreply@anthropic.com>
|
@claude-review — addressed all three warnings from your earlier review on commit 3eddac6: W1 — vertex clone moved into pool-creation branch (eac844e) W2 — unsafe block removed in favor of safe split-borrow (eac844e) 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) {
...
}
}
W3 — source-plane visibility now documented prominently (eac844e) Verification: Auto-merge (squash) is enabled — please re-review. |
|
Claude finished @aram-devdocs's task in 1m 28s —— View job PR Re-Review:
|
…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) <noreply@anthropic.com>
Overview
Type: perf
Summary:
CreatePlane()primitives bypass the engine's instancing path: a per-tile terrain (40k planes / 9 materials in throne_ge) produces 11k+ draw calls and 7 fps.InstantiatePlane(sourcePlaneId)mirrorsInstantiateModel(sourceModelId)for primitives so every instance that shares the same source plane renders through one instanced draw call. A 9-material terrain collapses to ~9 instanced draws regardless of tile count.Related Issues: Closes #679
Changes Made
Engine Core (
goud_engine/src/)core_plane_instances.rs: per-source-planePlaneInstancePoolover the existinginstanced_meshesmap.instantiate_plane(source_plane_id) -> Option<u32>allocates a slot in the pool'sInstanceTransformvec and returns a stable handle.flush_dirty_plane_instance_pools()re-uploads the GPU instance buffer once per frame before the instanced pass. Capacity grows withnext_power_of_two; in-place updates use a split-borrow with a SAFETY-commented unsafe slice (no per-frameVecclone). Persistentscratch_dirty_plane_pool_idsavoids per-frame allocation of the dirty list.set_object_position/set_object_rotation/set_object_scaledispatch to the pool first viatry_update_plane_instance_transform, then fall through to the denseobjectsmap.remove_objectdoes the same and additionally cascades pool teardown when called on a source plane (frees buffers, invalidates instance handles).add_object_to_sceneresolves a plane-instance handle to its source plane so existing scene APIs work unchanged.render_instanced_and_particlesfilters pool draws by source-plane scene membership.FFI Layer (
goud_engine/src/ffi/)goud_renderer3d_instantiate_plane(context_id, source_plane_id) -> u32inffi/renderer3d/primitives.rs. Doc comment covers lifecycle (cascading source-plane destruction) and the interaction withset_static_batching_enabled/set_instancing_enabled/set_min_instances_for_batching(issue ask 3).C# SDK (
sdks/csharp/)GoudGame.InstantiatePlane(uint sourcePlaneId)and matchingNativeMethodsentry.Python SDK (
sdks/python/)instantiate_plane(source_plane_id)plus_ffi.pyargtype/restype declarations.TypeScript SDK (
sdks/typescript/)Codegen Pipeline (
codegen/)instantiatePlanemethod + ffi mapping entry)Proc Macros (
goud_engine_macros/)#[goud_api]attribute changesTools (
tools/)WASM (
goud_engine/src/wasm/)Examples (
examples/)No changes.
Documentation
FFI doc + schema doc explain the interaction between
set_static_batching_enabled/set_instancing_enabled/set_min_instances_for_batchingand primitives + plane instances.Architectural Compliance
#[no_mangle] extern \"C\"and#[repr(C)]where neededcargo run -p lint-layersis clean// SAFETY:block inflush_dirty_plane_instance_poolscovers the disjoint-field split borrowTesting
cargo test --libpasses (4 787 tests, including 5 new plane-instance tests)cargo clippy --all-targets -- -D warningsis cleancargo fmt --all -- --checkpassesNew tests in
goud_engine/src/libs/graphics/renderer3d/tests.rs:test_instantiate_plane_collapses_many_tiles_to_single_draw_call— 10 000 tiles → 1 instanced draw.test_instantiate_plane_per_instance_transform_updates_pool_only— per-instance position/rotation/scale + remove keep the pool dense.test_instantiate_plane_two_source_planes_two_draw_calls— 1 000 + 2 000 instances over two source planes → 2 instanced draws.test_instantiate_plane_respects_current_scene— pool only draws when its source plane is in the current scene.test_remove_source_plane_cascades_pool_teardown— destroying the source plane frees the pool and invalidates instance handles.Pre-existing failure:
cargo test --test native_main_threadrequires a real display and fails locally onmaintoo. Not introduced by this PR; CI skips it via theCIenv var.SDK interface changes:
InstantiatePlane(sourcePlaneId)exposed in C#, Python, TypeScript, Go, Swift, Kotlin, Lua, C, C++. No existing method signatures changed.Version Bump
Bump type: minor
Justification: Adds a new public FFI export and SDK method without changing any existing signatures.
Security
unsafeblocks — or each one has a// SAFETY:comment and is necessaryPerformance
next_power_of_twocapacity to bound buffer reallocations.Deployment
Reviewer Notes
set_object_static,set_static_batching_enabled,set_instancing_enabled, andset_min_instances_for_batchingdo not gate plane-instance pools — pools always use the instancing path. The FFI doc comment ongoud_renderer3d_instantiate_planeand the schema doc string both spell this out (issue ask 3).flush_dirty_plane_instance_poolsrelies onself.instanced_meshesandself.backendbeing disjoint fields; the SAFETY comment documents the invariant.🤖 Generated with Claude Code