Skip to content

Commit 2b25abc

Browse files
committed
docs: L2 spline-quaternion interpolation design spec
1 parent 07fb4a2 commit 2b25abc

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# L2 — Spline-Quaternion Interpolation (Design)
2+
3+
**Date:** 2026-07-11
4+
**Status:** Approved (design), pending implementation plan
5+
**Sub-project:** Layer 2 of the 5-layer animation-naturalness program
6+
**Program order:** **L2** → L3 (post-IK) → L4 (secondary motion) → L1 (mocap clips) → L5 (library upgrade)
7+
8+
---
9+
10+
## 1. Context and motivation
11+
12+
Posecode is a text-to-motion system: an LLM writes a `.posecode` document of phases
13+
and joint angles, the parser produces a `PosecodeIR`, and `posecode-render` plays it
14+
on a mannequin. Unlike Mixamo (dense recorded motion capture), posecode has roughly
15+
**one keyframe per phase** and interpolates between them.
16+
17+
Today's interpolation (`packages/posecode-render/src/timeline.ts`, `sample()`) finds the
18+
two bracketing keyframes and runs an **independent** per-segment slerp:
19+
20+
```ts
21+
const eased = EASE[b.easing](local);
22+
node.quaternion.slerpQuaternions(a.quats.get(bone)!, b.quats.get(bone)!, eased);
23+
```
24+
25+
Because every segment eases independently, **angular velocity resets to ~zero at every
26+
interior keyframe**: each phase accelerates from rest and decelerates back to rest. The
27+
figure visibly *hits a sequence of mannequin poses* instead of moving through them. This
28+
is the textbook "robotic" case:
29+
30+
- Research: spherical **spline** quaternion interpolation is perceived as *significantly
31+
more natural* than linear-Euler or plain slerp
32+
([Perceived Naturalness of Interpolation Methods, Springer](https://link.springer.com/chapter/10.1007/978-3-030-90439-5_9)).
33+
- Animation principle: natural movement follows arcs and carries momentum; ignoring this
34+
reads as mechanical ([12 Principles of Animation](https://pixune.com/blog/12-principles-of-animation/)).
35+
36+
**Goal:** make motion *flow* through interior keyframes (C1-continuous velocity) while
37+
still allowing deliberate pauses, and expose that control in the DSL and editor. This is a
38+
vertical slice: render engine **and** `.posecode` language **and** editor tooling.
39+
40+
### Non-goals (deferred)
41+
42+
- Arced *translational* effector paths beyond what joint-space splines already produce
43+
(YAGNI for L2; joint-space squad already arcs the limbs).
44+
- Contact correction / foot-lock / grip (that is **L3**).
45+
- Additive secondary motion — arm swing, follow-through (that is **L4**).
46+
- Bulk rewrite of the 73 library documents to exploit `flow` (that is **L5**).
47+
48+
---
49+
50+
## 2. Approach
51+
52+
### 2.1 Interpolation method: squad (spherical quadratic)
53+
54+
Adopt **squad** (Shoemake's spherical-and-quadrangle quaternion spline). For each interior
55+
keyframe it derives an intermediate control quaternion from the keyframe's two neighbors,
56+
then interpolates each segment as a quadrangle blend of the two endpoints and their two
57+
controls. The result is **C1-continuous** across interior keyframes — velocity carries
58+
through — and passes **exactly through every keyframe** (so authored poses are unchanged at
59+
keyframe times).
60+
61+
Rejected alternatives:
62+
- *Three.js cubic `QuaternionKeyframeTrack`* — couples the timeline to THREE's mixer, and
63+
cubic interpolation on raw quaternion components is not truly spherical (needs
64+
renormalization, can shorten/overshoot).
65+
- *Log-quaternion Catmull-Rom* — equivalent result to squad with more moving parts.
66+
67+
Squad is the exact method the naturalness research validates, is self-contained, and
68+
composes with the existing per-keyframe `Map<boneId, Quaternion>` representation.
69+
70+
**Root motion:** root yaw and travel (`timeline.ts:203`, currently linear) receive the same
71+
C1 smoothing (scalar Catmull-Rom on the yaw and x/z tracks) so the whole body moves as one.
72+
Yaw keeps its existing "sweep the long way for large turns" property (interpolate raw values,
73+
not shortest arc).
74+
75+
### 2.2 Timing modes (DSL + editor)
76+
77+
Replace the `linear | ease-in | ease-out | ease-in-out` easing enum with **timing modes**
78+
that express *boundary velocity* (through-point vs rest-point), not merely curve shape.
79+
This is what lets the spline flow *or* pause per phase. Mode names avoid the existing `hold`
80+
joint-action keyword (`vocab.ts:47`, "keep the joint at its neutral / rest angle").
81+
82+
| Mode | Meaning | Boundary velocity at this keyframe |
83+
|------|---------|-------------------------------------|
84+
| `flow` | Pass through this pose continuously (spline through-point). **Default for flowing sequences.** | Carried (C1) |
85+
| `settle` | Decelerate to a genuine rest — the deliberate pause (squat bottom, plank hold, rep top). | Zero (ease to rest) |
86+
| `drive` | Accelerate from rest — the concentric effort ("drive up"). | Zero on entry, carried on exit |
87+
| `snap` | Fast, near-immediate arrival — an accent / pop. | Fast arrival, then rest |
88+
| `linear` | Constant velocity — intentionally mechanical. | Constant |
89+
90+
A phase's mode governs the **arrival** at that phase's keyframe; the squad tangents combine a
91+
keyframe's own mode with its neighbors' so that, e.g., `flow → flow` carries velocity while
92+
`… → settle → drive …` produces a clean rest-then-push (a rep).
93+
94+
### 2.3 Migration — zero breakage
95+
96+
`EASINGS` is a zod enum (`schema.ts:14`) validated at parse time, and all 73 library
97+
documents plus the spec examples use the old four names. Therefore:
98+
99+
- The old four names remain **accepted as deprecated aliases**, resolved at parse time to a
100+
mode:
101+
- `linear``linear`
102+
- `ease-in``drive`
103+
- `ease-out``settle`
104+
- `ease-in-out``settle`
105+
- Aliased docs keep their **current stop-at-each-pose feel** (a `settle`/`drive` mapping
106+
reproduces the existing independent-ease behavior at boundaries), so L2 is a
107+
**non-regression** for every existing move.
108+
- The editor surfaces a **deprecation diagnostic** (hint severity) nudging authors to the
109+
new modes, with a suggested replacement.
110+
- The intentional per-move switch to `flow` (the actual naturalness win for existing moves)
111+
is done deliberately in **L5**, not as a risky bulk rewrite in L2.
112+
113+
---
114+
115+
## 3. Components and boundaries
116+
117+
Each unit has one purpose, a clear interface, and is independently testable.
118+
119+
### 3.1 `packages/posecode-render/src/squad.ts` (new)
120+
121+
- **Purpose:** pure quaternion-spline math, no timeline/DSL knowledge.
122+
- **Interface (proposed):**
123+
- `squad(q0, qa, qb, q1, t): Quaternion` — quadrangle blend for one segment given the two
124+
endpoints (`q0`,`q1`) and their control quaternions (`qa`,`qb`).
125+
- `control(prev, cur, next): Quaternion` — Shoemake intermediate control for a keyframe.
126+
- Helpers `slerpUnit`, `logMap`/`expMap` as needed, kept private.
127+
- **Depends on:** `three` only.
128+
- **Boundary test:** given three keyframes, the angular velocity sampled just before and
129+
just after the middle keyframe is continuous (equal within tolerance); the current slerp
130+
path fails this test.
131+
132+
### 3.2 `packages/posecode-render/src/timeline.ts` (modified)
133+
134+
- **Purpose:** build the keyframe list (unchanged) and sample it with squad + mode-derived
135+
boundary velocities; smooth root yaw/travel.
136+
- **Change:** `sample()` selects the segment as today, but computes the pose from
137+
`squad(...)` using the neighbor keyframes for controls, honoring each keyframe's timing
138+
mode for boundary velocity. The `EASE` table is replaced by a mode→tangent policy.
139+
- **Invariant preserved:** at exact keyframe times, the sampled pose equals the authored
140+
keyframe pose (so `render.test.ts` keyframe-time assertions stay green).
141+
142+
### 3.3 `packages/posecode-parser` (modified)
143+
144+
- `schema.ts`: `EASINGS``MODES = ["flow","settle","drive","snap","linear"]`; accept
145+
legacy names via a preprocessing alias map before the enum (or a superset enum + a
146+
normalization step) so old docs validate and normalize to a canonical mode.
147+
- `types.ts`: rename `Easing``TimingMode` (keep a deprecated `Easing` type alias exported
148+
for one release to avoid breaking downstream imports), update `Phase`.
149+
- `parser.ts`: resolve the mode token, emit the canonical mode, and flag legacy tokens for a
150+
deprecation diagnostic.
151+
152+
### 3.4 `packages/posecode-language` + `packages/posecode-lsp` (modified)
153+
154+
- `vocab.ts`: export `MODES`; add `KEYWORD_DOCS` for each mode.
155+
- completion / hover: offer modes with docs; still offer legacy names but marked deprecated.
156+
- `diagnostics.ts`: deprecation warning for legacy mode tokens with a suggested replacement;
157+
unknown mode → error with "did you mean" suggestion.
158+
- `tmLanguage` (syntax highlight) and LSP `convert.ts` kind: recognize the new mode tokens.
159+
160+
### 3.5 Documents
161+
162+
- Update **2–3 flagship moves** to the new modes as live demonstrations of `flow` (e.g. a
163+
multi-phase flowing move like a dance phrase or jumping-jacks, plus one that legitimately
164+
`settle`s like squat). The remaining 70 stay on aliases until L5.
165+
166+
---
167+
168+
## 4. Data flow
169+
170+
```
171+
.posecode text
172+
→ tokenizer → parser (resolves mode token, records legacy→canonical + deprecation flag)
173+
→ PosecodeIR (Phase.mode: TimingMode)
174+
→ buildTimeline() (keyframes carry mode)
175+
→ sample(t): pick segment → squad(prev,a,b,next; mode-derived tangents) → bone quats
176+
→ Catmull-Rom root yaw / travel
177+
→ viewer applies contact solving (unchanged in L2) → render
178+
```
179+
180+
Editor path: parser diagnostics + vocab feed completion/hover/highlight; deprecation hints
181+
render inline.
182+
183+
---
184+
185+
## 5. Error handling
186+
187+
- **Unknown mode token:** parse error, message lists valid modes and a "did you mean"
188+
nearest match (existing diagnostics style).
189+
- **Legacy mode token:** parses successfully, normalizes to canonical mode, emits a
190+
deprecation diagnostic (hint) with the recommended replacement.
191+
- **Degenerate keyframe sequences:** squad needs neighbors for tangents. Endpoints (first/
192+
last keyframe) use one-sided tangents; a lone segment (2 keyframes) falls back to slerp.
193+
Identical adjacent quaternions produce zero-length tangents → fall back to slerp for that
194+
segment (no NaNs).
195+
- **Numerical safety:** all control/log/exp results renormalized; guard `acos`/`sin` domain
196+
as in the existing IK/slerp code.
197+
198+
---
199+
200+
## 6. Testing (TDD)
201+
202+
Write tests first (they should fail against the current slerp), then implement squad.
203+
204+
1. **Velocity continuity (new, RED first):** three keyframes `flow`; sample angular velocity
205+
(finite-difference) just before and after the interior keyframe; assert continuity within
206+
tolerance. Current slerp fails; squad passes.
207+
2. **Keyframe pass-through:** at each keyframe time the sampled pose equals the authored pose
208+
(protects existing `render.test.ts` assertions and the eval harness).
209+
3. **Settle = rest:** a `settle` keyframe has ~zero angular velocity at its boundary.
210+
4. **Alias mapping:** `ease-in→drive`, `ease-out→settle`, `ease-in-out→settle`,
211+
`linear→linear`; aliased docs parse and render without regression.
212+
5. **Deprecation diagnostic:** legacy token yields a hint with the correct suggested mode;
213+
unknown token yields an error.
214+
6. **Existing suites stay green:** parser, render, eval, language, lsp.
215+
7. **Coverage:** maintain the project's ≥80% bar for changed packages.
216+
217+
Manual verification: load a flowing multi-phase move in the playground before/after and
218+
confirm the stop-start cadence is gone (browser preview + screenshot).
219+
220+
---
221+
222+
## 7. Risks
223+
224+
- **Overshoot:** squad can overshoot on sharp direction reversals. Mitigation: mode-derived
225+
tangents damp velocity at `settle`/`snap`; add a tangent-magnitude clamp if a move visibly
226+
overshoots past its authored ROM (the ROM clamp is authored-time, not sample-time, so a
227+
spline could momentarily exceed it — clamp sampled quats back into ROM if needed, decided
228+
during implementation with a test).
229+
- **Downstream `Easing` import breakage:** mitigated by keeping a deprecated exported type
230+
alias for one release.
231+
- **Scope creep into L3/L4:** contact/secondary motion explicitly out of scope here.
232+
233+
---
234+
235+
## 8. Definition of done
236+
237+
- Squad sampler implemented; velocity-continuity and pass-through tests pass.
238+
- New timing modes in schema/types/parser; legacy aliases + deprecation diagnostics.
239+
- Editor completion/hover/highlight/LSP updated for modes.
240+
- 2–3 flagship moves updated as `flow`/`settle` demos.
241+
- All existing test suites green; coverage ≥80% on changed packages.
242+
- Before/after playground verification captured.

0 commit comments

Comments
 (0)