Animation Blend¶
BlendSpace1D, crossfade, and keyframe events.
▶ Run in browserTags: 3d
A cube’s vertical position is driven by a BlendSpace1D that blends between an idle clip (gentle bob) and a bounce clip (big jumps). Press Up/Down or drag horizontally to adjust the blend parameter. Press Space or tap to crossfade between two colour-tint animations. The colour clips are one-shots, so within a second of each swap the outgoing clip has finished: crossfading then blends out of the pose it stopped on, and the HUD says which of the two states the next swap will start from. Keyframe events print to stdout when triggered.
Controls: Up/Down - Adjust blend parameter (idle <-> bounce) Drag left/right - Adjust blend parameter (mouse or touch) Space / tap - Crossfade between colour clips Escape - Quit
Source¶
1#!/usr/bin/env python3
2"""Animation Blend: BlendSpace1D, crossfade, and keyframe events.
3
4# /// simvx
5# web = { width = 1280, height = 720 }
6# ///
7
8A cube's vertical position is driven by a BlendSpace1D that blends between
9an *idle* clip (gentle bob) and a *bounce* clip (big jumps). Press **Up/Down**
10or drag horizontally to adjust the blend parameter. Press **Space** or tap
11to crossfade between two colour-tint animations. The colour clips are
12one-shots, so within a second of each swap the outgoing clip has finished:
13crossfading then blends out of the pose it stopped on, and the HUD says
14which of the two states the next swap will start from. Keyframe events
15print to stdout when triggered.
16
17Controls:
18 Up/Down - Adjust blend parameter (idle <-> bounce)
19 Drag left/right - Adjust blend parameter (mouse or touch)
20 Space / tap - Crossfade between colour clips
21 Escape - Quit
22"""
23
24from simvx.core import (
25 AnimationClip,
26 AnimationPlayer,
27 BlendSpace1D,
28 Camera3D,
29 DirectionalLight3D,
30 Input,
31 InputMap,
32 Key,
33 Material,
34 Mesh,
35 MeshInstance3D,
36 MouseButton,
37 Node,
38 Text2D,
39 Vec3,
40 WorldEnvironment,
41)
42from simvx.core.animation.tween import ease_in_out_sine, ease_linear
43from simvx.graphics import App
44
45# Mouse/touch tuning: a press that moves less than the tap threshold counts as
46# a tap (crossfade); a horizontal drag of DRAG_RANGE_PX sweeps the full 0..1
47# blend parameter.
48TAP_THRESHOLD_PX = 8.0
49DRAG_RANGE_PX = 300.0
50
51# ============================================================================
52# Helper -- build keyframe clips
53# ============================================================================
54
55
56def _idle_clip() -> AnimationClip:
57 """Gentle vertical bob: y oscillates 0 -> 0.5 -> 0 over 2 seconds."""
58 clip = AnimationClip("idle", 2.0)
59 clip.add_track(
60 "offset_y",
61 [
62 (0.0, 0.0),
63 (1.0, 0.5),
64 (2.0, 0.0),
65 ],
66 easing=ease_in_out_sine,
67 )
68 return clip
69
70
71def _bounce_clip() -> AnimationClip:
72 """Energetic bounce: y goes 0 -> 3 -> 0 over 1 second."""
73 clip = AnimationClip("bounce", 1.0)
74 clip.add_track(
75 "offset_y",
76 [
77 (0.0, 0.0),
78 (0.3, 3.0),
79 (1.0, 0.0),
80 ],
81 easing=ease_linear,
82 )
83 # Keyframe event at the peak
84 clip.tracks["offset_y"].add_event(0.3, lambda: print("[event] bounce peak!"))
85 return clip
86
87
88def _colour_clip_hold(name: str, rgb: tuple[float, float, float]) -> AnimationClip:
89 """One-shot clip that holds a single colour. Crossfade between two of these
90 smoothly tweens the target's tint_{r,g,b} from the current value to ``rgb``
91 over the crossfade duration, without jumping through each clip's internal
92 keyframe animation first. Both keyframes hold the same colour, so the tint
93 moves during a crossfade and at no other time; the duration is just how
94 long the player runs before it stops on that colour and holds it.
95 """
96 r, g, b = rgb
97 clip = AnimationClip(name, 1.0)
98 clip.add_track("tint_r", [(0.0, r), (1.0, r)])
99 clip.add_track("tint_g", [(0.0, g), (1.0, g)])
100 clip.add_track("tint_b", [(0.0, b), (1.0, b)])
101 return clip
102
103
104def _colour_clip_green() -> AnimationClip:
105 return _colour_clip_hold("green", (0.2, 1.0, 0.2))
106
107
108def _colour_clip_red() -> AnimationClip:
109 return _colour_clip_hold("red", (1.0, 0.2, 0.2))
110
111
112# ============================================================================
113# Demo scene
114# ============================================================================
115
116
117class BlendDemoScene(Node):
118 """Root node for the animation blend demo."""
119
120 def on_ready(self):
121 InputMap.add_action("blend_up", [Key.UP])
122 InputMap.add_action("blend_down", [Key.DOWN])
123 InputMap.add_action("crossfade", [Key.SPACE])
124 InputMap.add_action("quit", [Key.ESCAPE])
125
126 # Environment: default gradient sky so the background isn't a black void.
127 self.add_child(WorldEnvironment(name="Env"))
128
129 # Camera
130 cam = self.add_child(Camera3D(name="Camera"))
131 cam.position = Vec3(0, 3, 8)
132 cam.look_at(Vec3(0, 1, 0))
133
134 # Directional light so the cube is visible (without this the scene
135 # renders black and relies on the shadow_pass zero-vector fallback).
136 sun = self.add_child(DirectionalLight3D(name="Sun"))
137 sun.direction = Vec3(-0.5, -1.0, -0.3)
138
139 # Ground plane: a spatial reference so the blended vertical motion reads.
140 ground = self.add_child(MeshInstance3D(name="Ground"))
141 ground.mesh = Mesh.cube()
142 ground.material = Material(colour=(0.28, 0.32, 0.30, 1.0), roughness=0.9, metallic=0.0)
143 ground.scale = Vec3(30.0, 0.1, 30.0)
144 ground.position = Vec3(0, -0.05, 0)
145
146 # Cube
147 self.cube = self.add_child(MeshInstance3D(name="Cube"))
148 self.cube.mesh = Mesh.cube(size=1)
149 self.cube.material = Material(colour=(1, 1, 1, 1))
150 self.cube.position = Vec3(0, 1, 0)
151
152 # Animation target (lightweight proxy so we don't collide with node props)
153 self._anim_target = _AnimProxy()
154
155 # BlendSpace1D: idle <-> bounce
156 self.blend_space = BlendSpace1D()
157 self.blend_space.add_point(_idle_clip(), 0.0)
158 self.blend_space.add_point(_bounce_clip(), 1.0)
159 self._blend_param = 0.0
160 self._blend_time = 0.0
161 self._drag_distance = 0.0 # accumulated pointer travel for tap-vs-drag
162
163 # AnimationPlayer for the colour crossfade. Each clip holds a single
164 # tint and runs once, so crossfade() tweens smoothly from whatever is
165 # on screen to the other tint whether the outgoing clip is still
166 # running or has already finished and is holding its last pose. As a
167 # Node it is added to the tree and ticked by the engine: no manual
168 # pumping.
169 self._colour_player = self.add_child(AnimationPlayer(target=self._anim_target))
170 self._colour_player.add_clip(_colour_clip_green())
171 self._colour_player.add_clip(_colour_clip_red())
172 self._colour_player.play("green")
173 self._current_colour = "green"
174
175 # HUD: Text2D renders through Draw2D in screen pixels, not 3D world.
176 # Two short lines (values + control hints) so it fits narrow windows.
177 self.hud = self.add_child(Text2D(name="HUD", text="", position=(10, 10), font_scale=1.2))
178
179 def on_update(self, dt: float):
180 if Input.is_action_just_pressed("quit"):
181 self.app.quit()
182 return
183 # Adjust blend parameter
184 if Input.is_action_pressed("blend_up"):
185 self._blend_param = min(1.0, self._blend_param + dt)
186 if Input.is_action_pressed("blend_down"):
187 self._blend_param = max(0.0, self._blend_param - dt)
188
189 # Mouse/touch: drag horizontally to scrub the blend parameter; a short
190 # tap (press + release with little travel) crossfades. Touch arrives
191 # as MouseButton.LEFT on web, so this makes the demo playable on
192 # phones and tablets.
193 if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
194 self._drag_distance = 0.0
195 if Input.is_mouse_button_pressed(MouseButton.LEFT):
196 dx = float(Input.mouse_delta.x)
197 self._drag_distance += abs(dx)
198 if self._drag_distance > TAP_THRESHOLD_PX:
199 self._blend_param = min(1.0, max(0.0, self._blend_param + dx / DRAG_RANGE_PX))
200 if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_distance <= TAP_THRESHOLD_PX:
201 self._crossfade()
202
203 # Crossfade colour on Space.
204 if Input.is_action_just_pressed("crossfade"):
205 self._crossfade()
206
207 self.blend_space.set_parameter(self._blend_param)
208
209 # Advance blend time (loop at max clip duration)
210 self._blend_time += dt
211 if self._blend_time > 2.0:
212 self._blend_time -= 2.0
213
214 # Sample blend space
215 offset_y = self.blend_space.sample("offset_y", self._blend_time)
216 if offset_y is not None:
217 self.cube.position = Vec3(0, 1 + offset_y, 0)
218
219 # Colour animation: the player writes tint_{r,g,b} onto the proxy, and
220 # we mutate the existing material in place. Allocating a fresh Material
221 # every frame would leak into the bindless material array and overflow
222 # its 1024-slot cap in ~17 seconds at 60 FPS.
223 r = getattr(self._anim_target, "tint_r", 1.0)
224 g = getattr(self._anim_target, "tint_g", 1.0)
225 b = getattr(self._anim_target, "tint_b", 1.0)
226 self.cube.material.colour = (r, g, b, 1.0)
227
228 # Update HUD: live values on one line, control hints on the next. The
229 # colour clip's state is worth showing because it is what the next
230 # crossfade blends out of: "running" while the clip (or a blend) is
231 # still going, "held" once it has finished on its final pose.
232 state = "running" if self._colour_player.playing else "held"
233 self.hud.text = (
234 f"Blend = {self._blend_param:.2f} Colour = {self._current_colour} ({state})\n"
235 "Up/Down or drag = blend Space or tap = crossfade ESC = quit"
236 )
237
238 def _crossfade(self):
239 """Swap the colour tint via a 0.5 s crossfade (Space key or tap).
240
241 Either state blends: a clip still running fades out as it plays, and
242 one that has already finished fades out of the pose it stopped on.
243 """
244 source = "running" if self._colour_player.playing else "held"
245 next_colour = "red" if self._current_colour == "green" else "green"
246 self._colour_player.crossfade(next_colour, duration=0.5)
247 self._current_colour = next_colour
248 print(f"[crossfade] {source} -> {next_colour}")
249
250
251class _AnimProxy:
252 """Lightweight object that AnimationPlayer writes properties onto."""
253
254 tint_r: float = 1.0
255 tint_g: float = 1.0
256 tint_b: float = 1.0
257 offset_y: float = 0.0
258
259
260# ============================================================================
261# Entry point
262# ============================================================================
263
264if __name__ == "__main__":
265 App(width=1280, height=720, title="Animation Blend Demo").run(BlendDemoScene())