// Перенос анимации скелета пользователя на риг SkinTokens. // skin-tokens.cpp переводит позу покоя в мировые оси (повороты костей — единичные, переводы — в метрах, без // промежуточных узлов вроде Armature с масштабом 0.01 из Blender), а ключи анимации копирует из исходника как есть — // из-за этого они не сходятся с новой позой покоя. Здесь анимация пересчитывается: для каждой кости и кадра берётся её // мировое движение относительно покоя в исходнике D = W(кадр)·W(покой)⁻¹ (весь путь от корня сцены, со всеми // узлами), переводится в координаты рига (равномерный масштаб и сдвиг, найденные по положениям суставов) и // прикладывается к позе покоя рига. Скиннинг W·IBM = D — меш двигается ровно как исходный. /* eslint-disable @typescript-eslint/no-explicit-any */ type Json = Record; 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(); sNodes.forEach((n, i) => (n.children ?? []).forEach((c: number) => parentOf.set(c, i))); const byName = new Map(); 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(); 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(); 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(); 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(); // `${узел}/${path}` const times = new Set(); 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(), rots = new Map(); 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(); 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; }