3D Path Follow

a camera rail spirals up around a ring of shapes.

▶ Run in browser

Tags: 3d

A Path3D owns a Curve3D, a chain of points joined by cubic bezier segments. Each point carries an in-handle and an out-handle, offsets from the point that bend the segments either side of it; with no handles the segments are straight. Points are in the path’s local space, so moving the Path3D moves the whole route.

The rail here is a helix: two turns of radius 14 rising from a height of 2 to 8, built from 16 segments. A handle pointing along the helix’s tangent, a third of a segment long, makes each bezier segment hug the true helix, so the rail is round rather than a polygon.

self.rail.curve.add_point(point, handle_in=-handle, handle_out=handle)

A PathFollow3D child of the path does the travelling: set its progress, a distance in world units along the curve, and it places itself on the curve every frame. With loop=True (the default) a progress past the end wraps to the start; progress_ratio is the same position as a fraction of the curve’s length. Anything parented to the follower rides along, so a Camera3D child turns the path into a camera rail. The follower’s rotates=True would turn it to face along the curve; this rail sets rotates=False and has the camera look_at() the centre of the ring each frame instead.

Hold Up or Down to change the speed, which Property(range=(1, 30)) keeps in bounds. Space, a click or a tap pauses and resumes the ride.

Source

  1"""3D Path Follow: a camera rail spirals up around a ring of shapes.
  2
  3A ``Path3D`` owns a ``Curve3D``, a chain of points joined by cubic bezier segments. Each point
  4carries an in-handle and an out-handle, offsets from the point that bend the segments either side
  5of it; with no handles the segments are straight. Points are in the path's local space, so moving
  6the ``Path3D`` moves the whole route.
  7
  8The rail here is a helix: two turns of radius 14 rising from a height of 2 to 8, built from 16
  9segments. A handle pointing along the helix's tangent, a third of a segment long, makes each bezier
 10segment hug the true helix, so the rail is round rather than a polygon.
 11
 12    self.rail.curve.add_point(point, handle_in=-handle, handle_out=handle)
 13
 14A ``PathFollow3D`` child of the path does the travelling: set its ``progress``, a distance in
 15world units along the curve, and it places itself on the curve every frame. With ``loop=True``
 16(the default) a progress past the end wraps to the start; ``progress_ratio`` is the same position
 17as a fraction of the curve's length. Anything parented to the follower rides along, so a
 18``Camera3D`` child turns the path into a camera rail. The follower's ``rotates=True`` would turn
 19it to face along the curve; this rail sets ``rotates=False`` and has the camera ``look_at()`` the
 20centre of the ring each frame instead.
 21
 22Hold Up or Down to change the speed, which ``Property(range=(1, 30))`` keeps in bounds. Space,
 23a click or a tap pauses and resumes the ride.
 24"""
 25
 26import math
 27
 28from simvx.core import (
 29    Anchor,
 30    Axis1D,
 31    Camera3D,
 32    CanvasLayer,
 33    DirectionalLight3D,
 34    Input,
 35    Key,
 36    Label,
 37    Material,
 38    Mesh,
 39    MeshInstance3D,
 40    Node,
 41    Path3D,
 42    PathFollow3D,
 43    Pointer,
 44    Property,
 45    Vec3,
 46    on_input,
 47)
 48from simvx.graphics import App
 49
 50COLOURS = [(0.9, 0.25, 0.2), (0.2, 0.7, 0.9), (0.9, 0.85, 0.2), (0.6, 0.3, 0.8), (0.3, 0.9, 0.4), (0.9, 0.5, 0.1)]
 51RADIUS, TURNS, POINTS, RISE = 14.0, 2, 16, 6.0
 52
 53
 54class CameraRail(Node):
 55    input_context = {"speed": [Axis1D(low=Key.DOWN, high=Key.UP)], "pause": [Key.SPACE, Pointer.PRIMARY]}
 56
 57    speed = Property(8.0, range=(1, 30))
 58
 59    def on_ready(self):
 60        self.add_child(DirectionalLight3D(intensity=1.2, colour=(1.0, 0.95, 0.85), direction=(-1.0, -2.0, -0.5)))
 61        self.add_child(MeshInstance3D(mesh=Mesh.plane(30), material=Material(colour=(0.35, 0.4, 0.35), roughness=0.9)))
 62
 63        cube, sphere = Mesh.box(), Mesh.sphere(radius=0.6)
 64        for i in range(12):
 65            angle = i * math.tau / 12
 66            material = Material(colour=COLOURS[i % len(COLOURS)], roughness=0.4, metallic=0.2)
 67            position = (10.0 * math.cos(angle), 0.5, 10.0 * math.sin(angle))
 68            self.add_child(MeshInstance3D(mesh=sphere if i % 2 else cube, material=material, position=position))
 69
 70        self.rail = self.add_child(Path3D())
 71        spin = math.tau * TURNS
 72        for i in range(POINTS + 1):
 73            t = i / POINTS
 74            a = spin * t
 75            point = Vec3(RADIUS * math.cos(a), 2.0 + RISE * t, RADIUS * math.sin(a))
 76            # The helix's derivative over one segment, divided by three, is the bezier handle that follows it.
 77            handle = Vec3(-spin * RADIUS * math.sin(a), RISE, spin * RADIUS * math.cos(a)) / (3 * POINTS)
 78            self.rail.curve.add_point(point, handle_in=-handle, handle_out=handle)
 79
 80        self.rider = self.rail.add_child(PathFollow3D(rotates=False))
 81        self.camera = self.rider.add_child(Camera3D(fov=65, near=0.1, far=200.0))
 82        self.paused = False
 83        self.hud = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=20))
 84
 85    @on_input("pause")
 86    def toggle_pause(self, _event):
 87        self.paused = not self.paused
 88
 89    def on_update(self, dt):
 90        self.speed += 8.0 * Input.value("speed") * dt
 91        if not self.paused:
 92            self.rider.progress += self.speed * dt
 93        self.camera.look_at((0.0, 1.0, 0.0))
 94        state = "Paused" if self.paused else "Riding"
 95        self.hud.text = (
 96            f"{state}   Speed: {self.speed:.1f} (Up/Down)   Progress: {self.rider.progress_ratio:.0%}   "
 97            "Space / click: pause"
 98        )
 99
100
101if __name__ == "__main__":
102    App(title="3D Path Follow", width=1280, height=720).run(CameraRail())