Files

231 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { MMDLoader } from 'three/addons/loaders/MMDLoader.js';
import { MotionPlayer } from './motion-player.js';
const modelUrl = file => new URL(`./model/${file}`, import.meta.url).href;
const viewport = document.querySelector('#viewport');
const loading = document.querySelector('#loading');
const status = document.querySelector('#status');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0xe1e8e8);
renderer.domElement.tabIndex = 0;
renderer.domElement.setAttribute('aria-label', '无声铃鹿三维模型,拖动旋转,滚轮缩放;可用重置视角按钮恢复');
viewport.append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 1000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.autoRotateSpeed = 1.2;
controls.maxPolarAngle = Math.PI * 0.94;
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x8c9a9a, 2.1);
scene.add(hemisphere);
const key = new THREE.DirectionalLight(0xffffff, 2.5);
key.position.set(-15, 25, 30);
scene.add(key);
let model, grid, bounds, failed = false;
let motionPlayer, modelReady = false, motionLoading = false, resumeAfterSeek = false;
const motionSources = document.querySelector('#motion-sources');
const motionControls = document.querySelector('#motion-controls');
const motionStatus = document.querySelector('#motion-status');
const motionTime = document.querySelector('#motion-time');
const motionTimeText = document.querySelector('#motion-time-text');
const motionFile = document.querySelector('#motion-file');
function formatTime(seconds) {
const ticks = Math.round(Math.max(0, seconds) * 100);
return `${String(Math.floor(ticks / 6000)).padStart(2, '0')}:${String(Math.floor(ticks / 100) % 60).padStart(2, '0')}.${String(ticks % 100).padStart(2, '0')}`;
}
function updateMotionTime(state) {
motionTime.value = state.time;
const text = `${formatTime(state.time)} / ${formatTime(state.duration)}`;
if (motionTimeText.textContent !== text) motionTimeText.textContent = text;
motionTime.setAttribute('aria-valuetext', text);
}
function refreshMotionControls() {
motionSources.disabled = !modelReady || failed || motionLoading;
const state = motionPlayer?.state;
motionControls.disabled = !state?.name || failed || motionLoading;
if (!state) return;
document.querySelector('#motion-name').textContent = state.name || '尚未选择动作';
document.querySelector('#motion-play').textContent = state.playing ? '暂停' : '播放';
document.querySelector('#motion-loop').checked = state.loop;
document.querySelector('#motion-speed').value = String(state.speed);
motionTime.max = state.duration;
updateMotionTime(state);
motionStatus.classList.remove('error');
motionStatus.textContent = motionLoading ? '正在读取动作…' : state.name
? `${state.playing ? '播放中' : state.time >= state.duration ? '播放结束' : '已暂停'} · ${state.boneTrackCount} 条骨骼轨道 / ${state.morphTrackCount} 条表情轨道`
: '可载入待机动作,或导入本地角色 VMD。';
}
async function importMotion(readBuffer, name) {
if (!modelReady || failed || motionLoading) return;
motionLoading = true;
refreshMotionControls();
let message = '';
try {
const buffer = await readBuffer();
if (!failed) motionPlayer.load(buffer, name);
} catch (error) {
message = `动作加载失败:${error.message || error}。原动作保持不变。`;
} finally {
motionLoading = false;
refreshMotionControls();
if (message) {
motionStatus.textContent = message;
motionStatus.classList.add('error');
}
}
}
document.querySelector('#motion-demo').addEventListener('click', () => importMotion(async () => {
const response = await fetch(modelUrl('1002_idle.vmd'));
if (!response.ok) throw new Error(`待机动作文件不可用(HTTP ${response.status})`);
return response.arrayBuffer();
}, '无声铃鹿 · 待机'));
document.querySelector('#motion-import').addEventListener('click', () => motionFile.click());
motionFile.addEventListener('change', () => {
const file = motionFile.files[0];
motionFile.value = '';
if (file) importMotion(() => file.arrayBuffer(), file.name);
});
document.querySelector('#motion-play').addEventListener('click', () => {
if (motionPlayer.state.playing) motionPlayer.pause();
else motionPlayer.play();
});
document.querySelector('#motion-restart').addEventListener('click', () => motionPlayer.restart());
document.querySelector('#motion-clear').addEventListener('click', () => motionPlayer.clear());
document.querySelector('#motion-loop').addEventListener('change', event => motionPlayer.setLoop(event.target.checked));
document.querySelector('#motion-speed').addEventListener('change', event => motionPlayer.setSpeed(Number(event.target.value)));
motionTime.addEventListener('pointerdown', () => {
resumeAfterSeek = motionPlayer.state.playing;
motionPlayer.pause();
});
motionTime.addEventListener('input', () => motionPlayer.seek(Number(motionTime.value)));
function finishSeek() {
if (resumeAfterSeek) motionPlayer.play();
resumeAfterSeek = false;
}
motionTime.addEventListener('change', finishSeek);
motionTime.addEventListener('pointerup', finishSeek);
motionTime.addEventListener('pointercancel', finishSeek);
const failedResources = new Set();
function fail(message) {
failed = true;
loading.hidden = false;
document.querySelector('.spinner').hidden = true;
document.querySelector('#loading-title').textContent = '模型未完整加载';
document.querySelector('#loading-detail').textContent = message;
status.textContent = '加载失败';
document.querySelector('#display-controls').disabled = true;
motionPlayer?.pause();
refreshMotionControls();
}
renderer.domElement.addEventListener('webglcontextlost', event => {
event.preventDefault();
fail('图形上下文已丢失。请刷新页面重新加载。');
});
function frameModel() {
if (!bounds) return;
const size = bounds.getSize(new THREE.Vector3());
const center = bounds.getCenter(new THREE.Vector3());
const halfFov = THREE.MathUtils.degToRad(camera.fov / 2);
const distance = Math.max(size.y / 2 / Math.tan(halfFov), size.x / 2 / (Math.tan(halfFov) * camera.aspect)) * 1.25 + size.z / 2;
camera.near = Math.max(size.y / 1000, 0.001);
camera.far = Math.max(distance * 20, size.y * 100);
camera.position.copy(center).add(new THREE.Vector3(0, size.y * 0.025, distance));
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.minDistance = size.y * 0.15;
controls.maxDistance = distance * 5;
controls.update();
controls.saveState();
}
const manager = new THREE.LoadingManager();
manager.onProgress = (_url, done, total) => {
if (!failed) document.querySelector('#loading-detail').textContent = `正在读取模型与贴图 ${done} / ${total}`;
};
manager.onError = url => {
failedResources.add(url);
fail('无法读取资源:' + [...failedResources].join('、') + '。请确认 PMX 及 Texture2D 文件夹完整。');
};
manager.onLoad = () => {
if (failed || !model) return;
loading.hidden = true;
status.textContent = '模型与贴图已就绪';
document.querySelector('#display-controls').disabled = false;
modelReady = true;
refreshMotionControls();
};
const loader = new MMDLoader(manager);
loader.load(modelUrl('1002_00.pmx'), mesh => {
model = mesh;
scene.add(model);
model.updateMatrixWorld(true);
motionPlayer = new MotionPlayer(model, refreshMotionControls);
bounds = new THREE.Box3().setFromObject(model);
const size = bounds.getSize(new THREE.Vector3());
const center = bounds.getCenter(new THREE.Vector3());
grid = new THREE.GridHelper(size.y * 3, 30, 0x9eafaa, 0xc2ceca);
grid.position.set(center.x, bounds.min.y - size.y * 0.002, center.z);
grid.material.transparent = true;
grid.material.opacity = 0.55;
scene.add(grid);
frameModel();
const values = {
vertices: model.geometry.attributes.position.count,
triangles: model.geometry.index.count / 3,
bones: model.skeleton.bones.length,
morphs: model.morphTargetInfluences.length,
};
for (const [id, value] of Object.entries(values)) {
document.getElementById(id).textContent = value.toLocaleString('en-US');
}
}, undefined, error => {
console.error(error);
fail('PMX 加载失败。请确认导出文件存在且格式有效。' + (error.message || ''));
});
document.querySelector('#rotate').addEventListener('change', event => { controls.autoRotate = event.target.checked; });
document.querySelector('#grid').addEventListener('change', event => { grid.visible = event.target.checked; });
document.querySelector('#wireframe').addEventListener('change', event => {
for (const material of model.material) material.wireframe = event.target.checked;
});
document.querySelector('#reset').addEventListener('click', () => {
controls.autoRotate = false;
document.querySelector('#rotate').checked = false;
controls.reset();
frameModel();
});
new ResizeObserver(() => {
const width = viewport.clientWidth;
const height = viewport.clientHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
frameModel();
}).observe(viewport);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const delta = Math.min(clock.getDelta(), 0.1);
if (document.hidden || failed) return;
if (motionPlayer) {
motionPlayer.update(delta);
if (motionPlayer.state.name) updateMotionTime(motionPlayer.state);
}
controls.update(delta);
renderer.render(scene, camera);
});