3D Path Follow¶
a camera rail spirals up around a ring of shapes.
▶ Run in browserTags: 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())