Occlusion culling¶
a Hi-Z GPU cull drops objects hidden behind a near wall.
📄 Docs onlyTags: 3d culling performance
A large wall stands close to the camera. Directly behind it sits a dense field of
cubes that are almost entirely hidden, plus a few cubes off to the sides that the
wall does NOT cover. With occlusion culling ON (WorldEnvironment .occlusion_culling_enabled = True) the renderer rejects the instances the wall
occludes against the previous frame’s depth pyramid, so the hidden field is never
drawn. Toggle it OFF and every frustum-visible cube is submitted again.
The on-screen overlay reads App.last_telemetry and reports, live:
drawn – instances that survived the cull and were drawn this frame
total – frustum-visible instances submitted to the cull (pre-cull)
culled – total - drawn (the objects the wall hid)
The camera slowly orbits a small arc in front of the wall (and can be nudged with A/D) so the occlusion is observable as the hidden field pops in/out at the wall’s edges.
Controls: C : Toggle occlusion culling on/off A/D : Orbit the camera left / right ESC : Quit
Usage: uv run python examples/features/3d/occlusion_culling.py uv run python examples/features/3d/occlusion_culling.py –test
Source¶
1#!/usr/bin/env python3
2"""Occlusion culling: a Hi-Z GPU cull drops objects hidden behind a near wall.
3
4# /// simvx
5# tags = ["3d", "culling", "performance"]
6# screenshot_frame = 60
7# web = { disabled = true, reason = "occlusion culling is desktop-only until the WebGPU Hi-Z port" }
8# ///
9
10A large wall stands close to the camera. Directly behind it sits a dense field of
11cubes that are almost entirely hidden, plus a few cubes off to the sides that the
12wall does NOT cover. With occlusion culling ON (``WorldEnvironment
13.occlusion_culling_enabled = True``) the renderer rejects the instances the wall
14occludes against the previous frame's depth pyramid, so the hidden field is never
15drawn. Toggle it OFF and every frustum-visible cube is submitted again.
16
17The on-screen overlay reads ``App.last_telemetry`` and reports, live:
18 drawn -- instances that survived the cull and were drawn this frame
19 total -- frustum-visible instances submitted to the cull (pre-cull)
20 culled -- total - drawn (the objects the wall hid)
21
22The camera slowly orbits a small arc in front of the wall (and can be nudged with
23A/D) so the occlusion is observable as the hidden field pops in/out at the wall's
24edges.
25
26Controls:
27 C : Toggle occlusion culling on/off
28 A/D : Orbit the camera left / right
29 ESC : Quit
30
31Usage:
32 uv run python examples/features/3d/occlusion_culling.py
33 uv run python examples/features/3d/occlusion_culling.py --test
34"""
35
36import math
37import sys
38
39from simvx.core import (
40 AnchorPreset,
41 Camera3D,
42 DirectionalLight3D,
43 Input,
44 InputMap,
45 Key,
46 Label,
47 Material,
48 Mesh,
49 MeshInstance3D,
50 Node,
51 Panel,
52 Property,
53 Vec3,
54 WorldEnvironment,
55)
56from simvx.graphics import App
57
58WIDTH, HEIGHT = 1280, 720
59
60# Dense hidden field: a grid of cubes packed BEHIND the wall. Big enough that the
61# culled count is unmistakable in the overlay.
62FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 14, 8, 6
63
64
65class OcclusionDemo(Node):
66 """A near wall occluding a dense cube field, with a live cull-telemetry HUD."""
67
68 orbit_speed = Property(0.25, range=(0.0, 2.0))
69 orbit_extent = Property(0.6, range=(0.0, 2.0))
70
71 def __init__(self, **kwargs):
72 super().__init__(**kwargs)
73 self._time = 0.0
74 self._occlusion_on = True
75 self._orbit_offset = 0.0
76
77 def on_ready(self):
78 super().on_ready()
79
80 # Actions live here (not in module main) so web export keeps them.
81 InputMap.add_action("toggle_occlusion", [Key.C])
82 InputMap.add_action("orbit_left", [Key.A])
83 InputMap.add_action("orbit_right", [Key.D])
84 InputMap.add_action("quit", [Key.ESCAPE])
85
86 self.camera = self.add_child(Camera3D(position=Vec3(0.0, 1.5, 9.0), look_at=Vec3(0.0, 1.5, -8.0)))
87
88 light = DirectionalLight3D()
89 light.direction = Vec3(-0.4, -1.0, -0.5)
90 light.colour = (1.0, 0.97, 0.92)
91 light.intensity = 1.7
92 self.add_child(light)
93
94 cube = Mesh.cube()
95
96 # Ground plane (a flat scaled cube) so the scene reads as a space.
97 floor = MeshInstance3D(mesh=cube, material=Material(colour=(0.18, 0.19, 0.22), roughness=0.9))
98 floor.position = Vec3(0.0, -0.6, -6.0)
99 floor.scale = Vec3(40.0, 0.1, 40.0)
100 self.add_child(floor)
101
102 # The occluder: a large wall close to the camera, centred so it hides the
103 # field behind it but leaves the flanks open.
104 wall = MeshInstance3D(mesh=cube, material=Material(colour=(0.55, 0.42, 0.30), roughness=0.8))
105 wall.position = Vec3(0.0, 2.0, 2.0)
106 wall.scale = Vec3(7.0, 5.0, 0.4)
107 self.add_child(wall)
108
109 # Dense hidden field directly behind the wall: mostly occluded.
110 spacing = 0.85
111 x0 = -(FIELD_COLS - 1) * spacing * 0.5
112 y0 = 0.2
113 z0 = -2.0
114 self._hidden_count = 0
115 for cx in range(FIELD_COLS):
116 for cy in range(FIELD_ROWS):
117 for cz in range(FIELD_LAYERS):
118 c = MeshInstance3D(
119 mesh=cube,
120 material=Material(colour=(0.30, 0.55, 0.85), roughness=0.5, metallic=0.1),
121 )
122 c.position = Vec3(x0 + cx * spacing, y0 + cy * spacing, z0 - cz * spacing)
123 c.scale = Vec3(0.32, 0.32, 0.32)
124 self.add_child(c)
125 self._hidden_count += 1
126
127 # A few clearly visible cubes off to the sides (NOT behind the wall): these
128 # must keep drawing whether culling is on or off.
129 for sign in (-1, 1):
130 for k in range(3):
131 v = MeshInstance3D(
132 mesh=cube,
133 material=Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05)),
134 )
135 v.position = Vec3(sign * (5.0 + k * 0.8), 1.0, 0.0 - k * 1.2)
136 v.scale = Vec3(0.5, 0.5, 0.5)
137 self.add_child(v)
138
139 self._env = self.add_child(WorldEnvironment(name="Env"))
140 self._env.occlusion_culling_enabled = self._occlusion_on
141
142 self._build_hud()
143
144 def _build_hud(self) -> None:
145 # Top-left status panel: anchored (NOT absolute) so it tracks the viewport.
146 panel = Panel(name="HUD")
147 panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
148 panel.margin_left = 12.0
149 panel.margin_top = 12.0
150 panel.size = (340, 132)
151 self.add_child(panel)
152
153 self._hud = Label("", name="HUDLabel")
154 self._hud.set_anchor_preset(AnchorPreset.FULL_RECT)
155 self._hud.margin_left = 12.0
156 self._hud.margin_top = 10.0
157 self._hud.font_size = 18.0
158 self._hud.vertical_alignment = "top"
159 panel.add_child(self._hud)
160
161 # Bottom controls strip.
162 hint = Label("C: Toggle culling A/D: Orbit ESC: Quit", name="Hint")
163 hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
164 hint.margin_left = 12.0
165 hint.margin_bottom = 34.0
166 hint.font_size = 15.0
167 self.add_child(hint)
168
169 def on_process(self, dt: float):
170 if Input.is_action_just_pressed("quit"):
171 self.app.quit()
172 return
173
174 self._time += dt
175
176 if Input.is_action_pressed("orbit_left"):
177 self._orbit_offset -= dt
178 if Input.is_action_pressed("orbit_right"):
179 self._orbit_offset += dt
180
181 angle = math.sin(self._time * self.orbit_speed) * self.orbit_extent + self._orbit_offset
182 x = math.sin(angle) * 9.0
183 z = math.cos(angle) * 9.0
184 self.camera.position = Vec3(x, 1.6, z)
185 self.camera.look_at(Vec3(0.0, 1.5, -6.0))
186
187 if Input.is_action_just_pressed("toggle_occlusion"):
188 self._occlusion_on = not self._occlusion_on
189 self._env.occlusion_culling_enabled = self._occlusion_on
190
191 self._update_hud()
192
193 def _update_hud(self) -> None:
194 t = self.app.last_telemetry if self.app else {}
195 state = "ON" if self._occlusion_on else "OFF"
196 if "occlusion_total" in t:
197 total = int(t["occlusion_total"])
198 drawn = int(t["occlusion_drawn"])
199 culled = max(0, total - drawn)
200 self._hud.text = (
201 f"Occlusion: {state}\n"
202 f"drawn: {drawn}\n"
203 f"total: {total}\n"
204 f"culled: {culled}"
205 )
206 else:
207 self._hud.text = f"Occlusion: {state}\n(no cull telemetry yet)"
208
209
210def _run_test() -> None:
211 """Headless: with occlusion ON the wall must hide part of the field (drawn <
212 total); with occlusion OFF every frustum-visible instance is drawn (drawn ==
213 total). Verifies the cull actually fired on this scene and that the public
214 ``App.last_telemetry`` exposes the counts."""
215
216 def render_run(occlusion_on: bool) -> dict:
217 scene = OcclusionDemo(name="OcclusionDemo")
218 scene._occlusion_on = occlusion_on
219 app = App(title="Occlusion", width=WIDTH, height=HEIGHT, visible=False)
220 # A handful of frames: the single-phase cull needs a prior depth frame to
221 # build the Hi-Z pyramid before it can reject anything.
222 app.run_headless(scene, frames=8)
223 return dict(app.last_telemetry)
224
225 on = render_run(True)
226 print(f"occlusion ON telemetry: drawn={on.get('occlusion_drawn')} total={on.get('occlusion_total')}")
227 assert "occlusion_total" in on, "occlusion telemetry missing while culling ON"
228 assert "occlusion_drawn" in on, "occlusion telemetry missing while culling ON"
229 drawn_on, total_on = int(on["occlusion_drawn"]), int(on["occlusion_total"])
230 assert total_on > 0, f"no frustum-visible instances submitted to the cull (total={total_on})"
231 assert drawn_on < total_on, (
232 f"occlusion culling drew everything ({drawn_on}/{total_on}); the wall hid nothing"
233 )
234 print(f"occlusion ON: culled {total_on - drawn_on} of {total_on} instances -- PASSED")
235
236 off = render_run(False)
237 print(f"occlusion OFF telemetry keys: {sorted(k for k in off if k.startswith('occlusion'))}")
238 # With the gate off, the renderer never runs the cull, so the occlusion_* keys
239 # are absent (zero overhead). The scene still draws everything.
240 assert "occlusion_total" not in off and "occlusion_drawn" not in off, (
241 f"occlusion telemetry leaked while culling OFF: {off}"
242 )
243 print("occlusion OFF: no cull telemetry (cull disabled) -- PASSED")
244
245
246if __name__ == "__main__":
247 if "--test" in sys.argv:
248 _run_test()
249 else:
250 scene = OcclusionDemo(name="OcclusionDemo")
251 app = App(title="Occlusion Culling Demo", width=WIDTH, height=HEIGHT)
252 app.run(scene)