Occlusion culling

a Hi-Z GPU cull drops objects hidden behind a near wall.

📄 Docs only

Tags: 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)