Animation Blend¶
BlendSpace1D, crossfade, and keyframe events.
▶ Run in browserTags: 3d
An AnimationClip is a set of keyframe tracks, each naming a property and
its value over time. This example shows the three ways clips combine.
Blend space. BlendSpace1D places clips along one parameter axis and
mixes the two nearest in proportion. Here a gentle idle bob sits at 0.0 and
a two-hop bounce at 1.0; set_parameter picks the mix and
sample("height", time) returns the blended track value, which the scene
writes onto the cube. A blend space has no playhead of its own, so the scene
advances the time and loops it.
Crossfade. AnimationPlayer is a node that plays clips onto its target,
by default its parent. Two one-shot clips each hold one colour on the scene’s
tint property, and crossfade("red", duration=0.5) blends from whatever
is showing into the other. A clip that has already finished still blends
smoothly out of the pose it stopped on; the HUD shows whether the next swap
starts from a running or a held clip.
Keyframe events. track.add_event(time, callback) runs the callback
when playback crosses that time. The bounce clip counts its peaks this way,
and the HUD shows the count. An event belongs to the track’s time, not to the
clip’s blend weight, so the count climbs at blend 0.0 too, while the cube only
bobs. The player fires events itself; a blend space does not, so the scene
calls track.fire_events(previous, current) as it advances.
Controls: Up / Down - Blend parameter (idle to bounce) Drag left / right - Blend parameter (mouse or touch) Space / tap - Crossfade between the colour clips
Source¶
1# /// simvx
2# web = { width = 1280, height = 720 }
3# ///
4"""Animation Blend: BlendSpace1D, crossfade, and keyframe events.
5
6An ``AnimationClip`` is a set of keyframe tracks, each naming a property and
7its value over time. This example shows the three ways clips combine.
8
9**Blend space.** ``BlendSpace1D`` places clips along one parameter axis and
10mixes the two nearest in proportion. Here a gentle *idle* bob sits at 0.0 and
11a two-hop *bounce* at 1.0; ``set_parameter`` picks the mix and
12``sample("height", time)`` returns the blended track value, which the scene
13writes onto the cube. A blend space has no playhead of its own, so the scene
14advances the time and loops it.
15
16**Crossfade.** ``AnimationPlayer`` is a node that plays clips onto its target,
17by default its parent. Two one-shot clips each hold one colour on the scene's
18``tint`` property, and ``crossfade("red", duration=0.5)`` blends from whatever
19is showing into the other. A clip that has already finished still blends
20smoothly out of the pose it stopped on; the HUD shows whether the next swap
21starts from a *running* or a *held* clip.
22
23**Keyframe events.** ``track.add_event(time, callback)`` runs the callback
24when playback crosses that time. The bounce clip counts its peaks this way,
25and the HUD shows the count. An event belongs to the track's time, not to the
26clip's blend weight, so the count climbs at blend 0.0 too, while the cube only
27bobs. The player fires events itself; a blend space does not, so the scene
28calls ``track.fire_events(previous, current)`` as it advances.
29
30Controls:
31 Up / Down - Blend parameter (idle to bounce)
32 Drag left / right - Blend parameter (mouse or touch)
33 Space / tap - Crossfade between the colour clips
34"""
35
36from simvx.core import (
37 Anchor,
38 AnimationClip,
39 AnimationPlayer,
40 Axis1D,
41 BlendSpace1D,
42 Camera3D,
43 CanvasLayer,
44 DirectionalLight3D,
45 GestureRecognizer,
46 Input,
47 Key,
48 Label,
49 Material,
50 Mesh,
51 MeshInstance3D,
52 Node,
53 PointerType,
54 Property,
55 Vec3,
56 WorldEnvironment,
57 easing,
58 on_input,
59)
60from simvx.graphics import App
61
62CYCLE = 2.0 # both blended clips last this long, so one loop time drives both
63DRAG_RANGE_PX = 300.0 # a drag this wide sweeps the whole blend axis
64
65
66def colour_clip(name: str, rgb: tuple[float, float, float]) -> AnimationClip:
67 """A one-shot clip holding one tint: it changes colour only while crossfading."""
68 clip = AnimationClip(name, 1.0)
69 clip.add_track("tint", [(0.0, Vec3(rgb)), (1.0, Vec3(rgb))])
70 return clip
71
72
73class BlendDemo(Node):
74 blend = Property(0.0, range=(0.0, 1.0))
75 tint = Property(Vec3(1, 1, 1), on_change="_apply_tint")
76
77 input_context = {
78 "blend": [Axis1D(low=Key.DOWN, high=Key.UP)],
79 "crossfade": [Key.SPACE],
80 }
81
82 def on_ready(self):
83 self.add_child(WorldEnvironment())
84 self.add_child(Camera3D(position=(0, 3, 8), look_at=(0, 1, 0)))
85 self.add_child(DirectionalLight3D(direction=(-0.5, -1.0, -0.3)))
86 self.add_child(MeshInstance3D(mesh=Mesh.plane(30), material=Material(colour=(0.28, 0.32, 0.3), roughness=0.9)))
87 self.cube = self.add_child(MeshInstance3D(mesh=Mesh.box(1), material=Material(), position=(0, 1, 0)))
88
89 idle = AnimationClip("idle", CYCLE)
90 idle.add_track("height", [(0.0, 0.0), (1.0, 0.5), (2.0, 0.0)], easing=easing.ease_in_out_sine)
91 bounce = AnimationClip("bounce", CYCLE)
92 bounce.add_track("height", [(0.0, 0.0), (0.3, 3.0), (1.0, 0.0), (1.3, 3.0), (2.0, 0.0)])
93 self.peak_track = bounce.tracks["height"]
94 for peak in (0.3, 1.3):
95 self.peak_track.add_event(peak, self._on_peak)
96 self.blend_space = BlendSpace1D()
97 self.blend_space.add_point(idle, 0.0)
98 self.blend_space.add_point(bounce, 1.0)
99 self.time = 0.0
100 self.peaks = 0
101
102 self.player = self.add_child(AnimationPlayer())
103 self.player.add_clip(colour_clip("green", (0.2, 1.0, 0.2)))
104 self.player.add_clip(colour_clip("red", (1.0, 0.2, 0.2)))
105 self.player.play("green")
106
107 # The recogniser reads fingers only unless told otherwise; the mouse drags and taps too.
108 gestures = self.add_child(GestureRecognizer(pointer_types=(PointerType.TOUCH, PointerType.MOUSE)))
109 gestures.pan.connect(self._on_pan)
110 gestures.tap.connect(lambda _x, _y: self._crossfade())
111
112 self.hud = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=18))
113
114 def _apply_tint(self):
115 self.cube.material.colour = (*self.tint, 1.0)
116
117 def _on_pan(self, dx, _dy):
118 self.blend += dx / DRAG_RANGE_PX
119
120 def _on_peak(self):
121 self.peaks += 1
122
123 @on_input("crossfade")
124 def _on_crossfade(self, _event):
125 self._crossfade()
126
127 def _crossfade(self):
128 self.player.crossfade("red" if self.player.current_clip == "green" else "green", duration=0.5)
129
130 def on_update(self, dt):
131 self.blend += Input.value("blend") * dt
132 self.blend_space.set_parameter(self.blend)
133
134 previous, self.time = self.time, (self.time + dt) % CYCLE
135 self.peak_track.fire_events(previous, self.time)
136 self.cube.position = (0, 1 + self.blend_space.sample("height", self.time), 0)
137
138 state = "running" if self.player.playing else "held"
139 self.hud.text = (
140 f"Blend {self.blend:.2f} Colour {self.player.current_clip} ({state}) Peaks {self.peaks}\n"
141 "Up/Down or drag: blend Space or tap: crossfade"
142 )
143
144
145if __name__ == "__main__":
146 App(title="Animation Blend", width=1280, height=720).run(BlendDemo())