做 3D 虚拟城市项目这几年我接到的需求里出现频率最高的一类就是“在 3D 虚拟城市中展示自定义建筑”。听起来像是往场景里丢一个模型的事真干起来你会发现从资产制作、坐标系对齐、落地高程、材质表现到几十栋楼同时在线时的绘制调用预算每一环都能单独卡你两三天。这个需求的本质是城市底座是别人给的、批量生成的、粗糙的而自定义建筑是你自己的、精细的、要重点展示的。两套东西要在一套渲染管线里共存还要点得中、看得清、跑得动。这篇内容适合三类人看正在做智慧园区/规划汇报类可视化项目的前端想把自家设计方案放进虚拟场景做展示的建筑与室内从业者以及刚接手一个“城市单体”混合场景、正在纠结选型和性能预算的开发者。下面我按我实际落地的顺序拆尽量把参数和踩坑点都写清楚。1. 需求拆解为什么“往城市里放一栋楼”没那么简单1.1 从“展示建筑”倒推真实技术链路很多需求文档上只写一句“在虚拟城市中展示自定义建筑”但这句话背后的验收标准往往是三件事第一建筑要出现在正确的位置、正确的高度、正确的朝向上第二建筑要能被单独点击、单独高亮、弹出信息第三切换视角时要流畅不能一转镜头就掉帧。把这三条翻译成技术语言链路其实很长资产制作规范 → 格式导出与压缩 → 场景坐标系统一 → 落位与高程对齐 → 材质与光照适配 → 渲染批次与 LOD 调度 → 拾取与交互 → 数据配置与接口。我见过不少项目一开始跳过“资产规范”和“坐标系”这两步直接拿一个模型往场景里一扔结果模型小了 1000 倍缩成一个点或者整栋楼陷进地里半层高或者朝向偏了 30 度。这类问题在后期排查极其痛苦因为你根本分不清是模型的问题、单位的问题还是坐标换算的问题。我的建议是把这条链路显式写进排期前两步哪怕多花一天后面能省三天。另外一个常被忽略的点是“展示”这个词的歧义。有的甲方说的展示是“静态摆在那里能看”有的是“能飞进去看内部”有的是“能一键切换不同方案做对比”。这三种诉求对资产精度、相机控制、内存占用的要求差了一个量级。所以接到标题之后我第一件事永远是问清楚展示的深度和交互的边界而不是先动手写代码。1.2 三类典型场景选型差异很大同样是“城市自定义建筑”场景不同技术选型几乎是对立的。我把常见的分成三类你可以对号入座。场景类型典型诉求推荐技术路线主要代价汇报演示型固定几条镜头路径画面要好看交互少预烘焙光照 高精度模型 后处理资产体积大不方便实时改数据看板型几十上百栋楼点击看指标配色随数据变轻量化模型 合批 数据驱动材质单体细节弱靠贴图和色彩表达沉浸漫游型第一人称走进去能进楼层、看内部分块加载 LOD 简化碰撞体工程量大内存和加载策略复杂选型错位的代价非常直观。我见过一个数据看板型项目美术按照汇报演示的标准做了每栋楼几十万面的精模结果一屏放三十栋帧率直接掉到个位数。反过来漫游型项目如果只做了外壳用户走到门口发现进不去那就是需求没实现。我的经验是先确定“用户和建筑的距离”。如果最近距离也在五十米开外那外立面细节和贴图分辨率是第一优先级面数可以砍如果用户要贴到墙面上看材质那 PBR 参数和贴图精度必须给足面数反倒不是瓶颈贴图带宽才是。1.3 三个最容易被低估的隐性成本第一个是坐标与高程。虚拟城市底座通常来自公开的地理数据或批量生成的路网它自带一套投影而你的建筑模型是在建模软件里以局部原点为基准做的。两者之间隔着一次换算而这次换算只要差一点点视觉上就是“楼浮在空中”或者“楼陷进地里”。高程数据的分辨率往往只有几十米一格你的建筑占地如果小于这个格子落地位置基本靠插值猜。第二个是材质体系不统一。城市底图为了性能多半用的是顶点色或者很简单的漫反射贴图你的建筑如果用了全套 PBR两者放在一起会有明显的“贴纸感”——建筑太亮太新城市太灰太旧。解决办法通常是在建筑材质上做一点妥协比如降低金属度、环境光遮蔽贴图加重、整体调色往城市的基调靠。第三个是交互拾取的精度。城市底座为了省面数很多构件是合并过的射线打上去拿不到有意义的对象而自定义建筑需要精确到“点中这栋楼”。这就意味着你不能对全场景做统一拾取必须给自定义建筑单独维护一套可拾取对象列表并且做好粗筛。注意这三项成本在需求评审阶段几乎不会被提起但它们决定了项目后期是“调参数”还是“返工”。2. 场景底座与坐标系统让城市和建筑说同一种语言2.1 城市底图的三种来源与各自的坑虚拟城市底座一般有三种来路。第一种是自己用程序生成的规则路网网格划分整齐好处是坐标干净、无限扩展坏处是假汇报时一眼能看出来。第二种是公开的地理数据加工而来建筑体块、道路、水系都有真实感强但数据是别人生产的坐标系、单位、精度你都得先摸清楚。第三种是美术手工搭的低模城市可控性最强适合风格化表达但覆盖面有限。不管哪一种你接手后第一件事应该是拿尺子量在场景里找一条已知宽度的道路量一下它的实际数值是多少。如果一条双向四车道量出来是 3.5那说明场景单位是“十米”或者别的什么如果量出来是 3500那可能是厘米。这个动作看着笨但能一次性排除掉后面 80% 的“模型尺寸不对”的怀疑。第二个要确认的是朝向。有的底座是 Z 轴朝北有的是 -Z 朝北有的是 X 轴朝东。判断方法很简单找一条南北向的主干道看它的走向和哪个轴平行再对照真实地图确认哪头是北。这个结论一定要写进项目文档因为过两周你自己也会忘。第三个要确认的是“地面是不是平的”。很多底座的地形是带起伏的但建筑体块是平的两者之间有缝隙。如果你的自定义建筑直接放在 y0 的平面上在低洼区域就会悬空。正确的做法是把建筑挂到地形高度上具体做法在 2.3 里说。2.2 坐标系、单位与局部原点三个必须钉死的约定Web 端 3D 场景的事实标准是右手坐标系、Y 轴向上、单位米glTF 格式也是这个约定。但建模软件里常见的是 Z 轴向上导出时如果不勾选转换模型进场景就是躺着的。我的做法是在导出环节强制转换而不是在代码里旋转因为代码里旋转会让后续的包围盒计算、射线拾取、LOD 切换阈值全部多一层心智负担。单位问题更隐蔽。Blender 默认单位是米但从 CAD 导出的模型经常是毫米。一个 30 米高的楼如果按毫米导出但被当成米读就是 30000 米高直接飞出天际反过来就是 0.03 米在地上找不着。我的处理方式是在资产入库流水线里加一道自动检查读取模型包围盒的最大边长如果超过 1000 或小于 1就报警并要求人工确认单位。这一步用几行脚本就能做省下的排查时间非常可观。局部原点也就是模型的轴心是第三个约定。建筑模型的轴心应该放在“底面中心”而不是几何中心也不是建模时的世界原点。原因很直接落位的时候你要的是“这栋楼的地面中心落在哪个坐标”如果轴心在几何中心你就得额外减去半个楼高一旦楼层高度改了这个补偿值就错了很容易出现半层楼陷进地里。// 资产入库时的包围盒自检跑在 Node 端读 glb 头部信息 const MAX_REASONABLE 1000; // 米 const MIN_REASONABLE 1; function checkAssetBounds(box) { const size box.getSize(new Vector3()); const maxEdge Math.max(size.x, size.y, size.z); if (maxEdge MAX_REASONABLE || maxEdge MIN_REASONABLE) { console.warn(疑似单位错误: 最长边 ${maxEdge.toFixed(2)}); } // 轴心是否在底面中心底面 y 应接近 0 if (Math.abs(box.min.y) 0.5) { console.warn(轴心不在底面: min.y ${box.min.y.toFixed(2)}); } }2.3 建筑落位从经纬度到场景坐标的换算这一节是整篇里最容易出错的地方我把计算过程写全。假设城市底座的场景原点对应某个经纬度 (lon0, lat0)场景采用 X 轴向东、Z 轴向南也就是 -Z 向北、Y 轴向上。要把一栋楼放到 (lon, lat)换算分三步。第一步算经纬度差对应多少米。纬度方向每度约 111320 米这个数值随纬度变化很小工程上可以直接用。经度方向每度约 111320 × cos(lat0) 米因为经线在高纬度收拢。比如在北纬 40 度经度方向每度只有约 85 公里比纬度方向短了四分之一如果两个方向用同一个系数东西向就会整体拉伸。第二步把米数映射到场景坐标。X 向东为正所以 x (lon - lon0) × 111320 × cos(lat0)。Z 轴向南为正而纬度增大是向北所以 z -(lat - lat0) × 111320。第三步处理高程。建筑底面高度 该点地形高程 - 场景基准高程。地形高程一般从底座的射线检测里拿从建筑落点上方很高的位置往下打一条射线取第一个交点的高度。这里有个细节射线可能先打到别的建筑上所以要过滤掉非地形对象或者干脆在城市底座里单独存一份地形高度场查询函数。const DEG_TO_M 111320; function lngLatToScene(lon, lat, origin) { const x (lon - origin.lon) * DEG_TO_M * Math.cos(origin.lat * Math.PI / 180); const z -(lat - origin.lat) * DEG_TO_M; return { x, z }; } // 落位先算平面坐标再用地形射线求高度 function placeBuilding(model, lon, lat, origin, terrainRaycaster) { const { x, z } lngLatToScene(lon, lat, origin); model.position.set(x, 0, z); terrainRaycaster.set(new Vector3(x, 500, z), new Vector3(0, -1, 0)); const hits terrainRaycaster.intersectObjects(terrainMeshes, false); if (hits.length) { model.position.y hits[0].point.y; } else { model.position.y 0; // 兜底务必打日志 console.warn(落点未命中地形: ${lon}, ${lat}); } }提示如果建筑占地比较大比如一个长 80 米的厂房单点采样会让它一半悬空。这种情况要在占地范围内取多个采样点取最低点作为底面高度宁可略微下沉也不要悬空视觉上更容易接受。朝向也要一起处理。真实建筑通常按正北偏某个角度布置而模型在建模时的正面一般朝向 Z 或 -Z。落位时把模型绕 Y 轴旋转对应角度即可但要注意旋转会改变包围盒如果后续要做视锥剔除包围盒需要重新计算不能复用旋转前的。3. 建筑资产制作与导入让模型进得来、不发胖3.1 建模规范面数、轴心与命名的三条硬约定资产规范这件事写在文档里没人看最好的办法是做成一个检查脚本导出后跑一遍不合格直接报错。硬约定我只留三条。第一条面数上限按用途分档。用于远景展示的单体建筑外立面 3 万到 8 万三角面足够加上屋顶和底部构件不超过 15 万需要近距离看材质的可以到 30 万但要配合 LOD。超过这个量级先想想是不是把窗框、栏杆、空调机位这些小构件都做成独立网格了这些完全可以用贴图加法线表现。第二条轴心在底面中心正面朝 -Z。这个跟前一节的坐标约定对齐别让下游再转一次。第三条命名要能被程序解析。我习惯用“楼层-构件类型-序号”的结构比如F03-Wall-01、F01-Lobby-Glass。这样在代码里做分楼层显示、按构件类型替换材质、单独高亮某个部分都能靠字符串匹配搞定不用额外维护一张映射表。如果做的是多方案对比方案编号也要进命名比如A-F03-Wall-01。还有一点值得强调模型里不要留相机、灯光、空物体、隐藏图层。这些在 glTF 里可能被保留导入后变成一堆看不见的节点既增加遍历开销又会在射线拾取时产生奇怪的结果。导出前清理一遍场景是个不到一分钟但极有价值的习惯。3.2 导出格式与压缩参数glb Draco KTX2格式没什么好纠结的Web 端就用 glTF 的二进制版 glb。它的优势是解析快、体积小、生态完整几乎所有 3D 引擎都能直接读。剩下的问题都是“怎么让它更小”。几何压缩用 Draco。实测下来建筑这类有大量重复平面的模型压缩率在 60% 到 85% 之间效果非常明显。参数上要注意两点quantizePosition默认 14 位对于大型场景够用但如果你的模型很小比如室内构件可以提到 16 位避免顶点抖动法线和 UV 的量化位数不要低于 10否则法线会出现方向偏差表现为光照下表面出现脏斑。贴图压缩用 KTX2 或者直接转 WebP。一张 2048×2048 的 PNG 反照率贴图原始可能有 5 到 8 MB转成 KTX2 的 ETC1S 后通常能压到 500 KB 以内。代价是解码需要额外的转码器文件首次加载会多一点开销而且某些压缩格式对法线贴图不友好法线贴图建议用 UASTC 模式或者干脆保留 PNG/WebP。# 一次到位几何压缩 贴图压缩 无用节点清理 npx gltf-transform optimize building.glb building.opt.glb \ --compress draco \ --texture-compress webp \ --texture-size 2048量化参数不要一次性拉满。我踩过的坑是为了极致压缩把位置量化调到 12 位结果场景里几栋楼在远处出现面片互相穿插的闪烁排查了大半天才定位到是量化精度问题。原则是“先保精度再压体积”压缩率差 5% 无感精度掉一档就是事故。3.3 材质与 PBRWeb 端的水土不服建模软件里的材质预览和浏览器里的实际表现差距往往比想象中大。最常见的三个偏差是“整体发黑”“金属反光像塑料”“玻璃完全看不见”。发黑的根因通常是环境光照缺失。PBR 材质的金属度和粗糙度需要一张环境贴图来提供间接光照如果场景里只放了平行光和环境光金属度大于 0.3 的表面几乎会变成纯黑。解决办法是加一张低分辨率的环境贴图或者给场景加半球光做近似。反过来如果发现模型亮得像贴纸多半是环境贴图强度给太高或者材质的金属度被误设成了 1。玻璃是另一个重灾区。很多导出工具会把玻璃材质带上透射transmission属性浏览器里渲染透射的代价很高而且顺序容易出问题表现为玻璃后面的东西时有时无。我的处理方式是把玻璃降级为“半透明 低不透明度 高环境反射”关掉真实透射视觉上损失很小性能收益很大。还有色彩空间这个老问题。反照率贴图要用 sRGB 色彩空间法线、粗糙度、金属度这些数据贴图必须用线性空间。这两类搞反了前者表现为颜色发灰发暗后者表现为表面凹凸方向诡异。导入环节统一设置一次别指望渲染时补救。3.4 层级结构为交互预留的钩子导入之后的模型层级建议做一次重构而不是直接用导入器给的原结构。目标结构是根节点挂业务数据建筑编号、名称、方案版本下面一层是 LOD 组再下面才是网格。这样切换 LOD、整体显隐、替换材质都能在根节点上一次性操作。层级不宜过深。我见过一个模型嵌套了七八层空节点每层只挂一个子节点结果遍历和拾取的时候要做大量无意义的递归。一般三层以内就够了。另外网格的userData里存一份业务标识拾取到网格后直接读比反过来从场景树往上爬找根节点要快得多也稳得多。提示给每个可交互网格设置userData.pickable true射线检测时先按这个标记过滤能显著减少无效的相交计算。城市底座里成千上万的构件则标记为不可拾取直接从候选集里排除。4. 渲染与性能让几十栋自定义建筑同时在线4.1 先分清合批和实例化别用错这两个概念经常被混着用但适用条件完全不同。实例化适合“几何和材质完全相同、只是位置不同”的对象比如路灯、行道树、栏杆立柱、标准层的窗格。它一次绘制调用就能渲染成百上千个副本收益极大。而自定义建筑每栋的几何都不一样实例化对它无效你真正需要的是合批把材质相同、彼此靠近的网格合并成一个几何体减少绘制调用。实操上我的分层策略是这样的城市底座的重复构件树、灯、车用实例化城市底座的建筑体块按街区合并自定义建筑内部同一材质的构件比如所有玻璃、所有外墙石材在单体范围内合并但保留拾取所需的最小粒度——通常保留到“楼层”这一级就够了不需要精确到每扇窗。这里有个矛盾要处理合并之后单个网格的独立控制能力就没了。所以合并的粒度要跟交互需求对齐。如果需求是“点击整栋楼弹出信息”那整栋楼合并成一个也不影响如果需求是“点击某一层高亮”那合并粒度就得停在楼层。4.2 LOD 与分块加载把不可见的省掉LOD 的核心不是“做三个精度版本”这么简单而是切换阈值怎么定。常见的做法是按屏幕空间误差算物体的包围球投影到屏幕上的像素尺寸小于某个值就切低模。经验值是当物体在屏幕上占据的高度低于 80 像素时切到中模低于 20 像素时切到低模。这个阈值不要写死最好做成可配置因为不同分辨率、不同设备上表现不一样。低模不要简单地用减面工具一减了之那样容易出现破面和法线错乱。更稳的做法是手工做一个“外壳版”把内凹的细节全部去掉窗洞用贴图表现保留建筑的整体轮廓。这个版本的面数通常在原版的 5% 到 10%画面上几乎看不出差别。分块加载解决的是“一开始不用把所有东西都加载进来”的问题。按城市网格划分成若干块相机移动时只加载视野范围内的块远处的块用低精度占位。实现上有两个细节要注意第一加载要有优先级队列视线正前方的块优先背后的块延后第二卸载不要立刻做留一个缓冲区范围避免用户在边界来回移动时反复加载卸载那种体验非常糟糕。4.3 光照方案三个档位按设备切光照是性能开销的大头也是最容易做出“质感”的地方。我一般准备三档方案运行时按设备能力切换。档位方案构成大致开销适用设备高方向光实时阴影 环境贴图 后处理描边高桌面独显、高端平板中方向光 烘焙 AO 环境贴图低分辨率中主流笔记本、中端手机低半球光 假阴影贴片 顶点色低低端手机假阴影这个技巧值得单独说。实时阴影贴图的开销跟场景复杂度、阴影贴图分辨率直接相关一个覆盖几平方公里的城市场景用一张 2048 的阴影贴图远景的阴影会糊成一团。我的做法是近处用实时阴影远处直接在每个建筑底部贴一张渐变透明的圆形或矩形贴片模拟投影。这两种混用的时候交界处的过渡要调一下贴片的不透明度否则会有明显的断层。环境贴图别用高分辨率的 HDR。一张 1024 的 HDR 转成冰岛式RGBE或者用预过滤的立方体贴图视觉效果已经足够。真正影响质感的是环境贴图的对比度而不是分辨率。我通常会把环境贴图的强度压到 0.6 到 0.8让建筑不至于“亮到发光”。4.4 移动端降级实测参数这部分是纯经验不同场景差异大仅供参考但至少给你一个起点。目标是中端手机稳定 30 帧以上。绘制调用控制在 150 以内超过这个数在手机上掉帧非常明显单个建筑模型三角面在 LOD0 不超过 8 万LOD1 不超过 2 万LOD2 不超过 4000同时可见的自定义建筑数量控制在 20 栋以内超过就靠 LOD 和剔除压贴图总量控制在 80 MB 以内单张不超过 2048玻璃和金属的表面贴图可以压到 1024关闭后处理中的泛光和景深描边改成高亮材质替换抗锯齿用 FXAA 而不是 MSAA后者在移动端对带宽压力大设备像素比上限设为 2超过就是浪费肉眼分辨不出来。降级最好做成可探测的启动时读一下渲染器信息如果是移动端或者检测到帧率持续低于 25自动降一档并且给用户一个手动切换的入口。别指望一套参数通吃所有设备。5. 交互闭环点击、高亮与信息面板5.1 射线拾取的精度与性能拾取最容易出的两个问题一个是点不中一个是点中了错的。点不中通常是三个原因网格没有被标记为可拾取、射线检测没有开启递归、或者模型的包围盒算错了比如轴心被移动过但没更新包围盒。点错则多半是因为有透明或不可见的网格挡在前面比如一个很大的地面平面、一个透明的玻璃罩、一个隐藏的辅助体。我的做法是维护一个显式的pickableObjects数组只往里放真正需要交互的根节点射线检测只对这个数组做同时把recursive设为 true 让它往下找子网格。这个做法牺牲了一点内存但换来了完全可控的拾取行为。性能上如果有几十栋楼、每栋几百个网格直接对整个数组做射线检测会很慢。优化分两步先用包围球做粗筛只保留射线方向上的候选再对候选做精确检测。如果精度要求高、面数又大可以引入 BVH 加速结构把每栋楼的三角形组织成层次包围盒单次拾取的时间能降一个数量级。import { Raycaster, Vector2, Sphere, Ray } from three; function pickAt(ndcX, ndcY) { const raycaster new Raycaster(); raycaster.setFromCamera(new Vector2(ndcX, ndcY), camera); // 第一步包围球粗筛排除射线方向之外的建筑 const candidates pickableRoots.filter(root { const sphere root.userData.boundingSphere; return raycaster.ray.intersectsSphere(sphere); }); // 第二步只对候选做精确检测 const hits raycaster.intersectObjects(candidates, true); return hits.find(h h.object.userData.pickable) || null; }包围球要在建筑加载完成、落位和旋转之后再算算完缓存在userData里别每帧重算。5.2 高亮的三种做法与代价高亮这件事简单方案和高级方案的差距很大但未必都需要。第一种是替换材质。把选中建筑的所有网格材质换成一个自发光的高亮材质或者只改emissive属性。优点是开销几乎为零缺点是会丢失原有的材质细节建筑变成一块纯色。适合数据看板类场景。第二种是描边。用后处理的描边通道或者复制一份网格、放大一点点、用背面渲染。前者效果好但后处理通道对透明物体和重叠物体容易出错后者实现简单代价是几何量翻倍对大体量建筑不划算。我用得最多的是“复制外壳 背面渲染 缩放 1.01 倍”这个组合对建筑这种块状物体效果稳定。第三种是叠加光晕或者地面光圈。不改建筑本身的材质在它周围加一层半透明的体积感光效或者在地面画一圈扩散的环。这种做法的视觉引导效果最好几乎不影响主模型的渲染但需要额外做几个贴图。三种可以组合用但别同时上。我的默认配置是鼠标悬停用轻微的自发光点击选中用外壳描边镜头聚焦时叠加地面光圈。5.3 数据驱动的配置结构建筑不能硬编码在代码里必须有配置文件。我一般用一份 JSON 描述所有自定义建筑字段设计成这样{ buildings: [ { id: bldg-001, name: 演示建筑A, model: /assets/buildings/bldg-001.glb, lod: [/assets/buildings/bldg-001-l1.glb, /assets/buildings/bldg-001-l2.glb], position: { lon: 0, lat: 0 }, rotationY: 15, anchor: ground, pickable: true, tags: [展示区, 方案A], popup: { title: 演示建筑A, fields: [建筑面积, 层数, 竣工年份] } } ] }几个字段值得解释。anchor用来指定落位基准ground表示贴合地形absolute表示用固定高度后者适合建在平台上的建筑。tags是给筛选和批量操作用的比如“只看方案A”。popup里的fields应该是数据字段的引用而不是值本身实际数据从业务接口取这样模型配置和业务数据解耦。配置文件的加载要能容错。某一栋楼的模型 404 了不能让整个场景挂掉应该是其余正常加载缺失的那栋在控制台报警并在界面上标红。这种“部分失败”的处理能力在真实项目里比想象中重要。5.4 与外部系统对接的接口设计通常这些建筑数据来自某个业务系统接口设计上有两点要注意。第一坐标不要用像素或者自定义网格编号传直接用经纬度。业务系统里存的往往是地址或者地块编号让后端做一次地理编码转成经纬度前端只负责换算成场景坐标。这样前端和后端的职责清晰换一套城市底座也不用改接口。第二模型地址要带版本号或者哈希。建筑方案会改改完之后要保证用户拿到的是新模型而不是浏览器缓存里的旧模型。文件名加哈希是最省事的做法比如bldg-001.a3f9.glb改一次模型换一个哈希。接口返回的结构建议扁平化不要嵌套太深。前端拿到之后先做一次校验id 是否重复、坐标是否在合理范围内、模型地址是否可达。校验失败的条目单独收集起来展示别默默丢掉。6. 常见问题与排查实录6.1 模型不显示、发黑、闪烁的排查顺序这三类问题占了实际遇到的八成以上我整理了一套固定顺序按这个走基本能在十分钟内定位。模型完全看不到先看控制台有没有 404再看加载器有没有报解析错误然后检查模型的位置和缩放——把相机强行对准模型包围盒中心如果这时候能看到说明是落位或缩放的问题。如果还看不到检查材质是不是全透明或者透明度被设成了 0。最后检查相机的近远裁剪面near设得太小比如 0.0001会让深度精度崩掉远处的东西渲染不出来far设得太小则会把远处的建筑裁掉。发黑分成两种整体均匀发黑通常是光照或色彩空间问题局部斑块状发黑通常是法线方向反了或者法线贴图的空间搞错了。前者检查环境贴图和环境光强度后者把材质临时换成基础材质看是否恢复正常能快速区分是几何问题还是材质问题。闪烁分两种面片互相穿插的闪烁是共面导致的深度冲突用多边形偏移或者把两个面拉开 0.01 米就能解决随相机移动整体抖动的闪烁是深度缓冲精度不足通常是近裁剪面太小或者场景尺度太大导致的把near调到 0.1 到 1 之间往往立竿见影。6.2 性能骤降的定位方法帧率从 60 掉到 20别急着优化代码先确定瓶颈在哪。我的顺序是先看绘制调用数再看三角面数再看贴图内存最后看 CPU 侧的脚本耗时。绘制调用超过预期多半是合并没有生效或者某个材质被意外地拆成了多份。有个隐蔽的情况模型里带了大量只有一两个三角面的小网格每个都是一次绘制调用。用工具统计一下每个材质的网格数量能快速发现这类问题。三角面数正常但帧率还是低看是不是透明物体太多。半透明渲染需要排序而且填充率消耗大一栋全玻璃幕墙的楼在屏幕上占很大面积时开销可能比一栋实体楼高好几倍。解决办法是玻璃用不透明材质加环境反射来伪装或者把玻璃拆成小块减少重叠。还有一种情况是 CPU 侧的问题每帧都在重新计算包围盒、重新排序、重新遍历场景树。这类问题的特征是帧率跟视角有关看向建筑密集区就掉帧。打开性能面板看脚本耗时一眼就能看出来。6.3 常见问题速查表现象最可能的原因处理方式模型缩成一个点单位是毫米被当成米导入时统一乘 0.001 或重新导出模型巨大无比单位是米被当成毫米同上方向相反模型躺在地上导出时未转换 Z 轴向上导出勾选 Y 轴向上或在导入时旋转楼陷进地里半层轴心在几何中心重设轴心到底面中心楼悬空未做地形高度采样加射线检测取最低点玻璃后面的物体时隐时现透射渲染排序问题关掉 transmission改半透明贴图模糊未开各向异性或 mipmap 配置错设置各向异性为 4 到 8点击没反应未加入拾取列表或未开递归维护显式拾取数组recursive 设为 true点击选错对象透明/隐藏网格挡在前面给非交互对象打标记并排除远处建筑抖动深度精度不足调大 near或启用对数深度缓冲加载后一段白屏主线程被模型解析阻塞用异步加载加加载进度提示6.4 几条不在文档里的实操心得第一条永远准备一个“最小可复现场景”。一个地面、一个相机、一栋楼去掉所有业务逻辑。遇到诡异问题时先往这个场景里搬能搬出来复现的就是模型问题搬不出来就是业务代码问题。这个习惯帮我省下的时间比任何调试工具都多。第二条资产流水线要能一键重跑。模型改了、压缩参数调了、坐标改了都要能一条命令重新生成全部产物而不是手工改十个文件。哪怕一开始只有三栋楼也值得花半天搭这条流水线因为项目后期模型数量一定会涨。第三条给每个建筑留一个“调试模式”。开启后显示包围盒、显示轴心、显示落点、显示 LOD 切换阈值、显示拾取命中位置。这些可视化在排查问题时价值极高而且实现成本很低加起来不到一百行代码。第四条验收的时候一定要用真实设备跑一遍。桌面浏览器上顺滑得不行的场景在中端手机上可能只有十几帧。而且手机的屏幕小、触摸精度低点选建筑的容差要比鼠标大不少拾取阈值建议放宽到 20 像素左右。第五条关于“展示”的最后一公里镜头。用户点击一栋楼之后相机怎么过去这个过程决定了整个交互的第一印象。直接瞬移会很突兀匀速直线飞过去又很机械。我一般用带缓入缓出的弧线路径时长控制在 1.2 到 1.8 秒终点位置让建筑占据画面高度的三分之二左右同时稍微偏一点角度别正对着看正对会让建筑显得扁平。这套东西我前后在几个模拟项目里迭代过从最早一栋楼要调一整天到现在一套配置加一份资产规范基本两天能把一个包含十几栋自定义建筑的场景跑起来。真正费时间的从来不是写渲染代码而是那些看不见的约定单位、坐标、轴心、材质、拾取边界。把这些钉死剩下的就是调参数的事了。