@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,877 @@
|
|
|
1
|
+
# React Three Fiber Fundamentals
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { Canvas } from '@react-three/fiber'
|
|
7
|
+
import { useRef } from 'react'
|
|
8
|
+
import { useFrame } from '@react-three/fiber'
|
|
9
|
+
|
|
10
|
+
function RotatingBox() {
|
|
11
|
+
const meshRef = useRef()
|
|
12
|
+
|
|
13
|
+
useFrame((state, delta) => {
|
|
14
|
+
meshRef.current.rotation.x += delta
|
|
15
|
+
meshRef.current.rotation.y += delta * 0.5
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<mesh ref={meshRef}>
|
|
20
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
21
|
+
<meshStandardMaterial color="hotpink" />
|
|
22
|
+
</mesh>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function App() {
|
|
27
|
+
return (
|
|
28
|
+
<Canvas camera={{ position: [0, 0, 5], fov: 75 }}>
|
|
29
|
+
<ambientLight intensity={0.5} />
|
|
30
|
+
<directionalLight position={[5, 5, 5]} />
|
|
31
|
+
<RotatingBox />
|
|
32
|
+
</Canvas>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Canvas Component
|
|
38
|
+
|
|
39
|
+
The root component that creates the WebGL context, scene, camera, and renderer.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { Canvas } from '@react-three/fiber'
|
|
43
|
+
|
|
44
|
+
function App() {
|
|
45
|
+
return (
|
|
46
|
+
<Canvas
|
|
47
|
+
// Camera configuration
|
|
48
|
+
camera={{
|
|
49
|
+
position: [0, 5, 10],
|
|
50
|
+
fov: 75,
|
|
51
|
+
near: 0.1,
|
|
52
|
+
far: 1000,
|
|
53
|
+
}}
|
|
54
|
+
// Or use orthographic
|
|
55
|
+
orthographic
|
|
56
|
+
camera={{ zoom: 50, position: [0, 0, 100] }}
|
|
57
|
+
|
|
58
|
+
// Renderer settings
|
|
59
|
+
gl={{
|
|
60
|
+
antialias: true,
|
|
61
|
+
alpha: true,
|
|
62
|
+
powerPreference: 'high-performance',
|
|
63
|
+
preserveDrawingBuffer: true, // For screenshots
|
|
64
|
+
}}
|
|
65
|
+
dpr={[1, 2]} // Pixel ratio min/max
|
|
66
|
+
|
|
67
|
+
// Shadows
|
|
68
|
+
shadows // or shadows="soft" | "basic" | "percentage"
|
|
69
|
+
|
|
70
|
+
// Color management
|
|
71
|
+
flat // Disable automatic sRGB color management
|
|
72
|
+
|
|
73
|
+
// Frame loop control
|
|
74
|
+
frameloop="demand" // 'always' | 'demand' | 'never'
|
|
75
|
+
|
|
76
|
+
// Event handling
|
|
77
|
+
eventSource={document.getElementById('root')}
|
|
78
|
+
eventPrefix="client" // 'offset' | 'client' | 'page' | 'layer' | 'screen'
|
|
79
|
+
|
|
80
|
+
// Callbacks
|
|
81
|
+
onCreated={(state) => {
|
|
82
|
+
console.log('Canvas ready:', state.gl, state.scene, state.camera)
|
|
83
|
+
}}
|
|
84
|
+
onPointerMissed={() => console.log('Clicked background')}
|
|
85
|
+
|
|
86
|
+
// Styling
|
|
87
|
+
style={{ width: '100%', height: '100vh' }}
|
|
88
|
+
>
|
|
89
|
+
<Scene />
|
|
90
|
+
</Canvas>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Canvas Defaults
|
|
96
|
+
|
|
97
|
+
R3F sets sensible defaults:
|
|
98
|
+
|
|
99
|
+
- Renderer: antialias, alpha, outputColorSpace = SRGBColorSpace
|
|
100
|
+
- Camera: PerspectiveCamera at [0, 0, 5]
|
|
101
|
+
- Scene: Automatic resize handling
|
|
102
|
+
- Events: Pointer events enabled
|
|
103
|
+
|
|
104
|
+
## useFrame Hook
|
|
105
|
+
|
|
106
|
+
Subscribe to the render loop. Called every frame (typically 60fps).
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { useFrame } from '@react-three/fiber'
|
|
110
|
+
import { useRef } from 'react'
|
|
111
|
+
|
|
112
|
+
function AnimatedMesh() {
|
|
113
|
+
const meshRef = useRef()
|
|
114
|
+
|
|
115
|
+
useFrame((state, delta, xrFrame) => {
|
|
116
|
+
// state: Full R3F state (see useThree)
|
|
117
|
+
// delta: Time since last frame in seconds
|
|
118
|
+
// xrFrame: XR frame if in VR/AR mode
|
|
119
|
+
|
|
120
|
+
// Animate rotation
|
|
121
|
+
meshRef.current.rotation.y += delta
|
|
122
|
+
|
|
123
|
+
// Access clock
|
|
124
|
+
const elapsed = state.clock.elapsedTime
|
|
125
|
+
meshRef.current.position.y = Math.sin(elapsed) * 2
|
|
126
|
+
|
|
127
|
+
// Access pointer position (-1 to 1)
|
|
128
|
+
const { x, y } = state.pointer
|
|
129
|
+
meshRef.current.rotation.x = y * 0.5
|
|
130
|
+
meshRef.current.rotation.z = x * 0.5
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<mesh ref={meshRef}>
|
|
135
|
+
<boxGeometry />
|
|
136
|
+
<meshStandardMaterial color="orange" />
|
|
137
|
+
</mesh>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### useFrame with Priority
|
|
143
|
+
|
|
144
|
+
Control render order with priority (higher = later).
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// Default priority is 0
|
|
148
|
+
useFrame((state, delta) => {
|
|
149
|
+
// Runs first
|
|
150
|
+
}, -1)
|
|
151
|
+
|
|
152
|
+
useFrame((state, delta) => {
|
|
153
|
+
// Runs after priority -1
|
|
154
|
+
}, 0)
|
|
155
|
+
|
|
156
|
+
// Manual rendering with positive priority
|
|
157
|
+
useFrame((state, delta) => {
|
|
158
|
+
// Take over rendering
|
|
159
|
+
state.gl.render(state.scene, state.camera)
|
|
160
|
+
}, 1)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Conditional useFrame
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
function ConditionalAnimation({ active }) {
|
|
167
|
+
useFrame((state, delta) => {
|
|
168
|
+
if (!active) return // Skip when inactive
|
|
169
|
+
meshRef.current.rotation.y += delta
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## useThree Hook
|
|
175
|
+
|
|
176
|
+
Access the R3F state store.
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
import { useThree } from '@react-three/fiber'
|
|
180
|
+
|
|
181
|
+
function CameraInfo() {
|
|
182
|
+
// Get full state (triggers re-render on any change)
|
|
183
|
+
const state = useThree()
|
|
184
|
+
|
|
185
|
+
// Selective subscription (recommended)
|
|
186
|
+
const camera = useThree((state) => state.camera)
|
|
187
|
+
const gl = useThree((state) => state.gl)
|
|
188
|
+
const scene = useThree((state) => state.scene)
|
|
189
|
+
const size = useThree((state) => state.size)
|
|
190
|
+
|
|
191
|
+
// Available state properties:
|
|
192
|
+
// gl: WebGLRenderer
|
|
193
|
+
// scene: Scene
|
|
194
|
+
// camera: Camera
|
|
195
|
+
// raycaster: Raycaster
|
|
196
|
+
// pointer: Vector2 (normalized -1 to 1)
|
|
197
|
+
// mouse: Vector2 (deprecated, use pointer)
|
|
198
|
+
// clock: Clock
|
|
199
|
+
// size: { width, height, top, left }
|
|
200
|
+
// viewport: { width, height, factor, distance, aspect }
|
|
201
|
+
// performance: { current, min, max, debounce, regress }
|
|
202
|
+
// events: Event handlers
|
|
203
|
+
// set: State setter
|
|
204
|
+
// get: State getter
|
|
205
|
+
// invalidate: Trigger re-render (for frameloop="demand")
|
|
206
|
+
// advance: Advance one frame (for frameloop="never")
|
|
207
|
+
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Common useThree Patterns
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
// Responsive to viewport
|
|
216
|
+
function ResponsiveObject() {
|
|
217
|
+
const viewport = useThree((state) => state.viewport)
|
|
218
|
+
return (
|
|
219
|
+
<mesh scale={[viewport.width / 4, viewport.height / 4, 1]}>
|
|
220
|
+
<planeGeometry />
|
|
221
|
+
<meshBasicMaterial color="blue" />
|
|
222
|
+
</mesh>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Manual render trigger
|
|
227
|
+
function TriggerRender() {
|
|
228
|
+
const invalidate = useThree((state) => state.invalidate)
|
|
229
|
+
|
|
230
|
+
const handleClick = () => {
|
|
231
|
+
// Trigger render when using frameloop="demand"
|
|
232
|
+
invalidate()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Update camera
|
|
237
|
+
function CameraController() {
|
|
238
|
+
const camera = useThree((state) => state.camera)
|
|
239
|
+
const set = useThree((state) => state.set)
|
|
240
|
+
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
camera.position.set(10, 10, 10)
|
|
243
|
+
camera.lookAt(0, 0, 0)
|
|
244
|
+
}, [camera])
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## JSX Elements
|
|
249
|
+
|
|
250
|
+
All Three.js objects are available as JSX elements (camelCase).
|
|
251
|
+
|
|
252
|
+
### Meshes
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
// Basic mesh structure
|
|
256
|
+
<mesh
|
|
257
|
+
position={[0, 0, 0]} // x, y, z
|
|
258
|
+
rotation={[0, Math.PI, 0]} // Euler angles in radians
|
|
259
|
+
scale={[1, 2, 1]} // x, y, z or single number
|
|
260
|
+
visible={true}
|
|
261
|
+
castShadow
|
|
262
|
+
receiveShadow
|
|
263
|
+
>
|
|
264
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
265
|
+
<meshStandardMaterial color="red" />
|
|
266
|
+
</mesh>
|
|
267
|
+
|
|
268
|
+
// With ref
|
|
269
|
+
const meshRef = useRef()
|
|
270
|
+
<mesh ref={meshRef} />
|
|
271
|
+
// meshRef.current is the THREE.Mesh
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Geometry args
|
|
275
|
+
|
|
276
|
+
Constructor arguments via `args` prop:
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// BoxGeometry(width, height, depth, widthSegments, heightSegments, depthSegments)
|
|
280
|
+
<boxGeometry args={[1, 1, 1, 1, 1, 1]} />
|
|
281
|
+
|
|
282
|
+
// SphereGeometry(radius, widthSegments, heightSegments)
|
|
283
|
+
<sphereGeometry args={[1, 32, 32]} />
|
|
284
|
+
|
|
285
|
+
// PlaneGeometry(width, height, widthSegments, heightSegments)
|
|
286
|
+
<planeGeometry args={[10, 10]} />
|
|
287
|
+
|
|
288
|
+
// CylinderGeometry(radiusTop, radiusBottom, height, radialSegments)
|
|
289
|
+
<cylinderGeometry args={[1, 1, 2, 32]} />
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Groups
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
<group position={[5, 0, 0]} rotation={[0, Math.PI / 4, 0]}>
|
|
296
|
+
<mesh position={[-1, 0, 0]}>
|
|
297
|
+
<boxGeometry />
|
|
298
|
+
<meshStandardMaterial color="red" />
|
|
299
|
+
</mesh>
|
|
300
|
+
<mesh position={[1, 0, 0]}>
|
|
301
|
+
<boxGeometry />
|
|
302
|
+
<meshStandardMaterial color="blue" />
|
|
303
|
+
</mesh>
|
|
304
|
+
</group>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Nested Properties
|
|
308
|
+
|
|
309
|
+
Use dashes for nested properties:
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
<mesh
|
|
313
|
+
position-x={5}
|
|
314
|
+
rotation-y={Math.PI}
|
|
315
|
+
scale-z={2}
|
|
316
|
+
>
|
|
317
|
+
<meshStandardMaterial
|
|
318
|
+
color="red"
|
|
319
|
+
metalness={0.8}
|
|
320
|
+
roughness={0.2}
|
|
321
|
+
/>
|
|
322
|
+
</mesh>
|
|
323
|
+
|
|
324
|
+
// Shadow camera properties
|
|
325
|
+
<directionalLight
|
|
326
|
+
castShadow
|
|
327
|
+
shadow-mapSize={[2048, 2048]}
|
|
328
|
+
shadow-camera-left={-10}
|
|
329
|
+
shadow-camera-right={10}
|
|
330
|
+
shadow-camera-top={10}
|
|
331
|
+
shadow-camera-bottom={-10}
|
|
332
|
+
/>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### attach Prop
|
|
336
|
+
|
|
337
|
+
Control how children attach to parents:
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
<mesh>
|
|
341
|
+
<boxGeometry />
|
|
342
|
+
{/* Default: attaches as 'material' */}
|
|
343
|
+
<meshStandardMaterial />
|
|
344
|
+
</mesh>
|
|
345
|
+
|
|
346
|
+
{/* Explicit attach */}
|
|
347
|
+
<mesh>
|
|
348
|
+
<boxGeometry attach="geometry" />
|
|
349
|
+
<meshStandardMaterial attach="material" />
|
|
350
|
+
</mesh>
|
|
351
|
+
|
|
352
|
+
{/* Array attachment */}
|
|
353
|
+
<mesh>
|
|
354
|
+
<boxGeometry />
|
|
355
|
+
<meshStandardMaterial attach="material-0" color="red" />
|
|
356
|
+
<meshStandardMaterial attach="material-1" color="blue" />
|
|
357
|
+
</mesh>
|
|
358
|
+
|
|
359
|
+
{/* Custom attachment with function */}
|
|
360
|
+
<someObject>
|
|
361
|
+
<texture
|
|
362
|
+
attach={(parent, self) => {
|
|
363
|
+
parent.map = self
|
|
364
|
+
return () => { parent.map = null } // Cleanup
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
</someObject>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Event Handling
|
|
371
|
+
|
|
372
|
+
R3F provides React-style events on 3D objects.
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
function InteractiveBox() {
|
|
376
|
+
const [hovered, setHovered] = useState(false)
|
|
377
|
+
const [clicked, setClicked] = useState(false)
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<mesh
|
|
381
|
+
onClick={(e) => {
|
|
382
|
+
e.stopPropagation() // Prevent bubbling
|
|
383
|
+
setClicked(!clicked)
|
|
384
|
+
|
|
385
|
+
// Event properties:
|
|
386
|
+
console.log(e.object) // THREE.Mesh
|
|
387
|
+
console.log(e.point) // Vector3 - intersection point
|
|
388
|
+
console.log(e.distance) // Distance from camera
|
|
389
|
+
console.log(e.face) // Intersected face
|
|
390
|
+
console.log(e.faceIndex) // Face index
|
|
391
|
+
console.log(e.uv) // UV coordinates
|
|
392
|
+
console.log(e.normal) // Face normal
|
|
393
|
+
console.log(e.pointer) // Normalized pointer coords
|
|
394
|
+
console.log(e.ray) // Raycaster ray
|
|
395
|
+
console.log(e.camera) // Camera
|
|
396
|
+
console.log(e.delta) // Distance moved (drag events)
|
|
397
|
+
}}
|
|
398
|
+
onContextMenu={(e) => console.log('Right click')}
|
|
399
|
+
onDoubleClick={(e) => console.log('Double click')}
|
|
400
|
+
onPointerOver={(e) => {
|
|
401
|
+
e.stopPropagation()
|
|
402
|
+
setHovered(true)
|
|
403
|
+
document.body.style.cursor = 'pointer'
|
|
404
|
+
}}
|
|
405
|
+
onPointerOut={(e) => {
|
|
406
|
+
setHovered(false)
|
|
407
|
+
document.body.style.cursor = 'default'
|
|
408
|
+
}}
|
|
409
|
+
onPointerDown={(e) => console.log('Pointer down')}
|
|
410
|
+
onPointerUp={(e) => console.log('Pointer up')}
|
|
411
|
+
onPointerMove={(e) => console.log('Moving over mesh')}
|
|
412
|
+
onWheel={(e) => console.log('Wheel:', e.deltaY)}
|
|
413
|
+
scale={hovered ? 1.2 : 1}
|
|
414
|
+
>
|
|
415
|
+
<boxGeometry />
|
|
416
|
+
<meshStandardMaterial color={clicked ? 'hotpink' : 'orange'} />
|
|
417
|
+
</mesh>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Event Propagation
|
|
423
|
+
|
|
424
|
+
Events bubble up through the scene graph:
|
|
425
|
+
|
|
426
|
+
```tsx
|
|
427
|
+
<group onClick={(e) => console.log('Group clicked')}>
|
|
428
|
+
<mesh onClick={(e) => {
|
|
429
|
+
e.stopPropagation() // Stop bubbling to group
|
|
430
|
+
console.log('Mesh clicked')
|
|
431
|
+
}}>
|
|
432
|
+
<boxGeometry />
|
|
433
|
+
<meshStandardMaterial />
|
|
434
|
+
</mesh>
|
|
435
|
+
</group>
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## primitive Element
|
|
439
|
+
|
|
440
|
+
Use existing Three.js objects directly:
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
import * as THREE from 'three'
|
|
444
|
+
|
|
445
|
+
// Existing object
|
|
446
|
+
const geometry = new THREE.BoxGeometry()
|
|
447
|
+
const material = new THREE.MeshStandardMaterial({ color: 'red' })
|
|
448
|
+
const mesh = new THREE.Mesh(geometry, material)
|
|
449
|
+
|
|
450
|
+
function Scene() {
|
|
451
|
+
return <primitive object={mesh} position={[0, 1, 0]} />
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Common with loaded models
|
|
455
|
+
function Model({ gltf }) {
|
|
456
|
+
return <primitive object={gltf.scene} />
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## extend Function
|
|
461
|
+
|
|
462
|
+
Register custom Three.js classes for JSX use:
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
import { extend } from '@react-three/fiber'
|
|
466
|
+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
|
467
|
+
|
|
468
|
+
// Extend once (usually at module level)
|
|
469
|
+
extend({ OrbitControls })
|
|
470
|
+
|
|
471
|
+
// Now use as JSX
|
|
472
|
+
function Scene() {
|
|
473
|
+
const { camera, gl } = useThree()
|
|
474
|
+
return <orbitControls args={[camera, gl.domElement]} />
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// TypeScript declaration
|
|
478
|
+
declare global {
|
|
479
|
+
namespace JSX {
|
|
480
|
+
interface IntrinsicElements {
|
|
481
|
+
orbitControls: ReactThreeFiber.Object3DNode<OrbitControls, typeof OrbitControls>
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
## Refs and Imperative Access
|
|
488
|
+
|
|
489
|
+
```tsx
|
|
490
|
+
import { useRef, useEffect } from 'react'
|
|
491
|
+
import { useFrame } from '@react-three/fiber'
|
|
492
|
+
import * as THREE from 'three'
|
|
493
|
+
|
|
494
|
+
function MeshWithRef() {
|
|
495
|
+
const meshRef = useRef<THREE.Mesh>(null)
|
|
496
|
+
const materialRef = useRef<THREE.MeshStandardMaterial>(null)
|
|
497
|
+
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (meshRef.current) {
|
|
500
|
+
// Direct Three.js access
|
|
501
|
+
meshRef.current.geometry.computeBoundingBox()
|
|
502
|
+
console.log(meshRef.current.geometry.boundingBox)
|
|
503
|
+
}
|
|
504
|
+
}, [])
|
|
505
|
+
|
|
506
|
+
useFrame(() => {
|
|
507
|
+
if (materialRef.current) {
|
|
508
|
+
materialRef.current.color.setHSL(Math.random(), 1, 0.5)
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return (
|
|
513
|
+
<mesh ref={meshRef}>
|
|
514
|
+
<boxGeometry />
|
|
515
|
+
<meshStandardMaterial ref={materialRef} />
|
|
516
|
+
</mesh>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
## Performance Patterns
|
|
522
|
+
|
|
523
|
+
### Avoiding Re-renders
|
|
524
|
+
|
|
525
|
+
```tsx
|
|
526
|
+
// BAD: Creates new object every render
|
|
527
|
+
<mesh position={[x, y, z]} />
|
|
528
|
+
|
|
529
|
+
// GOOD: Mutate existing position
|
|
530
|
+
const meshRef = useRef()
|
|
531
|
+
useFrame(() => {
|
|
532
|
+
meshRef.current.position.x = x
|
|
533
|
+
})
|
|
534
|
+
<mesh ref={meshRef} />
|
|
535
|
+
|
|
536
|
+
// GOOD: Use useMemo for static values
|
|
537
|
+
const position = useMemo(() => [x, y, z], [x, y, z])
|
|
538
|
+
<mesh position={position} />
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Component Isolation
|
|
542
|
+
|
|
543
|
+
```tsx
|
|
544
|
+
// Isolate animated components to prevent parent re-renders
|
|
545
|
+
function Scene() {
|
|
546
|
+
return (
|
|
547
|
+
<>
|
|
548
|
+
<StaticEnvironment />
|
|
549
|
+
<AnimatedObject /> {/* Only this re-renders on animation */}
|
|
550
|
+
</>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function AnimatedObject() {
|
|
555
|
+
const ref = useRef()
|
|
556
|
+
useFrame((_, delta) => {
|
|
557
|
+
ref.current.rotation.y += delta
|
|
558
|
+
})
|
|
559
|
+
return <mesh ref={ref}><boxGeometry /></mesh>
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Dispose
|
|
564
|
+
|
|
565
|
+
R3F auto-disposes geometries, materials, and textures. Override with:
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
<mesh dispose={null}> {/* Prevent auto-dispose */}
|
|
569
|
+
<boxGeometry />
|
|
570
|
+
<meshStandardMaterial />
|
|
571
|
+
</mesh>
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Common Patterns
|
|
575
|
+
|
|
576
|
+
### Fullscreen Canvas
|
|
577
|
+
|
|
578
|
+
```tsx
|
|
579
|
+
// styles.css
|
|
580
|
+
html, body, #root {
|
|
581
|
+
margin: 0;
|
|
582
|
+
padding: 0;
|
|
583
|
+
width: 100%;
|
|
584
|
+
height: 100%;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// App.tsx
|
|
588
|
+
<Canvas style={{ width: '100%', height: '100%' }}>
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Responsive Canvas
|
|
592
|
+
|
|
593
|
+
```tsx
|
|
594
|
+
function ResponsiveScene() {
|
|
595
|
+
const { viewport } = useThree()
|
|
596
|
+
|
|
597
|
+
return (
|
|
598
|
+
<mesh scale={Math.min(viewport.width, viewport.height) / 5}>
|
|
599
|
+
<boxGeometry />
|
|
600
|
+
<meshStandardMaterial />
|
|
601
|
+
</mesh>
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### Forwarding Refs
|
|
607
|
+
|
|
608
|
+
```tsx
|
|
609
|
+
import { forwardRef } from 'react'
|
|
610
|
+
|
|
611
|
+
const CustomMesh = forwardRef((props, ref) => {
|
|
612
|
+
return (
|
|
613
|
+
<mesh ref={ref} {...props}>
|
|
614
|
+
<boxGeometry />
|
|
615
|
+
<meshStandardMaterial color="orange" />
|
|
616
|
+
</mesh>
|
|
617
|
+
)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
// Usage
|
|
621
|
+
const meshRef = useRef()
|
|
622
|
+
<CustomMesh ref={meshRef} position={[0, 1, 0]} />
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
## Debugging with Leva
|
|
626
|
+
|
|
627
|
+
Leva provides a GUI for tweaking parameters in real-time during development.
|
|
628
|
+
|
|
629
|
+
### Installation
|
|
630
|
+
|
|
631
|
+
```bash
|
|
632
|
+
npm install leva
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### Basic Controls
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
import { useControls } from 'leva'
|
|
639
|
+
|
|
640
|
+
function DebugMesh() {
|
|
641
|
+
const { position, color, scale, visible } = useControls({
|
|
642
|
+
position: { value: [0, 0, 0], step: 0.1 },
|
|
643
|
+
color: '#ff0000',
|
|
644
|
+
scale: { value: 1, min: 0.1, max: 5, step: 0.1 },
|
|
645
|
+
visible: true,
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
return (
|
|
649
|
+
<mesh position={position} scale={scale} visible={visible}>
|
|
650
|
+
<boxGeometry />
|
|
651
|
+
<meshStandardMaterial color={color} />
|
|
652
|
+
</mesh>
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### Organized Folders
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
import { useControls, folder } from 'leva'
|
|
661
|
+
|
|
662
|
+
function DebugScene() {
|
|
663
|
+
const { lightIntensity, lightColor, shadowMapSize } = useControls({
|
|
664
|
+
Lighting: folder({
|
|
665
|
+
lightIntensity: { value: 1, min: 0, max: 5 },
|
|
666
|
+
lightColor: '#ffffff',
|
|
667
|
+
shadowMapSize: { value: 1024, options: [512, 1024, 2048, 4096] },
|
|
668
|
+
}),
|
|
669
|
+
Camera: folder({
|
|
670
|
+
fov: { value: 75, min: 30, max: 120 },
|
|
671
|
+
near: { value: 0.1, min: 0.01, max: 1 },
|
|
672
|
+
}),
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
return (
|
|
676
|
+
<directionalLight
|
|
677
|
+
intensity={lightIntensity}
|
|
678
|
+
color={lightColor}
|
|
679
|
+
shadow-mapSize={[shadowMapSize, shadowMapSize]}
|
|
680
|
+
/>
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Button Actions
|
|
686
|
+
|
|
687
|
+
```tsx
|
|
688
|
+
import { useControls, button } from 'leva'
|
|
689
|
+
|
|
690
|
+
function DebugActions() {
|
|
691
|
+
const meshRef = useRef()
|
|
692
|
+
|
|
693
|
+
useControls({
|
|
694
|
+
'Reset Position': button(() => {
|
|
695
|
+
meshRef.current.position.set(0, 0, 0)
|
|
696
|
+
}),
|
|
697
|
+
'Random Color': button(() => {
|
|
698
|
+
meshRef.current.material.color.setHex(Math.random() * 0xffffff)
|
|
699
|
+
}),
|
|
700
|
+
'Log State': button(() => {
|
|
701
|
+
console.log(meshRef.current.position)
|
|
702
|
+
}),
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
return <mesh ref={meshRef}>...</mesh>
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Hide in Production
|
|
710
|
+
|
|
711
|
+
```tsx
|
|
712
|
+
import { Leva } from 'leva'
|
|
713
|
+
|
|
714
|
+
function App() {
|
|
715
|
+
return (
|
|
716
|
+
<>
|
|
717
|
+
{/* Hide Leva panel in production */}
|
|
718
|
+
<Leva hidden={process.env.NODE_ENV === 'production'} />
|
|
719
|
+
|
|
720
|
+
<Canvas>
|
|
721
|
+
<Scene />
|
|
722
|
+
</Canvas>
|
|
723
|
+
</>
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
### Monitor Values (Read-Only)
|
|
729
|
+
|
|
730
|
+
```tsx
|
|
731
|
+
import { useControls, monitor } from 'leva'
|
|
732
|
+
import { useFrame } from '@react-three/fiber'
|
|
733
|
+
|
|
734
|
+
function PerformanceMonitor() {
|
|
735
|
+
const [fps, setFps] = useState(0)
|
|
736
|
+
|
|
737
|
+
useControls({
|
|
738
|
+
FPS: monitor(() => fps, { graph: true, interval: 100 }),
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
useFrame((state) => {
|
|
742
|
+
// Update FPS display
|
|
743
|
+
setFps(Math.round(1 / state.clock.getDelta()))
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
return null
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Integration with useFrame
|
|
751
|
+
|
|
752
|
+
```tsx
|
|
753
|
+
function AnimatedDebugMesh() {
|
|
754
|
+
const meshRef = useRef()
|
|
755
|
+
|
|
756
|
+
const { speed, amplitude, enabled } = useControls('Animation', {
|
|
757
|
+
enabled: true,
|
|
758
|
+
speed: { value: 1, min: 0, max: 5 },
|
|
759
|
+
amplitude: { value: 1, min: 0, max: 3 },
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
useFrame(({ clock }) => {
|
|
763
|
+
if (!enabled) return
|
|
764
|
+
meshRef.current.position.y = Math.sin(clock.elapsedTime * speed) * amplitude
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
return (
|
|
768
|
+
<mesh ref={meshRef}>
|
|
769
|
+
<sphereGeometry />
|
|
770
|
+
<meshStandardMaterial color="cyan" />
|
|
771
|
+
</mesh>
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
## 妙搭沙箱适配铁律 (React 19 + 严格模式)
|
|
777
|
+
|
|
778
|
+
妙搭沙箱跑 React 19 + React Compiler 严格 lint。下列写法 commit 时会报错,且**运行时部分组件会被 React 跳过编译/重复挂载抛错 → Canvas 内部黑屏**。失败 case `app_4k7v39352vh9q` 12 次循环改 lint 没修对就是踩了这些坑。
|
|
779
|
+
|
|
780
|
+
### 1. `Math.random()` / `Date.now()` 严禁 render 期间直接调用
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
// ❌ render 期间生成位置, React 19 报 "impure function during render"
|
|
784
|
+
{enemies.map((_, i) => <group position={[Math.random() * 10, 0, 0]}>...</group>)}
|
|
785
|
+
|
|
786
|
+
// ✅ useMemo 一次性算好, deps=[]
|
|
787
|
+
const enemyPositions = useMemo(
|
|
788
|
+
() => Array.from({ length: 8 }, () => [Math.random() * 10, 0, Math.random() * 10]),
|
|
789
|
+
[]
|
|
790
|
+
)
|
|
791
|
+
{enemyPositions.map((pos, i) => <group key={i} position={pos}>...</group>)}
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### 2. 动画 / 位置更新优先用 drei 高阶组件, 严禁 render 里读写 ref.current
|
|
795
|
+
|
|
796
|
+
```tsx
|
|
797
|
+
// ❌ render 期间访问 ref.current — React 19 报 "Cannot access refs during render"
|
|
798
|
+
<mesh rotation={[ref.current?.rotation.y + 0.01 || 0, 0, 0]} />
|
|
799
|
+
|
|
800
|
+
// ❌ 自己写 useFrame 逐帧推位置 — 容易踩坑且代码冗长
|
|
801
|
+
function Earth() {
|
|
802
|
+
const ref = useRef<THREE.Mesh>(null)
|
|
803
|
+
useFrame(() => { if (ref.current) ref.current.rotation.y += 0.005 })
|
|
804
|
+
return <mesh ref={ref}>...</mesh>
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ✅ 优先用 drei 高阶组件让库帮你驱动动画
|
|
808
|
+
<OrbitControls autoRotate autoRotateSpeed={0.4} /> // 整个场景自动旋转
|
|
809
|
+
<Float speed={1.5} rotationIntensity={1} floatIntensity={2}> // 自动悬浮
|
|
810
|
+
<mesh>...</mesh>
|
|
811
|
+
</Float>
|
|
812
|
+
|
|
813
|
+
// ✅ 必须自己写 useFrame 时, ref 操作放 useFrame 内, 不进 render JSX
|
|
814
|
+
function Earth() {
|
|
815
|
+
const ref = useRef<THREE.Mesh>(null)
|
|
816
|
+
useFrame(() => { if (ref.current) ref.current.rotation.y += 0.005 })
|
|
817
|
+
return <mesh ref={ref}><sphereGeometry /><meshStandardMaterial /></mesh>
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
### 3. useEffect 里 setState 加 if 条件 / 用 setter 函数, 防级联渲染
|
|
822
|
+
|
|
823
|
+
```tsx
|
|
824
|
+
// ❌ React 19 报 "Calling setState synchronously within an effect can trigger cascading renders"
|
|
825
|
+
useEffect(() => { setHealth(100) }, [gameState])
|
|
826
|
+
|
|
827
|
+
// ✅ 加条件或 setter 函数
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
if (gameState === 'playing') {
|
|
830
|
+
setHealth((h) => (h > 0 ? h : 100))
|
|
831
|
+
}
|
|
832
|
+
}, [gameState])
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
### 4. Canvas 外必须包 ErrorBoundary, Suspense 不吞 error
|
|
836
|
+
|
|
837
|
+
`<Canvas>` 内部 mesh / geometry / material 抛 runtime error 时, **Suspense fallback 只吞 Promise (异步加载), 不吞 Error**. 没 ErrorBoundary → 整个 Canvas 黑屏, 控制台报错被吞, 用户看到只剩 HUD.
|
|
838
|
+
|
|
839
|
+
```tsx
|
|
840
|
+
// ❌ 只有 Suspense, Canvas 内部错误 → 整片黑
|
|
841
|
+
<Canvas>
|
|
842
|
+
<Suspense fallback={null}>
|
|
843
|
+
<Scene />
|
|
844
|
+
</Suspense>
|
|
845
|
+
</Canvas>
|
|
846
|
+
|
|
847
|
+
// ✅ ErrorBoundary 包 Canvas, fallback 显示降级 UI 而不是黑屏
|
|
848
|
+
import { ErrorBoundary } from 'react-error-boundary'
|
|
849
|
+
|
|
850
|
+
<ErrorBoundary fallback={<div className="p-8 text-center">3D 场景加载失败, 请刷新重试</div>}>
|
|
851
|
+
<Canvas camera={{ position: [0, 0, 5] }}>
|
|
852
|
+
<Suspense fallback={null}>
|
|
853
|
+
<Scene />
|
|
854
|
+
</Suspense>
|
|
855
|
+
</Canvas>
|
|
856
|
+
</ErrorBoundary>
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
### 5. 黑屏必查清单 (调试 3D 场景空白时按顺序排查)
|
|
860
|
+
|
|
861
|
+
| 项 | 要求 |
|
|
862
|
+
|----|------|
|
|
863
|
+
| Canvas 父容器有显式高度 | `h-screen` / `h-[800px]`, **不要** `h-full` 链断 |
|
|
864
|
+
| 至少 1 个 `<ambientLight intensity≥0.3>` | 没灯 = 全黑 |
|
|
865
|
+
| `<directionalLight position={[5,5,5]} intensity≥0.5>` | 主光源 |
|
|
866
|
+
| Camera position 跟 mesh 有距离 (5-20 单位) | `<Canvas camera={{ position: [0, 0, 5] }}>` |
|
|
867
|
+
| 至少 1 个 mesh 在 `[0, 0, 0]` 附近 | 调试时放在原点确认 |
|
|
868
|
+
| 浏览器 Console 看有无红字 Error | 看 R3F 抛了什么(没 ErrorBoundary 时只有 Console 看得到) |
|
|
869
|
+
|
|
870
|
+
## See Also
|
|
871
|
+
|
|
872
|
+
- `r3f-geometry` - Geometry creation
|
|
873
|
+
- `r3f-materials` - Material configuration
|
|
874
|
+
- `r3f-lighting` - Lights and shadows
|
|
875
|
+
- `r3f-interaction` - Controls and user input
|
|
876
|
+
- `r3f-animation` - 动画(useFrame / drei autoRotate / Float)
|
|
877
|
+
- `r3f-loaders` - GLTF 加载 + Suspense 模式
|