Files
sprite-ai/server/anim.ts
T
2026-09-30 07:38:21 +03:00

231 lines
12 KiB
TypeScript

// Перенос анимации скелета пользователя на риг SkinTokens.
// skin-tokens.cpp переводит позу покоя в мировые оси (повороты костей — единичные, переводы — в метрах, без
// промежуточных узлов вроде Armature с масштабом 0.01 из Blender), а ключи анимации копирует из исходника как есть —
// из-за этого они не сходятся с новой позой покоя. Здесь анимация пересчитывается: для каждой кости и кадра берётся её
// мировое движение относительно покоя в исходнике D = W(кадр)·W(покой)⁻¹ (весь путь от корня сцены, со всеми
// узлами), переводится в координаты рига (равномерный масштаб и сдвиг, найденные по положениям суставов) и
// прикладывается к позе покоя рига. Скиннинг W·IBM = D — меш двигается ровно как исходный.
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
type M4 = Float64Array; // 4×4, по столбцам (как в glTF)
type V3 = [number, number, number];
type Q = [number, number, number, number]; // x, y, z, w
const ident = (): M4 => { const m = new Float64Array(16); m[0] = m[5] = m[10] = m[15] = 1; return m; };
function mul(a: M4, b: M4): M4 {
const r = new Float64Array(16);
for (let c = 0; c < 4; c++) for (let row = 0; row < 4; row++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + row] * b[c * 4 + k];
r[c * 4 + row] = s;
}
return r;
}
function compose(t: V3, q: Q, s: V3): M4 {
const [x, y, z, w] = q;
const m = new Float64Array(16);
m[0] = (1 - 2 * (y * y + z * z)) * s[0]; m[1] = 2 * (x * y + z * w) * s[0]; m[2] = 2 * (x * z - y * w) * s[0];
m[4] = 2 * (x * y - z * w) * s[1]; m[5] = (1 - 2 * (x * x + z * z)) * s[1]; m[6] = 2 * (y * z + x * w) * s[1];
m[8] = 2 * (x * z + y * w) * s[2]; m[9] = 2 * (y * z - x * w) * s[2]; m[10] = (1 - 2 * (x * x + y * y)) * s[2];
m[12] = t[0]; m[13] = t[1]; m[14] = t[2]; m[15] = 1;
return m;
}
/** Обратная для аффинной матрицы общего вида (3×3 + перенос). */
function inv(m: M4): M4 {
const [a, b, c, d, e, f, g, h, i] = [m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]];
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
const det = a * A + b * B + c * C;
const r = new Float64Array(16);
const k = 1 / det;
// строки обратной 3×3 → в столбцовый порядок
const n = [A * k, -(b * i - c * h) * k, (b * f - c * e) * k, B * k, (a * i - c * g) * k, -(a * f - c * d) * k,
C * k, -(a * h - b * g) * k, (a * e - b * d) * k];
r[0] = n[0]; r[4] = n[1]; r[8] = n[2]; r[1] = n[3]; r[5] = n[4]; r[9] = n[5]; r[2] = n[6]; r[6] = n[7]; r[10] = n[8];
r[12] = -(r[0] * m[12] + r[4] * m[13] + r[8] * m[14]);
r[13] = -(r[1] * m[12] + r[5] * m[13] + r[9] * m[14]);
r[14] = -(r[2] * m[12] + r[6] * m[13] + r[10] * m[14]);
r[15] = 1;
return r;
}
/** Перенос и поворот (масштаб отбрасывается — у костей рига его нет). */
function decompose(m: M4): { t: V3; q: Q } {
const sx = Math.hypot(m[0], m[1], m[2]), sy = Math.hypot(m[4], m[5], m[6]), sz = Math.hypot(m[8], m[9], m[10]);
const r = [m[0] / sx, m[1] / sx, m[2] / sx, m[4] / sy, m[5] / sy, m[6] / sy, m[8] / sz, m[9] / sz, m[10] / sz];
const [m00, m10, m20, m01, m11, m21, m02, m12, m22] = r;
const tr = m00 + m11 + m22;
let q: Q;
if (tr > 0) { const s = Math.sqrt(tr + 1) * 2; q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, 0.25 * s]; }
else if (m00 > m11 && m00 > m22) { const s = Math.sqrt(1 + m00 - m11 - m22) * 2; q = [0.25 * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s]; }
else if (m11 > m22) { const s = Math.sqrt(1 + m11 - m00 - m22) * 2; q = [(m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m02 - m20) / s]; }
else { const s = Math.sqrt(1 + m22 - m00 - m11) * 2; q = [(m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s]; }
const n = Math.hypot(...q);
return { t: [m[12], m[13], m[14]], q: q.map(x => x / n) as Q };
}
function slerp(a: Q, b: Q, t: number): Q {
let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const bb = d < 0 ? b.map(x => -x) as Q : b;
d = Math.abs(d);
if (d > 0.9995) {
const r = a.map((x, i) => x + (bb[i] - x) * t) as Q;
const n = Math.hypot(...r);
return r.map(x => x / n) as Q;
}
const th = Math.acos(d), s = Math.sin(th);
const wa = Math.sin((1 - t) * th) / s, wb = Math.sin(t * th) / s;
return a.map((x, i) => x * wa + bb[i] * wb) as Q;
}
/** Данные аксессора как массив чисел (float или нормализованные целые). */
function accessor(json: Json, bin: Buffer, index: number): { data: number[]; size: number; count: number } {
const acc = json.accessors[index];
const view = json.bufferViews[acc.bufferView];
const size = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }[acc.type as string] ?? 1;
const comp = acc.componentType as number;
const bytes = { 5126: 4, 5125: 4, 5123: 2, 5122: 2, 5121: 1, 5120: 1 }[comp] ?? 4;
const stride = view.byteStride ?? size * bytes;
const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
const data: number[] = [];
for (let i = 0; i < acc.count; i++) for (let k = 0; k < size; k++) {
const o = base + i * stride + k * bytes;
let v = comp === 5126 ? bin.readFloatLE(o) : comp === 5125 ? bin.readUInt32LE(o) : comp === 5123 ? bin.readUInt16LE(o)
: comp === 5122 ? bin.readInt16LE(o) : comp === 5121 ? bin.readUInt8(o) : bin.readInt8(o);
if (acc.normalized) v = comp === 5123 ? v / 65535 : comp === 5122 ? Math.max(v / 32767, -1) : comp === 5121 ? v / 255 : Math.max(v / 127, -1);
data.push(v);
}
return { data, size, count: acc.count };
}
interface Track { times: number[]; values: number[]; size: number; interp: string }
function sample(tr: Track, t: number): number[] {
const { times, values, size } = tr;
const cubic = tr.interp === 'CUBICSPLINE';
const at = (i: number) => (cubic ? values.slice((i * 3 + 1) * size, (i * 3 + 2) * size) : values.slice(i * size, (i + 1) * size));
if (t <= times[0]) return at(0);
if (t >= times[times.length - 1]) return at(times.length - 1);
let i = 0;
while (times[i + 1] < t) i++;
const a = at(i), b = at(i + 1);
if (tr.interp === 'STEP') return a;
const u = (t - times[i]) / (times[i + 1] - times[i]);
if (size === 4) return slerp(a as Q, b as Q, u);
return a.map((x, k) => x + (b[k] - x) * u);
}
/**
* Анимации исходного скелета (src) как новые анимации для суставов рига (rig — JSON результата SkinTokens, суставы
* сопоставляются по имени). Возвращает glTF animations + данные аксессоров; addAccessor добавляет их в итоговый GLB.
*/
export function retargetAnimations(
src: { json: Json; bin: Buffer }, rig: Json, rigJoints: number[],
addAccessor: (values: number[], type: 'SCALAR' | 'VEC3' | 'VEC4', minMax?: boolean) => number,
nodeOffset: number,
): Json[] {
const sj = src.json;
const sNodes: Json[] = sj.nodes;
const parentOf = new Map<number, number>();
sNodes.forEach((n, i) => (n.children ?? []).forEach((c: number) => parentOf.set(c, i)));
const byName = new Map<string, number>();
sNodes.forEach((n, i) => { if (n.name) byName.set(n.name, i); });
const pairs = rigJoints.map(j => [j, byName.get(rig.nodes[j].name)] as const).filter((p): p is readonly [number, number] => p[1] !== undefined);
if (pairs.length < 2) return [];
const restLocal = (i: number): M4 => {
const n = sNodes[i];
if (n.matrix) return Float64Array.from(n.matrix);
return compose(n.translation ?? [0, 0, 0], n.rotation ?? [0, 0, 0, 1], n.scale ?? [1, 1, 1]);
};
const world = (local: (i: number) => M4) => {
const cache = new Map<number, M4>();
const w = (i: number): M4 => {
let m = cache.get(i);
if (!m) {
const p = parentOf.get(i);
m = p === undefined ? local(i) : mul(w(p), local(i));
cache.set(i, m);
}
return m;
};
return w;
};
const sRest = world(restLocal);
// поза покоя рига: суставы без промежуточных узлов (skin-tokens.cpp), мировые матрицы по иерархии суставов
const rParent = new Map<number, number>();
rigJoints.forEach(j => (rig.nodes[j].children ?? []).forEach((c: number) => rParent.set(c, j)));
const rLocal = (j: number) => compose(rig.nodes[j].translation ?? [0, 0, 0], rig.nodes[j].rotation ?? [0, 0, 0, 1], rig.nodes[j].scale ?? [1, 1, 1]);
const rRestCache = new Map<number, M4>();
const rRest = (j: number): M4 => {
let m = rRestCache.get(j);
if (!m) { const p = rParent.get(j); m = p === undefined ? rLocal(j) : mul(rRest(p), rLocal(j)); rRestCache.set(j, m); }
return m;
};
// исходник → риг: равномерный масштаб и сдвиг по положениям суставов (МНК)
const ps = pairs.map(([, s]) => [sRest(s)[12], sRest(s)[13], sRest(s)[14]]);
const pr = pairs.map(([r]) => [rRest(r)[12], rRest(r)[13], rRest(r)[14]]);
const mean = (a: number[][]) => [0, 1, 2].map(k => a.reduce((s, v) => s + v[k], 0) / a.length);
const ms = mean(ps), mr = mean(pr);
let num = 0, den = 0;
ps.forEach((p, i) => { for (let k = 0; k < 3; k++) { num += (p[k] - ms[k]) * (pr[i][k] - mr[k]); den += (p[k] - ms[k]) ** 2; } });
const sc = den > 0 ? num / den : 1;
const F = compose([mr[0] - sc * ms[0], mr[1] - sc * ms[1], mr[2] - sc * ms[2]], [0, 0, 0, 1], [sc, sc, sc]);
const Finv = inv(F);
const out: Json[] = [];
for (const anim of (sj.animations ?? []) as Json[]) {
const tracks = new Map<string, Track>(); // `${узел}/${path}`
const times = new Set<number>();
for (const ch of anim.channels as Json[]) {
if (!['translation', 'rotation', 'scale'].includes(ch.target?.path) || ch.target.node === undefined) continue;
const smp = anim.samplers[ch.sampler];
const input = accessor(sj, src.bin, smp.input).data;
const output = accessor(sj, src.bin, smp.output);
input.forEach(t => times.add(t));
tracks.set(`${ch.target.node}/${ch.target.path}`, { times: input, values: output.data, size: output.size, interp: smp.interpolation ?? 'LINEAR' });
}
const T = [...times].sort((a, b) => a - b);
if (!T.length) continue;
const trans = new Map<number, number[]>(), rots = new Map<number, number[]>();
for (const [r] of pairs) { trans.set(r, []); rots.set(r, []); }
for (const t of T) {
const frameLocal = (i: number): M4 => {
const n = sNodes[i];
const tr = tracks.get(`${i}/translation`), ro = tracks.get(`${i}/rotation`), s = tracks.get(`${i}/scale`);
if (!tr && !ro && !s) return restLocal(i);
return compose((tr ? sample(tr, t) : n.translation ?? [0, 0, 0]) as V3, (ro ? sample(ro, t) : n.rotation ?? [0, 0, 0, 1]) as Q,
(s ? sample(s, t) : n.scale ?? [1, 1, 1]) as V3);
};
const sFrame = world(frameLocal);
const rFrame = new Map<number, M4>();
for (const [r, s] of pairs) {
const D = mul(mul(F, mul(sFrame(s), inv(sRest(s)))), Finv); // мировое движение сустава, в координатах рига
rFrame.set(r, mul(D, rRest(r)));
}
for (const [r] of pairs) {
const p = rParent.get(r);
const parentW = p === undefined ? ident() : rFrame.get(p) ?? rRest(p);
const { t: tt, q } = decompose(mul(inv(parentW), rFrame.get(r)!));
trans.get(r)!.push(...tt);
rots.get(r)!.push(...q);
}
}
const input = addAccessor(T, 'SCALAR', true);
const samplers: Json[] = [], channels: Json[] = [];
for (const [r] of pairs) {
samplers.push({ input, output: addAccessor(trans.get(r)!, 'VEC3'), interpolation: 'LINEAR' });
channels.push({ sampler: samplers.length - 1, target: { node: r + nodeOffset, path: 'translation' } });
samplers.push({ input, output: addAccessor(rots.get(r)!, 'VEC4'), interpolation: 'LINEAR' });
channels.push({ sampler: samplers.length - 1, target: { node: r + nodeOffset, path: 'rotation' } });
}
out.push({ ...(anim.name ? { name: anim.name } : {}), samplers, channels });
}
return out;
}