我用 Three.js 造了个 3D 漫步世界,角色走路像喝醉了——以及我是怎么修好的

23 阅读11分钟

事情是这样的。某天深夜我刷到一个 3D 散步模拟器的视频,心想"这玩意儿我也能做啊",然后打开编辑器就开始写了。三个小时后,我的角色确实在走路——只不过脚不动、身体往前滑,活像个刚喝完三斤白酒的大叔在冰面上漂移。但没关系,修 bug 本来就是开发的一部分。下面我把整个项目从零到跑起来的过程完整记录下来,包括素材怎么找、模型怎么搞、Three.js 怎么搭,以及那些让我怀疑人生的坑。


一、免费 3D 素材去哪找?别花钱,真没必要

做 3D 场景最头疼的第一件事就是:模型从哪来?总不能自己建吧?答案是——确实不用自己建,网上免费资源一大把,关键是要找对地方。

人物模型 + 动画:Mixamo

image.png

image.png

这是 Adobe 旗下的免费平台,提供大量带骨骼绑定的人物模型和动作捕捉动画。我的项目用了三个动画:Walking、Running、Idle,全部从 Mixamo 下载,格式选 FBX。

使用要点:

  • 注册 Adobe 账号即可免费使用
  • 下载时选择 FBX for Unity (.fbx) 格式,Three.js 的 FBXLoader 能直接读
  • 同一个角色下载多个动画时,骨骼结构必须一致(用同一个 T-Pose 角色),否则动画切换时会"抽风"
  • 建议先下载一个带动画的完整模型作为基础 mesh,再单独下载其他动画的 FBX 文件只取动画数据

场景装饰模型:免费网站大盘点

场景里的树、花、城堡这些装饰物,全是从免费网站上薅的。下面把我常用的网站列出来,附上链接,按推荐程度排序:

1. Sketchfab —— sketchfab.com

3D 模型的"GitHub",模型数量最多、质量也最高。筛选条件选 Downloadable + Free,格式选 glTF/GLB。下载后直接丢进 public/models/ 目录就能用。我的樱树、枫树、松树、雪人、玫瑰花、城堡全是从这找的。唯一缺点是免费模型里偶尔会混入低质量的,需要自己筛一下。

2. Poly Pizza —— poly.pizza

低多边形(Low Poly)风格模型的天堂!全部免费,无需注册即可下载,格式支持 GLB 和 OBJ。如果你做的是 Low Poly 风格的场景,这个网站比 Sketchfab 更好用——模型风格统一,不会出现一个写实树旁边一个卡通树的尴尬。我的项目里部分装饰模型就是从这拿的。

3. Quaternius —— quaternius.com

又一个 Low Poly 免费素材站,特点是模型分类非常清晰(人物、动物、植物、建筑、道具……),而且所有模型都是 CC0 协议(公共领域),随便用,不用署名。模型质量稳定,风格偏简约可爱。

4. Kenney —— kenney.nl/assets

游戏开发界的"免费素材之王"。不仅有 3D 模型,还有贴图、音效、UI 素材,全部 CC0 协议。3D 模型以 Low Poly 为主,适合做游戏原型和独立项目。网站界面简洁,下载方便,一个 ZIP 包里包含所有格式(FBX、OBJ、GLB 都有)。

5. CGTrader —— cgtrader.com

专业级 3D 模型市场,但筛选 Free 标签后能找到不少高质量免费模型。格式选择多(FBX、OBJ、GLB 等),质量普遍比 Sketchfab 高,但免费数量少一些。适合找那种"一个模型撑起整个场景"的重点物件(比如我的城堡模型就是在这找到的)。

6. Free3D —— free3d.com

名字就叫 Free3D。模型种类多,格式全,但界面比较老,需要花点时间翻找。适合找一些 Sketchfab 上没有的冷门模型。

7. Tripo AI —— tripo3d.ai

如果以上网站都找不到想要的,可以用 AI 生成。输入文字描述就能生成 3D 模型,免费额度够用。项目里也预留了 Meshy AI 的接口(modelGenerator.ts),原理一样:调 API 提交生成任务 → 轮询等结果 → 拿到 GLB 模型 URL。

8. Meshy AI —— meshy.ai

和 Tripo 类似的 AI 3D 生成工具,文字/图片转 3D 模型。免费用户每月有一定生成额度,生成的模型可以直接导出 GLB 格式。

选站建议:

  • 要 Low Poly 风格 → Poly Pizza / Quaternius / Kenney
  • 要写实风格 → Sketchfab / CGTrader
  • 要特定物件找不到 → Free3D / CGTrader
  • 以上都没有 → Tripo AI / Meshy AI 自己生成

我的素材清单:

文件用途来源
Walking.fbx行走动画Mixamo
Running.fbx跑步动画Mixamo
Idle.fbx待机动画Mixamo
樱树.glb春天场景Sketchfab
春天的树.glb春/夏场景Sketchfab
枫树.glb秋天场景Sketchfab
松树.glb冬天/夜晚Sketchfab
花.glb春/夏装饰Sketchfab
玫瑰花.glb浪漫场景Sketchfab
城堡.glb浪漫场景Sketchfab
雪人.glb冬天场景Sketchfab
高楼大厦.glb城市场景Sketchfab

一个省钱小技巧: 同一类模型只下载一个,然后通过代码随机缩放、旋转、摆放,视觉上就能产生"很多不同树"的效果。我的春天场景 30 棵樱树其实都是同一个模型,只是大小和角度不同。


二、Three.js 场景搭建:从一块绿地板开始

chrome-capture-2026-05-22.gif

技术栈选型很简单:Vue 3 + Three.js + Vite + TypeScript。Vue 负责 UI 面板,Three.js 负责 3D 渲染,各司其职。

核心架构

整个 3D 部分拆成了几个类,各管一摊:

SceneManager(总管)
  ├── Character(角色:加载模型、切换动画)
  ├── Environment(环境:天空、地面、灯光、雾气)
  ├── ParticleSystem(粒子:雪花、落叶、花瓣、萤火虫)
  └── Path(路径:脚下的路和地面纹理滚动)

渲染器初始化

this.renderer = new THREE.WebGLRenderer({ antialias: true })
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0

几个关键配置:

  • antialias: true —— 抗锯齿,不然模型边缘全是毛刺
  • PCFSoftShadowMap —— 柔和阴影,比默认的 BasicShadowMap 好看很多
  • ACESFilmicToneMapping —— 电影级色调映射,让画面更有质感,不会过曝

地面滚动的"障眼法"

这是个有意思的技巧。角色其实并没有真的在场景中"走很远"——地面是跟着角色移动的,但纹理的 UV offset 在不断偏移,制造出"地面在脚下流过"的视觉效果。

export function updateGroundScroll(pathMesh: THREE.Mesh, ground: THREE.Mesh | null, charZ: number): void {
  const uvOffset = charZ * 0.05
  const pathMat = pathMesh.material as THREE.MeshStandardMaterial
  if (pathMat.map) {
    pathMat.map.offset.y = uvOffset
  }
  if (ground) {
    const groundMat = ground.material as THREE.MeshStandardMaterial
    if (groundMat.map) {
      groundMat.map.offset.y = uvOffset
    }
  }
}

这样做的好处是:地面和路径的几何体始终跟着角色走,不需要无限长的地面,但视觉上看起来角色一直在往前走。装饰物(树、花等)则是固定在场景中的,角色走过就留在身后——这种"近处动、远处不动"的组合,比纯滚动更有沉浸感。

粒子系统:从雪花到萤火虫

粒子系统支持五种效果:雪花、落叶、沙尘、萤火虫、花瓣。核心思路一样——用 THREE.Points + BufferGeometry,每帧更新位置。

不同粒子的区别在于运动方向和视觉参数:

switch (config.type) {
  case 'snow':     // 缓慢下落 + 轻微水平漂移
  case 'leaves':   // 下落 + 水平飘动 + 前进方向
  case 'sand':     // 水平方向为主(沙漠风暴感)
  case 'fireflies': // 随机缓慢漂浮 + 加法混合(发光感)
  case 'petals':   // 类似落叶但更轻柔
}

萤火虫效果有个特殊处理:使用 AdditiveBlending(加法混合),让粒子叠加时更亮,模拟发光效果。这是做夜间场景氛围的关键。

还有一个细节:粒子超出角色周围 20 单位范围后会被"回收"并重新生成在角色附近,这样不管角色走多远,粒子始终围绕在身边。

场景过渡动画

切换场景时不是硬切,而是用 smoothstep 缓动函数做 2 秒的颜色渐变:

const eased = t * t * (3 - 2 * t) // smoothstep
this.scene.background = startBg.clone().lerp(targetBg, eased)

天空颜色、地面颜色、环境光、雾气参数全部同步插值,所以从春天切到冬天时,你能看到绿色慢慢变白、粉色天空渐变成冷蓝色,整个过程很丝滑。


三、角色动画:从"冰面漂移"到正常走路

chrome-capture-2026-05-22 (1).gif

这是整个项目踩坑最多的部分。

动画加载

Mixamo 下载的 FBX 文件里,每个文件都包含完整的模型 + 一个动画。我的做法是:用 Walking.fbx 作为基础模型(取 mesh),然后从 Running.fbx 和 Idle.fbx 里只提取动画数据:

async load(walkUrl: string, runUrl: string, idleUrl: string): Promise<void> {
  const loader = new FBXLoader()
  const walkModel = await this.loadFBX(loader, walkUrl)
  this.mesh = walkModel  // 用 walk 模型作为基础 mesh

  this.mixer = new THREE.AnimationMixer(this.mesh)

  // 从 walk 模型取行走动画
  if (walkModel.animations.length > 0) {
    const clip = this.stripRootPosition(walkModel.animations[0])
    this.walkAction = this.mixer.clipAction(clip)
  }

  // 从 run 模型只取跑步动画
  const runModel = await this.loadFBX(loader, runUrl)
  if (runModel.animations.length > 0) {
    const clip = this.stripRootPosition(runModel.animations[0])
    this.runAction = this.mixer.clipAction(clip)
  }
  // idle 同理...
}

第一个坑:角色在场景里"漂移"

加载完动画后,角色确实在走了,但整个人在场景里不断往前滑——这就是开头说的"冰面漂移"。

原因:Mixamo 的动画数据里包含了根骨骼(Root/Hips)的位移信息。动画播放时,角色的根节点会按照动画里的位移数据移动,而我的代码也在移动角色的 position,两者叠加就导致角色"跑得比预期快"。

解决方法:stripRootPosition——在应用动画之前,把根骨骼的位移轨道过滤掉:

private stripRootPosition(clip: THREE.AnimationClip): THREE.AnimationClip {
  const stripped = clip.clone()
  stripped.tracks = stripped.tracks.filter((track) => {
    const name = track.name.toLowerCase()
    const isPosition = name.endsWith('.position') || name.includes('position')
    const isRoot = !name.includes('.') || name.includes('hips') || name.includes('root')
    return !(isPosition && isRoot)  // 过滤掉根骨骼的位移
  })
  return stripped
}

只保留旋转轨道,位移由代码控制。这样角色就在原地踏步,位置完全由我的键盘输入决定。

第二个坑:动画切换时角色"抽搐"

直接从一个动画切到另一个时,角色会突然跳到新姿势,看起来像触电。

解决方法:crossFadeTo——使用 Three.js 的动画交叉淡入淡出:

private crossFadeTo(newAction: THREE.AnimationAction | null, duration: number): void {
  if (!this.currentAction) {
    this.currentAction = newAction
    newAction.play()
    return
  }
  const oldAction = this.currentAction
  newAction.reset()
  newAction.setLoop(THREE.LoopRepeat, Infinity)
  newAction.play()
  oldAction.crossFadeTo(newAction, duration, true)  // 0.3秒过渡
  this.currentAction = newAction
}

0.3 秒的过渡时间,从走到跑、从跑到停,都很自然。

第三个坑:角色走路没有上下起伏

过滤掉根骨骼位移后,角色走路时完全没有上下弹动,看起来像在滑旱冰。

解决方法:手动加一个正弦波的垂直偏移(bob):

update(delta: number, isMoving: boolean): void {
  this.mixer?.update(delta)
  if (isMoving && this.bobAmplitude > 0) {
    const speed = this.currentState === 'run' ? 12 : 7
    this.bobPhase += delta * speed
    const bob = Math.abs(Math.sin(this.bobPhase)) * this.bobAmplitude
    if (this.mesh) {
      this.mesh.position.y += bob
    }
  }
}

走路时振幅 0.04,跑步时 0.08,频率也更快。Math.abs(Math.sin(...)) 让弹动始终向上,模拟脚掌着地时的反作用力。

模型缩放与对齐

Mixamo 模型的大小各不相同,需要统一缩放到约 1.8 单位高(模拟人类身高),并确保脚底对齐地面 y=0:

const box = new THREE.Box3().setFromObject(this.mesh)
const size = box.getSize(new THREE.Vector3())
const scale = 1.8 / size.y
this.mesh.scale.set(scale, scale, scale)

// 重新计算包围盒,把脚底对齐到 y=0
const newBox = new THREE.Box3().setFromObject(this.mesh)
this.mesh.position.y = -newBox.min.y

四、AI 加持:用 Claude 生成场景配置

项目最酷的部分——用户输入一段文字描述(比如"樱花飘落的夜晚"),Claude API 会生成对应的场景配置 JSON,然后直接渲染出来。

实现原理

  1. 用户输入场景描述
  2. 调用 Claude API,system prompt 里定义了场景配置的 JSON Schema
  3. Claude 返回一个符合接口的 JSON 对象
  4. 解析 JSON,应用到场景中
const SYSTEM_PROMPT = `You are a scene configuration generator for a 3D walking simulation.
Given a user's scene description, generate a JSON configuration object.

The JSON must follow this exact TypeScript interface:
{
  ground: { color: string, roughness: number },
  sky: { color: string, fogColor: string, fogNear: number, fogFar: number },
  ambientLight: { color: string, intensity: number },
  directionalLight: { color: string, intensity: number, position: [number, number, number] },
  particles: { type: "snow" | "leaves" | "sand" | "fireflies" | "none", ... } | null,
  decorations: { prompt: string, count: number, side: "left" | "right" | "both" }[]
}`

关键技巧:system prompt 里把接口定义得非常精确,包括枚举值("snow" | "leaves" | ...),这样 Claude 返回的 JSON 几乎不需要后处理就能直接用。

JSON 提取的容错处理

Claude 有时候会在 JSON 外面包一层 markdown 代码块(```json ... ```),所以需要正则提取:

const jsonMatch = text.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
  throw new Error('Failed to parse scene configuration from AI response')
}
return JSON.parse(jsonMatch[0]) as SceneConfig

城市场景的特殊处理

城市场景没有用 GLB 模型,而是用代码程序化生成建筑:随机高度的 BoxGeometry + 窗户发光贴片 + 路灯点光源。

image.png

// 程序化生成建筑
for (let i = 0; i < buildingConfig.count; i++) {
  const width = 2 + Math.random() * 4
  const height = 8 + Math.random() * 25
  const geo = new THREE.BoxGeometry(width, height, depth)
  // ...窗户是小的 PlaneGeometry,带 emissive 材质
}

窗户用 MeshStandardMaterialemissive 属性实现自发光,40% 的窗户随机不亮,模拟真实城市里有的房间关灯了的效果。

模型缓存

同一个 GLB 文件可能被实例化多次(比如 30 棵樱树),每次都重新加载太浪费。用 Map 做了个简单的缓存:

const modelCache = new Map<string, THREE.Group>()

async function loadGLBModel(url: string): Promise<THREE.Group> {
  if (modelCache.has(url)) {
    return modelCache.get(url)!.clone()  // 缓存命中,直接 clone
  }
  // ...加载并缓存
}

clone()load() 快几个数量级,30 棵树只加载一次模型文件。


最后

这个项目从"一块绿地板上一个不会动的方块人"到"7 种场景自由切换 + AI 生成 + 粒子特效",大概花了半天时间。最耗时的不是写代码,而是找素材和调参数——天空颜色差一点感觉就完全不对,雾的远近对氛围影响巨大。

技术栈总结:Vue 3 + Three.js + Vite + TypeScript,模型来源 Mixamo + Sketchfab,AI 场景生成用 Claude API。整个项目没有花一分钱在素材上,免费资源完全够用。

如果你也想做类似的项目,记住三点:

  1. 先让角色能动起来,其他都是锦上添花
  2. Mixamo 的动画一定要 stripRootPosition,不然你会花两小时调试"为什么角色在漂移"
  3. 粒子效果是氛围的灵魂,没有雪花的冬天和没有花瓣的春天,就是一坨绿色/白色方块

image.png

image.png

image.png

image.png

image.png

image.png