Occlusion Culling
Skip rendering of objects hidden behind walls and terrain using occlusion queries.
Introduction
Professional games do not render what the player cannot see. Occlusion culling skips objects behind walls, hills, and buildings. This guide explains how AAA engines use GPU occlusion queries, precomputed visibility, and portal systems — then builds occlusion culling in Appley.
Advanced Guide 18 of 25
Screenshot
How Professional Games Do It
U
n
F
r
U
s
R
e
P
r
F
o
U
s
F
o
I
m
B
u
B
a
G
P
FluxScript Implementation
Here is how to build this system in Appley using FluxScript (Appley Scripting):
// GPU occlusion culling system
const OCCLUSION_BATCH_SIZE = 100
occlusionBuffer = null
visibilityResults = {}
on start {
// Create occlusion depth buffer (quarter resolution for speed)
occlusionBuffer = renderTarget.create(
screen.width / 4, screen.height / 4,
{ format: "depth", name: "occlusionDepth" }
)
}
on update(dt) {
camera = scene.find("MainCamera")
objects = scene.getVisibleObjects() // After frustum culling
// Phase 1: Render occluder depth (simplified geometry)
renderTarget.activate(occlusionBuffer)
renderTarget.clearDepth()
for obj in objects {
if obj.isOccluder {
// Render simplified mesh (bounding box or low LOD)
obj.renderOccluder(occlusionBuffer)
}
}
renderTarget.deactivate()
// Phase 2: Test object visibility (batched queries)
queries = []
for obj in objects {
if obj.isOccludee && !obj.isOccluder {
// Create occlusion query for bounding box
query = gpu.beginOcclusionQuery()
gpu.renderBoundingBox(obj.bounds, occlusionBuffer)
gpu.endOcclusionQuery(query)
queries.push({ id: obj.id, query: query })
}
}
// Read results from previous frame (latency hiding)
prevResults = gpu.readOcclusionQueries(prevFrameQueries)
for result in prevResults {
visibilityResults[result.id] = result.visible
}
// Phase 3: Render visible objects
for obj in objects {
if shouldRender(obj) {
obj.render()
}
}
prevFrameQueries = queries
}
func shouldRender(obj) {
// Always render occluders and large objects
if obj.isOccluder return true
if obj.bounds.radius > 10 return true // Large objects rarely fully occluded
// Check visibility result from previous frame
if visibilityResults[obj.id] == false {
return false // Occluded — skip rendering
}
return true
}
// Precomputed visibility for static scenes
precomputedVisibility = {}
func bakeVisibility() {
// Divide world into cells (8m grid)
cells = {}
for obj in scene.getStaticObjects() {
cx = math.floor(obj.position.x / 8)
cz = math.floor(obj.position.z / 8)
key = cx + "," + cz
if cells[key] == null cells[key] = []
cells[key].push(obj)
}
// For each cell, raycast to all objects to determine visibility
for cellKey in cells {
parts = string.split(cellKey, ",")
cx = math.parse(parts[0])
cz = math.parse(parts[1])
samplePoint = vec3(cx * 8 + 4, 1.5, cz * 8 + 4)
visibleObjects = []
for obj in scene.getStaticObjects() {
// Cast 5 rays to object bounds
rays = [
obj.position,
obj.position + vec3(obj.bounds.radius, 0, 0),
obj.position + vec3(-obj.bounds.radius, 0, 0),
obj.position + vec3(0, 0, obj.bounds.radius),
obj.position + vec3(0, 0, -obj.bounds.radius)
]
for target in rays {
dir = math.normalize(target - samplePoint)
dist = math.distance(samplePoint, target)
hit = physics.raycast(samplePoint, dir, dist)
if hit == null || hit.distance >= dist {
visibleObjects.push(obj.id)
break // At least one ray reaches the object
}
}
}
precomputedVisibility[cellKey] = visibleObjects
}
print("Baked visibility: " + cells.length + " cells")
}
func getVisibleObjectsFromCell(playerPos) {
cx = math.floor(playerPos.x / 8)
cz = math.floor(playerPos.z / 8)
key = cx + "," + cz
return precomputedVisibility[key] || []
}Node Graph Equivalent
The same system implemented as a Node Graph in Appley Node Scripting:
[On Update]
Phase 1: Render Occluder Depth
--> [Activate: quarter-resolution depth buffer]
--> [For Each: occluder object]
--> [Render: simplified mesh to depth buffer]
Phase 2: Batched Occlusion Queries
--> [For Each: occludee object]
--> [Begin: GPU occlusion query]
--> [Render: bounding box against depth]
--> [End: query]
--> [Read: previous frame results (latency hiding)]
Phase 3: Render Visible Objects
--> [For Each: object]
--> [Branch: shouldRender?]
+-- Occluder/large --> [Always render]
+-- Occluded (from prev results) --> [Skip]
+-- Visible --> [Render full mesh]
[Function: bakeVisibility]
--> [Divide: world into 8m cells]
--> [For Each Cell]
--> [For Each Static Object]
--> [Cast: 5 rays to object bounds]
--> [Branch: any ray reaches?]
+-- True --> [Mark: visible from cell]
--> [Store: cell -> visible object IDs]Professional Optimization Tips
How AAA studios optimize these systems — applicable to your Appley project:
- Use quarter-resolution depth buffers for occlusion queries. Full-resolution queries are expensive. Quarter resolution is 16x fewer pixels and still accurate enough for bounding box tests.
- Read occlusion results one frame late. GPU queries have latency — results are not available in the same frame. Read previous frame results and use them for current frame rendering. This hides latency completely.
- Use precomputed visibility for static scenes. Bake a cell-to-object visibility table at build time. At runtime, just look up the current cell — no GPU queries needed. This is the fastest occlusion method.
- Always render large occluders (buildings, hills). Large objects are rarely fully occluded and are themselves occluders. Skip the query for them — always render. This saves query bandwidth.
Common Mistakes
Same-frame occlusion query reads
GPU occlusion queries return results 1-2 frames later. Reading them in the same frame stalls the GPU. Read previous frame results and use them this frame.
Testing every small object
Small objects (pebbles, grass) are not worth occlusion testing. The query costs more than rendering the object. Only test objects above a polygon threshold.
