Skip to content

About

Unity 6 / URP tech sample: 100k physical objects with simulation LOD (Rigidbody / Burst / dormant data), GPU-instanced rendering and instanced grass with a shared HLSL wind.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

Massive Objects Showcase

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.

demo

The player sphere clears a path: objects near it get real Rigidbodies, the rest of the 100k stay dormant data.


The idea: level of detail for simulation

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.

How a frame works

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.

Grass

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 _Time and 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.

Code structure

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:

  • MassiveSimulation knows nothing about GameObjects. It talks to physics through IFullTierBackend, so the Rigidbody pool can be replaced with DOTS Physics or a test stub without changes to the simulation.
  • MassiveWorld is 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.

Running

  • 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.

Benchmark

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.

What I would do next

  • 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/

About

Unity 6 / URP tech sample: 100k physical objects with simulation LOD (Rigidbody / Burst / dormant data), GPU-instanced rendering and instanced grass with a shared HLSL wind.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages