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