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