零素材 Three.js 程序化生成:3D 电子宠物实战
不加载任何模型和贴图,一只会做饭、看书、打扫的 3D 小鼠全靠代码现算——这就是程序化生成的魅力。本文拆开四个核心机制:10 通道姿态阻尼、行为状态机、Canvas 木纹贴图、家具与行为注册表解耦,并给出实测数据。
一、先看成果:零素材能做出什么
项目名称是「贝塔的小窝 · Beta’s Burrow」,一只 2~3 头身的小鼠住在断面玩偶屋风格的洞穴里,会自己走去烹饪、吃奶酪、阅读、小睡、打扫、坐着发呆。整个工程的依赖只有three和vite两个 npm 包,仓库里没有任何.glb/.png/.jpg:所有几何体是CapsuleGeometry、SphereGeometry这类基本体拼的,木纹是 Canvas 现画的。
| 模块 | 实现 | 说明 |
|---|---|---|
| 场景 | core/World.js | 渲染器 / 相机 / OrbitControls / 四层灯光 / 主循环 |
| 房间 | core/Room.js | 木地板、背墙、圆门、地毯、吊灯,14 个 Mesh |
| 角色 | core/Character.js | 小鼠本体 + 3 件道具,37 个 Mesh |
| 家具 | furniture/*.js | 6 件家具各一个文件,共 47~48 个 Mesh |
| 行为 | actions/*.js | 6 个行为各一个文件,enter/update/exit 三段式 |
| 调度 | core/ActionManager.js | 行走 → 执行 → 待机的状态机 + 自动生活模式 |
| 材质 | core/materials.js | 全局色板 + Canvas 程序化木纹 |
实测装配一遍场景:房间 14 个 Mesh + 角色 37 个 + 家具 47~48 个 ≈ 99 个 Mesh,材质实例 77~78 个。几何体分布是 Sphere 26、Box 35~36、Cylinder 19、Capsule 6、Torus 4、Circle 3、Cone 2、Ring 1、Plane 1、Tube 1。
启动就是标准 Vite 工程,没有任何资源预处理步骤:
npminstall# 依赖只有 three 和 vite 两个npmrun dev# 启动开发服务器,支持热重载npmrun build# 打包到 dist/二、技术选型:为什么用纯程序化生成
2026 年 AI 生成 3D 的热度很高(Meta 的 WorldGen 用程序化推理生成可探索环境、Google 的 Genie 3 直接生成交互世界),但它们产出的东西要么拿不到可编辑网格,要么只是逐帧模拟。对一个要长期维护的小项目来说,可控性才是第一位的。
| 方案 | 素材体积 | 首屏等待 | 改一个颜色的成本 | 适合场景 |
|---|---|---|---|---|
| 外部 glTF / FBX 模型 | 几 MB 到几十 MB | 要异步加载 + 进度条 | 回 DCC 工具重新导出 | 写实风格、复杂角色 |
| 图集 + 精灵动画 | 中等 | 快 | 重画图集 | 2D 桌宠、像素风 |
| 纯程序化几何 + Canvas 贴图 | 0 字节 | 无等待 | 改一行色板常量 | 卡通低模、参数化角色 |
程序化生成的代价是建模全靠写代码,但基本体拼装有几个很讨巧的做法:耳朵是SphereGeometry压扁(scale.set(1, 1, 0.45))再叠一层浅色内耳,耳根单独挂Group做枢轴,摆耳只改一个rotation;尾巴是四个控制点的CatmullRomCurve3扫出TubeGeometry,尾尖再补一个小球;躯干、四肢统一用CapsuleGeometry,关节处塞球体过渡,圆润感就出来了;背带裤是胶囊体加两条BoxGeometry背带和一块口袋方块。这些几何在 Three.js 里都是参数化的,改半径和分段数立刻见效,而用模型文件就得回 DCC 工具重导一遍。整站零网络请求,配色只动palette一个常量对象。
三、核心原理一:10 通道姿态阻尼系统
角色动画没有用AnimationMixer和骨骼,而是定义了一组姿态通道,每个行为只负责设「目标值」,引擎每帧做指数阻尼逼近:
// core/Character.js —— 可阻尼的姿态通道constCHANNELS=['armL','armR','headTilt','headNod','earL','earR','tailSway','tailSwayY','lean','legFold'];// 每帧:阻尼过渡 + 应用(config.poseDamp = 9)tick(dt){this.t+=dt;constp=this.pose,tg=this.target;constk=Math.min(1,dt*config.poseDamp);// 关键:k 与 dt 成正比for(constcofCHANNELS)p[c]+=(tg[c]-p[c])*k;this._apply();}p += (target - p) * k是指数趋近,时间常数 τ = 1/poseDamp ≈0.111 秒。连续形式下到达 95% 需要 3τ ≈ 0.333 秒。我把它在不同帧率下真跑了一遍(脚本复刻自上面的tick):
| 帧率 | dt | k | 到 95% 帧数 | 到 95% 耗时 | 到 99% 耗时 |
|---|---|---|---|---|---|
| 144 fps | 0.0069 | 0.0625 | 47 | 0.326 s | 0.500 s |
| 120 fps | 0.0083 | 0.0750 | 39 | 0.325 s | 0.500 s |
| 60 fps | 0.0167 | 0.1500 | 19 | 0.317 s | 0.483 s |
| 30 fps | 0.0333 | 0.3000 | 9 | 0.300 s | 0.433 s |
| 20 fps | 0.0500 | 0.4500 | 6 | 0.300 s | 0.400 s |
可以看到 144 fps 和 30 fps 的收敛耗时只差 0.026 秒——这是帧率无关的关键:如果写成p += (target - p) * 0.15这种写死系数,30 fps 会慢 5 倍。指数阻尼还天然不会过冲,不需要额外做缓动曲线。
行为代码因此写起来极其轻,eat.js的啃咬动作只用了十几行:
// actions/eat.js —— 只设目标,不管插值update(char,f,dt,e){constchomp=Math.max(0,Math.sin(e*3));// 0..1 的啃咬节奏char.target.headNod=0.14+chomp*0.2;// 低头去啃char.target.armL=-0.5+Math.sin(e*3)*0.05;char.target.armR=-0.5-Math.sin(e*3)*0.05;char.target.earL=0.08+chomp*0.1;// 耳朵跟着抖char.target.earR=0.08+chomp*0.1;}行走时反过来:_apply()里if (this.moving)会用walkPhase直接覆盖手臂和腿的旋转,行为设置的目标值暂时失效——行走与动作互斥,避免了「边走边啃奶酪」的鬼畜画面。
同一套_apply()还负责身体浮动:走路时是Math.abs(Math.sin(walkPhase)) * 0.06的上下颠簸,待机时换成Math.sin(t * 1.8) * 0.02的呼吸。实测走路颠簸峰值 0.06 单位、待机呼吸峰值 0.02 单位(周期 3.49 秒);walkPhase += dt * 9意味着步频 1.43 步/秒,配合 1.7 单位/秒的速度,步长正好 0.593 单位、走一米要 0.588 秒。这几个数字全是派生量——改config.walkSpeed会让步长和步频一起变,动画代码一行都不用动。
四、核心原理二:行走 → 执行 → 待机的三段状态机
ActionManager只有一个current对象和一个queue槽位:
// core/ActionManager.jsupdate(dt){if(this.current){constcur=this.current;if(cur.state==='walk'){// 走到位才切到 'act',walkTo 返回 true 表示已到达if(this.char.walkTo(cur.targetPos,dt)){cur.state='act';cur.elapsed=0;cur.action.enter?.(this.char,cur.furniture);}}else{cur.elapsed+=dt;cur.action.update?.(this.char,cur.furniture,dt,cur.elapsed,this);if(cur.elapsed>cur.action.duration){// 计时结束cur.action.exit?.(this.char,cur.furniture);this.current=null;}}return;}if(this.queue){this._start(this.queue);this.queue=null;return;}this.char.idle();// 待机if(this.auto){// 自动生活:随机挑一件家具this.autoTimer-=dt;if(this.autoTimer<=0){this._pickRandom();this.autoTimer=config.autoMin+Math.random()*(config.autoMax-config.autoMin);}}}站位点是「家具坐标 + standOffset」,行走只处理 x/z 平面,速度 1.7 单位/秒,到达阈值 0.05:
// core/Character.js —— 走向目标点,返回是否已到达walkTo(target,dt){constdx=target.x-this.root.position.x;constdz=target.z-this.root.position.z;constd=Math.hypot(dx,dz);if(d>0.05){conststep=Math.min(config.walkSpeed*dt,d);// 不会冲过目标this.root.position.x+=(dx/d)*step;this.root.position.z+=(dz/d)*step;this.root.rotation.y=Math.atan2(dx,dz);// 转身朝向this.walkPhase+=dt*9;// 步频 1.43 步/秒returnfalse;}this.moving=false;returntrue;}从出生点(0,0)走到各家具的实测耗时:
| 家具 | 行为 | duration | 站位 | 直线距离 | 行走耗时 |
|---|---|---|---|---|---|
| 火柴盒床 | 小睡 | 7 s | (-2.00, -0.70) | 2.119 | 1.233 s |
| 线轴椅子 | 休息 | 5 s | (1.10, -1.40) | 1.780 | 1.033 s |
| 瓶盖餐桌 | 吃奶酪 | 5 s | (0.00, -0.05) | 0.050 | 0.017 s(1 帧) |
| 罐头灶台 | 烹饪 | 6 s | (-1.00, -0.10) | 1.005 | 0.567 s |
| 小书架 | 阅读 | 6 s | (2.20, -0.70) | 2.309 | 1.333 s |
| 小扫帚 | 打扫 | 6 s | (1.70, 0.40) | 1.746 | 1.000 s |
把状态机在 Node 里以 1/60 步长跑满 30 分钟(108000 帧),得到的时间分配是:行走 156.0 s(8.7%)、执行行为 769.3 s(42.7%)、发呆 874.7 s(48.6%),共完成 133 次行为,平均每13.53 秒一个循环(行为均值 5.833 s + 间隔均值 6.499 s + 行走)。60 万次随机采样下 6 个行为的选中次数都在 10 万 ± 0.5% 以内,_pickRandom的分布是均匀的。
五、核心原理三:Canvas 程序化木纹贴图
零素材的关键一步是木纹。512×512 的 Canvas 上画 110 条正弦扰动的细线,再点 5 个径向渐变当木结,最后包成CanvasTexture:
// core/materials.js —— 程序生成木纹,无任何外部图片exportfunctionwoodTexture(){if(_woodTex)return_woodTex;// 缓存:整站只生成一次constc=document.createElement('canvas');c.width=512;c.height=512;constctx=c.getContext('2d');ctx.fillStyle=palette.wood;ctx.fillRect(0,0,512,512);for(leti=0;i<110;i++){// 110 条木纹线ctx.strokeStyle=`rgba(110,70,35,${0.05+Math.random()*0.13})`;ctx.lineWidth=1+Math.random()*2.5;consty=Math.random()*512;ctx.beginPath();ctx.moveTo(0,y);for(letx=0;x<=512;x+=20){// 每 20px 一个采样点ctx.lineTo(x,y+Math.sin(x*0.04+i)*4+(Math.random()-0.5)*5);}ctx.stroke();}// 5 处木结(径向渐变)consttex=newTHREE.CanvasTexture(c);tex.wrapS=tex.wrapT=THREE.RepeatWrapping;// 平铺给地板/家具复用tex.colorSpace=THREE.SRGBColorSpace;_woodTex=tex;returntex;}// 每次调用 clone 一份,repeat 控制平铺密度exportfunctionwoodMaterial(repeat=[1,1]){consttex=woodTexture().clone();tex.needsUpdate=true;// clone 后必须置位,否则不上传tex.repeat.set(repeat[0],repeat[1]);returnnewTHREE.MeshStandardMaterial({map:tex,roughness:0.85,metalness:0.0});}实测一次生成的绘制调用:fillRect1 次、beginPath/moveTo/stroke各 110 次、lineTo2860次、createRadialGradient5 次。整站woodMaterial()被调用10 次(地板、踢脚线、门框、床、椅身、两个椅盘、桌腿、书架框、扫帚杆),因为缓存命中,Canvas 只画了 1 次,但.clone()产生了10 份独立贴图对象。
六、核心原理四:家具与行为的注册表解耦
家具不认识行为,行为也不认识家具,二者只通过一个字符串 id 关联:
// furniture/index.js —— 新增家具只需 import 并加入数组exportconstfurnitureDefs=[bed,chair,table,stove,bookshelf,broom];// furniture/broom.js —— 一件家具就是一个纯数据对象exportdefault{id:'broom',name:'小扫帚',emoji:'🧹',action:'clean',// 只写行为 id,二者完全解耦position:[1.7,0,-0.4],standOffset:[0,0,0.8],// 贝塔站到哪meta:{},build(){/* 返回 THREE.Group */},animate(group,t){}// 可选每帧动画};FurnitureManager遍历定义数组实例化,并在traverse时把userData.furnitureId打到每个 Mesh 上,供射线拾取时向上回溯父节点找到家具:
// core/FurnitureManager.jsgroup.traverse((o)=>{if(o.isMesh){o.castShadow=true;o.receiveShadow=true;o.userData.furnitureId=def.id;// 供射线拾取识别}});点击拾取还顺手解决了「拖拽旋转视角」与「点击家具」的冲突——按下与抬起的位移超过 6 像素就算拖拽:
// main.js —— 6px 阈值区分点击与拖拽constmoved=Math.hypot(e.clientX-downPt.x,e.clientY-downPt.y);if(moved>6)return;// 视为拖拽旋转,忽略于是新增一件家具的流程只有三步:
- 在
src/furniture/新建myThing.js,导出带id / name / action / position / standOffset / build()的定义对象; - 在
src/furniture/index.js里import并加进furnitureDefs数组; - 若
action指向的行为还没写,再在src/actions/按enter / update / exit三段式补一个文件。
反向也成立:家具和行为互不import,删掉任何一个都不会牵连另一个,ActionManager只在运行时按 id 查表。
七、踩坑记录(6 个,都真踩过)
木纹贴图 clone 出 10 份,显存翻 10 倍。
woodMaterial()每次clone()都会生成新uuid的贴图,WebGL 会各上传一次。512×512 RGBA 单张 1.00 MB,带 mipmap 约 1.33 MB,10 份就是约13.33 MB 显存(估算)。正确做法是共享一张贴图、用mesh.material.map.repeat配合 UV 缩放,或直接接受「房间小、可接受」的取舍——本项目选了后者。蒸汽三个球的相位不均匀。
s.userData.phase = i * 1.7配上((t * 0.4 + phase) % 0.4),余数换算成时间是 0 s / 0.25 s / 0.5 s,间隔变成 0.25 / 0.25 /0.5秒,而理想三等分应为 0.333 秒——最后一个球会「等半秒」才跟上,看起来像卡顿。改成phase = i * (0.4 / 3)即可均匀。程序化生成用了
Math.random,场景不确定。书架的书宽是0.05 + Math.random() * 0.04,循环条件while (x < 0.28)导致书的数量随机。重建 200 次实测:Mesh 总数 16(2 次)、17(108 次)、18(71 次)、19(19 次)。想要可复现的效果,必须换成带种子的 PRNG。主循环
dt = Math.min(dt, 0.05)会让低帧率变成慢动作。钳制是为了防止切后台回来时一帧跳很远,但代价是 15 fps 下真实 1 秒只推进 0.75 秒动画、10 fps 只有 0.5 秒、5 fps 只有 0.25 秒。钳制值要和预期最低帧率一起定。行为执行中的点击不会打断,且队列只有一个槽位。
update()开头if (this.current) { ...; return; },实测连续request三个家具后queue只剩最后一个(bookshelf);在read执行中再请求table,当前行为仍是read,table只是排队。想要「立刻响应」,得在request里做打断逻辑并调用当前行为的exit复位。站位阈值 0.05 卡在边界上。餐桌站位点 (0, -0.05) 与出生点距离恰好 0.050,
d > 0.05判假 → 一步不走直接开吃。判定要用>=或把阈值调小,否则会出现「原地瞬移开吃」的观感。
八、实测数据汇总
| 指标 | 数值 | 来源 |
|---|---|---|
| 场景 Mesh 总数 | 98~99(房间 14 / 角色 37 / 家具 47~48) | 装配真实源码统计 |
| 材质实例 | 77~78 | 同上 |
| 木纹 Canvas 绘制 | 110 条线 / 2860 次 lineTo / 5 个木结 | 假 canvas 上下文计数 |
| 木纹贴图 | 生成 1 次,clone 10 次 | 同上 |
| 姿态收敛到 95% | 60 fps:19 帧 / 0.317 s | 复刻tick实跑 |
| 自动间隔均值 | 6.4986 s(理论 6.5) | 100 万次采样 |
| 30 分钟时间分配 | 行走 8.7% / 执行 42.7% / 发呆 48.6% | 108000 帧状态机模拟 |
| 单次行为循环 | 13.53 s(133 次 / 30 分钟) | 同上 |
| 行走速度 | 1.7 单位/秒,步长 0.593,步频 1.43 步/秒 | 复刻walkTo |
| 身体浮动 | 待机 ±0.02(周期 3.49 s),走路 ±0.06 | 复刻_apply |
九、小结与下一步
- 程序化生成不是「省事」,是把建模成本换成参数维护成本,卡通低模场景非常划算。
- 姿态阻尼用
dt参与系数是帧率无关的最低门槛,写死系数必踩。 - 行为与家具靠 id 解耦后,加一个行为只要新增一个文件 + 一行 import。
下一步想做的:给Math.random换成种子 PRNG 让场景可复现、把 10 份木纹贴图合并成 1 份、给行为加「可被新请求打断」的优先级。
完整工程已整理好(含一键启动脚本 + 部署文档,支持二次开发和商用),需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后续会把 Three.js 程序化生成系列继续更下去。