338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
import { LoopOnce, LoopRepeat } from 'three';
|
|
import { MMDAnimationHelper } from 'three/addons/animation/MMDAnimationHelper.js';
|
|
import { MMDLoader } from 'three/addons/loaders/MMDLoader.js';
|
|
import { MMDParser } from 'three/addons/libs/mmdparser.module.js';
|
|
|
|
const parser = new MMDParser.Parser();
|
|
const animationBuilder = new MMDLoader().animationBuilder;
|
|
const shiftJis = new TextDecoder('shift_jis');
|
|
|
|
// MMDParser.parseVmd stops after the camera records. Read the optional VMD footer
|
|
// separately so its per-frame IK on/off switches are not lost (notably FK leg VMDs).
|
|
function readIkTracks(buffer, metadata, ikNames) {
|
|
const view = new DataView(buffer);
|
|
let offset = 50 + 4 + metadata.motionCount * 111 + 4 + metadata.morphCount * 23
|
|
+ 4 + metadata.cameraCount * 61;
|
|
const tracks = new Map();
|
|
|
|
function skipRecords(size) {
|
|
if (offset === view.byteLength) return false; // Older VMDs can omit the footer.
|
|
if (view.byteLength - offset < 4) throw new Error('VMD 附加数据不完整。');
|
|
const count = view.getUint32(offset, true);
|
|
offset += 4;
|
|
if (count > Math.floor((view.byteLength - offset) / size)) {
|
|
throw new Error('VMD 附加数据已截断。');
|
|
}
|
|
offset += count * size;
|
|
return true;
|
|
}
|
|
|
|
if (!skipRecords(28) || !skipRecords(9) || offset === view.byteLength) return tracks;
|
|
if (view.byteLength - offset < 4) throw new Error('VMD IK 数据不完整。');
|
|
const frameCount = view.getUint32(offset, true);
|
|
offset += 4;
|
|
for (let frame = 0; frame < frameCount; frame++) {
|
|
if (view.byteLength - offset < 9) throw new Error('VMD IK 帧已截断。');
|
|
const time = view.getUint32(offset, true) / 30;
|
|
const count = view.getUint32(offset + 5, true);
|
|
offset += 9; // frame number, model display flag, IK switch count
|
|
if (count > Math.floor((view.byteLength - offset) / 21)) {
|
|
throw new Error('VMD IK 开关已截断。');
|
|
}
|
|
for (let i = 0; i < count; i++) {
|
|
const bytes = new Uint8Array(buffer, offset, 20);
|
|
const terminator = bytes.indexOf(0);
|
|
const name = shiftJis.decode(bytes.subarray(0, terminator < 0 ? 20 : terminator));
|
|
const index = ikNames.get(name);
|
|
if (index !== undefined) {
|
|
let keys = tracks.get(index);
|
|
if (!keys) {
|
|
keys = [];
|
|
tracks.set(index, keys);
|
|
}
|
|
keys.push({ time, enabled: view.getUint8(offset + 20) !== 0 });
|
|
}
|
|
offset += 21;
|
|
}
|
|
}
|
|
for (const keys of tracks.values()) keys.sort((a, b) => a.time - b.time);
|
|
return tracks;
|
|
}
|
|
|
|
function capturePose(mesh) {
|
|
const bones = mesh.skeleton.bones;
|
|
const pose = new Float64Array(bones.length * 10);
|
|
for (let i = 0; i < bones.length; i++) {
|
|
const bone = bones[i];
|
|
bone.position.toArray(pose, i * 10);
|
|
bone.quaternion.toArray(pose, i * 10 + 3);
|
|
bone.scale.toArray(pose, i * 10 + 7);
|
|
}
|
|
return { bones: pose, morphs: mesh.morphTargetInfluences.slice() };
|
|
}
|
|
|
|
function restorePose(mesh, snapshot) {
|
|
const bones = mesh.skeleton.bones;
|
|
for (let i = 0; i < bones.length; i++) {
|
|
const bone = bones[i];
|
|
bone.position.fromArray(snapshot.bones, i * 10);
|
|
bone.quaternion.fromArray(snapshot.bones, i * 10 + 3);
|
|
bone.scale.fromArray(snapshot.bones, i * 10 + 7);
|
|
}
|
|
const influences = mesh.morphTargetInfluences;
|
|
for (let i = 0; i < influences.length; i++) influences[i] = snapshot.morphs[i];
|
|
mesh.updateMatrixWorld(true);
|
|
}
|
|
|
|
function releaseMotion(helper, mixer, clip, mesh) {
|
|
if (!helper) return;
|
|
mixer.stopAllAction();
|
|
mixer.uncacheClip(clip);
|
|
mixer.uncacheRoot(mesh);
|
|
helper.remove(mesh);
|
|
}
|
|
|
|
export class MotionPlayer {
|
|
constructor(mesh, onChange = () => {}) {
|
|
if (!mesh?.isSkinnedMesh || !mesh.geometry?.userData?.MMD || !mesh.skeleton) {
|
|
throw new Error('动画播放器需要已加载的 PMX 骨骼模型。');
|
|
}
|
|
this.mesh = mesh;
|
|
this.onChange = onChange;
|
|
this._helper = null;
|
|
this._mixer = null;
|
|
this._action = null;
|
|
this._clip = null;
|
|
this._name = '';
|
|
this._time = 0;
|
|
this._playing = false;
|
|
this._loop = true;
|
|
this._speed = 1;
|
|
this._boneTrackCount = 0;
|
|
this._morphTrackCount = 0;
|
|
this._disposed = false;
|
|
this._ikTracks = new Map();
|
|
this._iks = mesh.geometry.userData.MMD.iks || [];
|
|
this._originalIkLinks = this._iks.map(ik => ik.links.map(link => link.enabled));
|
|
this._ikNames = new Map(this._iks.map((ik, index) => [mesh.skeleton.bones[ik.target].name, index]));
|
|
}
|
|
|
|
get state() {
|
|
return {
|
|
name: this._name,
|
|
duration: this._clip?.duration || 0,
|
|
time: this._time,
|
|
playing: this._playing,
|
|
loop: this._loop,
|
|
speed: this._speed,
|
|
trackCount: this._clip?.tracks.length || 0,
|
|
boneTrackCount: this._boneTrackCount,
|
|
morphTrackCount: this._morphTrackCount,
|
|
};
|
|
}
|
|
|
|
load(buffer, name) {
|
|
if (this._disposed) throw new Error('动画播放器已释放,无法加载 VMD。');
|
|
if (!(buffer instanceof ArrayBuffer)) throw new Error('请选择有效的 VMD 文件(二进制数据)。');
|
|
|
|
let vmd;
|
|
try {
|
|
vmd = parser.parseVmd(buffer, true);
|
|
} catch (error) {
|
|
throw new Error(`VMD 格式无效或文件已损坏:${String(error.message || error)}`);
|
|
}
|
|
if (vmd.metadata.motionCount === 0 && vmd.metadata.morphCount === 0) {
|
|
throw new Error(vmd.metadata.cameraCount > 0
|
|
? '这是仅含镜头的 VMD,不能用于角色骨骼或表情动画。'
|
|
: 'VMD 不包含角色骨骼或表情动画。');
|
|
}
|
|
const bones = new Set(this.mesh.skeleton.bones.map(bone => bone.name));
|
|
const morphs = this.mesh.morphTargetDictionary;
|
|
if (!vmd.motions.some(motion => bones.has(motion.boneName))
|
|
&& !vmd.morphs.some(morph => Object.hasOwn(morphs, morph.morphName))) {
|
|
throw new Error('VMD 中没有与当前 PMX 模型匹配的骨骼或表情轨道,请选择对应角色的动作。');
|
|
}
|
|
|
|
let ikTracks;
|
|
try {
|
|
ikTracks = readIkTracks(buffer, vmd.metadata, this._ikNames);
|
|
} catch (error) {
|
|
throw new Error(`VMD 格式无效或文件已损坏:${error.message}`);
|
|
}
|
|
|
|
const snapshot = capturePose(this.mesh);
|
|
let helper, mixer, clip, action;
|
|
let boneTrackCount = 0;
|
|
let morphTrackCount = 0;
|
|
try {
|
|
// MMDLoader builds absolute bone positions from the current local pose.
|
|
// Always build against the bind pose, not the preceding clip's last frame.
|
|
this.mesh.pose();
|
|
clip = animationBuilder.build(vmd, this.mesh);
|
|
clip.name = name || '未命名动作';
|
|
for (const track of clip.tracks) {
|
|
if (track.name.startsWith('.bones[')) boneTrackCount++;
|
|
else if (track.name.startsWith('.morphTargetInfluences[')) morphTrackCount++;
|
|
for (const value of track.values) {
|
|
if (!Number.isFinite(value)) throw new Error('VMD 关键帧包含无效数字。');
|
|
}
|
|
}
|
|
if (!clip.tracks.length || !Number.isFinite(clip.duration)) {
|
|
throw new Error('VMD 没有可播放的骨骼或表情轨道。');
|
|
}
|
|
for (const keys of ikTracks.values()) {
|
|
clip.duration = Math.max(clip.duration, keys[keys.length - 1].time);
|
|
}
|
|
|
|
this.mesh.morphTargetInfluences.fill(0);
|
|
helper = new MMDAnimationHelper({ sync: false, pmxAnimation: true });
|
|
helper.enable('physics', false);
|
|
helper.add(this.mesh, { animation: clip, physics: false });
|
|
mixer = helper.objects.get(this.mesh).mixer;
|
|
action = mixer.existingAction(clip);
|
|
action.setLoop(this._loop ? LoopRepeat : LoopOnce, this._loop ? Infinity : 1);
|
|
action.clampWhenFinished = true;
|
|
action.paused = true;
|
|
} catch (error) {
|
|
if (helper?.meshes.includes(this.mesh)) {
|
|
if (mixer) releaseMotion(helper, mixer, clip, this.mesh);
|
|
else helper.remove(this.mesh);
|
|
}
|
|
restorePose(this.mesh, snapshot);
|
|
throw new Error(`无法加载 VMD 动画:${error.message || error}`);
|
|
}
|
|
|
|
releaseMotion(this._helper, this._mixer, this._clip, this.mesh);
|
|
this.mesh.pose();
|
|
this.mesh.morphTargetInfluences.fill(0);
|
|
// With no physics, every IK link is available unless the VMD turns it off.
|
|
helper.enable('physics', false);
|
|
this._helper = helper;
|
|
this._mixer = mixer;
|
|
this._clip = clip;
|
|
this._action = action;
|
|
this._ikTracks = ikTracks;
|
|
this._name = name || '未命名动作';
|
|
this._time = 0;
|
|
this._playing = false;
|
|
this._boneTrackCount = boneTrackCount;
|
|
this._morphTrackCount = morphTrackCount;
|
|
this._sample();
|
|
this.onChange();
|
|
}
|
|
|
|
play() {
|
|
if (!this._clip || this._playing || this._clip.duration === 0) return;
|
|
if (this._time >= this._clip.duration) this._setTime(0);
|
|
this._playing = true;
|
|
this.onChange();
|
|
}
|
|
|
|
pause() {
|
|
if (!this._playing) return;
|
|
this._playing = false;
|
|
this.onChange();
|
|
}
|
|
|
|
restart() {
|
|
if (!this._clip) return;
|
|
this._setTime(0);
|
|
this._playing = this._clip.duration > 0;
|
|
this.onChange();
|
|
}
|
|
|
|
seek(seconds) {
|
|
if (!this._clip) return;
|
|
if (!Number.isFinite(seconds)) throw new Error('定位时间必须是有限的秒数。');
|
|
this._setTime(Math.max(0, Math.min(seconds, this._clip.duration)));
|
|
this.onChange();
|
|
}
|
|
|
|
setLoop(loop) {
|
|
if (this._loop === Boolean(loop)) return;
|
|
this._loop = Boolean(loop);
|
|
if (this._action) this._action.setLoop(this._loop ? LoopRepeat : LoopOnce, this._loop ? Infinity : 1);
|
|
this.onChange();
|
|
}
|
|
|
|
setSpeed(speed) {
|
|
if (!Number.isFinite(speed) || speed <= 0) throw new Error('播放速度必须是大于零的有限数字。');
|
|
if (this._speed === speed) return;
|
|
this._speed = speed;
|
|
this.onChange();
|
|
}
|
|
|
|
clear() {
|
|
if (this._disposed) return;
|
|
releaseMotion(this._helper, this._mixer, this._clip, this.mesh);
|
|
this._helper = this._mixer = this._clip = this._action = null;
|
|
this._ikTracks = new Map();
|
|
this._name = '';
|
|
this._time = 0;
|
|
this._playing = false;
|
|
this._boneTrackCount = this._morphTrackCount = 0;
|
|
for (let i = 0; i < this._iks.length; i++) {
|
|
for (let j = 0; j < this._iks[i].links.length; j++) {
|
|
this._iks[i].links[j].enabled = this._originalIkLinks[i][j];
|
|
}
|
|
}
|
|
this.mesh.pose();
|
|
this.mesh.morphTargetInfluences.fill(0);
|
|
this.mesh.updateMatrixWorld(true);
|
|
this.onChange();
|
|
}
|
|
|
|
update(deltaSeconds) {
|
|
if (!this._playing || !Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return;
|
|
const duration = this._clip.duration;
|
|
const next = this._time + deltaSeconds * this._speed;
|
|
if (this._loop) {
|
|
this._setTime(next % duration);
|
|
} else if (next >= duration) {
|
|
this._setTime(duration);
|
|
this._playing = false;
|
|
this.onChange();
|
|
} else {
|
|
this._setTime(next);
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
if (this._disposed) return;
|
|
this.clear();
|
|
this._disposed = true;
|
|
}
|
|
|
|
_setTime(seconds) {
|
|
this._time = seconds;
|
|
this._sample();
|
|
}
|
|
|
|
_sample() {
|
|
this._applyIk(this._time);
|
|
// The helper restores pre-IK/grant bones, resamples the mixer, then applies
|
|
// IK/grants once. Setting the action's absolute time and updating by zero
|
|
// also works when paused or seeking backwards, without cumulative offsets.
|
|
this._action.time = this._time;
|
|
this._helper.update(0);
|
|
this.mesh.updateMatrixWorld(true);
|
|
}
|
|
|
|
_applyIk(time) {
|
|
for (let i = 0; i < this._iks.length; i++) {
|
|
const keys = this._ikTracks.get(i);
|
|
let enabled = true;
|
|
if (keys) {
|
|
let low = 0, high = keys.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
if (keys[middle].time <= time + 1e-7) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
if (low) enabled = keys[low - 1].enabled;
|
|
}
|
|
for (const link of this._iks[i].links) link.enabled = enabled;
|
|
}
|
|
}
|
|
}
|