SubViewport¶
render a live 3D scene to a texture, shown on an in-world monitor.
▶ Run in browserTags: 3d subviewport render-to-texture
A SubViewport renders its own subtree (a spinning cube on a blue background,
viewed by its own Camera3D) into an offscreen texture every frame. Hand the
node to a material as Material(albedo_map=sub_viewport) and a flat “monitor”
slab in the main scene becomes a live screen in the world: the binding is
assign-once, and the renderer re-resolves the feed’s texture slot each frame.
The same node works as a Sprite2D texture for a picture-in-picture overlay.
Usage: uv run python examples/features/3d/sub_viewport.py
Source¶
1"""SubViewport: render a live 3D scene to a texture, shown on an in-world monitor.
2
3A SubViewport renders its own subtree (a spinning cube on a blue background,
4viewed by its own Camera3D) into an offscreen texture every frame. Hand the
5node to a material as `Material(albedo_map=sub_viewport)` and a flat "monitor"
6slab in the main scene becomes a live screen in the world: the binding is
7assign-once, and the renderer re-resolves the feed's texture slot each frame.
8The same node works as a `Sprite2D` texture for a picture-in-picture overlay.
9
10# /// simvx
11# tags = ["3d", "subviewport", "render-to-texture"]
12# screenshot_frame = 60
13# ///
14
15Usage:
16 uv run python examples/features/3d/sub_viewport.py
17"""
18
19from simvx.core import (
20 Camera3D,
21 DirectionalLight3D,
22 Input,
23 InputMap,
24 Key,
25 Material,
26 Mesh,
27 MeshInstance3D,
28 Node,
29 Sprite2D,
30 SubViewport,
31 Text2D,
32)
33from simvx.graphics import App
34
35WIDTH, HEIGHT = 1280, 720
36
37PIP_SIZE = 260 # picture-in-picture edge length in pixels
38PIP_MARGIN = 20 # gap from the window's left and bottom edges
39
40
41class SubViewportScene(Node):
42 def on_ready(self):
43 InputMap.add_action("quit", [Key.ESCAPE])
44
45 # ── Main-scene camera (looks at the monitor) ──────────────────────
46 self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 0, 0), up=(0, 1, 0)))
47 sun = self.add_child(DirectionalLight3D(intensity=2.0))
48 sun.direction = (-0.4, -1.0, -0.6)
49
50 # ── SubViewport: a self-contained 3D scene with its OWN camera ────
51 # Its subtree never appears in the main pass; it renders offscreen.
52 self.sub = self.add_child(SubViewport(size=(512, 512)))
53 self.sub.add_child(Camera3D(position=(0, 0, 3.0), look_at=(0, 0, 0), up=(0, 1, 0)))
54 sub_sun = self.sub.add_child(DirectionalLight3D(intensity=3.0))
55 sub_sun.direction = (-0.5, -0.7, -0.5)
56 # A large emissive backdrop fills the feed so the monitor reads as a
57 # bright screen rather than a dark panel, and makes the live update
58 # obvious as the foreground cube spins across it.
59 self.sub.add_child(
60 MeshInstance3D(
61 mesh=Mesh.cube(6.0),
62 material=Material(colour=(0.15, 0.35, 0.85, 1.0), roughness=1.0),
63 position=(0, 0, -3.5),
64 )
65 )
66 self.feed_cube = self.sub.add_child(
67 MeshInstance3D(
68 mesh=Mesh.cube(1.8),
69 material=Material(colour=(0.95, 0.55, 0.15, 1.0), roughness=0.4),
70 )
71 )
72
73 # ── The in-world monitor: a thin slab textured with the live feed ─
74 # Material(albedo_map=sub_viewport) is the first-class live-feed
75 # binding: assign the node once and the backend re-resolves its
76 # bindless slot every frame. The slab is shaded like any other
77 # surface, so the main scene's sun rakes across the screen as the
78 # monitor orbits.
79 self.monitor = self.add_child(
80 MeshInstance3D(
81 mesh=Mesh.cube(1.0),
82 material=Material(colour=(1, 1, 1, 1), albedo_map=self.sub),
83 position=(0, 0, 0),
84 scale=(3.2, 1.8, 0.08),
85 )
86 )
87
88 # ── Reference props so the main scene clearly differs from the feed ─
89 self.add_child(
90 MeshInstance3D(
91 mesh=Mesh.cube(0.6),
92 material=Material(colour=(0.3, 0.8, 0.4, 1.0)),
93 position=(-2.6, -0.6, 0.5),
94 )
95 )
96 self.add_child(
97 MeshInstance3D(
98 mesh=Mesh.sphere(0.5),
99 material=Material(colour=(0.3, 0.5, 0.95, 1.0)),
100 position=(2.6, -0.6, 0.5),
101 )
102 )
103
104 # Picture-in-picture: the SAME live feed drawn directly as a 2D sprite in
105 # the corner. Pass the SubViewport node itself as the Sprite2D ``texture``:
106 # the sprite resolves the live bindless slot each draw and is treated as a
107 # dynamic drawable, so the retained 2D item cache re-emits it every frame
108 # instead of frame-skipping a feed whose slot it cannot see change.
109 self.pip = self.add_child(
110 Sprite2D(
111 texture=self.sub,
112 position=(PIP_MARGIN + PIP_SIZE * 0.5, HEIGHT - PIP_MARGIN - PIP_SIZE * 0.5),
113 width=PIP_SIZE,
114 height=PIP_SIZE,
115 )
116 )
117
118 self.add_child(Text2D(text="SubViewport → live monitor feed", position=(20, 20), font_scale=1.8))
119
120 def on_update(self, dt):
121 if Input.is_action_pressed("quit"):
122 self.app.quit()
123 return
124 # Spin the feed cube so the monitor visibly updates each frame.
125 self.feed_cube.rotate_y(1.4 * dt)
126 self.feed_cube.rotate_x(0.7 * dt)
127 # Slowly orbit the monitor for depth.
128 self.monitor.rotate_y(0.25 * dt)
129 # Keep the corner feed on the window's bottom-left (Sprite2D positions
130 # from its centre), so it survives a resize.
131 self.pip.position = (PIP_MARGIN + PIP_SIZE * 0.5, self.app.height - PIP_MARGIN - PIP_SIZE * 0.5)
132
133
134if __name__ == "__main__":
135 app = App(title="SubViewport", width=WIDTH, height=HEIGHT)
136 app.run(SubViewportScene())