1. 先把需求拆开这个虚拟城市到底要什么接到在 3D 虚拟城市中展示自定义建筑这个活儿的时候大多数人第一反应是打开建模软件建个楼然后扔进场景里。我一开始也这么想结果第一次交付就被打回来了——楼是立起来了但它悬在地面上方三米朝向和大门的位置完全对不上旁边的既有建筑是灰白方盒子我这栋是写实贴图的玻璃幕墙视觉上像一张照片贴在了简笔画上。这事儿说到底不是能不能渲染出来的问题而是一个数据对齐 视觉融合 性能可控的组合工程。所谓 3D 虚拟城市本质是一套带地理坐标的三维场景基座里面预先铺好了地形、路网、建筑白模所谓自定义建筑就是用户自己产出、坐标已知、尺寸确定的一栋或一组模型要精准落进这个基座的正确位置上并且看起来像是本来就在那儿。这篇东西写给两类人看一类是刚开始接触三维城市展示、手里有模型但不知道怎么放进场景的前端或可视化工程师另一类是做方案汇报、城市设计、园区规划需要把自己的设计成果塞进城市底图里给别人看的人。你不需要会写复杂的图形学代码但至少要知道经纬度怎么变成场景里的米、模型导出要注意什么、卡顿该往哪儿找原因。我把整条链路拆成七块需求拆解、底图准备、模型交付标准、定位与融合、性能、排查、经验。每一块都是我实际踩过坑之后总结出来的你照着抄能省不少时间。1.1 三种典型诉求对应三条完全不同的技术路线同样是展示自定义建筑背后的目的差别特别大路线选错了后面全是返工。第一种是方案可视化就是给甲方或者评审看的重点是画面好看、材质真实、能转着看、能切换视角场景范围通常只有几百米到一公里性能压力不大可以上 PBR 材质、实时阴影、环境光遮蔽。第二种是空间关系验证比如看这栋楼会不会挡住旁边的采光、退线够不够、视线通廊通不通这种场景范围可能到几公里甚至整个城区重点是几何精度和遮挡关系正确材质反而是次要的白模加简单明暗就够。第三种是批量展示比如一个园区有几十栋楼要一次性放进城市里或者一个地块有多个方案要并排对比这时候重点变成批量定位、批量切换、批量高亮交互逻辑比渲染质量重要得多。我见过最典型的翻车是需求其实是第二种验证空间关系但开发同学按第一种做给整城五十万栋建筑都上了写实材质、开了实时阴影结果在中端笔记本上只有 8 帧。反过来也有需求是给专家评审看的方案汇报结果全程白模无阴影被说看不出体量感。所以开工之前我一定会问三个问题场景范围多大、模型精度要求多少、跑在什么设备上。这三个答案基本就锁死了技术路线。诉求类型典型场景范围建筑精度材质要求性能预算方案可视化300m ~ 1.5km高含细节构件PBR 实时阴影中可接受 30fps空间关系验证2km ~ 全城中体块准确白模 简明清暗高需稳定 50fps 以上批量展示对比单地块 ~ 园区中低可复用纯色 描边中切换需瞬时响应这张表我一般直接贴上白板和需求方对齐完再动手比口头沟通有效得多。1.2 坐标系是第一个坎绕不过去绝大多数模型飘了位置偏了朝向反了的问题根子都在坐标系。地理数据是经纬度WGS84 椭球上的角度三维场景是米制的笛卡尔坐标这中间必须做一次投影。城市尺度上最常用的是墨卡托投影因为它把一个球面局部摊平成平面时形状保持得不错代价是越往高纬度走尺度被放得越大需要按纬度做一次余弦修正。我常用的做法是先选定一个场景原点比如项目所在的那个路口把原点投影到墨卡托平面作为 (0,0)其他所有点都减去原点得到相对米数再直接喂给三维场景。这样做的误差在城市尺度几公里内完全可以接受而且实现极简不需要引入完整的地理投影库。如果你做的是省级范围那就必须换成更严谨的投影方式还得处理椭球高和海拔高的差异。三维场景的坐标轴也得说清楚这是最容易搞错的地方。在 Three.js 这套体系里Y 轴朝上Z 轴朝屏幕外也就是朝南X 轴朝东。而地理上的北在墨卡托平面里是 Y 增大映射到三维场景就变成了 Z 减小。所以换算的时候北向分量要取负号这个负号漏掉整栋楼就跑到镜像位置去了而且在小范围里看起来好像差不多特别容易蒙混过关。1.3 技术选型别一上来就选最重的选型的核心原则是用足够的工具不用最强的工具。如果场景范围只有几公里、不需要地球曲率、不需要流式加载全球数据那一个通用三维渲染库加上自己做的局部坐标系就够了包体小、可控性强、调试方便。如果场景要覆盖整个城市、需要瓦片流式加载、需要和地理信息服务对接那就得上专业的三维地理引擎代价是学习曲线和定制成本都高。我的实际经验是90% 的自定义建筑展示需求用通用三维渲染库都能做完而且做得更轻快。真正需要地理引擎的场景是那种用户要从城市级视角一路缩放进去看某栋楼的因为这时候需要多级瓦片和动态加载自己实现成本太高。模式上我习惯把三维部分做成scripts/viewer.js负责场景、相机、控制器、坐标换算scripts/cityLayer.js负责底图白模scripts/siteLayer.js负责自定义建筑和交互三层各管各的换底图或者换模型都不用改其他部分。2. 城市底图从开放数据到能渲染的白模自定义建筑是主角但没有底图衬托它就是一栋孤零零的盒子看的人根本判断不出它在城市里的位置。底图的作用是提供尺度参照和空间语境——周边的楼有多高、道路多宽、这块地是不是临街这些信息全靠底图。所以我一般会先把底图做扎实再往里放模型。底图数据来源通常有两类一是甲方提供的地形和建筑矢量数据最理想字段齐全、坐标系明确二是公开的社区协作矢量地图数据可以拿到建筑轮廓、道路、水系但高度字段经常缺失或者不准。拿到数据后不能直接渲染必须做清洗否则会出现一堆面积只有几平方米的噪点楼、轮廓互相重叠导致 Z-fighting、以及奇形怪状的多边形把性能拖垮。2.1 轮廓清洗三个必做的过滤第一个过滤是面积阈值我一般把 25 平方米以下的轮廓直接丢掉这些通常是车棚、门卫室、围墙附属结构在远景里看不出来但会白白吃掉三角形的预算。第二个是轮廓简化用道格拉斯-普克之类的算法把点数量降下来容差取 0.5 到 0.8 米肉眼看不出差别顶点数能砍掉一半以上。第三个是重叠处理同一栋楼经常被拆成好几个多边形因为有天井、连廊如果不合并渲染出来就是几个独立的盒子彼此穿插接缝处会出现闪烁。# 轮廓清洗的伪代码按瓦片批量跑 def clean_building_polygon(poly, min_area25.0, tolerance0.6): if poly.area_sqm min_area: return None # 附属结构直接丢 poly poly.buffer(-0.3) # 内缩一点抵消相邻轮廓贴边 poly poly.simplify(tolerance) # 单位是米0.6 比较稳 if poly.area_sqm min_area * 0.6: # 简化后过度坍缩的也丢掉 return None return poly def merge_same_building(polys, gap1.5): # 间距小于 gap 的多边形合并处理天井、连廊 return unary_union(polys).buffer(gap).buffer(-gap)注意buffer这种内缩外扩的写法会把尖角磨圆对体量还原有要求的时候慎用或者换成保留角点的合并算法。清洗完之后一定要目视检查。我习惯抽三个位置密集老城区、新建小区、工业园各截一张俯视图看看有没有缺楼、有没有明显错位。这一步花的十分钟能省掉后面两小时找不到原因的调试。2.2 高度字段缺失时的三级回退策略公开数据的建筑高度字段缺失率很高我遇到过整片区域只有 20% 的楼带高度。直接全部给一个默认高度城市会变成一堵平坦的墙完全没有层次自定义建筑放进去看不出比例关系。所以我做了一个三级回退一级有明确的height字段直接用单位是米。二级有层数字段按每层 3.2 米估算再加 1 米的屋顶结构。住宅楼一般 3.0写字楼 3.6 到 4.2公共建筑挑高更大所以我按建筑类型微调。三级什么都没有按类型给经验值——独立住宅 6 米多层住宅 21 米商业 15 米工业厂房 9 米其他 12 米。function estimateHeightMeters(tags) { if (tags.height) return parseFloat(tags.height); if (tags[building:levels]) { const levels parseFloat(tags[building:levels]); const perLevel levels 8 ? 3.6 : 3.2; // 高层层高通常更大 return levels * perLevel 1.0; } const fallback { house: 6, detached: 6, apartments: 21, residential: 18, commercial: 15, office: 24, retail: 12, industrial: 9, warehouse: 9, school: 14, hospital: 18 }; return fallback[tags.building] ?? 12; }提示估算出来的高度只能用于远景。如果你需要让自定义建筑和旁边某栋楼做精确的体量对比那几栋楼的高度必须现场核实不能靠估算。我在一个采光分析的项目里就因为邻居楼高估了 8 米结论完全反了。还有个小技巧估算完之后跑一次高度分布统计如果某个区域 95% 的楼都在 12 米说明那一片全是回退到默认值了得回去补数据或者换数据源。2.3 白模批量生成实例化是省性能的第一招白模不需要每栋楼一个独立网格那样十万栋楼就是十万次绘制调用浏览器直接跪。正确做法是用一个单位立方体几何体 实例化渲染把所有楼的变换矩阵塞进一个缓冲区一次绘制搞定。关键是这个立方体要把原点放在底面中心这样平移时 y 坐标直接就是地面高度缩放时 y 方向直接就是楼高省掉一堆偏移计算。import * as THREE from three; // 底面中心为原点的单位立方体 const boxGeo new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0); const mat new THREE.MeshStandardMaterial({ color: 0xc3cad3, roughness: 0.92, metalness: 0.0, flatShading: true }); const mesh new THREE.InstancedMesh(boxGeo, mat, buildings.length); mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage); const m new THREE.Matrix4(); const q new THREE.Quaternion(); const pos new THREE.Vector3(); const scl new THREE.Vector3(); buildings.forEach((b, i) { const [x, z] localXY(b.lon, b.lat, ORIGIN); pos.set(x, b.groundY, z); scl.set(b.width, b.height, b.depth); // 朝向角把建筑物轮廓的主方向对齐 q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), b.rotationY); m.compose(pos, q, scl); mesh.setMatrixAt(i, m); }); mesh.instanceMatrix.needsUpdate true; mesh.castShadow true; mesh.receiveShadow true;对于坡屋顶的建筑我一般单独做一套屋顶几何棱柱或者人字顶也是实例化只在近景启用超出一定距离就切换回纯方盒子。这套做法在一个约 12 万栋楼的城区数据上实测桌面端中档独显能稳在 55 到 60 帧内存占用约 380MB。3. 自定义建筑模型交付标准比建模本身更重要这一节是我最想强调的。很多项目延期不是因为渲染不出来而是因为模型交付不规范来回返工五六轮。我做过统计一个自定义建筑从建模到成功入场景平均有 60% 的时间花在对接和修模型上真正渲染只占 20%。所以我现在都会先给建模的同学一份交付清单把这些约定写死后面省事太多。3.1 单位、轴向、原点、朝向四个必须写死的约定单位统一用米。这一点听起来废话但我真遇到过用厘米建模的导进来是一栋 8000 米高的楼把整个场景撑爆了而且相机裁剪面还得跟着改一改远景全糊。Y 轴朝上也是硬要求有些从其他软件导出的模型是 Z 轴朝上进场景就是躺着的得转 90 度。原点位置建议放在建筑占地范围的底面中心这样定位时只需要一个经纬度加一个地面高度三行代码搞定如果原点放在某个角点那你必须额外提供偏移量否则每次调整位置都要试。朝向最容易被忽略。建筑的正立面朝哪个方向直接决定了它在城市里看起来对不对。我要求模型在本地坐标系里默认正立面朝 -Z也就是朝北然后额外提供一个朝向角字段单位度顺时针为正。为什么这么定因为在三维场景里相机默认从 Z 看向原点模型正脸朝 -Z 意味着它背对初始相机看起来是背面——这不符合直觉。所以实际上我改成正立面朝 Z朝南朝向角表示从正南顺时针旋转多少度这样初始视角直接看到正脸沟通成本最低。约定项要求违反后的典型症状单位米模型巨大或极小相机裁剪失效上方向Y模型侧躺原点底面中心定位需要额外偏移量反复试位初始朝向正立面朝 Z初始视角看到背面需要手动转朝向字段顺时针角度单位度楼歪了和道路不对齐3.2 导出参数三角形预算和压缩策略一栋方案建筑的三角形数控制在5 万到 15 万之间比较合适。低于 5 万曲面和细节会明显棱角化高于 15 万在场景里和其他建筑混排时会明显拉低帧率尤其是一栋楼占掉整个场景三角形预算的三分之一这就不划算了。我见过一个模型 120 万面单栋楼导进来笔记本风扇立刻起飞。导出格式我自己固定用glTF 的二进制版本.glb单文件、自带材质和贴图引用、所有主流三维库都支持。导出时开几何压缩能把文件体积砍掉 60% 到 80%代价是加载时要多挂一个解码器。贴图统一转成 GPU 友好的压缩纹理格式尤其是移动端能省一大截显存。这里有个坑压缩纹理不支持所有格式的通道组合如果材质里用了自定义的混合通道转完可能颜色发灰需要在导出前把通道拆成标准的贴图槽位。# 用命令行工具做批量优化示例压缩几何 转纹理 精简 gltf-transform draco input.glb output-draco.glb gltf-transform webp input.glb output-tex.glb --quality 85 gltf-transform prune output-tex.glb output-final.glb提示压缩之前一定先备份原始文件。我看过太多压缩完发现法线精度不够、曲面出现硬棱的案例回头原始文件已经被覆盖了只能重新建模。3.3 材质适配让自定义建筑和底图不是两个世界这是视觉融合的核心。底图白模是灰白、高粗糙度、无金属感的你的自定义建筑如果贴了一套高反射玻璃幕墙在同一个光照环境下会显得格外突兀像 P 上去的。我的处理原则是自定义建筑允许比底图精致但色调和光照必须同源。具体做法是统一环境光照和色调映射让所有材质共享同一个环境贴图。自定义建筑的材质要检查三个参数粗糙度不要低于 0.25太光滑会像塑料金属度对非金属构件必须是 0默认值 1 会让它在没有环境光时发黑基础色不要用纯白或纯黑纯白在色调映射后会过曝。另外我习惯给自定义建筑加一个非常淡的环境光遮蔽让它在和地面、和旁边建筑接触的地方有自然的暗角这一层脏是真实感的关键。3.4 一个容易被忽略的细节模型内部结构方案建筑的模型里往往包含楼板、隔墙、家具、电梯井这些东西从外面根本看不见但会占掉大量三角形预算和显存。我要求建模端在导出前做一次内部清理把被外立面完全包裹的构件删掉。有次一个模型 30 万面清理完只剩 9 万面视觉上完全没差别。4. 定位与融合把楼放到它该在的地方前面都是准备工作真正考验人的是这一步。你要把一个已知经纬度、已知地面高程、已知朝向的模型精准地放进一个用局部米制坐标描述的场景里还要让它和地面贴合、和周边建筑关系正确。4.1 经纬度到场景坐标的换算与手算校验我把换算封装成一个纯函数方便单元测试。核心是墨卡托投影加原点平移北向取负号。const R 6378137; const toRad d (d * Math.PI) / 180; function mercatorMeters(lon, lat) { const x R * toRad(lon); const y R * Math.log(Math.tan(Math.PI / 4 toRad(lat) / 2)); return [x, y]; } // 返回场景中的 [x, z]x 朝东z 朝南 function localXY(lon, lat, origin) { const [px, py] mercatorMeters(lon, lat); const [ox, oy] mercatorMeters(origin.lon, origin.lat); return [px - ox, -(py - oy)]; }手算校验一定要做别信代码。举个实际数字原点在东经 120.0000 度、北纬 30.0000 度目标建筑在东经 120.0010 度、北纬 30.0000 度。经度差 0.001 度在纬度 30 度处每度经度对应的地面距离约是111320 × cos(30°) ≈ 96400米所以 0.001 度约等于96.4 米。而墨卡托投影出来的 X 差值会是R × 0.001 × π/180 ≈ 111.3 米——比实际大了约 15.4%这就是墨卡托在高纬度放大尺度导致的。这个差异要不要修看你干什么。如果只是把楼放到大概位置在 96 米的距离上差 15 米视觉上能看出来偏了将近四分之一条街肯定不行。所以我的做法是在局部坐标系里对 X 分量乘一个cos(纬度)修正让场景里的米真正等于地面米。function localXYCorrected(lon, lat, origin) { const [px, py] mercatorMeters(lon, lat); const [ox, oy] mercatorMeters(origin.lon, origin.lat); const k Math.cos(toRad(origin.lat)); // 尺度修正因子 return [(px - ox) * k, -(py - oy) * k]; }修正之后再算上面那个例子X 差值变成 111.3 × cos(30°) ≈ 96.4 米和手算对上了。这个校验我每次都做因为它是唯一能确认坐标系没搞反的低成本手段。Z 方向也要校验纬度差 0.001 度大约对应 111.3 米这个不用乘修正因子因为墨卡托的 Y 方向本来就是按地面距离等比的局部范围内。4.2 贴地校正为什么模型总是悬空或者陷进去模型放到场景里y 坐标应该设成多少直觉是设成地面高度但这里有两个坑。第一模型的原点不一定在底面有些模型原点在几何中心有些在某个楼角你设成地面高度它就有一半埋在地下。解决办法是在加载后算一次包围盒用-bbox.min.y做补偿这个补偿值算一次缓存起来就行。function placeCustomBuilding(root, site, origin) { const [x, z] localXYCorrected(site.lon, site.lat, origin); root.updateMatrixWorld(true); const bbox new THREE.Box3().setFromObject(root); // 补偿模型原点偏移让底面正好落在 groundY root.position.set( x, site.groundY - bbox.min.y, z ); root.rotation.y THREE.MathUtils.degToRad(-site.heading); return root; }注意rotation.y前面那个负号。三维场景里绕 Y 轴正向旋转是从上往下看的逆时针而朝向角按顺时针定义所以符号要反过来。这个符号错了楼就会以对称轴镜像很多建筑是对称的你根本看不出来直到发现大门朝错了方向。第二个坑是地面本身不平。城市底图的地面经常是一个平面但真实地块可能有高差尤其是山地城市或者带地下车库出入口的场地。这种情况下单一groundY会让建筑一头扎进地面一头悬空。我的处理是给场地做一个局部台地——在建筑底面下方生成一块稍微外扩、带侧壁的薄板把建筑垫起来视觉上像是做了一个基座既解决了悬空又让建筑有了落地感一举两得。4.3 视觉融合的四个手法模型位置对了但看起来还是外来户这时候需要做视觉融合。我常用的手法有四个按性价比排序。第一个是统一色调映射和曝光。所有材质走同一套色调映射自定义建筑的贴图在导出前按底图的亮度做一次匹配。最简便的做法是给自定义建筑整体套一个极淡的雾色混合让远处的建筑自然融入背景近处保持清晰。第二个是加接地阴影。这一招的效果最明显。在建筑底面下方放一个半透明的、边缘羽化的圆形或者矩形贴片模拟环境光遮蔽。比起真实阴影计算它的成本几乎为零但能让建筑瞬间站在地面上而不是浮着。第三个是轮廓描边。当底图是白模时给自定义建筑加一圈细描边或者色差能明确告诉观众这栋是重点。描边不要太粗1 到 2 像素就够用后处理做或者用棱边几何体加一个反面扩张的材质。第四个是周边环境适配。如果建筑有裙房、有雨棚、有地面铺装尽量把这些元素一起放进去让它和地面产生接触面。孤零零一个塔楼永远融不进去加上入口广场和绿化带观感立刻不一样。5. 性能与体验让它在浏览器里真的跑得动三维城市展示最怕的不是做不出来是做出来跑不动。我在一台中端轻薄本上测过同一个场景优化前后帧率能差 6 倍。这一节讲几个我反复验证有效的招数。5.1 实例化、合并与视锥剔除的取舍之前说过白模用实例化但自定义建筑不适合实例化因为它往往只有一栋而且要做单独的材质、单独的高亮、单独的点选实例化反而麻烦。所以策略是底图白模用实例化自定义建筑用独立网格。对于底图除了实例化还要做空间分块。把城市按 500 米见方切成瓦片每块一个实例化网格这样视锥剔除才能在块级别生效。如果整个城市是一个实例化网格它的包围盒是整个城市剔除永远不会触发等于没有剔除。// 按瓦片组织每块独立做视锥剔除 const chunks new Map(); // key: tx,tz for (const b of buildings) { const key ${Math.floor(b.x / 500)},${Math.floor(b.z / 500)}; if (!chunks.has(key)) chunks.set(key, []); chunks.get(key).push(b); } // 每个 chunk 建一个 InstancedMesh加入场景实测下来按 500 米分块视野内通常只有 10 到 20 块参与绘制三角形数量能降一个数量级。块的大小也有讲究太小了绘制调用变多太大了剔除粒度过粗500 米在中等密度城区是比较甜的点。5.2 资源加载把首屏时间压到 3 秒内首屏时间的构成大概是底图数据请求、底图几何构建、模型文件下载、模型解析、纹理上传显存、首帧渲染。我的优化顺序是先出粗后出细先用极低精度的白模每栋楼一个纯色盒子无材质在 1 秒内铺满屏幕让用户有东西可看再异步加载精细模型和自定义建筑。模型文件本身也要控制。一栋 10 万面的建筑压缩后大概 2 到 5MB加上贴图可能到 8MB。在网络一般的情况下这就是三四秒。所以我会把模型拆成两部分主体几何必须首屏和细节构件可延后后者在用户靠近时再加载。这个分级加载的思路和图片的渐进式加载是一回事。// 分帧插入避免一次性构建阻塞主线程 async function addChunksProgressively(chunkList, scene, onProgress) { for (let i 0; i chunkList.length; i) { scene.add(chunkList[i].build()); onProgress((i 1) / chunkList.length); await new Promise(r requestAnimationFrame(r)); // 让出主线程 } }注意requestAnimationFrame让出主线程只能避免长任务卡顿真正的解析和几何构建仍然在主线程上。如果单块数据特别大得考虑放到 Worker 里做。5.3 交互设计点选、高亮、剖切和飞行动画交互是展示这个词的落脚点。最基础的三个交互是点选建筑查看信息、高亮当前选中、切换到预设视角。点选要注意实例化网格的射线检测返回的是instanceId不是独立对象你要自己维护 instanceId 到数据记录的映射。自定义建筑是独立网格命中的是子网格需要往上找到根节点再取数据。const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); function onPointerDown(event) { const rect renderer.domElement.getBoundingClientRect(); pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObjects([cityMesh, customRoot], true); if (!hits.length) return; const hit hits[0]; if (hit.object.isInstancedMesh) { const record cityData[hit.instanceId]; showInfo(record); } else { let node hit.object; while (node.parent node ! customRoot) node node.parent; showInfo(node.userData.site); } }高亮我一般用两种方式改材质颜色简单但会破坏原有材质或者加一层描边/发光壳成本高一点但视觉效果好。方案汇报场合我更倾向后一种因为它保留了建筑本身的材质表现。剖切是个很实用的功能尤其是看内部结构或者做楼层展示。用裁剪平面就能实现成本极低一个平面方程的事。飞行动画则是把相机位置和注视点做插值缓动函数用先快后慢的曲线飞行时长控制在 1.2 到 1.8 秒之间太短显得仓促太长让人等得不耐烦。6. 常见问题与排查技巧实录这一节是我这些年的病历本按症状查基本能覆盖八成的问题。6.1 位置类问题速查表症状最可能的原因排查方法楼飘在半空模型原点不在底面未做包围盒补偿打印bbox.min.y非 0 就要补偿楼陷进地下地面高度取错或用了椭球高而非场地高对比周边建筑的落地高度位置整体偏移几百米经纬度顺序写反纬度当经度打印投影结果和手算对比位置偏移但方向对漏了纬度余弦修正检查是否乘了cos(lat)楼镜像了北向符号错或rotation.y少了负号找一个不对称特征大门验证楼和道路不对齐朝向角基准不一致确认建模端基准是正南还是正北这张表我打印贴在显示器边上出问题先扫一眼比瞎试快得多。6.2 渲染类问题发黑、闪烁、破面材质发黑最常见的原因是金属度默认值不对。很多建模软件导出时把金属度写成 1而这个值需要环境贴图才能正确表现没有环境光就是纯黑。解决办法是把所有非金属材质的金属度归零或者给场景加一个环境贴图。闪烁Z-fighting是共面导致的比如建筑底面和地面完全重合。解决办法是让地面稍微低一点或者在建筑底面加一个微小的偏移0.02 米就够肉眼看不出来。轮廓重叠的相邻建筑也会闪那就要回到清洗阶段做合并。破面通常是法线方向反了表现为从外面看是透的能看到内部。在材质上加双面渲染能临时盖住但正确做法是重新计算法线。还有些破面是索引压缩精度不够导致的把压缩器的位置量化位数调高能解决。6.3 移动端特有问题移动端最常见的是黑屏原因八成是显存超了或者着色器编译失败。排查顺序先降贴图分辨率再关掉实时阴影最后检查是不是用了移动端不支持的纹理格式。我一般会做自动降级检测到设备是移动端或者帧率低于 25就关阴影、关后处理、把像素比降到 1.5。还有一个隐蔽的问题是内存泄漏。切换模型时如果不释放旧的几何体和纹理来回切十次内存就爆了。所有dispose()调用必须成对出现我习惯写一个disposeObject3D工具函数递归清理避免遗漏。function disposeObject3D(root) { root.traverse(obj { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { const mats Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach(m { Object.values(m).forEach(v { if (v v.isTexture) v.dispose(); }); m.dispose(); }); } }); }7. 我在实操中踩过的几个坑和后续可扩展的方向说几个印象深的。第一次做这个功能时我把自定义建筑的高度设成了地面高度 模型包围盒高度 / 2结果楼浮在半空因为包围盒高度的一半不等于原点到地面的距离——模型原点在几何中心时才对原点在底面时就直接悬空了一倍楼高。后来我统一用-bbox.min.y补偿再也没出过这个问题。第二个坑是光照。我一开始只用了方向光加环境光自定义建筑和底图在同一套光下白天看起来还行切到黄昏场景时自定义建筑的玻璃幕墙反射出的是纯白因为环境贴图没跟着换。教训是环境贴图必须和场景光照预设绑定切预设的时候一起换。第三个坑是性能我在一个 8 万栋楼的场景里用实例化帧率还是只有 20查了半天发现是阴影贴图的分辨率设成了 4096把整个城市的阴影都渲染进一张巨图改成 2048 并且加阴影相机裁剪范围后帧率直接翻倍。后续能扩展的方向挺多。一个是多方案并排对比把两三个模型放在同一场地的不同位置或者做左右分屏配合同步相机评审时特别好用。另一个是时间维度加上简单的日照分析让用户拖动时间轴看建筑阴影的变化这个对方案汇报的加分很大。还有就是数据联动点选建筑弹出层数、面积、功能分区把三维模型和业务数据绑起来这个做起来不难但能显著提升这是个能用的工具而不是这是个演示动画的感觉。我个人最喜欢加的其实是那个最朴素的功能——一个准确的回到项目位置按钮因为它解决的是所有展示场景里最高频的动作。