Animation Blend

BlendSpace1D, crossfade, and keyframe events.

▶ Run in browser

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