从(一)把底图和标记点跑通之后,我给自己定的下一个目标是挺明确的一个坎:地图不能只是“看得见”,它得能像一张真正的城市地图那样,把楼“长”出来,能被点到,能和大屏上的业务数据对上号。这篇记录(二)就顺着实际开发顺序写——从GeoJSON数据怎么变成3D建筑,到建筑内部楼层小地图怎么做,再到点击高亮、大屏联动,最后是关于性能优化和上线发布的教训。整个链路是一条线走下来的,没有平行对比多个方案那种弯弯绕,重点说清楚每一步为什么这样做。
1. 数据这关绕不过去:GeoJSON转3D建筑的坐标系处理
1.1 数据来源与结构:先搞到能用的建筑轮廓
做3D地图的第一步不是写代码,是确定数据从哪来。我这次用的是阿里DataV.GeoAtlas提供的城市GeoJSON数据,它能直接拿到包含建筑轮廓的行政区划面数据,也支持按区县边界下载。如果你是做单体建筑或者园区场景,用OpenStreetMap导出或者手绘轮廓也可以用,但那种数据通常没有“楼层高度”这类属性,后面要做体块拉伸时得自己补或者外部关联。
GeoJSON的数据结构说白了就是一层层嵌套的坐标。拿MultiPolygon类型来说,最外层是面集合,每个面是一个环数组,环里才是经纬度坐标点。初学者很容易栽在“怎么取到某个建筑的坐标”这个问题上,实际写代码前先打印一下JSON结构,看清楚是Polygon还是MultiPolygon,再决定遍历层数,能省掉后面至少一个小时的调试时间。
1.2 经纬度转本地平面坐标:为什么不能直接拿经纬度当x、z用
这里要明确一件事:GeoJSON里的坐标是WGS84经纬度,单位是度;three.js场景里我们用米作为单位。如果不做任何转换直接把经纬度塞给Mesh的position,建筑会挤在原点附近,因为经纬度的角秒级变化在场景里根本体现不出来。
我用的是一套很常见的本地切平面近似算法:选定场景中心点作为原点,然后计算每个点相对原点的距离。横向用“经度差 × 每度经度对应的米数”,纵向用“纬度差 × 每度纬度对应的米数”。每度纬度大约对应111.32公里,每度经度要根据当前纬度做余弦修正,纬度越高,同样经度差对应的实际距离越短。
function lonLatToLocal(lon, lat, originLon, originLat) { const x = (lon - originLon) * 111320 * Math.cos(originLat * Math.PI / 180); const z = (lat - originLat) * 111320; return { x, z }; }这个算法在几十公里范围内精度足够,做城市级3D地图完全没有问题。如果你要做的场景跨了多个城市,那就得换Web Mercator投影或者用d3-geo先做投影转换了,但那种情况更适合cesium这类引擎来干,three.js更适合聚焦在几公里到几十平方公里的范围。
1.3 高度数据与单位约定:一单位就是一米
建筑高度数据是这个项目里最不稳定的变量。比较正规的来源是建筑普查数据或者BIM模型,但大部分时候我拿到的就只有层数或者完全没有。我的做法是写一个映射函数:有楼层数就按“楼层数 × 3.5米”估算高度,没有就按区域类型给个默认值,比如商业区默认25米,住宅区默认18米。这个估算当然不精确,但做城市整体效果是够用的。
另外强烈建议把场景单位统一成“1单位=1米”,相机近裁面、远裁面、灯光强度全部按米来调。你一旦引入缩放因子,后面所有涉及物理单位的计算都会变得很难排查。
2. 把城市从平面“拉”起来:建筑体块、贴图与楼层内部小地图
2.1 用ExtrudeGeometry做建筑体块的实现与方向坑
拿到轮廓坐标后,我用了three.js的ExtrudeGeometry做建筑拉伸。基本思路是先把GeoJSON里的经纬度坐标转成本地坐标,然后构造一个THREE.Shape,再传入拉伸深度。这里要注意一个致命的细节:ExtrudeGeometry默认是在XY平面上创建Shape,然后沿Z轴方向拉伸,而three.js场景里“向上”的方向是Y轴。如果你直接把本地坐标丢进Shape,建筑实际上会是躺在地上的。
解决方案是拉伸完成后把geometry绕X轴旋转-90度,让原本的Z方向变成Y方向。这行旋转代码是我在这个项目里第一次被坑到的地方,印象特别深刻:
const shape = new THREE.Shape(); coordinates.forEach(([lon, lat], index) => { const { x, z } = lonLatToLocal(lon, lat, originLon, originLat); if (index === 0) shape.moveTo(x, z); else shape.lineTo(x, z); }); const geometry = new THREE.ExtrudeGeometry(shape, { depth: buildingHeight, bevelEnabled: false }); geometry.rotateX(-Math.PI / 2);如果你拿到的是MultiPolygon,记得先遍历取出每个面,分别创建Shape再合并成一组Mesh,千万不能把多个环塞到同一个Shape里,不然会得到错误的孔洞三角剖分。
2.2 材质分组:屋顶和侧面分开贴才能出效果
ExtrudeGeometry自带材质分组,这一点容易被忽略。它生成的groups里,索引0对应顶面和底面,索引1对应侧面。利用这个特性,可以给建筑指定两个材质组成数组,实现“屋顶一个颜色、墙面一个颜色”的效果。对于城市大场景,墙面用纯色或简单噪点纹理,屋顶用稍暗的颜色,能在视觉上立刻拉开建筑的立体感,比我一开始整栋楼用一种材质效果好得多。
const materials = [ roofMaterial, // 顶面和底面 wallMaterial // 侧面 ]; const mesh = new THREE.Mesh(geometry, materials);如果是做到夜景风格的大屏,墙面的材质可以直接用MeshStandardMaterial加一点自发光(emissive)属性,配合场景里的点光源,比纯粹依赖贴图更有数字化大屏的味道。
2.3 楼层内部小地图:不是非要用Unity那套,three.js也能做
标题里提到“unity 3d楼层小地图”,其实three.js做室内小地图同样可以。我的做法是,把选中建筑做成半透明状态,在建筑内部放置一个楼层平面图Plane,平面图由Canvas绘制,包含房间区域、走廊边界、楼梯位置。点击楼层按钮时,通过切换Plane的贴图来切换楼层,并配合一个小的相机动画,把镜头从楼外向目标层拉近。
楼层平面图不推荐用图片资源,因为标注和颜色调整不方便。Better的方式是用Canvas动态绘制,把每个房间的矩形画上去,旁边配上楼层号和功能说明。这样的好处是大屏联动时,业务数据可以直接驱动Canvas重绘,比如把当前工位占用率高的房间标红,这比纯粹贴一张静态图灵活得多。整个楼层小地图模块可以独立成类,接收buildingId和floorNumber两个参数,对外只暴露一个showFloor方法。
3. 交互不是点缀:拾取、高亮、点击弹窗与大屏联动的完整链路
3.1 Raycaster拾取建筑与hover高亮
在three.js里做点击拾取,Raycaster是绕不开的。我的做法是维护一个“可交互建筑列表”,只在raycaster.intersectObjects的时候传这个列表,而不是把整个场景树都丢进去,否则性能会随着建筑数量上升而快速劣化。另外,建筑Mesh底下的ExtrudeGeometry顶点数多,射线检测比普通的BoxGeometry要慢,建议把raycaster的far值设短一点,比如1000米,超出这个距离就不做精细检测。
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); renderer.domElement.addEventListener('pointermove', (event) => { pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(interactiveBuildings, false); // 命中则高亮,未命中则恢复 });高亮效果我用的是三步叠加:先把建筑的材质换成高亮材质,再给建筑加一个沿着轮廓边的outline线框,最后在建筑上方放置一个发光的标点。这套组合在大屏上看非常醒目,而且不依赖后期处理管线,性能开销可控。
3.2 点击信息面板:HTML遮罩层比场景内TextMesh更实用
点击建筑后展示楼栋信息,我踩过两个方案:一是场景内创建Sprite或者TextMesh作为文字标签,二是用HTML的绝对定位浮层。结论是:展示业务详情用HTML浮层更合适,因为详情面板需要支持滚动、图表、跳转链接,这些在three.js场景里做会特别别扭。
实现上就是给每个建筑Mesh挂一个userData对象,把楼栋名、层数、面积、入驻企业等数据塞进去。点击事件命中后,把建筑数据通过事件分发出去,外层HTML面板负责渲染。这样three.js只管3D交互,数据展示交给浏览器原生能力,职责清晰,也很方便后面接大屏数据。
3.3 大屏联动:数据推送与视觉反馈的时间节奏
这个项目最终形态是放在一个数据可视化大屏里的,整个3D地图区域会嵌在一个iframe里,大屏主体框架在父页面。联动方式我最后选的是postMessage。父页面把“当前统计口径”“告警事件列表”通过消息发给3D地图页面,地图页面根据消息内容更新建筑颜色、高度柱状图和小地图楼层数据。
这里有一个很容易被忽略的体验问题:数据变化的视觉反馈节奏。一开始我是收到消息后立即改建筑颜色,结果大屏上的更新显得非常生硬。后来加了一个约0.6秒的淡入过渡,以及一个“从建筑顶部向下扫光”的Shader动画,数据更新立刻变得有仪式感,视觉上也更贴合“数字孪生驾驶舱”那种感觉。Shader本身不复杂,就是给建筑材质加一个uniform的progress值,控制高亮区域从顶部往下的范围。
4. 性能优化实测:顶点合并、LOD与场景植物的低成本方案
4.1 合并几何体:draw call从几千降到几百
城市级场景建筑数量动辄上千,如果每个建筑都是一个独立Mesh,浏览器的帧率会被成百上千的draw call拖垮。我的优化第一步是把建筑批量合并成几个大的BufferGeometry。three.js提供了BufferGeometryUtils.mergeGeometries,把相同材质的几何体合并成一个,重新渲染时GPU只需要切换几次材质状态,性能提升非常明显。
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; // 按材质分桶,再把同一桶的geometry合并 const mergedGeo = mergeGeometries(geoList, false); const mergedMesh = new THREE.Mesh(mergedGeo, sharedMaterial);这里有一个取舍:合并后你就失去了单独拾取单栋建筑的能力。我的解决思路是“按区域分组合并”:把整个城市划分成若干个区块,每个区块内合并一栋建筑一栋建筑的几何体,区块之间不合并。这样拾取时先命中区块Mesh,再用区块内保存的建筑ID列表反查具体建筑,既控制了draw call,又保留了单栋建筑的高亮和点击能力。这个思路在后期接告警定位时帮了大忙,不需要把几百个Mesh铺在场景里。
4.2 LOD与距离管理:远处不要显示精细建筑
几何体合并只是第一步。场景范围拉大后,远处的建筑占了大量顶点资源,但屏幕上可能只有几个像素。我给项目加了“距离分级”机制:在100米范围内显示完整ExtrudeGeometry建筑;100到300米范围内显示简化低模,也就是用BoxGeometry按同样底面积和高度替换;300米以外直接合并成一片色块。
具体实现可以用THREE.LOD类,也可以手动在渲染循环里做距离判断。实际调试下来手动判断更灵活,因为我可以更精细地控制“哪些区块进入精细范围”。一个值得注意的点是:切换LOD层级时要避免闪跳,可以用交叉淡入或者把切换阈值设得滞后一些,比如进入150米才切精细,离到200米才切回低模,防止相机在边界上反复触发切换。
4.3 柳树这类装饰植物的低成本实现
有一个相关热搜词是“three.js柳树”,我在项目里确实被这玩意折腾过。最开始我试图在园区建筑周边放一些独立建模的柳树模型,结果一棵树的三角面数就要上万,放几十棵场景帧率直接崩掉。后来换了个思路:柳树这种垂枝植物,用两个垂直交叉的平面,每张贴图加透明通道,平面中间配一个细长的树干模型,远处看效果完全够。更讲究一点,可以在顶点Shader里对叶片部位做正弦偏移来模拟随风摆动,因为柳条的柔性让这个动作非常显眼。
// 十字面片示意:两个平面互相垂直 const planeGeo = new THREE.PlaneGeometry(6, 10); const material = new THREE.MeshStandardMaterial({ map: treeTexture, transparent: true, side: THREE.DoubleSide, alphaTest: 0.5 }); const group = new THREE.Group(); const p1 = new THREE.Mesh(planeGeo, material); const p2 = new THREE.Mesh(planeGeo, material); p2.rotation.y = Math.PI / 2; group.add(p1, p2);alphaTest设为0.5可以避免透明物体排序造成的闪烁,这是个很容易被忽略的经验。如果追求更低的开销,可以直接用Sprite(始终面向相机),树再多也不慌。对于园区级的场景,交叉十字面的立体感够用,我用的是两层十字错位摆放,视觉上比单个十字面更饱满一些。
5. 发布上线阶段的踩坑清单:兼容性、文字与光效
5.1 WebGL兼容性:老机器上白屏怎么排查
项目要上线给不同环境访问,最头疼的是兼容性。第一次在客户机器上打开时,页面直接白屏,控制台报的是WebGL context lost。排查后发现那台机器默认用的是集成显卡,浏览器没有启用硬件加速,再加上我创建renderer时开了antialias和alpha,显存压力比较大。解决方案是把antialias做成配置项,必要时关闭抗锯齿;同时检测WebGL2失败时自动回退到WebGL1,这两步解决了绝大多数白屏问题。
5.2 中文标签模糊:canvas生成Sprite要注意dpr
建筑名称标签我最后是统一用Canvas画出来的文字纹理,再生成Sprite放到建筑上方。在普通屏幕上看起来没问题,一旦接到4K大屏上,字就发虚。原因是Canvas默认的分辨率没有跟随设备的devicePixelRatio。修复方式是在绘制文字前把canvas的宽高乘上dpr,然后用ctx.scale(dpr, dpr)再绘制,基本上能保证大屏上每一栋楼的文字都清晰锐利。这个细节不处理,再好看的三维场景也会被模糊的标签拉低档次。
5.3 色彩空间与灯光强度:为什么模型发灰或者过曝
three.js新版本默认是SRGBColorSpace输出,贴图默认要设置colorSpace为SRGBColorSpace,否则纹理颜色会发灰。这个问题在普通情况下不明显,但在大屏深色背景上差异很大。另一个容易翻车的是灯光强度:新版本three.js里,PointLight的强度跟衰减单位有关,数值调的体验跟老版本完全不同。我建议用MeshStandardMaterial加一个DirectionalLight配合HDR环境贴图,光照会更可控;不要为了亮度堆一堆点光源,点光源一多阴影和性能都会跟着崩。
5.4 上线前实测的几个典型问题
这些问题我印象很深,整理一个小表方便有同样需求的朋友对照排查:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 建筑躺在地面上 | ExtrudeGeometry默认沿Z轴拉伸 | geometry.rotateX(-Math.PI / 2) |
| 点击建筑没反应 | 射线检测的mesh层数不对,子Mesh导致的穿透 | intersectObjects传不递归参数,或按区块Mesh命中反查 |
| 远处建筑闪烁 | 透明贴图或材质深度写入冲突 | 拉开LOD切换阈值,远处改用自己的颜色块 |
| 大屏上文字模糊 | Canvas渲染没有处理devicePixelRatio | canvas宽高乘dpr后再绘制 |
| 页面崩溃或白屏 | WebGL上下文被搞挂 | 提供WebGL1回退,关闭antialias,做异常上报 |
如果你还在做类似的城市3D地图场景,我最想提醒的一句话是:先把数据链路和对齐逻辑想清楚,再去调灯光和材质。比如坐标转换、高度估算、区块划分这些底子没打好,后面所有的交互和性能优化都会事倍功半。而一旦数据这条线通顺了,three.js做3D地图的自由度确实比传统GIS引擎大得多,不管是接楼层小地图还是做大屏联动,边界都比较开放。这套场景跑下来,下一步我准备把更多业务数据接到里面去,比如告警事件的动态推送、建筑能耗柱状图的实时更新,等路线成熟了再单独写一篇。