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. The colour clips are one-shots, so within a second of each swap the outgoing clip has finished: crossfading then blends out of the pose it stopped on, and the HUD says which of the two states the next swap will start from. 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.  The colour clips are
 12one-shots, so within a second of each swap the outgoing clip has finished:
 13crossfading then blends out of the pose it stopped on, and the HUD says
 14which of the two states the next swap will start from.  Keyframe events
 15print to stdout when triggered.
 16
 17Controls:
 18    Up/Down         - Adjust blend parameter (idle <-> bounce)
 19    Drag left/right - Adjust blend parameter (mouse or touch)
 20    Space / tap     - Crossfade between colour clips
 21    Escape          - Quit
 22"""
 23
 24from simvx.core import (
 25    AnimationClip,
 26    AnimationPlayer,
 27    BlendSpace1D,
 28    Camera3D,
 29    DirectionalLight3D,
 30    Input,
 31    InputMap,
 32    Key,
 33    Material,
 34    Mesh,
 35    MeshInstance3D,
 36    MouseButton,
 37    Node,
 38    Text2D,
 39    Vec3,
 40    WorldEnvironment,
 41)
 42from simvx.core.animation.tween import ease_in_out_sine, ease_linear
 43from simvx.graphics import App
 44
 45# Mouse/touch tuning: a press that moves less than the tap threshold counts as
 46# a tap (crossfade); a horizontal drag of DRAG_RANGE_PX sweeps the full 0..1
 47# blend parameter.
 48TAP_THRESHOLD_PX = 8.0
 49DRAG_RANGE_PX = 300.0
 50
 51# ============================================================================
 52# Helper -- build keyframe clips
 53# ============================================================================
 54
 55
 56def _idle_clip() -> AnimationClip:
 57    """Gentle vertical bob: y oscillates 0 -> 0.5 -> 0 over 2 seconds."""
 58    clip = AnimationClip("idle", 2.0)
 59    clip.add_track(
 60        "offset_y",
 61        [
 62            (0.0, 0.0),
 63            (1.0, 0.5),
 64            (2.0, 0.0),
 65        ],
 66        easing=ease_in_out_sine,
 67    )
 68    return clip
 69
 70
 71def _bounce_clip() -> AnimationClip:
 72    """Energetic bounce: y goes 0 -> 3 -> 0 over 1 second."""
 73    clip = AnimationClip("bounce", 1.0)
 74    clip.add_track(
 75        "offset_y",
 76        [
 77            (0.0, 0.0),
 78            (0.3, 3.0),
 79            (1.0, 0.0),
 80        ],
 81        easing=ease_linear,
 82    )
 83    # Keyframe event at the peak
 84    clip.tracks["offset_y"].add_event(0.3, lambda: print("[event] bounce peak!"))
 85    return clip
 86
 87
 88def _colour_clip_hold(name: str, rgb: tuple[float, float, float]) -> AnimationClip:
 89    """One-shot clip that holds a single colour. Crossfade between two of these
 90    smoothly tweens the target's tint_{r,g,b} from the current value to ``rgb``
 91    over the crossfade duration, without jumping through each clip's internal
 92    keyframe animation first. Both keyframes hold the same colour, so the tint
 93    moves during a crossfade and at no other time; the duration is just how
 94    long the player runs before it stops on that colour and holds it.
 95    """
 96    r, g, b = rgb
 97    clip = AnimationClip(name, 1.0)
 98    clip.add_track("tint_r", [(0.0, r), (1.0, r)])
 99    clip.add_track("tint_g", [(0.0, g), (1.0, g)])
100    clip.add_track("tint_b", [(0.0, b), (1.0, b)])
101    return clip
102
103
104def _colour_clip_green() -> AnimationClip:
105    return _colour_clip_hold("green", (0.2, 1.0, 0.2))
106
107
108def _colour_clip_red() -> AnimationClip:
109    return _colour_clip_hold("red", (1.0, 0.2, 0.2))
110
111
112# ============================================================================
113# Demo scene
114# ============================================================================
115
116
117class BlendDemoScene(Node):
118    """Root node for the animation blend demo."""
119
120    def on_ready(self):
121        InputMap.add_action("blend_up", [Key.UP])
122        InputMap.add_action("blend_down", [Key.DOWN])
123        InputMap.add_action("crossfade", [Key.SPACE])
124        InputMap.add_action("quit", [Key.ESCAPE])
125
126        # Environment: default gradient sky so the background isn't a black void.
127        self.add_child(WorldEnvironment(name="Env"))
128
129        # Camera
130        cam = self.add_child(Camera3D(name="Camera"))
131        cam.position = Vec3(0, 3, 8)
132        cam.look_at(Vec3(0, 1, 0))
133
134        # Directional light so the cube is visible (without this the scene
135        # renders black and relies on the shadow_pass zero-vector fallback).
136        sun = self.add_child(DirectionalLight3D(name="Sun"))
137        sun.direction = Vec3(-0.5, -1.0, -0.3)
138
139        # Ground plane: a spatial reference so the blended vertical motion reads.
140        ground = self.add_child(MeshInstance3D(name="Ground"))
141        ground.mesh = Mesh.cube()
142        ground.material = Material(colour=(0.28, 0.32, 0.30, 1.0), roughness=0.9, metallic=0.0)
143        ground.scale = Vec3(30.0, 0.1, 30.0)
144        ground.position = Vec3(0, -0.05, 0)
145
146        # Cube
147        self.cube = self.add_child(MeshInstance3D(name="Cube"))
148        self.cube.mesh = Mesh.cube(size=1)
149        self.cube.material = Material(colour=(1, 1, 1, 1))
150        self.cube.position = Vec3(0, 1, 0)
151
152        # Animation target (lightweight proxy so we don't collide with node props)
153        self._anim_target = _AnimProxy()
154
155        # BlendSpace1D: idle <-> bounce
156        self.blend_space = BlendSpace1D()
157        self.blend_space.add_point(_idle_clip(), 0.0)
158        self.blend_space.add_point(_bounce_clip(), 1.0)
159        self._blend_param = 0.0
160        self._blend_time = 0.0
161        self._drag_distance = 0.0  # accumulated pointer travel for tap-vs-drag
162
163        # AnimationPlayer for the colour crossfade. Each clip holds a single
164        # tint and runs once, so crossfade() tweens smoothly from whatever is
165        # on screen to the other tint whether the outgoing clip is still
166        # running or has already finished and is holding its last pose. As a
167        # Node it is added to the tree and ticked by the engine: no manual
168        # pumping.
169        self._colour_player = self.add_child(AnimationPlayer(target=self._anim_target))
170        self._colour_player.add_clip(_colour_clip_green())
171        self._colour_player.add_clip(_colour_clip_red())
172        self._colour_player.play("green")
173        self._current_colour = "green"
174
175        # HUD: Text2D renders through Draw2D in screen pixels, not 3D world.
176        # Two short lines (values + control hints) so it fits narrow windows.
177        self.hud = self.add_child(Text2D(name="HUD", text="", position=(10, 10), font_scale=1.2))
178
179    def on_update(self, dt: float):
180        if Input.is_action_just_pressed("quit"):
181            self.app.quit()
182            return
183        # Adjust blend parameter
184        if Input.is_action_pressed("blend_up"):
185            self._blend_param = min(1.0, self._blend_param + dt)
186        if Input.is_action_pressed("blend_down"):
187            self._blend_param = max(0.0, self._blend_param - dt)
188
189        # Mouse/touch: drag horizontally to scrub the blend parameter; a short
190        # tap (press + release with little travel) crossfades. Touch arrives
191        # as MouseButton.LEFT on web, so this makes the demo playable on
192        # phones and tablets.
193        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
194            self._drag_distance = 0.0
195        if Input.is_mouse_button_pressed(MouseButton.LEFT):
196            dx = float(Input.mouse_delta.x)
197            self._drag_distance += abs(dx)
198            if self._drag_distance > TAP_THRESHOLD_PX:
199                self._blend_param = min(1.0, max(0.0, self._blend_param + dx / DRAG_RANGE_PX))
200        if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_distance <= TAP_THRESHOLD_PX:
201            self._crossfade()
202
203        # Crossfade colour on Space.
204        if Input.is_action_just_pressed("crossfade"):
205            self._crossfade()
206
207        self.blend_space.set_parameter(self._blend_param)
208
209        # Advance blend time (loop at max clip duration)
210        self._blend_time += dt
211        if self._blend_time > 2.0:
212            self._blend_time -= 2.0
213
214        # Sample blend space
215        offset_y = self.blend_space.sample("offset_y", self._blend_time)
216        if offset_y is not None:
217            self.cube.position = Vec3(0, 1 + offset_y, 0)
218
219        # Colour animation: the player writes tint_{r,g,b} onto the proxy, and
220        # we mutate the existing material in place. Allocating a fresh Material
221        # every frame would leak into the bindless material array and overflow
222        # its 1024-slot cap in ~17 seconds at 60 FPS.
223        r = getattr(self._anim_target, "tint_r", 1.0)
224        g = getattr(self._anim_target, "tint_g", 1.0)
225        b = getattr(self._anim_target, "tint_b", 1.0)
226        self.cube.material.colour = (r, g, b, 1.0)
227
228        # Update HUD: live values on one line, control hints on the next. The
229        # colour clip's state is worth showing because it is what the next
230        # crossfade blends out of: "running" while the clip (or a blend) is
231        # still going, "held" once it has finished on its final pose.
232        state = "running" if self._colour_player.playing else "held"
233        self.hud.text = (
234            f"Blend = {self._blend_param:.2f}   Colour = {self._current_colour} ({state})\n"
235            "Up/Down or drag = blend   Space or tap = crossfade   ESC = quit"
236        )
237
238    def _crossfade(self):
239        """Swap the colour tint via a 0.5 s crossfade (Space key or tap).
240
241        Either state blends: a clip still running fades out as it plays, and
242        one that has already finished fades out of the pose it stopped on.
243        """
244        source = "running" if self._colour_player.playing else "held"
245        next_colour = "red" if self._current_colour == "green" else "green"
246        self._colour_player.crossfade(next_colour, duration=0.5)
247        self._current_colour = next_colour
248        print(f"[crossfade] {source} -> {next_colour}")
249
250
251class _AnimProxy:
252    """Lightweight object that AnimationPlayer writes properties onto."""
253
254    tint_r: float = 1.0
255    tint_g: float = 1.0
256    tint_b: float = 1.0
257    offset_y: float = 0.0
258
259
260# ============================================================================
261# Entry point
262# ============================================================================
263
264if __name__ == "__main__":
265    App(width=1280, height=720, title="Animation Blend Demo").run(BlendDemoScene())