AppleyAppley Docs
Docs/Optimization

Occlusion Culling

Skip rendering of objects hidden behind walls and terrain using occlusion queries.

32 min read Advanced Updated 2026-07-01 v2.0 ~135 min to complete

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

This is part of the Advanced Documentation collection. Category: Optimization. Estimated completion: 135 minutes. Difficulty: Advanced/Professional.

Screenshot

Occlusion Culling
Occlusion Culling

How Professional Games Do It

1

U

n

2

F

r

3

U

s

4

R

e

5

P

r

6

F

o

7

U

s

8

F

o

9

I

m

10

B

u

11

B

a

12

G

P

FluxScript Implementation

Here is how to build this system in Appley using FluxScript (Appley Scripting):

Occlusion Culling — FluxScript
// 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:

Occlusion Culling — Node Graph
[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.