src/lib/pipeline/m2/index.js
import THREE from 'three';
import Submesh from './submesh';
import M2Material from './material';
import AnimationManager from './animation-manager';
import BatchManager from './batch-manager';
class M2 extends THREE.Group {
static cache = {};
constructor(path, data, skinData, instance = null) {
super();
this.matrixAutoUpdate = false;
this.eventListeners = [];
this.name = path.split('\\').slice(-1).pop();
this.path = path;
this.data = data;
this.skinData = skinData;
this.batchManager = new BatchManager();
// Instanceable M2s share geometry, texture units, and animations.
this.canInstance = data.canInstance;
this.animated = data.animated;
this.billboards = [];
// Keep track of whether or not to use skinning. If the M2 has bone animations, useSkinning is
// set to true, and all meshes and materials used in the M2 will be skinning enabled. Otherwise,
// skinning will not be enabled. Skinning has a very significant impact on the render loop in
// three.js.
this.useSkinning = false;
this.mesh = null;
this.submeshes = [];
this.parts = new Map();
this.geometry = null;
this.submeshGeometries = new Map();
this.skeleton = null;
this.bones = [];
this.rootBones = [];
if (instance) {
this.animations = instance.animations;
// To prevent over-updating animation timelines, instanced M2s shouldn't receive animation
// time deltas. Instead, only the original M2 should receive time deltas.
this.receivesAnimationUpdates = false;
} else {
this.animations = new AnimationManager(this, data.animations, data.sequences);
if (this.animated) {
this.receivesAnimationUpdates = true;
} else {
this.receivesAnimationUpdates = false;
}
}
this.createSkeleton(data.bones);
// Instanced M2s can share geometries and texture units.
if (instance) {
this.batches = instance.batches;
this.geometry = instance.geometry;
this.submeshGeometries = instance.submeshGeometries;
} else {
this.createTextureAnimations(data);
this.createBatches(data, skinData);
this.createGeometry(data.vertices);
}
this.createMesh(this.geometry, this.skeleton, this.rootBones);
this.createSubmeshes(data, skinData);
}
createSkeleton(boneDefs) {
const rootBones = [];
const bones = [];
const billboards = [];
for (let boneIndex = 0, len = boneDefs.length; boneIndex < len; ++boneIndex) {
const boneDef = boneDefs[boneIndex];
const bone = new THREE.Bone();
bones.push(bone);
// M2 bone positioning seems to be inverted on X and Y
const { pivotPoint } = boneDef;
const correctedPosition = new THREE.Vector3(-pivotPoint[0], -pivotPoint[1], pivotPoint[2]);
bone.position.copy(correctedPosition);
if (boneDef.parentID > -1) {
const parent = bones[boneDef.parentID];
parent.add(bone);
// Correct bone positioning relative to parent
let up = bone;
while (up = up.parent) {
bone.position.sub(up.position);
}
} else {
bone.userData.isRoot = true;
rootBones.push(bone);
}
// Enable skinning support on this M2 if we have bone animations.
if (boneDef.animated) {
this.useSkinning = true;
}
// Flag billboarded bones
if (boneDef.billboarded) {
bone.userData.billboarded = true;
bone.userData.billboardType = boneDef.billboardType;
billboards.push(bone);
}
// Bone translation animation block
if (boneDef.translation.animated) {
this.animations.registerTrack({
target: bone,
property: 'position',
animationBlock: boneDef.translation,
trackType: 'VectorKeyframeTrack',
valueTransform: function(value) {
return [
bone.position.x + -value[0],
bone.position.y + -value[1],
bone.position.z + value[2]
];
}
});
}
// Bone rotation animation block
if (boneDef.rotation.animated) {
this.animations.registerTrack({
target: bone,
property: 'quaternion',
animationBlock: boneDef.rotation,
trackType: 'QuaternionKeyframeTrack',
valueTransform: function(value) {
return [value[0], value[1], -value[2], -value[3]];
}
});
}
// Bone scaling animation block
if (boneDef.scaling.animated) {
this.animations.registerTrack({
target: bone,
property: 'scale',
animationBlock: boneDef.scaling,
trackType: 'VectorKeyframeTrack'
});
}
}
// Preserve the bones
this.bones = bones;
this.rootBones = rootBones;
this.billboards = billboards;
// Assemble the skeleton
this.skeleton = new THREE.Skeleton(bones);
this.skeleton.matrixAutoUpdate = this.matrixAutoUpdate;
}
// Returns a map of M2Materials indexed by submesh. Each material represents a batch,
// to be rendered in the order of appearance in the map's entry for the submesh index.
createBatches(data, skinData) {
const batches = new Map();
const batchDefs = this.batchManager.createDefs(data, skinData);
const batchLen = batchDefs.length;
for (let batchIndex = 0; batchIndex < batchLen; ++batchIndex) {
const batchDef = batchDefs[batchIndex];
const { submeshIndex } = batchDef;
if (!batches.has(submeshIndex)) {
batches.set(submeshIndex, []);
}
// Array that will contain materials matching each batch.
const submeshBatches = batches.get(submeshIndex);
// Observe the M2's skinning flag in the M2Material.
batchDef.useSkinning = this.useSkinning;
const batchMaterial = new M2Material(this, batchDef);
submeshBatches.unshift(batchMaterial);
}
this.batches = batches;
}
createGeometry(vertices) {
const geometry = new THREE.Geometry();
for (let vertexIndex = 0, len = vertices.length; vertexIndex < len; ++vertexIndex) {
const vertex = vertices[vertexIndex];
const { position } = vertex;
geometry.vertices.push(
// Provided as (X, Z, -Y)
new THREE.Vector3(position[0], position[2], -position[1])
);
geometry.skinIndices.push(
new THREE.Vector4(...vertex.boneIndices)
);
geometry.skinWeights.push(
new THREE.Vector4(...vertex.boneWeights)
);
}
// Mirror geometry over X and Y axes and rotate
const matrix = new THREE.Matrix4();
matrix.makeScale(-1, -1, 1);
geometry.applyMatrix(matrix);
geometry.rotateX(-Math.PI / 2);
// Preserve the geometry
this.geometry = geometry;
}
createMesh(geometry, skeleton, rootBones) {
let mesh;
if (this.useSkinning) {
mesh = new THREE.SkinnedMesh(geometry);
// Assign root bones to mesh
rootBones.forEach((bone) => {
mesh.add(bone);
bone.skin = mesh;
});
// Bind mesh to skeleton
mesh.bind(skeleton);
} else {
mesh = new THREE.Mesh(geometry);
}
mesh.matrixAutoUpdate = this.matrixAutoUpdate;
// Never display the mesh
// TODO: We shouldn't really even have this mesh in the first place, should we?
mesh.visible = false;
// Add mesh to the group
this.add(mesh);
// Assign as root mesh
this.mesh = mesh;
}
createSubmeshes(data, skinData) {
const { vertices } = data;
const { submeshes, indices, triangles } = skinData;
const subLen = submeshes.length;
for (let submeshIndex = 0; submeshIndex < subLen; ++submeshIndex) {
const submeshDef = submeshes[submeshIndex];
// Bring up relevant batches and geometry.
const submeshBatches = this.batches.get(submeshIndex);
const submeshGeometry = this.submeshGeometries.get(submeshIndex) ||
this.createSubmeshGeometry(submeshDef, indices, triangles, vertices);
const submesh = this.createSubmesh(submeshDef, submeshGeometry, submeshBatches);
this.parts.set(submesh.userData.partID, submesh);
this.submeshes.push(submesh);
this.submeshGeometries.set(submeshIndex, submeshGeometry);
this.add(submesh);
}
}
createSubmeshGeometry(submeshDef, indices, triangles, vertices) {
const geometry = this.geometry.clone();
// TODO: Figure out why this isn't cloned by the line above
geometry.skinIndices = Array.from(this.geometry.skinIndices);
geometry.skinWeights = Array.from(this.geometry.skinWeights);
const uvs = [];
const { startTriangle: start, triangleCount: count } = submeshDef;
for (let i = start, faceIndex = 0; i < start + count; i += 3, ++faceIndex) {
const vindices = [
indices[triangles[i]],
indices[triangles[i + 1]],
indices[triangles[i + 2]]
];
const face = new THREE.Face3(vindices[0], vindices[1], vindices[2]);
geometry.faces.push(face);
uvs[faceIndex] = [];
for (let vinIndex = 0, vinLen = vindices.length; vinIndex < vinLen; ++vinIndex) {
const index = vindices[vinIndex];
const { textureCoords, normal } = vertices[index];
uvs[faceIndex].push(new THREE.Vector2(textureCoords[0][0], textureCoords[0][1]));
face.vertexNormals.push(new THREE.Vector3(normal[0], normal[1], normal[2]));
}
}
geometry.faceVertexUvs = [uvs];
const bufferGeometry = new THREE.BufferGeometry().fromGeometry(geometry);
return bufferGeometry;
}
createSubmesh(submeshDef, geometry, batches) {
const rootBone = this.bones[submeshDef.rootBone];
const opts = {
skeleton: this.skeleton,
geometry: geometry,
rootBone: rootBone,
useSkinning: this.useSkinning,
matrixAutoUpdate: this.matrixAutoUpdate
};
const submesh = new Submesh(opts);
submesh.applyBatches(batches);
submesh.userData.partID = submeshDef.partID;
return submesh;
}
createTextureAnimations(data) {
this.textureAnimations = new THREE.Object3D();
this.uvAnimationValues = [];
this.transparencyAnimationValues = [];
this.vertexColorAnimationValues = [];
const { uvAnimations, transparencyAnimations, vertexColorAnimations } = data;
this.createUVAnimations(uvAnimations);
this.createTransparencyAnimations(transparencyAnimations);
this.createVertexColorAnimations(vertexColorAnimations);
}
// TODO: Add support for rotation and scaling in UV animations.
createUVAnimations(uvAnimationDefs) {
if (uvAnimationDefs.length === 0) {
return;
}
uvAnimationDefs.forEach((uvAnimationDef, index) => {
// Default value
this.uvAnimationValues[index] = {
translation: [1.0, 1.0, 1.0],
rotation: [0.0, 0.0, 0.0, 1.0],
scaling: [1.0, 1.0, 1.0],
matrix: new THREE.Matrix4()
};
const { translation } = uvAnimationDef;
this.animations.registerTrack({
target: this,
property: 'uvAnimationValues[' + index + '].translation',
animationBlock: translation,
trackType: 'VectorKeyframeTrack'
});
// Set up event subscription to produce matrix from translation, rotation, and scaling
// values.
const updater = () => {
const animationValue = this.uvAnimationValues[index];
// Set up matrix for use in uv transform in vertex shader.
animationValue.matrix = new THREE.Matrix4().compose(
new THREE.Vector3(...animationValue.translation),
new THREE.Quaternion(...animationValue.rotation),
new THREE.Vector3(...animationValue.scaling)
);
};
this.animations.on('update', updater);
this.eventListeners.push([this.animations, 'update', updater]);
});
}
createTransparencyAnimations(transparencyAnimationDefs) {
if (transparencyAnimationDefs.length === 0) {
return;
}
transparencyAnimationDefs.forEach((transparencyAnimationDef, index) => {
// Default value
this.transparencyAnimationValues[index] = 1.0;
this.animations.registerTrack({
target: this,
property: 'transparencyAnimationValues[' + index + ']',
animationBlock: transparencyAnimationDef,
trackType: 'NumberKeyframeTrack',
valueTransform: function(value) {
return [value];
}
});
});
}
createVertexColorAnimations(vertexColorAnimationDefs) {
if (vertexColorAnimationDefs.length === 0) {
return;
}
vertexColorAnimationDefs.forEach((vertexColorAnimationDef, index) => {
// Default value
this.vertexColorAnimationValues[index] = {
color: [1.0, 1.0, 1.0],
alpha: 1.0
};
const { color, alpha } = vertexColorAnimationDef;
this.animations.registerTrack({
target: this,
property: 'vertexColorAnimationValues[' + index + '].color',
animationBlock: color,
trackType: 'VectorKeyframeTrack'
});
this.animations.registerTrack({
target: this,
property: 'vertexColorAnimationValues[' + index + '].alpha',
animationBlock: alpha,
trackType: 'NumberKeyframeTrack',
valueTransform: function(value) {
return [value];
}
});
});
}
applyBillboards(camera) {
for (let i = 0, len = this.billboards.length; i < len; ++i) {
const bone = this.billboards[i];
switch (bone.userData.billboardType) {
case 0:
this.applySphericalBillboard(camera, bone);
break;
case 3:
this.applyCylindricalZBillboard(camera, bone);
break;
default:
break;
}
}
}
applySphericalBillboard(camera, bone) {
const boneRoot = bone.skin;
if (!boneRoot) {
return;
}
const camPos = this.worldToLocal(camera.position.clone());
const modelForward = new THREE.Vector3(camPos.x, camPos.y, camPos.z);
modelForward.normalize();
const modelVmEl = boneRoot.modelViewMatrix.elements;
const modelRight = new THREE.Vector3(modelVmEl[0], modelVmEl[4], modelVmEl[8]);
modelRight.multiplyScalar(-1);
const modelUp = new THREE.Vector3();
modelUp.crossVectors(modelForward, modelRight);
modelUp.normalize();
const rotateMatrix = new THREE.Matrix4();
rotateMatrix.set(
modelForward.x, modelRight.x, modelUp.x, 0,
modelForward.y, modelRight.y, modelUp.y, 0,
modelForward.z, modelRight.z, modelUp.z, 0,
0, 0, 0, 1
);
bone.rotation.setFromRotationMatrix(rotateMatrix);
}
applyCylindricalZBillboard(camera, bone) {
const boneRoot = bone.skin;
if (!boneRoot) {
return;
}
const camPos = this.worldToLocal(camera.position.clone());
const modelForward = new THREE.Vector3(camPos.x, camPos.y, camPos.z);
modelForward.normalize();
const modelVmEl = boneRoot.modelViewMatrix.elements;
const modelRight = new THREE.Vector3(modelVmEl[0], modelVmEl[4], modelVmEl[8]);
const modelUp = new THREE.Vector3(0, 0, 1);
const rotateMatrix = new THREE.Matrix4();
rotateMatrix.set(
modelForward.x, modelRight.x, modelUp.x, 0,
modelForward.y, modelRight.y, modelUp.y, 0,
modelForward.z, modelRight.z, modelUp.z, 0,
0, 0, 0, 1
);
bone.rotation.setFromRotationMatrix(rotateMatrix);
}
set displayInfo(displayInfo) {
for (let i = 0, len = this.submeshes.length; i < len; ++i) {
this.submeshes[i].displayInfo = displayInfo;
}
}
detachEventListeners() {
this.eventListeners.forEach((entry) => {
const [target, event, listener] = entry;
target.removeListener(event, listener);
});
}
dispose() {
this.detachEventListeners();
this.eventListeners = [];
this.geometry.dispose();
this.mesh.geometry.dispose();
this.submeshes.forEach((submesh) => {
submesh.dispose();
});
}
clone() {
let instance = {};
if (this.canInstance) {
instance.animations = this.animations;
instance.geometry = this.geometry;
instance.submeshGeometries = this.submeshGeometries;
instance.batches = this.batches;
} else {
instance = null;
}
return new this.constructor(this.path, this.data, this.skinData, instance);
}
}
export default M2;