update: add agent.md + skills
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
# Agent — La Fabrik
|
||||||
|
|
||||||
|
You are working on **La Fabrik**, an interactive 3D web experience built with React Three Fiber. The player steps into the role of a technician in Altera (2050) and completes missions: repairing an e-bike, fixing a power grid, upgrading a vertical farm.
|
||||||
|
|
||||||
|
## Project Identity
|
||||||
|
|
||||||
|
- **Stack:** React 19, Three.js, @react-three/fiber 9, @react-three/drei, @react-three/rapier, GSAP, TypeScript, Vite
|
||||||
|
- **No external state lib.** State is managed by a custom `GameManager` singleton with a subscribe/getState pattern.
|
||||||
|
- **No Zustand, no Redux, no Context for global state.**
|
||||||
|
- **Versions are pinned** (no `^` in dependencies). Do not upgrade packages without explicit request.
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
### Two patterns coexist
|
||||||
|
|
||||||
|
1. **Singleton manager classes** — for orchestration, audio, cinematics, zone detection, debug
|
||||||
|
2. **Declarative React components** — for all 3D scene objects (map, zones, lights, player, postprocessing)
|
||||||
|
|
||||||
|
Scene objects are **never** singleton classes. Managers are **never** React components.
|
||||||
|
|
||||||
|
### State ownership
|
||||||
|
|
||||||
|
- `GameManager` is the single source of truth for durable gameplay state (phase, zone, mission, input lock, dialogue)
|
||||||
|
- Other managers (`CinematicManager`, `AudioManager`, `ZoneManager`) handle side effects only — they read from GameManager but do not duplicate its state
|
||||||
|
- React components subscribe to GameManager through `useGameState()` hook
|
||||||
|
- **High-frequency values** (movement, camera interpolation, physics) stay in `useRef` + `useFrame` — never in React state
|
||||||
|
|
||||||
|
### File conventions
|
||||||
|
|
||||||
|
- Every file starts with a comment: `# route path <relative_path>` (e.g. `# route path src/world/Map.tsx`)
|
||||||
|
- Scene components live in `src/world/` and `src/components/3d/`
|
||||||
|
- UI overlays live in `src/components/ui/`
|
||||||
|
- Managers live in `src/stateManager/`
|
||||||
|
- Hooks live in `src/hooks/`
|
||||||
|
- Static data lives in `src/data/`
|
||||||
|
- Shaders live in `src/shaders/`
|
||||||
|
- Utilities live in `src/utils/`
|
||||||
|
|
||||||
|
### Import paths
|
||||||
|
|
||||||
|
Use `@/` alias for imports from `src/`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GameManager } from "@/stateManager/GameManager";
|
||||||
|
import { useGameState } from "@/hooks/useGameState";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory management
|
||||||
|
|
||||||
|
- Dispose only what you own (custom materials, render targets, manual clones)
|
||||||
|
- Never blindly deep-dispose shared/cached assets (drei loaders cache models)
|
||||||
|
- Use `Dispose.material()`, `Dispose.mesh()`, `Dispose.renderTarget()` from `src/utils/Dispose.ts`
|
||||||
|
|
||||||
|
### Debug
|
||||||
|
|
||||||
|
- Debug panel activates with `?debug` in URL
|
||||||
|
- All debug logic goes through `Debug.getInstance()` from `src/utils/Debug.ts`
|
||||||
|
- Never scatter `if (isDev)` blocks across files
|
||||||
|
- `r3f-perf` is lazy-loaded only in debug mode via `src/components/3d/DebugPerf.tsx`
|
||||||
|
|
||||||
|
## Managers (4 max)
|
||||||
|
|
||||||
|
| Manager | Responsibility |
|
||||||
|
| ------------------ | ------------------------------------------------------------------- |
|
||||||
|
| `GameManager` | Phase, zone, mission, input lock, dialogue — single source of truth |
|
||||||
|
| `CinematicManager` | GSAP timelines, camera lock/unlock |
|
||||||
|
| `AudioManager` | Music, SFX, spatial audio |
|
||||||
|
| `ZoneManager` | Zone detection, LOD triggers |
|
||||||
|
|
||||||
|
## Do NOT
|
||||||
|
|
||||||
|
- Create new manager classes without explicit request
|
||||||
|
- Use Zustand, Redux, or React Context for global state
|
||||||
|
- Put high-frequency values in React state (`useState`)
|
||||||
|
- Import `CinematicManager`/`AudioManager`/`ZoneManager` directly from components — always go through `GameManager`
|
||||||
|
- Upgrade pinned dependency versions
|
||||||
|
- Create files outside the documented architecture without explicit request
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
See `.agent/skills/` for detailed patterns per technology:
|
||||||
|
|
||||||
|
- `r3f.md` — React Three Fiber component patterns
|
||||||
|
- `three.md` — Three.js conventions and AnimationMixer
|
||||||
|
- `gsap.md` — GSAP timeline and cinematic patterns
|
||||||
|
- `managers.md` — Singleton manager implementation
|
||||||
|
- `memory.md` — GPU memory and disposal rules
|
||||||
|
- `debug.md` — Debug utility and r3f-perf setup
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Skill — Debug
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
Append `?debug` to the URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5173?debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debug singleton
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/Debug.ts
|
||||||
|
import GUI from "lil-gui";
|
||||||
|
|
||||||
|
export class Debug {
|
||||||
|
private static _instance: Debug | null = null;
|
||||||
|
|
||||||
|
readonly active: boolean;
|
||||||
|
gui: GUI | null = null;
|
||||||
|
|
||||||
|
static getInstance(): Debug {
|
||||||
|
if (!Debug._instance) Debug._instance = new Debug();
|
||||||
|
return Debug._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.active = new URLSearchParams(window.location.search).has("debug");
|
||||||
|
if (this.active) {
|
||||||
|
this.gui = new GUI({ title: "La-Fabrik Debug" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.gui?.destroy();
|
||||||
|
Debug._instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding debug controls
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const debug = Debug.getInstance();
|
||||||
|
|
||||||
|
if (debug.active) {
|
||||||
|
const folder = debug.gui!.addFolder("Lighting");
|
||||||
|
folder.add(params, "intensity", 0, 5).name("Sun intensity");
|
||||||
|
folder.addColor(params, "color").name("Sun color");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## r3f-perf (lazy loaded)
|
||||||
|
|
||||||
|
r3f-perf is loaded only in debug mode to avoid dependency issues in production:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/3d/DebugPerf.tsx
|
||||||
|
import { Suspense, lazy } from "react";
|
||||||
|
|
||||||
|
const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf })));
|
||||||
|
|
||||||
|
export function DebugPerf() {
|
||||||
|
const debug = new URLSearchParams(window.location.search).has("debug");
|
||||||
|
if (!debug) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Perf position="top-left" />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage in Canvas:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Canvas>
|
||||||
|
{/* scene content */}
|
||||||
|
<DebugPerf />
|
||||||
|
</Canvas>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- All debug UI goes through `Debug.getInstance()` — never inline `if (isDev)` checks
|
||||||
|
- r3f-perf is always lazy-imported, never a hard dependency in scene components
|
||||||
|
- Debug folders should be organized by domain (Lighting, PostFX, Player, Zone)
|
||||||
|
- Debug panel must not affect production builds — it simply doesn't mount when `?debug` is absent
|
||||||
|
- Clean up debug folders in `destroy()` when relevant
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Skill — GSAP
|
||||||
|
|
||||||
|
GSAP is used exclusively for **cinematic sequences** and **UI transitions**. It is never used for per-frame 3D animation (that's `useFrame` + AnimationMixer).
|
||||||
|
|
||||||
|
## Cinematic timeline pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import gsap from "gsap";
|
||||||
|
|
||||||
|
export class CinematicManager {
|
||||||
|
private static _instance: CinematicManager | null = null;
|
||||||
|
private timeline: gsap.core.Timeline | null = null;
|
||||||
|
|
||||||
|
static getInstance(): CinematicManager {
|
||||||
|
if (!CinematicManager._instance) {
|
||||||
|
CinematicManager._instance = new CinematicManager();
|
||||||
|
}
|
||||||
|
return CinematicManager._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
play(id: string, camera: THREE.Camera): void {
|
||||||
|
this.timeline?.kill();
|
||||||
|
|
||||||
|
this.timeline = gsap.timeline({
|
||||||
|
onStart: () => {
|
||||||
|
GameManager.getInstance().setPhase("cinematic");
|
||||||
|
GameManager.getInstance().lockInput(true);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
GameManager.getInstance().setPhase("exploring");
|
||||||
|
GameManager.getInstance().lockInput(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example: camera pan to workshop
|
||||||
|
this.timeline
|
||||||
|
.to(camera.position, {
|
||||||
|
x: 5,
|
||||||
|
y: 3,
|
||||||
|
z: 10,
|
||||||
|
duration: 2,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
})
|
||||||
|
.to(
|
||||||
|
camera.rotation,
|
||||||
|
{ y: Math.PI / 4, duration: 1.5, ease: "power2.out" },
|
||||||
|
"-=1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.timeline?.kill();
|
||||||
|
this.timeline = null;
|
||||||
|
CinematicManager._instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI animation pattern
|
||||||
|
|
||||||
|
For HTML overlays (cinematic bars, dialogue fade-in):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import gsap from "gsap";
|
||||||
|
|
||||||
|
export function CinematicBars({ visible }: { visible: boolean }) {
|
||||||
|
const topRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
gsap.to(topRef.current, { y: 0, duration: 0.6, ease: "power2.out" });
|
||||||
|
gsap.to(bottomRef.current, { y: 0, duration: 0.6, ease: "power2.out" });
|
||||||
|
} else {
|
||||||
|
gsap.to(topRef.current, { y: -60, duration: 0.4, ease: "power2.in" });
|
||||||
|
gsap.to(bottomRef.current, { y: 60, duration: 0.4, ease: "power2.in" });
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={topRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
/* black bar top */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={bottomRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
/* black bar bottom */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Always `.kill()` previous timeline before creating a new one
|
||||||
|
- Lock input via `GameManager.lockInput(true)` during cinematics
|
||||||
|
- Set phase to `'cinematic'` at start, restore to `'exploring'` at end
|
||||||
|
- Use `ease: 'power2.inOut'` for camera moves, `'power2.out'` for UI reveals
|
||||||
|
- Never use GSAP to animate values that R3F's `useFrame` already handles (positions updated every frame)
|
||||||
|
- Timelines are owned by `CinematicManager` — components trigger them, they don't create them
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Skill — Singleton Managers
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
Every manager follows the exact same singleton structure:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class SomeManager {
|
||||||
|
private static _instance: SomeManager | null = null;
|
||||||
|
|
||||||
|
static getInstance(): SomeManager {
|
||||||
|
if (!SomeManager._instance) {
|
||||||
|
SomeManager._instance = new SomeManager();
|
||||||
|
}
|
||||||
|
return SomeManager._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// init logic
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
// cleanup logic
|
||||||
|
SomeManager._instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managers in this project
|
||||||
|
|
||||||
|
| Manager | File | Role |
|
||||||
|
| ------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GameManager` | `src/stateManager/GameManager.ts` | Single source of truth. Owns phase, zone, mission, input lock, dialogue. Has `subscribe()` + `getState()`. |
|
||||||
|
| `CinematicManager` | `src/stateManager/CinematicManager.ts` | GSAP timelines. Locks/unlocks input via GameManager. |
|
||||||
|
| `AudioManager` | `src/stateManager/AudioManager.ts` | Music, SFX, spatial audio. Reads phase from GameManager. |
|
||||||
|
| `ZoneManager` | `src/stateManager/ZoneManager.ts` | Zone entry/exit detection, LOD triggers. Notifies GameManager of zone changes. |
|
||||||
|
|
||||||
|
## GameManager is the orchestrator
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class GameManager {
|
||||||
|
cinematic!: CinematicManager;
|
||||||
|
audio!: AudioManager;
|
||||||
|
zone!: ZoneManager;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.cinematic = CinematicManager.getInstance();
|
||||||
|
this.audio = AudioManager.getInstance();
|
||||||
|
this.zone = ZoneManager.getInstance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Components and hooks access other managers **through GameManager only**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Correct
|
||||||
|
GameManager.getInstance().cinematic.play("intro");
|
||||||
|
|
||||||
|
// Wrong — never import sub-managers directly in components
|
||||||
|
CinematicManager.getInstance().play("intro");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscribe pattern (GameManager only)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => this.listeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
this.listeners.forEach((cb) => cb())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `set*()` method calls `this.emit()` to notify subscribers.
|
||||||
|
|
||||||
|
## React bridge hook
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// hooks/useGameState.ts
|
||||||
|
export function useGameState() {
|
||||||
|
const game = GameManager.getInstance();
|
||||||
|
const [state, setState] = useState(game.getState());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return game.subscribe(() => setState({ ...game.getState() }));
|
||||||
|
}, [game]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Max 4 managers total
|
||||||
|
- Only `GameManager` holds durable state with `subscribe()`
|
||||||
|
- Other managers are side-effect handlers — they do not store persistent state
|
||||||
|
- Always call `destroy()` on cleanup (App unmount)
|
||||||
|
- Never create manager instances with `new` — always use `.getInstance()`
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Skill — GPU Memory Management
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Dispose only what you own. Never blindly traverse and dispose shared or cached assets.
|
||||||
|
|
||||||
|
## What to dispose
|
||||||
|
|
||||||
|
| Resource | When to dispose |
|
||||||
|
| ---------------------------------- | ------------------------------------ |
|
||||||
|
| Custom `THREE.ShaderMaterial` | When the component using it unmounts |
|
||||||
|
| `THREE.WebGLRenderTarget` | When the pass or effect is destroyed |
|
||||||
|
| Manually created `THREE.Geometry` | When no longer needed |
|
||||||
|
| Manually created `THREE.Texture` | When no longer needed |
|
||||||
|
| Cloned scenes with owned materials | When the clone is removed |
|
||||||
|
|
||||||
|
## What NOT to dispose
|
||||||
|
|
||||||
|
| Resource | Why |
|
||||||
|
| -------------------------------- | ----------------------------------------------- |
|
||||||
|
| GLTF scenes loaded via `useGLTF` | drei caches them — disposing breaks other users |
|
||||||
|
| Textures loaded via `useTexture` | drei caches them — same reason |
|
||||||
|
| Shared materials from a GLTF | Other instances may reference them |
|
||||||
|
|
||||||
|
## Dispose utility
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/Dispose.ts
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
export class Dispose {
|
||||||
|
static material(material: THREE.Material): void {
|
||||||
|
for (const value of Object.values(material)) {
|
||||||
|
if (value instanceof THREE.Texture) {
|
||||||
|
value.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static mesh(mesh: THREE.Mesh): void {
|
||||||
|
mesh.geometry?.dispose();
|
||||||
|
const materials = Array.isArray(mesh.material)
|
||||||
|
? mesh.material
|
||||||
|
: [mesh.material];
|
||||||
|
for (const mat of materials) {
|
||||||
|
if (mat) this.material(mat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static renderTarget(rt: THREE.WebGLRenderTarget): void {
|
||||||
|
rt.texture.dispose();
|
||||||
|
rt.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in React components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader });
|
||||||
|
meshRef.current.material = material;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Dispose.material(material);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in managers
|
||||||
|
|
||||||
|
```ts
|
||||||
|
destroy(): void {
|
||||||
|
if (this.renderTarget) {
|
||||||
|
Dispose.renderTarget(this.renderTarget)
|
||||||
|
this.renderTarget = null
|
||||||
|
}
|
||||||
|
SomeManager._instance = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Every `useEffect` that creates a GPU resource must return a cleanup that disposes it
|
||||||
|
- Every manager `destroy()` must dispose its owned GPU resources
|
||||||
|
- Never call `.dispose()` on assets returned by drei loaders (`useGLTF`, `useTexture`)
|
||||||
|
- When in doubt, don't dispose — a small leak is better than a crash from disposing shared resources
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Skill — React Three Fiber
|
||||||
|
|
||||||
|
## Component pattern
|
||||||
|
|
||||||
|
Every 3D scene object is a React component. No class-based scene objects.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
|
||||||
|
export function MyObject() {
|
||||||
|
const ref = useRef<THREE.Group>(null);
|
||||||
|
const gltf = useGLTF("/models/my-object.glb");
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
// per-frame logic here
|
||||||
|
});
|
||||||
|
|
||||||
|
return <primitive ref={ref} object={gltf.scene.clone()} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Scene components return JSX with Three.js elements (`<mesh>`, `<group>`, `<primitive>`)
|
||||||
|
- Use `useRef` for mutable per-frame values — never `useState`
|
||||||
|
- Use `useFrame` for animation loops — never `requestAnimationFrame`
|
||||||
|
- Use `useGLTF` / `useTexture` from drei for asset loading — they handle caching
|
||||||
|
- Clone scenes with `.clone()` when reusing a GLTF in multiple places
|
||||||
|
- Cleanup in `useEffect` return — stop AnimationMixers, dispose owned resources
|
||||||
|
|
||||||
|
## Loading assets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Models
|
||||||
|
const gltf = useGLTF("/models/workshop/ebike.glb");
|
||||||
|
|
||||||
|
// Textures
|
||||||
|
const [diffuse, normal] = useTexture([
|
||||||
|
"/textures/wall_diffuse.jpg",
|
||||||
|
"/textures/wall_normal.jpg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Preload (call outside component)
|
||||||
|
useGLTF.preload("/models/map/base.glb");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Physics (Rapier)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||||
|
|
||||||
|
<RigidBody type="fixed">
|
||||||
|
<CuboidCollider args={[10, 0.1, 10]} />
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[20, 0.2, 20]} />
|
||||||
|
<meshStandardMaterial />
|
||||||
|
</mesh>
|
||||||
|
</RigidBody>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Wrap physics scene in `<Physics>` component
|
||||||
|
- `type="fixed"` for static colliders (ground, walls)
|
||||||
|
- `type="dynamic"` for movable objects
|
||||||
|
- Player uses `type="dynamic"` with `lockRotations`
|
||||||
|
|
||||||
|
## Postprocessing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||||
|
|
||||||
|
<EffectComposer>
|
||||||
|
<Bloom intensity={0.5} luminanceThreshold={0.9} />
|
||||||
|
<Vignette offset={0.3} darkness={0.5} />
|
||||||
|
</EffectComposer>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always wrap in `<EffectComposer>`
|
||||||
|
- Keep effects minimal for performance
|
||||||
|
- Disable heavy effects on low-end devices via Debug panel
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- Do not use `new THREE.Scene()` or `new THREE.WebGLRenderer()` — R3F handles this
|
||||||
|
- Do not use `requestAnimationFrame` — use `useFrame`
|
||||||
|
- Do not store per-frame values in `useState` — use `useRef`
|
||||||
|
- Do not manually append to DOM — everything goes through `<Canvas>`
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Skill — Three.js
|
||||||
|
|
||||||
|
## AnimationMixer
|
||||||
|
|
||||||
|
The standard way to play GLTF animations in this project:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF, useAnimations } from "@react-three/drei";
|
||||||
|
|
||||||
|
export function AnimatedObject() {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const { scene, animations } = useGLTF("/models/object.glb");
|
||||||
|
const { actions } = useAnimations(animations, group);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
actions["Idle"]?.play();
|
||||||
|
return () => {
|
||||||
|
actions["Idle"]?.stop();
|
||||||
|
};
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
return <primitive ref={group} object={scene.clone()} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual mixer (when drei's useAnimations is not enough)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mixer = useRef<THREE.AnimationMixer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mixer.current = new THREE.AnimationMixer(gltf.scene);
|
||||||
|
const clip = gltf.animations.find((a) => a.name === "Walk");
|
||||||
|
if (clip) mixer.current.clipAction(clip).play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mixer.current?.stopAllAction();
|
||||||
|
mixer.current = null;
|
||||||
|
};
|
||||||
|
}, [gltf]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
mixer.current?.update(delta);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Materials
|
||||||
|
|
||||||
|
- Prefer `meshStandardMaterial` for PBR
|
||||||
|
- Use `meshBasicMaterial` for unlit UI elements or hologram base
|
||||||
|
- Custom shaders go in `src/shaders/` as `.glsl` files
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#4a90d9"
|
||||||
|
roughness={0.4}
|
||||||
|
metalness={0.6}
|
||||||
|
map={diffuseTexture}
|
||||||
|
normalMap={normalTexture}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raycasting
|
||||||
|
|
||||||
|
In R3F, raycasting is built into the event system:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<mesh
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// handle click on this mesh
|
||||||
|
}}
|
||||||
|
onPointerOver={() => setHovered(true)}
|
||||||
|
onPointerOut={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
For custom raycasting, use `useThree` to access the raycaster:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { raycaster, camera, scene } = useThree();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never instantiate `THREE.WebGLRenderer` or `THREE.Scene` — R3F owns these
|
||||||
|
- Use `useThree()` to access renderer, camera, scene, gl, size
|
||||||
|
- Texture format: prefer `.jpg` for diffuse, `.png` for alpha, `.hdr`/`.exr` for HDRI
|
||||||
|
- Model format: always `.glb` (binary GLTF) — smaller and faster than `.gltf`
|
||||||
|
- Keep triangle count reasonable per zone: aim for < 100k tris visible at once
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy } from "react";
|
||||||
const Perf = lazy(() => import('r3f-perf').then((m) => ({ default: m.Perf })))
|
const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf })));
|
||||||
export function DebugPerf() {
|
export function DebugPerf() {
|
||||||
const debug = new URLSearchParams(window.location.search).has('debug')
|
const debug = new URLSearchParams(window.location.search).has("debug");
|
||||||
if (!debug) return null
|
if (!debug) return null;
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Perf position="top-left" />
|
<Perf position="top-left" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user