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

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"""
 34
 35import math
 36
 37from simvx.core import (
 38    AnchorPreset,
 39    Camera3D,
 40    DirectionalLight3D,
 41    Input,
 42    Key,
 43    Label,
 44    Material,
 45    Mesh,
 46    MeshInstance3D,
 47    Node,
 48    Panel,
 49    Property,
 50    Vec3,
 51    WorldEnvironment,
 52)
 53from simvx.graphics import App
 54
 55WIDTH, HEIGHT = 1280, 720
 56
 57# Dense hidden field: a grid of cubes packed BEHIND the wall. Big enough that the
 58# culled count is unmistakable in the overlay.
 59FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 14, 8, 6
 60
 61
 62class OcclusionDemo(Node):
 63    """A near wall occluding a dense cube field, with a live cull-telemetry HUD."""
 64
 65    input_actions = {
 66        "toggle_occlusion": [Key.C],
 67        "orbit_left": [Key.A],
 68        "orbit_right": [Key.D],
 69        "quit": [Key.ESCAPE],
 70    }
 71
 72    orbit_speed = Property(0.25, range=(0.0, 2.0))
 73    orbit_extent = Property(0.6, range=(0.0, 2.0))
 74
 75    def __init__(self, **kwargs):
 76        super().__init__(**kwargs)
 77        self._time = 0.0
 78        self._occlusion_on = True
 79        self._orbit_offset = 0.0
 80
 81    def on_ready(self):
 82        super().on_ready()
 83
 84        self.camera = self.add_child(Camera3D(position=Vec3(0.0, 1.5, 9.0), look_at=Vec3(0.0, 1.5, -8.0)))
 85
 86        light = DirectionalLight3D()
 87        light.direction = Vec3(-0.4, -1.0, -0.5)
 88        light.colour = (1.0, 0.97, 0.92)
 89        light.intensity = 1.7
 90        self.add_child(light)
 91
 92        cube = Mesh.cube()
 93
 94        # Ground plane (a flat scaled cube) so the scene reads as a space.
 95        floor = MeshInstance3D(mesh=cube, material=Material(colour=(0.18, 0.19, 0.22), roughness=0.9))
 96        floor.position = Vec3(0.0, -0.6, -6.0)
 97        floor.scale = Vec3(40.0, 0.1, 40.0)
 98        self.add_child(floor)
 99
100        # The occluder: a large wall close to the camera, centred so it hides the
101        # field behind it but leaves the flanks open.
102        wall = MeshInstance3D(mesh=cube, material=Material(colour=(0.55, 0.42, 0.30), roughness=0.8))
103        wall.position = Vec3(0.0, 2.0, 2.0)
104        wall.scale = Vec3(7.0, 5.0, 0.4)
105        self.add_child(wall)
106
107        # Dense hidden field directly behind the wall: mostly occluded.
108        spacing = 0.85
109        x0 = -(FIELD_COLS - 1) * spacing * 0.5
110        y0 = 0.2
111        z0 = -2.0
112        for cx in range(FIELD_COLS):
113            for cy in range(FIELD_ROWS):
114                for cz in range(FIELD_LAYERS):
115                    c = MeshInstance3D(
116                        mesh=cube,
117                        material=Material(colour=(0.30, 0.55, 0.85), roughness=0.5, metallic=0.1),
118                    )
119                    c.position = Vec3(x0 + cx * spacing, y0 + cy * spacing, z0 - cz * spacing)
120                    c.scale = Vec3(0.32, 0.32, 0.32)
121                    self.add_child(c)
122
123        # A few clearly visible cubes off to the sides (NOT behind the wall): these
124        # must keep drawing whether culling is on or off.
125        for sign in (-1, 1):
126            for k in range(3):
127                v = MeshInstance3D(
128                    mesh=cube,
129                    material=Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05)),
130                )
131                v.position = Vec3(sign * (5.0 + k * 0.8), 1.0, 0.0 - k * 1.2)
132                v.scale = Vec3(0.5, 0.5, 0.5)
133                self.add_child(v)
134
135        self._env = self.add_child(WorldEnvironment(name="Env"))
136        self._env.occlusion_culling_enabled = self._occlusion_on
137
138        self._build_hud()
139
140    def _build_hud(self) -> None:
141        # Top-left status panel: anchored (NOT absolute) so it tracks the viewport.
142        panel = Panel(name="HUD")
143        panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
144        panel.margin_left = 12.0
145        panel.margin_top = 12.0
146        panel.size = (340, 132)
147        self.add_child(panel)
148
149        self._hud = Label("", name="HUDLabel")
150        self._hud.set_anchor_preset(AnchorPreset.FULL_RECT)
151        self._hud.margin_left = 12.0
152        self._hud.margin_top = 10.0
153        self._hud.font_size = 18.0
154        self._hud.vertical_alignment = "top"
155        panel.add_child(self._hud)
156
157        # Bottom controls strip.
158        hint = Label("C: Toggle culling    A/D: Orbit    ESC: Quit", name="Hint")
159        hint.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
160        hint.margin_left = 12.0
161        hint.margin_bottom = 34.0
162        hint.font_size = 15.0
163        self.add_child(hint)
164
165    def on_update(self, dt: float):
166        if Input.is_action_just_pressed("quit"):
167            self.app.quit()
168            return
169
170        self._time += dt
171
172        if Input.is_action_pressed("orbit_left"):
173            self._orbit_offset -= dt
174        if Input.is_action_pressed("orbit_right"):
175            self._orbit_offset += dt
176
177        angle = math.sin(self._time * self.orbit_speed) * self.orbit_extent + self._orbit_offset
178        x = math.sin(angle) * 9.0
179        z = math.cos(angle) * 9.0
180        self.camera.position = Vec3(x, 1.6, z)
181        self.camera.look_at(Vec3(0.0, 1.5, -6.0))
182
183        if Input.is_action_just_pressed("toggle_occlusion"):
184            self._occlusion_on = not self._occlusion_on
185        self._env.occlusion_culling_enabled = self._occlusion_on
186
187        self._update_hud()
188
189    def _update_hud(self) -> None:
190        t = self.app.last_telemetry if self.app else {}
191        state = "ON" if self._occlusion_on else "OFF"
192        if "occlusion_total" in t:
193            total = int(t["occlusion_total"])
194            drawn = int(t["occlusion_drawn"])
195            culled = max(0, total - drawn)
196            self._hud.text = f"Occlusion: {state}\n" f"drawn:  {drawn}\n" f"total:  {total}\n" f"culled: {culled}"
197        else:
198            self._hud.text = f"Occlusion: {state}\n(no cull telemetry yet)"
199
200
201if __name__ == "__main__":
202    scene = OcclusionDemo(name="OcclusionDemo")
203    app = App(title="Occlusion Culling Demo", width=WIDTH, height=HEIGHT)
204    app.run(scene)