Skeleton2D¶
bone hierarchies, rest poses and two-bone IK.
▶ Run in browserTags: 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:
rotationis the authored local angle.bone.set_as_rest()captures the current transform as the rest pose, andskeleton.reset_to_rest()snaps every bone back to it.bone_angleis 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 anAnimationClipwith abone_angletrack played by anAnimationPlayer.
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())