[WIP] Hide 3D objects that can't be seen, and benchmark a large 3D city by 4ian · Pull Request #9216 · 4ian/GDevelop · GitHub
Skip to content

[WIP] Hide 3D objects that can't be seen, and benchmark a large 3D city - #9216

Draft
4ian wants to merge 4 commits into
masterfrom
claude/3d-city-benchmark
Draft

4ian wants to merge 4 commits into
masterfrom
claude/3d-city-benchmark

Conversation

@4ian

@4ian 4ian commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Work in progress. This makes large 3D scenes scale with what the camera sees, rather than with the number of objects. It's measured on the 3d-car-coin-hunt example with a city 10 times larger, and with a new CI benchmark.

Measurements: 3d-car-coin-hunt with a 10x larger city

The city of the example (buildings, roads, trees, parked cars, coins) is repeated in 5x2 tiles, giving 2800 instances. The game is exported with the CLI of the portable build and played headlessly with scripted inputs (driving around) through the gameplay test harness, rendering without a GPU.

Frame (median) Render Draw calls
master, city x1 (288 objects) 10.7ms 8.8ms 1462
master, city x10 (2808 objects) 42.1ms 31.8ms 1693
this PR, city x10 22.7ms 13.6ms 1687

Why it was slow: draw calls barely grow with the city, because Three.js already frustum-culls each mesh. But at each render, Three.js goes through every mesh of the scene (about 20,000 nodes at x10) to:

  • update its matrices: 24% of the JavaScript time;
  • find what to draw for the camera and for the shadow map: 28%, plus 11% of frustum tests.

Changes

  • 3D culling (layer-pixi-renderer.ts): before rendering a 3D layer, 3D models and cubes whose box is outside the camera frustum, and outside the frustums of the shadow cameras (directional and spot lights), are hidden (visible = false). Three.js then skips their meshes.
    • Three.js no longer updates their matrices at each render. Their matrices are updated when they are culled and whenever they move, including when an extension calls updateMatrixWorld on them, so raycasts (like the Raycaster3D extension) and extensions reading positions stay correct.
    • The test is a sphere containing the box, whatever the rotation, flipping and center. It's done per GDJS object (2800 tests) rather than per mesh (about 16,000).
    • Never culled: objects that can render outside their box, through isRenderedInsideItsBox(). That covers models with an animation, objects whose root has children added by extensions (like lights), custom objects and extension objects.
    • Disabled for the frame: when a shadow-casting point light exists, or when the last render had a shadow-casting light that the culling didn't check (for example a light nested in an object, detected by watching the shadow map renderer). Also disabled in the in-game editor and for VR.
  • Model3D: the animation mixer is no longer updated for models without an animation playing (it ran for all 2800 objects).
  • New CI benchmark "Large 3D city (2500 buildings)": 2500 Model3D buildings on a grid, 40 moving cars, a directional light with shadows, and a camera driving through the city. On CI: about 8-9.5ms → 3ms per frame (-63%). The other benchmarks are unchanged.
    • Its draw calls go from 469 to 451, and the benchmark comment flags this counter. The sphere around each box is tighter than the bounding spheres Three.js uses per mesh, so a few meshes that Three.js would send to the GPU without drawing any pixel are skipped. The rendering is the same (below).

Checks

  • Renders are pixel-identical between master and this PR: full-resolution screenshots of the x1 and x10 cities, at 3 moments of the drive each.
  • The state checksums of the harness are identical.
  • New tests (tests/Extensions/3d-culling.js):
    • far objects are hidden, and shown again once they can be seen;
    • objects hidden by the game stay hidden;
    • objects just off screen are kept when a light makes them cast shadows on the screen;
    • culled objects have correct matrices for raycasts: when never moved, after moving, and when an extension updates them;
    • nothing is hidden in the in-game editor.
    • The raycast test fails without the matrix updates, and the shadow test caught a bug on the first frame.
  • Full GDJS test suite: 1370 passed.

Known limits

  • Shaders displacing vertices (like the FoliageSwaying3D extension) can draw a few pixels outside the box. Three.js's own culling of meshes has the same limit.
  • Something rendering the scene from another camera (minimap, mirror) would miss objects culled for the main camera. No extension of the extensions repository does it.

Next steps measured, not in this PR

  • Instancing (prototype): draw one InstancedMesh per mesh of each model, filled every frame with the visible instances only, and hide the original meshes (kept for raycasts).
    • Draw calls go from 1457 to 202. Frame time: city x1 10.7-13.4ms → 7.5-8.4ms (render about halved); city x10 21.7ms → 18.6ms (render 12.4ms → 9.0ms). The gain should be bigger on real (mobile) GPUs.
    • It's invisible to the game only under conditions. Materials must be shared, which is true for "StandardWithoutMetalness" and "KeepOriginal", but "Basic" clones them per object. There must be no transparency (no per-instance sorting) and no per-object material change by extensions. Each mesh needs its own batch, keyed by object and material type. Flipped objects need separate batches. Batches must be maintained when objects are created, deleted or reloaded.
    • It's a larger change, best done separately.
  • Custom objects: they are never culled. A 3D custom object could be culled as a whole when its children are 3D objects included in its bounds, not rotated around X or Y (its box ignores these rotations), and themselves rendered inside their box.
  • Remaining per-object costs at x10 (master, debug runtime): Physics3D for about 2200 mostly static bodies is about 1.4ms per frame (Jolt's step is about 0.4ms of it), and the steps of behaviors doing nothing (each Physics3D behavior after the first, Base3DBehavior) about 1ms. Static bodies could skip the IsActive call after the step and the polling of 9 getters before it; behaviors without steps could be skipped by the scene loop.
  • Culling loop: it's still O(objects): about 0.2ms for 2800 objects here. A spatial grid of static objects would only matter for scenes with tens of thousands of objects.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FDUqntVYfmRadSTSqSDDPE

2500 3D model buildings on a grid, 40 moving cars, a light casting
shadows and a camera driving through the city: like a driving game, most
of the city can't be seen at any time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDUqntVYfmRadSTSqSDDPE
Three.js goes through all the meshes of a scene at each render: to update
their matrices, and to find the ones to render for the camera and for each
shadow map. With a large city, most of the frame was spent on objects that
can't be seen.

Before rendering a 3D layer, the 3D models and cubes whose box is out of
the camera frustum and of the shadow cameras are now hidden, and their
matrices are only updated when they move (for raycasts). Objects that can
render outside of their box (animated models, objects with children added
by extensions, custom objects...) are never hidden, and culling is
disabled when lights casting shadows can't be checked (point lights,
lights in objects), in the in-game editor and in VR.

The 3d-car-coin-hunt example with a city 10 times larger (2800 objects):
42ms -> 23ms per frame, with identical renders and state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDUqntVYfmRadSTSqSDDPE
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

⏱️ Game engine benchmarks

Comparing 9806f3a7 to its base 8301cdca (on a pull request, the version tested is the pull request merged into its base), run alternately 3 times on the same machine (AMD EPYC 7763 64-Core Processor, 4 cores, rendering without GPU).

Benchmark Base This version Change Range between rounds
platformer objects jumping in loop(1000x) 133.6ms 145.1ms +8.6% +1.4% to +15.0% 🐢 slower
hitBoxesCollisionTest of 10 objects with 10 objects(10000x) 90.3ms 88.0ms -2.5% -4.8% to -1.6% ≈
hitBoxesCollisionTest of 30 objects with 30 objects(1112x) 46.5ms 44.9ms -3.4% -7.2% to +3.3% ≈
hitBoxesCollisionTest of 100 objects with 100 objects(100x) 15.5ms 14.9ms -3.9% -32.9% to +4.3% ≈
hitBoxesCollisionTest of 500 objects with 500 objects(4x) 2.200ms 2.200ms +0.0% -2.2% to +15.8% ≈
hitBoxesCollisionTest of 1000 objects with 1000 objects(1x) 1.200ms 1.200ms -0.0% -7.7% to +9.1% ≈
hitBoxesCollisionTest of 1 object with 1000 objects(1000x) 53.5ms 53.0ms -0.9% -5.9% to +0.8% ≈
hitBoxesCollisionTest of 10 objects with 1000 objects(100x) 12.1ms 12.2ms +0.8% -4.1% to +13.6% ≈
hitBoxesCollisionTest of 1000 objects with 1 object(1000x) 57.0ms 57.0ms 0.0% -4.3% to +3.0% ≈
hitBoxesCollisionTest of 500 objects with themselves(4x) 1.500ms 1.600ms +6.7% 0.0% to +30.8% ≈
setAngle and setLength(100000x) 11.5ms 11.5ms 0.0% 0.0% to +0.9% ≈
convertCoords(100000x) 21.4ms 21.4ms 0.0% -0.0% to +0.0% ≈
convertInverseCoords(100000x) 21.5ms 21.4ms -0.5% -1.4% to +0.5% ≈
collisionTest between two overlapping rectangles(120000x) 48.7ms 47.3ms -3.0% -10.0% to +1.4% ≈
collisionTest between two non overlapping rectangles(120000x) 11.1ms 11.0ms -0.9% -6.8% to +1.8% ≈
getAABB of a non rotated, default center(180000x) 19.2ms 19.2ms 0.0% -8.8% to +1.1% ≈
getAABB of a rotated, default center(180000x) 19.2ms 20.7ms +7.8% -9.4% to +11.6% ≈
getAABB of a non rotated, non default center(180000x) 20.3ms 20.3ms +0.0% -1.0% to +2.0% ≈
getAABB of a rotated, non default center(180000x) 62.3ms 61.4ms -1.4% -4.6% to +0.2% ≈
getAABB of a non rotated sprite, with custom hitboxes, origin and center(60000x) 19.9ms 19.8ms -0.5% -1.5% to +2.0% ≈
getAABB of a rotated sprite, with custom hitboxes, origin and center(60000x) 68.0ms 68.0ms 0.0% -1.3% to +1.5% ≈
get(400000x) 11.6ms 11.6ms +0.0% -0.0% to +0.0% ≈
Scene: Static sprites (10000) 5.250ms 5.200ms -1.0% -7.3% to 0.0% ≈
Scene: Moving and rotating sprites (5000) 6.800ms 6.500ms -4.4% -11.1% to +2.3% ≈
Scene: Collisions and separation (500 vs 500) 16.9ms 15.9ms -6.2% -12.1% to 0.0% ≈
Scene: Physics 2D (500 bodies) 3.400ms 3.400ms 0.0% -2.9% to +3.0% ≈
Scene: Physics 3D (500 bodies) 13.4ms 13.9ms +3.7% +2.6% to +5.6% ≈
Scene: Large 3D city (2500 buildings) 7.850ms 2.900ms -63.1% -64.6% to -48.7% 🚀 faster
Scene: Platformer characters (300) 1.100ms 1.100ms 0.0% -9.1% to +9.1% ≈
Scene: Text updates (1000) 57.0ms 55.8ms -2.2% -4.6% to +0.2% ≈
Scene: Objects creation and deletion (100 per frame) 2.000ms 2.100ms +5.0% -7.3% to +10.5% ≈
Scene: Heavy events (variables, loops, strings) 1.500ms 1.500ms 0.0% -6.3% to 0.0% ≈

Times are the median duration of a batch of iterations (micro benchmarks) or of a frame (scene benchmarks). A change is reported only when larger than 5% and measured by every pair of rounds.

⚠️ Counters changed or not deterministic (draw calls, objects count or final state of a scene):

Benchmark Counter Base This version
Scene: Large 3D city (2500 buildings) drawCallsPerFrame 469.3 451.3

An object culled before it ever moved kept the matrices of its last render
(or none), so raycasts missed it. Extensions calling `updateMatrixWorld` on
a culled object that moved also read stale matrices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDUqntVYfmRadSTSqSDDPE

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants