Skeleton2D

bone hierarchies, rest poses and two-bone IK.

▶ Run in browser

Tags: 2d skeleton bones animation ik procedural

A Skeleton2D groups a hierarchy of Bone2D nodes. A bone is a Node2D with a bone_length, so a child bone placed at (parent.bone_length, 0) starts at its parent’s tip, and anything else parented to a bone rides it: here every limb is a tapered Polygon2D child and the skull a disc on the neck, and no code ever moves them. The whole figure is built from the RIG table, one row per bone.

A bone has two rotation channels:

  • rotation is the authored local angle. bone.set_as_rest() captures the current transform as the rest pose, and skeleton.reset_to_rest() snaps every bone back to it.

  • bone_angle is a pose offset layered on top, so animating it never destroys the rest pose. The idle sway writes it directly each frame, and the wave is an AnimationClip with a bone_angle track played by an AnimationPlayer.

The left arm is driven by SkeletonModification2DTwoBoneIK. Build it with the upper and lower bone indices (skeleton.find_bone_index(name)), register it with skeleton.add_modification(ik), and set ik.target to a world position: the skeleton solves it every frame, keeping the hand on the orbiting red target while the spine sways underneath. ik.flip mirrors the elbow. The gizmo overlay draws each bone from its world_position to its bone_tip, the world-space end of the bone, and the status line uses bone_tip to show how close the hand is.

Controls (each also has a button): Space: wave the right arm G: show or hide the bone gizmos F: flip the IK elbow R: reset the skeleton to its rest pose

Source

  1# /// simvx
  2# tags = ["2d", "skeleton", "bones", "animation", "ik", "procedural"]
  3# web = { width = 800, height = 600, responsive = true }
  4# ///
  5"""Skeleton2D: bone hierarchies, rest poses and two-bone IK.
  6
  7A `Skeleton2D` groups a hierarchy of `Bone2D` nodes. A bone is a `Node2D` with a
  8`bone_length`, so a child bone placed at `(parent.bone_length, 0)` starts at its
  9parent's tip, and anything else parented to a bone rides it: here every limb is
 10a tapered `Polygon2D` child and the skull a disc on the neck, and no code ever
 11moves them. The whole figure is built from the `RIG` table, one row per bone.
 12
 13A bone has two rotation channels:
 14
 15- `rotation` is the authored local angle. `bone.set_as_rest()` captures the
 16  current transform as the rest pose, and `skeleton.reset_to_rest()` snaps
 17  every bone back to it.
 18- `bone_angle` is a pose offset layered on top, so animating it never destroys
 19  the rest pose. The idle sway writes it directly each frame, and the wave is an
 20  `AnimationClip` with a `bone_angle` track played by an `AnimationPlayer`.
 21
 22The left arm is driven by `SkeletonModification2DTwoBoneIK`. Build it with the
 23upper and lower bone indices (`skeleton.find_bone_index(name)`), register it
 24with `skeleton.add_modification(ik)`, and set `ik.target` to a world position:
 25the skeleton solves it every frame, keeping the hand on the orbiting red target
 26while the spine sways underneath. `ik.flip` mirrors the elbow. The gizmo overlay
 27draws each bone from its `world_position` to its `bone_tip`, the world-space end
 28of the bone, and the status line uses `bone_tip` to show how close the hand is.
 29
 30Controls (each also has a button):
 31    Space: wave the right arm
 32    G: show or hide the bone gizmos
 33    F: flip the IK elbow
 34    R: reset the skeleton to its rest pose
 35"""
 36
 37import math
 38from typing import NamedTuple
 39
 40from simvx.core import (
 41    Anchor,
 42    AnimationClip,
 43    AnimationPlayer,
 44    Bone2D,
 45    Button,
 46    CanvasLayer,
 47    HBoxContainer,
 48    Key,
 49    Label,
 50    Node2D,
 51    Polygon2D,
 52    Skeleton2D,
 53    SkeletonModification2DTwoBoneIK,
 54    VBoxContainer,
 55    Vec2,
 56    on_input,
 57)
 58from simvx.graphics import App
 59
 60LIMB = (0.62, 0.68, 0.86, 1.0)
 61TORSO = (0.42, 0.52, 0.78, 1.0)
 62SKIN = (0.92, 0.78, 0.62, 1.0)
 63
 64
 65class BoneSpec(NamedTuple):
 66    name: str
 67    parent: str | None
 68    angle: float  # degrees, relative to the parent bone
 69    length: float
 70    root_w: float  # limb width at the joint
 71    tip_w: float  # limb width at the far end
 72    attach: float = 1.0  # fraction along the parent bone where this bone starts
 73    colour: tuple = LIMB
 74
 75
 76# Angles are screen-space: 0 points right and +90 points down, so the spine at
 77# -90 points up and every child angle is read relative to its parent.
 78RIG = (
 79    BoneSpec("spine", None, -90, 130, 48, 34, colour=TORSO),
 80    BoneSpec("neck", "spine", 0, 30, 18, 16, colour=SKIN),
 81    BoneSpec("arm_r", "spine", 118, 78, 18, 14, attach=0.84),
 82    BoneSpec("forearm_r", "arm_r", 34, 70, 14, 10),
 83    BoneSpec("arm_l", "spine", -118, 78, 18, 14, attach=0.84),
 84    BoneSpec("forearm_l", "arm_l", -34, 70, 14, 10),
 85    BoneSpec("thigh_r", None, 72, 92, 24, 18),
 86    BoneSpec("shin_r", "thigh_r", 20, 86, 18, 12),
 87    BoneSpec("thigh_l", None, 108, 92, 24, 18),
 88    BoneSpec("shin_l", "thigh_l", -20, 86, 18, 12),
 89)
 90
 91DESIGN_HEIGHT = 600  # the rig is authored for a window this tall and scales with it
 92WAVE_SECONDS = 1.6
 93SWAY_SPEED = 1.7
 94# The IK target orbits here in skeleton space, inside the arm's reach. A target
 95# out of reach is not an error: the solver stretches the arm straight toward it.
 96TARGET_CENTRE = Vec2(-84, -72)
 97TARGET_RADIUS = 42.0
 98TARGET_ORBIT = 3.0  # seconds per revolution
 99
100
101def disc(radius: float, segments: int = 24) -> list[tuple[float, float]]:
102    step = math.tau / segments
103    return [(math.cos(i * step) * radius, math.sin(i * step) * radius) for i in range(segments)]
104
105
106def limb(spec: BoneSpec) -> list[tuple[float, float]]:
107    """A tapered quad from the bone's origin to its tip."""
108    return [(0, -spec.root_w / 2), (spec.length, -spec.tip_w / 2), (spec.length, spec.tip_w / 2), (0, spec.root_w / 2)]
109
110
111def wave_player(bone: Bone2D, keys: list[float]) -> AnimationPlayer:
112    """An AnimationPlayer whose "wave" clip sweeps one bone's pose offset and returns to rest."""
113    clip = AnimationClip("wave", duration=WAVE_SECONDS)
114    clip.add_track("bone_angle", [(WAVE_SECONDS * i / (len(keys) - 1), v) for i, v in enumerate(keys)])
115    player = bone.add_child(AnimationPlayer(target=bone))
116    player.add_clip(clip)
117    return player
118
119
120class BoneGizmos(Node2D):
121    """Draws every bone of a skeleton as a line from its origin to its tip."""
122
123    dynamic = True
124
125    def __init__(self, skeleton: Skeleton2D, **kwargs):
126        super().__init__(**kwargs)
127        self.skeleton = skeleton
128
129    def on_draw(self, renderer):
130        for bone in self.skeleton.bones:
131            renderer.line(bone.world_position, bone.bone_tip, stroke=(0.15, 0.95, 0.75, 1.0), width=2)
132            renderer.circle(bone.world_position, 4, fill=(1.0, 0.85, 0.25, 1.0))
133
134
135class SkeletonDemo(Node2D):
136    dynamic = True
137    input_context = {"wave": [Key.SPACE], "gizmos": [Key.G], "flip": [Key.F], "reset": [Key.R]}
138
139    def on_ready(self):
140        self.t = 0.0
141        self.skeleton = self.add_child(Skeleton2D())
142        self.bones: dict[str, Bone2D] = {}
143        for spec in RIG:
144            parent = self.bones.get(spec.parent, self.skeleton)
145            offset = (parent.bone_length * spec.attach, 0) if spec.parent else (0, 0)
146            rotation = math.radians(spec.angle)
147            bone = parent.add_child(Bone2D(name=spec.name, bone_length=spec.length, position=offset, rotation=rotation))
148            bone.set_as_rest()
149            bone.add_child(Polygon2D(polygon=limb(spec), colour=spec.colour))
150            self.bones[spec.name] = bone
151        neck = self.bones["neck"]
152        neck.add_child(Polygon2D(position=(neck.bone_length, 0), polygon=disc(27), colour=SKIN))
153
154        self.target = self.skeleton.add_child(Polygon2D(polygon=disc(9), colour=(1.0, 0.42, 0.32, 1.0), z_index=1))
155        self.ik = SkeletonModification2DTwoBoneIK(
156            upper_bone_index=self.skeleton.find_bone_index("arm_l"),
157            lower_bone_index=self.skeleton.find_bone_index("forearm_l"),
158        )
159        self.skeleton.add_modification(self.ik)
160
161        self.wave_players = [
162            wave_player(self.bones["arm_r"], [0.0, -1.35, -0.95, -1.35, 0.0]),
163            wave_player(self.bones["forearm_r"], [0.0, -0.75, -0.15, -0.75, 0.0]),
164        ]
165        self.gizmos = self.add_child(BoneGizmos(self.skeleton, z_index=10))
166
167        hud = self.add_child(CanvasLayer())
168        header = hud.add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=12))
169        header.add_child(Label("Skeleton2D", font_size=28))
170        self.status = header.add_child(Label(""))
171        buttons = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
172        buttons.add_child(Button("Wave", action="wave"))
173        self.gizmo_button = buttons.add_child(Button("Gizmos", action="gizmos", checked=True))
174        buttons.add_child(Button("Flip elbow", action="flip"))
175        buttons.add_child(Button("Rest pose", action="reset"))
176
177    @property
178    def waving(self) -> bool:
179        return self.wave_players[0].playing
180
181    @on_input("wave")
182    def wave(self, _event):
183        if not self.waving:
184            for player in self.wave_players:
185                player.play("wave")
186
187    @on_input("gizmos")
188    def toggle_gizmos(self, _event):
189        self.gizmos.visible = not self.gizmos.visible
190        self.gizmo_button.checked = self.gizmos.visible
191
192    @on_input("flip")
193    def flip(self, _event):
194        self.ik.flip = not self.ik.flip
195
196    @on_input("reset")
197    def reset(self, _event):
198        for player in self.wave_players:
199            player.stop()
200        self.skeleton.reset_to_rest()
201        self.t = 0.0
202
203    def on_update(self, dt):
204        w, h = self.viewport_size
205        self.skeleton.position = (w * 0.44, h * 0.55)
206        self.skeleton.scale = (h / DESIGN_HEIGHT, h / DESIGN_HEIGHT)
207        self.t += dt
208
209        # Idle sway on the pose channel. The right arm yields it to the wave clip
210        # while that plays; the left arm takes none, because the IK owns it.
211        sway = math.sin(self.t * SWAY_SPEED)
212        self.bones["spine"].bone_angle = sway * 0.055
213        self.bones["neck"].bone_angle = -sway * 0.09
214        if not self.waving:
215            self.bones["arm_r"].bone_angle = sway * 0.12
216            self.bones["forearm_r"].bone_angle = sway * 0.16
217
218        a = self.t * math.tau / TARGET_ORBIT
219        self.target.position = TARGET_CENTRE + Vec2(math.cos(a), math.sin(a)) * TARGET_RADIUS
220        self.ik.target = self.target.world_position
221
222        gap = math.dist(self.bones["forearm_l"].bone_tip, self.ik.target)
223        self.status.text = (
224            f"{self.skeleton.bone_count} bones   hand to target: {gap:.1f}px   "
225            f"elbow: {'flipped' if self.ik.flip else 'normal'}   {'waving' if self.waving else 'idle sway'}"
226        )
227
228
229if __name__ == "__main__":
230    App(title="Skeleton2D", width=800, height=600).run(SkeletonDemo())