资讯动态

零素材 Three.js 程序化生成:3D 电子宠物实战

发布时间:2026/9/29 21:44:47 来源:尧图企业网站定制
零素材 Three.js 程序化生成3D 电子宠物实战不加载任何模型和贴图一只会做饭、看书、打扫的 3D 小鼠全靠代码现算——这就是程序化生成的魅力。本文拆开四个核心机制10 通道姿态阻尼、行为状态机、Canvas 木纹贴图、家具与行为注册表解耦并给出实测数据。一、先看成果零素材能做出什么项目名称是「贝塔的小窝 · Beta’s Burrow」一只 23 头身的小鼠住在断面玩偶屋风格的洞穴里会自己走去烹饪、吃奶酪、阅读、小睡、打扫、坐着发呆。整个工程的依赖只有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/*.js6 件家具各一个文件共 4748 个 Mesh行为actions/*.js6 个行为各一个文件enter/update/exit 三段式调度core/ActionManager.js行走 → 执行 → 待机的状态机 自动生活模式材质core/materials.js全局色板 Canvas 程序化木纹实测装配一遍场景房间 14 个 Mesh 角色 37 个 家具 4748 个 ≈ 99 个 Mesh材质实例 7778 个。几何体分布是 Sphere 26、Box 3536、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 9tick(dt){this.tdt;constpthis.pose,tgthis.target;constkMath.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帧率dtk到 95% 帧数到 95% 耗时到 99% 耗时144 fps0.00690.0625470.326 s0.500 s120 fps0.00830.0750390.325 s0.500 s60 fps0.01670.1500190.317 s0.483 s30 fps0.03330.300090.300 s0.433 s20 fps0.05000.450060.300 s0.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){constchompMath.max(0,Math.sin(e*3));// 0..1 的啃咬节奏char.target.headNod0.14chomp*0.2;// 低头去啃char.target.armL-0.5Math.sin(e*3)*0.05;char.target.armR-0.5-Math.sin(e*3)*0.05;char.target.earL0.08chomp*0.1;// 耳朵跟着抖char.target.earR0.08chomp*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){constcurthis.current;if(cur.statewalk){// 走到位才切到 actwalkTo 返回 true 表示已到达if(this.char.walkTo(cur.targetPos,dt)){cur.stateact;cur.elapsed0;cur.action.enter?.(this.char,cur.furniture);}}else{cur.elapseddt;cur.action.update?.(this.char,cur.furniture,dt,cur.elapsed,this);if(cur.elapsedcur.action.duration){// 计时结束cur.action.exit?.(this.char,cur.furniture);this.currentnull;}}return;}if(this.queue){this._start(this.queue);this.queuenull;return;}this.char.idle();// 待机if(this.auto){// 自动生活随机挑一件家具this.autoTimer-dt;if(this.autoTimer0){this._pickRandom();this.autoTimerconfig.autoMinMath.random()*(config.autoMax-config.autoMin);}}}站位点是「家具坐标 standOffset」行走只处理 x/z 平面速度 1.7 单位/秒到达阈值 0.05// core/Character.js —— 走向目标点返回是否已到达walkTo(target,dt){constdxtarget.x-this.root.position.x;constdztarget.z-this.root.position.z;constdMath.hypot(dx,dz);if(d0.05){conststepMath.min(config.walkSpeed*dt,d);// 不会冲过目标this.root.position.x(dx/d)*step;this.root.position.z(dz/d)*step;this.root.rotation.yMath.atan2(dx,dz);// 转身朝向this.walkPhasedt*9;// 步频 1.43 步/秒returnfalse;}this.movingfalse;returntrue;}从出生点0,0走到各家具的实测耗时家具行为duration站位直线距离行走耗时火柴盒床小睡7 s(-2.00, -0.70)2.1191.233 s线轴椅子休息5 s(1.10, -1.40)1.7801.033 s瓶盖餐桌吃奶酪5 s(0.00, -0.05)0.0500.017 s1 帧罐头灶台烹饪6 s(-1.00, -0.10)1.0050.567 s小书架阅读6 s(2.20, -0.70)2.3091.333 s小扫帚打扫6 s(1.70, 0.40)1.7461.000 s把状态机在 Node 里以 1/60 步长跑满 30 分钟108000 帧得到的时间分配是行走 156.0 s8.7%、执行行为 769.3 s42.7%、发呆 874.7 s48.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;// 缓存整站只生成一次constcdocument.createElement(canvas);c.width512;c.height512;constctxc.getContext(2d);ctx.fillStylepalette.wood;ctx.fillRect(0,0,512,512);for(leti0;i110;i){// 110 条木纹线ctx.strokeStylergba(110,70,35,${0.05Math.random()*0.13});ctx.lineWidth1Math.random()*2.5;constyMath.random()*512;ctx.beginPath();ctx.moveTo(0,y);for(letx0;x512;x20){// 每 20px 一个采样点ctx.lineTo(x,yMath.sin(x*0.04i)*4(Math.random()-0.5)*5);}ctx.stroke();}// 5 处木结径向渐变consttexnewTHREE.CanvasTexture(c);tex.wrapStex.wrapTTHREE.RepeatWrapping;// 平铺给地板/家具复用tex.colorSpaceTHREE.SRGBColorSpace;_woodTextex;returntex;}// 每次调用 clone 一份repeat 控制平铺密度exportfunctionwoodMaterial(repeat[1,1]){consttexwoodTexture().clone();tex.needsUpdatetrue;// 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.castShadowtrue;o.receiveShadowtrue;o.userData.furnitureIddef.id;// 供射线拾取识别}});点击拾取还顺手解决了「拖拽旋转视角」与「点击家具」的冲突——按下与抬起的位移超过 6 像素就算拖拽// main.js —— 6px 阈值区分点击与拖拽constmovedMath.hypot(e.clientX-downPt.x,e.clientY-downPt.y);if(moved6)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 MB10 份就是约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 总数 162 次、17108 次、1871 次、1919 次。想要可复现的效果必须换成带种子的 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当前行为仍是readtable只是排队。想要「立刻响应」得在request里做打断逻辑并调用当前行为的exit复位。站位阈值 0.05 卡在边界上。餐桌站位点 (0, -0.05) 与出生点距离恰好 0.050d 0.05判假 → 一步不走直接开吃。判定要用或把阈值调小否则会出现「原地瞬移开吃」的观感。八、实测数据汇总指标数值来源场景 Mesh 总数9899房间 14 / 角色 37 / 家具 4748装配真实源码统计材质实例7778同上木纹 Canvas 绘制110 条线 / 2860 次 lineTo / 5 个木结假 canvas 上下文计数木纹贴图生成 1 次clone 10 次同上姿态收敛到 95%60 fps19 帧 / 0.317 s复刻tick实跑自动间隔均值6.4986 s理论 6.5100 万次采样30 分钟时间分配行走 8.7% / 执行 42.7% / 发呆 48.6%108000 帧状态机模拟单次行为循环13.53 s133 次 / 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 程序化生成系列继续更下去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑