SubViewport Occlusion¶
an offscreen view that culls what its own camera cannot see.
📄 Docs onlyTags: 3d subviewport culling performance
Occlusion culling skips drawing objects hidden behind others. Set
SubViewport(use_occlusion=True) and the view runs its own Hi-Z occlusion cull
for its own camera, sized to its own target and independent of the main camera.
Only the view opts in here: the main scene renders without occlusion culling.
Inside the SubViewport a brown wall stands close to the camera with a dense field of blue cubes packed behind it. The wall hides the field, so the cull skips it and it is never drawn into the texture. Orange cubes on either side of the wall stay in view, spinning, to show the visible part of the feed still renders.
The feed is shown twice: on an in-world monitor through
Material(albedo_map=sub_viewport) and as a picture-in-picture
Sprite2D(texture=sub_viewport) in the bottom-left corner. Both bindings are
assigned once and follow the live texture. The label at the top left reads the
view’s cull counts each frame from app.last_telemetry: view_occlusion_total
is how many instances the view submitted and view_occlusion_drawn how many
survived the cull.
Source¶
1# /// simvx
2# tags = ["3d", "subviewport", "culling", "performance"]
3# screenshot_frame = 60
4# web = { disabled = true, reason = "per-view occlusion culling is desktop-only until the WebGPU Hi-Z port" }
5# ///
6"""SubViewport Occlusion: an offscreen view that culls what its own camera cannot see.
7
8Occlusion culling skips drawing objects hidden behind others. Set
9`SubViewport(use_occlusion=True)` and the view runs its own Hi-Z occlusion cull
10for its own camera, sized to its own target and independent of the main camera.
11Only the view opts in here: the main scene renders without occlusion culling.
12
13Inside the SubViewport a brown wall stands close to the camera with a dense
14field of blue cubes packed behind it. The wall hides the field, so the cull
15skips it and it is never drawn into the texture. Orange cubes on either side of
16the wall stay in view, spinning, to show the visible part of the feed still
17renders.
18
19The feed is shown twice: on an in-world monitor through
20`Material(albedo_map=sub_viewport)` and as a picture-in-picture
21`Sprite2D(texture=sub_viewport)` in the bottom-left corner. Both bindings are
22assigned once and follow the live texture. The label at the top left reads the
23view's cull counts each frame from `app.last_telemetry`: `view_occlusion_total`
24is how many instances the view submitted and `view_occlusion_drawn` how many
25survived the cull.
26"""
27
28from itertools import product
29
30from simvx.core import (
31 Anchor,
32 Camera3D,
33 CanvasLayer,
34 Control,
35 DirectionalLight3D,
36 Label,
37 Material,
38 Mesh,
39 MeshInstance3D,
40 Node,
41 Sprite2D,
42 SubViewport,
43)
44from simvx.graphics import App
45
46FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 10, 6, 4
47FIELD_SPACING = 0.85
48PIP_SIZE = 240
49
50
51class SubViewportOcclusionScene(Node):
52 def on_ready(self):
53 self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 1, 0)))
54 self.add_child(DirectionalLight3D(intensity=2.0, direction=(-0.4, -1.0, -0.6)))
55
56 # The offscreen view: its own camera, light, wall and cubes. Its cull runs for this camera alone.
57 sub = self.add_child(SubViewport(size=(512, 512), use_occlusion=True))
58 sub.add_child(Camera3D(position=(0, 1.5, 9), look_at=(0, 1.5, -8)))
59 sub.add_child(DirectionalLight3D(intensity=2.5, direction=(-0.5, -0.8, -0.5)))
60
61 # Wide enough to hide the whole field, narrow enough to leave the flank cubes in view.
62 wall_mat = Material(colour=(0.55, 0.42, 0.30), roughness=0.8)
63 sub.add_child(MeshInstance3D(mesh=Mesh.box((5, 5, 0.4)), material=wall_mat, position=(0, 2, 2)))
64
65 field_mesh = Mesh.box(0.32)
66 field_mat = Material(colour=(0.30, 0.55, 0.85), roughness=0.5)
67 x0 = -(FIELD_COLS - 1) * FIELD_SPACING / 2
68 for cx, cy, cz in product(range(FIELD_COLS), range(FIELD_ROWS), range(FIELD_LAYERS)):
69 position = (x0 + cx * FIELD_SPACING, 0.2 + cy * FIELD_SPACING, -2 - cz * FIELD_SPACING)
70 sub.add_child(MeshInstance3D(mesh=field_mesh, material=field_mat, position=position))
71
72 flank_mesh = Mesh.box(0.6)
73 flank_mat = Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05))
74 self.flanks = [
75 sub.add_child(
76 MeshInstance3D(mesh=flank_mesh, material=flank_mat, position=(side * (3.8 + k * 0.4), 1.5, -k * 0.5))
77 )
78 for side in (-1, 1)
79 for k in range(3)
80 ]
81
82 # The live feed on a monitor in the main scene.
83 monitor_mat = Material(colour=(1, 1, 1), roughness=1.0, albedo_map=sub)
84 self.add_child(MeshInstance3D(mesh=Mesh.box((3.4, 3.4, 0.08)), material=monitor_mat, position=(0, 1.2, 0)))
85
86 hud = self.add_child(CanvasLayer())
87 self.status = hud.add_child(Label("View cull: waiting for the first frame", anchor=Anchor.TOP_LEFT, inset=12))
88 hud.add_child(
89 Label(
90 f"use_occlusion = True: {FIELD_COLS * FIELD_ROWS * FIELD_LAYERS} cubes behind the view's wall",
91 anchor=Anchor.BOTTOM_RIGHT,
92 inset=12,
93 )
94 )
95 # Picture-in-picture: the same feed as a sprite, centred in an anchored frame.
96 pip = hud.add_child(Control(anchor=Anchor.BOTTOM_LEFT, size=(PIP_SIZE, PIP_SIZE), inset=12))
97 pip.add_child(Sprite2D(texture=sub, position=(PIP_SIZE / 2, PIP_SIZE / 2), width=PIP_SIZE, height=PIP_SIZE))
98
99 def on_update(self, dt):
100 for i, flank in enumerate(self.flanks):
101 flank.rotate_y((0.8 + 0.1 * i) * dt)
102 telemetry = self.app.last_telemetry
103 if total := telemetry.get("view_occlusion_total"):
104 drawn = telemetry["view_occlusion_drawn"]
105 self.status.text = f"View cull: drew {drawn} of {total} instances, culled {total - drawn}"
106
107
108if __name__ == "__main__":
109 App(title="SubViewport Occlusion", width=1280, height=720).run(SubViewportOcclusionScene())