
0. 先说清楚我为什么放弃了让 AI 直接吐模型AI 直接生成3D模型这件事我从最开始的兴奋到后来的冷静前后大概折腾了小半年。最早的体验确实震撼敲一句一把做旧的木质餐椅北欧风格几十秒后一个带贴图的.glb就躺在下载目录里了。可真正把它拖进项目问题一个接一个冒出来——表面全是三角碎面想在椅背上加个雕花、把扶手往外挪两厘米完全无从下手文件动辄几十上百兆移动端加载进度条能磨死人想让同一把椅子换个高矮胖瘦只能重新生成一次而返回来的又是一个长得差不多的陌生椅子尺寸、比例、朝向全都对不上。后来我换了个思路效果反而好得多不让 AI 直接吐 3D 模型让它写代码。具体说就是让 AI 输出 Three.js、Blender Python、OpenSCAD 这类程序化建模代码由代码在浏览器或本地渲染引擎里现场长出模型来。这一换整个工作流就顺了模型体积从几十兆文件变成了几 KB 脚本改动从重新生成变成了调一个参数交付物从一个死模型变成了一套能跑、能调、能接进项目的生成器。这篇是上篇主要聊思路、选型和落地实操。它会讲清楚三件事AI 直接生成网格为什么在工程场景里经常不顶用代码生成 3D 的技术栈该怎么挑以及怎么给 AI 喂提示词、怎么验收它写出来的代码。适合已经玩过文生 3D、但发现落地就翻车的同学也适合想把 3D 能力接进自己网站或工具的开发者。纯小白也看得懂我会把坐标系、材质这些基础概念用生活化的方式带过去。1. 直接生成网格 vs 代码生成一次彻底的思路切换1.1 AI 直接生成的网格到底坏在哪先说结论AI 直接生成的网格模型问题不在好不好看而在能不能用。第一是拓扑不可控。目前的文生 3D 大多走的是隐式场或稀疏体素重建的路线输出的是等值面提取出来的三角网格。这类网格的特点是面数极高、分布极不均匀——平坦的墙面上可能挤着几千个三角形而真正的细节转折处反而只有零星几个面。你想在软件里选一条边做倒角根本选不出来因为那里压根就没有规则的边。第二是尺度全凭运气。同一个提示词连续生成三次得到的三把椅子高度可能分别是 0.83 米、1.12 米、0.94 米朝向也是随机的。放到场景里你得一个个手动缩放、旋转、摆正。批量处理几十个模型时这部分人力成本比建模本身还高。第三是不可参数化。这是最致命的。产品设计的本质是改客户说柜子再高十公分门板缝隙要三毫米好的资产应该是参数一动、模型自动重算。而一个烘焙好的网格只能回到建模软件里手动拉伸顶点改完还要重新烘焙、重新导出。第四是文件体积和运行开销。高面数网格意味着几万到几十万的顶点如果每个实例都独立上传显存页面上的 drawcall 会迅速把帧率拖垮。我在一个展厅项目里算过账20 个 AI 生成的高模椅子摆进场景帧率从 60 掉到 22。注意我说的不是文生 3D 没用。做概念探索、做氛围图、做一次性的展示道具它非常高效。问题在于你把它当生产资产来用——这是两件完全不同的事。1.2 代码生成 3D 的本质优势模型从名词变成了动词用代码描述 3D和用网格描述 3D差别大到像两种物种。网格描述的是结果这一堆三角形在哪里、朝哪边。代码描述的是过程圆柱的直径是d高度是h沿着圆周每隔360/n度挖一个直径为holeD的孔。前者是名词后者是动词。而动词天然携带了变化的能力。我把它总结成四条实打实的好处参数化 可复现把参数外置成一个配置对象改一个数字全场景重算。配合固定随机种子同一个配置在任何机器上跑出来的结果完全一致——这对团队协作和版本管理是决定性的。体积极小一个生成一片街区的脚本大概 8 KB几分钟就能写完。同样效果的.glb打包出来至少 5 MB 起。加载速度、CDN 成本、首屏体验全都不同。可版本控制代码能 diff、能 code review、能回滚。你上周把层高从 3.0 改成 3.2这次想退回来git revert一行搞定。而模型文件 v1 / v2 / v2_最终版 / v2_最终版改这类地狱相信大家都经历过。天然可交互代码生成的场景每个零件都是独立对象鼠标悬停高亮、点击弹信息、拖动改参数都是在已有数据结构上顺手加的几行逻辑不需要额外的碰撞体生成和射线检测预处理。1.3 四条主流路线怎么选顺着让 AI 写代码这个思路往下走具体能走的路线不止一条。我把最常用的四条列出来对比你可以按自己的交付形态对号入座。路线运行环境输出物适合场景学习成本参数化能力Three.js / 前端 3D 库浏览器实时渲染场景 / 可导出 GLB官网交互、配置器、数据可视化、轻量展厅中强Blender Python 脚本本地 / 无头服务器GLB、FBX、OBJ 等离线资产批量出资产、渲染管线、影视游戏预处理中高很强OpenSCAD本地 / 命令行STL、3MF机械零件、结构件、3D 打印件低极强CAD 二次开发接口本地工程图 实体模型工业设计、模具、装配体高极强选择逻辑其实很简单交付物是网页里能转能点的选 Three.js交付物是要导进别的软件继续用的文件选 Blender Python交付物是要打出来或者要加工的实体选 OpenSCAD。我个人的主力组合是 Three.js 打前端、Blender Python 兜后端。前端负责交互和即时预览用户点导出后端用无头 Blender 把这套参数重新跑一遍吐出一个干净的低模 GLB。两边共用同一份参数 JSON这就保证了看到的和导出的是同一个东西。这个架构我用了两年多基本没翻过车。2. 让 AI 写 3D 代码前先把约束喂给它2.1 为什么必须先锁死技术栈和运行环境有个现象很多人应该遇到过让 AI 写个 3D 场景它给出的代码里混着three.min.js的旧版 CDN 引用、THREE.Geometry这种早被删除的 API、还有一堆没头没尾的function init()。跑起来一片红。根本原因不是 AI 不懂 3D而是你没告诉它在哪个世界里说话。Three.js 的 API 在 r125 前后有一次大改Geometry被BufferGeometry取代outputEncoding后来又被outputColorSpace取代。AI 的训练语料横跨了所有版本你不限定版本它就会把不同年代的写法缝在一起。所以第一条铁律在提示词里把运行环境和版本号写死越具体越好。运行环境浏览器 ES Module / Node.js / Blender 4.x / OpenSCAD 2021.01库与版本three0.160.0通过 importmap 加载模块系统ESM不用 CommonJS不用全局THREE禁止项不许用 CDN 老式script src不许用已废弃 API不许引入除 three 之外的第三方库别嫌啰嗦这五行能省掉你后面两小时 debugging。2.2 单位、坐标系、命名规范三条最容易翻车的约定接下来是三个必须提前约定的基础设定AI 默认是不管的但错了会很难受。单位统一取米。3D 世界里1 个单位等于多少没有物理意义但一旦和现实脱钩光照强度、相机近远裁剪面、移动速度这些参数就全乱套。我在提示词里固定写1 个世界单位 1 米楼层层高 3.2 米门宽 0.9 米人眼高度 1.6 米。这样相机默认位置放(8, 6, 12)就很自然光照也不用反复试。坐标系要专门点出来。Three.js 是 Y 轴向上Blender 是 Z 轴向上OpenSCAD 也是 Z 轴向上。同一套参数在两边跑如果不做转换楼就会躺在地上。我的做法是前端用 Y-up导出给 Blender 之前在代码里做一次(x, y, z) - (x, -z, y)的矩阵变换。这一步我写了个 6 行的工具函数专门处理。命名规范决定了后期能不能维护。让 AI 生成对象时一律带上语义化名字building_07_floor_03、window_row_2_col_5而不是mesh1、box_02。看着不起眼但当你想把三号楼以上的所有窗户换成暖色玻璃时一个name.startsWith(building_03)就能搞定否则只能靠遍历坐标硬猜。实操心得这三条我直接固化成了一个系统前缀每次开新项目粘贴到提示词最前面。相当于给 AI 戴上一副眼镜后面它说的话就不会跑偏。2.3 把造型翻译成参数 函数的思路这一步是整个方法论的核心也是最需要动脑子的地方。用户的需求通常是形容词一个有点未来感的城市街区。而代码需要的是名词加数字。中间这个翻译我总结了一个三步拆解法。第一步找重复单元。街区里最重复的是楼楼里最重复的是窗窗里最重复的是窗框 玻璃。把重复单元识别出来代码量能降一个数量级。第二步把这些单元抽成函数。比如createBuilding({ width, depth, floors, palette })返回一个THREE.Group。函数内部怎么实现是 AI 的活输入输出由你定。第三步把变化的部分抽成配置。楼层数、楼间距、颜色、随机种子全部放进一个CONFIG对象。写死的东西越少后期越好改。这三步做完你给 AI 的输入就从帮我做个未来城市变成了下面这种可执行规格生成函数 createBlock(CONFIG)要求 - 输入blockCount楼栋数、minFloors / maxFloors层数范围、 floorHeight层高米、buildingWidth / buildingDepth楼栋底面尺寸米、 gap楼间距米、seed随机种子 - 输出一个 THREE.Group内部每栋楼是一个独立的 Group命名 building_XX - 每栋楼按层生成窗户窗户用 InstancedMesh 实现整片街区共用一个实例网格 - 使用传入的 seed 生成确定性的伪随机序列禁止直接调用 Math.random() - 所有参数从 CONFIG 读取函数体内不得出现硬编码的尺寸数字看到没这时候 AI 就不会天马行空了因为它被框在一个明确的输入输出契约里。这也是让它写代码比让它吐模型更好驾驭的根本原因——代码有接口接口就是约束。3. 实操从一段提示词到一个能转能点的 3D 场景3.1 场景骨架渲染器、相机、光照的固定写法先搭在地基。下面是 Three.js 场景的最小可用骨架我把它当作项目的起点模板每次都从这儿开始改。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title参数化街区/title style html, body { margin: 0; height: 100%; overflow: hidden; background: #10131a; } #app { display: block; width: 100vw; height: 100vh; } /style /head body canvas idapp/canvas script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js, three/addons/: https://unpkg.com/three0.160.0/examples/jsm/ } } /script script typemodule import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; const canvas document.getElementById(app); // 1. 渲染器 const renderer new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled true; renderer.shadowMap.type THREE.PCFSoftShadowMap; // 2. 场景与雾效 const scene new THREE.Scene(); scene.background new THREE.Color(#10131a); scene.fog new THREE.Fog(#10131a, 60, 220); // 3. 相机1 单位 1 米机位高度约等于人眼 const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(60, 45, 80); // 4. 控制器 const controls new OrbitControls(camera, canvas); controls.target.set(0, 10, 0); controls.enableDamping true; controls.maxPolarAngle Math.PI / 2.05; controls.update(); // 5. 光照半球光打底 平行光做太阳 const hemi new THREE.HemisphereLight(#bcd4ff, #3a3a44, 1.2); scene.add(hemi); const sun new THREE.DirectionalLight(#fff2d8, 2.0); sun.position.set(80, 120, 60); sun.castShadow true; sun.shadow.mapSize.set(2048, 2048); const d 120; Object.assign(sun.shadow.camera, { left: -d, right: d, top: d, bottom: -d, near: 1, far: 400 }); scene.add(sun); // 6. 自适应 window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // 7. 渲染循环 renderer.setAnimationLoop(() { controls.update(); renderer.render(scene, camera); }); /script /body /html有几个细节值得单独说。setPixelRatio(Math.min(window.devicePixelRatio, 2))这行很重要。高分屏上devicePixelRatio可能是 3 甚至 4意味着渲染分辨率翻三倍像素量变成九倍。限制到 2 之后画面肉眼几乎看不出差别但帧率能稳住。相机的近远裁剪面也别乱填。近裁剪面填0.1、远裁剪面填10000000深度精度会被拉爆远处的模型会出现明显的 Z-fighting 闪烁。按场景尺度填20 到 30 米见方的场景0.1 / 1000就很舒服。平行光的阴影相机范围一定要手动设。默认范围很小你会发现只有场景中心一小块有影子。上面那段Object.assign就是把阴影范围撑到 ±120 米。3.2 用几何体组合替代雕塑式建模骨架搭好之后才是真正的建模环节。这里要扭转一个观念代码生成 3D 不是雕刻而是搭积木。一把椅子 一个坐面长方体 四条腿圆柱 一块靠背长方体。一辆车 车身长方体 四个轮胎圆柱 挡风玻璃旋转过的平面。一栋楼 主体长方体 一排窗户小方块 屋顶一个稍大的薄板。几乎所有工业制品和建筑都能用长方体、圆柱、球、圆锥、圆环这五种基本几何体的组合逼近到七八分像。这个思路特别适合交给 AI因为组合逻辑清晰、参数可枚举、验证也容易。我给 AI 的提示词里通常会写所有形体必须由 BoxGeometry / CylinderGeometry / SphereGeometry / ConeGeometry / TorusGeometry 组合而成禁止使用 LatheGeometry 或 ExtrudeGeometry 等难以参数化的几何体。每个零件尺寸必须是 CONFIG 中某个参数的直接表达式不允许出现魔法数字。现在写一个参数化建筑生成器。为了方便复现先用一个带种子的伪随机函数代替Math.random()// 确定性伪随机同一个种子永远得到同一串数字 function mulberry32(seed) { let s seed 0; return function () { s (s 0x6D2B79F5) 0; let t Math.imul(s ^ (s 15), 1 | s); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; } const CONFIG { seed: 20240517, blockCount: 14, minFloors: 4, maxFloors: 16, floorHeight: 3.2, buildingWidth: 12, buildingDepth: 10, gap: 7, windowWidth: 1.6, windowHeight: 1.5, palette: [#cbb8a9, #8d99ae, #b7b8a9, #a26769] };然后是建筑本体。注意每栋楼我都没有用硬编码尺寸全部从CONFIG推导function createBuilding(rand, config) { const group new THREE.Group(); const floors Math.floor( config.minFloors rand() * (config.maxFloors - config.minFloors 1) ); const height floors * config.floorHeight; const color config.palette[Math.floor(rand() * config.palette.length)]; const shellMat new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.05 }); const shell new THREE.Mesh( new THREE.BoxGeometry(config.buildingWidth, height, config.buildingDepth), shellMat ); shell.position.y height / 2; // 底面贴地 shell.castShadow true; shell.receiveShadow true; group.add(shell); // 屋顶压顶比主体稍微外扩一点视觉上收边 const cap new THREE.Mesh( new THREE.BoxGeometry( config.buildingWidth 0.6, 0.35, config.buildingDepth 0.6 ), shellMat ); cap.position.y height 0.175; cap.castShadow true; group.add(cap); group.userData { floors, height, color }; return group; }这里有两个容易忽略的点。shell.position.y height / 2是把长方体的中心上移半个高度让底面正好落在y 0的地面上——BoxGeometry默认以几何中心为原点不这么做楼会陷进地里一半。屋顶压顶往外扩 0.6 米则是纯粹的视觉技巧加一条比主体略宽的薄板边缘会形成一条清晰的分界线比纯方块看起来像建筑得多。3.3 窗户一个 InstancedMesh 省下 90% 的性能如果每扇窗都建一个Mesh14 栋楼、平均 10 层、每层 8 扇那就是 1120 个 drawcall帧率必然崩。正确做法是InstancedMesh——一份几何体、一份材质通过矩阵描述上千个实例的位置。function collectWindowMatrices(rand, config) { const matrices []; const dummy new THREE.Object3D(); const cols 3; // 每个立面 3 列窗 const colStep config.buildingWidth / (cols 1); for (let b 0; b config.blockCount; b) { // 与上一节相同的推导逻辑保证位置一致 const floors Math.floor( config.minFloors rand() * (config.maxFloors - config.minFloors 1) ); if (floors 1) continue; for (let f 1; f floors; f) { // 首层不放窗 const y f * config.floorHeight config.windowHeight / 2; for (let c 1; c cols; c) { const x -config.buildingWidth / 2 c * colStep; // 前立面 dummy.position.set(x, y, config.buildingDepth / 2 0.02); dummy.rotation.set(0, 0, 0); dummy.updateMatrix(); matrices.push(dummy.matrix.clone()); // 后立面朝向翻转 180 度 dummy.position.set(x, y, -config.buildingDepth / 2 - 0.02); dummy.rotation.set(0, Math.PI, 0); dummy.updateMatrix(); matrices.push(dummy.matrix.clone()); } } } return matrices; }拿到矩阵数组之后一次性建出实例网格function createWindowMesh(matrices) { const geo new THREE.BoxGeometry(CONFIG.windowWidth, CONFIG.windowHeight, 0.1); const mat new THREE.MeshStandardMaterial({ color: #1b2a3a, roughness: 0.25, metalness: 0.6 }); const mesh new THREE.InstancedMesh(geo, mat, matrices.length); matrices.forEach((m, i) mesh.setMatrixAt(i, m)); mesh.instanceMatrix.needsUpdate true; return mesh; }一栋楼的窗户实例全部塞进一个InstancedMeshdrawcall 从上千降到 1。这就是代码生成相比堆模型的又一个隐形优势你随时知道场景里有什么能针对性地做批处理优化。而一个从外部导入的高模你连它有几个 mesh 都得先遍历一遍。3.4 让场景活起来交互与动画的接入点静态场景其实没什么意思代码生成真正的价值在于可交互。而这件事之所以简单是因为每个对象都还带着语义化的名字和userData。加上鼠标悬停高亮也就是十几行const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); canvas.addEventListener(pointermove, (e) { pointer.x (e.clientX / window.innerWidth) * 2 - 1; pointer.y -(e.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObjects(scene.children, true); const first hits.find(h h.object.isMesh); buildings.forEach(b { const active first first.object.parent b; b.userData.active active; }); });然后在渲染循环里根据active插值调整高度或者缩放就有一个很轻的呼吸反馈。我一般还会做一个参数面板用原生input typerange绑到CONFIG上改一个值就重新生成整片街区。用户拖动层数滑块看着楼一栋栋长起来的那几秒往往比最终画面更让人兴奋。这里有个小提醒重建场景时记得释放旧的几何体和材质。function disposeTree(obj) { obj.traverse((child) { if (child.isMesh || child.isInstancedMesh) { child.geometry.dispose(); const mats Array.isArray(child.material) ? child.material : [child.material]; mats.forEach(m m.dispose()); } }); }不写这个用户拖 200 次滑块显存就会被 200 套废弃几何体撑爆浏览器直接黑屏。这个坑我踩过排查了一个下午才定位到。3.5 后端出资产Blender Python 与 OpenSCAD 的两个短例前端看爽了客户说给我导出模型文件。这时候就该后端上场了。Blender 支持无头模式运行 Python 脚本一个命令就能批量出资产blender --background --python build_city.py -- --seed 20240517 --out ./out/city.glb对应的脚本骨架大概长这样import bpy import sys import math argv sys.argv[sys.argv.index(--) 1:] if -- in sys.argv else [] FLOOR_HEIGHT 3.2 WIDTH 12.0 DEPTH 10.0 def clear_scene(): bpy.ops.object.select_all(actionSELECT) bpy.ops.object.delete(use_globalFalse) def make_building(index, x, y, floors): height floors * FLOOR_HEIGHT # Blender 是 Z 轴向上所以高度写在 z 分量 bpy.ops.mesh.primitive_cube_add(size1, location(x, y, height / 2)) obj bpy.context.active_object obj.name fbuilding_{index:02d} obj.scale (WIDTH, DEPTH, height) bpy.ops.object.transform_apply(locationFalse, rotationFalse, scaleTrue) return obj def main(): clear_scene() for i in range(14): make_building(i, i * (WIDTH 7), 0, 4 (i % 9)) bpy.ops.export_scene.gltf( filepath./out/city.glb, export_formatGLB, export_applyTrue ) if __name__ __main__: main()注意bpy.ops.object.transform_apply那一步。Blender 里scale只是对象的变换属性几何体本身没变。不 apply 就导出很多引擎读进去会得到一个单位立方体加一个缩放矩阵表现不一致。这是个非常经典的坑。如果交付物是 3D 打印件或者机械件OpenSCAD 更顺手语法几乎是声明式建模的教科书// 参数化法兰盘 plate_d 80; // 盘外径 thickness 6; // 板厚 hole_d 6; // 螺栓孔直径 hole_pcd 60; // 孔中心圆直径 hole_n 6; // 孔数量 difference() { cylinder(d plate_d, h thickness, $fn 96); for (i [0 : hole_n - 1]) { rotate([0, 0, i * 360 / hole_n]) translate([hole_pcd / 2, 0, -1]) cylinder(d hole_d, h thickness 2, $fn 32); } // 中心通孔 translate([0, 0, -1]) cylinder(d 30, h thickness 2, $fn 64); }改hole_n 8八个孔立刻重排角度自动是 45 度。这就是参数化建模的爽点几何关系被写进了代码里而不是靠你手动数角度。4. AI 写出来的 3D 代码怎么验收和迭代4.1 视觉验收一张十分钟就能过完的检查清单AI 写的代码能跑起来不代表它写对了。我固定了一套验收流程基本十分钟内能过完。检查项具体做法常见异常比例是否合理放一个 1.6 米高的参考人形方块进去楼矮得像积木门高过人是否贴地关闭阴影从正侧方平视模型半陷进地面或悬空光照是否正常只留环境光看一次只留平行光看一次全黑、全白、模型背光面纯黑阴影是否溢出把相机拉远到 200 米外远处出现条带状阴影破面实例是否正确打开开发者工具的渲染统计drawcall 数量远超预期交互是否穿透鼠标划过密集区域高亮错位、hover 到背面物体释放是否干净反复重建场景 30 次显存持续上涨不回落到基线其中参考人形这一条我强烈建议加上。用一个BoxGeometry(0.5, 1.6, 0.3)的方块站在场景里任何比例失调都会瞬间暴露。这比你在脑子里算尺寸快得多。4.2 代码验收比画面更重要的三件事画面对了只是及格代码本身的质量决定这个项目能不能往下走。第一参数有没有外置。我会全文件搜索数字。如果createBuilding里出现12、3.2这种裸数字说明 AI 把参数写死了。我会直接回一句把所有尺寸字面量提取到 CONFIG 对象函数体内只允许出现参数名、0、1、2 这类结构性数字。第二随机是不是确定性的。搜索Math.random。只要出现就要求替换成传入的种子函数。理由前面说过不可复现的生成器等于没有生成器。第三结构是不是扁平。AI 特别喜欢写一个 500 行的init()把所有东西塞在一起。我会要求它拆成createRenderer、createScene、createBuilding、createWindows、bindEvents、animate这几个独立函数。拆分之后你想替换光照方案只需要改一个函数不会牵一发动全身。顺带说一句很多 AI 在生成较长的 Three.js 代码时会把renderer.setAnimationLoop和requestAnimationFrame混用导致出现两套渲染循环、帧率翻倍。遇到画面莫名很快或者动画速度不对先搜这两个词。4.3 迭代提示词怎么让 AI 越改越准跟 AI 迭代代码和跟同事做 code review 的思路是一样的说清楚哪里、什么现象、什么期望别只说不对。我常用的三种句式现象 期望第 3 章里的建筑底部陷进了地面大约 1.6 米期望底面严格贴合 y 0。给出修改后的完整代码。约束 范围只允许修改createWindowMesh这个函数其他函数保持原样。反例 替换不要用BoxGeometry(1,1,1)配合scale来缩放会造成法线错误请直接传入真实尺寸。第三种尤其好用。因为 AI 很容易走先建单位立方体、再靠 scale 缩放的捷径但这会带来两个问题缩放不均匀时光照和法线会算错如果后面要做 CSG 布尔运算或者加倒角非单位缩放的对象很难处理。直接给真实尺寸是最省事的写法。5. 常见坑与排查速查表5.1 黑屏三连问从报错到渲染的定位顺序黑屏是最常见的求助。别看它吓人排查顺序非常固定按这个顺序走九成问题三分钟内能定位。先看控制台有没有报错。有红色报错就别急着调参数先解决报错。最常见的两类Failed to resolve module specifier threeimportmap 写错或者路径拼错、xxx is not a constructor用了已废弃的 API。再看画布尺寸是不是 0。如果 canvas 的父容器高度是 auto 且没有内容clientHeight就是 0渲染器怎么画都是空白。给容器显式设height: 100vh或者用ResizeObserver动态测量。最后看相机和物体有没有同框。打一句console.log(camera.position, scene.children.length)。相机在原点、目标也在原点lookAt之后方向未定义画面自然是一片黑。或者物体在y 500相机在y 5也看不到。临时把相机拉到(0, 200, 200)广角俯视一眼能立刻判断是没渲染还是没看见。排除了这三条还黑就查光照。用了MeshStandardMaterial但场景里只有环境光强度为默认值物体是可以看见的但如果只用了DirectionalLight且方向平行于视线物体背面就完全是黑的。加一个HemisphereLight打底基本不会全黑。5.2 坐标系和尺度的那些看起来对其实不对这一类问题最磨人因为画面是正常的只是感觉不对。现象根因解决模型躺在地上Blender 用 Z-upThree.js 用 Y-up 直接导入导入时绕 X 轴旋转 -90 度或导出前做轴变换模型大小差 100 倍一边按米建模另一边按厘米在导入层统一乘一个scale常量并写进注释旋转轴心不对绕了几何中心而不是底面把几何体translate半高或用 Group 包裹后旋转边缘闪烁Z-fighting两个共面物体深度值相同把其中一个偏移 0.01或调大近裁剪面阴影有一圈锯齿硬边阴影贴图分辨率不足或偏差过大提高mapSize调shadow.bias到 -0.0005 附近关于 Z-fighting我有一段经验值得说。做双层玻璃或者地面跑道标线时两条共面的面几乎必然闪烁。很多人第一反应是把偏移量设成 0.001结果还是闪。原因是深度缓冲的精度在远处非常低远处的 0.001 根本区分不出来。正确做法是让偏移量随距离放大或者干脆把标线抬高 0.02 到 0.05 米——在这个尺度上肉眼看不出来但深度值差得足够明显。5.3 AI 编造 API 的识别与纠正AI 编造 API 是 3D 领域的高发问题因为 Three.js 迭代快、社区文章多、版本混杂。几个我遇到过的高频幻觉new THREE.Geometry()—— r125 之后已移除必须用BufferGeometryrenderer.outputEncoding THREE.sRGBEncoding—— 新版改成了renderer.outputColorSpace THREE.SRGBColorSpaceTHREE.MeshPhysicalMaterial({ reflectivity: 1 })—— 参数名不对实际是reflectivity存在于MeshStandardMaterial物理材质该用clearcoat、transmissionscene.autoUpdate false之后忘了updateMatrixWorld()—— 不报错但物体位置不动非常隐蔽识别方法很简单凡是你不认识的 API先去官方文档搜一次。我个人的习惯是把 Three.js 文档加在书签栏第一位写代码的时候随手查。宁可多花十秒确认也不要为了省事在控制台里反复试错。还有一个效率技巧。当你怀疑某个 API 用法不对时直接问 AI请确认xxx在 three0.160.0 中是否存在如果不存在给出等价的正确写法。 明确要求它如果不确定就说不确定能过滤掉相当一部分编造内容。5.4 性能优化的临门一脚最后一个板块说说性能。代码生成虽然结构清晰但堆起量来照样能把页面拖死。我一般按下面的顺序做优化从性价比最高的开始。合并几何体。同材质的静态物体用BufferGeometryUtils.mergeGeometries合并成一个 mesh。一栋楼的 20 个零件合并后是一个对象drawcall 直接除以 20。import { mergeGeometries } from three/addons/utils/BufferGeometryUtils.js; const merged mergeGeometries(geometries, false); const mesh new THREE.Mesh(merged, sharedMaterial);用实例化处理重复。窗户、栏杆、路灯、树木。原则是只要同一份几何体在场景里出现超过 20 次就应该考虑InstancedMesh。把不可见的关掉。相机视野之外的楼栋直接visible false。这个最粗暴也最有效配合分块chunk管理一屏之外的东西全部不入渲染队列。降级材质。远处的物体换成MeshLambertMaterial甚至MeshBasicMaterial省掉 PBR 计算。控制阴影投射范围。只让主要建筑castShadow小零件全部关掉。阴影贴图的渲染成本往往比主渲染还高而在这种街景里少几个小物体的阴影根本看不出来。注意事项这五条优化的顺序不能乱。先做合并和实例化如果还卡再做剔除和降级。直接从降级开始你会发现画质掉了一大截帧率却没涨多少因为瓶颈其实在 drawcall 上。6. 上篇收个尾我踩过的那几个坑我最早做参数化街区的时候犯过一个特别典型的错误把参数写在了两个地方。前端 Three.js 的CONFIG和后端 Blender 脚本的常量各写了一套。结果有一次前端把层高从 3.2 改成 3.6用来看效果导出的时候忘了同步客户拿到文件后说跟你在网页上看到的不一样。那次之后我所有项目的第一件事就是把参数抽成一份 JSON前后端都从这份 JSON 读。另一个坑是关于随机种子的。我一度觉得每次刷新楼栋排布都不一样挺有意思直到做交付验收客户说上一版那个转角有高楼的布局比现在好。我打开历史记录只有一个city.html没有任何可复现的参数。那次之后种子必须显式写在 URL 参数或者配置文件里成了我给自己立的规矩。第三个坑最 trivial但最浪费时间忘了dispose。参数面板调了两百多次之后页面开始卡顿我以为是渲染问题去优化几何体、去调光照折腾半天没找到原因。后来打开任务管理器发现浏览器进程吃掉了 2 GB 内存才反应过来是泄漏。现在我的模板里disposeTree是标配每次重建场景之前先跑一遍写完再看代码都安心。上篇到这里基本把思路、选型、前端实操和后端出资产的主干走完了。下篇我打算接着聊更深的部分怎么把 CSG 布尔运算接进这套流程做出带挖孔和倒角的零件怎么用 AI 生成资产变体做批量差异化以及怎么把这套生成器封装成接口让业务系统传参就能拿到模型文件。那部分内容偏工程化坑也更多尤其是布尔运算之后法线和 UV 的处理能聊的东西不少。