JustPaste.it
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VRM Viewer</title>
<style>
* { 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a1a; overflow: hidden; }
canvas { display: block; }
.controls {
position: fixed; bottom: 20px; left: 20px; right: 20px;
background: rgba(0,0,0,0.85); border-radius: 16px; padding: 12px;
z-index: 100; display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;
}
button {
background: #2c6a3e; border: none; color: white;
padding: 10px 20px; border-radius: 8px; cursor: pointer;
font-weight: bold; font-size: 14px;
}
button:hover { background: #3e8a5a; transform: scale(1.02); }
button.danger { background: #6a2c2c; }
button.danger:hover { background: #aa3e3e; }
.info {
position: fixed; top: 10px; left: 10px;
background: rgba(0,0,0,0.8); padding: 8px 16px;
border-radius: 8px; font-size: 12px; monospace; z-index: 100;
}
.status {
position: fixed; bottom: 10px; right: 10px;
background: rgba(0,0,0,0.6); padding: 4px 10px;
border-radius: 8px; font-size: 10px; monospace;
}
</style>
</head>
<body>

<div class="info">🎉 VRM Viewer</div>
<div class="status" id="status">Ready</div>
<div class="controls">
<button id="btnWave">💃 WAVE</button>
<button id="btnTalk">đŸ—Ŗī¸ TALKING</button>
<button id="btnStop" class="danger">âšī¸ STOP</button>
</div>

<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/"
}
}
</script>

<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const VRM_PATH = './Riko1.vrm';
const statusDiv = document.getElementById('status');

// ========== BONE MAPS ==========
const mixamoVRMRigMap = {
'mixamorigHips': 'hips', 'mixamorigSpine': 'spine',
'mixamorigSpine1': 'chest', 'mixamorigSpine2': 'upperChest',
'mixamorigNeck': 'neck', 'mixamorigHead': 'head',
'mixamorigLeftShoulder': 'leftShoulder', 'mixamorigLeftArm': 'leftUpperArm',
'mixamorigLeftForeArm': 'leftLowerArm', 'mixamorigLeftHand': 'leftHand',
'mixamorigRightShoulder': 'rightShoulder', 'mixamorigRightArm': 'rightUpperArm',
'mixamorigRightForeArm': 'rightLowerArm', 'mixamorigRightHand': 'rightHand',
'mixamorigLeftUpLeg': 'leftUpperLeg', 'mixamorigLeftLeg': 'leftLowerLeg',
'mixamorigLeftFoot': 'leftFoot', 'mixamorigLeftToeBase': 'leftToes',
'mixamorigRightUpLeg': 'rightUpperLeg', 'mixamorigRightLeg': 'rightLowerLeg',
'mixamorigRightFoot': 'rightFoot', 'mixamorigRightToeBase': 'rightToes',
};

const J_BIP_MAP = {
'hips': 'J_Bip_C_Hips', 'spine': 'J_Bip_C_Spine',
'chest': 'J_Bip_C_Chest', 'upperChest': 'J_Bip_C_UpperChest',
'neck': 'J_Bip_C_Neck', 'head': 'J_Bip_C_Head',
'leftShoulder': 'J_Bip_L_Shoulder', 'leftUpperArm': 'J_Bip_L_UpperArm',
'leftLowerArm': 'J_Bip_L_LowerArm', 'leftHand': 'J_Bip_L_Hand',
'rightShoulder': 'J_Bip_R_Shoulder', 'rightUpperArm': 'J_Bip_R_UpperArm',
'rightLowerArm': 'J_Bip_R_LowerArm', 'rightHand': 'J_Bip_R_Hand',
'leftUpperLeg': 'J_Bip_L_UpperLeg', 'leftLowerLeg': 'J_Bip_L_LowerLeg',
'leftFoot': 'J_Bip_L_Foot', 'leftToes': 'J_Bip_L_ToeBase',
'rightUpperLeg': 'J_Bip_R_UpperLeg', 'rightLowerLeg': 'J_Bip_R_LowerLeg',
'rightFoot': 'J_Bip_R_Foot', 'rightToes': 'J_Bip_R_ToeBase',
};

// ========== SCENE ==========
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 50);
camera.position.set(0, 1.4, 3.2);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.2, 0);
controls.enableDamping = true;

scene.add(new THREE.AmbientLight(0x404060, 0.8));
const mainLight = new THREE.DirectionalLight(0xfff5e6, 1.3);
mainLight.position.set(1, 2.5, 1.5);
scene.add(mainLight);
const fillLight = new THREE.PointLight(0x556688, 0.5);
fillLight.position.set(-1, 1.5, 2);
scene.add(fillLight);

let vrmGroup = null;
let mixer = null;
let currentAction = null;
let clock = new THREE.Clock();
let vrmBoneByHumanoid = new Map();

// ========== LOAD VRM ==========
const gltfLoader = new GLTFLoader();
statusDiv.textContent = 'Loading VRM...';

gltfLoader.load(VRM_PATH, (gltf) => {
vrmGroup = gltf.scene;
scene.add(vrmGroup);
vrmGroup.position.y = -0.02;

for (const [humanName, boneName] of Object.entries(J_BIP_MAP)) {
const bone = vrmGroup.getObjectByName(boneName);
if (bone) vrmBoneByHumanoid.set(humanName, bone);
}

mixer = new THREE.AnimationMixer(vrmGroup);
statusDiv.textContent = 'Ready';
console.log('✅ VRM loaded');

}, (error) => {
console.error(error);
statusDiv.textContent = 'VRM Error';
});

// ========== RETARGETING ==========
async function loadMixamoAnimation(url) {
const loader = new FBXLoader();
const asset = await loader.loadAsync(url);

const clip = THREE.AnimationClip.findByName(asset.animations, 'mixamo.com');
if (!clip) return null;

const tracks = [];
const restRotationInverse = new THREE.Quaternion();
const parentRestWorldRotation = new THREE.Quaternion();
const _quatA = new THREE.Quaternion();

const motionHips = asset.getObjectByName('mixamorigHips');
const vrmHips = vrmBoneByHumanoid.get('hips');
const hipsPositionScale = vrmHips ? (vrmHips.position.y / motionHips.position.y) : 1.0;

for (const track of clip.tracks) {
const trackSplitted = track.name.split('.');
const mixamoRigName = trackSplitted[0];
const vrmBoneName = mixamoVRMRigMap[mixamoRigName];
const vrmBone = vrmBoneByHumanoid.get(vrmBoneName);

if (vrmBone && vrmBoneName) {
const propertyName = trackSplitted[1];
const mixamoRigNode = asset.getObjectByName(mixamoRigName);

if (mixamoRigNode && track instanceof THREE.QuaternionKeyframeTrack) {
mixamoRigNode.getWorldQuaternion(restRotationInverse).invert();
if (mixamoRigNode.parent) {
mixamoRigNode.parent.getWorldQuaternion(parentRestWorldRotation);
} else {
parentRestWorldRotation.set(0, 0, 0, 1);
}
let values = track.values.slice();
for (let i = 0; i < values.length; i += 4) {
_quatA.fromArray(values, i);
_quatA.premultiply(parentRestWorldRotation).multiply(restRotationInverse);
_quatA.toArray(values, i);
}
tracks.push(new THREE.QuaternionKeyframeTrack(
`${vrmBone.name}.${propertyName}`, track.times, values
));

} else if (track instanceof THREE.VectorKeyframeTrack && propertyName === 'position') {
let values = track.values.slice();
for (let i = 0; i < values.length; i++) {
if (i % 3 !== 1) values[i] = -values[i];
values[i] *= hipsPositionScale;
}
tracks.push(new THREE.VectorKeyframeTrack(
`${vrmBone.name}.${propertyName}`, track.times, values
));
}
}
}

if (tracks.length === 0) return null;
return new THREE.AnimationClip('vrmAnimation', clip.duration, tracks);
}

// ========== PLAY ONCE ==========
async function playFBXAnimation(url) {
if (!mixer || vrmBoneByHumanoid.size === 0) { statusDiv.textContent = 'VRM not ready'; return; }
statusDiv.textContent = 'Loading...';
try {
const clip = await loadMixamoAnimation(url);
if (!clip) { statusDiv.textContent = 'Failed'; return; }

if (currentAction) currentAction.stop();
const action = mixer.clipAction(clip);
action.reset();
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
action.play();
currentAction = action;
statusDiv.textContent = `â–ļ ${url.split('/').pop()}`;
} catch (err) {
console.error(err);
statusDiv.textContent = `Error: ${err.message}`;
}
}

// ========== LOOP PLAY ==========
async function loopFBXAnimation(url) {
if (!mixer || vrmBoneByHumanoid.size === 0) { statusDiv.textContent = 'VRM not ready'; return; }
statusDiv.textContent = 'Loading...';
try {
const clip = await loadMixamoAnimation(url);
if (!clip) { statusDiv.textContent = 'Failed'; return; }

if (currentAction) currentAction.stop();
const action = mixer.clipAction(clip);
action.reset();
action.setLoop(THREE.LoopRepeat, Infinity);
action.clampWhenFinished = false;
action.play();
currentAction = action;
statusDiv.textContent = `🔁 ${url.split('/').pop()}`;
} catch (err) {
console.error(err);
statusDiv.textContent = `Error: ${err.message}`;
}
}

// ========== STOP ==========
function stopAnimation() {
if (currentAction) { currentAction.stop(); currentAction = null; }
statusDiv.textContent = 'Stopped';
}

// ========== BUTTONS ==========
document.getElementById('btnWave').addEventListener('click', () => playFBXAnimation('./mixamo/Wave.fbx'));
document.getElementById('btnTalk').addEventListener('click', () => playFBXAnimation('./mixamo/Talking.fbx'));
document.getElementById('btnStop').addEventListener('click', stopAnimation);

// ========== WEBSOCKET ==========
function connectWS() {
const ws = new WebSocket('ws://localhost:8765');
ws.onopen = () => console.log('WebSocket connected');
ws.onclose = () => setTimeout(connectWS, 3000);
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.action === 'play' && data.path) playFBXAnimation(data.path);
if (data.action === 'loopplay' && data.path) loopFBXAnimation(data.path);
if (data.action === 'stop') stopAnimation();
} catch(err) {}
};
}
connectWS();

// ========== RENDER LOOP ==========
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.033);
if (mixer) mixer.update(delta);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>