muse-engine-32 / engine /anim /skeleton-pose.js
axonis22's picture
Muse Engine 32: your FBX/GLB scene as the level, skinned characters, Model check, save/load setup
6c78aa4 verified
Raw History Blame Contribute Delete
12.7 kB
// engine/anim/skeleton-pose.js - written by Muse Glimmer (Phase 31, part B, 27 Sep 2026; raw output in game/muse-authored/phase31/raw/),
// reviewed by Grok Bot (coach) with game/muse-authored/phase31/harness/. Coach fixes are marked [coach fix P31-n], see FIXES-P31.md.
window.MuseSkeletonPose = (function () {
function createPose(N) {
var t = new Float32Array(3 * N);
var r = new Float32Array(4 * N);
var s = new Float32Array(3 * N);
for (var i = 0; i < N; i++) {
r[4 * i + 3] = 1;
s[3 * i] = 1;
s[3 * i + 1] = 1;
s[3 * i + 2] = 1;
}
return { t: t, r: r, s: s };
}
function restPose(skeleton, out) {
var n = skeleton.nodes;
var N = n.length;
var t = out.t,
r = out.r,
s = out.s;
for (var i = 0; i < N; i++) {
var nd = n[i];
t[3 * i] = nd.t[0];
t[3 * i + 1] = nd.t[1];
t[3 * i + 2] = nd.t[2];
r[4 * i] = nd.r[0];
r[4 * i + 1] = nd.r[1];
r[4 * i + 2] = nd.r[2];
r[4 * i + 3] = nd.r[3];
s[3 * i] = nd.s[0];
s[3 * i + 1] = nd.s[1];
s[3 * i + 2] = nd.s[2];
}
}
function lerp(a, b, u) {
return a + (b - a) * u;
}
function slerp(qa, qb, u) {
var x1 = qa[0],
y1 = qa[1],
z1 = qa[2],
w1 = qa[3];
var x2 = qb[0],
y2 = qb[1],
z2 = qb[2],
w2 = qb[3];
var dot = x1 * x2 + y1 * y2 + z1 * z2 + w1 * w2;
if (dot < 0) {
x2 = -x2;
y2 = -y2;
z2 = -z2;
w2 = -w2;
dot = -dot;
}
if (dot > 0.9995) {
var ix = x1 + (x2 - x1) * u,
iy = y1 + (y2 - y1) * u,
iz = z1 + (z2 - z1) * u,
iw = w1 + (w2 - w1) * u;
var len = Math.sqrt(ix * ix + iy * iy + iz * iz + iw * iw) || 1;
return [ix / len, iy / len, iz / len, iw / len];
}
var theta = Math.acos(Math.min(1, Math.max(-1, dot)));
var sinTheta = Math.sin(theta);
var s0 = Math.sin((1 - u) * theta) / sinTheta;
var s1 = Math.sin(u * theta) / sinTheta;
return [x1 * s0 + x2 * s1, y1 * s0 + y2 * s1, z1 * s0 + z2 * s1, w1 * s0 + w2 * s1];
}
function binarySearch(times, t) {
var lo = 0,
hi = times.length - 1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (times[mid] <= t) lo = mid + 1;
else hi = mid - 1;
}
return hi;
}
function sampleClip(skeleton, clip, time, loop, out) {
restPose(skeleton, out);
var dur = clip.duration;
if (loop) {
time = ((time % dur) + dur) % dur;
} else {
if (time < 0) time = 0;
if (time > dur) time = dur;
}
var N = skeleton.nodes.length;
var t = out.t,
r = out.r,
s = out.s;
for (var i = 0; i < clip.tracks.length; i++) {
var tr = clip.tracks[i];
var node = tr.node;
if (node < 0 || node >= N) continue;
var times = tr.times;
var vals = tr.values;
var path = tr.path;
var interp = tr.interp;
var stride = path === "rotation" ? 4 : 3;
var k = times.length;
if (k === 0) continue;
var idx = binarySearch(times, time);
if (idx < 0) {
idx = 0;
}
if (idx >= k - 1) {
idx = k - 1;
}
var t0 = times[idx];
var t1 = times[Math.min(idx + 1, k - 1)];
var u = 0;
if (t1 > t0) u = (time - t0) / (t1 - t0);
if (u < 0) u = 0; // [coach fix P31-7] before the first key use the first key (was: extrapolated from keys 0-1)
if (u > 1) u = 1;
var base = idx * stride;
var base1 = Math.min(idx + 1, k - 1) * stride;
var off = 3 * node;
if (path === "translation") {
if (interp === "STEP") {
t[off] = vals[base];
t[off + 1] = vals[base + 1];
t[off + 2] = vals[base + 2];
} else {
t[off] = lerp(vals[base], vals[base1], u);
t[off + 1] = lerp(vals[base + 1], vals[base1 + 1], u);
t[off + 2] = lerp(vals[base + 2], vals[base1 + 2], u);
}
} else if (path === "scale") {
if (interp === "STEP") {
s[off] = vals[base];
s[off + 1] = vals[base + 1];
s[off + 2] = vals[base + 2];
} else {
s[off] = lerp(vals[base], vals[base1], u);
s[off + 1] = lerp(vals[base + 1], vals[base1 + 1], u);
s[off + 2] = lerp(vals[base + 2], vals[base1 + 2], u);
}
} else {
var qa = [vals[base], vals[base + 1], vals[base + 2], vals[base + 3]];
var qb = [vals[base1], vals[base1 + 1], vals[base1 + 2], vals[base1 + 3]];
if (interp === "STEP") {
r[4 * node] = qa[0];
r[4 * node + 1] = qa[1];
r[4 * node + 2] = qa[2];
r[4 * node + 3] = qa[3];
} else {
var q = slerp(qa, qb, u);
r[4 * node] = q[0];
r[4 * node + 1] = q[1];
r[4 * node + 2] = q[2];
r[4 * node + 3] = q[3];
}
}
}
}
function blendPoses(a, b, w, out) {
var N = a.t.length / 3;
var ta = a.t,
tb = b.t,
oa = out.t;
var sa = a.s,
sb = b.s,
os = out.s;
var ra = a.r,
rb = b.r,
or = out.r;
var iw = 1 - w;
for (var i = 0; i < N; i++) {
var i3 = i * 3,
i4 = i * 4;
oa[i3] = ta[i3] * iw + tb[i3] * w;
oa[i3 + 1] = ta[i3 + 1] * iw + tb[i3 + 1] * w;
oa[i3 + 2] = ta[i3 + 2] * iw + tb[i3 + 2] * w;
os[i3] = sa[i3] * iw + sb[i3] * w;
os[i3 + 1] = sa[i3 + 1] * iw + sb[i3 + 1] * w;
os[i3 + 2] = sa[i3 + 2] * iw + sb[i3 + 2] * w;
var qa = [ra[i4], ra[i4 + 1], ra[i4 + 2], ra[i4 + 3]];
var qb = [rb[i4], rb[i4 + 1], rb[i4 + 2], rb[i4 + 3]];
var q = slerp(qa, qb, w);
or[i4] = q[0];
or[i4 + 1] = q[1];
or[i4 + 2] = q[2];
or[i4 + 3] = q[3];
}
}
function quatToMat3(q, s) {
var x = q[0],
y = q[1],
z = q[2],
w = q[3];
var xx = x * x,
yy = y * y,
zz = z * z;
var xy = x * y,
xz = x * z,
yz = y * z;
var wx = w * x,
wy = w * y,
wz = w * z;
var sx = s[0],
sy = s[1],
sz = s[2];
return [
(1 - 2 * (yy + zz)) * sx,
2 * (xy - wz) * sy,
2 * (xz + wy) * sz,
2 * (xy + wz) * sx,
(1 - 2 * (xx + zz)) * sy,
2 * (yz - wx) * sz,
2 * (xz - wy) * sx,
2 * (yz + wx) * sy,
(1 - 2 * (xx + yy)) * sz,
];
}
var _m = new Float32Array(16), _res = new Float32Array(16); // [coach fix P31-8] scratch (was 2 new Float32Array per node per frame)
function worldMatrices(skeleton, pose, rootMatrix, out) {
var N = skeleton.nodes.length;
var nodes = skeleton.nodes;
var t = pose.t,
r = pose.r,
s = pose.s;
for (var i = 0; i < N; i++) {
var parent = nodes[i].parent;
var m = _m;
var ti = t[3 * i],
tj = t[3 * i + 1],
tk = t[3 * i + 2];
var si = s[3 * i],
sj = s[3 * i + 1],
sk = s[3 * i + 2];
var q = [r[4 * i], r[4 * i + 1], r[4 * i + 2], r[4 * i + 3]];
var m3 = quatToMat3(q, [si, sj, sk]);
m[0] = m3[0];
m[1] = m3[3];
m[2] = m3[6];
m[3] = 0;
m[4] = m3[1];
m[5] = m3[4];
m[6] = m3[7];
m[7] = 0;
m[8] = m3[2];
m[9] = m3[5];
m[10] = m3[8];
m[11] = 0;
m[12] = ti;
m[13] = tj;
m[14] = tk;
m[15] = 1;
if (parent === -1) {
var rm = rootMatrix;
var res = _res;
for (var row = 0; row < 4; row++)
for (var col = 0; col < 4; col++) {
var sum = 0;
for (var k = 0; k < 4; k++) sum += rm[k * 4 + row] * m[col * 4 + k];
res[col * 4 + row] = sum;
}
out.set(res, i * 16);
} else {
var p = out.subarray(parent * 16, parent * 16 + 16);
var res = _res;
for (var row = 0; row < 4; row++)
for (var col = 0; col < 4; col++) {
var sum = 0;
for (var k = 0; k < 4; k++) sum += p[k * 4 + row] * m[col * 4 + k];
res[col * 4 + row] = sum;
}
out.set(res, i * 16);
}
}
}
function skinPalette(world, boneNodes, inverseBind, bindShape, out) {
var J = boneNodes.length;
var tmp = new Float32Array(16);
for (var j = 0; j < J; j++) {
var node = boneNodes[j];
var w = world.subarray(node * 16, node * 16 + 16);
var ib = inverseBind.subarray(j * 16, j * 16 + 16);
for (var row = 0; row < 4; row++) {
for (var col = 0; col < 4; col++) {
var sum = 0;
for (var k = 0; k < 4; k++) sum += w[k * 4 + row] * ib[col * 4 + k];
tmp[col * 4 + row] = sum;
}
}
var off = 12 * j;
out[off] = tmp[0];
out[off + 1] = tmp[4];
out[off + 2] = tmp[8];
out[off + 3] = tmp[12];
out[off + 4] = tmp[1];
out[off + 5] = tmp[5];
out[off + 6] = tmp[9];
out[off + 7] = tmp[13];
out[off + 8] = tmp[2];
out[off + 9] = tmp[6];
out[off + 10] = tmp[10];
out[off + 11] = tmp[14];
}
}
// [coach fix P31-9] Fader rewritten around Muse's two-layer design. Muse's version: (a) at the end of a fade did
// `cur = nxt; nxt.idx = -1`, aliasing both layers so the character froze after every crossfade; (b) play() with the
// clip already playing started a fade to itself; (c) a new play() during a fade dropped the blended pose (a visible
// pop); (d) state().done read a non-existent field and was never true; (e) allocated two poses per update.
// Now: layer `to` (target) and `from` (a live clip, or a frozen snapshot when a play interrupts a fade).
function Fader(skeleton) {
var N = skeleton.nodes.length;
this.skeleton = skeleton;
this.to = null; // { idx, time, speed, loop, dur }
this.from = null; // { idx, time, speed, loop, dur } or { frozen: true }
this.fadeDur = 0;
this.fadeEl = 0;
this.pa = createPose(N);
this.pb = createPose(N);
this.snap = createPose(N);
this.last = createPose(N);
this.hasLast = false;
}
Fader.prototype.play = function (clipIndex, fadeSeconds, opts) {
opts = opts || {};
if (this.to && this.to.idx === clipIndex) return; // already the target: keep its time
var layer = { idx: clipIndex, time: 0, speed: opts.speed !== undefined ? opts.speed : 1, loop: opts.loop !== undefined ? opts.loop : true, dur: 0 };
var fade = fadeSeconds > 0 ? fadeSeconds : 0;
if (fade > 0 && this.to) {
if (this.from && this.fadeEl < this.fadeDur && this.hasLast) {
// interrupting a fade: the current blended result becomes a frozen source
this.snap.t.set(this.last.t);
this.snap.r.set(this.last.r);
this.snap.s.set(this.last.s);
this.from = { frozen: true };
} else this.from = this.to;
} else this.from = null;
this.to = layer;
this.fadeDur = fade;
this.fadeEl = 0;
};
function advance(l, clips) {
var c = clips[l.idx];
l.dur = c.duration;
l.time += l.dt;
if (!l.loop && l.time > c.duration) l.time = c.duration;
}
Fader.prototype.update = function (dt, clips, outPose) {
if (!this.to) {
restPose(this.skeleton, outPose);
return;
}
this.to.dt = dt * this.to.speed;
advance(this.to, clips);
sampleClip(this.skeleton, clips[this.to.idx], this.to.time, this.to.loop, this.pb);
var w = 1;
if (this.from && this.fadeDur > 0) {
this.fadeEl += dt;
w = Math.min(1, this.fadeEl / this.fadeDur);
}
if (this.from && w < 1) {
var src = this.snap;
if (!this.from.frozen) {
this.from.dt = dt * this.from.speed;
advance(this.from, clips);
sampleClip(this.skeleton, clips[this.from.idx], this.from.time, this.from.loop, this.pa);
src = this.pa;
}
blendPoses(src, this.pb, w, outPose);
} else {
this.from = null;
outPose.t.set(this.pb.t);
outPose.r.set(this.pb.r);
outPose.s.set(this.pb.s);
}
this.last.t.set(outPose.t);
this.last.r.set(outPose.r);
this.last.s.set(outPose.s);
this.hasLast = true;
this.w = w;
};
Fader.prototype.state = function () {
var l = this.to;
return {
clip: l ? l.idx : -1,
time: l ? l.time : 0,
weight: this.from && this.fadeDur > 0 ? Math.min(1, this.fadeEl / this.fadeDur) : 1,
fading: !!(this.from && this.fadeDur > 0 && this.fadeEl < this.fadeDur),
done: !!(l && !l.loop && l.dur > 0 && l.time >= l.dur),
};
};
return {
createPose: createPose,
restPose: restPose,
sampleClip: sampleClip,
blendPoses: blendPoses,
worldMatrices: worldMatrices,
skinPalette: skinPalette,
Fader: Fader,
};
})();