Animation Blend

Play Demo

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())