Occlusion Culling
Occlusion culling skips drawing meshes that are inside the camera frustum but completely hidden behind other geometry. Polyphase uses a baked potentially-visible-set (PVS), the same model as Unity's occlusion culling: the editor precomputes visibility once, and the runtime does a single cell lookup plus one bit test per draw. Nothing runs on the GPU, so the feature works identically on every target.
| Platform | Runtime culling | Bake |
|---|---|---|
| Windows / Linux (Vulkan) | Yes | Yes (editor) |
| Wii / GameCube (GX) | Yes | – |
| 3DS (C3D) | Yes | – |
| Addon platforms (PSP, PS2, Web, …) | Yes | – |
Frustum culling is separate and always on: it drops anything outside the view cone. Occlusion
culling handles the rest, things that are in view but behind something. The Stats overlay's
Culling mode and the free-fly camera HUD show both counts.
Quick start
- Select the walls, floors, and large props that should block the view and tick Primitive → Occlusion → Occluder Static.
- Select the meshes that should be hidden when they are behind an occluder and tick
Occludee Static.
World → Mark Selected Occluder + Occludeesets both on the selection. - Add one or more Occlusion Area nodes (
Spawn Node → 3D → Environment) covering the volume the camera can reach. Without them the bake volume is the union of all occludee bounds, which is usually far bigger than needed (a tall perimeter wall alone can push it past the cell limit). - In the scene asset's properties enable Occlusion Culling, then set Occlusion Cell Size, Bake Quality, Dynamic Objects and the Console Budget KB (see below).
World → Bake Occlusion Culling. A cancellable progress modal shows the cell count and progress. Re-bakes after moving props are incremental (see below).- Save the scene. The baked data is stored inside the scene asset and is cooked for every platform.
What to flag
Occluders want to be big and few; occludees want to be small and many. An occludee is only culled when all of it is hidden from the camera's cell, so an object that spans the level is almost never culled.
| Object | Occluder | Occludee |
|---|---|---|
| Floor / terrain | yes | no |
| Perimeter walls, mountains, distant scenery | yes | no, unless split into segments |
| Interior walls, buildings, big props | yes | yes |
| Pillars, crates, small props | optional | yes |
| Foliage, glass, particles, anything not opaque | no | yes if the mesh is opaque, otherwise leave off |
| Doors and anything that moves | no | yes (culled by the dynamic table) |
| Skybox | never | never |
The bake logs a warning listing every occludee that spans more than 8 cells. Split those into segments, or untick Occludee Static and keep them as occluders.
For interiors use a cell size smaller than a corridor width (2 units is typical), one Occlusion Area per floor covering only walkable space, and make ceilings occluders too so cells cannot see over walls into other rooms. Open doorways need no setup; they are simply holes in the geometry.
Seeing the result
View → Occlusion Preview(withView → Boundson) draws in the editor viewport: the bake volume in cyan, the game camera's current cell in green, every occludee's box green (visible from that cell) or red (culled), and, for selected occludees, a line from the cell coloured by the result. The editor viewport itself is never culled; the Game Preview and Second Screen Preview panels are, each with its own camera (both 3DS screens are handled independently).- The Stats overlay
Cullingmode shows frustum- and occlusion-culled draw counts. Counts are summed over every view rendered that frame. - The scripts folder of the occlusion demo project has a
FreeFlyCam.luawith an on-screen HUD and F1/F2 toggles for the two culling passes.
How it works
- The bake gathers world-space triangles from every visible Occluder Static
StaticMesh3D,InstancedMesh3D(every instance),Terrain3DandVoxel3Dwith an opaque material, and builds a BVH over them. - The bake volume is split into uniform cells. For every cell a handful of sample points (skipping points inside solid geometry) cast rays to sample points on every occludee: box corners and face centres, a stride of mesh vertices, and for objects larger than two cells a grid of points across each face. The first unblocked ray marks the occludee visible from that cell.
- Identical per-cell bitsets are shared, and cell indices are stored as 16-bit values whenever fewer than 65,535 unique sets exist.
- Dynamic objects. When Occlusion Dynamic Objects is on, the bake also records which coarse cells (blocks of 4–16 cells per axis, chosen so there are about 512 of them) are visible from each cell. At runtime an occludee that has no baked slot (spawned, animated, or moved since the bake) is tested by its current bounds against that table. It is coarser than the per-object table but it costs nothing per frame beyond a few bit tests.
- Instanced meshes. Consoles unroll
InstancedMesh3Dinto one draw per unroll cell on load. The bake records one occludee per unroll cell in addition to the whole node, so on consoles the cells cull individually (a forest behind a hill drops the hidden trees, not all or nothing). - At runtime
Renderer::OcclusionCullruns before frustum culling, so the frustum math is only spent on draws that survive the bit test. Culled draws receive the same "not in view" treatment as frustum-culled ones: skeletal meshes and particles follow their Always Update settings. Shadow casters are never occlusion-culled. - Occludees and occluders are matched to the baked data by their world-space bounds when the scene is instantiated, with a tolerance of 10% of a cell or 1% of the object's size. An occludee that moves afterwards switches to the dynamic table. If an occluder moves or disappears the data is flagged stale, culling is disabled, and the editor shows a toast with a Re-bake button.
- Incremental bakes. If the grid and every occluder are unchanged since the last bake, the bits of occludees that already existed are copied and only new or moved occludees are ray-cast. The dynamic table is reused as well. Moving an occluder, changing the cell size, or changing the bake volume forces a full bake.
| Bake Quality | Samples per cell | Targets per occludee |
|---|---|---|
| Low | 4 | 9 (+ face grid up to 6×6 per face on large objects) |
| Medium | 9 | 15 + 8 mesh vertices (+ face grid) |
| High | 17 | 15 + 24 mesh vertices (+ face grid up to 8×8) |
Memory
Everything below is loaded into RAM with the scene on every platform. The bake log and the load-time
log (Occlusion: resolved …) print the actual sizes.
| Table | Size |
|---|---|
| Occludee / occluder entries | 24 bytes each |
| Static cell table | cells × 2 bytes (× 4 if there are ≥ 65,535 unique sets) |
| Static set pool | unique sets × ⌈occludees / 32⌉ × 4 bytes |
| Dynamic cell table | cells × 2 bytes |
| Dynamic set pool | unique coarse sets × ⌈coarse cells / 32⌉ × 4 bytes |
Worked example, a 230 × 60 × 220 unit level at 4-unit cells: 58 × 15 × 55 = 47,850 cells. With 30 occludees and 400 unique sets the static table is about 96 KB + 1.6 KB; the dynamic table adds about 96 KB + (unique coarse sets × 32 bytes). Halving the cell size multiplies the cell count by 8.
Controls:
- Occlusion Cell Size is the main lever. Cells ÷ cell size³.
- Occlusion Area nodes bound the grid; the bake refuses more than 262,144 cells and warns above 65,536.
- Occlusion Dynamic Objects off skips the second table entirely (roughly halves the data).
- Occlusion Console Budget KB (default 512, 0 = unlimited): when a scene is cooked for a console target (GameCube, Wii, 3DS, and addon consoles that report themselves as consoles) and its data is larger than this, the cook writes an empty table and logs a warning. Desktop builds and the editor copy always keep the full data. Use it to opt a scene out on the smallest targets without changing the scene itself.
Limitations
- Moving an occluder invalidates the bake; only occludees may move freely.
- Cell size is a trade-off: smaller cells give tighter culling but more cells to bake and store.
- Translucent, additive, and masked materials never occlude.
- Dynamic occludees use the coarse table, so they are culled less aggressively than static ones.
- Ray sampling is an approximation. A mesh seen only through a very small gap may pop; raise the bake quality or reduce the cell size.
Gameplay queries
World:IsSeenByCamera(node [, camera]) and World:GetSeenByCamera([tags] [, camera]) answer
"can the camera see this right now" using the same data as the renderer: a frustum test on the
node's bounds, then the baked static bit for baked occludees, or the dynamic table for anything
else (plain Node3Ds use a small box around their position). They are evaluated on demand, so
there is no one-frame lag, and they work in the editor, in play mode, and on every platform. Hidden
nodes and the camera itself are never "seen". GetSeenByCamera walks the whole scene, so call it
when something happens rather than every frame on a console.
-- Enemy AI: only react when the player camera can actually see us.
if world:IsSeenByCamera(self) then self:Alert() end
-- Everything visible, or only nodes with one of the given tags.
local all = world:GetSeenByCamera()
local threats = world:GetSeenByCamera({ "Enemy", "Turret" })
local pickups = world:GetSeenByCamera("Pickup")
for _, node in ipairs(threats) do ... end
API
C++:
Renderer::Get()->EnableOcclusionCulling(bool);
Renderer::Get()->IsOcclusionCullingEnabled();
Renderer::Get()->GetNumOcclusionCulled();
Renderer::Get()->GetNumFrustumCulled();
prim->EnableOccluder(bool); prim->IsOccluder();
prim->EnableOccludee(bool); prim->IsOccludee();
world->IsOcclusionCullingEnabled();
world->IsOcclusionDataStale();
world->IsSeenByCamera(node, camera = nullptr);
world->GetSeenByCamera(outNodes, &tags /* or nullptr */, camera = nullptr);
Lua:
Renderer.EnableOcclusionCulling(false)
local n = Renderer.GetNumOcclusionCulled()
mesh:EnableOccludee(true)
if mesh:IsOccluder() then ... end
world:IsSeenByCamera(node) -- bool
world:GetSeenByCamera() -- { Node3D, ... }
world:GetSeenByCamera("Enemy") -- filtered by one tag
world:GetSeenByCamera({ "A", "B" }) -- any of several tags
REST (editor controller): POST /api/occlusion/bake, POST /api/occlusion/clear,
GET /api/occlusion/status, PUT /api/occlusion/preview with {"enabled": true}.