Occlusion Culling¶
a GPU Hi-Z cull skips what a near wall hides.
📄 Docs onlyTags: 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())