Split Screen¶
two players in one 3D world, each with their own RenderView.
▶ Run in browserTags: 3d renderview render-to-texture split-screen camera multiplayer
Two players drive around one shared arena. Each has a ChaseCamera trailing it
and a RenderView rendering the world through that camera into an offscreen
texture, and a Sprite2D shows each texture on its half of the window. Drive
one player into the other’s view and it appears there, because there is only
one world.
RenderView(camera="Camera")names its camera by node path, relative to the view; here each chase camera is its view’s child. The cameras are built withvisible=False, so neither becomes the main scene’s camera: the window shows only the two panels.Sprite2D(texture=view)draws the live capture as a 2D quad, the screen-space counterpart ofMaterial(albedo_map=view). A sprite with nowidthorheighttakes the texture’s size, so settingview.sizeto half the window every frame keeps each capture unscaled through any resize.A
RenderViewcaptures the 3D world only. The labels and the divider are 2D, so they draw over both halves rather than inside them, and a sprite showing a view can never feed back into it.
This is render-to-texture split screen: each view is one complete render of the
scene, which is what makes the halves free to be any size or shape and makes a
third player a third RenderView. Capture is skipped in the pipelined render
mode, where both halves would freeze, so run it on the default synchronous path.
Player one drives with W A S D and player two with the arrow keys: up and down walk along the heading, left and right turn in place.
Source¶
1# /// simvx
2# tags = ["3d", "renderview", "render-to-texture", "split-screen", "camera", "multiplayer"]
3# screenshot_frame = 40
4# ///
5"""Split Screen: two players in one 3D world, each with their own RenderView.
6
7Two players drive around one shared arena. Each has a `ChaseCamera` trailing it
8and a `RenderView` rendering the world through that camera into an offscreen
9texture, and a `Sprite2D` shows each texture on its half of the window. Drive
10one player into the other's view and it appears there, because there is only
11one world.
12
13- `RenderView(camera="Camera")` names its camera by node path, relative to the
14 view; here each chase camera is its view's child. The cameras are built with
15 `visible=False`, so neither becomes the main scene's camera: the window shows
16 only the two panels.
17- `Sprite2D(texture=view)` draws the live capture as a 2D quad, the screen-space
18 counterpart of `Material(albedo_map=view)`. A sprite with no `width` or
19 `height` takes the texture's size, so setting `view.size` to half the window
20 every frame keeps each capture unscaled through any resize.
21- A `RenderView` captures the 3D world only. The labels and the divider are 2D,
22 so they draw over both halves rather than inside them, and a sprite showing a
23 view can never feed back into it.
24
25This is render-to-texture split screen: each view is one complete render of the
26scene, which is what makes the halves free to be any size or shape and makes a
27third player a third `RenderView`. Capture is skipped in the pipelined render
28mode, where both halves would freeze, so run it on the default synchronous path.
29
30Player one drives with W A S D and player two with the arrow keys: up and down
31walk along the heading, left and right turn in place.
32"""
33
34import math
35
36from simvx.core import (
37 Anchor,
38 Axis2D,
39 CanvasLayer,
40 ChaseCamera,
41 DirectionalLight3D,
42 Input,
43 Label,
44 Material,
45 Mesh,
46 MeshInstance3D,
47 Node,
48 Polygon2D,
49 Quat,
50 RenderView,
51 Sprite2D,
52 Vec3,
53 WorldEnvironment,
54)
55from simvx.graphics import App
56
57WALK_SPEED = 7.0
58TURN_RATE = math.radians(130.0)
59ARENA = 22.0 # half-extent the players are kept inside
60DIVIDER = 8
61ONE = (0.95, 0.45, 0.15, 1.0) # player colours
62TWO = (0.25, 0.6, 0.95, 1.0)
63
64
65class Player(MeshInstance3D):
66 """A box driven by one typed action: up and down walk, left and right turn."""
67
68 def __init__(self, action, colour, **kwargs):
69 super().__init__(mesh=Mesh.box(1.2), material=Material(colour=colour, roughness=0.45), pivot="bottom", **kwargs)
70 self.action = action
71 # A node's forward is -Z, so this yaw starts the body facing the centre.
72 self.rotation = Quat.from_euler(0.0, math.atan2(self.position.x, self.position.z), 0.0)
73
74 def on_ready(self):
75 # A white roof marker, so each player is easy to spot in the other's half.
76 marker = Material(colour=(1.0, 1.0, 1.0, 1.0), unlit=True)
77 self.add_child(MeshInstance3D(mesh=Mesh.sphere(0.34), material=marker, position=(0, 1.5, 0)))
78
79 def on_update(self, dt):
80 move = Input.value(self.action) # +y is back, as down the screen in 2D
81 self.rotate_y(-move.x * TURN_RATE * dt)
82 step = self.forward * (-move.y * WALK_SPEED * dt)
83 self.position = (self.position + step).clamped(-ARENA, ARENA)
84
85
86class SplitScreen(Node):
87 input_context = {"drive_one": [Axis2D.WASD], "drive_two": [Axis2D.ARROWS]}
88
89 def on_ready(self):
90 # Ambient light matters here: each pillar is seen from two sides at once.
91 self.add_child(WorldEnvironment(ambient_light_energy=0.8))
92 self.add_child(DirectionalLight3D(intensity=2.6, direction=(-0.4, -1.0, -0.5)))
93 self._build_arena()
94
95 # Both start facing the obelisk, off the same line through it, so each
96 # half opens on the landmark with the other player visible beyond it.
97 self.players = [
98 self.add_child(Player("drive_one", ONE, position=(-8, 0, 6))),
99 self.add_child(Player("drive_two", TWO, position=(7, 0, -8))),
100 ]
101 self.views = [self.add_child(RenderView(camera="Camera")) for _ in self.players]
102 for view, player in zip(self.views, self.players, strict=True):
103 view.add_child(
104 ChaseCamera(
105 name="Camera",
106 target=player,
107 visible=False,
108 offset=Vec3(0, 3.2, 7.5),
109 look_offset=Vec3(0, 1.0, 0),
110 half_life=0.16,
111 fov=62.0,
112 )
113 )
114 self.panels = [self.add_child(Sprite2D(texture=view)) for view in self.views]
115 self.divider = self.add_child(Polygon2D(colour=(0.05, 0.06, 0.08, 1.0)))
116
117 hud = self.add_child(CanvasLayer())
118 for text, anchor, colour in (
119 ("Player One WASD", Anchor.TOP_LEFT, ONE),
120 ("Player Two Arrows", Anchor.TOP_RIGHT, TWO),
121 ):
122 label = hud.add_child(Label(text, anchor=anchor, inset=16, font_size=22))
123 label.text_colour = colour
124 self.gap = hud.add_child(Label("", anchor=Anchor.BOTTOM_LEFT, inset=16, font_size=18))
125
126 def _build_arena(self):
127 ground = Material(colour=(0.2, 0.22, 0.26, 1.0), roughness=0.95)
128 self.add_child(
129 MeshInstance3D(mesh=Mesh.box((ARENA * 2 + 6, 0.2, ARENA * 2 + 6)), material=ground, position=(0, -0.1, 0))
130 )
131 # A checkerboard of tiles: a chase view reads motion off the ground, and
132 # a flat colour gives it nothing to read.
133 tile, tile_mesh = Material(colour=(0.26, 0.29, 0.34, 1.0), roughness=0.9), Mesh.box((4.0, 0.02, 4.0))
134 for i in range(-7, 8):
135 for j in range(-7, 8):
136 if (i + j) % 2 == 0:
137 self.add_child(MeshInstance3D(mesh=tile_mesh, material=tile, position=(i * 4.0, 0.01, j * 4.0)))
138
139 # A central obelisk and a ring of pillars to drive around. The ring sits
140 # beyond where the cameras trail, so no pillar comes between a camera
141 # and its own player.
142 obelisk = Material(colour=(0.85, 0.8, 0.55, 1.0), roughness=0.5)
143 self.add_child(MeshInstance3D(mesh=Mesh.box((1.6, 7.0, 1.6)), material=obelisk, pivot="bottom"))
144 stone = Material(colour=(0.45, 0.5, 0.6, 1.0), roughness=0.7)
145 for i in range(8):
146 angle = i * math.tau / 8
147 position = (math.cos(angle) * 19.0, 0, math.sin(angle) * 19.0)
148 pillar = Mesh.box((1.4, 2.6 + i % 3, 1.4))
149 self.add_child(MeshInstance3D(mesh=pillar, material=stone, pivot="bottom", position=position))
150
151 def on_update(self, dt):
152 # Each view renders at exactly its half of the window, and its sprite
153 # (native size) follows, centred on that half.
154 width, height = self.viewport_size
155 half = max(1, int(width // 2))
156 for i, (view, panel) in enumerate(zip(self.views, self.panels, strict=True)):
157 view.size = (half, max(1, int(height)))
158 panel.position = (half * (i + 0.5), height / 2)
159 x0, x1 = half - DIVIDER / 2, half + DIVIDER / 2
160 self.divider.polygon = ((x0, 0), (x1, 0), (x1, height), (x0, height))
161
162 one, two = self.players
163 gap = one.position.distance_to(two.position)
164 self.gap.text = f"One world, two views: the players are {gap:.1f} units apart."
165
166
167if __name__ == "__main__":
168 App(title="Split Screen", width=1280, height=720).run(SplitScreen())