
最近跟几个做独立游戏的朋友聊到3D资产制作发现一个很有意思的现象大家都开始往回卷程序化生成了。不是那种“AI一键生成模型”的云端生图式玩法而是最朴素的Coding式的生成3D——用代码规则去描述三维世界从一片叶子、一块瓦片这样的最小单元开始写起一路堆出屋顶、街道、区块最后铺成整个世界。与此同时vibe coding这股风也吹到了这个领域对着AI编程助手描述“我要一个能根据种子值生成整片村庄屋顶的脚本”它还真能给你写出能跑的代码。这篇文章就把我最近折腾“一叶一瓦到一世界”的完整路子捋一遍从规则设计、代码实现到AI辅助编程的质量把控都聊一聊适合想用程序化方式做3D场景、又不想从零啃图形学底层的人参考。1. 为什么是“Coding式生成3D”手工建模的尽头是规则化1.1 手工建模的痛长尾资产的成本黑洞先说个很直观的问题。你在游戏里看到一片森林觉得“哇好真实”但如果是靠美术人手一棵一棵摆树、一片一片贴草那这个项目大概率已经破产了。手工建模最大的痛点不是单件资产质量而是长尾成本一棵树能雕得很漂亮但一百棵、一千棵形态各异的树呢一片屋顶瓦片能做得很好但一片城中村的上万片瓦呢边际成本几乎是线性的。我见过不少团队的做法是“先建几个高模然后复制粘贴最后靠随机旋转和缩放糊弄过去”。结果就是场景里全是重复感玩家一眼就看出“这个树和那个树是同一个模型转了个角度”。这种重复感在写实风格下尤其刺眼因为自然界里几乎没有两片完全相同的叶子。程序化生成解决的就是这件事把“每一个都不一样”从美术成本问题变成“代码规则 随机种子”的计算问题。你写一套叶子生成的函数传不同的种子它就能给你产出形态有差异的叶子再去控制叶子的分布规律就能生成一片看起来自然、又不完全随机的树冠。1.2 程序化生成的本质把美术经验变成代码规则听名字“程序化生成”好像很高深拆开看其实特别朴素。它的本质是把美术的观察和经验翻译成一组可重复执行的数学规则。举个例子一片叶子的形状有什么规律基部收窄、中段最宽、叶尖渐尖主叶脉向两侧对称分布叶片表面还有轻微的褶皱。这些描述听起来像美术老师上课但落到代码里就是几条公式轮廓用贝塞尔曲线或幂函数控制褶皱用噪声函数叠加叶脉用UV坐标映射。核心要点是“规则必须可参数化”。参数化意味着你可以给同一个规则喂不同的输入得到不同的输出。比如叶子的长度、宽度、弯曲程度、颜色渐变偏移全部做成参数和种子值的函数。这样你写的不是“一片叶子”而是一个“叶子工厂”所有同类的叶子都是从这条流水线上出来的既保持了风格统一又规避了完全复制带来的呆板感。1.3 vibe coding 让“编码生成”重新回到聚光灯下说到这儿就得提今年特别热闹的vibe coding了。很多人以为vibe coding就是“对着AI说人话然后复制粘贴代码”觉得这东西顶多写个Todo应用。但我在生成3D这个方向上试下来发现它意外地契合。原因在于程序化生成的代码结果可验证性强——生成出来的树叶歪了、瓦片叠了、屋顶塌了你立刻就能看出来不需要像业务系统那样去推断一堆模糊的逻辑状态。AI写出来的生成代码如果不对劲你直接截个图丢回去说“叶脉方向反了右侧叶子塌下去了”它就能对着视觉反馈迭代。另一个契合点是程序化生成往往是小函数加小函数的堆叠逻辑单元清晰非常适合AI按“plan”去拆解实现。“写一个噪声函数”“写一个L系统分支生成”“写一个屋顶瓦片铺装逻辑”每个任务边界分明AI不太容易把上下文搞乱。这也是为什么我觉得“Coding式的生成3D”在vibe coding时代突然变得很香。2. 从叶子到瓦片最小单元的规则设计2.1 先定“原子”一叶一瓦是怎么来的任何程序化生成的系统第一步都是定义“原子单元”。这个原子就是世界里最小的、可以被反复实例化的几何对象。一叶、一瓦都是典型的原子。定原子的时候有个特别容易踩的坑原子既不能太小也不能太大。太小了比如你把“叶脉上的一个细胞”当原子那你得先生成十万个细胞才能拼出一片叶子性能直接爆炸太大了比如你把“整棵树”当原子那树和树之间的差异化就丧失了又退回复制粘贴的老路。我自己的经验是原子的粒度取决于“你希望哪个层级出现可见差异”。对于植物来说叶子是合理的原子——同一棵树上的叶子有差异但树冠的形态结构是更上层的规则对于建筑来说瓦片、砖块、窗框是原子——每块瓦的颜色可以不同但窗户的排布规则是上层的。2.2 规则不等于随机噪声函数与参数边界很多新手写程序化生成第一反应就是“多用点Math.random()”。这是最大的误解。纯随机生成的场景往往不是“自然”而是“脏”。你看现实世界里的瓦片屋顶颜色深浅不一但那是有规律的一片区域的瓦片因为同一批原料颜色接近相邻片区的瓦片因为老化程度不同颜色整体偏移。这种“局部一致、全局变化”的规律靠独立随机数是模拟不出来的。正确的做法是用噪声函数比如Perlin噪声或Simplex噪声来做空间上的连续变化。你取屋顶平面上每个瓦片的世界坐标丢进噪声函数得到一个值再映射到颜色偏移量。这样相邻的瓦片颜色接近隔得远的瓦片差异大看起来就像真的经历了日晒雨淋一样。此外所有参数都必须有边界约束。我给叶子长度做变化时会限制在基准值的0.8到1.2倍之间给瓦片做旋转时限制在正负2度以内。超出这个范围的变体就该怀疑是bug而不是特性。程序化生成的终极目标是“可控的丰富”而不是“失控的混乱”。你需要的不是让AI自由发挥而是把自由限制在风格框架里。2.3 层级组合对象 → 场景 → 世界的LOD化生成从一片叶子到一整个世界中间隔着好几级组装逻辑。我做的时候习惯分成四层层级内容生成方式原子层叶子、瓦片、石块参数化几何 噪声变形对象层树、房子、山体原子按结构规则组合场景层森林、村落、地块对象按分布规则摆放世界层连续大地图场景按区块拼接与LOD切分每一层都有独立的随机种子入口和规则参数。好处是调试非常方便世界层出了毛病不会牵连到原子层你想让整片森林变稀疏只需要调场景层的密度参数原子层的叶子生成函数一个字都不用改。世界层还有个关键概念叫LOD细节层次。远处的树不需要真的生成一千片叶子几个交叉的三角形面片加一张叶子贴图就够了走近了再切换成完整生成。编码生成的优势在这里体现得很充分——你可以按距离阈值动态调用不同层级的生成函数几何数据是实时算出来的天然适合LOD。3. 一个能跑的示例用代码搭出一个“瓦片屋顶”世界3.1 环境准备与技术选型聊完理念落到实处。我这里用纯前端方案Three.js 原生JavaScript为什么选这个组合因为Three.js的几何API足够底层又足够友好可以很方便地操作顶点坐标同时渲染结果在浏览器里就能直接看不用搭复杂的工程环境。你只需要一个HTML文件引入Three.js的CDN再加一个本地的噪声函数库就完事了。整个示例的目标是从一个种子值出发生成一片有起伏的地形在地形上铺满颜色和角度都带微差的瓦片屋顶最后把它们组合成一个小村落。整个过程不加载任何外部模型文件所有几何都是代码生成的。3.2 核心代码从单叶生成到批量铺装先看原子层。下面这段代码是我常用的叶子生成函数核心思路是在PlaneGeometry的平面网格上按照“中段宽、两端窄”的轮廓比例去修改顶点的X坐标再用噪声给Z轴加一点叶脉方向的起伏function mulberry32(seed) { let a seed 0; return function () { a | 0; a (a 0x6D2B79F5) | 0; let t Math.imul(a ^ (a 15), 1 | a); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; } function valueNoise(x, y, seed) { const rand mulberry32(Math.floor(x * 127.1 y * 311.7 seed * 74.7)); return rand() * 2 - 1; } function generateLeaf(seed, length 2, width 1) { const rand mulberry32(seed); const geom new THREE.PlaneGeometry(1, 1, 8, 16); const pos geom.attributes.position; for (let i 0; i pos.count; i) { let x pos.getX(i); let y pos.getY(i); // 把网格坐标归一化到 [-1, 1] 区间 const ny y; // 轮廓控制中间最宽两端收窄 const profile Math.pow(1 - Math.abs(ny), 0.6); const targetX x * profile * width; // 沿叶脉方向的轻微褶皱 const vein valueNoise(x * 6, y * 6, seed) * 0.02 * profile; pos.setX(i, targetX); pos.setZ(i, vein); } geom.computeVertexNormals(); return geom; }这里有两个容易被忽略的细节。第一个是mulberry32这是一个种子随机数生成器同一个种子永远产出同一串随机数这保证了生成结果的可复现性——今天生成的世界明天打开还长一样。第二个是profile这个中间变量它把噪声的强度也约束到叶子轮廓内否则叶尖处也会出现跟叶中部一样大的褶皱看起来会很不自然。再看对象层和场景层。村庄场景里大量重复的对象如果用普通的Mesh每个瓦片都是一个独立Draw Call几百片下来帧率就崩了。正确做法是InstancedMesh把不同的变换矩阵塞进同一个实例化网格里function generateVillage(gridSize, seed) { const baseTile createTileGeometry(); // 瓦片基础几何参数见下文 const count gridSize * gridSize; const mesh new THREE.InstancedMesh(baseTile, null, count); const dummy new THREE.Object3D(); let idx 0; const rand mulberry32(seed); for (let gx 0; gx gridSize; gx) { for (let gz 0; gz gridSize; gz) { dummy.position.set(gx * 1.05, 0, gz * 1.05); dummy.rotation.y (rand() - 0.5) * 0.06; dummy.scale.setScalar(0.9 rand() * 0.2); dummy.updateMatrix(); mesh.setMatrixAt(idx, dummy.matrix); } } mesh.instanceMatrix.needsUpdate true; return mesh; }瓦片颜色的差异化同样通过实例化方案解决——用setColorAt给每个实例单独上色颜色值取自空间坐标的噪声实现“局部一致、全局变化”的老化效果。这就是前面说的规则化思路每块瓦的位置、旋转、缩放、颜色全部由种子和坐标推导出来没有任何一个值是凭空拍的。3.3 世界级扩展区块加载与种子随机小村庄跑通之后扩展到“世界”级别核心问题就变成了区块划分。我的方案是把世界切割成固定大小的区块每个区块的生成完全由(chunkX, chunkZ)哈希出一个子种子function chunkSeed(worldSeed, chunkX, chunkZ) { return (worldSeed chunkX * 374761393 chunkZ * 668265263) 0; }这样做的好处一眼就能看出来区块之间天然解耦玩家走到哪个区块就生成哪个区块内存里只保留当前周围几个区块的数据区块卸载后直接丢弃再回来时用同一个子种子重新生成世界就无缝复原了。而且因为生成逻辑是纯函数式的——输入是坐标和种子输出是几何数据——多线程生成、服务端预生成、存档压缩全都变得异常好做。4. 当AI编程助手走进3D生成vibe coding实战4.1 vibe coding的正确打开方式说实话我一开始对vibe coding是持怀疑态度的。但用AI写了大概两周的生成代码之后我的态度变成了“这东西在特定领域是真能用但用法有讲究”。在3D生成这个领域我发现最高效的vibe coding方式不是“一次性描述整个村庄让AI全写”而是按层级喂小任务。我会先给AI一段“叶子生成函数”的现有代码然后说“帮我加一个叶柄厚度我不想要均匀的靠近叶脉的地方厚一点”。它改完之后我立刻运行看效果不对就截图反馈。这是一种“小步快跑 视觉反馈”的循环每一步的验证成本都很低AI犯的错也集中在局部不会殃及整个项目。反过来如果你一上来就让它“生成一个完整的开放世界”那AI大概率会给你糊一个概念框架回来看起来什么都有跑起来什么都崩。因为世界级生成涉及太多相互约束的规则单个上下文窗口根本装不下。4.2 代码质量会不会崩我的三条防线很多人在热搜里问“AI coding的到来会不会让代码质量下降”。我的回答是如果你只管复制粘贴那一定下降如果你把它当成一个能快速产出草稿的实习生那质量不降反升。区别在于你有没有防线。我的第一条防线是确定性验证。所有生成函数都要求“同种子同输出”所以我写了个断言工具给它两个相同的种子验证两次生成结果的关键顶点坐标完全一致。只要这个测试通过说明AI没有在某个地方偷偷调用了Math.random()或者依赖了非确定性的全局状态。第二条防线是边界值测试。程序化生成最容易翻车的是极端参数噪声函数的坐标到了浮点数精度极限会怎样种子取0或负数会怎样叶子长度设成0.01倍会不会出现退化几何我让AI在写每个函数时顺便写几个边界测试不用多三五个就够大部分转动次数和除零的问题都能当场抓住。第三条防线才是人工评审。AI写的代码我永远只接一半公共工具函数、噪声实现、随机数生成器这类底层代码我坚持自己写场景组装、规则配置这类“业务逻辑”才放心交给AI。因为底层代码一旦出错错误会像滚雪球一样传递到每一片叶子、每一块瓦片上排查成本极高。4.3 coding plan把大任务切给多智能体如果你手头项目更大、团队也愿意折腾可以试试现在很流行的“多智能体AI协助编码”工作流。大致思路是先让一个规划Agent把“生成3D世界”这个大目标拆成可执行的任务清单比如“实现噪声库”“实现L系统分支函数”“实现地形高度混合”“实现村庄分布算法”“实现LOD切换逻辑”然后派不同的开发Agent去认领各自的任务。这个过程中最重要的一件事是定义好Agent之间的接口契约。每个Agent只负责自己模块的输入输出比如地形模块的接口是“输入(x,z)坐标输出高度值”后续的村庄分布模块只能调这个接口不能绕进去改地形内部实现。有了契约多个Agent并行开发才不会互相踩脚。我亲测下来这种工作流对“一叶一瓦到一世界”这种层级分明、接口清晰的项目特别合适。因为程序化生成的模块天然就是按数据流切分的——几何数据从原子层流向对象层再流向场景层每一层都是上一层的消费方。把这种数据流画出来就等于把任务切分方案画出来了Agent们照着执行比让人脑去盯每一行代码高效得多。5. 常见问题与排查实录5.1 瓦片之间的接缝和穿模程序化拼装最常遇见的视觉事故就是“接缝”。瓦片与瓦片之间出现裂缝、重叠、高低错位基本都是两个原因一是原子几何的尺寸基准不一致二是随机旋转幅度给得太大。我的排查方法是先关掉所有随机旋转把所有实例的缩放设成1检查基础铺装是否严丝合缝确认没问题之后再逐步放开旋转和缩放每放开一项就渲一帧看。如果发现旋转后角部顶穿隔壁瓦片就把旋转幅度从正负6度收窄到正负2度同时把瓦片厚度略微增加。记住程序化场景里“局部穿模”是允许的自然界本来就没有绝对严丝合缝但“大面积规则性穿模”一眼就能被玩家识破必须优先消除。5.2 随机数可复现性的翻车现场我遇到过最诡异的一个bug是同一台机器、同一个种子两次运行生成的场景居然不一样。排查到最后发现是某处代码用了Date.now()作为某个噪声函数的偏移量。这种bug特别隐蔽因为它不影响单帧效果只影响世界存档后的还原——玩家第二天上线世界已经不是昨天那个了。给新手一个铁律生成管线里禁用一切时间相关、全局状态相关的来源。所有随机来源必须显式地取种子值而且种子值必须从统一的种子体系里派生。我习惯把世界种子、区块种子、对象种子、原子种子四级分开任何一级的计算都不允许使用上一级的随机数流只许用派生种子。这样任何一个层级的可复现性问题都能在五分钟内定位到是哪一级断了链。5.3 性能卡顿Draw Call和几何细分度场景一复杂就掉帧这个问题在程序化生成里尤其突出因为你的几何是运行时算出来的。解决顺序我建议按性价比来先从Draw Call入手把所有同材质对象换成InstancedMesh这一招通常能省出一大半性能再检查几何细分度叶子网格的段数从32x64改到8x16视觉差异很小但顶点数差了整整16倍。最后才是上LOD和视锥剔除。很多人一上来就搞八叉树其实对中小型场景完全没必要。先量化一下你的场景有多少个实例每个实例多少个三角形顶点数到几百万了如果数据量没到千万级三角形的量级用InstancedMesh加手动的距离切换LOD就绰绰有余了。我个人做了这么多程序化生成项目最深的一个体会是这套东西真正的门槛从来不是图形学多高深而是你能不能把观察到的世界规律抽象成一层套一层、且互相不纠缠的规则。从一叶一瓦开始写起每次只解决一个层级的问题最终拼出来的世界才会既丰富又稳定。编程式的生成本质上就是一种耐心的递归你把一片叶子写好把它交给下一层规则去组合剩下的世界就交给时间和种子去发酵了。