UNPKG

mdx-m3-viewer

Version:

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

752 lines (640 loc) 25.5 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 GenericObject from './genericobject'; 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 ParticleEmitterPopcorn from './particleemitterpopcorn'; 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 { /** * 800 for Warcraft 3: RoC and TFT. * >800 for Warcraft 3: Reforged. */ version: number = 800; name: string = ''; /** * To the best of my knowledge, this should always be left empty. */ animationFile: string = ''; extent: Extent = new Extent(); /** * This is only used by the now-defunct previewer that came with Art Tools. */ blendTime: number = 0; sequences: Sequence[] = []; globalSequences: number[] = []; materials: Material[] = []; textures: Texture[] = []; textureAnimations: TextureAnimation[] = []; geosets: Geoset[] = []; geosetAnimations: GeosetAnimation[] = []; bones: Bone[] = []; lights: Light[] = []; helpers: Helper[] = []; attachments: Attachment[] = []; pivotPoints: Float32Array[] = []; particleEmitters: ParticleEmitter[] = []; particleEmitters2: ParticleEmitter2[] = []; /** * @since 900 */ particleEmittersPopcorn: ParticleEmitterPopcorn[] = []; ribbonEmitters: RibbonEmitter[] = []; cameras: Camera[] = []; eventObjects: EventObject[] = []; collisionShapes: CollisionShape[] = []; /** * @since 900 */ faceEffectTarget: string = ''; /** * A path to a face effect file, which is used by the FaceFX runtime * * @since 900 */ faceEffect: string = ''; /** * @since 900 */ bindPose: Float32Array[] = []; /** * 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. */ unknownChunks: UnknownChunk[] = []; constructor(buffer?: ArrayBuffer | string) { 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. */ load(buffer: ArrayBuffer | string) { if (buffer instanceof ArrayBuffer) { this.loadMdx(buffer); } else { this.loadMdl(buffer); } } /** * Load the model from MDX. */ loadMdx(buffer: ArrayBuffer) { 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 === 'CORN') { this.loadDynamicObjects(this.particleEmittersPopcorn, ParticleEmitterPopcorn, 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 === 'FAFX') { this.loadFaceEffectChunk(stream, size); } else if (tag === 'BPOS') { this.loadBindPoseChunk(stream, size); } else { this.unknownChunks.push(new UnknownChunk(stream, size, tag)); } } } loadVersionChunk(stream: BinaryStream) { this.version = stream.readUint32(); } loadModelChunk(stream: BinaryStream) { this.name = stream.read(80); this.animationFile = stream.read(260); this.extent.readMdx(stream); this.blendTime = stream.readUint32(); } loadStaticObjects(out: any[], constructor: typeof Sequence | typeof Texture, stream: BinaryStream, count: number) { for (let i = 0; i < count; i++) { let object = new constructor(); object.readMdx(stream); out.push(object); } } loadGlobalSequenceChunk(stream: BinaryStream, size: number) { for (let i = 0, l = size / 4; i < l; i++) { this.globalSequences.push(stream.readUint32()); } } loadDynamicObjects(out: any[], constructor: typeof Material | typeof TextureAnimation | typeof Geoset | typeof GeosetAnimation | typeof Bone | typeof Light | typeof Helper | typeof Attachment | typeof ParticleEmitter | typeof ParticleEmitter2 | typeof RibbonEmitter | typeof Camera | typeof EventObject | typeof CollisionShape | typeof ParticleEmitterPopcorn, stream: BinaryStream, size: number) { let end = stream.index + size; while (stream.index < end) { let object = new constructor(); object.readMdx(stream, this.version); out.push(object); } } loadPivotPointChunk(stream: BinaryStream, size: number) { for (let i = 0, l = size / 12; i < l; i++) { this.pivotPoints.push(stream.readFloat32Array(3)); } } loadFaceEffectChunk(stream: BinaryStream, size: number) { this.faceEffectTarget = stream.read(80); this.faceEffect = stream.read(260); } loadBindPoseChunk(stream: BinaryStream, size: number) { for (let i = 0, l = stream.readUint32(); i < l; i++) { this.bindPose[i] = stream.readFloat32Array(12); } } /** * Save the model as MDX. */ 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); if (this.version > 800) { this.saveDynamicObjectChunk(stream, 'CORN', this.particleEmittersPopcorn); } this.saveDynamicObjectChunk(stream, 'RIBB', this.ribbonEmitters); this.saveDynamicObjectChunk(stream, 'CAMS', this.cameras); this.saveDynamicObjectChunk(stream, 'EVTS', this.eventObjects); this.saveDynamicObjectChunk(stream, 'CLID', this.collisionShapes); if (this.version > 800) { this.saveFaceEffectChunk(stream); this.saveBindPoseChunk(stream); } for (let chunk of this.unknownChunks) { chunk.writeMdx(stream); } return buffer; } saveVersionChunk(stream: BinaryStream) { stream.write('VERS'); stream.writeUint32(4); stream.writeUint32(this.version); } saveModelChunk(stream: BinaryStream) { 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); } saveStaticObjectChunk(stream: BinaryStream, name: string, objects: (Sequence | Texture)[], size: number) { if (objects.length) { stream.write(name); stream.writeUint32(objects.length * size); for (let object of objects) { object.writeMdx(stream); } } } saveGlobalSequenceChunk(stream: BinaryStream) { if (this.globalSequences.length) { stream.write('GLBS'); stream.writeUint32(this.globalSequences.length * 4); for (let globalSequence of this.globalSequences) { stream.writeUint32(globalSequence); } } } saveDynamicObjectChunk(stream: BinaryStream, name: string, objects: (Material | TextureAnimation | Geoset | GeosetAnimation | GenericObject | Camera)[]) { if (objects.length) { stream.write(name); stream.writeUint32(this.getObjectsByteLength(objects)); for (let object of objects) { object.writeMdx(stream, this.version); } } } savePivotPointChunk(stream: BinaryStream) { if (this.pivotPoints.length) { stream.write('PIVT'); stream.writeUint32(this.pivotPoints.length * 12); for (let pivotPoint of this.pivotPoints) { stream.writeFloat32Array(pivotPoint); } } } saveFaceEffectChunk(stream: BinaryStream) { if (this.faceEffectTarget.length || this.faceEffect.length) { stream.write('FAFX'); stream.writeUint32(340); stream.write(this.faceEffectTarget); stream.skip(80 - this.faceEffectTarget.length); stream.write(this.faceEffect); stream.skip(260 - this.faceEffect.length); } } saveBindPoseChunk(stream: BinaryStream) { if (this.bindPose.length) { stream.write('BPOS'); stream.writeUint32(4 + this.bindPose.length * 48); stream.writeUint32(this.bindPose.length); for (let matrix of this.bindPose) { stream.writeFloat32Array(matrix); } } } /** * Load the model from MDL. */ loadMdl(buffer: string) { let token: string; let stream = new TokenStream(buffer); while (token = <string>stream.readToken()) { 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 === 'ParticleEmitterPopcorn') { this.loadObject(this.particleEmittersPopcorn, ParticleEmitterPopcorn, 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 if (token === 'FaceFX') { this.loadFaceEffectBlock(stream); } else if (token === 'BindPose') { this.loadBindPoseBlock(stream); } else { throw new Error(`Unsupported block: ${token}`); } } } loadVersionBlock(stream: TokenStream) { for (let token of stream.readBlock()) { if (token === 'FormatVersion') { this.version = stream.readInt(); } else { throw new Error(`Unknown token in Version: "${token}"`); } } } loadModelBlock(stream: TokenStream) { 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 // NumSoundEmitters (deprecated) // NumAttachments // NumParticleEmitters // NumParticleEmitters2 // NumParticleEmittersPopcorn (>800) // NumRibbonEmitters // NumEvents // NumFaceFX (>800) stream.read(); } else if (token === 'BlendTime') { this.blendTime = stream.readInt(); } else if (token === 'MinimumExtent') { stream.readVector(this.extent.min); } else if (token === 'MaximumExtent') { stream.readVector(this.extent.max); } else if (token === 'BoundsRadius') { this.extent.boundsRadius = stream.readFloat(); } else if (token === 'AnimationFile') { this.animationFile = stream.read(); } else { throw new Error(`Unknown token in Model: "${token}"`); } } } loadNumberedObjectBlock(out: any[], constructor: typeof Sequence | typeof Texture | typeof Material | typeof TextureAnimation, name: string, stream: TokenStream) { 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}"`); } } } loadGlobalSequenceBlock(stream: TokenStream) { 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}"`); } } } loadObject(out: any[], constructor: typeof Geoset | typeof GeosetAnimation | typeof Bone | typeof Light | typeof Helper | typeof Attachment | typeof ParticleEmitter | typeof ParticleEmitter2 | typeof RibbonEmitter | typeof Camera | typeof EventObject | typeof CollisionShape, stream: TokenStream) { let object = new constructor(); object.readMdl(stream); out.push(object); } loadPivotPointBlock(stream: TokenStream) { let count = stream.readInt(); stream.read(); // { for (let i = 0; i < count; i++) { this.pivotPoints.push(<Float32Array>stream.readVector(new Float32Array(3))); } stream.read(); // } } loadFaceEffectBlock(stream: TokenStream) { this.faceEffectTarget = stream.read(); for (let token of stream.readBlock()) { if (token === 'Path') { this.faceEffect = stream.read(); } else { throw new Error(`Unknown token in FaceFX: "${token}"`); } } } loadBindPoseBlock(stream: TokenStream) { for (let token of stream.readBlock()) { if (token === 'Matrices') { let matrices = stream.readInt(); stream.read(); // { for (let i = 0; i < matrices; i++) { this.bindPose[i] = <Float32Array>stream.readVector(new Float32Array(12)); } stream.read(); // } } else { throw new Error(`Unknown token in BindPose: "${token}"`); } } } /** * Save the model as MDL. */ 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); if (this.version > 800) { this.saveObjects(stream, this.particleEmittersPopcorn) } this.saveObjects(stream, this.ribbonEmitters); this.saveObjects(stream, this.cameras); this.saveObjects(stream, this.eventObjects); this.saveObjects(stream, this.collisionShapes); if (this.version > 800) { this.saveFaceEffectBlock(stream); this.saveBindPoseBlock(stream); } return stream.buffer; } saveVersionBlock(stream: TokenStream) { stream.startBlock('Version'); stream.writeNumberAttrib('FormatVersion', this.version); stream.endBlock(); } saveModelBlock(stream: TokenStream) { stream.startObjectBlock('Model', this.name); stream.writeNumberAttrib('BlendTime', this.blendTime); this.extent.writeMdl(stream); if (this.animationFile.length) { stream.writeStringAttrib('AnimationFile', this.animationFile); } stream.endBlock(); } saveStaticObjectsBlock(stream: TokenStream, name: string, objects: (Sequence | Texture | Material | TextureAnimation)[]) { if (objects.length) { stream.startBlock(name, objects.length); for (let object of objects) { object.writeMdl(stream, this.version); } stream.endBlock(); } } saveGlobalSequenceBlock(stream: TokenStream) { if (this.globalSequences.length) { stream.startBlock('GlobalSequences', this.globalSequences.length); for (let globalSequence of this.globalSequences) { stream.writeNumberAttrib('Duration', globalSequence); } stream.endBlock(); } } saveObjects(stream: TokenStream, objects: (Geoset | GeosetAnimation | Bone | Light | Helper | Attachment | ParticleEmitter | ParticleEmitter2 | RibbonEmitter | Camera | EventObject | CollisionShape)[]) { for (let object of objects) { object.writeMdl(stream, this.version); } } savePivotPointBlock(stream: TokenStream) { if (this.pivotPoints.length) { stream.startBlock('PivotPoints', this.pivotPoints.length); for (let pivotPoint of this.pivotPoints) { stream.writeVector(pivotPoint); } stream.endBlock(); } } saveFaceEffectBlock(stream: TokenStream) { if (this.faceEffectTarget.length && this.faceEffect.length) { stream.startObjectBlock('FaceFX', this.faceEffectTarget); stream.writeStringAttrib('Path', this.faceEffect); stream.endBlock(); } } saveBindPoseBlock(stream: TokenStream) { if (this.bindPose.length) { stream.startBlock('BindPose'); stream.startBlock('Matrices', this.bindPose.length); for (let matrix of this.bindPose) { stream.writeVector(matrix); } stream.endBlock(); stream.endBlock(); } } /** * Calculate the size of the model as MDX. */ 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); if (this.version > 800) { size += this.getDynamicObjectsChunkByteLength(this.particleEmittersPopcorn); } size += this.getDynamicObjectsChunkByteLength(this.ribbonEmitters); size += this.getDynamicObjectsChunkByteLength(this.cameras); size += this.getDynamicObjectsChunkByteLength(this.eventObjects); size += this.getDynamicObjectsChunkByteLength(this.collisionShapes); if (this.version > 800) { size += this.getFaceEffectChunkByteLength(); size += this.getBindPoseChunkByteLength(); } size += this.getObjectsByteLength(this.unknownChunks); return size; } getObjectsByteLength(objects: (Material | TextureAnimation | Geoset | GeosetAnimation | GenericObject | Camera | UnknownChunk)[]) { let size = 0; for (let object of objects) { size += object.getByteLength(this.version); } return size; } getDynamicObjectsChunkByteLength(objects: (Material | TextureAnimation | Geoset | GeosetAnimation | GenericObject | Camera | UnknownChunk)[]) { if (objects.length) { return 8 + this.getObjectsByteLength(objects); } return 0; } getStaticObjectsChunkByteLength(objects: (Sequence | number | Texture | Float32Array)[], size: number) { if (objects.length) { return 8 + objects.length * size; } return 0; } getFaceEffectChunkByteLength() { if (this.faceEffectTarget.length || this.faceEffect.length) { return 348; } return 0; } getBindPoseChunkByteLength() { if (this.bindPose.length) { return 12 + this.bindPose.length * 48; } return 0; } }