448 lines
19 KiB
JavaScript
448 lines
19 KiB
JavaScript
const M2_HEADER_SIZE = 304;
|
|
const M2_VERTEX_SIZE = 48;
|
|
const M2_TEXTURE_SIZE = 16;
|
|
const M2_RENDER_FLAG_SIZE = 4;
|
|
const SKIN_HEADER_SIZE = 48;
|
|
const SKIN_SUBMESH_SIZE = 48;
|
|
const SKIN_TEXTURE_UNIT_SIZE = 24;
|
|
|
|
const VALUE_TYPES = {
|
|
floatQuaternion: {
|
|
byteLength: 16,
|
|
read: (buffer, offset) => [0, 4, 8, 12].map((component) => buffer.readFloatLE(offset + component)),
|
|
},
|
|
quaternion: {
|
|
byteLength: 8,
|
|
read: (buffer, offset) => [0, 2, 4, 6].map((component) => {
|
|
const value = buffer.readInt16LE(offset + component);
|
|
return (value < 0 ? value + 32768 : value - 32767) / 32767;
|
|
}),
|
|
},
|
|
uint8x4: {
|
|
byteLength: 4,
|
|
read: (buffer, offset) => Array.from(buffer.subarray(offset, offset + 4)),
|
|
},
|
|
uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) },
|
|
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
|
|
int16: { byteLength: 2, read: (buffer, offset) => buffer.readInt16LE(offset) },
|
|
vector3: { byteLength: 12, read: (buffer, offset) => vector3(buffer, offset) },
|
|
};
|
|
|
|
function asBuffer(value) {
|
|
return Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
}
|
|
|
|
function assertRange(buffer, offset, byteLength, label) {
|
|
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(byteLength)
|
|
|| offset < 0 || byteLength < 0 || offset > buffer.length - byteLength) {
|
|
throw new Error(`${label} points outside its source buffer.`);
|
|
}
|
|
}
|
|
|
|
function readArrayHeader(buffer, offset, label) {
|
|
assertRange(buffer, offset, 8, `${label} descriptor`);
|
|
return { count: buffer.readUInt32LE(offset), offset: buffer.readUInt32LE(offset + 4) };
|
|
}
|
|
|
|
function decodeArray(buffer, descriptorOffset, valueType, label) {
|
|
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
|
|
const byteLength = descriptor.count * valueType.byteLength;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
|
|
if (descriptor.count) assertRange(buffer, descriptor.offset, byteLength, label);
|
|
return {
|
|
...descriptor,
|
|
values: Array.from({ length: descriptor.count }, (_, index) => (
|
|
valueType.read(buffer, descriptor.offset + index * valueType.byteLength)
|
|
)),
|
|
};
|
|
}
|
|
|
|
function decodeNestedArray(buffer, descriptorOffset, valueType, label) {
|
|
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
|
|
const byteLength = descriptor.count * 8;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
|
|
if (descriptor.count) assertRange(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`);
|
|
return {
|
|
...descriptor,
|
|
sequences: Array.from({ length: descriptor.count }, (_, index) => (
|
|
decodeArray(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`)
|
|
)),
|
|
};
|
|
}
|
|
|
|
function decodeTrack(buffer, offset, valueType, label) {
|
|
assertRange(buffer, offset, 20, `${label} track`);
|
|
const interpolation = buffer.readUInt16LE(offset);
|
|
if (interpolation > 3) throw new Error(`${label} uses unsupported interpolation ${interpolation}.`);
|
|
const spline = interpolation === 2 || interpolation === 3;
|
|
const trackValueType = spline ? {
|
|
byteLength: valueType.byteLength * 3,
|
|
read: (source, valueOffset) => ({
|
|
value: valueType.read(source, valueOffset),
|
|
inTangent: valueType.read(source, valueOffset + valueType.byteLength),
|
|
outTangent: valueType.read(source, valueOffset + valueType.byteLength * 2),
|
|
}),
|
|
} : valueType;
|
|
const timestamps = decodeNestedArray(buffer, offset + 4, VALUE_TYPES.uint32, `${label} timestamps`);
|
|
const values = decodeNestedArray(buffer, offset + 12, trackValueType, `${label} values`);
|
|
if (timestamps.sequences.length !== values.sequences.length) {
|
|
throw new Error(`${label} timestamp and value sequence counts do not match.`);
|
|
}
|
|
const sequences = timestamps.sequences.map((timestampSequence, index) => {
|
|
const valueSequence = values.sequences[index];
|
|
if (timestampSequence.values.length !== valueSequence.values.length) {
|
|
throw new Error(`${label} sequence ${index} timestamp and value counts do not match.`);
|
|
}
|
|
return {
|
|
timestamps: timestampSequence.values,
|
|
values: valueSequence.values,
|
|
};
|
|
});
|
|
return {
|
|
interpolation,
|
|
globalSequence: buffer.readInt16LE(offset + 2),
|
|
sequences,
|
|
};
|
|
}
|
|
|
|
function decodeString(buffer, descriptorOffset, label) {
|
|
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
|
|
if (!descriptor.count) return "";
|
|
assertRange(buffer, descriptor.offset, descriptor.count, label);
|
|
const bytes = buffer.subarray(descriptor.offset, descriptor.offset + descriptor.count);
|
|
const terminator = bytes.indexOf(0);
|
|
return bytes.toString("utf8", 0, terminator < 0 ? bytes.length : terminator);
|
|
}
|
|
|
|
function vector2(buffer, offset) {
|
|
return [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4)];
|
|
}
|
|
|
|
function vector3(buffer, offset) {
|
|
return [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4), buffer.readFloatLE(offset + 8)];
|
|
}
|
|
|
|
function decodeVertices(buffer, name) {
|
|
const count = buffer.readUInt32LE(60);
|
|
const offset = buffer.readUInt32LE(64);
|
|
const byteLength = count * M2_VERTEX_SIZE;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} vertices are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} vertices`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const vertexOffset = offset + index * M2_VERTEX_SIZE;
|
|
const boneWeights = VALUE_TYPES.uint8x4.read(buffer, vertexOffset + 12);
|
|
return {
|
|
position: vector3(buffer, vertexOffset),
|
|
boneWeights,
|
|
normalizedBoneWeights: boneWeights.map((weight) => weight / 255),
|
|
boneIndices: VALUE_TYPES.uint8x4.read(buffer, vertexOffset + 16),
|
|
normal: vector3(buffer, vertexOffset + 20),
|
|
uv: vector2(buffer, vertexOffset + 32),
|
|
uv2: vector2(buffer, vertexOffset + 40),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodeTextures(buffer, name) {
|
|
const count = buffer.readUInt32LE(80);
|
|
const offset = buffer.readUInt32LE(84);
|
|
const byteLength = count * M2_TEXTURE_SIZE;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} textures are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} texture records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const textureOffset = offset + index * M2_TEXTURE_SIZE;
|
|
return {
|
|
type: buffer.readUInt32LE(textureOffset),
|
|
flags: buffer.readUInt32LE(textureOffset + 4),
|
|
filename: decodeString(buffer, textureOffset + 8, `${name} texture ${index} filename`),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodeMaterials(buffer, name) {
|
|
const count = buffer.readUInt32LE(112);
|
|
const offset = buffer.readUInt32LE(116);
|
|
const byteLength = count * M2_RENDER_FLAG_SIZE;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} materials are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} material records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const materialOffset = offset + index * M2_RENDER_FLAG_SIZE;
|
|
return {
|
|
flags: buffer.readUInt16LE(materialOffset),
|
|
blendMode: buffer.readUInt16LE(materialOffset + 2),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodeAnimations(buffer, name) {
|
|
const count = buffer.readUInt32LE(28);
|
|
const offset = buffer.readUInt32LE(32);
|
|
const recordSize = 64;
|
|
const byteLength = count * recordSize;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} animations are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} animation records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const animationOffset = offset + index * recordSize;
|
|
return {
|
|
index,
|
|
id: buffer.readUInt16LE(animationOffset),
|
|
variationIndex: buffer.readUInt16LE(animationOffset + 2),
|
|
durationMs: buffer.readUInt32LE(animationOffset + 4),
|
|
moveSpeed: buffer.readFloatLE(animationOffset + 8),
|
|
flags: buffer.readUInt32LE(animationOffset + 12),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodeColors(buffer, name) {
|
|
const count = buffer.readUInt32LE(72);
|
|
const offset = buffer.readUInt32LE(76);
|
|
const recordSize = 40;
|
|
const byteLength = count * recordSize;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} colors are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} color records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const colorOffset = offset + index * recordSize;
|
|
return {
|
|
color: decodeTrack(buffer, colorOffset, VALUE_TYPES.vector3, `${name} color ${index} RGB`),
|
|
alpha: decodeTrack(buffer, colorOffset + 20, VALUE_TYPES.int16, `${name} color ${index} alpha`),
|
|
};
|
|
});
|
|
}
|
|
|
|
function decodeTextureWeights(buffer, name) {
|
|
const count = buffer.readUInt32LE(88);
|
|
const offset = buffer.readUInt32LE(92);
|
|
const recordSize = 20;
|
|
const byteLength = count * recordSize;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} texture weights are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} texture-weight records`);
|
|
return Array.from({ length: count }, (_, index) => (
|
|
decodeTrack(buffer, offset + index * recordSize, VALUE_TYPES.int16, `${name} texture weight ${index}`)
|
|
));
|
|
}
|
|
|
|
/** Source-space bone and UV animation, shared by meshes, particles and ribbons. */
|
|
export function decodeWotlkM2Animation(buffer, name = "M2") {
|
|
const bones = readArrayHeader(buffer, 44, `${name} bones`);
|
|
if (bones.count) assertRange(buffer, bones.offset, bones.count * 88, `${name} bones`);
|
|
const transforms = readArrayHeader(buffer, 96, `${name} texture transforms`);
|
|
if (transforms.count) assertRange(buffer, transforms.offset, transforms.count * 60, `${name} texture transforms`);
|
|
return {
|
|
globalSequences: decodeArray(buffer, 20, VALUE_TYPES.uint32, `${name} global sequences`).values,
|
|
bones: Array.from({ length: bones.count }, (_, index) => {
|
|
const offset = bones.offset + index * 88;
|
|
const parent = buffer.readInt16LE(offset + 8);
|
|
if (parent >= index || parent < -1) throw new Error(`${name} bone ${index} has an invalid parent.`);
|
|
return {
|
|
parent,
|
|
flags: buffer.readUInt32LE(offset + 4),
|
|
pivot: vector3(buffer, offset + 76),
|
|
translation: decodeTrack(buffer, offset + 16, VALUE_TYPES.vector3, `${name} bone ${index} translation`),
|
|
rotation: decodeTrack(buffer, offset + 36, VALUE_TYPES.quaternion, `${name} bone ${index} rotation`),
|
|
scale: decodeTrack(buffer, offset + 56, VALUE_TYPES.vector3, `${name} bone ${index} scale`),
|
|
};
|
|
}),
|
|
textureTransforms: Array.from({ length: transforms.count }, (_, index) => {
|
|
const offset = transforms.offset + index * 60;
|
|
return {
|
|
translation: decodeTrack(buffer, offset, VALUE_TYPES.vector3, `${name} UV ${index} translation`),
|
|
rotation: decodeTrack(buffer, offset + 20, VALUE_TYPES.floatQuaternion, `${name} UV ${index} rotation`),
|
|
scale: decodeTrack(buffer, offset + 40, VALUE_TYPES.vector3, `${name} UV ${index} scale`),
|
|
};
|
|
}),
|
|
materials: decodeMaterials(buffer, name),
|
|
};
|
|
}
|
|
|
|
function decodeSkinSubmeshes(buffer, count, offset, triangleCount, vertexLookupCount, name) {
|
|
const byteLength = count * SKIN_SUBMESH_SIZE;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} submeshes are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} submesh records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const submeshOffset = offset + index * SKIN_SUBMESH_SIZE;
|
|
const vertexStart = buffer.readUInt16LE(submeshOffset + 4);
|
|
const vertexCount = buffer.readUInt16LE(submeshOffset + 6);
|
|
const indexStart = buffer.readUInt16LE(submeshOffset + 8);
|
|
const indexCount = buffer.readUInt16LE(submeshOffset + 10);
|
|
if (vertexStart + vertexCount > vertexLookupCount) {
|
|
throw new Error(`${name} submesh ${index} vertex range exceeds the vertex lookup.`);
|
|
}
|
|
if (indexStart + indexCount > triangleCount) {
|
|
throw new Error(`${name} submesh ${index} index range exceeds the triangle lookup.`);
|
|
}
|
|
return {
|
|
id: buffer.readUInt16LE(submeshOffset),
|
|
level: buffer.readUInt16LE(submeshOffset + 2),
|
|
vertexStart,
|
|
vertexCount,
|
|
indexStart,
|
|
indexCount,
|
|
boneCount: buffer.readUInt16LE(submeshOffset + 12),
|
|
boneComboIndex: buffer.readUInt16LE(submeshOffset + 14),
|
|
boneInfluences: buffer.readUInt16LE(submeshOffset + 16),
|
|
centerBoneIndex: buffer.readUInt16LE(submeshOffset + 18),
|
|
center: vector3(buffer, submeshOffset + 20),
|
|
sortCenter: vector3(buffer, submeshOffset + 32),
|
|
sortRadius: buffer.readFloatLE(submeshOffset + 44),
|
|
};
|
|
});
|
|
}
|
|
|
|
function lookupSlice(lookup, start, count, label) {
|
|
if (start + count > lookup.length) throw new Error(`${label} exceeds its M2 lookup table.`);
|
|
return lookup.slice(start, start + count);
|
|
}
|
|
|
|
function decodeSkinTextureUnits(buffer, count, offset, context, name) {
|
|
const byteLength = count * SKIN_TEXTURE_UNIT_SIZE;
|
|
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} texture units are too large to decode.`);
|
|
if (count) assertRange(buffer, offset, byteLength, `${name} texture unit records`);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const unitOffset = offset + index * SKIN_TEXTURE_UNIT_SIZE;
|
|
const submeshIndex = buffer.readUInt16LE(unitOffset + 4);
|
|
const materialIndex = buffer.readUInt16LE(unitOffset + 10);
|
|
const textureCount = buffer.readUInt16LE(unitOffset + 14);
|
|
const textureComboIndex = buffer.readUInt16LE(unitOffset + 16);
|
|
const textureCoordinateComboIndex = buffer.readUInt16LE(unitOffset + 18);
|
|
const textureWeightComboIndex = buffer.readUInt16LE(unitOffset + 20);
|
|
const textureTransformComboIndex = buffer.readUInt16LE(unitOffset + 22);
|
|
if (submeshIndex >= context.submeshes.length) {
|
|
throw new Error(`${name} texture unit ${index} references missing submesh ${submeshIndex}.`);
|
|
}
|
|
if (materialIndex >= context.materials.length) {
|
|
throw new Error(`${name} texture unit ${index} references missing material ${materialIndex}.`);
|
|
}
|
|
const textureIndices = lookupSlice(
|
|
context.lookups.textures, textureComboIndex, textureCount, `${name} texture unit ${index} textures`,
|
|
);
|
|
for (const textureIndex of textureIndices) {
|
|
if (textureIndex >= context.textures.length) {
|
|
throw new Error(`${name} texture unit ${index} references missing texture ${textureIndex}.`);
|
|
}
|
|
}
|
|
const submesh = context.submeshes[submeshIndex];
|
|
return {
|
|
flags: buffer.readUInt8(unitOffset),
|
|
priorityPlane: buffer.readInt8(unitOffset + 1),
|
|
shaderId: buffer.readUInt16LE(unitOffset + 2),
|
|
submeshIndex,
|
|
geosetIndex: buffer.readUInt16LE(unitOffset + 6),
|
|
colorIndex: buffer.readInt16LE(unitOffset + 8),
|
|
materialIndex,
|
|
materialLayer: buffer.readUInt16LE(unitOffset + 12),
|
|
textureCount,
|
|
textureComboIndex,
|
|
textureCoordinateComboIndex,
|
|
textureWeightComboIndex,
|
|
textureTransformComboIndex,
|
|
textureIndices,
|
|
textureFilenames: textureIndices.map((textureIndex) => context.textures[textureIndex].filename),
|
|
textureCoordinateIndices: lookupSlice(
|
|
context.lookups.textureCoordinates,
|
|
textureCoordinateComboIndex,
|
|
textureCount,
|
|
`${name} texture unit ${index} texture coordinates`,
|
|
),
|
|
textureWeightIndices: lookupSlice(
|
|
context.lookups.textureWeights,
|
|
textureWeightComboIndex,
|
|
1,
|
|
`${name} texture unit ${index} texture weights`,
|
|
),
|
|
textureTransformIndices: lookupSlice(
|
|
context.lookups.textureTransforms,
|
|
textureTransformComboIndex,
|
|
textureCount,
|
|
`${name} texture unit ${index} texture transforms`,
|
|
),
|
|
drawRange: { indexStart: submesh.indexStart, indexCount: submesh.indexCount },
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Decode a browser-neutral static mesh descriptor from WotLK 3.3.5 MD20 and SKIN data. */
|
|
export function decodeWotlkM2Mesh(m2Source, skinSource, name = "M2 mesh") {
|
|
const m2 = asBuffer(m2Source);
|
|
const skin = asBuffer(skinSource);
|
|
if (m2.length < M2_HEADER_SIZE || m2.toString("ascii", 0, 4) !== "MD20" || m2.readUInt32LE(4) !== 264) {
|
|
throw new Error(`${name} is not a supported WotLK 3.3.5 MD20.`);
|
|
}
|
|
if (skin.length < SKIN_HEADER_SIZE || skin.toString("ascii", 0, 4) !== "SKIN") {
|
|
throw new Error(`${name} has no supported WotLK SKIN profile.`);
|
|
}
|
|
|
|
const vertices = decodeVertices(m2, name);
|
|
const globalSequences = decodeArray(m2, 20, VALUE_TYPES.uint32, `${name} global sequences`).values;
|
|
const animations = decodeAnimations(m2, name);
|
|
const colors = decodeColors(m2, name);
|
|
const textures = decodeTextures(m2, name);
|
|
const textureWeights = decodeTextureWeights(m2, name);
|
|
const materials = decodeMaterials(m2, name);
|
|
const lookups = {
|
|
textures: decodeArray(m2, 128, VALUE_TYPES.uint16, `${name} texture lookup`).values,
|
|
textureCoordinates: decodeArray(m2, 136, VALUE_TYPES.int16, `${name} texture-coordinate lookup`).values,
|
|
textureWeights: decodeArray(m2, 144, VALUE_TYPES.uint16, `${name} texture-weight lookup`).values,
|
|
textureTransforms: decodeArray(m2, 152, VALUE_TYPES.int16, `${name} texture-transform lookup`).values,
|
|
};
|
|
const vertexLookup = decodeArray(skin, 4, VALUE_TYPES.uint16, `${name} SKIN vertex lookup`).values;
|
|
const triangleLookup = decodeArray(skin, 12, VALUE_TYPES.uint16, `${name} SKIN triangle lookup`).values;
|
|
const boneIndices = decodeArray(skin, 20, VALUE_TYPES.uint8x4, `${name} SKIN bone indices`).values;
|
|
if (triangleLookup.length % 3 !== 0) throw new Error(`${name} SKIN triangle lookup is not divisible by three.`);
|
|
if (boneIndices.length !== vertexLookup.length) {
|
|
throw new Error(`${name} SKIN bone-index count does not match its vertex lookup.`);
|
|
}
|
|
const indices = triangleLookup.map((lookupIndex, index) => {
|
|
if (lookupIndex >= vertexLookup.length) {
|
|
throw new Error(`${name} SKIN triangle index ${index} exceeds its vertex lookup.`);
|
|
}
|
|
const vertexIndex = vertexLookup[lookupIndex];
|
|
if (vertexIndex >= vertices.length) {
|
|
throw new Error(`${name} SKIN vertex lookup ${lookupIndex} references missing M2 vertex ${vertexIndex}.`);
|
|
}
|
|
return vertexIndex;
|
|
});
|
|
const submeshCount = skin.readUInt32LE(28);
|
|
const submeshOffset = skin.readUInt32LE(32);
|
|
const submeshes = decodeSkinSubmeshes(
|
|
skin, submeshCount, submeshOffset, triangleLookup.length, vertexLookup.length, name,
|
|
).map((submesh) => ({
|
|
...submesh,
|
|
indices: indices.slice(submesh.indexStart, submesh.indexStart + submesh.indexCount),
|
|
}));
|
|
const textureUnitCount = skin.readUInt32LE(36);
|
|
const textureUnitOffset = skin.readUInt32LE(40);
|
|
const textureUnits = decodeSkinTextureUnits(skin, textureUnitCount, textureUnitOffset, {
|
|
submeshes, materials, textures, lookups,
|
|
}, name);
|
|
|
|
return {
|
|
magic: "MD20",
|
|
version: 264,
|
|
modelName: decodeString(m2, 8, `${name} model name`),
|
|
globalSequences,
|
|
animations,
|
|
bounds: {
|
|
minimum: vector3(m2, 160),
|
|
maximum: vector3(m2, 172),
|
|
radius: m2.readFloatLE(184),
|
|
},
|
|
vertices,
|
|
indices,
|
|
colors,
|
|
textures,
|
|
textureWeights,
|
|
materials,
|
|
lookups,
|
|
skin: {
|
|
magic: "SKIN",
|
|
maximumBones: skin.readUInt32LE(44),
|
|
vertexLookup,
|
|
triangleLookup,
|
|
boneIndices,
|
|
submeshes,
|
|
textureUnits,
|
|
},
|
|
};
|
|
}
|