Split Screen

two players in one 3D world, each with their own RenderView.

▶ Run in browser

Tags: 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 with visible=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 of Material(albedo_map=view). A sprite with no width or height takes the texture’s size, so setting view.size to half the window every frame keeps each capture unscaled through any resize.

  • A RenderView captures 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())