|
| 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