Per-view occlusion¶
a SubViewport runs its own Hi-Z cull for its own camera.
📄 Docs onlyTags: 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())