* feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 * chore: 更新 pnpm-lock.yaml * fix: 移除未使用的变量和方法 * fix: 修复 mesh-3d-editor tsconfig 引用路径 * fix: 修复正则表达式 ReDoS 漏洞
742 lines
28 KiB
JavaScript
742 lines
28 KiB
JavaScript
/**
|
|
* FBX Animation Pipeline Test Script
|
|
* 完整模拟 FBX 动画管线:解析 -> 采样 -> 骨骼矩阵计算
|
|
*/
|
|
|
|
import { readFileSync } from 'fs';
|
|
import pako from 'pako';
|
|
|
|
const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx';
|
|
console.log(`=== FBX Animation Pipeline Test ===\n`);
|
|
console.log(`File: ${filePath}\n`);
|
|
|
|
// ===== FBX Parser =====
|
|
const buffer = readFileSync(filePath);
|
|
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
const version = view.getUint32(23, true);
|
|
const is64Bit = version >= 7500;
|
|
let offset = 27;
|
|
|
|
function readNode() {
|
|
let endOffset, numProperties, propertyListLen, nameLen;
|
|
if (is64Bit) {
|
|
endOffset = Number(view.getBigUint64(offset, true));
|
|
numProperties = Number(view.getBigUint64(offset + 8, true));
|
|
propertyListLen = Number(view.getBigUint64(offset + 16, true));
|
|
nameLen = view.getUint8(offset + 24);
|
|
offset += 25;
|
|
} else {
|
|
endOffset = view.getUint32(offset, true);
|
|
numProperties = view.getUint32(offset + 4, true);
|
|
propertyListLen = view.getUint32(offset + 8, true);
|
|
nameLen = view.getUint8(offset + 12);
|
|
offset += 13;
|
|
}
|
|
if (endOffset === 0) return null;
|
|
|
|
const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen));
|
|
offset += nameLen;
|
|
|
|
const properties = [];
|
|
const propsEnd = offset + propertyListLen;
|
|
while (offset < propsEnd) {
|
|
const typeCode = String.fromCharCode(buffer[offset++]);
|
|
switch (typeCode) {
|
|
case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break;
|
|
case 'C': properties.push(buffer[offset++] !== 0); break;
|
|
case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break;
|
|
case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break;
|
|
case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break;
|
|
case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break;
|
|
case 'S': case 'R': {
|
|
const len = view.getUint32(offset, true); offset += 4;
|
|
properties.push(typeCode === 'S' ? new TextDecoder().decode(buffer.slice(offset, offset + len)) : buffer.slice(offset, offset + len));
|
|
offset += len;
|
|
break;
|
|
}
|
|
case 'f': case 'd': case 'l': case 'i': case 'b': {
|
|
const arrayLen = view.getUint32(offset, true);
|
|
const encoding = view.getUint32(offset + 4, true);
|
|
const compressedLen = view.getUint32(offset + 8, true);
|
|
offset += 12;
|
|
const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4;
|
|
let dataView = view;
|
|
let dataOffset = offset;
|
|
if (encoding === 1) {
|
|
const decompressed = pako.inflate(buffer.slice(offset, offset + compressedLen));
|
|
dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength);
|
|
dataOffset = 0;
|
|
offset += compressedLen;
|
|
} else {
|
|
offset += arrayLen * elemSize;
|
|
}
|
|
const arr = [];
|
|
for (let i = 0; i < arrayLen; i++) {
|
|
if (typeCode === 'd') arr.push(dataView.getFloat64(dataOffset + i * 8, true));
|
|
else if (typeCode === 'f') arr.push(dataView.getFloat32(dataOffset + i * 4, true));
|
|
else if (typeCode === 'l') arr.push(dataView.getBigInt64(dataOffset + i * 8, true));
|
|
else if (typeCode === 'i') arr.push(dataView.getInt32(dataOffset + i * 4, true));
|
|
}
|
|
properties.push({ type: typeCode, data: arr });
|
|
break;
|
|
}
|
|
default: offset = propsEnd;
|
|
}
|
|
}
|
|
|
|
const children = [];
|
|
while (offset < endOffset) {
|
|
const child = readNode();
|
|
if (child) children.push(child);
|
|
else break;
|
|
}
|
|
offset = endOffset;
|
|
return { name, properties, children };
|
|
}
|
|
|
|
// Parse root nodes
|
|
const rootNodes = [];
|
|
while (offset < buffer.length - 100) {
|
|
const node = readNode();
|
|
if (node) rootNodes.push(node);
|
|
else break;
|
|
}
|
|
|
|
const objectsNode = rootNodes.find(n => n.name === 'Objects');
|
|
const connectionsNode = rootNodes.find(n => n.name === 'Connections');
|
|
|
|
// Parse connections
|
|
const connections = connectionsNode.children.map(c => ({
|
|
type: c.properties[0]?.split?.('\0')[0] || c.properties[0],
|
|
fromId: c.properties[1],
|
|
toId: c.properties[2],
|
|
property: c.properties[3]?.split?.('\0')[0]
|
|
}));
|
|
|
|
// Parse Models
|
|
const models = [];
|
|
const modelIdToIndex = new Map();
|
|
for (const node of objectsNode.children) {
|
|
if (node.name === 'Model') {
|
|
const id = node.properties[0];
|
|
const name = node.properties[1]?.split?.('\0')[0] || 'Model';
|
|
const type = node.properties[2]?.split?.('\0')[0] || '';
|
|
|
|
// Parse properties
|
|
let position = [0, 0, 0], rotation = [0, 0, 0], scale = [1, 1, 1], preRotation = null;
|
|
const props70 = node.children.find(c => c.name === 'Properties70');
|
|
if (props70) {
|
|
for (const p of props70.children) {
|
|
if (p.name === 'P') {
|
|
const propName = p.properties[0]?.split?.('\0')[0];
|
|
if (propName === 'Lcl Translation') position = [p.properties[4], p.properties[5], p.properties[6]];
|
|
else if (propName === 'Lcl Rotation') rotation = [p.properties[4], p.properties[5], p.properties[6]];
|
|
else if (propName === 'Lcl Scaling') scale = [p.properties[4], p.properties[5], p.properties[6]];
|
|
else if (propName === 'PreRotation') preRotation = [p.properties[4], p.properties[5], p.properties[6]];
|
|
}
|
|
}
|
|
}
|
|
|
|
modelIdToIndex.set(id, models.length);
|
|
models.push({ id, name, type, position, rotation, scale, preRotation });
|
|
}
|
|
}
|
|
|
|
// Parse Deformers (Clusters)
|
|
const clusters = [];
|
|
for (const node of objectsNode.children) {
|
|
if (node.name === 'Deformer' && node.properties[2]?.split?.('\0')[0] === 'Cluster') {
|
|
const id = node.properties[0];
|
|
const name = node.properties[1]?.split?.('\0')[0] || 'Cluster';
|
|
let transformLink = null;
|
|
for (const child of node.children) {
|
|
if (child.name === 'TransformLink') {
|
|
const arr = child.properties[0]?.data || child.properties[0];
|
|
if (arr && arr.length === 16) {
|
|
transformLink = new Float32Array(arr);
|
|
}
|
|
}
|
|
}
|
|
clusters.push({ id, name, transformLink });
|
|
}
|
|
}
|
|
|
|
// Build cluster -> bone mapping
|
|
// In FBX, Model (bone) -> Cluster connection means the cluster deforms that bone
|
|
const clusterToBone = new Map();
|
|
for (const conn of connections) {
|
|
if (conn.type === 'OO') {
|
|
// Try: cluster is fromId, bone is toId
|
|
let cluster = clusters.find(c => c.id === conn.fromId);
|
|
let boneModel = cluster ? models.find(m => m.id === conn.toId) : null;
|
|
|
|
// Also try: bone is fromId, cluster is toId (reversed)
|
|
if (!cluster || !boneModel) {
|
|
cluster = clusters.find(c => c.id === conn.toId);
|
|
boneModel = cluster ? models.find(m => m.id === conn.fromId) : null;
|
|
}
|
|
|
|
if (cluster && boneModel && boneModel.type === 'LimbNode') {
|
|
clusterToBone.set(cluster.id, {
|
|
clusterId: cluster.id,
|
|
boneModelId: boneModel.id,
|
|
boneModelIndex: modelIdToIndex.get(boneModel.id),
|
|
boneName: boneModel.name
|
|
});
|
|
}
|
|
}
|
|
}
|
|
console.log(`Cluster -> Bone mappings: ${clusterToBone.size}`);
|
|
if (clusterToBone.size === 0) {
|
|
console.log(`WARNING: No cluster-bone mappings found! Checking connection types...`);
|
|
// Debug: show some cluster-related connections
|
|
let count = 0;
|
|
for (const conn of connections) {
|
|
const isClusterFrom = clusters.some(c => c.id === conn.fromId);
|
|
const isClusterTo = clusters.some(c => c.id === conn.toId);
|
|
if (isClusterFrom || isClusterTo) {
|
|
if (count++ < 10) {
|
|
console.log(` [${conn.type}] ${conn.fromId} -> ${conn.toId} (prop: ${conn.property || 'none'})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse AnimationCurveNodes and Curves
|
|
const curveNodes = new Map();
|
|
const curves = new Map();
|
|
|
|
for (const node of objectsNode.children) {
|
|
if (node.name === 'AnimationCurveNode') {
|
|
const id = node.properties[0];
|
|
const name = node.properties[1]?.split?.('\0')[0] || '';
|
|
curveNodes.set(id, { id, name, attribute: name, targetModelId: null, curves: [] });
|
|
}
|
|
if (node.name === 'AnimationCurve') {
|
|
const id = node.properties[0];
|
|
let keyTimes = [], keyValues = [];
|
|
for (const child of node.children) {
|
|
if (child.name === 'KeyTime') {
|
|
const arr = child.properties[0]?.data || child.properties[0];
|
|
keyTimes = arr.map(t => Number(t) / 46186158000);
|
|
}
|
|
if (child.name === 'KeyValueFloat') {
|
|
keyValues = child.properties[0]?.data || child.properties[0];
|
|
}
|
|
}
|
|
curves.set(id, { id, keyTimes, keyValues, componentIndex: 0 });
|
|
}
|
|
}
|
|
|
|
// Link curves to curveNodes and curveNodes to models
|
|
for (const conn of connections) {
|
|
if (conn.type === 'OP') {
|
|
const curveNode = curveNodes.get(conn.fromId);
|
|
if (curveNode && conn.property?.includes('Lcl')) {
|
|
curveNode.targetModelId = conn.toId;
|
|
if (conn.property.includes('Translation')) curveNode.attribute = 'T';
|
|
else if (conn.property.includes('Rotation')) curveNode.attribute = 'R';
|
|
else if (conn.property.includes('Scaling')) curveNode.attribute = 'S';
|
|
}
|
|
}
|
|
if (conn.type === 'OP' || conn.type === 'OO') {
|
|
const curve = curves.get(conn.fromId);
|
|
const curveNode = curveNodes.get(conn.toId);
|
|
if (curve && curveNode) {
|
|
if (conn.property === 'd|X') curve.componentIndex = 0;
|
|
else if (conn.property === 'd|Y') curve.componentIndex = 1;
|
|
else if (conn.property === 'd|Z') curve.componentIndex = 2;
|
|
curveNode.curves.push(curve);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Build Animation Clips =====
|
|
function eulerToQuaternion(rx, ry, rz) {
|
|
const cx = Math.cos(rx / 2), sx = Math.sin(rx / 2);
|
|
const cy = Math.cos(ry / 2), sy = Math.sin(ry / 2);
|
|
const cz = Math.cos(rz / 2), sz = Math.sin(rz / 2);
|
|
return [
|
|
sx * cy * cz - cx * sy * sz,
|
|
cx * sy * cz + sx * cy * sz,
|
|
cx * cy * sz - sx * sy * cz,
|
|
cx * cy * cz + sx * sy * sz
|
|
];
|
|
}
|
|
|
|
function multiplyQuaternion(a, b) {
|
|
return [
|
|
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
|
|
];
|
|
}
|
|
|
|
function sampleCurve(curve, time) {
|
|
const { keyTimes, keyValues } = curve;
|
|
if (!keyTimes.length) return 0;
|
|
if (time <= keyTimes[0]) return keyValues[0];
|
|
if (time >= keyTimes[keyTimes.length - 1]) return keyValues[keyValues.length - 1];
|
|
|
|
for (let i = 0; i < keyTimes.length - 1; i++) {
|
|
if (time >= keyTimes[i] && time <= keyTimes[i + 1]) {
|
|
const t = (time - keyTimes[i]) / (keyTimes[i + 1] - keyTimes[i]);
|
|
return keyValues[i] + (keyValues[i + 1] - keyValues[i]) * t;
|
|
}
|
|
}
|
|
return keyValues[keyValues.length - 1];
|
|
}
|
|
|
|
// Build animation samplers
|
|
const animationSamplers = [];
|
|
const animationChannels = [];
|
|
|
|
for (const [id, cn] of curveNodes) {
|
|
if (!cn.targetModelId || cn.curves.length === 0) continue;
|
|
|
|
const nodeIndex = modelIdToIndex.get(cn.targetModelId);
|
|
if (nodeIndex === undefined) continue;
|
|
|
|
const xCurve = cn.curves.find(c => c.componentIndex === 0);
|
|
const yCurve = cn.curves.find(c => c.componentIndex === 1);
|
|
const zCurve = cn.curves.find(c => c.componentIndex === 2);
|
|
|
|
const refCurve = [xCurve, yCurve, zCurve].filter(Boolean).reduce((a, b) =>
|
|
a.keyTimes.length > b.keyTimes.length ? a : b);
|
|
|
|
const keyCount = refCurve.keyTimes.length;
|
|
const input = refCurve.keyTimes;
|
|
|
|
// Get model for PreRotation
|
|
const model = models[nodeIndex];
|
|
let preRotQuat = null;
|
|
if (model?.preRotation) {
|
|
const [prx, pry, prz] = model.preRotation.map(v => v * Math.PI / 180);
|
|
preRotQuat = eulerToQuaternion(prx, pry, prz);
|
|
}
|
|
|
|
let output, path;
|
|
if (cn.attribute === 'R') {
|
|
path = 'rotation';
|
|
output = new Float32Array(keyCount * 4);
|
|
for (let i = 0; i < keyCount; i++) {
|
|
const t = input[i];
|
|
const rx = (xCurve ? sampleCurve(xCurve, t) : 0) * Math.PI / 180;
|
|
const ry = (yCurve ? sampleCurve(yCurve, t) : 0) * Math.PI / 180;
|
|
const rz = (zCurve ? sampleCurve(zCurve, t) : 0) * Math.PI / 180;
|
|
let q = eulerToQuaternion(rx, ry, rz);
|
|
if (preRotQuat) q = multiplyQuaternion(preRotQuat, q);
|
|
output[i * 4] = q[0]; output[i * 4 + 1] = q[1];
|
|
output[i * 4 + 2] = q[2]; output[i * 4 + 3] = q[3];
|
|
}
|
|
} else if (cn.attribute === 'T') {
|
|
path = 'translation';
|
|
output = new Float32Array(keyCount * 3);
|
|
for (let i = 0; i < keyCount; i++) {
|
|
const t = input[i];
|
|
output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 0;
|
|
output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 0;
|
|
output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 0;
|
|
}
|
|
} else {
|
|
path = 'scale';
|
|
output = new Float32Array(keyCount * 3);
|
|
for (let i = 0; i < keyCount; i++) {
|
|
const t = input[i];
|
|
output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 1;
|
|
output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 1;
|
|
output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 1;
|
|
}
|
|
}
|
|
|
|
const samplerIndex = animationSamplers.length;
|
|
animationSamplers.push({ input: Float32Array.from(input), output });
|
|
animationChannels.push({ samplerIndex, target: { nodeIndex, path } });
|
|
}
|
|
|
|
const duration = Math.max(...animationSamplers.map(s => s.input[s.input.length - 1] || 0));
|
|
|
|
console.log(`=== Animation Data ===`);
|
|
console.log(`Channels: ${animationChannels.length}`);
|
|
console.log(`Duration: ${duration.toFixed(2)}s`);
|
|
|
|
// ===== Build Skeleton =====
|
|
const joints = [];
|
|
const boneModelIdToJointIndex = new Map();
|
|
|
|
for (const cluster of clusters) {
|
|
const boneInfo = clusterToBone.get(cluster.id);
|
|
if (!boneInfo) continue;
|
|
|
|
const nodeIndex = boneInfo.boneModelIndex;
|
|
const model = models[nodeIndex];
|
|
|
|
const jointIndex = joints.length;
|
|
boneModelIdToJointIndex.set(boneInfo.boneModelId, jointIndex);
|
|
|
|
// Invert TransformLink for inverseBindMatrix
|
|
let inverseBindMatrix = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);
|
|
if (cluster.transformLink) {
|
|
inverseBindMatrix = invertMatrix4(cluster.transformLink);
|
|
}
|
|
|
|
joints.push({
|
|
name: model?.name || `Joint_${jointIndex}`,
|
|
nodeIndex,
|
|
parentIndex: -1,
|
|
inverseBindMatrix
|
|
});
|
|
}
|
|
|
|
// Set parent indices
|
|
const modelParentMap = new Map();
|
|
for (const conn of connections) {
|
|
if (conn.type === 'OO') {
|
|
const childIdx = modelIdToIndex.get(conn.fromId);
|
|
const parentIdx = modelIdToIndex.get(conn.toId);
|
|
if (childIdx !== undefined && parentIdx !== undefined) {
|
|
// fromId (child) -> toId (parent)
|
|
const childModel = models[childIdx];
|
|
const parentModel = models[parentIdx];
|
|
if (childModel && parentModel) {
|
|
modelParentMap.set(conn.fromId, conn.toId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < joints.length; i++) {
|
|
const joint = joints[i];
|
|
const boneModelId = [...boneModelIdToJointIndex.entries()].find(([k, v]) => v === i)?.[0];
|
|
if (!boneModelId) continue;
|
|
|
|
let parentModelId = modelParentMap.get(boneModelId);
|
|
while (parentModelId) {
|
|
const parentJointIdx = boneModelIdToJointIndex.get(parentModelId);
|
|
if (parentJointIdx !== undefined) {
|
|
joint.parentIndex = parentJointIdx;
|
|
break;
|
|
}
|
|
parentModelId = modelParentMap.get(parentModelId);
|
|
}
|
|
}
|
|
|
|
console.log(`\n=== Skeleton ===`);
|
|
console.log(`Joints: ${joints.length}`);
|
|
console.log(`First 5 joints:`);
|
|
for (let i = 0; i < Math.min(5, joints.length); i++) {
|
|
const j = joints[i];
|
|
console.log(` [${i}] "${j.name}" nodeIndex=${j.nodeIndex}, parent=${j.parentIndex}`);
|
|
}
|
|
|
|
// Check animation channel targets vs skeleton joint nodeIndices
|
|
const animChannelNodeIndices = new Set(animationChannels.map(c => c.target.nodeIndex));
|
|
const jointNodeIndices = new Set(joints.map(j => j.nodeIndex));
|
|
|
|
console.log(`\n=== Animation vs Skeleton Mapping ===`);
|
|
console.log(`Animation channel target nodes: ${animChannelNodeIndices.size}`);
|
|
console.log(`Skeleton joint nodes: ${jointNodeIndices.size}`);
|
|
|
|
// Find intersection
|
|
const intersection = [...jointNodeIndices].filter(idx => animChannelNodeIndices.has(idx));
|
|
console.log(`Joints with animation: ${intersection.length}/${joints.length}`);
|
|
|
|
// Find joints without animation
|
|
const jointsWithoutAnim = joints.filter(j => !animChannelNodeIndices.has(j.nodeIndex));
|
|
if (jointsWithoutAnim.length > 0) {
|
|
console.log(`Joints WITHOUT animation:`);
|
|
for (const j of jointsWithoutAnim.slice(0, 5)) {
|
|
console.log(` "${j.name}" nodeIndex=${j.nodeIndex}`);
|
|
}
|
|
}
|
|
|
|
// ===== Test Animation Sampling =====
|
|
console.log(`\n=== Animation Sampling Test ===`);
|
|
|
|
function slerpQuaternion(q0, q1, t) {
|
|
let [x0, y0, z0, w0] = q0;
|
|
let [x1, y1, z1, w1] = q1;
|
|
let dot = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1;
|
|
if (dot < 0) { x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1; dot = -dot; }
|
|
if (dot > 0.9995) {
|
|
const r = [x0 + t * (x1 - x0), y0 + t * (y1 - y0), z0 + t * (z1 - z0), w0 + t * (w1 - w0)];
|
|
const len = Math.sqrt(r[0]**2 + r[1]**2 + r[2]**2 + r[3]**2);
|
|
return [r[0]/len, r[1]/len, r[2]/len, r[3]/len];
|
|
}
|
|
const theta0 = Math.acos(dot);
|
|
const theta = theta0 * t;
|
|
const sinTheta = Math.sin(theta);
|
|
const sinTheta0 = Math.sin(theta0);
|
|
const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0;
|
|
const s1 = sinTheta / sinTheta0;
|
|
return [s0*x0 + s1*x1, s0*y0 + s1*y1, s0*z0 + s1*z1, s0*w0 + s1*w1];
|
|
}
|
|
|
|
function sampleAnimation(time) {
|
|
const transforms = new Map();
|
|
for (const channel of animationChannels) {
|
|
const sampler = animationSamplers[channel.samplerIndex];
|
|
const { input, output } = sampler;
|
|
const { nodeIndex, path } = channel.target;
|
|
|
|
// Find keyframes
|
|
let i0 = 0;
|
|
for (let i = 0; i < input.length - 1; i++) {
|
|
if (time >= input[i] && time <= input[i + 1]) { i0 = i; break; }
|
|
if (time < input[i]) break;
|
|
i0 = i;
|
|
}
|
|
const i1 = Math.min(i0 + 1, input.length - 1);
|
|
const t = input[i1] > input[i0] ? (time - input[i0]) / (input[i1] - input[i0]) : 0;
|
|
|
|
let value;
|
|
if (path === 'rotation') {
|
|
const q0 = [output[i0*4], output[i0*4+1], output[i0*4+2], output[i0*4+3]];
|
|
const q1 = [output[i1*4], output[i1*4+1], output[i1*4+2], output[i1*4+3]];
|
|
value = slerpQuaternion(q0, q1, t);
|
|
} else {
|
|
const count = path === 'rotation' ? 4 : 3;
|
|
value = [];
|
|
for (let c = 0; c < count; c++) {
|
|
value.push(output[i0 * count + c] + (output[i1 * count + c] - output[i0 * count + c]) * t);
|
|
}
|
|
}
|
|
|
|
if (!transforms.has(nodeIndex)) transforms.set(nodeIndex, {});
|
|
transforms.get(nodeIndex)[path] = value;
|
|
}
|
|
return transforms;
|
|
}
|
|
|
|
// Check animation data at different times
|
|
const testTimes = [0, 0.5, 1.0, 1.5, 2.0];
|
|
for (const time of testTimes) {
|
|
const transforms = sampleAnimation(time);
|
|
|
|
// Count how many joints have animation
|
|
let matchCount = 0;
|
|
for (const joint of joints) {
|
|
if (transforms.has(joint.nodeIndex)) matchCount++;
|
|
}
|
|
|
|
console.log(`\nt=${time.toFixed(1)}s: ${transforms.size} node transforms, ${matchCount}/${joints.length} joints have animation`);
|
|
|
|
// Sample first 3 joints
|
|
for (let i = 0; i < Math.min(3, joints.length); i++) {
|
|
const j = joints[i];
|
|
const t = transforms.get(j.nodeIndex);
|
|
if (t) {
|
|
const pos = t.translation ? `[${t.translation.map(v => v.toFixed(2)).join(',')}]` : 'none';
|
|
const rot = t.rotation ? `[${t.rotation.map(v => v.toFixed(3)).join(',')}]` : 'none';
|
|
console.log(` Joint[${i}] "${j.name}": pos=${pos} rot=${rot}`);
|
|
} else {
|
|
console.log(` Joint[${i}] "${j.name}": NO ANIMATION DATA`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Check if animation changes over time =====
|
|
console.log(`\n=== Animation Value Changes ===`);
|
|
|
|
// Find a rotation channel and check value changes
|
|
const rotChannels = animationChannels.filter(c => c.target.path === 'rotation');
|
|
console.log(`Rotation channels: ${rotChannels.length}`);
|
|
|
|
if (rotChannels.length > 0) {
|
|
// Find one with varying values
|
|
for (const ch of rotChannels.slice(0, 5)) {
|
|
const sampler = animationSamplers[ch.samplerIndex];
|
|
const firstQ = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]];
|
|
const lastQ = [
|
|
sampler.output[(sampler.input.length-1)*4],
|
|
sampler.output[(sampler.input.length-1)*4+1],
|
|
sampler.output[(sampler.input.length-1)*4+2],
|
|
sampler.output[(sampler.input.length-1)*4+3]
|
|
];
|
|
const diff = Math.abs(firstQ[0]-lastQ[0]) + Math.abs(firstQ[1]-lastQ[1]) +
|
|
Math.abs(firstQ[2]-lastQ[2]) + Math.abs(firstQ[3]-lastQ[3]);
|
|
const nodeIdx = ch.target.nodeIndex;
|
|
const model = models[nodeIdx];
|
|
console.log(` Node[${nodeIdx}] "${model?.name}": ${sampler.input.length} keyframes, diff=${diff.toFixed(4)}`);
|
|
if (diff > 0.01) {
|
|
console.log(` First: [${firstQ.map(v=>v.toFixed(4)).join(', ')}]`);
|
|
console.log(` Last: [${lastQ.map(v=>v.toFixed(4)).join(', ')}]`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Calculate Bone Matrices =====
|
|
console.log(`\n=== Bone Matrix Test ===`);
|
|
|
|
// Test at t=0 (should be bind pose - identity matrices)
|
|
// 在 t=0 测试(应该是绑定姿势 - 单位矩阵)
|
|
|
|
function createTransformMatrix(pos, rot, scale) {
|
|
const [qx, qy, qz, qw] = rot;
|
|
const [sx, sy, sz] = scale;
|
|
const xx = qx*qx, xy = qx*qy, xz = qx*qz, xw = qx*qw;
|
|
const yy = qy*qy, yz = qy*qz, yw = qy*qw;
|
|
const zz = qz*qz, zw = qz*qw;
|
|
return new Float32Array([
|
|
(1 - 2*(yy+zz))*sx, 2*(xy+zw)*sx, 2*(xz-yw)*sx, 0,
|
|
2*(xy-zw)*sy, (1 - 2*(xx+zz))*sy, 2*(yz+xw)*sy, 0,
|
|
2*(xz+yw)*sz, 2*(yz-xw)*sz, (1 - 2*(xx+yy))*sz, 0,
|
|
pos[0], pos[1], pos[2], 1
|
|
]);
|
|
}
|
|
|
|
function multiplyMatrices(a, b) {
|
|
const result = new Float32Array(16);
|
|
for (let row = 0; row < 4; row++) {
|
|
for (let col = 0; col < 4; col++) {
|
|
let sum = 0;
|
|
for (let k = 0; k < 4; k++) {
|
|
sum += a[row + k * 4] * b[k + col * 4];
|
|
}
|
|
result[row + col * 4] = sum;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function invertMatrix4(m) {
|
|
const out = new Float32Array(16);
|
|
const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3];
|
|
const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7];
|
|
const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11];
|
|
const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15];
|
|
|
|
const b00 = m00*m11 - m01*m10, b01 = m00*m12 - m02*m10;
|
|
const b02 = m00*m13 - m03*m10, b03 = m01*m12 - m02*m11;
|
|
const b04 = m01*m13 - m03*m11, b05 = m02*m13 - m03*m12;
|
|
const b06 = m20*m31 - m21*m30, b07 = m20*m32 - m22*m30;
|
|
const b08 = m20*m33 - m23*m30, b09 = m21*m32 - m22*m31;
|
|
const b10 = m21*m33 - m23*m31, b11 = m22*m33 - m23*m32;
|
|
|
|
let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06;
|
|
if (!det) return out;
|
|
det = 1.0 / det;
|
|
|
|
out[0] = (m11*b11 - m12*b10 + m13*b09) * det;
|
|
out[1] = (m02*b10 - m01*b11 - m03*b09) * det;
|
|
out[2] = (m31*b05 - m32*b04 + m33*b03) * det;
|
|
out[3] = (m22*b04 - m21*b05 - m23*b03) * det;
|
|
out[4] = (m12*b08 - m10*b11 - m13*b07) * det;
|
|
out[5] = (m00*b11 - m02*b08 + m03*b07) * det;
|
|
out[6] = (m32*b02 - m30*b05 - m33*b01) * det;
|
|
out[7] = (m20*b05 - m22*b02 + m23*b01) * det;
|
|
out[8] = (m10*b10 - m11*b08 + m13*b06) * det;
|
|
out[9] = (m01*b08 - m00*b10 - m03*b06) * det;
|
|
out[10] = (m30*b04 - m31*b02 + m33*b00) * det;
|
|
out[11] = (m21*b02 - m20*b04 - m23*b00) * det;
|
|
out[12] = (m11*b07 - m10*b09 - m12*b06) * det;
|
|
out[13] = (m00*b09 - m01*b07 + m02*b06) * det;
|
|
out[14] = (m31*b01 - m30*b03 - m32*b00) * det;
|
|
out[15] = (m20*b03 - m21*b01 + m22*b00) * det;
|
|
return out;
|
|
}
|
|
|
|
// Test multiple times including t=0 (bind pose)
|
|
const testTimesForMatrix = [0, 1.0, 7.5];
|
|
|
|
// Build node default transforms from models
|
|
const nodeTransforms = [];
|
|
for (const model of models) {
|
|
const rx = model.rotation[0] * Math.PI / 180;
|
|
const ry = model.rotation[1] * Math.PI / 180;
|
|
const rz = model.rotation[2] * Math.PI / 180;
|
|
let quat = eulerToQuaternion(rx, ry, rz);
|
|
if (model.preRotation) {
|
|
const prx = model.preRotation[0] * Math.PI / 180;
|
|
const pry = model.preRotation[1] * Math.PI / 180;
|
|
const prz = model.preRotation[2] * Math.PI / 180;
|
|
const preQuat = eulerToQuaternion(prx, pry, prz);
|
|
quat = multiplyQuaternion(preQuat, quat);
|
|
}
|
|
nodeTransforms.push({
|
|
position: model.position,
|
|
rotation: quat,
|
|
scale: model.scale
|
|
});
|
|
}
|
|
|
|
// Calculate bone matrices for different times
|
|
function calculateBoneMatrices(time) {
|
|
const transforms = sampleAnimation(time);
|
|
const localMatrices = [], worldMatrices = [], skinMatrices = [];
|
|
const processed = new Set();
|
|
const processingOrder = [];
|
|
|
|
function addJoint(idx) {
|
|
if (processed.has(idx)) return;
|
|
if (joints[idx].parentIndex >= 0 && !processed.has(joints[idx].parentIndex)) {
|
|
addJoint(joints[idx].parentIndex);
|
|
}
|
|
processingOrder.push(idx);
|
|
processed.add(idx);
|
|
}
|
|
for (let i = 0; i < joints.length; i++) addJoint(i);
|
|
|
|
for (const jointIdx of processingOrder) {
|
|
const joint = joints[jointIdx];
|
|
const nodeIdx = joint.nodeIndex;
|
|
const node = nodeTransforms[nodeIdx];
|
|
|
|
// Get animated or default transform
|
|
const animT = transforms.get(nodeIdx);
|
|
const pos = animT?.translation || node.position;
|
|
const rot = animT?.rotation || node.rotation;
|
|
const scl = animT?.scale || node.scale;
|
|
|
|
localMatrices[jointIdx] = createTransformMatrix(pos, rot, scl);
|
|
|
|
if (joint.parentIndex >= 0) {
|
|
worldMatrices[jointIdx] = multiplyMatrices(worldMatrices[joint.parentIndex], localMatrices[jointIdx]);
|
|
} else {
|
|
worldMatrices[jointIdx] = localMatrices[jointIdx];
|
|
}
|
|
|
|
skinMatrices[jointIdx] = multiplyMatrices(worldMatrices[jointIdx], joint.inverseBindMatrix);
|
|
}
|
|
|
|
return skinMatrices;
|
|
}
|
|
|
|
// Test at multiple times
|
|
for (const time of testTimesForMatrix) {
|
|
console.log(`\n--- t=${time.toFixed(1)}s ---`);
|
|
const skinMatrices = calculateBoneMatrices(time);
|
|
|
|
// Check skin matrices - how many are NOT identity?
|
|
let nonIdentityCount = 0;
|
|
let maxDiff = 0;
|
|
for (let i = 0; i < skinMatrices.length; i++) {
|
|
const m = skinMatrices[i];
|
|
const diff = Math.abs(m[0]-1) + Math.abs(m[5]-1) + Math.abs(m[10]-1) + Math.abs(m[15]-1) +
|
|
Math.abs(m[1]) + Math.abs(m[2]) + Math.abs(m[3]) +
|
|
Math.abs(m[4]) + Math.abs(m[6]) + Math.abs(m[7]) +
|
|
Math.abs(m[8]) + Math.abs(m[9]) + Math.abs(m[11]) +
|
|
Math.abs(m[12]) + Math.abs(m[13]) + Math.abs(m[14]);
|
|
if (diff > 0.001) {
|
|
nonIdentityCount++;
|
|
if (diff > maxDiff) maxDiff = diff;
|
|
}
|
|
}
|
|
|
|
console.log(` Non-identity: ${nonIdentityCount}/${skinMatrices.length}, max diff: ${maxDiff.toFixed(4)}`);
|
|
if (time === 0) {
|
|
console.log(` (t=0 should have mostly identity matrices if bind pose is correct)`);
|
|
}
|
|
|
|
// Show first 3 skin matrices
|
|
for (let i = 0; i < Math.min(3, skinMatrices.length); i++) {
|
|
const m = skinMatrices[i];
|
|
console.log(` Joint[${i}] "${joints[i].name}":`);
|
|
console.log(` diagonal: [${m[0].toFixed(4)}, ${m[5].toFixed(4)}, ${m[10].toFixed(4)}, ${m[15].toFixed(4)}]`);
|
|
console.log(` translation: [${m[12].toFixed(4)}, ${m[13].toFixed(4)}, ${m[14].toFixed(4)}]`);
|
|
}
|
|
}
|
|
|
|
console.log(`\n=== Done ===`);
|