Skip to content

Commit c7a077b

Browse files
committed
feat(render): squad spline sampling with per-phase timing modes
1 parent f69ccf0 commit c7a077b

2 files changed

Lines changed: 96 additions & 15 deletions

File tree

packages/posecode-render/src/timeline.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
*/
99

1010
import * as THREE from "three";
11-
import type { PosecodeIR, ReachTarget, PinTarget } from "posecode-parser";
11+
import type { PosecodeIR, ReachTarget, PinTarget, TimingMode } from "posecode-parser";
1212
import { poseFor, type PoseSpec } from "./poses.js";
13+
import { squad, squadControl } from "./squad.js";
1314

1415
const DEG = Math.PI / 180;
1516

1617
type EulerDegTuple = [number, number, number];
17-
type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
1818

1919
interface Keyframe {
2020
time: number;
2121
name: string;
2222
cue?: string;
23-
easing: Easing;
23+
easing: TimingMode;
2424
quats: Map<string, THREE.Quaternion>;
2525
groundLock: string[];
2626
reaches: ReachTarget[];
@@ -71,11 +71,27 @@ function eulerToQuat([x, y, z]: EulerDegTuple): THREE.Quaternion {
7171
);
7272
}
7373

74-
const EASE: Record<Easing, (t: number) => number> = {
74+
/**
75+
* Per-mode remap of the normalized segment parameter (arrival shaping). `flow`
76+
* and `linear` are even; `settle`/`snap` decelerate into rest; `drive`
77+
* accelerates from rest. The spline (squad) carries velocity across keyframes;
78+
* this only shapes the timing within a segment.
79+
*/
80+
const MODE_EASE: Record<TimingMode, (t: number) => number> = {
81+
flow: (t) => t,
82+
settle: (t) => 1 - (1 - t) * (1 - t),
83+
drive: (t) => t * t,
84+
snap: (t) => 1 - (1 - t) * (1 - t) * (1 - t),
7585
linear: (t) => t,
76-
"ease-in": (t) => t * t,
77-
"ease-out": (t) => 1 - (1 - t) * (1 - t),
78-
"ease-in-out": (t) => t * t * (3 - 2 * t),
86+
};
87+
88+
/** A keyframe is a rest-point (zero boundary velocity) for these modes. */
89+
const REST_MODE: Record<TimingMode, boolean> = {
90+
flow: false,
91+
settle: true,
92+
drive: false,
93+
snap: true,
94+
linear: false,
7995
};
8096

8197
export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
@@ -96,7 +112,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
96112
keyframes.push({
97113
time: 0,
98114
name: ir.startPose ?? "start",
99-
easing: "linear",
115+
easing: "flow",
100116
quats: snapshot(curr),
101117
groundLock: [],
102118
reaches: [],
@@ -139,7 +155,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
139155
keyframes.push({
140156
time: t,
141157
name: "reset",
142-
easing: "ease-in-out",
158+
easing: "flow",
143159
quats: snapshot(new Map(baseJoints)),
144160
groundLock: [],
145161
reaches: [],
@@ -179,23 +195,40 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
179195
travelExtent,
180196
sample(time, bones) {
181197
const tt = duration > 0 ? ((time % duration) + duration) % duration : 0;
198+
let i = 0;
182199
let a = keyframes[0]!;
183200
let b = keyframes[keyframes.length - 1]!;
184-
for (let i = 0; i < keyframes.length - 1; i++) {
185-
if (tt >= keyframes[i]!.time && tt < keyframes[i + 1]!.time) {
186-
a = keyframes[i]!;
187-
b = keyframes[i + 1]!;
201+
for (let k = 0; k < keyframes.length - 1; k++) {
202+
if (tt >= keyframes[k]!.time && tt < keyframes[k + 1]!.time) {
203+
i = k;
204+
a = keyframes[k]!;
205+
b = keyframes[k + 1]!;
188206
break;
189207
}
190208
}
191209
const span = Math.max(1e-6, b.time - a.time);
192210
const local = THREE.MathUtils.clamp((tt - a.time) / span, 0, 1);
193-
const eased = EASE[b.easing](local);
211+
const eased = MODE_EASE[b.easing](local);
194212

213+
// Neighbors for the squad control quaternions (clamp at the ends → the
214+
// segment endpoint itself, giving a one-sided tangent).
215+
const kPrev = keyframes[Math.max(0, i - 1)]!;
216+
const kNext = keyframes[Math.min(keyframes.length - 1, i + 2)]!;
195217
for (const bone of bonesUsed) {
196218
const node = bones.get(bone);
197219
if (!node) continue;
198-
node.quaternion.slerpQuaternions(a.quats.get(bone)!, b.quats.get(bone)!, eased);
220+
const q0 = a.quats.get(bone)!;
221+
const q1 = b.quats.get(bone)!;
222+
// A rest-point keyframe uses its own value as the control (zero tangent
223+
// → the spline comes to / leaves from rest there); otherwise the
224+
// Shoemake control from the neighboring keyframe carries velocity.
225+
const s0 = REST_MODE[a.easing]
226+
? q0.clone()
227+
: squadControl(kPrev.quats.get(bone)!, q0, q1);
228+
const s1 = REST_MODE[b.easing]
229+
? q1.clone()
230+
: squadControl(q0, q1, kNext.quats.get(bone)!);
231+
squad(q0, s0, s1, q1, eased, node.quaternion);
199232
}
200233
// Root facing/position: linear interpolation of the raw values so a large
201234
// turn (e.g. 360°) sweeps the whole way round rather than taking a short

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,3 +703,51 @@ describe("cobra", () => {
703703
expect(Math.abs(pelvisUp - pelvisFlat)).toBeLessThan(0.05);
704704
});
705705
});
706+
707+
describe("spline interpolation (L2)", () => {
708+
it("interpolates joints with continuous velocity through an interior keyframe", () => {
709+
const src = [
710+
'posecode exercise "flowy"',
711+
" rig humanoid",
712+
' step "A" 1s flow:',
713+
" shoulders: flex 40",
714+
' step "B" 1s flow:',
715+
" shoulders: flex 120",
716+
' step "C" 1s flow:',
717+
" shoulders: flex 40",
718+
].join("\n");
719+
const { ir } = parse(src);
720+
const tl = buildTimeline(ir!);
721+
const m = buildMannequin();
722+
const read = (t: number) => {
723+
tl.sample(t, m.bones);
724+
return m.bones.get("shoulder_left")!.quaternion.clone();
725+
};
726+
const eps = 1e-3;
727+
const kf = 2; // end of "B" is an interior keyframe (t=2)
728+
const vBefore = read(kf).angleTo(read(kf - eps)) / eps;
729+
const vAfter = read(kf + eps).angleTo(read(kf)) / eps;
730+
expect(Math.abs(vBefore - vAfter)).toBeLessThan(0.3); // flow carries velocity
731+
});
732+
733+
it("settle brings a joint to rest at its keyframe", () => {
734+
const src = [
735+
'posecode exercise "rest"',
736+
" rig humanoid",
737+
' step "Down" 1s settle:',
738+
" knees: flex 90",
739+
' step "Up" 1s drive:',
740+
" knees: flex 0",
741+
].join("\n");
742+
const { ir } = parse(src);
743+
const tl = buildTimeline(ir!);
744+
const m = buildMannequin();
745+
const read = (t: number) => {
746+
tl.sample(t, m.bones);
747+
return m.bones.get("knee_left")!.quaternion.clone();
748+
};
749+
const eps = 1e-3;
750+
const v = read(1).angleTo(read(1 - eps)) / eps; // velocity arriving at the settle kf
751+
expect(v).toBeLessThan(0.2);
752+
});
753+
});

0 commit comments

Comments
 (0)