Animation Blend

BlendSpace1D, crossfade, and keyframe events.

▶ Run in browser

Tags: 3d

An AnimationClip is a set of keyframe tracks, each naming a property and its value over time. This example shows the three ways clips combine.

Blend space. BlendSpace1D places clips along one parameter axis and mixes the two nearest in proportion. Here a gentle idle bob sits at 0.0 and a two-hop bounce at 1.0; set_parameter picks the mix and sample("height", time) returns the blended track value, which the scene writes onto the cube. A blend space has no playhead of its own, so the scene advances the time and loops it.

Crossfade. AnimationPlayer is a node that plays clips onto its target, by default its parent. Two one-shot clips each hold one colour on the scene’s tint property, and crossfade("red", duration=0.5) blends from whatever is showing into the other. A clip that has already finished still blends smoothly out of the pose it stopped on; the HUD shows whether the next swap starts from a running or a held clip.

Keyframe events. track.add_event(time, callback) runs the callback when playback crosses that time. The bounce clip counts its peaks this way, and the HUD shows the count. An event belongs to the track’s time, not to the clip’s blend weight, so the count climbs at blend 0.0 too, while the cube only bobs. The player fires events itself; a blend space does not, so the scene calls track.fire_events(previous, current) as it advances.

Controls: Up / Down - Blend parameter (idle to bounce) Drag left / right - Blend parameter (mouse or touch) Space / tap - Crossfade between the colour clips

Source

  1# /// simvx
  2# web = { width = 1280, height = 720 }
  3# ///
  4"""Animation Blend: BlendSpace1D, crossfade, and keyframe events.
  5
  6An ``AnimationClip`` is a set of keyframe tracks, each naming a property and
  7its value over time. This example shows the three ways clips combine.
  8
  9**Blend space.** ``BlendSpace1D`` places clips along one parameter axis and
 10mixes the two nearest in proportion. Here a gentle *idle* bob sits at 0.0 and
 11a two-hop *bounce* at 1.0; ``set_parameter`` picks the mix and
 12``sample("height", time)`` returns the blended track value, which the scene
 13writes onto the cube. A blend space has no playhead of its own, so the scene
 14advances the time and loops it.
 15
 16**Crossfade.** ``AnimationPlayer`` is a node that plays clips onto its target,
 17by default its parent. Two one-shot clips each hold one colour on the scene's
 18``tint`` property, and ``crossfade("red", duration=0.5)`` blends from whatever
 19is showing into the other. A clip that has already finished still blends
 20smoothly out of the pose it stopped on; the HUD shows whether the next swap
 21starts from a *running* or a *held* clip.
 22
 23**Keyframe events.** ``track.add_event(time, callback)`` runs the callback
 24when playback crosses that time. The bounce clip counts its peaks this way,
 25and the HUD shows the count. An event belongs to the track's time, not to the
 26clip's blend weight, so the count climbs at blend 0.0 too, while the cube only
 27bobs. The player fires events itself; a blend space does not, so the scene
 28calls ``track.fire_events(previous, current)`` as it advances.
 29
 30Controls:
 31    Up / Down         - Blend parameter (idle to bounce)
 32    Drag left / right - Blend parameter (mouse or touch)
 33    Space / tap       - Crossfade between the colour clips
 34"""
 35
 36from simvx.core import (
 37    Anchor,
 38    AnimationClip,
 39    AnimationPlayer,
 40    Axis1D,
 41    BlendSpace1D,
 42    Camera3D,
 43    CanvasLayer,
 44    DirectionalLight3D,
 45    GestureRecognizer,
 46    Input,
 47    Key,
 48    Label,
 49    Material,
 50    Mesh,
 51    MeshInstance3D,
 52    Node,
 53    PointerType,
 54    Property,
 55    Vec3,
 56    WorldEnvironment,
 57    easing,
 58    on_input,
 59)
 60from simvx.graphics import App
 61
 62CYCLE = 2.0  # both blended clips last this long, so one loop time drives both
 63DRAG_RANGE_PX = 300.0  # a drag this wide sweeps the whole blend axis
 64
 65
 66def colour_clip(name: str, rgb: tuple[float, float, float]) -> AnimationClip:
 67    """A one-shot clip holding one tint: it changes colour only while crossfading."""
 68    clip = AnimationClip(name, 1.0)
 69    clip.add_track("tint", [(0.0, Vec3(rgb)), (1.0, Vec3(rgb))])
 70    return clip
 71
 72
 73class BlendDemo(Node):
 74    blend = Property(0.0, range=(0.0, 1.0))
 75    tint = Property(Vec3(1, 1, 1), on_change="_apply_tint")
 76
 77    input_context = {
 78        "blend": [Axis1D(low=Key.DOWN, high=Key.UP)],
 79        "crossfade": [Key.SPACE],
 80    }
 81
 82    def on_ready(self):
 83        self.add_child(WorldEnvironment())
 84        self.add_child(Camera3D(position=(0, 3, 8), look_at=(0, 1, 0)))
 85        self.add_child(DirectionalLight3D(direction=(-0.5, -1.0, -0.3)))
 86        self.add_child(MeshInstance3D(mesh=Mesh.plane(30), material=Material(colour=(0.28, 0.32, 0.3), roughness=0.9)))
 87        self.cube = self.add_child(MeshInstance3D(mesh=Mesh.box(1), material=Material(), position=(0, 1, 0)))
 88
 89        idle = AnimationClip("idle", CYCLE)
 90        idle.add_track("height", [(0.0, 0.0), (1.0, 0.5), (2.0, 0.0)], easing=easing.ease_in_out_sine)
 91        bounce = AnimationClip("bounce", CYCLE)
 92        bounce.add_track("height", [(0.0, 0.0), (0.3, 3.0), (1.0, 0.0), (1.3, 3.0), (2.0, 0.0)])
 93        self.peak_track = bounce.tracks["height"]
 94        for peak in (0.3, 1.3):
 95            self.peak_track.add_event(peak, self._on_peak)
 96        self.blend_space = BlendSpace1D()
 97        self.blend_space.add_point(idle, 0.0)
 98        self.blend_space.add_point(bounce, 1.0)
 99        self.time = 0.0
100        self.peaks = 0
101
102        self.player = self.add_child(AnimationPlayer())
103        self.player.add_clip(colour_clip("green", (0.2, 1.0, 0.2)))
104        self.player.add_clip(colour_clip("red", (1.0, 0.2, 0.2)))
105        self.player.play("green")
106
107        # The recogniser reads fingers only unless told otherwise; the mouse drags and taps too.
108        gestures = self.add_child(GestureRecognizer(pointer_types=(PointerType.TOUCH, PointerType.MOUSE)))
109        gestures.pan.connect(self._on_pan)
110        gestures.tap.connect(lambda _x, _y: self._crossfade())
111
112        self.hud = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=18))
113
114    def _apply_tint(self):
115        self.cube.material.colour = (*self.tint, 1.0)
116
117    def _on_pan(self, dx, _dy):
118        self.blend += dx / DRAG_RANGE_PX
119
120    def _on_peak(self):
121        self.peaks += 1
122
123    @on_input("crossfade")
124    def _on_crossfade(self, _event):
125        self._crossfade()
126
127    def _crossfade(self):
128        self.player.crossfade("red" if self.player.current_clip == "green" else "green", duration=0.5)
129
130    def on_update(self, dt):
131        self.blend += Input.value("blend") * dt
132        self.blend_space.set_parameter(self.blend)
133
134        previous, self.time = self.time, (self.time + dt) % CYCLE
135        self.peak_track.fire_events(previous, self.time)
136        self.cube.position = (0, 1 + self.blend_space.sample("height", self.time), 0)
137
138        state = "running" if self.player.playing else "held"
139        self.hud.text = (
140            f"Blend {self.blend:.2f}   Colour {self.player.current_clip} ({state})   Peaks {self.peaks}\n"
141            "Up/Down or drag: blend   Space or tap: crossfade"
142        )
143
144
145if __name__ == "__main__":
146    App(title="Animation Blend", width=1280, height=720).run(BlendDemo())