Per-view occlusion

a SubViewport runs its own Hi-Z cull for its own camera.

📄 Docs only

Tags: 3d subviewport culling performance

SubViewport.use_occlusion = True gives an offscreen view its own two-phase Hi-Z occlusion island (scratch depth prepass + pyramid sized to the view’s target), so the view’s camera culls what its OWN wall hides, independent of the main camera. Here the SubViewport contains a near wall with a dense cube field packed behind it; its feed shows the wall plus the un-occluded flanks, while the hidden field is never drawn into the texture. The main scene shows the live feed on a monitor slab plus a picture-in-picture sprite, and the HUD reports the per-view cull counts from App.last_telemetry (view_occlusion_drawn / view_occlusion_total).

The main scene itself renders WITHOUT occlusion culling (no WorldEnvironment toggle): only the SubViewport opts in, which is exactly the per-view point.

Controls: ESC : Quit

Usage: uv run python examples/features/3d/sub_viewport_occlusion.py

Source

  1"""Per-view occlusion: a SubViewport runs its own Hi-Z cull for its own camera.
  2
  3# /// simvx
  4# tags = ["3d", "subviewport", "culling", "performance"]
  5# screenshot_frame = 60
  6# web = { disabled = true, reason = "per-view occlusion culling is desktop-only until the WebGPU Hi-Z port" }
  7# ///
  8
  9``SubViewport.use_occlusion = True`` gives an offscreen view its own two-phase
 10Hi-Z occlusion island (scratch depth prepass + pyramid sized to the view's
 11target), so the view's camera culls what its OWN wall hides, independent of
 12the main camera. Here the SubViewport contains a near wall with a dense cube
 13field packed behind it; its feed shows the wall plus the un-occluded flanks,
 14while the hidden field is never drawn into the texture. The main scene shows
 15the live feed on a monitor slab plus a picture-in-picture sprite, and the HUD
 16reports the per-view cull counts from ``App.last_telemetry``
 17(``view_occlusion_drawn`` / ``view_occlusion_total``).
 18
 19The main scene itself renders WITHOUT occlusion culling (no WorldEnvironment
 20toggle): only the SubViewport opts in, which is exactly the per-view point.
 21
 22Controls:
 23    ESC : Quit
 24
 25Usage:
 26    uv run python examples/features/3d/sub_viewport_occlusion.py
 27"""
 28
 29from simvx.core import (
 30    AnchorPreset,
 31    Camera3D,
 32    DirectionalLight3D,
 33    Input,
 34    Key,
 35    Label,
 36    Material,
 37    Mesh,
 38    MeshInstance3D,
 39    Node,
 40    Panel,
 41    Sprite2D,
 42    SubViewport,
 43    Vec3,
 44)
 45from simvx.graphics import App
 46
 47WIDTH, HEIGHT = 1280, 720
 48
 49# Dense hidden field behind the SubViewport's wall: big enough that the culled
 50# count is unmistakable in the HUD.
 51FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 10, 6, 4
 52
 53
 54class SubViewportOcclusionScene(Node):
 55    """A SubViewport whose own camera occlusion-culls a field behind its wall."""
 56
 57    input_actions = {"quit": [Key.ESCAPE]}
 58
 59    def on_ready(self):
 60        # Main-scene camera looks at the monitor showing the feed.
 61        self.add_child(Camera3D(position=Vec3(0.0, 2.0, 6.0), look_at=Vec3(0.0, 1.0, 0.0)))
 62        sun = self.add_child(DirectionalLight3D(intensity=2.0))
 63        sun.direction = Vec3(-0.4, -1.0, -0.6)
 64
 65        # The offscreen view: its own camera, wall, and hidden field. The
 66        # per-view Hi-Z cull runs for THIS camera against THIS wall.
 67        self.sub = self.add_child(SubViewport(size=(512, 512), use_occlusion=True))
 68        self.sub.add_child(Camera3D(position=Vec3(0.0, 1.5, 9.0), look_at=Vec3(0.0, 1.5, -8.0)))
 69        sub_sun = self.sub.add_child(DirectionalLight3D(intensity=2.5))
 70        sub_sun.direction = Vec3(-0.5, -0.8, -0.5)
 71
 72        cube = Mesh.cube()
 73
 74        # The view's occluder: a large wall close to its camera. Sized so its
 75        # occlusion shadow covers the field but NOT the flank cubes (the feed
 76        # is square, so the frustum is narrower than the main window's).
 77        wall = self.sub.add_child(
 78            MeshInstance3D(mesh=cube, material=Material(colour=(0.55, 0.42, 0.30), roughness=0.8))
 79        )
 80        wall.position = Vec3(0.0, 2.0, 2.0)
 81        wall.scale = Vec3(5.0, 5.0, 0.4)
 82
 83        # Dense field directly behind the wall: mostly occluded in the feed.
 84        spacing = 0.85
 85        x0 = -(FIELD_COLS - 1) * spacing * 0.5
 86        self._field_count = 0
 87        for cx in range(FIELD_COLS):
 88            for cy in range(FIELD_ROWS):
 89                for cz in range(FIELD_LAYERS):
 90                    c = self.sub.add_child(
 91                        MeshInstance3D(
 92                            mesh=cube,
 93                            material=Material(colour=(0.30, 0.55, 0.85), roughness=0.5),
 94                        )
 95                    )
 96                    c.position = Vec3(x0 + cx * spacing, 0.2 + cy * spacing, -2.0 - cz * spacing)
 97                    c.scale = Vec3(0.32, 0.32, 0.32)
 98                    self._field_count += 1
 99
100        # Visible flank cubes (NOT behind the wall): these must survive the cull
101        # and keep spinning in the feed, proving the view renders correctly.
102        self.flanks = []
103        for sign in (-1, 1):
104            for k in range(3):
105                v = self.sub.add_child(
106                    MeshInstance3D(
107                        mesh=cube,
108                        material=Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05)),
109                    )
110                )
111                v.position = Vec3(sign * (3.8 + k * 0.4), 1.5, 0.0 - k * 0.5)
112                v.scale = Vec3(0.6, 0.6, 0.6)
113                self.flanks.append(v)
114
115        # The in-world monitor showing the live culled feed.
116        self.monitor_mat = Material(colour=(1, 1, 1, 1), roughness=1.0)
117        self.monitor = self.add_child(
118            MeshInstance3D(
119                mesh=Mesh.cube(1.0),
120                material=self.monitor_mat,
121                position=Vec3(0.0, 1.2, 0.0),
122                scale=Vec3(3.4, 3.4, 0.08),
123            )
124        )
125
126        # Picture-in-picture: the same feed as a 2D sprite (design §5.4).
127        self.pip = self.add_child(Sprite2D(texture=self.sub, position=(160, 560), width=240, height=240))
128
129        self._build_hud()
130
131    def _build_hud(self):
132        # Top-left status panel: anchored (NOT absolute) so it tracks the viewport.
133        panel = self.add_child(Panel(name="HUD"))
134        panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
135        panel.margin_left = 12.0
136        panel.margin_top = 12.0
137        panel.size = (460, 72)
138
139        self.hud = Label("view cull: warming up", name="HUDLabel")
140        self.hud.set_anchor_preset(AnchorPreset.FULL_RECT)
141        self.hud.margin_left = 12.0
142        self.hud.margin_top = 10.0
143        self.hud.font_size = 17.0
144        self.hud.vertical_alignment = "top"
145        panel.add_child(self.hud)
146
147        note = Label(
148            f"SubViewport.use_occlusion = True ({self._field_count} cubes behind the wall)  |  ESC quit",
149            name="HUDNote",
150        )
151        note.set_anchor_preset(AnchorPreset.FULL_RECT)
152        note.margin_left = 12.0
153        note.margin_top = 40.0
154        note.font_size = 13.0
155        note.vertical_alignment = "top"
156        panel.add_child(note)
157
158    def on_update(self, dt):
159        if Input.is_action_pressed("quit"):
160            self.app.quit()
161            return
162        for i, v in enumerate(self.flanks):
163            v.rotate_y((0.8 + 0.1 * i) * dt)
164        if self.sub.texture >= 0:
165            self.monitor_mat.albedo_tex_index = self.sub.texture
166        tel = self.app.last_telemetry
167        total = tel.get("view_occlusion_total")
168        drawn = tel.get("view_occlusion_drawn")
169        if total:
170            self.hud.text = f"view cull: drawn {drawn} / {total}  (culled {total - drawn})"
171
172
173if __name__ == "__main__":
174    app = App(title="SubViewport Occlusion", width=WIDTH, height=HEIGHT)
175    app.run(SubViewportOcclusionScene())