From 6529316bd96ac2f7b4716318da5f7e1f35520533 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 10:21:40 +0200 Subject: [PATCH] Dance director: multi-clip GLBs switch moves in time with the music GLBs can carry several animation clips (merged from Mixamo FBX files with scripts/merge via Blender NLA tracks). The director loops the current move and switches to a random different one every 16 beats (or ~14s without beats) with a 0.45s pose crossfade (per-node quaternion nlerp + translation lerp). Co-Authored-By: Claude Fable 5 --- src/model3d.js | 51 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/model3d.js b/src/model3d.js index 56efd5d..1cc0638 100644 --- a/src/model3d.js +++ b/src/model3d.js @@ -535,10 +535,9 @@ class ModelSim { // `targets` (base bone name -> world-space direction) an extra local // rotation is solved per bone so that its chain child points along the // target — FK retargeting of the tracked body onto the Mixamo rig. - // Sample the first animation clip at time tt (looping): node -> {r?, t?}. - _sampleAnim(tt) { - const a = this.anims[0]; - const T = tt % a.dur; + // Sample an animation clip at time tt (looping): node -> {r?, t?}. + _sampleAnim(a, tt) { + const T = ((tt % a.dur) + a.dur) % a.dur; const out = {}; for (const ch of a.channels) { const times = ch.times; @@ -563,6 +562,48 @@ class ModelSim { return out; } + // Dance director for multi-clip GLBs: plays the current move in loop and + // switches to the next one every 16 beats (or ~14s without beats) with a + // short pose crossfade, so a set of Mixamo dances follows the music. + _danceDirector(t, speed, beat) { + const d = this._dir || (this._dir = { clip: 0, prev: -1, t0: t, prevT0: 0, + lastSwitch: t, beatN: 0, prevBeat: 0 }); + if (beat > 0.6 && d.prevBeat <= 0.6) d.beatN++; + d.prevBeat = beat; + if (this.anims.length > 1 && + (d.beatN >= 16 || t - d.lastSwitch > 14)) { + d.prev = d.clip; d.prevT0 = d.t0; + let next = Math.floor(Math.random()*this.anims.length); + if (next === d.clip) next = (next + 1) % this.anims.length; + d.clip = next; d.t0 = t; d.lastSwitch = t; d.beatN = 0; + } + const cur = this._sampleAnim(this.anims[d.clip], (t - d.t0)*speed); + const FADE = 0.45; + const f = (t - d.lastSwitch)/FADE; + if (f >= 1 || d.prev < 0) return cur; + // crossfade with the previous move + const old = this._sampleAnim(this.anims[d.prev], (t - d.prevT0)*speed); + const w = f*f*(3 - 2*f); + for (const ni in old) { + const o = old[ni], c = cur[ni] || (cur[ni] = {}); + if (o.r) { + if (!c.r) c.r = o.r; + else { + const sg = (o.r[0]*c.r[0] + o.r[1]*c.r[1] + o.r[2]*c.r[2] + o.r[3]*c.r[3]) < 0 ? -1 : 1; + const q = [o.r[0] + (c.r[0]*sg - o.r[0])*w, o.r[1] + (c.r[1]*sg - o.r[1])*w, + o.r[2] + (c.r[2]*sg - o.r[2])*w, o.r[3] + (c.r[3]*sg - o.r[3])*w]; + const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1; + c.r = [q[0]/l, q[1]/l, q[2]/l, q[3]/l]; + } + } + if (o.t) { + if (!c.t) c.t = o.t; + else c.t = [o.t[0] + (c.t[0]-o.t[0])*w, o.t[1] + (c.t[1]-o.t[1])*w, o.t[2] + (c.t[2]-o.t[2])*w]; + } + } + return cur; + } + // Beat-locked procedural dance for rigged models without animation clips: // arms pump on alternate beats, hips sway, head nods, knees bounce. A // free-running metronome keeps the groove between detected beats. @@ -871,7 +912,7 @@ class ModelSim { if (skinnedLive) { curJoints = this._computeJoints(pose.skinTargets); } else if (this.anims) { - curJoints = this._computeJoints(null, this._sampleAnim(timeSec*speed)); + curJoints = this._computeJoints(null, this._danceDirector(timeSec, speed, beat)); } else { const dance = this._danceTargets(timeSec, beat, bass); curJoints = this._computeJoints(dance.targets);