Skip to content

Commit b14450e

Browse files
author
shijiashuai
committed
chore: update GitHub Pages workflow and docs
1 parent e18af7a commit b14450e

1 file changed

Lines changed: 121 additions & 80 deletions

File tree

src/components/viewer/VRMAvatar.tsx

Lines changed: 121 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
// VRM Avatar 组件 — 借鉴 AIRI 项目的模块化架构
22
// v2:完整对齐 AIRI 功能集(AnimationMixer + spring bone + combineSkeletons + 包围盒定位)
3-
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
4-
import { useFrame } from '@react-three/fiber';
5-
import * as THREE from 'three';
6-
import { VRM, VRMUtils } from '@pixiv/three-vrm';
7-
import { useDigitalHumanStore } from '@/store/digitalHumanStore';
8-
import { useVRMLoader } from '@/hooks/vrm/useVRMLoader';
9-
import { useVRMEmote } from '@/hooks/vrm/useVRMEmote';
10-
import { useVRMBlink } from '@/hooks/vrm/useVRMBlink';
11-
import { useVRMLipSync } from '@/hooks/vrm/useVRMLipSync';
12-
import { useVRMEyeSaccades } from '@/hooks/vrm/useVRMEyeSaccades';
13-
import { createVRMAnimationController } from '@/hooks/vrm/useVRMAnimation';
14-
import type { VRMEmoteController } from '@/hooks/vrm/useVRMEmote';
15-
import type { VRMAnimationController } from '@/hooks/vrm/useVRMAnimation';
3+
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
4+
import { useFrame } from "@react-three/fiber";
5+
import * as THREE from "three";
6+
import { VRM, VRMUtils } from "@pixiv/three-vrm";
7+
import { useDigitalHumanStore } from "@/store/digitalHumanStore";
8+
import { useVRMLoader } from "@/hooks/vrm/useVRMLoader";
9+
import { useVRMEmote } from "@/hooks/vrm/useVRMEmote";
10+
import { useVRMBlink } from "@/hooks/vrm/useVRMBlink";
11+
import { useVRMLipSync } from "@/hooks/vrm/useVRMLipSync";
12+
import { useVRMEyeSaccades } from "@/hooks/vrm/useVRMEyeSaccades";
13+
import { createVRMAnimationController } from "@/hooks/vrm/useVRMAnimation";
14+
import type { VRMEmoteController } from "@/hooks/vrm/useVRMEmote";
15+
import type { VRMAnimationController } from "@/hooks/vrm/useVRMAnimation";
1616

1717
interface VRMAvatarProps {
1818
url: string;
@@ -33,7 +33,7 @@ function computeModelBounds(scene: THREE.Object3D) {
3333
if (!obj.visible) return;
3434
const mesh = obj as THREE.Mesh;
3535
if (!mesh.isMesh || !mesh.geometry) return;
36-
if (mesh.name.startsWith('VRMC_springBone_collider')) return;
36+
if (mesh.name.startsWith("VRMC_springBone_collider")) return;
3737
const geo = mesh.geometry;
3838
if (!geo.boundingBox) geo.computeBoundingBox();
3939
childBox.copy(geo.boundingBox!);
@@ -67,100 +67,114 @@ function computeSkeletonTargets(
6767
let spineRotZ = 0;
6868
let hipsPosY = 0;
6969

70-
if (isAnim('nod') || currentBehavior === 'listening') {
70+
if (isAnim("nod") || currentBehavior === "listening") {
7171
headRotX = Math.sin(t * 3.5) * 0.2;
7272
spineRotX = Math.sin(t * 3.5) * 0.03;
73-
} else if (isAnim('shakeHead')) {
73+
} else if (isAnim("shakeHead")) {
7474
headRotY = Math.sin(t * 5) * 0.4;
7575
spineRotZ = Math.sin(t * 5) * 0.02;
76-
} else if (currentBehavior === 'thinking') {
76+
} else if (currentBehavior === "thinking") {
7777
headRotZ = Math.sin(t * 0.8) * 0.12;
7878
headRotY = -0.15 + Math.sin(t * 0.5) * 0.08;
7979
headRotX = 0.05;
80-
} else if (currentBehavior === 'greeting' || isAnim('wave') || isAnim('waveHand')) {
80+
} else if (
81+
currentBehavior === "greeting" ||
82+
isAnim("wave") ||
83+
isAnim("waveHand")
84+
) {
8185
headRotZ = Math.sin(t * 2.5) * 0.1;
8286
headRotY = 0.1 + Math.sin(t * 3) * 0.05;
8387
hipsPosY = Math.sin(t * 3) * 0.01;
84-
} else if (isAnim('bow')) {
88+
} else if (isAnim("bow")) {
8589
headRotX = -0.3;
8690
spineRotX = -0.25;
87-
} else if (isAnim('lookAround')) {
91+
} else if (isAnim("lookAround")) {
8892
headRotY = Math.sin(t * 1.2) * 0.5;
8993
headRotX = Math.sin(t * 2) * 0.1;
90-
} else if (isAnim('sleep')) {
94+
} else if (isAnim("sleep")) {
9195
headRotX = -0.3 + Math.sin(t * 0.4) * 0.03;
9296
headRotZ = 0.2;
9397
spineRotX = -0.1;
94-
} else if (isAnim('cheer')) {
98+
} else if (isAnim("cheer")) {
9599
headRotX = 0.15;
96100
headRotZ = Math.sin(t * 7) * 0.1;
97101
hipsPosY = Math.abs(Math.sin(t * 5)) * 0.08;
98-
} else if (isAnim('dance')) {
102+
} else if (isAnim("dance")) {
99103
headRotZ = Math.sin(t * 4) * 0.1;
100104
hipsPosY = Math.abs(Math.sin(t * 4)) * 0.06;
101-
} else if (isAnim('excited') || currentBehavior === 'excited') {
105+
} else if (isAnim("excited") || currentBehavior === "excited") {
102106
hipsPosY = Math.abs(Math.sin(t * 6)) * 0.1;
103107
headRotZ = Math.sin(t * 8) * 0.08;
104-
} else if (currentBehavior === 'speaking' || isSpeaking) {
108+
} else if (currentBehavior === "speaking" || isSpeaking) {
105109
headRotY = mouseX * 0.15 + Math.sin(t * 1.5) * 0.05;
106110
headRotX = -mouseY * 0.08 + Math.sin(t * 2) * 0.03;
107111
}
108112

109113
// 手臂
110-
let leftArmRotZ = 0, rightArmRotZ = 0, leftArmRotX = 0, rightArmRotX = 0;
114+
let leftArmRotZ = 0,
115+
rightArmRotZ = 0,
116+
leftArmRotX = 0,
117+
rightArmRotX = 0;
111118

112-
if (currentBehavior === 'greeting' || isAnim('wave') || isAnim('waveHand')) {
119+
if (currentBehavior === "greeting" || isAnim("wave") || isAnim("waveHand")) {
113120
rightArmRotZ = -Math.PI * 0.6 + Math.sin(t * 5) * 0.25;
114121
rightArmRotX = Math.sin(t * 5) * 0.15;
115-
} else if (isAnim('raiseHand')) {
122+
} else if (isAnim("raiseHand")) {
116123
rightArmRotZ = -Math.PI * 0.5;
117-
} else if (currentBehavior === 'speaking' || isSpeaking) {
124+
} else if (currentBehavior === "speaking" || isSpeaking) {
118125
leftArmRotZ = Math.sin(t * 2.5) * 0.06;
119126
rightArmRotZ = -Math.sin(t * 2.5 + 1.2) * 0.06;
120127
leftArmRotX = Math.sin(t * 3) * 0.08;
121128
rightArmRotX = Math.sin(t * 3 + 1) * 0.08;
122-
} else if (isAnim('excited') || currentBehavior === 'excited') {
129+
} else if (isAnim("excited") || currentBehavior === "excited") {
123130
leftArmRotZ = Math.PI * 0.4 + Math.sin(t * 7) * 0.2;
124131
rightArmRotZ = -Math.PI * 0.4 - Math.sin(t * 7 + 0.5) * 0.2;
125-
} else if (isAnim('bow')) {
132+
} else if (isAnim("bow")) {
126133
leftArmRotX = -0.15;
127134
rightArmRotX = -0.15;
128-
} else if (isAnim('clap')) {
135+
} else if (isAnim("clap")) {
129136
const cp = Math.sin(t * 10);
130137
leftArmRotZ = Math.PI * 0.2 + cp * 0.12;
131138
rightArmRotZ = -Math.PI * 0.2 - cp * 0.12;
132139
leftArmRotX = -0.5 + cp * 0.08;
133140
rightArmRotX = -0.5 + cp * 0.08;
134-
} else if (isAnim('thumbsUp')) {
141+
} else if (isAnim("thumbsUp")) {
135142
rightArmRotZ = -Math.PI * 0.45;
136143
rightArmRotX = -0.3;
137-
} else if (isAnim('shrug')) {
144+
} else if (isAnim("shrug")) {
138145
leftArmRotZ = Math.PI * 0.3;
139146
rightArmRotZ = -Math.PI * 0.3;
140147
leftArmRotX = -0.2;
141148
rightArmRotX = -0.2;
142-
} else if (isAnim('cheer')) {
149+
} else if (isAnim("cheer")) {
143150
leftArmRotZ = Math.PI * 0.65 + Math.sin(t * 5) * 0.12;
144151
rightArmRotZ = -Math.PI * 0.65 - Math.sin(t * 5 + 0.4) * 0.12;
145-
} else if (isAnim('crossArms')) {
152+
} else if (isAnim("crossArms")) {
146153
leftArmRotZ = Math.PI * 0.2;
147154
rightArmRotZ = -Math.PI * 0.2;
148155
leftArmRotX = -0.4;
149156
rightArmRotX = -0.4;
150-
} else if (isAnim('point')) {
157+
} else if (isAnim("point")) {
151158
rightArmRotZ = -Math.PI * 0.35;
152159
rightArmRotX = -0.55;
153-
} else if (isAnim('dance')) {
160+
} else if (isAnim("dance")) {
154161
leftArmRotZ = Math.PI * 0.3 + Math.sin(t * 4) * 0.3;
155162
rightArmRotZ = -Math.PI * 0.3 - Math.sin(t * 4 + Math.PI) * 0.3;
156163
leftArmRotX = Math.sin(t * 4) * 0.2;
157164
rightArmRotX = Math.sin(t * 4 + Math.PI) * 0.2;
158165
}
159166

160167
return {
161-
headRotX, headRotY, headRotZ,
162-
spineRotX, spineRotZ, hipsPosY,
163-
leftArmRotZ, rightArmRotZ, leftArmRotX, rightArmRotX,
168+
headRotX,
169+
headRotY,
170+
headRotZ,
171+
spineRotX,
172+
spineRotZ,
173+
hipsPosY,
174+
leftArmRotZ,
175+
rightArmRotZ,
176+
leftArmRotX,
177+
rightArmRotX,
164178
};
165179
}
166180

@@ -182,14 +196,20 @@ export default function VRMAvatar({
182196
const eyeSaccadesRef = useRef(useVRMEyeSaccades());
183197

184198
// 上一次的表情名,用于检测变化
185-
const lastExpressionRef = useRef<string>('neutral');
199+
const lastExpressionRef = useRef<string>("neutral");
186200

187201
// 骨骼动画插值状态
188202
const animState = useRef({
189-
headRotX: 0, headRotY: 0, headRotZ: 0,
190-
spineRotX: 0, spineRotZ: 0, hipsPosY: 0,
191-
leftArmRotZ: 0, rightArmRotZ: 0,
192-
leftArmRotX: 0, rightArmRotX: 0,
203+
headRotX: 0,
204+
headRotY: 0,
205+
headRotZ: 0,
206+
spineRotX: 0,
207+
spineRotZ: 0,
208+
hipsPosY: 0,
209+
leftArmRotZ: 0,
210+
rightArmRotZ: 0,
211+
leftArmRotX: 0,
212+
rightArmRotX: 0,
193213
});
194214

195215
// 鼠标跟踪
@@ -199,8 +219,8 @@ export default function VRMAvatar({
199219
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
200220
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
201221
};
202-
window.addEventListener('mousemove', handleMouseMove);
203-
return () => window.removeEventListener('mousemove', handleMouseMove);
222+
window.addEventListener("mousemove", handleMouseMove);
223+
return () => window.removeEventListener("mousemove", handleMouseMove);
204224
}, []);
205225

206226
// 使用单例 VRM 加载器
@@ -229,14 +249,14 @@ export default function VRMAvatar({
229249

230250
const vrm = gltf.userData.vrm as VRM;
231251
if (!vrm) {
232-
onError?.('文件不是有效的 VRM 模型');
252+
onError?.("文件不是有效的 VRM 模型");
233253
return;
234254
}
235255

236256
// ---- 性能优化(参考 AIRI) ----
237257
VRMUtils.removeUnnecessaryVertices(gltf.scene);
238258
// 合并骨骼(AIRI 关键优化,大幅提升性能)
239-
if (typeof VRMUtils.combineSkeletons === 'function') {
259+
if (typeof VRMUtils.combineSkeletons === "function") {
240260
VRMUtils.combineSkeletons(gltf.scene);
241261
}
242262
VRMUtils.removeUnnecessaryJoints(gltf.scene);
@@ -266,11 +286,7 @@ export default function VRMAvatar({
266286

267287
// ---- 模型定位(参考 AIRI 包围盒计算) ----
268288
const { size, center } = computeModelBounds(vrm.scene);
269-
vrm.scene.position.set(
270-
-center.x,
271-
-center.y + size.y * 0.05,
272-
-center.z,
273-
);
289+
vrm.scene.position.set(-center.x, -center.y + size.y * 0.05, -center.z);
274290

275291
// 旋转模型朝向摄像机(VRM0 兼容)
276292
VRMUtils.rotateVRM0(vrm);
@@ -285,19 +301,30 @@ export default function VRMAvatar({
285301

286302
// 如果提供了闲置动画 URL,加载并播放
287303
if (idleAnimationUrl) {
288-
animControllerRef.current.loadAndPlay(idleAnimationUrl).catch((err) => {
289-
console.warn('闲置动画加载失败:', err);
290-
});
304+
animControllerRef.current
305+
.loadAndPlay(idleAnimationUrl)
306+
.catch((err) => {
307+
console.warn("闲置动画加载失败:", err);
308+
});
291309
}
292310

293311
vrmRef.current = vrm;
294312
setLoaded(true);
295313
onLoad?.(vrm);
296314

297-
console.log('VRM 模型加载成功:', vrm.meta);
298-
console.log('模型尺寸:', size.toArray().map((v) => v.toFixed(2)));
299-
console.log('可用表情:', vrm.expressionManager?.expressions.map((e) => e.expressionName));
300-
console.log('Spring Bone 数量:', vrm.springBoneManager?.joints.length ?? 0);
315+
console.log("VRM 模型加载成功:", vrm.meta);
316+
console.log(
317+
"模型尺寸:",
318+
size.toArray().map((v) => v.toFixed(2)),
319+
);
320+
console.log(
321+
"可用表情:",
322+
vrm.expressionManager?.expressions.map((e) => e.expressionName),
323+
);
324+
console.log(
325+
"Spring Bone 数量:",
326+
vrm.springBoneManager?.joints.size ?? 0,
327+
);
301328
},
302329
(progress) => {
303330
if (cancelled) return;
@@ -307,8 +334,9 @@ export default function VRMAvatar({
307334
},
308335
(error) => {
309336
if (cancelled) return;
310-
const msg = error instanceof Error ? error.message : '加载 VRM 模型失败';
311-
console.error('VRM 加载错误:', error);
337+
const msg =
338+
error instanceof Error ? error.message : "加载 VRM 模型失败";
339+
console.error("VRM 加载错误:", error);
312340
onError?.(msg);
313341
},
314342
);
@@ -334,12 +362,16 @@ export default function VRMAvatar({
334362
const anim = animState.current;
335363

336364
const {
337-
currentExpression, isSpeaking, currentAnimation,
338-
currentBehavior, expressionIntensity,
365+
currentExpression,
366+
isSpeaking,
367+
currentAnimation,
368+
currentBehavior,
369+
expressionIntensity,
339370
} = useDigitalHumanStore.getState();
340371

341372
const intensity = Math.max(0, Math.min(1, expressionIntensity ?? 1));
342-
const isAnim = (name: string) => currentAnimation === name || currentBehavior === name;
373+
const isAnim = (name: string) =>
374+
currentAnimation === name || currentBehavior === name;
343375

344376
// 1. AnimationMixer 更新
345377
animControllerRef.current?.update(delta);
@@ -360,23 +392,32 @@ export default function VRMAvatar({
360392
lipSyncRef.current.update(vrm, delta, isSpeaking);
361393

362394
// 5. 空闲眼球微动
363-
eyeSaccadesRef.current.update(vrm, {
364-
x: mouse.current.x * 0.5,
365-
y: mouse.current.y * 0.3,
366-
z: -1,
367-
}, delta);
395+
eyeSaccadesRef.current.update(
396+
vrm,
397+
{
398+
x: mouse.current.x * 0.5,
399+
y: mouse.current.y * 0.3,
400+
z: -1,
401+
},
402+
delta,
403+
);
368404

369405
// 6. 骨骼叠加动画
370406
const targets = computeSkeletonTargets(
371-
t, mouse.current.x, mouse.current.y,
372-
currentBehavior, isSpeaking, isAnim,
407+
t,
408+
mouse.current.x,
409+
mouse.current.y,
410+
currentBehavior,
411+
isSpeaking,
412+
isAnim,
373413
);
374414

375-
const headNode = vrm.humanoid.getNormalizedBoneNode('head');
376-
const spineNode = vrm.humanoid.getNormalizedBoneNode('spine');
377-
const leftUpperArmNode = vrm.humanoid.getNormalizedBoneNode('leftUpperArm');
378-
const rightUpperArmNode = vrm.humanoid.getNormalizedBoneNode('rightUpperArm');
379-
const hipsNode = vrm.humanoid.getNormalizedBoneNode('hips');
415+
const headNode = vrm.humanoid.getNormalizedBoneNode("head");
416+
const spineNode = vrm.humanoid.getNormalizedBoneNode("spine");
417+
const leftUpperArmNode = vrm.humanoid.getNormalizedBoneNode("leftUpperArm");
418+
const rightUpperArmNode =
419+
vrm.humanoid.getNormalizedBoneNode("rightUpperArm");
420+
const hipsNode = vrm.humanoid.getNormalizedBoneNode("hips");
380421

381422
anim.headRotX = lerp(anim.headRotX, targets.headRotX, 0.1);
382423
anim.headRotY = lerp(anim.headRotY, targets.headRotY, 0.1);
@@ -411,7 +452,7 @@ export default function VRMAvatar({
411452
}
412453

413454
// 呼吸
414-
if (spineNode && !isAnim('bow') && !isAnim('sleep')) {
455+
if (spineNode && !isAnim("bow") && !isAnim("sleep")) {
415456
spineNode.rotation.x += Math.sin(t * 1.5) * 0.008;
416457
}
417458

0 commit comments

Comments
 (0)