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