Skip to content

Commit 43827d1

Browse files
committed
Smooth root travel with Hermite interpolation and update choreography
1 parent d2cb5a7 commit 43827d1

12 files changed

Lines changed: 293 additions & 102 deletions

packages/posecode-render/src/timeline.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ const REST_MODE: Record<TimingMode, boolean> = {
106106
linear: false,
107107
};
108108

109+
/** Cubic Hermite interpolation with endpoint velocities expressed per second. */
110+
function hermite(a: number, b: number, va: number, vb: number, span: number, t: number): number {
111+
const t2 = t * t;
112+
const t3 = t2 * t;
113+
return (
114+
(2 * t3 - 3 * t2 + 1) * a +
115+
(t3 - 2 * t2 + t) * span * va +
116+
(-2 * t3 + 3 * t2) * b +
117+
(t3 - t2) * span * vb
118+
);
119+
}
120+
121+
/**
122+
* Time-aware centered velocity at an interior root keyframe. Rest keyframes
123+
* deliberately have zero velocity; flowing keyframes carry momentum through.
124+
*/
125+
function rootVelocity(
126+
prev: Keyframe,
127+
current: Keyframe,
128+
next: Keyframe,
129+
read: (keyframe: Keyframe) => number,
130+
): number {
131+
if (current.rest) return 0;
132+
const span = next.time - prev.time;
133+
return span > 1e-6 ? (read(next) - read(prev)) / span : 0;
134+
}
135+
109136
export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
110137
const basePose = poseFor(ir.startPose);
111138
const baseJoints = new Map<string, EulerDegTuple>(
@@ -248,14 +275,33 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
248275
: squadControl(q0, q1, kNext.quats.get(bone)!);
249276
squad(q0, s0, s1, q1, eased, node.quaternion);
250277
}
251-
// Root facing/position: linear interpolation of the raw values so a large
252-
// turn (e.g. 360°) sweeps the whole way round rather than taking a short
253-
// arc. Uses the same eased param as the joints so everything moves as one.
254-
const rootYaw = a.yaw + (b.yaw - a.yaw) * eased;
255-
const rootOffset = {
256-
x: a.pos.x + (b.pos.x - a.pos.x) * eased,
257-
z: a.pos.z + (b.pos.z - a.pos.z) * eased,
258-
};
278+
// Root facing/position use the scalar analogue of the joint squad spline:
279+
// cubic Hermite with time-aware centered tangents. This carries velocity
280+
// through `flow` waypoints instead of hitting every travel point with a
281+
// visible direction/speed kink (box steps, grapevines, waltz, chassé).
282+
// Yaw remains a raw scalar rather than a quaternion so a 360° turn still
283+
// sweeps the full revolution. Rest points receive zero tangent.
284+
const yawA = rootVelocity(kPrev, a, b, (kf) => kf.yaw);
285+
const yawB = rootVelocity(a, b, kNext, (kf) => kf.yaw);
286+
const xA = rootVelocity(kPrev, a, b, (kf) => kf.pos.x);
287+
const xB = rootVelocity(a, b, kNext, (kf) => kf.pos.x);
288+
const zA = rootVelocity(kPrev, a, b, (kf) => kf.pos.z);
289+
const zB = rootVelocity(a, b, kNext, (kf) => kf.pos.z);
290+
// `linear` is an explicit authoring promise, so preserve a literal
291+
// straight interpolation for that mode. The spline applies to the
292+
// expressive timing modes, especially continuous `flow` choreography.
293+
const rootYaw = b.easing === "linear"
294+
? a.yaw + (b.yaw - a.yaw) * eased
295+
: hermite(a.yaw, b.yaw, yawA, yawB, span, eased);
296+
const rootOffset = b.easing === "linear"
297+
? {
298+
x: a.pos.x + (b.pos.x - a.pos.x) * eased,
299+
z: a.pos.z + (b.pos.z - a.pos.z) * eased,
300+
}
301+
: {
302+
x: hermite(a.pos.x, b.pos.x, xA, xB, span, eased),
303+
z: hermite(a.pos.z, b.pos.z, zA, zB, span, eased),
304+
};
259305
return {
260306
phaseName: b.name,
261307
...(b.cue ? { cue: b.cue } : {}),

packages/posecode-render/test/render.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,38 @@ describe("spatial choreography (turn & travel)", () => {
197197
expect(tl.travelExtent).toBeCloseTo(Math.hypot(0.5, 0.4), 3);
198198
});
199199

200+
it("carries root velocity smoothly through flow travel waypoints", () => {
201+
const src = [
202+
'posecode exercise "Corner"',
203+
" rig humanoid",
204+
" pose start = standing",
205+
' step "Across" 1s flow:',
206+
" travel: 1 0",
207+
' step "Forward" 1s flow:',
208+
" travel: 1 1",
209+
" repeat 1",
210+
].join("\n");
211+
const { ir } = parse(src);
212+
const tl = buildTimeline(ir!);
213+
const m = buildMannequin();
214+
const eps = 1e-3;
215+
const before = tl.sample(1 - eps, m.bones).rootOffset;
216+
const at = tl.sample(1, m.bones).rootOffset;
217+
const after = tl.sample(1 + eps, m.bones).rootOffset;
218+
const velocityBefore = {
219+
x: (at.x - before.x) / eps,
220+
z: (at.z - before.z) / eps,
221+
};
222+
const velocityAfter = {
223+
x: (after.x - at.x) / eps,
224+
z: (after.z - at.z) / eps,
225+
};
226+
227+
expect(at).toEqual({ x: 1, z: 0 });
228+
expect(velocityBefore.x).toBeCloseTo(velocityAfter.x, 2);
229+
expect(velocityBefore.z).toBeCloseTo(velocityAfter.z, 2);
230+
});
231+
200232
it("leaves yaw and offset at home for movements that never turn/travel", () => {
201233
const src = [
202234
'posecode exercise "Curl"',

spec/examples/box-step.posecode

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,49 @@ posecode exercise "Box step"
22
rig humanoid
33
pose start = standing
44

5-
step "Step forward-right" 0.9s flow:
6-
hip_right: flex 35
7-
knee_right: flex 40
8-
ankle_right: plantarflex 40
9-
shoulder_left: flex 25
10-
shoulder_right: extend 15
11-
travel: -0.35 0.35
5+
step "Right foot forward" 0.9s flow:
6+
hip_right: flex 30
7+
knee_right: flex 32
8+
ankle_right: plantarflex 24
9+
shoulder_left: flex 22
10+
shoulder_right: extend 12
11+
travel: 0 0.32
1212
pin: foot_left floor
13-
cue "Step the right foot forward and out to the corner"
13+
cue "Step forward onto the right foot with an easy contralateral arm swing"
1414

15-
step "Close the feet" 0.7s flow:
15+
step "Left foot side" 0.85s flow:
1616
hip_right: flex 0
1717
knee_right: flex 0
1818
ankle_right: plantarflex 0
19-
shoulders: flex 0
20-
travel: -0.35 0.35
21-
ground-lock: feet
22-
cue "Bring the left foot to meet it, standing tall on the corner"
23-
24-
step "Step back-left" 0.9s flow:
25-
hip_left: flex 35
26-
knee_left: flex 40
27-
ankle_left: plantarflex 40
28-
shoulder_right: flex 25
29-
shoulder_left: extend 15
30-
travel: 0 0
19+
hip_left: abduct 24
20+
knee_left: flex 22
21+
ankle_left: plantarflex 20
22+
shoulder_left: flex 0
23+
shoulder_right: flex 20
24+
travel: -0.32 0.32
3125
pin: foot_right floor
32-
cue "Step the left foot back to the start on the diagonal"
26+
cue "Transfer left to the side, keeping the torso lifted"
3327

34-
step "Close home" 0.7s settle:
35-
hip_left: flex 0
28+
step "Right foot back" 0.9s flow:
29+
hip_left: abduct 0
3630
knee_left: flex 0
3731
ankle_left: plantarflex 0
32+
hip_right: extend 18
33+
knee_right: flex 30
34+
ankle_right: plantarflex 24
35+
shoulder_right: flex 0
36+
shoulder_left: flex 22
37+
travel: -0.32 0
38+
pin: foot_left floor
39+
cue "Step back onto the right foot to trace the third side of the box"
40+
41+
step "Left foot closes home" 0.85s settle:
42+
hip_right: extend 0
43+
knee_right: flex 0
44+
ankle_right: plantarflex 0
3845
shoulders: flex 0
3946
travel: 0 0
4047
ground-lock: feet
41-
cue "Close the feet together, back home and ready to repeat"
48+
cue "Close the left foot and settle over both feet, completing the square"
4249

4350
repeat 4

spec/examples/chasse.posecode

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,72 @@ posecode exercise "Chassé"
22
rig humanoid
33
pose start = standing
44

5-
step "Reach & push" 0.8s settle:
6-
hip_right: flex 30
7-
knee_right: flex 25
8-
ankle_right: plantarflex 25
5+
step "Right foot reaches" 0.65s flow:
6+
hip_right: abduct 26
7+
knee_right: flex 20
8+
ankle_right: plantarflex 24
99
ankle_left: plantarflex 20
1010
shoulders: abduct 60
1111
elbows: flex 18
12-
travel: 0.5 0
12+
travel: 0.34 0
1313
pin: foot_left floor
1414
reach: foot_right floor
15-
cue "Reach the lead foot out and push off to travel sideways"
15+
cue "Reach the right foot sideways and push away from the left leg"
1616

17-
step "Gallop close" 0.6s drive:
18-
hip_right: flex 0
17+
step "Left foot chases" 0.5s flow:
18+
hip_right: abduct 0
1919
knee_right: flex 0
2020
ankle_right: plantarflex 0
21-
ankle_left: plantarflex 0
22-
travel: 0.9 0
23-
ground-lock: feet
24-
cue "Chase the trailing foot to the lead: feet meet in the air"
21+
hip_left: abduct 18
22+
knee_left: flex 22
23+
ankle_left: plantarflex 24
24+
travel: 0.66 0
25+
pin: foot_right floor
26+
cue "Let the left foot chase under the body without breaking the sideways flow"
27+
28+
step "Right foot reaches again" 0.65s flow:
29+
hip_left: abduct 0
30+
knee_left: flex 0
31+
ankle_left: plantarflex 18
32+
hip_right: abduct 26
33+
knee_right: flex 20
34+
ankle_right: plantarflex 24
35+
travel: 1 0
36+
pin: foot_left floor
37+
reach: foot_right floor
38+
cue "Reach right once more and finish the outward chassé"
2539

26-
step "Reach & push back" 0.8s settle:
27-
hip_left: flex 30
28-
knee_left: flex 25
29-
ankle_left: plantarflex 25
40+
step "Left foot reaches back" 0.65s flow:
41+
hip_right: abduct 0
42+
knee_right: flex 0
3043
ankle_right: plantarflex 20
31-
travel: 0.4 0
44+
hip_left: abduct 26
45+
knee_left: flex 20
46+
ankle_left: plantarflex 24
47+
travel: 0.66 0
3248
pin: foot_right floor
3349
reach: foot_left floor
34-
cue "Change direction and travel back the other way"
50+
cue "Reverse cleanly and reach the left foot back across the floor"
3551

36-
step "Gallop home" 0.6s drive:
37-
hip_left: flex 0
52+
step "Right foot chases" 0.5s flow:
53+
hip_left: abduct 0
3854
knee_left: flex 0
3955
ankle_left: plantarflex 0
40-
ankle_right: plantarflex 0
56+
hip_right: abduct 18
57+
knee_right: flex 22
58+
ankle_right: plantarflex 24
59+
travel: 0.34 0
60+
pin: foot_left floor
61+
cue "Let the right foot chase under the body on the return"
62+
63+
step "Left foot closes home" 0.7s settle:
64+
hip_right: abduct 0
65+
knee_right: flex 0
66+
ankles: plantarflex 0
4167
shoulders: abduct 0
4268
elbows: flex 0
4369
travel: 0 0
4470
ground-lock: feet
45-
cue "Close home, ready to chassé again"
71+
cue "Close home over both feet and let the momentum resolve"
4672

4773
repeat 4

spec/examples/dance-phrase.posecode

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ posecode exercise "Dance phrase (8-count)"
1313
step "3-4 - demi-plié" 2s flow:
1414
hips: flex 18
1515
knees: flex 50
16-
ankles: plantarflex 50
16+
ankles: dorsiflex 12
1717
shoulders: flex 28
1818
elbows: flex 30
1919
ground-lock: feet
@@ -29,7 +29,7 @@ posecode exercise "Dance phrase (8-count)"
2929
ground-lock: feet
3030
cue "Press up to relevé and lift the arms into a frame overhead"
3131

32-
step "7-8 - close" 2s settle:
32+
step "7-8 - close" 2.2s settle:
3333
ankles: plantarflex 0
3434
hips: rotate-out 0
3535
shoulders: flex 0

spec/examples/demi-plie.posecode

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ posecode exercise "Demi-plié"
22
rig humanoid
33
pose start = standing
44

5-
step "Plié" 2s flow:
5+
step "Plié" 2.2s settle:
66
hips: flex 20
77
hips: rotate-out 30
88
knees: flex 55
@@ -13,7 +13,7 @@ posecode exercise "Demi-plié"
1313
ground-lock: feet
1414
cue "Turn out from the hips and bend the knees over the toes: heels down"
1515

16-
step "Straighten" 2s settle:
16+
step "Straighten" 2.2s settle:
1717
hips: flex 0
1818
hips: rotate-out 0
1919
knees: flex 0

0 commit comments

Comments
 (0)