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 to adjust the blend parameter. Press Space to crossfade between two colour-tint animations. Keyframe events print to stdout when triggered.
Controls: Up/Down - Adjust blend parameter (idle <-> bounce) Space - 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**
10to adjust the blend parameter. Press **Space** to crossfade between two
11colour-tint animations. Keyframe events print to stdout when triggered.
12
13Controls:
14 Up/Down - Adjust blend parameter (idle <-> bounce)
15 Space - Crossfade between colour clips
16 Escape - Quit
17"""
18
19from simvx.core import (
20 Camera3D,
21 DirectionalLight3D,
22 Input,
23 InputMap,
24 Key,
25 Material,
26 Mesh,
27 MeshInstance3D,
28 Node,
29 Text2D,
30 Vec3,
31)
32from simvx.core.animation.blend_space import BlendSpace1D
33from simvx.core.animation.player import AnimationPlayer
34from simvx.core.animation.track import AnimationClip
35from simvx.core.animation.tween import ease_in_out_sine, ease_linear
36from simvx.graphics import App
37
38# ============================================================================
39# Helper -- build keyframe clips
40# ============================================================================
41
42
43def _idle_clip() -> AnimationClip:
44 """Gentle vertical bob: y oscillates 0 -> 0.5 -> 0 over 2 seconds."""
45 clip = AnimationClip("idle", 2.0)
46 clip.add_track(
47 "offset_y",
48 [
49 (0.0, 0.0),
50 (1.0, 0.5),
51 (2.0, 0.0),
52 ],
53 easing=ease_in_out_sine,
54 )
55 return clip
56
57
58def _bounce_clip() -> AnimationClip:
59 """Energetic bounce: y goes 0 -> 3 -> 0 over 1 second."""
60 clip = AnimationClip("bounce", 1.0)
61 clip.add_track(
62 "offset_y",
63 [
64 (0.0, 0.0),
65 (0.3, 3.0),
66 (1.0, 0.0),
67 ],
68 easing=ease_linear,
69 )
70 # Keyframe event at the peak
71 clip.tracks["offset_y"].add_event(0.3, lambda: print("[event] bounce peak!"))
72 return clip
73
74
75def _colour_clip_hold(name: str, rgb: tuple[float, float, float]) -> AnimationClip:
76 """Looping clip that holds a single colour. Crossfade between two of these
77 smoothly tweens the target's tint_{r,g,b} from the current value to ``rgb``
78 over the crossfade duration, without jumping through each clip's internal
79 keyframe animation first. Duration must be non-zero so AnimationPlayer
80 keeps ``playing=True`` (a 0-length clip ends immediately and crossfade()
81 then falls back to an instant play()).
82 """
83 r, g, b = rgb
84 clip = AnimationClip(name, 1.0)
85 clip.add_track("tint_r", [(0.0, r), (1.0, r)])
86 clip.add_track("tint_g", [(0.0, g), (1.0, g)])
87 clip.add_track("tint_b", [(0.0, b), (1.0, b)])
88 return clip
89
90
91def _colour_clip_green() -> AnimationClip:
92 return _colour_clip_hold("green", (0.2, 1.0, 0.2))
93
94
95def _colour_clip_red() -> AnimationClip:
96 return _colour_clip_hold("red", (1.0, 0.2, 0.2))
97
98
99# ============================================================================
100# Demo scene
101# ============================================================================
102
103
104class BlendDemoScene(Node):
105 """Root node for the animation blend demo."""
106
107 def on_ready(self):
108 InputMap.add_action("blend_up", [Key.UP])
109 InputMap.add_action("blend_down", [Key.DOWN])
110 InputMap.add_action("crossfade", [Key.SPACE])
111 InputMap.add_action("quit", [Key.ESCAPE])
112
113 # Camera
114 cam = self.add_child(Camera3D(name="Camera"))
115 cam.position = Vec3(0, 3, 8)
116 cam.look_at(Vec3(0, 1, 0))
117
118 # Directional light so the cube is visible (without this the scene
119 # renders black and relies on the shadow_pass zero-vector fallback).
120 sun = self.add_child(DirectionalLight3D(name="Sun"))
121 sun.direction = Vec3(-0.5, -1.0, -0.3)
122
123 # Cube
124 self.cube = self.add_child(MeshInstance3D(name="Cube"))
125 self.cube.mesh = Mesh.cube(size=1)
126 self.cube.material = Material(colour=(1, 1, 1, 1))
127 self.cube.position = Vec3(0, 1, 0)
128
129 # Animation target (lightweight proxy so we don't collide with node props)
130 self._anim_target = _AnimProxy()
131
132 # BlendSpace1D: idle <-> bounce
133 self.blend_space = BlendSpace1D()
134 self.blend_space.add_point(_idle_clip(), 0.0)
135 self.blend_space.add_point(_bounce_clip(), 1.0)
136 self._blend_param = 0.0
137 self._blend_time = 0.0
138
139 # AnimationPlayer for colour crossfade. Clips play once and hold
140 # their final value; crossfade() swaps between the two tints.
141 self._colour_player = AnimationPlayer(target=self._anim_target)
142 self._colour_player.add_clip(_colour_clip_green())
143 self._colour_player.add_clip(_colour_clip_red())
144 self._colour_player.play("green", loop=True)
145 self._current_colour = "green"
146
147 # HUD: Text2D renders through Draw2D in screen pixels, not 3D world.
148 self.hud = self.add_child(Text2D(name="HUD", text="", x=10, y=10, font_scale=1.5))
149
150 def on_process(self, dt: float):
151 if Input.is_action_just_pressed("quit"):
152 self.app.quit()
153 return
154 # Adjust blend parameter
155 if Input.is_action_pressed("blend_up"):
156 self._blend_param = min(1.0, self._blend_param + dt)
157 if Input.is_action_pressed("blend_down"):
158 self._blend_param = max(0.0, self._blend_param - dt)
159
160 # Crossfade colour on Space (_input override was never wired for a
161 # plain Node, so poll the action here).
162 if Input.is_action_just_pressed("crossfade"):
163 next_colour = "red" if self._current_colour == "green" else "green"
164 self._colour_player.crossfade(next_colour, duration=0.5)
165 self._current_colour = next_colour
166 print(f"[crossfade] -> {next_colour}")
167
168 self.blend_space.set_parameter(self._blend_param)
169
170 # Advance blend time (loop at max clip duration)
171 self._blend_time += dt
172 if self._blend_time > 2.0:
173 self._blend_time -= 2.0
174
175 # Sample blend space
176 offset_y = self.blend_space.sample("offset_y", self._blend_time)
177 if offset_y is not None:
178 self.cube.position = Vec3(0, 1 + offset_y, 0)
179
180 # Colour animation: mutate the existing material in place; allocating
181 # a fresh Material every frame leaks into the bindless material array
182 # and overflows the 1024-slot cap in ~17 seconds at 60 FPS.
183 self._colour_player.on_process(dt)
184 r = getattr(self._anim_target, "tint_r", 1.0)
185 g = getattr(self._anim_target, "tint_g", 1.0)
186 b = getattr(self._anim_target, "tint_b", 1.0)
187 self.cube.material.colour = (r, g, b, 1.0)
188
189 # Update HUD: surface the key bindings next to the live values.
190 self.hud.text = (
191 f"Animation blend (Up/Down) = {self._blend_param:.2f} | "
192 f"Colour (Space) = {self._current_colour} | ESC = quit"
193 )
194
195
196class _AnimProxy:
197 """Lightweight object that AnimationPlayer writes properties onto."""
198
199 tint_r: float = 1.0
200 tint_g: float = 1.0
201 tint_b: float = 1.0
202 offset_y: float = 0.0
203
204
205# ============================================================================
206# Entry point
207# ============================================================================
208
209if __name__ == "__main__":
210 App(width=1280, height=720, title="Animation Blend Demo").run(BlendDemoScene())