Occlusion Culling

a GPU Hi-Z cull skips what a near wall hides.

📄 Docs only

Tags: 3d culling performance

Frustum culling drops what is outside the camera’s view; occlusion culling also drops what is inside the view but hidden behind something closer. Turn it on through the WorldEnvironment:

WorldEnvironment(occlusion_culling_enabled=True)

The renderer then builds a depth pyramid (Hi-Z) from the previous frame and tests every instance’s bounds against it on the GPU. An instance entirely behind nearer depth is never drawn. Nothing in the scene changes: every MeshInstance3D is tested, with no occluder or occludee to mark.

Here a large wall stands close to the camera with a dense grid of blue cubes behind it, and a few orange cubes stand off to the sides where the wall covers nothing. The camera sways back and forth in front of the wall, so cubes at its edges come in and out of view. The status label reads App.last_telemetry: while the cull runs, occlusion_total is the instances that passed the frustum test and occlusion_drawn those that also passed the occlusion test, so the difference is what the wall hid. With the cull off the renderer skips it entirely and reports no counts.

Controls: C / Occlusion button - Toggle occlusion culling A, D / drag - Orbit the camera Scroll / pinch - Zoom

Source

  1# /// simvx
  2# tags = ["3d", "culling", "performance"]
  3# screenshot_frame = 60
  4# web = { disabled = true, reason = "occlusion culling is desktop-only until the WebGPU Hi-Z port" }
  5# ///
  6"""Occlusion Culling: a GPU Hi-Z cull skips what a near wall hides.
  7
  8Frustum culling drops what is outside the camera's view; occlusion culling also
  9drops what is inside the view but hidden behind something closer. Turn it on
 10through the ``WorldEnvironment``:
 11
 12    WorldEnvironment(occlusion_culling_enabled=True)
 13
 14The renderer then builds a depth pyramid (Hi-Z) from the previous frame and tests
 15every instance's bounds against it on the GPU. An instance entirely behind nearer
 16depth is never drawn. Nothing in the scene changes: every ``MeshInstance3D`` is
 17tested, with no occluder or occludee to mark.
 18
 19Here a large wall stands close to the camera with a dense grid of blue cubes
 20behind it, and a few orange cubes stand off to the sides where the wall covers
 21nothing. The camera sways back and forth in front of the wall, so cubes at its
 22edges come in and out of view. The status label reads ``App.last_telemetry``: while the
 23cull runs, ``occlusion_total`` is the instances that passed the frustum test and
 24``occlusion_drawn`` those that also passed the occlusion test, so the difference
 25is what the wall hid. With the cull off the renderer skips it entirely and
 26reports no counts.
 27
 28Controls:
 29    C / Occlusion button  - Toggle occlusion culling
 30    A, D / drag           - Orbit the camera
 31    Scroll / pinch        - Zoom
 32"""
 33
 34import math
 35
 36from simvx.core import (
 37    Anchor,
 38    Axis1D,
 39    Button,
 40    CanvasLayer,
 41    DirectionalLight3D,
 42    Input,
 43    Key,
 44    Label,
 45    Material,
 46    Mesh,
 47    MeshInstance3D,
 48    Node,
 49    OrbitCamera3D,
 50    WorldEnvironment,
 51    on_input,
 52)
 53from simvx.graphics import App
 54
 55FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 14, 8, 6
 56SPACING = 0.85
 57SWAY = 0.35  # radians either side of straight on
 58SWAY_RATE = 0.25  # radians of phase per second
 59
 60
 61class OcclusionDemo(Node):
 62    input_context = {
 63        "toggle_occlusion": [Key.C],
 64        "orbit": [Axis1D(low=Key.A, high=Key.D)],
 65    }
 66
 67    def on_ready(self):
 68        self.env = self.add_child(WorldEnvironment(occlusion_culling_enabled=True))
 69        self.camera = self.add_child(
 70            OrbitCamera3D(
 71                pivot=(0, 1.5, -6),
 72                distance=15,
 73                yaw=0.0,
 74                pitch=math.radians(-2),
 75                min_pitch=math.radians(-30),
 76                max_pitch=math.radians(-1),
 77            )
 78        )
 79        self.add_child(DirectionalLight3D(colour=(1.0, 0.97, 0.92), intensity=1.7, direction=(-0.4, -1.0, -0.5)))
 80
 81        floor = Material(colour=(0.18, 0.19, 0.22), roughness=0.9)
 82        self.add_child(MeshInstance3D(mesh=Mesh.plane(40), material=floor, position=(0, -0.55, -6)))
 83
 84        # The occluder: close to the camera, wide enough to hide the grid, clear of the flanks.
 85        wall = Material(colour=(0.55, 0.42, 0.30), roughness=0.8)
 86        self.add_child(MeshInstance3D(mesh=Mesh.box((7, 5, 0.4)), material=wall, position=(0, 2, 2)))
 87
 88        cube = Mesh.box(0.32)
 89        blue = Material(colour=(0.30, 0.55, 0.85), roughness=0.5, metallic=0.1)
 90        x0 = -(FIELD_COLS - 1) * SPACING / 2
 91        for i in range(FIELD_COLS):
 92            for j in range(FIELD_ROWS):
 93                for k in range(FIELD_LAYERS):
 94                    position = (x0 + i * SPACING, 0.2 + j * SPACING, -2 - k * SPACING)
 95                    self.add_child(MeshInstance3D(mesh=cube, material=blue, position=position))
 96
 97        # Off to the sides, where the wall covers nothing: these draw whether the cull is on or off.
 98        flank = Mesh.box(0.5)
 99        orange = Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05))
100        for side in (-1, 1):
101            for k in range(3):
102                position = (side * (5 + k * 0.8), 1, -k * 1.2)
103                self.add_child(MeshInstance3D(mesh=flank, material=orange, position=position))
104
105        hud = self.add_child(CanvasLayer())
106        self.status = hud.add_child(Label("", anchor=Anchor.TOP_LEFT, inset=12, font_size=18))
107        hud.add_child(Button("Occlusion", action="toggle_occlusion", anchor=Anchor.BOTTOM_RIGHT, inset=12))
108
109        # Example help overlay: not part of the technique shown.
110        help_text = "C: toggle culling   A, D / drag: orbit   Scroll: zoom"
111        hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=15))
112
113    @on_input("toggle_occlusion")
114    def toggle_occlusion(self, _event):
115        self.env.occlusion_culling_enabled = not self.env.occlusion_culling_enabled
116
117    def on_update(self, dt):
118        # The rate of change of SWAY * sin(t * SWAY_RATE): a sway that drag and A, D add to.
119        sway = SWAY * SWAY_RATE * math.cos(self.tree.now * SWAY_RATE)
120        self.camera.orbit((sway + Input.value("orbit")) * dt, 0.0)
121
122        state = "ON" if self.env.occlusion_culling_enabled else "OFF"
123        self.status.text = f"Occlusion culling: {state}"
124        telemetry = self.app.last_telemetry
125        if "occlusion_total" in telemetry:
126            drawn, total = telemetry["occlusion_drawn"], telemetry["occlusion_total"]
127            self.status.text += f"\ndrawn:  {drawn}\ntotal:  {total}\nculled: {total - drawn}"
128
129
130if __name__ == "__main__":
131    App(title="Occlusion Culling", width=1280, height=720).run(OcclusionDemo())