关于 threejs-scene-builder
threejs-scene-builder 是使用 Three.js 和 React Three Fiber 构建 3D 网页场景的开发技能,涵盖场景搭建、渲染、动画、角色、程序化生成、NPC 行为和物理示例。
主要功能
- 01Three.js 场景搭建与渲染
- 02React Three Fiber 集成
- 03角色动画与重定向
- 04程序化地形与模型生成
- 05NPC 行为和物理示例
适用场景
- 01创建交互式 3D 网页场景
- 02添加动画角色或虚拟形象
- 03构建程序化环境并制作 NPC 原型
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ jarrodmedrano/ jarrod-claude-skills --skill threejs-scene-builder安装后在 Claude Code 中输入/threejs-scene-builder确认已加载。
附带文件scripts/build-scene.ts
详细介绍
Three.js 与 React Three Fiber — 完整的 3D 开发技能
使用 Three.js 和 React Three Fiber(R3F)创建高级 3D Web 体验的综合指南,涵盖场景设置、角色系统、NPC、程序化生成、动画重定向、物理效果和交互式游戏玩法。
适用场景
- “创建 Three.js 场景” / “设置 3D 场景”
- “设置 React Three Fiber” / “创建 R3F 应用”
- “生成 WebGL 可视化效果” / “创建交互式 3D 图形”
- “添加 3D 角色” / “设置虚拟形象系统”
- “创建 NPC AI” / “添加游戏角色”
- “程序化 3D 生成” / “生成 3D 内容”
- “重定向动画” / “将 Mixamo 用于 Three.js”
- “设置角色控制器” / “添加物理效果”
- “集成 Ready Player Me”
- “使用 Three.js 创建 3D 游戏”
说明
1. 安装 Three.js
npm install three
# TypeScript types
npm install --save-dev @types/three
2. 基本场景设置
TypeScript:
// scene.ts
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
export class SceneManager {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: OrbitControls;
private animationId: number | null = null;
constructor(container: HTMLElement) {
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1a1a1a);
this.scene.fog = new THREE.Fog(0x1a1a1a, 10, 50);
// Camera
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(5, 5, 5);
this.camera.lookAt(0, 0, 0);
// Renderer
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(this.renderer.domElement);
// Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.minDistance = 2;
this.controls.maxDistance = 20;
// Lights
this.setupLights();
// Handle resize
window.addEventListener('resize', () => this.onWindowResize());
// Start animation
this.animate();
}
private setupLights() {
// Ambient light
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambient);
// Directional light (sun)
const directional = new THREE.DirectionalLight(0xffffff, 0.8);
directional.position.set(5, 10, 5);
directional.castShadow = true;
directional.shadow.camera.left = -10;
directional.shadow.camera.right = 10;
directional.shadow.camera.top = 10;
directional.shadow.camera.bottom = -10;
directional.shadow.mapSize.width = 2048;
directional.shadow.mapSize.height = 2048;
this.scene.add(directional);
// Hemisphere light
const hemisphere = new THREE.HemisphereLight(0xffffbb, 0x080820, 0.3);
this.scene.add(hemisphere);
}
private onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
private animate() {
this.animationId = requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
public addObject(object: THREE.Object3D) {
this.scene.add(object);
}
public dispose() {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
}
this.renderer.dispose();
this.controls.dispose();
}
}
// Usage
const container = document.getElementById('canvas-container')!;
const sceneManager = new SceneManager(container);
// Add a cube
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
sceneManager.addObject(cube);
3. 高级材质设置
// materials.ts
import * as THREE from 'three';
export class MaterialLibrary {
// PBR Material
static createPBRMaterial(options: {
color?: number;
roughness?: number;
metalness?: number;
normalMap?: THREE.Texture;
roughnessMap?: THREE.Texture;
}) {
return new THREE.MeshStandardMaterial({
color: options.color ?? 0xffffff,
roughness: options.roughness ?? 0.5,
metalness: options.metalness ?? 0.5,
normalMap: options.normalMap,
roughnessMap: options.roughnessMap,
});
}
// Glass Material
static createGlassMaterial() {
return new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0,
roughness: 0,
transmission: 1,
thickness: 0.5,
});
}
// Glowing Material
static createGlowMaterial(color: number = 0x00ff00) {
return new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
});
}
// Toon Material
static createToonMaterial(color: number = 0x00ff00) {
return new THREE.MeshToonMaterial({
color,
gradientMap: this.createGradientTexture(),
});
}
private static createGradientTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const gradient = ctx.createLinearGradient(0, 0, 256, 0);
gradient.addColorStop(0, '#000000');
gradient.addColorStop(0.5, '#808080');
gradient.addColorStop(1, '#ffffff');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 256, 1);
return new THREE.CanvasTexture(canvas);
}
}
4. 几何体辅助工具
// geometries.ts
import * as THREE from 'three';
export class GeometryHelpers {
static createGroundPlane(size: number = 20) {
const geometry = new THREE.PlaneGeometry(size, size);
const material = new THREE.MeshStandardMaterial({
color: 0x808080,
roughness: 0.8,
metalness: 0.2,
});
const plane = new THREE.Mesh(geometry, material);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
return plane;
}
static createSkybox(textureLoader: THREE.TextureLoader, path: string) {
const loader = new THREE.CubeTextureLoader();
const texture = loader.load([
`${path}/px.jpg`, `${path}/nx.jpg`,
`${path}/py.jpg`, `${path}/ny.jpg`,
`${path}/pz.jpg`, `${path}/nz.jpg`,
]);
return texture;
}
static createParticles(count: number = 1000) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) {
positions[i] = (Math.random() - 0.5) * 20;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.05,
color: 0xffffff,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
});
return new THREE.Points(geometry, material);
}
}
5. 动画系统
// animation.ts
import * as THREE from 'three';
export class AnimationController {
private mixer: THREE.AnimationMixer;
private actions: Map<string, THREE.AnimationAction> = new Map();
constructor(model: THREE.Object3D, animations: THREE.AnimationClip[]) {
this.mixer = new THREE.AnimationMixer(model);
animations.forEach((clip, index) => {
const action = this.mixer.clipAction(clip);
this.actions.set(clip.name || `animation_${index}`, action);
});
}
play(name: string, fadeIn: number = 0.5) {
const action = this.actions.get(name);
if (action) {
action.reset().fadeIn(fadeIn).play();
}
}
stop(name: string, fadeOut: number = 0.5) {
const action = this.actions.get(name);
if (action) {
action.fadeOut(fadeOut);
}
}
update(deltaTime: number) {
this.mixer.update(deltaTime);
}
}
6. 模型加载
// loader.ts
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
export class ModelLoader {
private gltfLoader: GLTFLoader;
private textureLoader: THREE.TextureLoader;
private loadingManager: THREE.LoadingManager;
constructor(onProgress?: (progress: number) => void) {
this.loadingManager = new THREE.LoadingManager();
if (onProgress) {
this.loadingManager.onProgress = (url, loaded, total) => {
onProgress((loaded / total) * 100);
};
}
// Setup DRACO loader for compressed models
const dracoLoader = new DRACOLoader(this.loadingManager);
dracoLoader.setDecoderPath('/draco/');
this.gltfLoader = new GLTFLoader(this.loadingManager);
this.gltfLoader.setDRACOLoader(dracoLoader);
this.textureLoader = new THREE.TextureLoader(this.loadingManager);
}
async loadGLTF(url: string): Promise<THREE.Group> {
return new Promise((resolve, reject) => {
this.gltfLoader.load(
url,
(gltf) => {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
resolve(gltf.scene);
},
undefined,
reject
);
});
}
async loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
this.textureLoader.load(url, resolve, undefined, reject);
});
}
}
7. 后期处理
// post-processing.ts
import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass';
export class PostProcessing {
private composer: EffectComposer;
constructor(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera
) {
this.composer = new EffectComposer(renderer);
// Render pass
const renderPass = new RenderPass(scene, camera);
this.composer.addPass(renderPass);
// Bloom pass
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // strength
0.4, // radius
0.85 // threshold
);
this.composer.addPass(bloomPass);
// SSAO pass
const ssaoPass = new SSAOPass(scene, camera);
ssaoPass.kernelRadius = 16;
this.composer.addPass(ssaoPass);
}
render() {
this.composer.render();
}
resize(width: number, height: number) {
this.composer.setSize(width, height);
}
}
8. 性能优化
// optimization.ts
import * as THREE from 'three';
export class PerformanceOptimizer {
static optimizeGeometry(geometry: THREE.BufferGeometry) {
// Merge vertices
geometry.computeVertexNormals();
geometry.normalizeNormals();
return geometry;
}
static createLOD(geometries: THREE.BufferGeometry[], distances: number[]) {
const lod = new THREE.LOD();
geometries.forEach((geometry, index) => {
const material = new THREE.MeshStandardMaterial();
const mesh = new THREE.Mesh(geometry, material);
lod.addLevel(mesh, distances[index]);
});
return lod;
}
static enableInstancing(
geometry: THREE.BufferGeometry,
material: THREE.Material,
count: number,
positions: THREE.Vector3[]
) {
const mesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
positions.forEach((pos, i) => {
matrix.setPosition(pos);
mesh.setMatrixAt(i, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
return mesh;
}
static setupFrustumCulling(camera: THREE.Camera, objects: THREE.Object3D[]) {
const frustum = new THREE.Frustum();
const projScreenMatrix = new THREE.Matrix4();
return () => {
camera.updateMatrixWorld();
projScreenMatrix.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(projScreenMatrix);
objects.forEach((obj) => {
obj.visible = frustum.intersectsObject(obj);
});
};
}
}
9. React 集成
// ThreeCanvas.tsx
import React, { useEffect, useRef } from 'react';
import { SceneManager } from './scene';
export const ThreeCanvas: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const sceneManagerRef = useRef<SceneManager | null>(null);
useEffect(() => {
if (!containerRef.current) return;
// Initialize scene
sceneManagerRef.current = new SceneManager(containerRef.current);
// Cleanup
return () => {
sceneManagerRef.current?.dispose();
};
}, []);
return (
<div
ref={containerRef}
style={{ width: '100vw', height: '100vh' }}
/>
);
};
10. 完整示例
// main.ts
import * as THREE from 'three';
import { SceneManager } from './scene';
import { GeometryHelpers } from './geometries';
import { MaterialLibrary } from './materials';
import { ModelLoader } from './loader';
async function main() {
const container = document.getElementById('app')!;
const scene = new SceneManager(container);
// Add ground plane
const ground = GeometryHelpers.createGroundPlane();
scene.addObject(ground);
// Add particles
const particles = GeometryHelpers.createParticles(5000);
scene.addObject(particles);
// Load model
const loader = new ModelLoader((progress) => {
console.log(`Loading: ${progress.toFixed(0)}%`);
});
try {
const model = await loader.loadGLTF('/models/scene.gltf');
model.scale.set(2, 2, 2);
scene.addObject(model);
} catch (error) {
console.error('Failed to load model:', error);
}
// Add rotating cube with PBR material
const cubeGeo = new THREE.BoxGeometry();
const cubeMat = MaterialLibrary.createPBRMaterial({
color: 0x2194ce,
roughness: 0.3,
metalness: 0.8,
});
const cube = new THREE.Mesh(cubeGeo, cubeMat);
cube.position.y = 1;
scene.addObject(cube);
// Animation loop
function animate() {
requestAnimationFrame(animate);
cube.rotation.y += 0.01;
}
animate();
}
main();
最佳实践
推荐做法:
- 使用 BufferGeometry
- 启用视锥体剔除
- 对大量对象使用实例化网格
- 优化纹理
- 对远处的对象使用 LOD
- 释放几何体和材质
- 限制阴影贴图
- 谨慎使用后期处理
不推荐做法:
- 在渲染循环中创建对象
- 不使用 LOD 的高多边形模型
- 忘记释放资源
- 使用过多光源
- 跳过纹理压缩
- 在移动设备上以设备原生像素比进行渲染
- 不必要地逐帧更新 uniforms
🚀 第 2 部分:React Three Fiber(R3F)
React Three Fiber 是 Three.js 的 React 渲染器,可实现声明式、基于组件的 3D 开发。
安装
npm install three @react-three/fiber
# Ecosystem libraries
npm install @react-three/drei @react-three/postprocessing
npm install postprocessing
npm install --save-dev @types/three
基本 R3F 场景
// App.tsx
import { useRef, useState } from 'react';
import { Canvas, useFrame, MeshProps } from '@react-three/fiber';
import { OrbitControls, Environment, Sky } from '@react-three/drei';
import * as THREE from 'three';
function Box(props: MeshProps) {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHover] = useState(false);
const [active, setActive] = useState(false);
useFrame((state, delta) => {
if (meshRef.current) {
meshRef.current.rotation.x += delta;
}
});
return (
<mesh
{...props}
ref={meshRef}
scale={active ? 1.5 : 1}
onClick={() => setActive(!active)}
onPointerOver={() => setHover(true)}
onPointerOut={() => setHover(false)}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
</mesh>
);
}
export default function App() {
return (
<Canvas camera={{ position: [0, 0, 5], fov: 75 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<Box position={[0, 0, 0]} />
<OrbitControls />
<Sky sunPosition={[100, 20, 100]} />
<Environment preset="sunset" />
</Canvas>
);
}
高级 R3F 场景管理器
// Scene.tsx
import { useRef, useMemo } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import {
OrbitControls,
PerspectiveCamera,
Environment,
ContactShadows,
useGLTF,
useTexture,
Html,
Stats,
} from '@react-three/drei';
import {
EffectComposer,
Bloom,
DepthOfField,
Vignette,
SSAO,
} from '@react-three/postprocessing';
import * as THREE from 'three';
// Scene configuration
interface SceneConfig {
enablePostProcessing?: boolean;
enableShadows?: boolean;
enableStats?: boolean;
toneMappingExposure?: number;
}
// Main scene component
function Scene({ config }: { config: SceneConfig }) {
const { gl, scene } = useThree();
// Configure renderer
useMemo(() => {
gl.shadowMap.enabled = config.enableShadows ?? true;
gl.shadowMap.type = THREE.PCFSoftShadowMap;
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = config.toneMappingExposure ?? 1;
scene.background = new THREE.Color(0x1a1a1a);
}, [gl, scene, config]);
return (
<>
{config.enableStats && <Stats />}
{/* Lighting */}
<ambientLight intensity={0.3} />
<directionalLight
position={[10, 10, 5]}
intensity={1}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-10}
shadow-camera-right={10}
shadow-camera-top={10}
shadow-camera-bottom={-10}
/>
<hemisphereLight args={[0xffffbb, 0x080820, 0.2]} />
{/* Environment */}
<Environment preset="city" background={false} />
{/* Your 3D content here */}
</>
);
}
// App with Canvas wrapper
export default function App() {
return (
<Canvas shadows dpr={[1, 2]}>
<Scene config={{ enablePostProcessing: true, enableShadows: true }} />
<OrbitControls makeDefault />
{/* Post-processing */}
<EffectComposer>
<Bloom luminanceThreshold={1} intensity={0.5} />
<DepthOfField focusDistance={0.01} focalLength={0.2} bokehScale={3} />
<SSAO />
<Vignette opacity={0.5} />
</EffectComposer>
</Canvas>
);
}
R3F Hooks 与最佳实践
// hooks/useAnimatedModel.ts
import { useRef, useEffect } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import * as THREE from 'three';
export function useAnimatedModel(modelPath: string, animationName?: string) {
const group = useRef<THREE.Group>(null);
const { scene, animations } = useGLTF(modelPath);
const { actions, names } = useAnimations(animations, group);
useEffect(() => {
if (animationName && actions[animationName]) {
actions[animationName]?.play();
} else if (names.length > 0 && actions[names[0]]) {
actions[names[0]]?.play();
}
}, [actions, animationName, names]);
return { group, scene, actions, names };
}
// Usage
function AnimatedCharacter() {
const { group, scene } = useAnimatedModel('/models/character.glb', 'Idle');
return (
<group ref={group}>
<primitive object={scene} />
</group>
);
}
Drei 库 — 必备辅助工具
import {
// Cameras & Controls
PerspectiveCamera, OrthographicCamera, OrbitControls,
FlyControls, PointerLockControls, TrackballControls,
// Staging
Environment, Sky, Stars, Cloud, ContactShadows,
BakeShadows, AccumulativeShadows, RandomizedLight,
// Abstractions
Box, Sphere, Plane, Cone, Cylinder, Torus,
Text, Text3D, Html, Billboard,
// Loaders
useGLTF, useFBX, useTexture, useCubeTexture,
// Performance
Instances, Instance, Merged, Detailed,
// Misc
useHelper, Grid, GizmoHelper, Stats, PerformanceMonitor,
Center, Bounds, Shadow, Reflector
} from '@react-three/drei';
// Example: Performance-optimized instances
function Trees({ count = 100 }) {
const { nodes } = useGLTF('/models/tree.glb');
return (
<Instances limit={count} geometry={nodes.tree.geometry}>
<meshStandardMaterial color="green" />
{Array.from({ length: count }, (_, i) => (
<Instance
key={i}
position={[
(Math.random() - 0.5) * 50,
0,
(Math.random() - 0.5) * 50,
]}
rotation={[0, Math.random() * Math.PI * 2, 0]}
scale={0.5 + Math.random() * 0.5}
/>
))}
</Instances>
);
}
🎮 第 3 部分:角色系统与动画
角色动画系统
// characterAnimation.ts
import * as THREE from 'three';
export class CharacterAnimationController {
private mixer: THREE.AnimationMixer;
private actions: Map<string, THREE.AnimationAction> = new Map();
private currentAction: THREE.AnimationAction | null = null;
constructor(model: THREE.Object3D, animations: THREE.AnimationClip[]) {
this.mixer = new THREE.AnimationMixer(model);
animations.forEach((clip) => {
const action = this.mixer.clipAction(clip);
this.actions.set(clip.name, action);
});
}
play(name: string, fadeTime: number = 0.3) {
const nextAction = this.actions.get(name);
if (!nextAction) return;
if (this.currentAction && this.currentAction !== nextAction) {
this.currentAction.fadeOut(fadeTime);
}
nextAction.reset().fadeIn(fadeTime).play();
this.currentAction = nextAction;
}
crossFade(fromName: string, toName: string, duration: number = 0.3) {
const fromAction = this.actions.get(fromName);
const toAction = this.actions.get(toName);
if (fromAction && toAction) {
fromAction.fadeOut(duration);
toAction.reset().fadeIn(duration).play();
this.currentAction = toAction;
}
}
setWeight(name: string, weight: number) {
const action = this.actions.get(name);
if (action) action.setEffectiveWeight(weight);
}
update(deltaTime: number) {
this.mixer.update(deltaTime);
}
getAction(name: string) {
return this.actions.get(name);
}
}
使用 SkeletonUtils 重定向动画
// retargeting.ts
import * as THREE from 'three';
import { SkeletonUtils } from 'three/examples/jsm/utils/SkeletonUtils';
export class AnimationRetargeter {
/**
* Retarget animation from one skeleton to another
* Based on: https://github.com/upf-gti/retargeting-threejs
*/
static retargetAnimation(
sourceClip: THREE.AnimationClip,
targetSkeleton: THREE.Skeleton,
sourceSkeleton: THREE.Skeleton
): THREE.AnimationClip {
// Use SkeletonUtils for retargeting
const retargetedClip = SkeletonUtils.retargetClip(
targetSkeleton.bones[0],
sourceSkeleton.bones[0],
sourceClip,
{}
);
return retargetedClip;
}
/**
* Load Mixamo animation and apply to custom character
*/
static async loadMixamoAnimation(
fbxPath: string,
targetModel: THREE.Object3D,
loader: any
): Promise<THREE.AnimationClip[]> {
return new Promise((resolve, reject) => {
loader.load(
fbxPath,
(fbx: any) => {
// Extract animations
const animations = fbx.animations;
// Find skeletons
const targetSkeleton = this.findSkeleton(targetModel);
const sourceSkeleton = this.findSkeleton(fbx);
if (!targetSkeleton || !sourceSkeleton) {
reject(new Error('Skeleton not found'));
return;
}
// Retarget all animations
const retargetedClips = animations.map((clip: THREE.AnimationClip) =>
this.retargetAnimation(clip, targetSkeleton, sourceSkeleton)
);
resolve(retargetedClips);
},
undefined,
reject
);
});
}
private static findSkeleton(object: THREE.Object3D): THREE.Skeleton | null {
let skeleton: THREE.Skeleton | null = null;
object.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
skeleton = child.skeleton;
}
});
return skeleton;
}
}
Mixamo 集成工作流
// mixamoIntegration.ts
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader';
import * as THREE from 'three';
export class MixamoCharacter {
private model: THREE.Group;
private mixer: THREE.AnimationMixer;
private animations: Map<string, THREE.AnimationClip> = new Map();
constructor(model: THREE.Group) {
this.model = model;
this.mixer = new THREE.AnimationMixer(model);
}
/**
* Load character with embedded animations (GLB from Mixamo)
*/
static async loadWithAnimations(url: string): Promise<MixamoCharacter> {
const loader = new GLTFLoader();
return new Promise((resolve, reject) => {
loader.load(
url,
(gltf) => {
const character = new MixamoCharacter(gltf.scene);
// Add animations
gltf.animations.forEach((clip) => {
character.animations.set(clip.name, clip);
});
resolve(character);
},
undefined,
reject
);
});
}
/**
* Add separate animation file (FBX without skin)
*/
async addAnimation(name: string, fbxPath: string): Promise<void> {
const loader = new FBXLoader();
return new Promise((resolve, reject) => {
loader.load(
fbxPath,
(fbx) => {
if (fbx.animations.length > 0) {
this.animations.set(name, fbx.animations[0]);
}
resolve();
},
undefined,
reject
);
});
}
play(name: string, loop: boolean = true) {
const clip = this.animations.get(name);
if (!clip) return;
const action = this.mixer.clipAction(clip);
action.loop = loop ? THREE.LoopRepeat : THREE.LoopOnce;
action.play();
return action;
}
update(deltaTime: number) {
this.mixer.update(deltaTime);
}
getModel() {
return this.model;
}
}
// Usage Example
async function setupMixamoCharacter() {
// Method 1: Load character with animations
const character = await MixamoCharacter.loadWithAnimations(
'/models/character-with-animations.glb'
);
// Method 2: Load character and add animations separately
const character2 = await MixamoCharacter.loadWithAnimations(
'/models/character.glb'
);
await character2.addAnimation('walk', '/animations/walking.fbx');
await character2.addAnimation('run', '/animations/running.fbx');
character2.play('walk');
}
Ready Player Me 集成
// ReadyPlayerMeAvatar.tsx
import { useEffect, useRef } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import * as THREE from 'three';
interface AvatarProps {
avatarId: string;
animationUrl?: string;
position?: [number, number, number];
}
export function ReadyPlayerMeAvatar({
avatarId,
animationUrl,
position = [0, 0, 0]
}: AvatarProps) {
const group = useRef<THREE.Group>(null);
const avatarUrl = `https://models.readyplayer.me/${avatarId}.glb`;
const { scene, animations } = useGLTF(avatarUrl);
const { actions, names } = useAnimations(animations, group);
useEffect(() => {
// Apply better lighting to RPM avatars
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
// Fix material issues
if (child.material) {
child.material.envMapIntensity = 1;
}
}
});
}, [scene]);
useEffect(() => {
// Play first animation or idle
const actionName = names.includes('Idle') ? 'Idle' : names[0];
if (actionName && actions[actionName]) {
actions[actionName]?.play();
}
}, [actions, names]);
return (
<group ref={group} position={position}>
<primitive object={scene} />
</group>
);
}
// Preload avatars for better performance
useGLTF.preload('https://models.readyplayer.me/your-avatar-id.glb');
🤖 第 4 部分:NPC AI 与游戏行为
使用 Yuka.js 集成游戏 AI
npm install yuka
// npcAI.ts
import * as YUKA from 'yuka';
import * as THREE from 'three';
export class NPCController {
private entityManager: YUKA.EntityManager;
private time: YUKA.Time;
private vehicle: YUKA.Vehicle;
private mesh: THREE.Mesh;
constructor(mesh: THREE.Mesh) {
this.mesh = mesh;
this.entityManager = new YUKA.EntityManager();
this.time = new YUKA.Time();
// Create vehicle (NPC)
this.vehicle = new YUKA.Vehicle();
this.vehicle.maxSpeed = 3;
this.vehicle.maxForce = 10;
this.vehicle.setRenderComponent(mesh, this.sync);
this.entityManager.add(this.vehicle);
}
// Sync Three.js mesh with Yuka entity
private sync(entity: YUKA.GameEntity, renderComponent: THREE.Object3D) {
renderComponent.position.copy(entity.position as any);
renderComponent.quaternion.copy(entity.rotation as any);
}
// Seek behavior - move towards target
seekTarget(targetPosition: THREE.Vector3) {
const seekBehavior = new YUKA.SeekBehavior(
new YUKA.Vector3(targetPosition.x, targetPosition.y, targetPosition.z)
);
this.vehicle.steering.add(seekBehavior);
}
// Flee behavior - run away from threat
fleeFrom(threatPosition: THREE.Vector3) {
const fleeBehavior = new YUKA.FleeBehavior(
new YUKA.Vector3(threatPosition.x, threatPosition.y, threatPosition.z)
);
this.vehicle.steering.add(fleeBehavior);
}
// Wander behavior - random movement
enableWander() {
const wanderBehavior = new YUKA.WanderBehavior();
wanderBehavior.radius = 5;
wanderBehavior.distance = 10;
wanderBehavior.jitter = 2;
this.vehicle.steering.add(wanderBehavior);
}
// Follow path behavior
followPath(waypoints: THREE.Vector3[]) {
const path = new YUKA.Path();
waypoints.forEach((wp) => {
path.add(new YUKA.Vector3(wp.x, wp.y, wp.z));
});
path.loop = true;
const followPathBehavior = new YUKA.FollowPathBehavior(path, 0.5);
this.vehicle.steering.add(followPathBehavior);
}
// Obstacle avoidance
enableObstacleAvoidance(obstacles: YUKA.GameEntity[]) {
const obstacleAvoidance = new YUKA.ObstacleAvoidanceBehavior(obstacles);
this.vehicle.steering.add(obstacleAvoidance);
}
update() {
const delta = this.time.update().getDelta();
this.entityManager.update(delta);
}
getVehicle() {
return this.vehicle;
}
}
使用状态机实现高级 NPC
// npcStateMachine.ts
import * as YUKA from 'yuka';
export class NPCWithStates extends YUKA.GameEntity {
private stateMachine: YUKA.StateMachine<NPCWithStates>;
private states: {
idle: YUKA.State<NPCWithStates>;
patrol: YUKA.State<NPCWithStates>;
chase: YUKA.State<NPCWithStates>;
attack: YUKA.State<NPCWithStates>;
flee: YUKA.State<NPCWithStates>;
};
public target: YUKA.GameEntity | null = null;
public health: number = 100;
public detectionRadius: number = 10;
public attackRange: number = 2;
constructor() {
super();
this.stateMachine = new YUKA.StateMachine(this);
this.states = this.setupStates();
this.stateMachine.currentState = this.states.idle;
}
private setupStates() {
// Idle State
const idleState = new YUKA.State('idle');
idleState.enter = () => console.log('NPC is idle');
idleState.execute = (npc: NPCWithStates) => {
// Check for nearby targets
if (npc.detectTarget()) {
npc.stateMachine.changeState(npc.states.chase);
}
};
// Patrol State
const patrolState = new YUKA.State('patrol');
patrolState.execute = (npc: NPCWithStates) => {
// Patrol logic
if (npc.detectTarget()) {
npc.stateMachine.changeState(npc.states.chase);
}
};
// Chase State
const chaseState = new YUKA.State('chase');
chaseState.enter = () => console.log('NPC is chasing');
chaseState.execute = (npc: NPCWithStates) => {
if (!npc.target) {
npc.stateMachine.changeState(npc.states.idle);
return;
}
const distance = npc.position.distanceTo(npc.target.position);
if (distance <= npc.attackRange) {
npc.stateMachine.changeState(npc.states.attack);
} else if (distance > npc.detectionRadius) {
npc.stateMachine.changeState(npc.states.idle);
}
};
// Attack State
const attackState = new YUKA.State('attack');
attackState.execute = (npc: NPCWithStates) => {
if (!npc.target) {
npc.stateMachine.changeState(npc.states.idle);
return;
}
const distance = npc.position.distanceTo(npc.target.position);
if (distance > npc.attackRange) {
npc.stateMachine.changeState(npc.states.chase);
}
};
// Flee State
const fleeState = new YUKA.State('flee');
fleeState.enter = () => console.log('NPC is fleeing');
fleeState.execute = (npc: NPCWithStates) => {
if (npc.health > 30) {
npc.stateMachine.changeState(npc.states.idle);
}
};
return {
idle: idleState,
patrol: patrolState,
chase: chaseState,
attack: attackState,
flee: fleeState,
};
}
detectTarget(): boolean {
// Implement target detection logic
return false; // Placeholder
}
update(delta: number): this {
this.stateMachine.update();
return this;
}
}
React Three Fiber NPC 组件
// NPCComponent.tsx
import { useRef, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
import { NPCController } from './npcAI';
import * as THREE from 'three';
interface NPCProps {
modelPath: string;
initialPosition: [number, number, number];
behavior: 'wander' | 'patrol' | 'chase';
waypoints?: [number, number, number][];
target?: THREE.Vector3;
}
export function NPC({
modelPath,
initialPosition,
behavior,
waypoints,
target
}: NPCProps) {
const { scene } = useGLTF(modelPath);
const groupRef = useRef<THREE.Group>(null);
const npcController = useRef<NPCController | null>(null);
useEffect(() => {
if (!groupRef.current) return;
// Find the first mesh in the scene hierarchy
// For production, use scene.getObjectByName() or traverse to find specific mesh
let mesh: THREE.Mesh | undefined;
scene.traverse((child) => {
if (!mesh && child instanceof THREE.Mesh) {
mesh = child;
}
});
if (!mesh) return;
npcController.current = new NPCController(mesh);
// Setup behavior
switch (behavior) {
case 'wander':
npcController.current.enableWander();
break;
case 'patrol':
if (waypoints) {
const points = waypoints.map(wp => new THREE.Vector3(...wp));
npcController.current.followPath(points);
}
break;
case 'chase':
if (target) {
npcController.current.seekTarget(target);
}
break;
}
}, [behavior, waypoints, target, scene]);
useFrame(() => {
npcController.current?.update();
});
return (
<group ref={groupRef} position={initialPosition}>
<primitive object={scene} />
</group>
);
}
🎲 第 5 部分:程序化生成
程序化角色生成
// proceduralCharacter.ts
import * as THREE from 'three';
interface CharacterParameters {
height: number; // 0.5 - 1.5 (multiplier)
bodyWidth: number; // 0.7 - 1.3
headSize: number; // 0.8 - 1.2
armLength: number; // 0.8 - 1.2
legLength: number; // 0.9 - 1.1
skinColor: THREE.Color;
hairColor: THREE.Color;
}
export class ProceduralCharacterGenerator {
/**
* Generate a procedural humanoid character
* Inspired by parametric models like SMPL
*/
static generate(params: CharacterParameters): THREE.Group {
const character = new THREE.Group();
// Base measurements
const baseHeight = 1.7 * params.height;
const headHeight = 0.25 * params.headSize;
const torsoHeight = 0.45 * baseHeight;
const legHeight = 0.45 * baseHeight * params.legLength;
const armLength = 0.35 * baseHeight * params.armLength;
// Head
const headGeo = new THREE.SphereGeometry(
headHeight / 2,
32,
32
);
const headMat = new THREE.MeshStandardMaterial({
color: params.skinColor
});
const head = new THREE.Mesh(headGeo, headMat);
head.position.y = baseHeight - headHeight / 2;
head.castShadow = true;
character.add(head);
// Torso
const torsoGeo = new THREE.BoxGeometry(
0.3 * params.bodyWidth,
torsoHeight,
0.2
);
const torso = new THREE.Mesh(torsoGeo, headMat);
torso.position.y = baseHeight - headHeight - torsoHeight / 2;
torso.castShadow = true;
character.add(torso);
// Arms
const armGeo = new THREE.CylinderGeometry(0.04, 0.04, armLength, 8);
const leftArm = new THREE.Mesh(armGeo, headMat);
leftArm.position.set(
-0.15 * params.bodyWidth - 0.04,
baseHeight - headHeight - armLength / 2 - 0.05,
0
);
leftArm.castShadow = true;
character.add(leftArm);
const rightArm = leftArm.clone();
rightArm.position.x *= -1;
character.add(rightArm);
// Legs
const legGeo = new THREE.CylinderGeometry(0.06, 0.05, legHeight, 8);
const leftLeg = new THREE.Mesh(legGeo, headMat);
leftLeg.position.set(
-0.08,
legHeight / 2,
0
);
leftLeg.castShadow = true;
character.add(leftLeg);
const rightLeg = leftLeg.clone();
rightLeg.position.x *= -1;
character.add(rightLeg);
// Hair (simple)
const hairGeo = new THREE.SphereGeometry(headHeight / 2 + 0.02, 32, 32);
const hairMat = new THREE.MeshStandardMaterial({
color: params.hairColor
});
const hair = new THREE.Mesh(hairGeo, hairMat);
hair.position.copy(head.position);
hair.position.y += 0.02;
hair.scale.set(1, 0.6, 1);
character.add(hair);
return character;
}
/**
* Generate random character parameters
*/
static randomParams(): CharacterParameters {
return {
height: 0.8 + Math.random() * 0.4,
bodyWidth: 0.85 + Math.random() * 0.3,
headSize: 0.9 + Math.random() * 0.2,
armLength: 0.9 + Math.random() * 0.2,
legLength: 0.95 + Math.random() * 0.1,
skinColor: new THREE.Color().setHSL(
0.08 + Math.random() * 0.05,
0.3 + Math.random() * 0.3,
0.4 + Math.random() * 0.3
),
hairColor: new THREE.Color().setHSL(
Math.random(),
0.5 + Math.random() * 0.5,
0.2 + Math.random() * 0.3
),
};
}
}
// Usage
const params = ProceduralCharacterGenerator.randomParams();
const character = ProceduralCharacterGenerator.generate(params);
scene.add(character);
程序化地形生成
npm install simplex-noise
// proceduralTerrain.ts
import * as THREE from 'three';
import { SimplexNoise } from 'simplex-noise';
export class TerrainGenerator {
static generate(
width: number,
depth: number,
segments: number,
heightScale: number = 10
): THREE.Mesh {
const geometry = new THREE.PlaneGeometry(
width,
depth,
segments,
segments
);
const noise = new SimplexNoise();
const vertices = geometry.attributes.position.array;
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const z = vertices[i + 1];
// Multi-octave noise for realistic terrain
let height = 0;
height += noise.noise2D(x * 0.01, z * 0.01) * heightScale;
height += noise.noise2D(x * 0.05, z * 0.05) * (heightScale * 0.25);
height += noise.noise2D(x * 0.1, z * 0.1) * (heightScale * 0.125);
vertices[i + 2] = height;
}
geometry.computeVertexNormals();
geometry.rotateX(-Math.PI / 2);
// Vertex coloring based on height
const colors: number[] = [];
for (let i = 0; i < vertices.length; i += 3) {
const height = vertices[i + 2];
const color = new THREE.Color();
if (height < 0) {
color.setHex(0x3a7bc8); // Water
} else if (height < 2) {
color.setHex(0xc2b280); // Sand
} else if (height < 5) {
color.setHex(0x567d46); // Grass
} else {
color.setHex(0x8b7355); // Mountain
}
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute(colors, 3)
);
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
flatShading: true,
});
return new THREE.Mesh(geometry, material);
}
}
程序化建筑
// proceduralBuilding.ts
import * as THREE from 'three';
export class BuildingGenerator {
static generateBuilding(
floors: number = 3 + Math.floor(Math.random() * 7),
floorHeight: number = 3,
width: number = 5 + Math.random() * 5,
depth: number = 5 + Math.random() * 5
): THREE.Group {
const building = new THREE.Group();
// Building material
const wallMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(0, 0, 0.6 + Math.random() * 0.2),
roughness: 0.8,
});
// Create floors
for (let i = 0; i < floors; i++) {
const floorGeo = new THREE.BoxGeometry(width, floorHeight, depth);
const floor = new THREE.Mesh(floorGeo, wallMaterial);
floor.position.y = i * floorHeight + floorHeight / 2;
floor.castShadow = true;
floor.receiveShadow = true;
building.add(floor);
// Add windows
BuildingGenerator.addWindows(floor, width, depth, floorHeight);
}
// Roof
const roofGeo = new THREE.ConeGeometry(
Math.max(width, depth) * 0.7,
floorHeight,
4
);
const roofMat = new THREE.MeshStandardMaterial({ color: 0x8b4513 });
const roof = new THREE.Mesh(roofGeo, roofMat);
roof.position.y = floors * floorHeight + floorHeight / 2;
roof.rotation.y = Math.PI / 4;
roof.castShadow = true;
building.add(roof);
return building;
}
private static addWindows(
floor: THREE.Mesh,
width: number,
depth: number,
height: number
) {
const windowMaterial = new THREE.MeshStandardMaterial({
color: 0x87ceeb,
emissive: 0x87ceeb,
emissiveIntensity: 0.3,
});
const windowSize = 0.8;
const windowDepth = 0.1;
// Front and back windows
for (let i = -1; i <= 1; i += 2) {
const windowGeo = new THREE.BoxGeometry(
windowSize,
windowSize,
windowDepth
);
const window = new THREE.Mesh(windowGeo, windowMaterial);
window.position.set(0, 0, (depth / 2 + windowDepth / 2) * i);
floor.add(window);
}
}
}
⚡ 第 6 部分:物理效果与角色控制器
Rapier 物理集成
npm install @react-three/rapier
// PhysicsScene.tsx
import { Physics, RigidBody, CuboidCollider } from '@react-three/rapier';
import { Canvas } from '@react-three/fiber';
function PhysicsScene() {
return (
<Canvas>
<Physics gravity={[0, -9.81, 0]}>
{/* Ground */}
<RigidBody type="fixed">
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[100, 100]} />
<meshStandardMaterial color="gray" />
</mesh>
</RigidBody>
{/* Dynamic objects */}
<RigidBody position={[0, 5, 0]}>
<mesh castShadow>
<boxGeometry />
<meshStandardMaterial color="red" />
</mesh>
</RigidBody>
</Physics>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} castShadow />
</Canvas>
);
}
使用物理效果的角色控制器
// CharacterController.tsx
import { useRef, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { RigidBody, CapsuleCollider, RapierRigidBody } from '@react-three/rapier';
import { useKeyboardControls } from '@react-three/drei';
import * as THREE from 'three';
interface CharacterControllerProps {
position?: [number, number, number];
}
export function CharacterController({ position = [0, 2, 0] }: CharacterControllerProps) {
const rigidBodyRef = useRef<RapierRigidBody>(null);
const [, get] = useKeyboardControls();
const velocity = useRef(new THREE.Vector3());
const direction = useRef(new THREE.Vector3());
const moveSpeed = 5;
const jumpForce = 5;
useFrame((state, delta) => {
if (!rigidBodyRef.current) return;
const { forward, backward, left, right, jump } = get();
// Get current velocity
const currentVel = rigidBodyRef.current.linvel();
velocity.current.set(currentVel.x, currentVel.y, currentVel.z);
// Calculate movement direction
direction.current.set(0, 0, 0);
if (forward) direction.current.z -= 1;
if (backward) direction.current.z += 1;
if (left) direction.current.x -= 1;
if (right) direction.current.x += 1;
direction.current.normalize();
// Apply movement
velocity.current.x = direction.current.x * moveSpeed;
velocity.current.z = direction.current.z * moveSpeed;
// Jump
if (jump && Math.abs(currentVel.y) < 0.1) {
velocity.current.y = jumpForce;
}
rigidBodyRef.current.setLinvel(velocity.current, true);
});
return (
<RigidBody
ref={rigidBodyRef}
position={position}
lockRotations={true}
>
<CapsuleCollider args={[0.5, 0.5]} />
<mesh castShadow>
<capsuleGeometry args={[0.5, 1]} />
<meshStandardMaterial color="blue" />
</mesh>
</RigidBody>
);
}
// Keyboard controls setup
import { KeyboardControls } from '@react-three/drei';
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
{ name: 'left', keys: ['ArrowLeft', 'KeyA'] },
{ name: 'right', keys: ['ArrowRight', 'KeyD'] },
{ name: 'jump', keys: ['Space'] },
];
function App() {
return (
<KeyboardControls map={keyboardMap}>
<Canvas>
{/* Your scene */}
<CharacterController />
</Canvas>
</KeyboardControls>
);
}
📚 第 7 部分:学术参考与研究
角色动画与重定向
关键概念:
- IK(逆向运动学):计算关节角度以到达目标位置(例如手部放置位置)
- FK(正向运动学):根据关节角度计算末端位置
- 重定向:在不同骨架结构之间转移动画
研究领域:
-
动作重定向技术
- 构建骨架对应关系
- 在转移过程中保留动作特征
- 基于 IK 与基于 FK 的方法
-
实现参考:
- 使用 SkeletonUtils(Three.js)进行基本重定向
- upf-gti/retargeting-threejs — 开源重定向求解器
- 使用 Blender 手动重定向的 Mixamo 工作流
参数化身体模型
SMPL(Skinned Multi-Person Linear)
- 用约 100 个参数编码身体形状和姿势的参数化模型
- 应用:创建虚拟形象、虚拟试穿、动作捕捉
- 可使用 SMPL-X JavaScript 库进行 Web 实现
现代方法:
- AvatarForge:AI 驱动的多模态虚拟形象生成
- SmartAvatar:基于 VLM 的参数化虚拟形象创建
- FaceMaker:程序化面部特征生成
学术论文:
- “FaceMaker—A Procedural Face Generator to Foster Character Design Research”
- “Parametric 3D human modeling with biharmonic SMPL”
- “AvatarCraft: Transforming Text into Neural Human Avatars”
游戏 AI 与寻路
转向行为(Craig Reynolds):
- 寻找、逃离、追逐、躲避
- 游荡、抵达、避障
- 路径跟随、跟随领队
寻路算法:
- 使用 A*(A-Star)寻找最优路径
- 用于 3D 环境的导航网格
- 针对移动障碍物的动态寻路
状态机:
- 用于行为控制的有限状态机(FSM)
- 用于复杂 NPC 的分层状态机
- 面向目标的行动规划(GOAP)
实现:
- Yuka.js - 完整的 Web 游戏 AI 库
- 与 Three.js 和 React Three Fiber 集成
程序化生成
噪声函数:
- Perlin Noise - 平滑、自然的随机效果
- Simplex Noise - 性能更佳,且没有方向性伪影
- 用于生成逼真地形的多倍频噪声
程序化技术:
- 用于生成有机结构(树木、植物)的 L-系统
- 用于关卡生成的波函数坍缩
- 用于体积地形的 Marching Cubes
- 用于角色变化的参数化模型
🎯 最佳实践总结
React Three Fiber
应该:
- 使用
useFrame实现动画,而不是setInterval - 利用 drei 辅助工具完成常见任务
- 对重复几何体使用
<Instances> - 在 useEffect 中正确清理资源
- 使用 Suspense 延迟加载模型
- 启用
concurrent模式以提升性能
不应该:
- 在渲染过程中创建 Three.js 对象(使用 useMemo)
- 忘记释放几何体/材质
- 使用过多灯光(会影响性能)
- 在移动设备上跳过
dpr限制
角色系统
应该:
- 在 Web 中使用 GLTFLoader(尽可能避免使用 FBX)
- 使用 fadeIn/fadeOut 实现动画混合
- 使用 useGLTF.preload() 缓存已加载的模型
- 使用 Mixamo 快速制作原型
- 正确设置骨架层级
不应该:
- 混用不兼容的骨骼结构
- 在重定向动画时跳过骨骼名称验证
- 忘记在每一帧更新动画混合器
- 在生产环境中加载未压缩的模型
NPC 与游戏 AI
应该:
- 在生产环境中使用 Yuka.js,打造可直接投入使用的 AI
- 使用状态机实现复杂行为
- 优化 AI 更新频率(不必每帧更新)
- NPC 数量较多时使用空间分区
- 添加感知系统(视觉、听觉)
不应该:
- 每帧运行寻路算法
- 跳过行为优先级系统
- 忘记为 AI 预留性能预算
- 将行为值硬编码(应使用配置)
物理
应该:
- 使用 Rapier 以获得最佳 Web 性能
- 为快速移动的物体启用 CCD
- 为复杂形状使用复合碰撞体
- 将物理更新频率限制为 60 Hz
- 为静态物体实现休眠状态
不应该:
- 所有物体都使用网格碰撞体
- 跳过碰撞层/遮罩设置
- 按渲染帧率运行物理模拟
- 忘记清理物理对象
🔗 必备资源
文档
模型资源
学习
✅ 完整检查清单
基础设置
- 已安装 Three.js 或 R3F
- 已配置场景、摄像机和渲染器
- 已实现光照系统
- 已添加控制器(OrbitControls/PointerLock)
- 画布尺寸可响应变化
角色系统
- 已实现 GLTF/GLB 加载器
- 动画系统正常运行
- 角色控制器(如有需要)
- 已集成 Ready Player Me 或 Mixamo
- 动画混合功能正常
游戏功能(如适用)
- 使用 Yuka.js 实现 NPC AI
- 使用 Rapier 实现物理效果
- 碰撞检测
- 状态管理
- 输入处理
程序化生成(如适用)
- 地形生成
- 角色变体
- 程序化建筑/道具
- 基于噪声的系统
性能
- 对重复对象使用实例化
- 已实现 LOD 系统
- 已启用视锥剔除
- 纹理优化
- 阴影贴图优化
- 后期处理优化
生产就绪
- 已添加错误边界
- 已实现加载状态
- 移动端优化
- 妥善清理/释放资源
- 资源预加载
- 性能监控
评论
支持 Markdown