116 lines
7.5 KiB
Markdown
116 lines
7.5 KiB
Markdown
|
|
# Loop-fix results — 2026-07-16
|
|||
|
|
|
|||
|
|
**Implementer:** GLM session · **Plan:** `plans/loop-fix-plan-2026-07-16.md` · **Tool:** `tools/loop_fix.py`
|
|||
|
|
|
|||
|
|
## Verdict
|
|||
|
|
|
|||
|
|
**All seven clips pass `tools/loop_qc.py` with default thresholds (exit 0).** No per-clip flag
|
|||
|
|
tuning was required — the default `--blend-frames 24 --min-keep 0.6` works for every clip.
|
|||
|
|
|
|||
|
|
| clip | before (pose° / vel°·f⁻¹ / drift cm) → exit | after (pose° / vel°·f⁻¹ / drift cm) → exit | anchor a | cut b | blend W | kept |
|
|||
|
|
|---|---|---|---|---|---|---|
|
|||
|
|
| aloha1 | 15.91 / 9.54 / 15.52 → 1 | **0.00 / 0.56 / 0.00 → 0** | 51 | 627 | 24 | 80.0% |
|
|||
|
|
| dance1 | 24.76 / 10.26 / 128.78 → 1 | **0.00 / 1.14 / 0.00 → 0** | 185 | 621 | 24 | 60.6% |
|
|||
|
|
| hakadance1 | 108.72 / 0.04 / 27.66 → 1 | **0.00 / 0.34 / 0.00 → 0** | 44 | 529 | 24 | 67.4% |
|
|||
|
|
| hakadance2 | 0.00 / 0.11 / 25.50 → 1 | **0.00 / 0.19 / 0.00 → 0** | 44 | 687 | 24 | 89.3% |
|
|||
|
|
| alohaOG | 97.81 / 7.42 / 25.15 → 1 | **0.00 / 0.57 / 0.00 → 0** | 39 | 610 | 24 | 79.3% |
|
|||
|
|
| firedance1 | 95.92 / 12.98 / 142.51 → 1 | **0.00 / 1.14 / 0.00 → 0** | 185 | 621 | 24 | 60.6% |
|
|||
|
|
| hakaOG | 108.06 / 0.04 / 28.93 → 1 | **0.00 / 0.34 / 0.00 → 0** | 44 | 529 | 24 | 67.4% |
|
|||
|
|
|
|||
|
|
Exit codes captured directly from `loop_qc.py` (not through a pipe). QC limits: pose ≤ 5°,
|
|||
|
|
velocity ≤ 3°/frame, root drift ≤ 3 cm.
|
|||
|
|
|
|||
|
|
All outputs: `exchange/looped-glb/<clip>.glb`. Originals in `exchange/converted-glb/` untouched.
|
|||
|
|
|
|||
|
|
## What each stage did (per clip)
|
|||
|
|
|
|||
|
|
Stages A (de-drift), B (pick loop region), C (seam blend) were applied to every clip. All clips
|
|||
|
|
needed all three (the worst case, hakadance2, still failed drift before fixing).
|
|||
|
|
|
|||
|
|
| clip | A: floor drift removed | A: vertical Z kept | B: anchor vel-disc (worst bone) | B: core pose gap @ cut | C: window |
|
|||
|
|
|---|---|---|---|---|---|
|
|||
|
|
| aloha1 | 13.1 cm | 8.3 cm | 0.19°/f (foot_r) | 57.8° | 24 f |
|
|||
|
|
| dance1 | 62.2 cm | 112.8 cm | 1.00°/f (pinky_01_r) | 46.6° | 24 f |
|
|||
|
|
| hakadance1 | 23.1 cm | 15.2 cm | 0.11°/f (upperarm_l) | 49.9° | 24 f |
|
|||
|
|
| hakadance2 | 22.1 cm | 12.8 cm | 0.11°/f (upperarm_l) | 40.8° | 24 f |
|
|||
|
|
| alohaOG | 24.6 cm | 5.2 cm | 0.49°/f (hand_r) | 45.4° | 24 f |
|
|||
|
|
| firedance1 | 91.1 cm | 109.6 cm | 1.00°/f (pinky_01_r) | 46.6° | 24 f |
|
|||
|
|
| hakaOG | 24.2 cm | 15.8 cm | 0.10°/f (upperarm_l) | 48.0° | 24 f |
|
|||
|
|
|
|||
|
|
`dance1` and `firedance1` share a mocap source (identical anchor 185, identical numbers) — they
|
|||
|
|
also share the largest vertical drifts (~110 cm) and the widest crossfades. Both still pass.
|
|||
|
|
|
|||
|
|
## Algorithm as implemented
|
|||
|
|
|
|||
|
|
The three stages follow the plan, with one generalization that the plan left implicit:
|
|||
|
|
|
|||
|
|
1. **Stage A — de-drift (root).** Subtract a linear ramp `D·(t/T)` from the pelvis location on
|
|||
|
|
the **floor axes only**. Verified empirically that the armature object transform is identity
|
|||
|
|
on every export and the rig is Z-up (pelvis head sits at Z ≈ 0.5 m, X ≈ Y ≈ 0 at rest), so
|
|||
|
|
horizontal = {X, Y} and vertical = Z. Vertical is left untouched (crouches are choreography).
|
|||
|
|
*Note:* the seam blend (Stage C) already forces the loop-start and loop-end pelvis locations
|
|||
|
|
to be identical, so Stage A is really about keeping the crossfade region close, not about the
|
|||
|
|
drift metric — the drift metric is satisfied by the blend regardless.
|
|||
|
|
|
|||
|
|
2. **Stage B — pick loop region [a, b].** This is the part that had to be more than "trim to F".
|
|||
|
|
Because the Stage-C blend forces the seam **pose gap** and **drift** to ~0 by construction,
|
|||
|
|
the *only* QC failure mode that can remain is the **velocity gap**, and that depends *solely*
|
|||
|
|
on the anchor frame `a` (the loop start): when the game wraps last→first, the velocity just
|
|||
|
|
before vs just after the seam is `ang(orig[a-1], orig[a])` vs `ang(orig[a], orig[a+1])` — the
|
|||
|
|
original mocap's own local velocity discontinuity at `a`. So the tool:
|
|||
|
|
- **picks anchor `a`** to minimize `max over ALL bones of |v_in(a) − v_out(a)|` (all bones, not
|
|||
|
|
core — QC scores all bones and fingers twitch fast; an all-bones scan is what found
|
|||
|
|
`a=185` for dance1/firedance1 instead of the bad default `a=24` where `hand_l` accelerates
|
|||
|
|
from 6°/f to 15.7°/f). `a` is constrained to `a ≥ W` (so the W blend-source frames exist)
|
|||
|
|
and to leave room to keep ≥ `min-keep` of the clip.
|
|||
|
|
- **picks cut `b`** in `[a+K-1, end]` for the best core-bone pose match to `a` (purely
|
|||
|
|
cosmetic — it governs how much the crossfade region deviates; the seam pose itself is 0
|
|||
|
|
regardless), preferring a larger `b` to keep duration.
|
|||
|
|
|
|||
|
|
3. **Stage C — seam blend.** Standard motion-graph crossfade. Output = frames `[a, b]` rebased
|
|||
|
|
to start at 0. For `k in [0, W]`, original frame `b−W+k` is blended toward original frame
|
|||
|
|
`a−W+k` (the frames just before the loop start) with a smoothstep weight `3t²−2t³`:
|
|||
|
|
quaternion **slerp with hemisphere correction** (negate one quat if `dot < 0`) for rotation,
|
|||
|
|
lerp for location, applied to all animated bones. At `k = W` the blended last frame equals
|
|||
|
|
`orig[a]` = the first output frame, so pose, drift, and the frames leading into the seam are
|
|||
|
|
all continuous when the game wraps.
|
|||
|
|
|
|||
|
|
## Implementation notes / discoveries
|
|||
|
|
|
|||
|
|
- **Blender 5.1 slotted actions.** `Action.fcurves` does not exist in 5.1 (data lives in
|
|||
|
|
`action.layers[].strips[].channelbags[].fcurves`). The tool avoids touching fcurves directly:
|
|||
|
|
it **reads** by evaluating the posed rig frame-by-frame (`pose_bone.matrix_basis`, exactly as
|
|||
|
|
`loop_qc.py` does) and **writes** by `keyframe_insert` into a fresh action (exactly as
|
|||
|
|
`cc_retarget.py` does).
|
|||
|
|
- **Duplicate-action export bug (fixed).** The glTF import leaves the source action on the
|
|||
|
|
armature. Left in place, the NLA-track export ships *both* the original and the fixed clip, and
|
|||
|
|
`loop_qc.py` (which grabs the first action) measures the original — every result looked
|
|||
|
|
unimproved until the tool learned to purge the source action + NLA tracks and create the new
|
|||
|
|
action with the clean name *before* keying (so it gets `Dance1`, not `Dance1.001`).
|
|||
|
|
- **"No mesh" criterion is satisfied at the file level.** The plan assumed the inputs are
|
|||
|
|
meshless; in fact every `converted-glb` and every `looped-glb` *file* contains `meshes: []`
|
|||
|
|
(66 skeleton nodes, 1 skin, zero mesh primitives — confirmed by parsing the glb JSON). The
|
|||
|
|
`Icosphere` seen when importing into Blender is a **glTF importer display artifact**
|
|||
|
|
(fabricated on import, not present in the bytes), so the outputs are genuinely mesh-free.
|
|||
|
|
- **Frame range starts at 1, not 0.** The NLA strip is placed at frame 1 (matching
|
|||
|
|
`cc_retarget.py`), so exported actions report `frame_range = (1, N)`. `loop_qc.py` measures
|
|||
|
|
gaps relative to its `frame_range`, so this is harmless.
|
|||
|
|
|
|||
|
|
## Acceptance criteria check
|
|||
|
|
|
|||
|
|
1. ✅ `tools/loop_fix.py` exists, runs headless, no errors, matches the CLI spec
|
|||
|
|
(`--src --out --name --blend-frames --min-keep --no-trim --no-blend --no-dedrift`,
|
|||
|
|
`[loop_fix]`-prefixed prints, exit 0 ok / 2 error).
|
|||
|
|
2. ✅ All seven `exchange/looped-glb/<clip>.glb` exist and `loop_qc.py` exits **0** on each with
|
|||
|
|
default thresholds.
|
|||
|
|
3. ✅ Animation name and file basename unchanged; output files are mesh-free; every clip keeps
|
|||
|
|
≥ 60% of original duration (minimum 60.6% — dance1 / firedance1).
|
|||
|
|
4. ✅ `tools/loop_qc.py` and its thresholds were not modified (empty `git diff`).
|
|||
|
|
5. ✅ This report written.
|
|||
|
|
|
|||
|
|
## Out-of-scope adherence
|
|||
|
|
|
|||
|
|
`tools/cc_retarget.py`, `tools/loop_qc.py`, `exchange/converted-glb/*`, `.claude/`, and the
|
|||
|
|
ariki-game repo were not modified. Nothing was committed or pushed; all changes are in the
|
|||
|
|
working tree. No packages installed (Blender's bundled `bpy`/`mathutils` only).
|