<!DOCTYPE html>
<html>
<head>
<title>VRM with BVH Animation Player</title>
<meta charset="utf-8">
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<script src="https://unpkg.com/three@0.150.1/examples/js/loaders/BVHLoader.js"></script>
<script src="./js/aframe-vrm.js"></script>
<script>
// ==================== コンポーネント定義エリア ====================
// このエリアにコンポーネントの定義をまとめ、重複しないようにします。
// 1. カメラコントロール用コンポーネント
AFRAME.registerComponent('camera-relative-controls', {
schema: {
targetSpeed: { type: 'number', default: 5 },
acceleration: { type: 'number', default: 10 },
damping: { type: 'number', default: 8 },
brakingDeceleration: { type: 'number', default: 20 },
enabled: { type: 'boolean', default: true },
rotationSpeed: { type: 'number', default: 1.5 },
verticalSpeed: { type: 'number', default: 3 },
groundY: { type: 'number', default: 0 },
ceilingY: { type: 'number', default: 50 }
},
init: function () {
this.keys = {};
this.leftThumbstickInput = { x: 0, y: 0 };
this.rightThumbstickInput = { x: 0, y: 0 };
this.currentVelocity = new THREE.Vector3();
this.cameraDirection = new THREE.Vector3();
this.cameraRight = new THREE.Vector3();
this.moveDirection = new THREE.Vector3();
this.desiredVelocity = new THREE.Vector3();
this.cameraWorldQuaternion = new THREE.Quaternion();
this.rigEl = this.el;
this.cameraEl = this.el.querySelector('[camera]');
this.el.sceneEl.addEventListener('loaded', () => {
this.leftHand = document.getElementById('leftHand');
if (this.leftHand) { this.leftHand.addEventListener('thumbstickmoved', this.onLeftThumbstickMoved.bind(this)); }
this.rightHand = document.getElementById('rightHand');
if (this.rightHand) { this.rightHand.addEventListener('thumbstickmoved', this.onRightThumbstickMoved.bind(this)); }
});
window.addEventListener('keydown', this.onKeyDown.bind(this));
window.addEventListener('keyup', this.onKeyUp.bind(this));
},
remove: function () {
window.removeEventListener('keydown', this.onKeyDown.bind(this));
window.removeEventListener('keyup', this.onKeyUp.bind(this));
if (this.leftHand) { try { this.leftHand.removeEventListener('thumbstickmoved', this.onLeftThumbstickMoved.bind(this)); } catch(e){} }
if (this.rightHand) { try { this.rightHand.removeEventListener('thumbstickmoved', this.onRightThumbstickMoved.bind(this)); } catch(e){} }
},
onLeftThumbstickMoved: function (evt) { this.leftThumbstickInput.x = evt.detail.x; this.leftThumbstickInput.y = evt.detail.y; },
onRightThumbstickMoved: function (evt) { this.rightThumbstickInput.x = evt.detail.x; this.rightThumbstickInput.y = evt.detail.y; },
tick: function (time, timeDelta) {
if (!this.data.enabled || !this.cameraEl) return;
const data = this.data;
const dt = timeDelta / 1000;
const position = this.rigEl.object3D.position;
if (this.rigEl.sceneEl.is('vr-mode')) {
if (Math.abs(this.rightThumbstickInput.x) > 0.1) { this.rigEl.object3D.rotation.y -= this.rightThumbstickInput.x * data.rotationSpeed * dt; }
}
const cameraObject = this.cameraEl.object3D;
cameraObject.getWorldQuaternion(this.cameraWorldQuaternion);
this.cameraDirection.set(0, 0, -1).applyQuaternion(this.cameraWorldQuaternion);
this.cameraRight.set(1, 0, 0).applyQuaternion(this.cameraWorldQuaternion);
this.cameraRight.y = 0;
this.cameraRight.normalize();
this.moveDirection.set(0, 0, 0);
if (this.keys['KeyW'] || this.keys['ArrowUp']) { this.moveDirection.add(this.cameraDirection); }
if (this.keys['KeyS'] || this.keys['ArrowDown']) { this.moveDirection.sub(this.cameraDirection); }
if (this.keys['KeyA'] || this.keys['ArrowLeft']) { this.moveDirection.sub(this.cameraRight); }
if (this.keys['KeyD'] || this.keys['ArrowRight']) { this.moveDirection.add(this.cameraRight); }
if (Math.abs(this.leftThumbstickInput.y) > 0.1) { this.moveDirection.add(this.cameraDirection.clone().multiplyScalar(-this.leftThumbstickInput.y)); }
if (Math.abs(this.leftThumbstickInput.x) > 0.1) { this.moveDirection.add(this.cameraRight.clone().multiplyScalar(this.leftThumbstickInput.x)); }
const isInputting = this.moveDirection.lengthSq() > 0.0001;
if (isInputting) { this.moveDirection.normalize(); }
let lerpFactor = data.damping;
if (isInputting) {
this.desiredVelocity.copy(this.moveDirection).multiplyScalar(data.targetSpeed);
lerpFactor = data.acceleration;
} else {
this.desiredVelocity.set(0,0,0);
lerpFactor = data.damping;
}
const effectiveLerpFactor = 1.0 - Math.exp(-lerpFactor * dt);
this.currentVelocity.lerp(this.desiredVelocity, effectiveLerpFactor);
if (this.currentVelocity.lengthSq() > 0.0001) {
position.add(this.currentVelocity.clone().multiplyScalar(dt));
}
let verticalMovement = 0;
if (this.rigEl.sceneEl.is('vr-mode') && Math.abs(this.rightThumbstickInput.y) > 0.1) {
verticalMovement = this.rightThumbstickInput.y * data.verticalSpeed * dt;
}
const nextY = position.y + verticalMovement;
position.y = THREE.MathUtils.clamp(nextY, data.groundY, data.ceilingY);
},
onKeyDown: function (event) { if (['KeyW', 'ArrowUp', 'KeyS', 'ArrowDown', 'KeyA', 'ArrowLeft', 'KeyD', 'ArrowRight'].includes(event.code)) { this.keys[event.code] = true; } },
onKeyUp: function (event) { if (this.keys[event.code] !== undefined) { delete this.keys[event.code]; } }
});
// 2. BVH再生用コンポーネント(デバッグログ出力機能付き)
AFRAME.registerComponent('bvh-player', {
schema: {
src: { type: 'string' }, // BVHファイルのURL
loop: { type: 'boolean', default: true }, // アニメーションをループさせるか
},
init: function () {
console.log('✅ bvh-player: コンポーネント初期化');
this.mixer = null; // three.jsのアニメーションミキサー
this.vrmData = null; // ロードされたVRMのデータ
// VRMモデルのロード完了を待ってから処理を開始
this.el.addEventListener('vrm-loaded', (e) => {
console.log('✅ vrm-loaded: VRMモデルの読み込み完了', e.detail.vrm);
this.vrmData = e.detail.vrm;
this.loadBVH(this.data.src);
});
},
// BVHファイルを読み込む関数
loadBVH: function(url) {
console.log(`⏳ bvh-player: BVHファイルの読み込みを開始... URL: ${url}`);
const loader = new THREE.BVHLoader();
loader.load(url, (bvh) => {
console.log('✅ bvh-player: BVHファイルの読み込み成功', bvh);
// --- BVHボーン名とVRMヒューマノイドボーン名の対応表 ---
const boneMap = {
'J_Bip_C_Hips': 'hips',
'J_Bip_C_Spine': 'spine',
'J_Bip_C_Chest': 'chest',
'J_Bip_C_UpperChest': 'upperChest',
'J_Bip_C_Neck': 'neck',
'J_Bip_C_Head': 'head',
'J_Bip_L_Shoulder': 'leftShoulder',
'J_Bip_L_UpperArm': 'leftUpperArm',
'J_Bip_L_LowerArm': 'leftLowerArm',
'J_Bip_L_Hand': 'leftHand',
'J_Bip_R_Shoulder': 'rightShoulder',
'J_Bip_R_UpperArm': 'rightUpperArm',
'J_Bip_R_LowerArm': 'rightLowerArm',
'J_Bip_R_Hand': 'rightHand',
'J_Bip_L_UpperLeg': 'leftUpperLeg',
'J_Bip_L_LowerLeg': 'leftLowerLeg',
'J_Bip_L_Foot': 'leftFoot',
'J_Bip_L_ToeBase': 'leftToes',
'J_Bip_R_UpperLeg': 'rightUpperLeg',
'J_Bip_R_LowerLeg': 'rightLowerLeg',
'J_Bip_R_Foot': 'rightFoot',
'J_Bip_R_ToeBase': 'rightToes'
};
console.log('⏳ bvh-player: ボーンのマッピングを開始...');
const newTracks = [];
bvh.clip.tracks.forEach(track => {
const bvhNodeName = track.name.split('.')[0];
const vrmBoneName = boneMap[bvhNodeName];
if (vrmBoneName) {
const vrmBoneNode = this.vrmData.humanoid.getBoneNode(vrmBoneName);
if (vrmBoneNode) {
console.log(` 👍 マッピング成功: ${bvhNodeName} -> ${vrmBoneName}`);
const newTrack = track.clone();
newTrack.name = `${vrmBoneNode.name}.${track.name.split('.')[1]}`;
newTracks.push(newTrack);
}
}
});
console.log(`✅ bvh-player: ボーンのマッピング完了。${newTracks.length}個のトラックをマッピングしました。`);
if (newTracks.length === 0) {
console.error('❌ エラー: マッピングされたボーンが一つもありません。boneMapのキー(左側の名前)がBVHファイル内のボーン名と一致しているか確認してください。');
return; // 処理を中断
}
const filteredTracks = newTracks.filter(track => !track.name.endsWith('.position'));
console.log(`✅ bvh-player: 位置情報を除外。${filteredTracks.length}個のトラックが残りました。`);
const newClip = new THREE.AnimationClip('bvh_animation', bvh.clip.duration, filteredTracks);
this.mixer = new THREE.AnimationMixer(this.el.object3D);
const action = this.mixer.clipAction(newClip);
if(this.data.loop) {
action.setLoop(THREE.LoopRepeat);
}
action.play();
console.log('✅ bvh-player: アニメーション再生を開始しました。', this.mixer);
},
undefined, // onProgress コールバック
(error) => {
console.error('❌ bvh-player: BVHファイルの読み込みに失敗しました。', error);
console.error('CORSポリシーエラーの可能性があります。ローカルサーバーで実行しているか、BVHファイルのURLが正しいか確認してください。');
});
},
tick: function (time, timeDelta) {
if (this.mixer) {
this.mixer.update(timeDelta / 1000);
}
}
});
</script>
</head>
<body>
<a-scene renderer="physicallyCorrectLights: true">
<a-entity
id="avatar"
vrm="src: ./vrm/tesA1_V0a.vrm"
bvh-player="src: https://p-bookmark.sakura.ne.jp/junkerstock/vrm/8.bvh"
position="0 0 0"
rotation="0 180 0">
</a-entity>
<a-entity id="rig" position="0 0 2.5" camera-relative-controls="groundY: -1.5">
<a-entity id="player">
<a-entity id="camera" camera look-controls position="0 1.6 0"></a-entity>
<a-entity id="leftHand" oculus-touch-controls="hand: left"></a-entity>
<a-entity id="rightHand" oculus-touch-controls="hand: right"></a-entity>
</a-entity>
</a-entity>
<a-sky color="#ECECEC"></a-sky>
<a-plane position="0 0 0" rotation="-90 0 0" width="100" height="100" color="#FFFFFF" shadow></a-plane>
<a-light type="directional" color="#FFF" intensity="0.6" position="-1 2 2"></a-light>
<a-light type="ambient" color="#FFF" intensity="0.6"></a-light>
</a-scene>
</body>
</html>
使用変数
-------( Function ) | |
action | |
boneMap | |
bvhNodeName | |
cameraDirection | |
cameraEl | |
cameraObject | |
cameraRight | |
charset | |
color | |
controls | |
currentVelocity | |
data | |
desiredVelocity | |
dt | |
eftThumbstickInput | |
eraWorldQuaternion | |
ffectiveLerpFactor | |
filteredTracks | |
ghtThumbstickInput | |
height | |
id | |
intensity | |
isInputting | |
keys | |
leftHand | |
length | |
lerpFactor | |
loader | |
mixer | |
moveDirection | |
name | |
newClip | |
newTrack | |
newTracks | |
nextY | |
player | |
position | |
renderer | |
rigEl | |
rightHand | |
rotation | |
src | |
track | |
type | |
url) { console.log -------( Function ) | |
verticalMovement | |
vrm | |
vrmBoneName | |
vrmBoneNode | |
vrmData | |
width | |
x | |
y | |
コンポーネント定義エリア |