
做了几年高德地图相关的可视化项目我踩过最深的一个坑就是“3D 建筑”看着很炫但真正做起来所有人都会在“多楼层”这三个字上翻车。默认的高德 3D 地图上那个楼体其实只是一个没有楼层的“盒子”本地图瓦片渲染出来的一整块白模你点击它不会有任何楼层反馈也不存在“第几层”的概念。所以我今天想聊的不是怎么打开高德自带的 3D 城市模式而是如何在 Web 端基于高德地图 JS API 自己写一套 3D 建筑多楼层模型相关的代码把一栋楼从“一个盒子”变成“一组楼层体块”并且支持楼层点击、切换、定位这类交互。这个需求在商场导览、园区管理、医院内部导航、消防疏散演练等场景里特别常见。适合的人群是已经接入过高德地图 JS API想进一步做自定义 3D 场景的开发者。需要提醒的是这个方案不依赖高德官方现成的“室内楼层图”插件而是基于 AMap.GLCustomLayer 和 three.js 自己搭一套所以需要你具备一点 WebGL 的基础但我会把坐标换算、数据结构、渲染流程都拆开讲尽量让没太接触过 three.js 的人也能照着做。1. 项目背景与核心思路拆解1.1 为什么“多楼层模型”不能直接用官方接口先说结论高德地图目前提供的 3D 建筑效果本质是地图瓦片服务的一部分。你打开高德 JS API 2.0/3.0把viewMode设为3D地图上确实会出现城市建筑白模但这些白模是一整栋楼渲染成一个体块数据是瓦片图层在后台绘制好的。作为开发者你拿不到“这栋楼每一层的轮廓线”也拿不到“这栋楼每层层高是多少”这类结构化数据。那么做多楼层模型第一步要建立的认知就是高德只负责“地面和环境的 3D 底图”楼层数据必须自己准备然后把楼层模型叠加到地图上。我之前在做一个商场室内导航项目时甲方直接说“你们不是接高德地图了吗为什么还要我们提供楼层 CAD 图”这里就是认知偏差。高德的 3D 地图和“可以切换楼层的建模”是两码事后者需要独立的楼层 GeoJSON 或者 BIM 数据源。1.2 高德地图 3D 能力的边界高德 JS API 从 2.0 开始引入了基于 WebGL 的完整 3D 渲染能力但它的 3D 能力是分层级的我整理成了一张表方便理解能力等级官方接口能做什么局限性底图级viewMode: 3D开启倾斜摄影视角显示建筑白模、路网、POI 的 3D 化无法单独控制某个建筑无法获得建筑内部楼层数据图层级AMap.Buildings控制建筑白模的显示隐藏、样式、透明度只有外形没有建筑语义无法拆楼层自绘图层AMap.GLCustomLayer在 3D 地图场景中叠加自定义 WebGL 内容如 three.js 场景需要自己完成坐标映射和渲染循环技术门槛较高室内图层高德室内地图 SDK支持室内楼层切换功能封闭不支持自定义 3D 楼体外观我们在项目中最终选择的是AMap.GLCustomLayer加 three.js 的组合原因很简单只有这条路能同时满足“建筑外观自定义”“楼层数据驱动”“交互反馈即时”这三个要求。如果你只需要让楼层看起来是一层层叠起来的也可以直接用AMap.Object3D.Mesh画几个立方体但要做复杂造型和点击高亮还是 three.js 更顺手。1.3 多楼层模型与普通建筑白模的本质区别普通建筑白模是“一个 Polygon 拉伸成一个体块”最多带点屋顶造型。多楼层模型则是“一栋楼被拆成 N 个层对象”每个层对象至少包含楼层外轮廓Polygon平面坐标楼层高度height用于挤出三维体块的厚度楼层编号floor_no用于控制和渲染顺序楼层属性name、功能分区、商户信息、房间号等这个数据结构的差异决定了渲染时不能简单用一个ExtrudeGeometry把所有点串起来而必须按楼层逐层生成单独的 Mesh。每一层是一个独立的 three.js Object3D 节点这样才能在楼层切换时只控制当前楼层显隐或者在点击时单独高亮其中一层。2. 核心细节解析与数据实操准备2.1 坐标系才是第一坑GCJ-02 火星坐标做高德地图相关开发坐标系转换永远是第一步多楼层模型也不例外。高德地图所有坐标都基于 GCJ-02俗称火星坐标它和国际标准的 WGS-84 之间存在偏移。如果你手里的楼层 CAD 图、测绘数据或者第三方 BIM 数据是 WGS-84 坐标直接画到高德地图上会整体偏移大概几百米在高倍缩放下特别明显。我一般会自己封装一个坐标转换工具// WGS-84 转 GCJ-02高德使用 function wgs84ToGcj02(lng, lat) { const a 6378245.0; const ee 0.006693421622965943; let dLat transformLat(lng - 105.0, lat - 35.0); let dLng transformLng(lng - 105.0, lat - 35.0); const radLat (lat / 180.0) * Math.PI; let magic Math.sin(radLat); magic 1 - ee * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / (((a * (1 - ee)) / (magic * sqrtMagic)) * Math.PI); dLng (dLng * 180.0) / ((a / sqrtMagic) * Math.cos(radLat) * Math.PI); return { lng: lng dLng, lat: lat dLat }; }注意如果楼层数据是从百度地图拿到的还需要先做 BD-09 到 WGS-84 的逆转换再调用上面的逻辑转成 GCJ-02中间不能跳步。2.2 楼层数据怎么组织最顺手我推荐用 GeoJSON 的 FeatureCollection 结构每个楼层对应一个 Featuregeometry 是 Polygon 或者 MultiPolygon。{ type: FeatureCollection, features: [ { type: Feature, properties: { floor_no: 1, floor_height: 4.5, name: B1, category: 停车场, model: mall, center: [116.397428, 39.90923] }, geometry: { type: Polygon, coordinates: [ [ [116.3965, 39.9088], [116.3985, 39.9088], [116.3985, 39.9105], [116.3965, 39.9105], [116.3965, 39.9088] ] ] } } ] }实际项目中楼层轮廓不是简单的矩形每个楼层可能形状不同。比如商业综合体的低层可能有中庭连廊高层是标准楼层甚至有些楼层有退台设计。这些都可以用多个多边形组合表达。关键是 properties 里要有floor_no这是楼层排序、切换和事件绑定的唯一标识。2.3 瓦片数据与自绘楼层的分工前面说了高德的 3D 建筑白模是瓦片服务渲染出来的但我们做多楼层时不能完全依赖它。我的分工策略是打开高德 3D 视图但设置showBuildingBlock: false把自带白模关掉避免和自定义楼层模型在视觉上重叠自己渲染楼层体块楼栋外的环境建筑则保留高德的白模作为环境如果希望城市环境更真实可以用高德自定义地图平台把底图调成暗色配合楼层模型的发光材质视觉效果会很高级。有朋友会问能不能从瓦片服务里直接解析出楼层轮廓理论上瓦片是二进制或图片里面不一定包含楼层语义数据逆解析成本很高而且涉及合规性不建议这么做。数据还是我们自己做瓦片只当底图。3. 实操过程代码实现与参数计算3.1 初始化一个适合多楼层的 3D 地图先初始化高德地图核心配置如下const map new AMap.Map(mapContainer, { viewMode: 3D, pitch: 55, rotation: -35, zoom: 18, center: [116.397428, 39.90923], mapStyle: amap://styles/darkblue, showBuildingBlock: false, buildingAnimation: false });几个参数解释一下pitch俯仰角55 度能清晰看到楼层侧面太低看不到楼层厚度太高接近俯视建筑物立面感会弱。rotation地图旋转角度可以根据楼栋朝向调整。zoom多楼层模型建议在 17-19 级展示太低楼层细节丢失太高会出现大量场景裁剪。showBuildingBlock: false关掉高德自带建筑白模这是为了避免自己画的楼层模型和白模产生深度冲突。3.2 GLCustomLayer 里塞一个 three.js 场景高德提供了AMap.GLCustomLayer可以在 3D 地图的渲染循环里插入自定义 WebGL 内容。我们要做的是把 three.js 的 WebGLRenderer 和高德传进来的 gl 上下文绑定然后把整个 three.js 场景同步到地图的相机投影矩阵中。初始化 GLCustomLayer 时高德会在地图场景中创建自定义图层并传递frameData和gl给render回调。这里有一个极其关键的细节不要在自定义图层里自己调用requestAnimationFrame而是等地图的 render 回调来驱动 three.js 渲染否则会出现渲染不同步、闪烁、黑屏等问题。let customLayer; let scene, camera, renderer; function initThreeLayer() { customLayer new AMap.GLCustomLayer({ zIndex: 120, init: (gl) { renderer new THREE.WebGLRenderer({ context: gl, antialias: true, alpha: true }); renderer.autoClear false; renderer.setClearColor(0x000000, 0); scene new THREE.Scene(); camera new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); // 添加光源 const ambient new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient); const dir new THREE.DirectionalLight(0xffffff, 0.8); dir.position.set(200, 400, 300); scene.add(dir); buildBuildingFloors(); }, render: () { // 关键由高德地图驱动渲染 renderer.resetState(); renderer.render(scene, camera); } }); map.add(customLayer); }注意renderer.resetState()如果不调用高德内部 WebGL 状态和 three.js 的渲染状态会冲突表现出的症状就是整个地图变黑或者只有 3D 建筑没有底图这个问题排查了我一天。3.3 经纬度到 three.js 世界坐标的换算这是整个多楼层模型最核心、最容易出错的环节。three.js 场景里使用直角坐标单位米而高德地图用的是经纬度。你不能直接把经纬度塞给 three.js否则模型会跑到外太空。我的解决方案是以整栋楼的中心点为原点把每个楼层顶点的经纬度值换算成相对于中心点的“米”偏移const center [116.397428, 39.90923]; // 经纬度差转米 function lngLatToLocal(lng, lat) { const earthRadius 6371000; const latRad (lat * Math.PI) / 180; const lngRadius earthRadius * Math.cos(latRad); const dx (lng - center[0]) * (Math.PI / 180) * lngRadius; const dy (lat - center[1]) * (Math.PI / 180) * earthRadius; return { x: dx, z: dy }; }这里的核心是“米制坐标”。纬度方向 1 度大约等于 111 公里经度方向则乘上cos(lat)所以在纬度 39.9 度附近经度 1 度约等于 85 公里。直接用这个公式换算误差在几百米范围内可以忽略但如果你做的是精度要求很高的大型园区建议用更精确的投影公式或者请求高德的坐标转换服务。3.4 逐楼层生成 Mesh 体块拿到局部坐标点之后可以用 three.js 的Shape和ExtrudeGeometry生成立体楼层。function buildFloorMesh(floor, localPoints) { const shape new THREE.Shape(); localPoints.forEach((p, index) { if (index 0) shape.moveTo(p.x, p.z); else shape.lineTo(p.x, p.z); }); const extrudeSettings { depth: floor.properties.floor_height || 4, bevelEnabled: false }; const geometry new THREE.ExtrudeGeometry(shape, extrudeSettings); const material new THREE.MeshLambertMaterial({ color: getFloorColor(floor.properties.floor_no), transparent: true, opacity: 0.85, side: THREE.DoubleSide }); const mesh new THREE.Mesh(geometry, material); // ExtrudeGeometry 沿 z 轴挤出但我们希望楼层向上所以旋转 90 度 mesh.rotation.x -Math.PI / 2; mesh.position.y floor.properties.floor_no * floor.properties.floor_height; mesh.userData floor.properties; return mesh; }这里有个容易踩的坑ExtrudeGeometry默认沿 z 轴挤出而我们的楼层需要沿 y 轴堆叠。所以要在生成后旋转mesh.rotation.x -Math.PI/2再通过mesh.position.y设置每一层的底部位置。floor_no可以直接从 1 开始累加如果地下有 B1、B2则应该让floor_no为负数这样楼层排序才正确。3.5 楼层切换与显隐控制多楼层交互的核心就是把当前楼层显示出来其他楼层透明或者隐藏。我的做法是function showFloor(targetFloorNo) { buildingGroup.children.forEach((child) { if (child.userData.floor_no targetFloorNo) { child.visible true; child.material.opacity 1; } else { child.visible false; } }); }如果要做高亮效果可以保留相邻楼层的“透明线框”状态。比如当前楼层是 3 层那么 2 层和 4 层可以保留半透明轮廓这样用户能感知到楼栋的整体结构同时又能聚焦当前层。业内叫“楼层淡化”而不是“楼层隐藏”体验更好。楼层切换的交互控件我通常不用原生 DOM 按钮而是创建一个绝对定位的div浮层放在地图容器左上角或右侧点击楼层号时调用showFloor()。如果需要更贴近地图也可以用AMap.Marker配合自定义 content 来做楼层选择器这样它能跟着地图 Marker 层级走缩放时保持相对位置。3.6 楼层点击高亮和属性面板为了让用户能点击某个楼层查看属性我用 three.js 的Raycaster。map.on(click, (e) { const pixel map.lngLatToContainer([e.lnglat.getLng(), e.lnglat.getLat()]); const ndcX (pixel.x / window.innerWidth) * 2 - 1; const ndcY -(pixel.y / window.innerHeight) * 2 1; const raycaster new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera); buildingGroup.children.forEach((mesh) { const intersects raycaster.intersectObject(mesh, true); if (intersects.length 0) { showFloor(mesh.userData.floor_no); openInfoPanel(mesh.userData); } }); });注意高德地图map.on(click)传递的坐标是经纬度而 three.js 的 Raycaster 需要归一化设备坐标。中间要根据地图容器的尺寸换算一次。4. 场景适配Web、小程序与移动端4.1 小程序接入高德地图时的多楼层取舍很多项目想在小程序里也实现同样的 3D 多楼层模型。说实话微信小程序的原生 map 组件能力有限它支持enable-3D和enable-building属性但那是高德小程序 SDK 封装的地图 3D 外观不开放自定义 WebGL 图层。你没办法在小程序里直接跑 three.js 的 GLCustomLayer。我在实际项目里的兼容策略有三种简单场景小程序端用 Canvas 绘制楼层平面图覆盖在 map 组件上通过点击楼层按钮切换 Canvas 里的平面图。复杂场景小程序里用一个 WebView 加载 H5 页面在 H5 里跑完整的高德 JS API 3D 多楼层模型。混合场景小程序显示 2D 地图点击楼栋 Marker 后跳转到 H5 的 3D 页面。如果你对跳转交互有要求比如“从微信小程序跳转到高德 app 查看实时路况”那属于另一个话题。这里更通用的做法是:小程序内保持轻量展示3D 重交互放到 H5 或 App 的 WebView 中。4.2 瓦片与自定义模型的移动端性能优化移动端 WebView 跑 3D 地图性能瓶颈往往不在高德地图本身而在 three.js 的楼层模型。楼层模型动辄几十层每层一个 ExtrudeGeometrydraw call 太多了。我做了这几项优化用THREE.InstancedMesh代替多个独立 Mesh对于标准楼层轮廓相同的楼层只用一套几何体每层一个实例矩阵draw call 从几十降到一次。只渲染可视楼层根据当前相机俯仰角和视野裁剪掉视野外的楼层。LOD 策略在高楼层视角下简化楼体几何比如把轮廓 200 个点降采样到 32 个点。实际测试下来iPhone 12 上性能可以稳定在 50 fps 以上安卓中端机也能流畅拖动。4.3 高德 API 配额与收费注意事项我们做多楼层项目时通常会配合高德的地理编码、逆地理编码等 API 使用。但有段时间我被高德 API 收费规则坑过。高德开放平台的服务配额不是完全免费的不同服务有不同的日配额JS API 本身免费但如果你在楼栋模型里根据经纬度反查地址会调用“逆地理编码”这个服务有配额限制超了会报错。实战中出现过的错误是 Android 端onRegeocodeSearchFailed返回错误码10021处理方式检查申请 Key 时是否勾选了对应的 Web 服务 API逆地理编码需要单独开通。检查 IP 白名单如果服务端请求需要把服务器出口 IP 加到白名单如果浏览器直接请求需要允许“浏览器端”使用 Key 并绑定域名白名单。检查配额是否用尽测试阶段可以把地图缩放降到 15 级以下或者改用手动缓存地址数据不要每次渲染楼层都请求逆地理编码。4.4 在 3D 模型周边叠加雷达扩散效果多楼层模型做好后经常需要在楼栋周围叠加类似雷达扩散的动画效果用来表达“当前位置”或者“覆盖范围”。这个效果看起来像水波纹一圈一圈扩散。如果用高德官方 API可以用AMap.Circle通过循环改变半径和透明度实现。let circle; let radius 0; function startRadarAnimation(center) { circle new AMap.Circle({ center: center, radius: 0, strokeColor: #66ccff, strokeWeight: 2, fillColor: #66ccff, fillOpacity: 0.3 }); circle.setMap(map); const timer setInterval(() { radius 2; circle.setRadius(radius); circle.setOptions({ fillOpacity: Math.max(0, 0.3 - radius / 500) }); if (radius 200) clearInterval(timer); }, 30); }如果想更平滑可以在 three.js 场景里加一个圆环 Mesh同样按帧更新它的 scale 和 opacity。这个效果和多楼层模型不冲突既有垂直方向的分层又有水平方向的动态扩散视觉冲击力不错。5. 常见问题与排查技巧实录5.1 GLCustomLayer 黑屏只剩底图这是最常见的现象。底图正常显示但自定义楼层完全看不到。经验判断90% 是因为没有在render回调里调用renderer.resetState()。高德地图和 three.js 内部都会修改 WebGL 状态必须通过resetState()把状态重置three.js 再渲染才不会污染地图的底图管线。如果 resetState 加了还是黑屏检查三件事WebGLRenderer 创建时是否传入了高德 init 回调里的 gl context如果自己 new 了一个 context会导致双 WebGL 上下文冲突scene 里的相机是否在正确位置相机如果坐在原点视野太小楼层模型可能出现但被裁剪掉了是否在 init 里执行了renderer.setSize(window.innerWidth, window.innerHeight)这里不要设置否则会覆盖高德 map canvas 的尺寸。5.2 楼层模型整体偏移几百米偏移问题优先查坐标系。出现几百米偏移几乎可以断定是把 WGS-84 坐标直接当 GCJ-02 用了。唯一解决办法是在数据进入渲染流程前统一经过一个坐标系转换服务。我的建议是数据预处理阶段就转好不要在渲染时每个顶点转换那样会增加每帧 CPU 消耗。5.3 3D 场景里文字和 POI 显示异常自绘楼层模型叠加后高德地图的 POI 标注可能会出现遮挡关系不对的情况。我自己常用一个土办法关掉 3D 模式下的 POI 或降低 POI 层级然后用自己的AMap.Marker或者 three.js 的 TextGeometry 显示楼层名称。Map 的hidePOI不是官方参数但可以通过map.setMapStyle搭配自定义样式关闭 POI 显示或者在地图样式 JSON 里过滤。文字渲染在 three.js 里比较麻烦我推荐用 CSS2DRenderer它可以把一个 DOM 元素固定在 three.js 对象的屏幕位置上。这样楼层名称永远是清晰的矢量文字不会因为 WebGL 缩放变模糊。5.4 瓦片加载慢楼层模型出现了但底图还是模糊的这个现象通常在快速缩放时出现底图瓦片会先用低分辨率显示然后逐步变清晰是正常表现。但如果你希望多楼层模型在瓦片加载完成前不显得突兀可以在模型上增加简单的加载状态或者等地图complete事件后再添加 GLCustomLayer。注意complete事件在每次交互后可能不会触发所以不要反复添加图层否则会产生多个渲染循环导致卡顿。5.5 移动端内存占用过高崩溃楼层数据如果包含大量纹理和复杂几何体移动端很容易崩。我最高纪录是做一个 45 层写字楼时把每层外墙的幕墙玻璃都做成独立纹理结果 iOS Safari 直接白屏。后来改成了共享材质并把纹理压缩成 WebP内存占用降了 70%。6. 一点实操心得写到这里关于高德地图 3D 建筑多楼层模型的核心链路已经比较完整了。从数据结构、坐标转换、GLCustomLayer 接入、three.js 楼层构建到交互和移动端适配每一个环节都可以单独展开去深挖。我自己最大的体会是做这类项目一定要尽早把“数据”和“渲染”分开验证。不要把楼层 GeoJSON 和渲染代码一起联调否则出来任何问题你分不清是坐标偏移、数据结构错误还是 WebGL 渲染问题。先用一个最简单的立方体渲染楼层验证地图到 three.js 的坐标换算没问题再逐步替换成真实楼层轮廓。另外如果你在高德地图官方的 buildings 白模上叠加自建楼层关不关showBuildingBlock都纠结很久。我的经验是独立展示目标楼栋时就关掉整片城区同时展示时就保留但给自己楼栋的材质加一点发光或者描边和周边白模形成区隔效果会很不错。最后再分享一个小技巧楼层切换时给楼层 Mesh 加一个简单的 TWEEN 位移和透明度变化比如当前楼层从底部升起 0.2 秒比直接 visible 切换流畅很多。这个动画用原生 JS 写也不复杂关键是体现“层”的概念而不是瞬间消失出现。多楼层模型的体验好坏往往就藏在这些细节里。