UNPKG

mdx-m3-viewer

Version:

A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.

764 lines (682 loc) 22.7 kB
import BinaryStream from '../../common/binarystream'; import TokenStream from './tokenstream'; import Extent from './extent'; import Sequence from './sequence'; import Material from './material'; import Texture from './texture'; import TextureAnimation from './textureanimation'; import Geoset from './geoset'; import GeosetAnimation from './geosetanimation'; import Bone from './bone'; import Light from './light'; import Helper from './helper'; import Attachment from './attachment'; import ParticleEmitter from './particleemitter'; import ParticleEmitter2 from './particleemitter2'; import RibbonEmitter from './ribbonemitter'; import Camera from './camera'; import EventObject from './eventobject'; import CollisionShape from './collisionshape'; import UnknownChunk from './unknownchunk'; /** * A Warcraft 3 model. * Supports loading from and saving to both the binary MDX and text MDL file formats. */ export default class Model { /** * @param {?ArrayBuffer|string} buffer */ constructor(buffer) { /** * 800 for Warcraft 3: RoC and TFT. * 900 for Warcraft 3: Reforged. * * @member {number} */ this.version = 800; /** @member {string} */ this.name = ''; /** * To the best of my knowledge, this should always be left empty. * This is probably a leftover from the Warcraft 3 beta. * * @member {string} */ this.animationFile = ''; /** @member {Extent} */ this.extent = new Extent(); /** @member {number} */ this.blendTime = 0; /** @member {Array<Sequence>} */ this.sequences = []; /** @member {Array<number>} */ this.globalSequences = []; /** @member {Array<Material>} */ this.materials = []; /** @member {Array<Texture>} */ this.textures = []; /** @member {Array<TextureAnimation>} */ this.textureAnimations = []; /** @member {Array<Geoset>} */ this.geosets = []; /** @member {Array<GeosetAnimation>} */ this.geosetAnimations = []; /** @member {Array<Bone>} */ this.bones = []; /** @member {Array<Light>} */ this.lights = []; /** @member {Array<Helper>} */ this.helpers = []; /** @member {Array<Attachment>} */ this.attachments = []; /** @member {Array<Float32Array>} */ this.pivotPoints = []; /** @member {Array<ParticleEmitter>} */ this.particleEmitters = []; /** @member {Array<ParticleEmitter2>} */ this.particleEmitters2 = []; /** @member {Array<RibbonEmitter>} */ this.ribbonEmitters = []; /** @member {Array<Camera>} */ this.cameras = []; /** @member {Array<EventObject>} */ this.eventObjects = []; /** @member {Array<CollisionShape>} */ this.collisionShapes = []; /** * @since 900 * @member {Array<Float32Array} */ this.bindPose = []; /** * The MDX format is chunk based, and Warcraft 3 does not mind there being unknown chunks in there. * Some 3rd party tools use this to attach metadata to models. * When an unknown chunk is encountered, it will be added here. * These chunks will be saved when saving as MDX. * * @member {Array<UnknownChunk} */ this.unknownChunks = []; if (buffer) { this.load(buffer); } } /** * Load the model from MDX or MDL. * The format is detected by the buffer type: ArrayBuffer for MDX, and string for MDL. * * @param {ArrayBuffer|string} buffer */ load(buffer) { if (buffer instanceof ArrayBuffer) { this.loadMdx(buffer); } else { this.loadMdl(buffer); } } /** * Load the model from MDX. * * @param {ArrayBuffer} buffer */ loadMdx(buffer) { let stream = new BinaryStream(buffer); if (stream.read(4) !== 'MDLX') { throw new Error('WrongMagicNumber'); } while (stream.remaining() > 0) { let tag = stream.read(4); let size = stream.readUint32(); if (tag === 'VERS') { this.loadVersionChunk(stream); } else if (tag === 'MODL') { this.loadModelChunk(stream); } else if (tag === 'SEQS') { this.loadStaticObjects(this.sequences, Sequence, stream, size / 132); } else if (tag === 'GLBS') { this.loadGlobalSequenceChunk(stream, size); } else if (tag === 'MTLS') { this.loadDynamicObjects(this.materials, Material, stream, size); } else if (tag === 'TEXS') { this.loadStaticObjects(this.textures, Texture, stream, size / 268); } else if (tag === 'TXAN') { this.loadDynamicObjects(this.textureAnimations, TextureAnimation, stream, size); } else if (tag === 'GEOS') { this.loadDynamicObjects(this.geosets, Geoset, stream, size); } else if (tag === 'GEOA') { this.loadDynamicObjects(this.geosetAnimations, GeosetAnimation, stream, size); } else if (tag === 'BONE') { this.loadDynamicObjects(this.bones, Bone, stream, size); } else if (tag === 'LITE') { this.loadDynamicObjects(this.lights, Light, stream, size); } else if (tag === 'HELP') { this.loadDynamicObjects(this.helpers, Helper, stream, size); } else if (tag === 'ATCH') { this.loadDynamicObjects(this.attachments, Attachment, stream, size); } else if (tag === 'PIVT') { this.loadPivotPointChunk(stream, size); } else if (tag === 'PREM') { this.loadDynamicObjects(this.particleEmitters, ParticleEmitter, stream, size); } else if (tag === 'PRE2') { this.loadDynamicObjects(this.particleEmitters2, ParticleEmitter2, stream, size); } else if (tag === 'RIBB') { this.loadDynamicObjects(this.ribbonEmitters, RibbonEmitter, stream, size); } else if (tag === 'CAMS') { this.loadDynamicObjects(this.cameras, Camera, stream, size); } else if (tag === 'EVTS') { this.loadDynamicObjects(this.eventObjects, EventObject, stream, size); } else if (tag === 'CLID') { this.loadDynamicObjects(this.collisionShapes, CollisionShape, stream, size); } else if (tag === 'BPOS') { this.loadBindPoseChunk(stream, size); } else { this.unknownChunks.push(new UnknownChunk(stream, size, tag)); } } } /** * @param {BinaryStream} stream */ loadVersionChunk(stream) { this.version = stream.readUint32(); } /** * @param {BinaryStream} stream */ loadModelChunk(stream) { this.name = stream.read(80); this.animationFile = stream.read(260); this.extent.readMdx(stream); this.blendTime = stream.readUint32(); } /** * @param {Array<Sequence|Texture>} out * @param {constructor} constructor * @param {BinaryStream} stream * @param {number} count */ loadStaticObjects(out, constructor, stream, count) { for (let i = 0; i < count; i++) { let object = new constructor(); object.readMdx(stream); out.push(object); } } /** * @param {BinaryStream} stream * @param {number} size */ loadGlobalSequenceChunk(stream, size) { for (let i = 0, l = size / 4; i < l; i++) { this.globalSequences.push(stream.readUint32()); } } /** * @param {Array<Material|TextureAnimation|Geoset|GeosetAnimation|GenericObject|Camera>} out * @param {constructor} constructor * @param {BinaryStream} stream * @param {number} size */ loadDynamicObjects(out, constructor, stream, size) { let end = stream.index + size; while (stream.index < end) { let object = new constructor(); object.readMdx(stream, this.version); out.push(object); } } /** * @param {BinaryStream} stream * @param {number} size */ loadPivotPointChunk(stream, size) { for (let i = 0, l = size / 12; i < l; i++) { this.pivotPoints.push(stream.readFloat32Array(new Float32Array(3))); } } /** * @param {BinaryStream} stream * @param {number} size */ loadBindPoseChunk(stream, size) { for (let i = 0, l = stream.readUint32(); i < l; i++) { this.bindPose[i] = stream.readFloat32Array(16); } } /** * Save the model as MDX. * * @return {ArrayBuffer} */ saveMdx() { let buffer = new ArrayBuffer(this.getByteLength()); let stream = new BinaryStream(buffer); stream.write('MDLX'); this.saveVersionChunk(stream); this.saveModelChunk(stream); this.saveStaticObjectChunk(stream, 'SEQS', this.sequences, 132); this.saveGlobalSequenceChunk(stream); this.saveDynamicObjectChunk(stream, 'MTLS', this.materials); this.saveStaticObjectChunk(stream, 'TEXS', this.textures, 268); this.saveDynamicObjectChunk(stream, 'TXAN', this.textureAnimations); this.saveDynamicObjectChunk(stream, 'GEOS', this.geosets); this.saveDynamicObjectChunk(stream, 'GEOA', this.geosetAnimations); this.saveDynamicObjectChunk(stream, 'BONE', this.bones); this.saveDynamicObjectChunk(stream, 'LITE', this.lights); this.saveDynamicObjectChunk(stream, 'HELP', this.helpers); this.saveDynamicObjectChunk(stream, 'ATCH', this.attachments); this.savePivotPointChunk(stream); this.saveDynamicObjectChunk(stream, 'PREM', this.particleEmitters); this.saveDynamicObjectChunk(stream, 'PRE2', this.particleEmitters2); this.saveDynamicObjectChunk(stream, 'RIBB', this.ribbonEmitters); this.saveDynamicObjectChunk(stream, 'CAMS', this.cameras); this.saveDynamicObjectChunk(stream, 'EVTS', this.eventObjects); this.saveDynamicObjectChunk(stream, 'CLID', this.collisionShapes); this.saveBindPoseChunk(stream); for (let chunk of this.unknownChunks) { chunk.writeMdx(stream); } return buffer; } /** * @param {BinaryStream} stream */ saveVersionChunk(stream) { stream.write('VERS'); stream.writeUint32(4); stream.writeUint32(this.version); } /** * @param {BinaryStream} stream */ saveModelChunk(stream) { stream.write('MODL'); stream.writeUint32(372); stream.write(this.name); stream.skip(80 - this.name.length); stream.write(this.animationFile); stream.skip(260 - this.animationFile.length); this.extent.writeMdx(stream); stream.writeUint32(this.blendTime); } /** * @param {BinaryStream} stream * @param {string} name * @param {Array<Sequence|Texture>} objects * @param {number} size */ saveStaticObjectChunk(stream, name, objects, size) { if (objects.length) { stream.write(name); stream.writeUint32(objects.length * size); for (let object of objects) { object.writeMdx(stream); } } } /** * @param {BinaryStream} stream */ saveGlobalSequenceChunk(stream) { if (this.globalSequences.length) { stream.write('GLBS'); stream.writeUint32(this.globalSequences.length * 4); for (let globalSequence of this.globalSequences) { stream.writeUint32(globalSequence); } } } /** * @param {BinaryStream} stream * @param {string} name * @param {Array<Material|TextureAnimation|Geoset|GeosetAnimation|GenericObject|Camera>} objects */ saveDynamicObjectChunk(stream, name, objects) { if (objects.length) { stream.write(name); stream.writeUint32(this.getObjectsByteLength(objects)); for (let object of objects) { object.writeMdx(stream, this.version); } } } /** * @param {BinaryStream} stream */ savePivotPointChunk(stream) { if (this.pivotPoints.length) { stream.write('PIVT'); stream.writeUint32(this.pivotPoints.length * 12); for (let pivotPoint of this.pivotPoints) { stream.writeFloat32Array(pivotPoint); } } } /** * @param {BinaryStream} stream */ saveBindPoseChunk(stream) { if (this.bindPose.length) { stream.write('BPOS'); stream.writeUint32(this.bindPose.length * 64); stream.writeUint32(this.bindPose.length); for (let matrix of this.bindPose) { stream.writeFloat32Array(matrix); } } } /** * Load the model from MDL. * * @param {string} buffer */ loadMdl(buffer) { let token; let stream = new TokenStream(buffer); while (token = stream.read()) { if (token === 'Version') { this.loadVersionBlock(stream); } else if (token === 'Model') { this.loadModelBlock(stream); } else if (token === 'Sequences') { this.loadNumberedObjectBlock(this.sequences, Sequence, 'Anim', stream); } else if (token === 'GlobalSequences') { this.loadGlobalSequenceBlock(stream); } else if (token === 'Textures') { this.loadNumberedObjectBlock(this.textures, Texture, 'Bitmap', stream); } else if (token === 'Materials') { this.loadNumberedObjectBlock(this.materials, Material, 'Material', stream); } else if (token === 'TextureAnims') { this.loadNumberedObjectBlock(this.textureAnimations, TextureAnimation, 'TVertexAnim', stream); } else if (token === 'Geoset') { this.loadObject(this.geosets, Geoset, stream); } else if (token === 'GeosetAnim') { this.loadObject(this.geosetAnimations, GeosetAnimation, stream); } else if (token === 'Bone') { this.loadObject(this.bones, Bone, stream); } else if (token === 'Light') { this.loadObject(this.lights, Light, stream); } else if (token === 'Helper') { this.loadObject(this.helpers, Helper, stream); } else if (token === 'Attachment') { this.loadObject(this.attachments, Attachment, stream); } else if (token === 'PivotPoints') { this.loadPivotPointBlock(stream); } else if (token === 'ParticleEmitter') { this.loadObject(this.particleEmitters, ParticleEmitter, stream); } else if (token === 'ParticleEmitter2') { this.loadObject(this.particleEmitters2, ParticleEmitter2, stream); } else if (token === 'RibbonEmitter') { this.loadObject(this.ribbonEmitters, RibbonEmitter, stream); } else if (token === 'Camera') { this.loadObject(this.cameras, Camera, stream); } else if (token === 'EventObject') { this.loadObject(this.eventObjects, EventObject, stream); } else if (token === 'CollisionShape') { this.loadObject(this.collisionShapes, CollisionShape, stream); } else { throw new Error(`Unsupported block: ${token}`); } } } /** * @param {TokenStream} stream */ loadVersionBlock(stream) { for (let token of stream.readBlock()) { if (token === 'FormatVersion') { this.version = stream.readInt(); } else { throw new Error(`Unknown token in Version: "${token}"`); } } } /** * @param {TokenStream} stream */ loadModelBlock(stream) { this.name = stream.read(); for (let token of stream.readBlock()) { if (token.startsWith('Num')) { // Don't care about the number of things, the arrays will grow as they wish. // This includes: // NumGeosets // NumGeosetAnims // NumHelpers // NumLights // NumBones // NumAttachments // NumParticleEmitters // NumParticleEmitters2 // NumRibbonEmitters // NumEvents stream.read(); } else if (token === 'BlendTime') { this.blendTime = stream.readInt(); } else if (token === 'MinimumExtent') { stream.readFloatArray(this.extent.min); } else if (token === 'MaximumExtent') { stream.readFloatArray(this.extent.max); } else if (token === 'BoundsRadius') { this.extent.boundsRadius = stream.readFloat(); } else { throw new Error(`Unknown token in Model: "${token}"`); } } } /** * @param {Array<Sequence|Texture|Material|TextureAnimation>} out * @param {constructor} constructor * @param {string} name * @param {TokenStream} stream */ loadNumberedObjectBlock(out, constructor, name, stream) { stream.read(); // Don't care about the number, the array will grow. for (let token of stream.readBlock()) { if (token === name) { let object = new constructor(); object.readMdl(stream); out.push(object); } else { throw new Error(`Unknown token in ${name}: "${token}"`); } } } /** * @param {TokenStream} stream */ loadGlobalSequenceBlock(stream) { stream.read(); // Don't care about the number, the array will grow. for (let token of stream.readBlock()) { if (token === 'Duration') { this.globalSequences.push(stream.readInt()); } else { throw new Error(`Unknown token in GlobalSequences: "${token}"`); } } } /** * @param {Array<Geoset|GeosetAnimation|GenericObject|Camera>} out * @param {constructor} constructor * @param {TokenStream} stream */ loadObject(out, constructor, stream) { let object = new constructor(); object.readMdl(stream); out.push(object); } /** * @param {TokenStream} stream */ loadPivotPointBlock(stream) { let count = stream.readInt(); stream.read(); // { for (let i = 0; i < count; i++) { this.pivotPoints.push(stream.readFloatArray(new Float32Array(3))); } stream.read(); // } } /** * Save the model as MDL. * * @return {string} */ saveMdl() { let stream = new TokenStream(); this.saveVersionBlock(stream); this.saveModelBlock(stream); this.saveStaticObjectsBlock(stream, 'Sequences', this.sequences); this.saveGlobalSequenceBlock(stream); this.saveStaticObjectsBlock(stream, 'Textures', this.textures); this.saveStaticObjectsBlock(stream, 'Materials', this.materials); this.saveStaticObjectsBlock(stream, 'TextureAnims', this.textureAnimations); this.saveObjects(stream, this.geosets); this.saveObjects(stream, this.geosetAnimations); this.saveObjects(stream, this.bones); this.saveObjects(stream, this.lights); this.saveObjects(stream, this.helpers); this.saveObjects(stream, this.attachments); this.savePivotPointBlock(stream); this.saveObjects(stream, this.particleEmitters); this.saveObjects(stream, this.particleEmitters2); this.saveObjects(stream, this.ribbonEmitters); this.saveObjects(stream, this.cameras); this.saveObjects(stream, this.eventObjects); this.saveObjects(stream, this.collisionShapes); return stream.buffer; } /** * @param {TokenStream} stream */ saveVersionBlock(stream) { stream.startBlock('Version'); stream.writeAttrib('FormatVersion', this.version); stream.endBlock(); } /** * @param {TokenStream} stream */ saveModelBlock(stream) { stream.startObjectBlock('Model', this.name); stream.writeAttrib('BlendTime', this.blendTime); this.extent.writeMdl(stream); stream.endBlock(); } /** * @param {TokenStream} stream * @param {string} name * @param {Array<Sequence|Texture|Material|TextureAnimation>} objects */ saveStaticObjectsBlock(stream, name, objects) { if (objects.length) { stream.startBlock(name, objects.length); for (let object of objects) { object.writeMdl(stream); } stream.endBlock(); } } /** * @param {TokenStream} stream */ saveGlobalSequenceBlock(stream) { if (this.globalSequences.length) { stream.startBlock('GlobalSequences', this.globalSequences.length); for (let globalSequence of this.globalSequences) { stream.writeAttrib('Duration', globalSequence); } stream.endBlock(); } } /** * @param {TokenStream} stream * @param {Array<Geoset|GeosetAnimation|GenericObject|Camera>} objects */ saveObjects(stream, objects) { for (let object of objects) { object.writeMdl(stream); } } /** * @param {TokenStream} stream */ savePivotPointBlock(stream) { if (this.pivotPoints.length) { stream.startBlock('PivotPoints', this.pivotPoints.length); for (let pivotPoint of this.pivotPoints) { stream.writeFloatArray(pivotPoint); } stream.endBlock(); } } /** * Calculate the size of the model as MDX. * * @return {number} */ getByteLength() { let size = 396; size += this.getStaticObjectsChunkByteLength(this.sequences, 132); size += this.getStaticObjectsChunkByteLength(this.globalSequences, 4); size += this.getDynamicObjectsChunkByteLength(this.materials); size += this.getStaticObjectsChunkByteLength(this.textures, 268); size += this.getDynamicObjectsChunkByteLength(this.textureAnimations); size += this.getDynamicObjectsChunkByteLength(this.geosets); size += this.getDynamicObjectsChunkByteLength(this.geosetAnimations); size += this.getDynamicObjectsChunkByteLength(this.bones); size += this.getDynamicObjectsChunkByteLength(this.lights); size += this.getDynamicObjectsChunkByteLength(this.helpers); size += this.getDynamicObjectsChunkByteLength(this.attachments); size += this.getStaticObjectsChunkByteLength(this.pivotPoints, 12); size += this.getDynamicObjectsChunkByteLength(this.particleEmitters); size += this.getDynamicObjectsChunkByteLength(this.particleEmitters2); size += this.getDynamicObjectsChunkByteLength(this.ribbonEmitters); size += this.getDynamicObjectsChunkByteLength(this.cameras); size += this.getDynamicObjectsChunkByteLength(this.eventObjects); size += this.getDynamicObjectsChunkByteLength(this.collisionShapes); size += this.getBindPoseChunkByteLength(); size += this.getObjectsByteLength(this.unknownChunks); return size; } /** * @param {Array<Material|TextureAnimation|Geoset|GeosetAnimation|GenericObject|Camera|UnknownChunk>} objects * @return {number} */ getObjectsByteLength(objects) { let size = 0; for (let object of objects) { size += object.getByteLength(this.version); } return size; } /** * @param {Array<Material|TextureAnimation|Geoset|GeosetAnimation|GenericObject|Camera|UnknownChunk>} objects * @return {number} */ getDynamicObjectsChunkByteLength(objects) { if (objects.length) { return 8 + this.getObjectsByteLength(objects); } return 0; } /** * @param {Array<Sequence|number|Texture|Float32Array>} objects * @param {number} size * @return {number} */ getStaticObjectsChunkByteLength(objects, size) { if (objects.length) { return 8 + objects.length * size; } return 0; } /** * @return {number} */ getBindPoseChunkByteLength() { if (this.bindPose.length) { return 12 + this.bindPose.length * 64; } return 0; } }