事情是这样的。某天深夜我刷到一个 3D 散步模拟器的视频,心想"这玩意儿我也能做啊",然后打开编辑器就开始写了。三个小时后,我的角色确实在走路——只不过脚不动、身体往前滑,活像个刚喝完三斤白酒的大叔在冰面上漂移。但没关系,修 bug 本来就是开发的一部分。下面我把整个项目从零到跑起来的过程完整记录下来,包括素材怎么找、模型怎么搞、Three.js 怎么搭,以及那些让我怀疑人生的坑。
一、免费 3D 素材去哪找?别花钱,真没必要
做 3D 场景最头疼的第一件事就是:模型从哪来?总不能自己建吧?答案是——确实不用自己建,网上免费资源一大把,关键是要找对地方。
人物模型 + 动画:Mixamo
这是 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 场景搭建:从一块绿地板开始
技术栈选型很简单: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)
天空颜色、地面颜色、环境光、雾气参数全部同步插值,所以从春天切到冬天时,你能看到绿色慢慢变白、粉色天空渐变成冷蓝色,整个过程很丝滑。
三、角色动画:从"冰面漂移"到正常走路
这是整个项目踩坑最多的部分。
动画加载
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,然后直接渲染出来。
实现原理
- 用户输入场景描述
- 调用 Claude API,system prompt 里定义了场景配置的 JSON Schema
- Claude 返回一个符合接口的 JSON 对象
- 解析 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 + 窗户发光贴片 + 路灯点光源。
// 程序化生成建筑
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 材质
}
窗户用 MeshStandardMaterial 的 emissive 属性实现自发光,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。整个项目没有花一分钱在素材上,免费资源完全够用。
如果你也想做类似的项目,记住三点:
- 先让角色能动起来,其他都是锦上添花
- Mixamo 的动画一定要 stripRootPosition,不然你会花两小时调试"为什么角色在漂移"
- 粒子效果是氛围的灵魂,没有雪花的冬天和没有花瓣的春天,就是一坨绿色/白色方块