100 000 physical objects and ~170 000 grass blades in Unity 6 / URP — with only a few hundred GameObjects.
A small, self-contained tech sample: the player only interacts with a handful of objects at a time, so only those get real physics. Everything else is plain data processed by Burst and drawn with GPU instancing.
Open Assets/Showcase/Generated/Demo.unity and press Play. The player sphere drives itself on a figure-eight
and plows through the field; the overlay in the corner shows how many objects are in each tier right now.
The player sphere clears a path: objects near it get real Rigidbodies, the rest of the 100k stay dormant data.
Rendering has LODs. Simulation can have them too. Every object is in one of three tiers, chosen by distance to the player:
| Tier | Where | What it costs |
|---|---|---|
| Full | < 12 m | A pooled Rigidbody with a collider. Real PhysX, real collisions with the player. |
| Simplified | < 30 m | Burst job: gravity, ground, friction. No object-object collisions. |
| Dormant | everything else | Pure data. Not simulated at all, only drawn. |
Objects move between tiers smoothly:
- Hysteresis. Entering a tier uses the radius, leaving it uses radius + 3 m, so objects on a border do not flicker between tiers.
- Never freeze mid-air. Leaving Full always goes through Simplified, and an object goes Dormant only after it has settled.
- Graceful degradation. The Rigidbody pool has a fixed size (512). If it runs out, objects stay Simplified instead of allocating and spiking a frame.
FixedUpdate PhysX moves the few hundred pooled Rigidbodies
Update
1. ReadBack copy Rigidbody poses into the state array
2. WakeNearby grid query around the player: Dormant -> Simplified
3. UpdateTiers promote / demote / put to sleep the awake set only
4. SimplifiedStep Burst IJobParallelFor over the awake set
5. Grid rebuild Burst counting sort of 100k objects into 8 m cells (600 x 600 m world)
6. Render frustum-cull ~5600 cells, Burst-gather visible matrices,
Graphics.RenderMeshInstanced in batches of 1023
The expensive "look at every object" work is done exactly twice per frame, both times inside Burst: assigning cells and gathering matrices. Everything on the main thread is proportional to what is near the player, not to the world size.
GrassField bakes blade transforms into per-chunk instance buffers once. Each frame only chunks inside the camera frustum
are submitted, and all motion happens in the vertex shader.
WindCore.hlsl is a shared include for any foliage shader:
- rolling breeze from 3-octave value-noise FBM scrolled along the wind direction;
- gusts as a sharp travelling wave front;
- the bend keeps the blade length, so tips sag instead of stretching;
- depends only on
_Timeand world position — no textures, no CPU work per frame.
Wind parameters are shader globals set by one WindSettings component, so every foliage material shares the same breeze.
Assets/Showcase
├── Runtime Showcase.Runtime.asmdef
│ ├── Simulation plain C# and Burst jobs, no MonoBehaviours
│ │ ├── ObjectState, TierPolicy, CellLayout
│ │ ├── SpatialGrid counting-sort grid + radius query
│ │ ├── SimplifiedStepJob cheap physics tier
│ │ ├── IFullTierBackend contract for "real physics"
│ │ └── MassiveSimulation tier orchestration
│ ├── Rendering instanced drawing and cell culling
│ ├── Grass blade mesh + chunked grass field
│ └── View MonoBehaviours: composition root, Rigidbody pool, camera, overlay, benchmark
├── Shaders InstancedGrass.shader, WindCore.hlsl
├── Editor builds the demo scene, URP settings and the benchmark player from code
└── Tests
├── EditMode NUnit tests for tier policy and spatial grid
└── PlayMode smoke test: runs the demo scene, fails on any error
Logic and presentation are separated on purpose:
MassiveSimulationknows nothing about GameObjects. It talks to physics throughIFullTierBackend, so the Rigidbody pool can be replaced with DOTS Physics or a test stub without changes to the simulation.MassiveWorldis only a composition root: it creates the pieces and calls them from the Unity loop.- Pure logic (
TierPolicy,CellLayout,SpatialGrid) is covered by EditMode tests; a PlayMode smoke test runs the real scene and checks objects actually move through all three tiers.
- Unity 6000.3 with URP (packages are listed in
Packages/manifest.json). - Open
Assets/Showcase/Generated/Demo.unity, press Play. - To regenerate the scene and materials: menu Showcase → Build Demo Scene.
- Tests: Window → General → Test Runner, EditMode and PlayMode → Run All.
Build the player with menu Showcase → Build Benchmark Player
(batch mode: -executeMethod Showcase.Editor.BenchmarkBuild.BuildFromCommandLine), then run:
Builds/Benchmark/Showcase.exe -benchmark -screen-fullscreen 1 -screen-width 1920 -screen-height 1080
With -benchmark the player turns vsync off, warms up for 5 s, records one full autopilot loop (52 s),
writes benchmark.txt next to the executable (or to -benchmarkOut <path>) and quits.
Without the flag the demo runs as usual.
Results on a laptop with integrated graphics: AMD Ryzen 7 5700U, AMD Radeon Graphics (iGPU), 16 GB RAM, Windows 10, Direct3D 11, 1920×1080:
| Metric | Value |
|---|---|
| Objects in the world | 100 000 |
| Drawn per frame | ~15 500 objects + ~70 000 grass blades |
| Draw calls | ~660 on average, ~930 max |
| Rigidbodies | ~165 on average, ~195 max (0.2% of all objects) |
| Awake objects (Full + Simplified) | ~890 |
MassiveWorld.Update: tick, Burst jobs, culling, draw submission |
6.2 ms median, 21 ms p99 |
| Average FPS | 31–35 sustained, up to 78 on a cool machine |
Object counts and draw calls were the same in every run. FPS on this laptop depends mostly on temperature: the CPU and the iGPU share a ~15 W budget, so after a couple of minutes of load the clocks drop and a 1080p run falls from ~78 to ~35 FPS. The update time above was measured in that throttled state.
- Find the main-thread spikes: 1% low is only 17–29 FPS on the laptop above, while the median frame is much faster.
- Move cell culling and grass culling into a compute shader with
RenderMeshIndirect, so the CPU does not touch visible lists at all. - Time-slice the tier re-evaluation for very large awake sets.
- Swap the flat-ground simplified tier for a heightfield lookup to support terrain.
Author: Aleksandr Kartak — Unity developer & technical artist. Portfolio: https://matbcontrol.github.io/
