Occlusion culling on the CPU for an instanced city

· 1 min read · three.js, performance, culling, webgpu, game-dev

A dense neon city street at night, where most of the buildings in the frustum hide behind the first row of facades

From a street in Threejs-Punk, most of the city inside the frustum is behind the first row of facades. A ray test says about 90% of the instances. Drawing them is wasted work.

Why the built-in queries do not fit #

three's occlusionTest wraps one draw. In this city, one draw is an (asset, LOD) mesh spread over the whole map, a query needs a draw to get a result, and the answer arrives frames later. That is the wrong tool for an instanced city.

A software depth buffer #

Instead, an occlusion culler rasterizes the nearby collider boxes (one per building hull slab, roughly 500 to 700 within 260 m) into a 256x128 software depth buffer. When the instance buffers are rebuilt, every instance whose world box lies entirely behind that buffer is left out. Meshes left empty stop drawing.

The city re-renders the buffer and rebuilds every layer together every 2 m, 2 degrees or 300 ms, about 1 ms of JavaScript.

It errs toward drawing #

A wrong cull is worse than a wasted draw, so the culler is conservative:

  • occluders shrink by 1 m,
  • tested boxes grow by 1 m,
  • the buffer covers a 1.3x wider field of view,
  • nothing within 18 m is culled.

The planar mirror reuses the view's lists.

What it bought #

Measured on the project's harness: 56 to 76% of the city instances culled, from 2.26 to 1.72 M triangles and about 25 fewer draws per frame. Screenshots with and without culling, still and while driving or turning at 80 to 105 km/h, differ only by the animation noise.

It can be A/B tested from the console, and it has knobs for the on/off state and the buffer resolution, like every optional cost in the project.

This is one of several wins in the performance work of the game. The city itself is in the generated city post, and the game is on its project page.

Related projects