@lark-apaas/coding-steering 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -13
- package/steering/html/skills/rich-interactive-design/SKILL.md +5 -2
- package/steering/html/skills/rich-interactive-design/references/tasks/interactive-scene.md +1 -0
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +214 -0
- package/steering/vite-react/skills/react-three-fiber/references/animation.md +1001 -0
- package/steering/vite-react/skills/react-three-fiber/references/fundamentals.md +877 -0
- package/steering/vite-react/skills/react-three-fiber/references/geometry.md +717 -0
- package/steering/vite-react/skills/react-three-fiber/references/interaction.md +880 -0
- package/steering/vite-react/skills/react-three-fiber/references/lighting.md +668 -0
- package/steering/vite-react/skills/react-three-fiber/references/loaders.md +607 -0
- package/steering/vite-react/skills/react-three-fiber/references/materials.md +601 -0
- package/steering/vite-react/skills/react-three-fiber/references/physics.md +820 -0
- package/steering/vite-react/skills/react-three-fiber/references/postprocessing.md +754 -0
- package/steering/vite-react/skills/react-three-fiber/references/shaders.md +874 -0
- package/steering/vite-react/skills/react-three-fiber/references/textures.md +635 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
# React Three Fiber Animation
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { Canvas, useFrame } from '@react-three/fiber'
|
|
7
|
+
import { useRef } from 'react'
|
|
8
|
+
|
|
9
|
+
function RotatingBox() {
|
|
10
|
+
const meshRef = useRef()
|
|
11
|
+
|
|
12
|
+
useFrame((state, delta) => {
|
|
13
|
+
meshRef.current.rotation.x += delta
|
|
14
|
+
meshRef.current.rotation.y += delta * 0.5
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<mesh ref={meshRef}>
|
|
19
|
+
<boxGeometry />
|
|
20
|
+
<meshStandardMaterial color="hotpink" />
|
|
21
|
+
</mesh>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function App() {
|
|
26
|
+
return (
|
|
27
|
+
<Canvas>
|
|
28
|
+
<ambientLight />
|
|
29
|
+
<RotatingBox />
|
|
30
|
+
</Canvas>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## useFrame Hook
|
|
36
|
+
|
|
37
|
+
The core animation hook in R3F. Runs every frame.
|
|
38
|
+
|
|
39
|
+
### Basic Usage
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { useFrame } from '@react-three/fiber'
|
|
43
|
+
import { useRef } from 'react'
|
|
44
|
+
|
|
45
|
+
function AnimatedMesh() {
|
|
46
|
+
const meshRef = useRef()
|
|
47
|
+
|
|
48
|
+
useFrame((state, delta) => {
|
|
49
|
+
// state contains: clock, camera, scene, gl, mouse, etc.
|
|
50
|
+
// delta is time since last frame in seconds
|
|
51
|
+
|
|
52
|
+
meshRef.current.rotation.y += delta
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<mesh ref={meshRef}>
|
|
57
|
+
<boxGeometry />
|
|
58
|
+
<meshStandardMaterial color="orange" />
|
|
59
|
+
</mesh>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### State Object
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
useFrame((state, delta, xrFrame) => {
|
|
68
|
+
const {
|
|
69
|
+
clock, // THREE.Clock
|
|
70
|
+
camera, // Current camera
|
|
71
|
+
scene, // Scene
|
|
72
|
+
gl, // WebGLRenderer
|
|
73
|
+
mouse, // Normalized mouse position (-1 to 1)
|
|
74
|
+
pointer, // Same as mouse
|
|
75
|
+
viewport, // Viewport dimensions
|
|
76
|
+
size, // Canvas size
|
|
77
|
+
raycaster, // Raycaster
|
|
78
|
+
get, // Get current state
|
|
79
|
+
set, // Set state
|
|
80
|
+
invalidate, // Request re-render (when frameloop="demand")
|
|
81
|
+
} = state
|
|
82
|
+
|
|
83
|
+
// Time-based animation
|
|
84
|
+
const t = clock.getElapsedTime()
|
|
85
|
+
meshRef.current.position.y = Math.sin(t) * 2
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Render Priority
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// Lower numbers run first. Default is 0.
|
|
93
|
+
// Use negative for pre-render, positive for post-render
|
|
94
|
+
|
|
95
|
+
function PreRender() {
|
|
96
|
+
useFrame(() => {
|
|
97
|
+
// Runs before main render
|
|
98
|
+
}, -1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function PostRender() {
|
|
102
|
+
useFrame(() => {
|
|
103
|
+
// Runs after main render
|
|
104
|
+
}, 1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function DefaultRender() {
|
|
108
|
+
useFrame(() => {
|
|
109
|
+
// Runs at default priority (0)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Conditional Animation
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
function ConditionalAnimation({ isAnimating }) {
|
|
118
|
+
const meshRef = useRef()
|
|
119
|
+
|
|
120
|
+
useFrame((state, delta) => {
|
|
121
|
+
if (!isAnimating) return
|
|
122
|
+
meshRef.current.rotation.y += delta
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return <mesh ref={meshRef}>...</mesh>
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## GLTF Animations with useAnimations
|
|
130
|
+
|
|
131
|
+
The recommended way to play animations from GLTF/GLB files.
|
|
132
|
+
|
|
133
|
+
### useAnimations Basic Usage
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { useGLTF, useAnimations } from '@react-three/drei'
|
|
137
|
+
import { useEffect, useRef } from 'react'
|
|
138
|
+
|
|
139
|
+
function AnimatedModel() {
|
|
140
|
+
const group = useRef()
|
|
141
|
+
const { scene, animations } = useGLTF('/models/character.glb')
|
|
142
|
+
const { actions, names } = useAnimations(animations, group)
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
// Play first animation
|
|
146
|
+
actions[names[0]]?.play()
|
|
147
|
+
}, [actions, names])
|
|
148
|
+
|
|
149
|
+
return <primitive ref={group} object={scene} />
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Animation Control
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
function Character() {
|
|
157
|
+
const group = useRef()
|
|
158
|
+
const { scene, animations } = useGLTF('/models/character.glb')
|
|
159
|
+
const { actions, mixer } = useAnimations(animations, group)
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const action = actions['Walk']
|
|
163
|
+
if (action) {
|
|
164
|
+
// Playback control
|
|
165
|
+
action.play()
|
|
166
|
+
action.stop()
|
|
167
|
+
action.reset()
|
|
168
|
+
action.paused = true
|
|
169
|
+
|
|
170
|
+
// Speed
|
|
171
|
+
action.timeScale = 1.5 // 1.5x speed
|
|
172
|
+
action.timeScale = -1 // Reverse
|
|
173
|
+
|
|
174
|
+
// Loop modes
|
|
175
|
+
action.loop = THREE.LoopOnce
|
|
176
|
+
action.loop = THREE.LoopRepeat
|
|
177
|
+
action.loop = THREE.LoopPingPong
|
|
178
|
+
action.repetitions = 3
|
|
179
|
+
action.clampWhenFinished = true
|
|
180
|
+
|
|
181
|
+
// Weight (for blending)
|
|
182
|
+
action.weight = 1
|
|
183
|
+
}
|
|
184
|
+
}, [actions])
|
|
185
|
+
|
|
186
|
+
return <primitive ref={group} object={scene} />
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Crossfade Between Animations
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { useGLTF, useAnimations } from '@react-three/drei'
|
|
194
|
+
import { useState, useEffect, useRef } from 'react'
|
|
195
|
+
|
|
196
|
+
function Character() {
|
|
197
|
+
const group = useRef()
|
|
198
|
+
const { scene, animations } = useGLTF('/models/character.glb')
|
|
199
|
+
const { actions } = useAnimations(animations, group)
|
|
200
|
+
const [currentAnim, setCurrentAnim] = useState('Idle')
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
// Fade out all animations
|
|
204
|
+
Object.values(actions).forEach(action => {
|
|
205
|
+
action?.fadeOut(0.5)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Fade in current animation
|
|
209
|
+
actions[currentAnim]?.reset().fadeIn(0.5).play()
|
|
210
|
+
}, [currentAnim, actions])
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<group ref={group}>
|
|
214
|
+
<primitive object={scene} />
|
|
215
|
+
</group>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Animation Events
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
function AnimatedModel() {
|
|
224
|
+
const group = useRef()
|
|
225
|
+
const { scene, animations } = useGLTF('/models/character.glb')
|
|
226
|
+
const { actions, mixer } = useAnimations(animations, group)
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
// Listen for animation events
|
|
230
|
+
const onFinished = (e) => {
|
|
231
|
+
console.log('Animation finished:', e.action.getClip().name)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const onLoop = (e) => {
|
|
235
|
+
console.log('Animation looped:', e.action.getClip().name)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
mixer.addEventListener('finished', onFinished)
|
|
239
|
+
mixer.addEventListener('loop', onLoop)
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
mixer.removeEventListener('finished', onFinished)
|
|
243
|
+
mixer.removeEventListener('loop', onLoop)
|
|
244
|
+
}
|
|
245
|
+
}, [mixer])
|
|
246
|
+
|
|
247
|
+
return <primitive ref={group} object={scene} />
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Animation Blending
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
function CharacterController({ speed = 0 }) {
|
|
255
|
+
const group = useRef()
|
|
256
|
+
const { scene, animations } = useGLTF('/models/character.glb')
|
|
257
|
+
const { actions } = useAnimations(animations, group)
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
// Start all animations
|
|
261
|
+
actions['Idle']?.play()
|
|
262
|
+
actions['Walk']?.play()
|
|
263
|
+
actions['Run']?.play()
|
|
264
|
+
}, [actions])
|
|
265
|
+
|
|
266
|
+
// Blend based on speed
|
|
267
|
+
useFrame(() => {
|
|
268
|
+
if (speed < 0.1) {
|
|
269
|
+
actions['Idle']?.setEffectiveWeight(1)
|
|
270
|
+
actions['Walk']?.setEffectiveWeight(0)
|
|
271
|
+
actions['Run']?.setEffectiveWeight(0)
|
|
272
|
+
} else if (speed < 5) {
|
|
273
|
+
const t = speed / 5
|
|
274
|
+
actions['Idle']?.setEffectiveWeight(1 - t)
|
|
275
|
+
actions['Walk']?.setEffectiveWeight(t)
|
|
276
|
+
actions['Run']?.setEffectiveWeight(0)
|
|
277
|
+
} else {
|
|
278
|
+
const t = Math.min((speed - 5) / 5, 1)
|
|
279
|
+
actions['Idle']?.setEffectiveWeight(0)
|
|
280
|
+
actions['Walk']?.setEffectiveWeight(1 - t)
|
|
281
|
+
actions['Run']?.setEffectiveWeight(t)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
return <primitive ref={group} object={scene} />
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Spring Animation (@react-spring/three)
|
|
290
|
+
|
|
291
|
+
Physics-based spring animations that integrate with R3F.
|
|
292
|
+
|
|
293
|
+
### Installation
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
npm install @react-spring/three
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Basic Spring
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
import { useSpring, animated } from '@react-spring/three'
|
|
303
|
+
|
|
304
|
+
function AnimatedBox() {
|
|
305
|
+
const [active, setActive] = useState(false)
|
|
306
|
+
|
|
307
|
+
const { scale, color } = useSpring({
|
|
308
|
+
scale: active ? 1.5 : 1,
|
|
309
|
+
color: active ? '#ff6b6b' : '#4ecdc4',
|
|
310
|
+
config: { mass: 1, tension: 280, friction: 60 }
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<animated.mesh
|
|
315
|
+
scale={scale}
|
|
316
|
+
onClick={() => setActive(!active)}
|
|
317
|
+
>
|
|
318
|
+
<boxGeometry />
|
|
319
|
+
<animated.meshStandardMaterial color={color} />
|
|
320
|
+
</animated.mesh>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Spring Config Presets
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
import { useSpring, animated, config } from '@react-spring/three'
|
|
329
|
+
|
|
330
|
+
function SpringPresets() {
|
|
331
|
+
const { position } = useSpring({
|
|
332
|
+
position: [0, 2, 0],
|
|
333
|
+
config: config.wobbly // Presets: default, gentle, wobbly, stiff, slow, molasses
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Or custom config
|
|
337
|
+
const { rotation } = useSpring({
|
|
338
|
+
rotation: [0, Math.PI, 0],
|
|
339
|
+
config: {
|
|
340
|
+
mass: 1,
|
|
341
|
+
tension: 170,
|
|
342
|
+
friction: 26,
|
|
343
|
+
clamp: false,
|
|
344
|
+
precision: 0.01,
|
|
345
|
+
velocity: 0,
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<animated.mesh position={position} rotation={rotation}>
|
|
351
|
+
<boxGeometry />
|
|
352
|
+
<meshStandardMaterial />
|
|
353
|
+
</animated.mesh>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Multiple Springs
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
import { useSprings, animated } from '@react-spring/three'
|
|
362
|
+
|
|
363
|
+
function AnimatedBoxes({ count = 5 }) {
|
|
364
|
+
const [springs, api] = useSprings(count, (i) => ({
|
|
365
|
+
position: [i * 2 - count, 0, 0],
|
|
366
|
+
scale: 1,
|
|
367
|
+
config: { mass: 1, tension: 280, friction: 60 }
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
const handleClick = (index) => {
|
|
371
|
+
api.start((i) => {
|
|
372
|
+
if (i === index) return { scale: 1.5 }
|
|
373
|
+
return { scale: 1 }
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return springs.map((spring, i) => (
|
|
378
|
+
<animated.mesh
|
|
379
|
+
key={i}
|
|
380
|
+
position={spring.position}
|
|
381
|
+
scale={spring.scale}
|
|
382
|
+
onClick={() => handleClick(i)}
|
|
383
|
+
>
|
|
384
|
+
<boxGeometry />
|
|
385
|
+
<meshStandardMaterial color="orange" />
|
|
386
|
+
</animated.mesh>
|
|
387
|
+
))
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Gesture Integration
|
|
392
|
+
|
|
393
|
+
```tsx
|
|
394
|
+
import { useSpring, animated } from '@react-spring/three'
|
|
395
|
+
import { useDrag } from '@use-gesture/react'
|
|
396
|
+
|
|
397
|
+
function DraggableBox() {
|
|
398
|
+
const [spring, api] = useSpring(() => ({
|
|
399
|
+
position: [0, 0, 0],
|
|
400
|
+
config: { mass: 1, tension: 280, friction: 60 }
|
|
401
|
+
}))
|
|
402
|
+
|
|
403
|
+
const bind = useDrag(({ movement: [mx, my], down }) => {
|
|
404
|
+
api.start({
|
|
405
|
+
position: down ? [mx / 100, -my / 100, 0] : [0, 0, 0]
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<animated.mesh {...bind()} position={spring.position}>
|
|
411
|
+
<boxGeometry />
|
|
412
|
+
<meshStandardMaterial color="hotpink" />
|
|
413
|
+
</animated.mesh>
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Chain Animations
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
import { useSpring, animated, useChain, useSpringRef } from '@react-spring/three'
|
|
422
|
+
|
|
423
|
+
function ChainedAnimation() {
|
|
424
|
+
const scaleRef = useSpringRef()
|
|
425
|
+
const rotationRef = useSpringRef()
|
|
426
|
+
|
|
427
|
+
const { scale } = useSpring({
|
|
428
|
+
ref: scaleRef,
|
|
429
|
+
from: { scale: 0 },
|
|
430
|
+
to: { scale: 1 },
|
|
431
|
+
config: { tension: 200, friction: 20 }
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const { rotation } = useSpring({
|
|
435
|
+
ref: rotationRef,
|
|
436
|
+
from: { rotation: [0, 0, 0] },
|
|
437
|
+
to: { rotation: [0, Math.PI * 2, 0] },
|
|
438
|
+
config: { tension: 100, friction: 30 }
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Scale first (0-0.5), then rotation (0.5-1)
|
|
442
|
+
useChain([scaleRef, rotationRef], [0, 0.5])
|
|
443
|
+
|
|
444
|
+
return (
|
|
445
|
+
<animated.mesh scale={scale} rotation={rotation}>
|
|
446
|
+
<boxGeometry />
|
|
447
|
+
<meshStandardMaterial color="cyan" />
|
|
448
|
+
</animated.mesh>
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
## Morph Targets
|
|
454
|
+
|
|
455
|
+
Blend between different mesh shapes.
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
import { useGLTF } from '@react-three/drei'
|
|
459
|
+
import { useFrame } from '@react-three/fiber'
|
|
460
|
+
import { useRef } from 'react'
|
|
461
|
+
|
|
462
|
+
function MorphingFace() {
|
|
463
|
+
const { scene, nodes } = useGLTF('/models/face.glb')
|
|
464
|
+
const meshRef = useRef()
|
|
465
|
+
|
|
466
|
+
useFrame(({ clock }) => {
|
|
467
|
+
const t = clock.getElapsedTime()
|
|
468
|
+
|
|
469
|
+
// Access morph target influences
|
|
470
|
+
if (meshRef.current?.morphTargetInfluences) {
|
|
471
|
+
// Animate smile
|
|
472
|
+
const smileIndex = meshRef.current.morphTargetDictionary['smile']
|
|
473
|
+
meshRef.current.morphTargetInfluences[smileIndex] = (Math.sin(t) + 1) / 2
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<primitive ref={meshRef} object={nodes.Face} />
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Controlled Morph Targets
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
function MorphControls({ morphInfluences }) {
|
|
487
|
+
const { nodes } = useGLTF('/models/face.glb')
|
|
488
|
+
const meshRef = useRef()
|
|
489
|
+
|
|
490
|
+
useFrame(() => {
|
|
491
|
+
if (meshRef.current?.morphTargetInfluences) {
|
|
492
|
+
Object.entries(morphInfluences).forEach(([name, value]) => {
|
|
493
|
+
const index = meshRef.current.morphTargetDictionary[name]
|
|
494
|
+
if (index !== undefined) {
|
|
495
|
+
meshRef.current.morphTargetInfluences[index] = value
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
return <primitive ref={meshRef} object={nodes.Face} />
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Usage
|
|
505
|
+
<MorphControls morphInfluences={{ smile: 0.5, blink: 1, angry: 0 }} />
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
## Skeletal Animation
|
|
509
|
+
|
|
510
|
+
### Accessing Bones
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
import { useGLTF } from '@react-three/drei'
|
|
514
|
+
import { useFrame } from '@react-three/fiber'
|
|
515
|
+
import { useEffect, useRef } from 'react'
|
|
516
|
+
|
|
517
|
+
function SkeletalCharacter() {
|
|
518
|
+
const { scene } = useGLTF('/models/character.glb')
|
|
519
|
+
const headBoneRef = useRef()
|
|
520
|
+
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
// Find skeleton
|
|
523
|
+
scene.traverse((child) => {
|
|
524
|
+
if (child.isSkinnedMesh) {
|
|
525
|
+
const skeleton = child.skeleton
|
|
526
|
+
const headBone = skeleton.bones.find(b => b.name === 'Head')
|
|
527
|
+
headBoneRef.current = headBone
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
}, [scene])
|
|
531
|
+
|
|
532
|
+
// Animate bone
|
|
533
|
+
useFrame(({ clock }) => {
|
|
534
|
+
if (headBoneRef.current) {
|
|
535
|
+
headBoneRef.current.rotation.y = Math.sin(clock.elapsedTime) * 0.3
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
return <primitive object={scene} />
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Bone Attachments
|
|
544
|
+
|
|
545
|
+
```tsx
|
|
546
|
+
function CharacterWithWeapon() {
|
|
547
|
+
const { scene } = useGLTF('/models/character.glb')
|
|
548
|
+
const weaponRef = useRef()
|
|
549
|
+
const handBoneRef = useRef()
|
|
550
|
+
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
scene.traverse((child) => {
|
|
553
|
+
if (child.isSkinnedMesh) {
|
|
554
|
+
const handBone = child.skeleton.bones.find(b => b.name === 'RightHand')
|
|
555
|
+
if (handBone && weaponRef.current) {
|
|
556
|
+
handBone.add(weaponRef.current)
|
|
557
|
+
handBoneRef.current = handBone
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
return () => {
|
|
563
|
+
// Cleanup
|
|
564
|
+
if (handBoneRef.current && weaponRef.current) {
|
|
565
|
+
handBoneRef.current.remove(weaponRef.current)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}, [scene])
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<>
|
|
572
|
+
<primitive object={scene} />
|
|
573
|
+
<mesh ref={weaponRef} position={[0, 0, 0.5]}>
|
|
574
|
+
<boxGeometry args={[0.1, 0.1, 1]} />
|
|
575
|
+
<meshStandardMaterial color="gray" />
|
|
576
|
+
</mesh>
|
|
577
|
+
</>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
## Procedural Animation Patterns
|
|
583
|
+
|
|
584
|
+
### Smooth Damping
|
|
585
|
+
|
|
586
|
+
```tsx
|
|
587
|
+
import { useFrame } from '@react-three/fiber'
|
|
588
|
+
import { useRef } from 'react'
|
|
589
|
+
import * as THREE from 'three'
|
|
590
|
+
|
|
591
|
+
function SmoothFollow({ target }) {
|
|
592
|
+
const meshRef = useRef()
|
|
593
|
+
const currentPos = useRef(new THREE.Vector3())
|
|
594
|
+
|
|
595
|
+
useFrame((state, delta) => {
|
|
596
|
+
// Lerp towards target
|
|
597
|
+
currentPos.current.lerp(target, delta * 5)
|
|
598
|
+
meshRef.current.position.copy(currentPos.current)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
return (
|
|
602
|
+
<mesh ref={meshRef}>
|
|
603
|
+
<sphereGeometry args={[0.5]} />
|
|
604
|
+
<meshStandardMaterial color="blue" />
|
|
605
|
+
</mesh>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Spring Physics (Manual)
|
|
611
|
+
|
|
612
|
+
```tsx
|
|
613
|
+
function SpringMesh({ target = 0 }) {
|
|
614
|
+
const meshRef = useRef()
|
|
615
|
+
const spring = useRef({
|
|
616
|
+
position: 0,
|
|
617
|
+
velocity: 0,
|
|
618
|
+
stiffness: 100,
|
|
619
|
+
damping: 10
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
useFrame((state, delta) => {
|
|
623
|
+
const s = spring.current
|
|
624
|
+
const force = -s.stiffness * (s.position - target)
|
|
625
|
+
const dampingForce = -s.damping * s.velocity
|
|
626
|
+
|
|
627
|
+
s.velocity += (force + dampingForce) * delta
|
|
628
|
+
s.position += s.velocity * delta
|
|
629
|
+
|
|
630
|
+
meshRef.current.position.y = s.position
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<mesh ref={meshRef}>
|
|
635
|
+
<boxGeometry />
|
|
636
|
+
<meshStandardMaterial color="green" />
|
|
637
|
+
</mesh>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Oscillation Patterns
|
|
643
|
+
|
|
644
|
+
```tsx
|
|
645
|
+
function OscillatingMesh() {
|
|
646
|
+
const meshRef = useRef()
|
|
647
|
+
|
|
648
|
+
useFrame(({ clock }) => {
|
|
649
|
+
const t = clock.elapsedTime
|
|
650
|
+
|
|
651
|
+
// Sine wave
|
|
652
|
+
meshRef.current.position.y = Math.sin(t * 2) * 0.5
|
|
653
|
+
|
|
654
|
+
// Circular motion
|
|
655
|
+
meshRef.current.position.x = Math.cos(t) * 2
|
|
656
|
+
meshRef.current.position.z = Math.sin(t) * 2
|
|
657
|
+
|
|
658
|
+
// Bouncing
|
|
659
|
+
meshRef.current.position.y = Math.abs(Math.sin(t * 3)) * 2
|
|
660
|
+
|
|
661
|
+
// Figure 8
|
|
662
|
+
meshRef.current.position.x = Math.sin(t) * 2
|
|
663
|
+
meshRef.current.position.z = Math.sin(t * 2) * 1
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<mesh ref={meshRef}>
|
|
668
|
+
<sphereGeometry args={[0.3]} />
|
|
669
|
+
<meshStandardMaterial color="purple" />
|
|
670
|
+
</mesh>
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
## Drei Animation Helpers
|
|
676
|
+
|
|
677
|
+
### Float
|
|
678
|
+
|
|
679
|
+
```tsx
|
|
680
|
+
import { Float } from '@react-three/drei'
|
|
681
|
+
|
|
682
|
+
function FloatingObject() {
|
|
683
|
+
return (
|
|
684
|
+
<Float
|
|
685
|
+
speed={1} // Animation speed
|
|
686
|
+
rotationIntensity={1} // Rotation intensity
|
|
687
|
+
floatIntensity={1} // Float intensity
|
|
688
|
+
floatingRange={[-0.1, 0.1]} // Range of y-axis float
|
|
689
|
+
>
|
|
690
|
+
<mesh>
|
|
691
|
+
<boxGeometry />
|
|
692
|
+
<meshStandardMaterial color="gold" />
|
|
693
|
+
</mesh>
|
|
694
|
+
</Float>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### MeshWobbleMaterial / MeshDistortMaterial
|
|
700
|
+
|
|
701
|
+
```tsx
|
|
702
|
+
import { MeshWobbleMaterial, MeshDistortMaterial } from '@react-three/drei'
|
|
703
|
+
|
|
704
|
+
function WobblyMesh() {
|
|
705
|
+
return (
|
|
706
|
+
<mesh>
|
|
707
|
+
<torusKnotGeometry args={[1, 0.4, 100, 16]} />
|
|
708
|
+
<MeshWobbleMaterial
|
|
709
|
+
factor={1} // Wobble amplitude
|
|
710
|
+
speed={2} // Wobble speed
|
|
711
|
+
color="hotpink"
|
|
712
|
+
/>
|
|
713
|
+
</mesh>
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function DistortedMesh() {
|
|
718
|
+
return (
|
|
719
|
+
<mesh>
|
|
720
|
+
<sphereGeometry args={[1, 64, 64]} />
|
|
721
|
+
<MeshDistortMaterial
|
|
722
|
+
distort={0.5} // Distortion amount
|
|
723
|
+
speed={2} // Animation speed
|
|
724
|
+
color="cyan"
|
|
725
|
+
/>
|
|
726
|
+
</mesh>
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### Trail
|
|
732
|
+
|
|
733
|
+
```tsx
|
|
734
|
+
import { Trail } from '@react-three/drei'
|
|
735
|
+
import { useFrame } from '@react-three/fiber'
|
|
736
|
+
import { useRef } from 'react'
|
|
737
|
+
|
|
738
|
+
function TrailingMesh() {
|
|
739
|
+
const meshRef = useRef()
|
|
740
|
+
|
|
741
|
+
useFrame(({ clock }) => {
|
|
742
|
+
const t = clock.elapsedTime
|
|
743
|
+
meshRef.current.position.x = Math.sin(t) * 3
|
|
744
|
+
meshRef.current.position.y = Math.cos(t * 2) * 2
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
return (
|
|
748
|
+
<Trail
|
|
749
|
+
width={2}
|
|
750
|
+
length={8}
|
|
751
|
+
color="hotpink"
|
|
752
|
+
attenuation={(t) => t * t}
|
|
753
|
+
>
|
|
754
|
+
<mesh ref={meshRef}>
|
|
755
|
+
<sphereGeometry args={[0.2]} />
|
|
756
|
+
<meshStandardMaterial color="white" />
|
|
757
|
+
</mesh>
|
|
758
|
+
</Trail>
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
## Animation with Zustand State
|
|
764
|
+
|
|
765
|
+
```tsx
|
|
766
|
+
import { create } from 'zustand'
|
|
767
|
+
import { useFrame } from '@react-three/fiber'
|
|
768
|
+
|
|
769
|
+
const useStore = create((set) => ({
|
|
770
|
+
isAnimating: false,
|
|
771
|
+
speed: 1,
|
|
772
|
+
toggleAnimation: () => set((state) => ({ isAnimating: !state.isAnimating })),
|
|
773
|
+
setSpeed: (speed) => set({ speed })
|
|
774
|
+
}))
|
|
775
|
+
|
|
776
|
+
function AnimatedMesh() {
|
|
777
|
+
const meshRef = useRef()
|
|
778
|
+
const { isAnimating, speed } = useStore()
|
|
779
|
+
|
|
780
|
+
useFrame((state, delta) => {
|
|
781
|
+
if (isAnimating) {
|
|
782
|
+
meshRef.current.rotation.y += delta * speed
|
|
783
|
+
}
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
return (
|
|
787
|
+
<mesh ref={meshRef}>
|
|
788
|
+
<boxGeometry />
|
|
789
|
+
<meshStandardMaterial color="orange" />
|
|
790
|
+
</mesh>
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// UI Component
|
|
795
|
+
function Controls() {
|
|
796
|
+
const { toggleAnimation, setSpeed } = useStore()
|
|
797
|
+
|
|
798
|
+
return (
|
|
799
|
+
<div>
|
|
800
|
+
<button onClick={toggleAnimation}>Toggle</button>
|
|
801
|
+
<input
|
|
802
|
+
type="range"
|
|
803
|
+
min="0"
|
|
804
|
+
max="5"
|
|
805
|
+
step="0.1"
|
|
806
|
+
onChange={(e) => setSpeed(parseFloat(e.target.value))}
|
|
807
|
+
/>
|
|
808
|
+
</div>
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
## State Management Performance
|
|
814
|
+
|
|
815
|
+
Critical patterns for high-performance state management in animations.
|
|
816
|
+
|
|
817
|
+
### getState() in useFrame
|
|
818
|
+
|
|
819
|
+
Use `getState()` instead of hooks inside useFrame for zero subscription overhead:
|
|
820
|
+
|
|
821
|
+
```tsx
|
|
822
|
+
import { create } from 'zustand'
|
|
823
|
+
|
|
824
|
+
const useGameStore = create((set) => ({
|
|
825
|
+
playerPosition: [0, 0, 0],
|
|
826
|
+
targetPosition: [0, 0, 0],
|
|
827
|
+
setPlayerPosition: (pos) => set({ playerPosition: pos }),
|
|
828
|
+
}))
|
|
829
|
+
|
|
830
|
+
function Player() {
|
|
831
|
+
const meshRef = useRef()
|
|
832
|
+
|
|
833
|
+
useFrame((state, delta) => {
|
|
834
|
+
// ✅ GOOD: getState() has no subscription overhead
|
|
835
|
+
const { targetPosition } = useGameStore.getState()
|
|
836
|
+
|
|
837
|
+
// Lerp towards target
|
|
838
|
+
meshRef.current.position.lerp(
|
|
839
|
+
new THREE.Vector3(...targetPosition),
|
|
840
|
+
delta * 5
|
|
841
|
+
)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<mesh ref={meshRef}>
|
|
846
|
+
<boxGeometry />
|
|
847
|
+
<meshStandardMaterial color="blue" />
|
|
848
|
+
</mesh>
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
### Transient Subscriptions
|
|
854
|
+
|
|
855
|
+
Subscribe to state changes without triggering React re-renders:
|
|
856
|
+
|
|
857
|
+
```tsx
|
|
858
|
+
import { useEffect, useRef } from 'react'
|
|
859
|
+
|
|
860
|
+
function Enemy() {
|
|
861
|
+
const meshRef = useRef()
|
|
862
|
+
|
|
863
|
+
useEffect(() => {
|
|
864
|
+
// Subscribe directly - updates mesh without re-rendering component
|
|
865
|
+
const unsub = useGameStore.subscribe(
|
|
866
|
+
(state) => state.playerPosition,
|
|
867
|
+
(playerPos) => {
|
|
868
|
+
// Look at player (runs on every state change, no re-render)
|
|
869
|
+
meshRef.current.lookAt(...playerPos)
|
|
870
|
+
}
|
|
871
|
+
)
|
|
872
|
+
return unsub
|
|
873
|
+
}, [])
|
|
874
|
+
|
|
875
|
+
return (
|
|
876
|
+
<mesh ref={meshRef}>
|
|
877
|
+
<coneGeometry args={[0.5, 1, 4]} />
|
|
878
|
+
<meshStandardMaterial color="red" />
|
|
879
|
+
</mesh>
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
### Selective Subscriptions with Shallow
|
|
885
|
+
|
|
886
|
+
Subscribe to multiple values efficiently:
|
|
887
|
+
|
|
888
|
+
```tsx
|
|
889
|
+
import { shallow } from 'zustand/shallow'
|
|
890
|
+
|
|
891
|
+
function HUD() {
|
|
892
|
+
// Only re-renders when health OR score actually changes
|
|
893
|
+
const { health, score } = useGameStore(
|
|
894
|
+
(state) => ({ health: state.health, score: state.score }),
|
|
895
|
+
shallow
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<Html>
|
|
900
|
+
<div>Health: {health}</div>
|
|
901
|
+
<div>Score: {score}</div>
|
|
902
|
+
</Html>
|
|
903
|
+
)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// For single values, no shallow needed
|
|
907
|
+
const health = useGameStore((state) => state.health)
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Isolate Animated Components
|
|
911
|
+
|
|
912
|
+
Separate state-dependent UI from animated 3D objects:
|
|
913
|
+
|
|
914
|
+
```tsx
|
|
915
|
+
// ❌ BAD: Parent re-renders cause animation jank
|
|
916
|
+
function BadPattern() {
|
|
917
|
+
const [score, setScore] = useState(0)
|
|
918
|
+
const meshRef = useRef()
|
|
919
|
+
|
|
920
|
+
useFrame((_, delta) => {
|
|
921
|
+
meshRef.current.rotation.y += delta // Affected by score re-renders
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
return (
|
|
925
|
+
<>
|
|
926
|
+
<mesh ref={meshRef}>...</mesh>
|
|
927
|
+
<ScoreDisplay score={score} />
|
|
928
|
+
</>
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ✅ GOOD: Isolated animation component
|
|
933
|
+
function GoodPattern() {
|
|
934
|
+
return (
|
|
935
|
+
<>
|
|
936
|
+
<AnimatedMesh /> {/* Never re-renders from score */}
|
|
937
|
+
<ScoreDisplay /> {/* Has its own state subscription */}
|
|
938
|
+
</>
|
|
939
|
+
)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function AnimatedMesh() {
|
|
943
|
+
const meshRef = useRef()
|
|
944
|
+
|
|
945
|
+
useFrame((_, delta) => {
|
|
946
|
+
meshRef.current.rotation.y += delta // Smooth, uninterrupted
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
return <mesh ref={meshRef}>...</mesh>
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function ScoreDisplay() {
|
|
953
|
+
const score = useGameStore((state) => state.score)
|
|
954
|
+
return <Html><div>Score: {score}</div></Html>
|
|
955
|
+
}
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
## Performance Tips
|
|
959
|
+
|
|
960
|
+
1. **Isolate animated components**: Only the animated mesh re-renders
|
|
961
|
+
2. **Use refs over state**: Avoid React re-renders for animations
|
|
962
|
+
3. **Throttle expensive calculations**: Use delta accumulation
|
|
963
|
+
4. **Pause offscreen animations**: Check visibility
|
|
964
|
+
5. **Share animation clips**: Same clip for multiple instances
|
|
965
|
+
|
|
966
|
+
```tsx
|
|
967
|
+
// Isolate animation to prevent parent re-renders
|
|
968
|
+
function Scene() {
|
|
969
|
+
return (
|
|
970
|
+
<>
|
|
971
|
+
<StaticMesh /> {/* Never re-renders */}
|
|
972
|
+
<AnimatedMesh /> {/* Only this updates */}
|
|
973
|
+
</>
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Throttle expensive operations
|
|
978
|
+
function ThrottledAnimation() {
|
|
979
|
+
const meshRef = useRef()
|
|
980
|
+
const accumulated = useRef(0)
|
|
981
|
+
|
|
982
|
+
useFrame((state, delta) => {
|
|
983
|
+
accumulated.current += delta
|
|
984
|
+
|
|
985
|
+
// Only update every 100ms
|
|
986
|
+
if (accumulated.current > 0.1) {
|
|
987
|
+
// Expensive calculation here
|
|
988
|
+
accumulated.current = 0
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Cheap operations every frame
|
|
992
|
+
meshRef.current.rotation.y += delta
|
|
993
|
+
})
|
|
994
|
+
}
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
## See Also
|
|
998
|
+
|
|
999
|
+
- `r3f-loaders` - Loading animated GLTF models
|
|
1000
|
+
- `r3f-fundamentals` - useFrame and animation loop
|
|
1001
|
+
- `r3f-shaders` - Vertex animation in shaders
|