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. That texture is fed to a Material on a flat “monitor” slab in the main scene: a live screen in the world. The monitor’s feed updates each frame, proving the render-to- texture is genuinely live, not a one-shot capture.
Usage: uv run python examples/features/3d/sub_viewport.py uv run python examples/features/3d/sub_viewport.py –test
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. That texture
5is fed to a Material on a flat "monitor" slab in the main scene: a live screen
6in the world. The monitor's feed updates each frame, proving the render-to-
7texture is genuinely live, not a one-shot capture.
8
9# /// simvx
10# tags = ["3d", "subviewport", "render-to-texture"]
11# screenshot_frame = 60
12# ///
13
14Usage:
15 uv run python examples/features/3d/sub_viewport.py
16 uv run python examples/features/3d/sub_viewport.py --test
17"""
18
19import sys
20
21from simvx.core import (
22 Camera3D,
23 DirectionalLight3D,
24 Input,
25 InputMap,
26 Key,
27 Material,
28 Mesh,
29 MeshInstance3D,
30 Node,
31 Sprite2D,
32 SubViewport,
33 Text2D,
34)
35from simvx.graphics import App
36
37WIDTH, HEIGHT = 1280, 720
38
39
40class SubViewportScene(Node):
41 def on_ready(self):
42 InputMap.add_action("quit", [Key.ESCAPE])
43
44 # ── Main-scene camera (looks at the monitor) ──────────────────────
45 self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 0, 0), up=(0, 1, 0)))
46 sun = self.add_child(DirectionalLight3D(intensity=2.0))
47 sun.direction = (-0.4, -1.0, -0.6)
48
49 # ── SubViewport: a self-contained 3D scene with its OWN camera ────
50 # Its subtree never appears in the main pass; it renders offscreen.
51 self.sub = self.add_child(SubViewport(size=(512, 512)))
52 self.sub.add_child(Camera3D(position=(0, 0, 3.0), look_at=(0, 0, 0), up=(0, 1, 0)))
53 sub_sun = self.sub.add_child(DirectionalLight3D(intensity=3.0))
54 sub_sun.direction = (-0.5, -0.7, -0.5)
55 # A large emissive backdrop fills the feed so the monitor reads as a
56 # bright screen rather than a dark panel, and makes the live update
57 # obvious as the foreground cube spins across it.
58 self.sub.add_child(
59 MeshInstance3D(
60 mesh=Mesh.cube(6.0),
61 material=Material(colour=(0.15, 0.35, 0.85, 1.0), roughness=1.0),
62 position=(0, 0, -3.5),
63 )
64 )
65 self.feed_cube = self.sub.add_child(
66 MeshInstance3D(
67 mesh=Mesh.cube(1.8),
68 material=Material(colour=(0.95, 0.55, 0.15, 1.0), roughness=0.4),
69 )
70 )
71
72 # ── The in-world monitor: a thin slab textured with the live feed ─
73 # albedo_tex_index is set each frame from the SubViewport's texture
74 # slot. The slot is stable across resizes, so this binding holds.
75 self.monitor_mat = Material(colour=(1, 1, 1, 1), roughness=1.0)
76 self.monitor = self.add_child(
77 MeshInstance3D(
78 mesh=Mesh.cube(1.0),
79 material=self.monitor_mat,
80 position=(0, 0, 0),
81 scale=(3.2, 1.8, 0.08),
82 )
83 )
84
85 # ── Reference props so the main scene clearly differs from the feed ─
86 self.add_child(
87 MeshInstance3D(
88 mesh=Mesh.cube(0.6),
89 material=Material(colour=(0.3, 0.8, 0.4, 1.0)),
90 position=(-2.6, -0.6, 0.5),
91 )
92 )
93 self.add_child(
94 MeshInstance3D(
95 mesh=Mesh.sphere(0.5),
96 material=Material(colour=(0.3, 0.5, 0.95, 1.0)),
97 position=(2.6, -0.6, 0.5),
98 )
99 )
100
101 # Picture-in-picture: the SAME live feed drawn directly as a 2D sprite
102 # in the corner (the Sprite2D._texture_id path), proving the texture is
103 # consumable both in 3D (monitor material) and 2D (HUD).
104 self.pip = self.add_child(Sprite2D(position=(180, 600), width=260, height=260))
105
106 self.add_child(Text2D(text="SubViewport → live monitor feed", x=20, y=20, font_scale=1.8))
107
108 def on_process(self, dt):
109 if Input.is_action_pressed("quit"):
110 self.app.quit()
111 return
112 # Spin the feed cube so the monitor visibly updates each frame.
113 self.feed_cube.rotate_y(1.4 * dt)
114 self.feed_cube.rotate_x(0.7 * dt)
115 # Slowly orbit the monitor for depth.
116 self.monitor.rotate_y(0.25 * dt)
117 # Bind the SubViewport's live texture to the monitor material. The
118 # SubViewportManager publishes the bindless slot into sub.texture; we
119 # forward it to the material's direct GPU texture index.
120 if self.sub.texture >= 0:
121 self.monitor_mat.albedo_tex_index = self.sub.texture
122 self.pip._texture_id = self.sub.texture
123
124
125def _run_test() -> int:
126 from simvx.graphics.testing import assert_not_blank, save_png
127
128 out_dir = "/tmp/verify/subviewport"
129 import os
130
131 os.makedirs(out_dir, exist_ok=True)
132 app = App(title="SubViewport", width=WIDTH, height=HEIGHT, visible=False)
133 frames = [20, 40, 60, 90]
134 captured = app.run_headless(SubViewportScene(), frames=120, capture_frames=frames)
135 ok = True
136 for i, frame in zip(frames, captured, strict=True):
137 path = f"{out_dir}/frame_{i:03d}.png"
138 save_png(path, frame)
139 try:
140 assert_not_blank(frame)
141 print(f"saved {path} (non-blank)")
142 except AssertionError:
143 ok = False
144 print(f"saved {path} (BLANK: FAIL)")
145 return 0 if ok else 1
146
147
148if __name__ == "__main__":
149 if "--test" in sys.argv:
150 sys.exit(_run_test())
151 app = App(title="SubViewport", width=WIDTH, height=HEIGHT)
152 app.run(SubViewportScene())