@@ -16,7 +16,13 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
1616 clips : Map < string , THREE . AnimationClip > ;
1717 currentAction ?: THREE . AnimationAction ;
1818 frameId ?: number ;
19- } > ( { clips : new Map ( ) } ) ;
19+ pivot ?: THREE . Group ;
20+ // 视角控制
21+ rotY : number ;
22+ rotX : number ;
23+ dragging : boolean ;
24+ lastMouse : { x : number ; y : number } ;
25+ } > ( { clips : new Map ( ) , rotY : 0 , rotX : 0 , dragging : false , lastMouse : { x : 0 , y : 0 } } ) ;
2026
2127 useEffect ( ( ) => {
2228 const container = containerRef . current ;
@@ -34,8 +40,6 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
3440
3541 const scene = new THREE . Scene ( ) ;
3642 const camera = new THREE . PerspectiveCamera ( 30 , width / height , 0.1 , 10000 ) ;
37- camera . position . set ( 0 , 3 , 8 ) ;
38- camera . lookAt ( 0 , 2 , 0 ) ;
3943
4044 scene . add ( new THREE . AmbientLight ( 0xffffff , 2.0 ) ) ;
4145 const dirLight = new THREE . DirectionalLight ( 0xffffff , 0.5 ) ;
@@ -51,6 +55,31 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
5155 console . error ( "SMD 模型加载失败:" , err ) ;
5256 } ) ;
5357
58+ // Ctrl+拖拽旋转视角
59+ const canvas = renderer . domElement ;
60+ const onMouseDown = ( e : MouseEvent ) => {
61+ if ( e . ctrlKey ) {
62+ state . dragging = true ;
63+ state . lastMouse = { x : e . clientX , y : e . clientY } ;
64+ }
65+ } ;
66+ const onMouseMove = ( e : MouseEvent ) => {
67+ if ( ! state . dragging || ! state . pivot ) return ;
68+ const dx = e . clientX - state . lastMouse . x ;
69+ const dy = e . clientY - state . lastMouse . y ;
70+ state . rotY += dx * 0.01 ;
71+ state . rotX += dy * 0.01 ;
72+ state . rotX = Math . max ( - Math . PI / 3 , Math . min ( Math . PI / 3 , state . rotX ) ) ;
73+ state . pivot . rotation . y = state . rotY ;
74+ state . pivot . rotation . x = state . rotX ;
75+ state . lastMouse = { x : e . clientX , y : e . clientY } ;
76+ } ;
77+ const onMouseUp = ( ) => { state . dragging = false ; } ;
78+
79+ canvas . addEventListener ( "mousedown" , onMouseDown ) ;
80+ window . addEventListener ( "mousemove" , onMouseMove ) ;
81+ window . addEventListener ( "mouseup" , onMouseUp ) ;
82+
5483 const clock = new THREE . Clock ( ) ;
5584 const animate = ( ) => {
5685 state . frameId = requestAnimationFrame ( animate ) ;
@@ -62,13 +91,17 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
6291
6392 return ( ) => {
6493 if ( state . frameId ) cancelAnimationFrame ( state . frameId ) ;
94+ canvas . removeEventListener ( "mousedown" , onMouseDown ) ;
95+ window . removeEventListener ( "mousemove" , onMouseMove ) ;
96+ window . removeEventListener ( "mouseup" , onMouseUp ) ;
6597 renderer . dispose ( ) ;
6698 if ( container . contains ( renderer . domElement ) ) {
6799 container . removeChild ( renderer . domElement ) ;
68100 }
69101 } ;
70102 } , [ width , height ] ) ;
71103
104+ // 切换动画
72105 useEffect ( ( ) => {
73106 const state = stateRef . current ;
74107 if ( ! state . mixer ) return ;
@@ -101,56 +134,53 @@ async function loadModel(
101134 mixer ?: THREE . AnimationMixer ;
102135 clips : Map < string , THREE . AnimationClip > ;
103136 currentAction ?: THREE . AnimationAction ;
137+ pivot ?: THREE . Group ;
138+ rotY : number ;
104139 } ,
105140 scene : THREE . Scene ,
106141 camera : THREE . PerspectiveCamera ,
107142) {
108- console . log ( "[SMD] 开始加载模型..." ) ;
109-
110143 const pqcResp = await fetch ( `${ MODEL_BASE } /8.pqc` ) ;
111- if ( ! pqcResp . ok ) {
112- throw new Error ( `PQC 加载失败: ${ pqcResp . status } ` ) ;
113- }
114- const pqcText = await pqcResp . text ( ) ;
115- const config = parsePQC ( pqcText ) ;
116- console . log ( "[SMD] PQC 配置:" , config ) ;
144+ if ( ! pqcResp . ok ) throw new Error ( `PQC 加载失败: ${ pqcResp . status } ` ) ;
145+ const config = parsePQC ( await pqcResp . text ( ) ) ;
117146
118147 const bodyResp = await fetch ( `${ MODEL_BASE } /${ config . body } ` ) ;
119- if ( ! bodyResp . ok ) {
120- throw new Error ( `Body SMD 加载失败: ${ bodyResp . status } ` ) ;
121- }
122- const bodyText = await bodyResp . text ( ) ;
123- const bodySMD = parseSMD ( bodyText ) ;
124- console . log ( "[SMD] Body 解析完成:" , bodySMD . triangles . length , "个三角形," , bodySMD . bones . length , "个骨骼" ) ;
148+ if ( ! bodyResp . ok ) throw new Error ( `Body SMD 加载失败: ${ bodyResp . status } ` ) ;
149+ const bodySMD = parseSMD ( await bodyResp . text ( ) ) ;
125150
126- const texture = await new THREE . TextureLoader ( ) . loadAsync (
127- `${ MODEL_BASE } /cresselia-lp.png` ,
128- ) ;
151+ const texture = await new THREE . TextureLoader ( ) . loadAsync ( `${ MODEL_BASE } /cresselia-lp.png` ) ;
129152 texture . colorSpace = THREE . SRGBColorSpace ;
130- console . log ( "[SMD] 贴图加载完成" ) ;
131153
132154 const { mesh } = buildSkinnedMesh ( bodySMD , texture , config . scale ) ;
133- scene . add ( mesh ) ;
134155
135- // 自动适配相机:计算模型包围盒
156+ // 用 pivot group 包裹模型,方便旋转
157+ const pivot = new THREE . Group ( ) ;
158+ pivot . add ( mesh ) ;
159+ scene . add ( pivot ) ;
160+ state . pivot = pivot ;
161+
162+ // 计算包围盒,自动适配相机
136163 const box = new THREE . Box3 ( ) . setFromObject ( mesh ) ;
137164 const center = box . getCenter ( new THREE . Vector3 ( ) ) ;
138165 const size = box . getSize ( new THREE . Vector3 ( ) ) ;
139166 const maxDim = Math . max ( size . x , size . y , size . z ) ;
140167
141- console . log ( "[SMD] 模型包围盒 center:" , center , "size:" , size , "maxDim:" , maxDim ) ;
168+ // 把 pivot 原点移到模型中心
169+ pivot . position . set ( - center . x , - center . y , - center . z ) ;
170+
171+ // 旋转模型到正面(SMD 模型默认朝向可能不对)
172+ // 先旋转 180 度让模型面向相机
173+ state . rotY = Math . PI ;
174+ pivot . rotation . y = state . rotY ;
142175
143- // 相机对准模型中心,距离根据模型大小调整
144176 const fov = camera . fov * ( Math . PI / 180 ) ;
145- const dist = maxDim / ( 2 * Math . tan ( fov / 2 ) ) * 1.2 ;
146- camera . position . set ( center . x , center . y , center . z + dist ) ;
147- camera . lookAt ( center ) ;
177+ const dist = maxDim / ( 2 * Math . tan ( fov / 2 ) ) * 1.3 ;
178+ camera . position . set ( 0 , 0 , dist ) ;
179+ camera . lookAt ( 0 , 0 , 0 ) ;
148180 camera . near = dist / 100 ;
149181 camera . far = dist * 100 ;
150182 camera . updateProjectionMatrix ( ) ;
151183
152- console . log ( "[SMD] 相机位置:" , camera . position , "距离:" , dist ) ;
153-
154184 // 动画
155185 const mixer = new THREE . AnimationMixer ( mesh ) ;
156186 state . mixer = mixer ;
@@ -159,11 +189,9 @@ async function loadModel(
159189 try {
160190 const resp = await fetch ( `${ MODEL_BASE } /${ file } ` ) ;
161191 if ( ! resp . ok ) continue ;
162- const text = await resp . text ( ) ;
163- const animSMD = parseSMD ( text ) ;
192+ const animSMD = parseSMD ( await resp . text ( ) ) ;
164193 const clip = buildAnimationClip ( name , animSMD , bodySMD . bones , config . scale ) ;
165194 state . clips . set ( name , clip ) ;
166- console . log ( "[SMD] 动画加载:" , name , clip . duration . toFixed ( 2 ) + "s" ) ;
167195 } catch ( err ) {
168196 console . warn ( "[SMD] 动画加载失败:" , name , err ) ;
169197 }
@@ -174,8 +202,5 @@ async function loadModel(
174202 const action = mixer . clipAction ( idleClip ) ;
175203 action . play ( ) ;
176204 state . currentAction = action ;
177- console . log ( "[SMD] 播放 idle 动画" ) ;
178205 }
179-
180- console . log ( "[SMD] 模型加载完成" ) ;
181206}
0 commit comments