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
1717interface 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