After quite a bit tinkering, I found out how to add a second (also animated) character via three.js
and mmd(helper/animator):
However it appears this second character will only work well if it has the SAME bone structure, height etc. as the first.
As soon as I choose a different one, there will be
a) deformations (that would please satan)
or
b) straight up bone binding errors (that halt all progess)
three.js:42519 THREE.PropertyBinding: Trying to bind to objectIndex of objectName, but is undefined. PropertyBinding {path: '.bones[左中指先].position', parsedPath: {…}, node: SkinnedMesh, rootNode: SkinnedMesh, getValue: ƒ, …} (283) [Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, Bone, …]
bind @ three.js:42519
getValue_unbound @ three.js:42398
saveOriginalState @ three.js:41893
_activateAction @ three.js:43858
play @ three.js:43133
_setupMeshAnimation @ MMDAnimationHelper.js:439
_addMesh @ MMDAnimationHelper.js:296
add @ MMDAnimationHelper.js:90
VmdControl @ main.js:245
eval @ VM447:25
onclick @ index.html?pmx2=YusakuFujiki/yusaku.pmx&stage=/livestageclub/livestageclubanimated.pmx:128
three.js:42519 THREE.PropertyBinding: Trying to bind to objectIndex of objectName, but is undefined. PropertyBinding {path: '.bones[左中指先].quaternion', parsedPath: {…}, node: SkinnedMesh, rootNode: SkinnedMesh, getValue: ƒ, …}
(Here, mesh2 has 283 bones. mesh 1 130 …)
I suspect that the animation (vmd) is applied to both pmx and pmx2 without rhyme or reason.
How can this be done in a way that accomodates both models individual chracteristics ?
Note:
All animations play well when only applied to a single model - no matter which.
Only the introduction of a second model poses issues.
Here is my code
let scene, renderer, camera, mesh, mesh2, helper, cameraAnimation;
let ready = false;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const clock = new THREE.Clock();
function getQueryStringValue(key) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(key);}
const pmx = getQueryStringValue('pmx');
const pmx2 = getQueryStringValue('pmx2');
const stage = getQueryStringValue('stage') || "/sorclair/sorclair.pmx";
const light = getQueryStringValue('light');
let Pmx;
let Pmx2;
let StagePath = stage ? `./stages/${stage.trim()}` : './stages/sorclair/sorclair.pmx';
if (pmx) {
Pmx = `./pmx/pronama/${pmx.trim()}`;
console.log(`PMX: ${pmx.trim()}`);
} else {
console.log("No PMX selected.");
}
if (pmx2) {
Pmx2 = `./pmx/pronama/${pmx2.trim()}`;
console.log(`PMX2: ${pmx2.trim()}`);
} else {
console.log("No PMX2 selected.");
}
if (StagePath) {
StagePath = `./stages${stage.trim()}`;
} else {
StagePath = './stages/sorclair/sorclair.pmx';
}
console.log('StagePath:', StagePath);
if (StagePath) {
const loader = new THREE.MMDLoader();
const lastIndex = StagePath.lastIndexOf("/");
const basePath = StagePath.substring(0, lastIndex);
const vmd1Path = `${basePath}/001.vmd`;
const vmd2Path = `${basePath}/002.vmd`;
loader.load(StagePath, (stageObject) => {
var ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
scene.add(ambientLight);
scene.add(stageObject);
const mixer = new THREE.AnimationMixer(stageObject);
loader.loadAnimation(vmd1Path, stageObject, (vmd1Clip) => {
vmd1Clip.name = "001";
console.log(`Loaded VMD: ${vmd1Path}`);
const motionObject1 = MotionObjects.find(obj => obj.id === "001");
if (motionObject1) {
motionObject1.VmdClip = vmd1Clip;
const action1 = mixer.clipAction(vmd1Clip);
action1.play();
} else {
console.warn(`Motion object with id "001" not found.`);
}
}, onProgress, onError);
loader.loadAnimation(vmd2Path, stageObject, (vmd2Clip) => {
vmd2Clip.name = "002";
console.log(`Loaded VMD: ${vmd2Path}`);
const motionObject2 = MotionObjects.find(obj => obj.id === "002");
if (motionObject2) {
motionObject2.VmdClip = vmd2Clip;
const action2 = mixer.clipAction(vmd2Clip);
action2.play();
} else {
console.warn(`Motion object with id "002" not found.`);
}
}, onProgress, onError);
const clock = new THREE.Clock();
const animate = () => {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
};
animate();
}, onProgress, onError);
} else {
console.warn('No valid stage path found.');
}
//if (!Stage) {Stage = stage ? `stages${stage}` : '/sorclair/sorclair.pmx';}
if (!Pmx) {Pmx = `./pmx/pronama/AoiZaizen/AoiZaizen.pmx`;}
const MotionObjects = [
{ id: "001", pose: "001", VmdClip: null, AudioClip: false },
{ id: "002", pose: "002", VmdClip: null, AudioClip: false },
{ id: "003", pose: "003", VmdClip: null, AudioClip: false },
{ id: "004walknthink", pose: "004walknthink", VmdClip: null, AudioClip: false },
{ id: "005hard-carry", pose: "005hard-carry", VmdClip: null, AudioClip: true },
{ id: "bts-bestofme", pose: "bts-bestofme", VmdClip: null, AudioClip: true },
{ id: "seniorita", pose: "seniorita", VmdClip: null, AudioClip: true },
{ id: "test", pose: "test", VmdClip: null, AudioClip: false },
{ id: "lupin", pose: "lupin", VmdClip: null, AudioClip: true },
{ id: "lp-alightthatnevercomes", pose: "lp-alightthatnevercomes", VmdClip: null, AudioClip: true },
{ id: "pillarmen", pose: "pillarmen", VmdClip: null, AudioClip: true },
{ id: "halseywithoutme", pose: "halseywithoutme", VmdClip: null, AudioClip: true },
{ id: "riversinthedesert", pose: "riversinthedesert", VmdClip: null, AudioClip: true },
];
window.onload = () => {
Init();
LoadModeler();
Render();
};
function Init() {
document.getElementById("moveLeftButton").addEventListener("click", moveCameraLeft);
document.getElementById("moveRightButton").addEventListener("click", moveCameraRight);
document.getElementById("moveUpButton").addEventListener("click", moveCameraUp);
document.getElementById("moveDownButton").addEventListener("click", moveCameraDown);
document.getElementById("rotaterightButton").addEventListener("click", rotateCameraRight);
document.getElementById("rotateleftButton").addEventListener("click", rotateCameraLeft);
function moveCameraLeft() { camera.position.x -= 1; }
function moveCameraRight() { camera.position.x += 1; }
function moveCameraUp() { camera.position.y += 1; }
function moveCameraDown() { camera.position.y -= 1; }
function rotateCameraRight() { mesh.rotateY(Math.PI / 4); }
function rotateCameraLeft() { mesh.rotateY(-Math.PI / 4); }
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
//renderer.setClearColor(0xcccccc, 0);
document.body.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(100, windowWidth / windowHeight, 1, 1000);
camera.position.set(0, 19, 20);
}
async function LoadModeler() {
const loader = new THREE.MMDLoader();
function LoadPMX(path) {
return new Promise(resolve => {
loader.load(path, object => {
resolve(object);
});
});
}
async function LoadAllModels() {
mesh = await LoadPMX(Pmx);
scene.add(mesh);
if (Pmx2) {
mesh2 = await LoadPMX(Pmx2);
mesh2.position.x += 15;
scene.add(mesh2);
}
const stageMesh = await LoadPMX(StagePath);
//scene.add(stageMesh); //else will add scene a second time !
}
async function LoadAllAssets() {
await LoadAllModels();
await Promise.all(MotionObjects.map(async (MotionObject) => {
return await LoadVMD(MotionObject.id);
}));
await Promise.all(MotionObjects.map(async (MotionObject) => {
return await LoadAudio(MotionObject.id);
}));
cameraAnimation = await LoadCamera("camera-animation-id");
VmdControl("loop", true);
}
async function LoadVMD(id) {
return new Promise(resolve => {
const path = "./vmd/" + id + ".vmd";
const val = MotionObjects.findIndex(MotionObject => MotionObject.id == id);
loader.loadAnimation(path, mesh, vmd => {
vmd.name = id;
MotionObjects[val].VmdClip = vmd;
document.getElementById('readystate').innerHTML = "Checking motion vmd - " + id + ' (if stuck, click <a href="js/main.js" target="_blank">here</a>)';
resolve(true);
});
if (mesh2) {
loader.loadAnimation(path, mesh2, vmd => {
vmd.name = id;
MotionObjects[val].VmdClip = vmd;
resolve(true);
});
}
});
}
function LoadCamera(id) {
let camid;
if (new URLSearchParams(window.location.search).has('camera')) {
camid = new URLSearchParams(window.location.search).get('camera');
} else {
camid = localStorage.getItem('camid');
if (!camid) {
camid = 'bts-bestofme';
}
}
const path = "./camera/" + camid + ".vmd";
return new Promise((resolve, reject) => {
loader.loadAnimation(path, camera, cameraAnimation => {
cameraAnimation.name = path;
resolve(cameraAnimation);
console.log(path);
}, onProgress, (xhr) => reject(new Error(`Failed to load Camera VMD file: ${path}`)));
});
}
function LoadAudio(id) {
return new Promise(resolve => {
const path = "./audio/" + id + ".mp3";
const val = MotionObjects.findIndex(MotionObject => MotionObject.id == id);
if (MotionObjects[val].AudioClip) {
new THREE.AudioLoader().load(path, buffer => {
const listener = new THREE.AudioListener();
const audio = new THREE.Audio(listener).setBuffer(buffer);
MotionObjects[val].AudioClip = audio;
document.getElementById('readystate').innerHTML = "Checking audio - " + id + ' (if stuck, click <a href="audio" target="_blank">here</a>)';
resolve(true);
});
} else {
resolve(false);
}
});
}
await LoadAllAssets();
}
function VmdControl(id, loop) {
const index = MotionObjects.findIndex(MotionObject => MotionObject.id == id);
if (index === -1) {
document.getElementById('readystate').textContent = "Camera: ready - " + localStorage.getItem('camid');
return;
}
ready = false;
helper = new THREE.MMDAnimationHelper({ afterglow: 2.0, resetPhysicsOnLoop: true });
const enablePhysics = localStorage.getItem("physicsareon") === "true";
helper.add(mesh, {
animation: MotionObjects[index].VmdClip,
physics: enablePhysics
});
if (mesh2) {
helper.add(mesh2, {
animation: MotionObjects[index].VmdClip,
physics: enablePhysics
});
}
if (MotionObjects[index].AudioClip) {
MotionObjects[index].AudioClip.play();
}
if (cameraAnimation) {
helper.add(camera, { animation: cameraAnimation });
}
const mixer = helper.objects.get(mesh).mixer;
if (!loop) {
mixer.existingAction(MotionObjects[index].VmdClip).setLoop(THREE.LoopOnce);
}
mixer.addEventListener("loop", (event) => {
console.log("loop");
});
mixer.addEventListener("finished", (event) => {
console.log("finished");
VmdControl("loop", true);
});
ready = true;
}
function onProgress(xhr) {
if (xhr.lengthComputable) {
const percentComplete = xhr.loaded / xhr.total * 100;
console.log(Math.round(percentComplete, 2) + '% downloaded');
}
}
function onError(xhr) {
console.log("ERROR", xhr);
}
function Render() {
requestAnimationFrame(Render);
renderer.clear();
renderer.render(scene, camera);
if (ready) {
helper.update(clock.getDelta());
}
}
const generatePoseClickEvent = (motionObjects) => {
if (!Array.isArray(motionObjects)) {
console.error("motionObjects is not an array.");
return () => {};
}
const functionBody = motionObjects.map(function (motion) {
return "case '" + motion.pose + "':\n" +
" VmdControl('" + motion.id + "', " + (motion.pose === "pose1") + ");\n" +
" break;\n";
}).join("");
const poseClickEventFunction = new Function("id", "switch (id) { " + functionBody +
" default: VmdControl('001', true); break; }");
return poseClickEventFunction;
};
const PoseClickEvent = generatePoseClickEvent(MotionObjects);
document.querySelectorAll(".pose-button").forEach(button => {
button.addEventListener("click", (event) => {
const poseId = event.target.dataset.poseId;
PoseClickEvent(poseId);
});
});