
最近接了个园区可视化的需求甲方甩来一句话把这几栋楼的每一层都做成3D模型点击楼层能看企业信息。我打开高德地图把视角拉到60度看到的只是几栋灰扑扑的楼块——没有窗户、没有分层、更没有楼号。默认3D建筑能做到的事情止步于“这里有一栋楼”而“这栋楼有几层、每层做什么”完全指望不上。所以这篇文章围绕高德地图实现3D建筑多楼层模型的相关代码来写我会从方案选型、核心代码、交互效果到实测踩坑一步步讲清楚。文中涉及的内容在高德JS API 2.0和Three.js环境下验证过如果你正准备做园区楼宇可视化、数字孪生或3D地图大屏这篇文章可以直接当落地参考。1. 默认3D建筑是“楼块”不是“楼层”1.1 高德自带的3D建筑为什么做不了多楼层高德地图在viewMode: 3D下确实有建筑体块效果把俯仰角拉大能看到一栋栋楼从地面“长”出来。这个效果本质上是依据路网和建筑足迹数据做的拉伸体每一个建筑只对应一个向上升起的Box没有楼层拆分没有外立面细节也没有楼内实体结构。它的用途是让地图看起来更立体、更有空间感而不是帮你承载业务数据。我做这个项目时一开始也想偷懒试图用高德自带的楼块叠加信息窗口来替代楼层展示结果很快被打脸楼块是一个整体没法单独给第3层上色也没法只让第5层响应点击。你只能整栋楼挂一个事件这在“按楼层看企业信息”的需求面前基本等于没有实现。如果只是做个大屏背景那官方楼块完全够用渲染性能也经过官方优化不用操心。但一旦需求落到“楼层级交互”就必须自己动手在3D场景里建立“楼层”这个概念。1.2 三条技术路线我最终选了Custom3DLayer市面上能够实现高德地图多楼层模型的技术路线主要分三类各有各的适用场景方案实现难度楼层交互性能适用场景官方楼块 信息窗口低只能整栋好纯可视化背景Custom3DLayer Three.js Mesh中每层可独立操作中等楼层级交互CustomLayer 自定义glTF模型高需要额外处理较低精细化展示直接用高德的AMap.Custom3DLayer是当前性价比最高的路径。它底层基于Three.js但不需要从零引入一堆底层渲染逻辑高德帮你把地图瓦片和三维视角的同步做了。你只需要拿到scene和camera然后像平时写Three.js一样往场景里塞建筑模型就行。有人可能会问直接用Three.js覆盖一个透明Canvas层做地图叠加行不行当然行但你要手动处理每次地图拖动、缩放、俯仰时模型和地图像素的同步还得处理建筑遮挡关系。项目周期紧的时候这种“从零开始”的方案看起来很自由实际上是一个大坑。Custom3DLayer把这些同步做了虽然它暴露的API在不同高德版本里有些差异但核心思路都一样往地图里嵌入一个Three.js世界。2. 把Three.js塞进高德地图Custom3DLayer的使用思路2.1 地图初始化参数哪些直接影响3D效果先看一段最基础的地图初始化代码。要让后续3D楼体正常工作下面几个参数是必须配对的const map new AMap.Map(container, { viewMode: 3D, // 必须开启3D模式否则后面一切白搭 pitch: 60, // 俯仰角越大越接近俯视60度比较适合看楼层 zoom: 17, // 城市建筑适合16-18之间 center: [116.397428, 39.90923], mapStyle: amap://styles/light, // 浅色底图方便3D模型突出 showBuildingBlock: false // 关闭官方楼块避免和自建模型打架 });showBuildingBlock这一项很容易被忽略。我第一版测试时没关它结果自建的多楼层模型旁边还杵着官方楼块整个画面非常乱。关闭官方楼块之后三维世界只保留自建模型不管是视觉聚焦还是性能损耗都正常了。pitch是看楼层的关键。默认地图的俯仰角是0也就是垂直俯视这时候3D楼体被压缩成平面看起来毫无立体感。把pitch拉到50-70度之间楼体的侧立面才能露出来楼层分割线和玻璃材质才有效果。但要注意pitch太大楼体后面的建筑会遮挡视线所以通常配合rotation参数让地图旋转到合适角度。2.2 拿到scene之后先验证坐标系Custom3DLayer 的使用逻辑很简单创建图层实例获取内部Three.js的scene和camera然后往场景里放东西。const custom3DLayer new AMap.Custom3DLayer({ map: map, visible: true, zooms: [3, 20] }); const scene custom3DLayer.getScene(); const camera custom3DLayer.getCamera();这里有个关键问题Three.js世界坐标系和高德的经纬度坐标之间怎么对应不同高德版本提供的转换接口不太一样有的版本内部会直接暴露类似positionToWorld的方法有的需要自己处理。我在项目里用的是“以地图中心点为原点做米制换算”的方式因为多楼层模型的应用范围一般就几公里内米制换算在这个尺度下足够精确。function lngLatToLocal(lng, lat) { const centerLng 116.397428; const centerLat 39.90923; // 经度方向1度约等于111320 * cos(lat) 米 // 纬度方向1度约等于110540 米 const xOffset (lng - centerLng) * 111320 * Math.cos(centerLat * Math.PI / 180); const zOffset -(lat - centerLat) * 110540; return new THREE.Vector3(xOffset, 0, zOffset); }这里的z取了负值是因为Three.js默认坐标系中正Z轴朝屏幕外而地图纬度向北增加时在三维场景里对应的应该是负Z方向。如果写反了建筑会跑到地图的对称位置去整个场景错乱。这个坑我后面还会细讲。2.3 跑通一个最小Demo的完整代码在继续做多楼层之前先确认Custom3DLayer能跑通。下面是最小可运行代码一堆代码里放一个立方体!DOCTYPE html html head meta charsetutf-8 script srchttps://webapi.amap.com/maps?v2.0keyYOUR_KEY/script script srchttps://unpkg.com/three0.150.0/build/three.min.js/script /head body div idcontainer stylewidth: 100vw; height: 100vh;/div script const map new AMap.Map(container, { viewMode: 3D, pitch: 60, zoom: 17, center: [116.397428, 39.90923] }); const layer new AMap.Custom3DLayer({ map: map }); const scene layer.getScene(); const camera layer.getCamera(); // 用经纬度转成局部坐标 const pos lngLatToLocal(116.397428, 39.90923); const box new THREE.Mesh( new THREE.BoxGeometry(30, 20, 30), new THREE.MeshPhongMaterial({ color: 0x00aaff }) ); box.position.copy(pos); box.position.y 10; // 半个楼高让底面贴地 scene.add(box); function lngLatToLocal(lng, lat) { const centerLng 116.397428; const centerLat 39.90923; const x (lng - centerLng) * 111320 * Math.cos(centerLat * Math.PI / 180); const z -(lat - centerLat) * 110540; return new THREE.Vector3(x, 0, z); } /script /body /html这段代码跑起来能看到一个蓝色立方体立在地图指定位置说明坐标链路已经通了。接下来就可以在这个基础上扩展出真正的多楼层模型。3. 参数化搭建多楼层层高、外立面和楼层点选3.1 楼层参数化设计把楼变成数据和循环多楼层模型的核心不是“一栋楼”而是“一组楼层”。每个楼层都应该包含以下参数楼层编号、层高、楼体宽度、进深、材质颜色、是否可见、是否可点击。我习惯先把这些参数抽成一个对象数组用数据驱动建模这样后续接后端接口时只需要替换数据源不需要改建模逻辑。const buildingData { name: A栋, position: { lng: 116.397428, lat: 39.90923 }, floorHeight: 3.5, width: 32, depth: 24, floors: [ { id: 1, name: 1F, color: 0x3b82f6, height: 4.2 }, // 大堂层高一点 { id: 2, name: 2F, color: 0x60a5fa, height: 3.5 }, { id: 3, name: 3F, color: 0x93c5fd, height: 3.5 }, { id: 4, name: 4F, color: 0x93c5fd, height: 3.5 }, { id: 5, name: 5F, color: 0xbfdbfe, height: 3.5 }, { id: 6, name: 屋顶层, color: 0x94a3b8, height: 3.0 } ] };这样的数据结构非常直观后续做楼宇信息面板、筛选高亮、楼层隐藏都方便。height单独配置而不是统一用floorHeight是因为实际建筑的一楼大堂、机房层、屋顶层高度往往不同。3.2 每层单独Mesh还是合并几何体这是建楼体模型时最核心的一个决策。如果每层楼都是一个独立的Mesh好处是交互方便——用Raycaster拾取时直接就能判断点到哪个楼层设置颜色、透明度非常灵活。坏处是渲染开销大。假设一栋楼6层10栋楼就是60个Mesh每个Mesh都是一个独立的渲染单元每帧都要更新性能会肉眼可见地下降。如果为了性能把楼体合并成一个几何体交互又变得麻烦。你需要在拾取时自己维护“每个顶点属于哪一层”的映射关系代码复杂度成倍上升。我的建议是楼层数量少时比如单栋楼不超过20层直接用独立Mesh交互优先楼层规模大时用InstancedMesh或合并几何体再通过实例索引或顶点颜色做拾取映射。在实际项目里园区可视化一栋楼一般6到15层直接独立Mesh完全没问题。我的第一版实现就是这种方案性能并没有成为瓶颈。只有当几百栋楼同时展示时才需要考虑合批。楼层循环建模的核心代码function createBuilding(data, layer) { const group new THREE.Group(); const floorMeshes []; const baseY 0; data.floors.forEach((floor) { const geometry new THREE.BoxGeometry( data.width, floor.height, data.depth ); const material new THREE.MeshPhongMaterial({ color: floor.color || 0x60a5fa, transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthWrite: true }); const mesh new THREE.Mesh(geometry, material); // 计算当前的累积高度前面所有楼层的高度之和 当前楼层高度的一半 let accumulatedHeight 0; for (let i 0; i floor.id - 1; i) { accumulatedHeight data.floors[i].height; } mesh.position.y accumulatedHeight floor.height / 2; mesh.userData { floorId: floor.id, floorName: floor.name, buildingName: data.name }; floorMeshes.push(mesh); group.add(mesh); }); // 整体位置设置到地图坐标 const pos lngLatToLocal(data.position.lng, data.position.lat); group.position.copy(pos); scene.add(group); return floorMeshes; }这里的userData是Three.js给每个对象提供的自定义数据挂载点我把楼层ID、名称都塞进去后面做点击交互时直接取出来用。3.3 外立面材质玻璃、楼板和分割线如果用纯色BoxGeometry做楼层模型会显得非常“玩具”。实际项目里甲方最不喜欢的就是这种方块堆积效果。我建议至少做两层材质细节第一层是楼体的半透明玻璃外墙材料第二层是楼板层的分割线。最简单有效的做法是给楼层模型增加一个比主体稍大一点的线框或分隔片。// 给每个楼层加一个楼板贴楼体顶部的薄片视觉上形成楼层分割 function createFloorSlab(width, depth, height, color) { const slab new THREE.Mesh( new THREE.BoxGeometry(width 0.2, 0.1, depth 0.2), new THREE.MeshStandardMaterial({ color: color || 0x94a3b8, roughness: 0.6 }) ); return slab; }把楼板叠加到每层顶部外立面的“横向分割线”就出来了。如果想让玻璃幕墙有更真实的反射效果可以给主体玻璃使用THREE.MeshPhysicalMaterial并设置metalness、roughness和envMap。但要注意物理材质的渲染开销比Phong材质高移动端或低端设备上会明显吃力。3.4 用Raycaster点选楼层有了独立的楼层Mesh点选交互就非常简单了。关键是拿到Custom3DLayer内部的camera然后把鼠标位置映射成Three.js的射线。const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); container.addEventListener(click, (e) { const rect container.getBoundingClientRect(); mouse.x ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y -((e.clientY - rect.top) / rect.height) * 2 1; raycaster.setFromCamera(mouse, camera); const hits raycaster.intersectObjects(floorMeshes, false); if (hits.length 0) { const data hits[0].object.userData; console.log(点击了建筑, data.buildingName, 楼层, data.floorName); // 后续在这里触发信息窗口或楼层高亮 } });需要注意intersectObjects的第二个参数设为false不要递归。如果设成true而你往Mesh里又加了一些子对象Raycaster会返回最内层的子对象而不是你挂载了userData的楼层Mesh数据取起来就很绕。4. 让楼“活”起来视角联动、楼层高亮与雷达扩散4.1 控制每层透明度模拟“当前层”多楼层模型最常用的交互是“只看某一层”。做法很简单把目标楼层的透明度设为1其余楼层透明度降到0.15左右这样可以看清选中楼层在企业空间里的位置又不会失去楼宇整体的上下文。function highlightFloor(floorMeshes, targetFloorId) { floorMeshes.forEach(mesh { const id mesh.userData.floorId; if (id targetFloorId) { mesh.material.opacity 1; mesh.material.depthWrite true; } else { mesh.material.opacity 0.15; mesh.material.depthWrite false; } }); }这里有个比较隐蔽的问题透明物体的渲染顺序受深度写影响。如果让其他楼层透明后仍然写深度透明楼层的碎片会遮挡后面的楼体。所以要把非当前楼层的depthWrite设为false渲染顺序才正常。很多开发者在这个细节上卡住透明是透明了但楼后方的墙体会消失或者乱掉就是这个原因。4.2 点选高亮与相邻楼层半透明在实际业务里“只亮当前楼层”有点极端用户常常还想看到上下层的结构关系。演化出的一个更好用的方案是点击第3层第3层正常显示第2层和第4层半透明更远的楼层几乎隐藏。这样既突出目标又保留了建筑的垂直关系。function highlightFloorWithNeighbors(floorMeshes, targetFloorId) { floorMeshes.forEach(mesh { const diff Math.abs(mesh.userData.floorId - targetFloorId); if (diff 0) { mesh.material.opacity 1; } else if (diff 1) { mesh.material.opacity 0.5; } else { mesh.material.opacity 0.1; } mesh.material.needsUpdate true; }); }这个效果在演示时特别加分尤其是面向甲方的汇报场景能直观展示“我们对每一层都做了建模”。4.3 建筑底部的雷达扩散动画当时还有一个需求是“让重点建筑在地图上跳出来”除了放大缩放入场动画最实用的就是雷达扩散波。这个在Three.js里实现很简单用RingGeometry做一个贴地圆环然后让它不断扩大并淡出。const ring new THREE.Mesh( new THREE.RingGeometry(1, 3, 64), new THREE.MeshBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false }) ); ring.rotation.x -Math.PI / 2; // 让圆环水平贴地 ring.position.set(0, 0.2, 0); // 稍微抬高一点避免与地面深度打架 buildingGroup.add(ring); function updateRing(elapsed) { const progress (elapsed % 2000) / 2000; // 2秒一个周期 const radius 5 progress * 50; const scale radius / 5; ring.scale.set(scale, scale, 1); ring.material.opacity (1 - progress) * 0.8; }将这个updateRing放进requestAnimationFrame循环里即可。注意这个圆环是挂在建筑分组里的它的坐标和建筑一起移动不会因为地图旋转而错位。如果动画放在地图Canvas上你还需要在地图的move、zoom事件里同步位置比较麻烦。Custom3DLayer方案省去了这部分动画自然跟随地图变换。5. 实测踩坑坐标偏移、盖层闪烁、性能陡降和账单5.1 建筑整栋偏移几十米坐标换算的坑我第一版测试代码跑出来楼并没有落到地图中心的建筑位置上而是整体往东北方向偏移了大约40米。当时我一瞬间以为是高德底图的GCJ-02坐标和WGS-84坐标的差异引起的排查了半天才发现问题出在自己的经纬度换算函数里。我用的“米制换算”方法里有一句const x (lng - centerLng) * 111320 * Math.cos(centerLat * Math.PI / 180);看起来没问题但我在计算偏移的时候centerLng和centerLat写了一对写死的值而地图实际中心点是另一组坐标。只要中心点不一致所有建筑的位置都会产生一个恒定偏移。排查过程其实也很简单我把模型位置和地图上一个已知的Marker坐标做对比发现偏移量是固定的不随建筑变化。这说明不是随机的系统抖动而是换算基准不一致。把所有坐标计算统一封装成一个函数任何地方都不要再写死中心点问题就解决了。另一个偏移来源是Cos修正漏写。如果在高纬度地区不乘Math.cos(centerLat)楼体会在东西方向上被放大越往北越明显。国内北方城市做项目时一定要记得乘这个系数。5.2 模型被道路盖住或闪烁深度测试乱套了模型跑通之后下一个遇到的问题就是建筑表面在特定视角下会“闪”或者干脆被道路、绿地图层盖住像是建筑陷进了地底。这个问题的根源在地图底图和Three.js场景的深度关系。Custom3DLayer本质上是叠加在地图上方的WebGL图层渲染顺序和深度分配如果和地图底图冲突就会产生闪烁。解决办法有几个按优先级排序确认建筑底面坐标紧贴地面不要在地势有起伏的区域建楼。给楼体Mesh设置合适的renderOrder让模型在地图底图之后渲染。关闭地图底图的立体建筑也就是前面说的showBuildingBlock: false减少深度冲突。如果地形起伏导致模型陷入地面可以给模型整体加上固定高度的“垫层”。“垫层”是一个很实用的土办法不追求建筑底面绝对贴合地形而是把一栋楼作为一个整体抬升固定高度然后用一个半透明底座连接地面。在视觉上底座遮挡了悬空感效果很好性能也几乎没有损失。5.3 楼层一多就掉帧合批与实例化园区里如果有几十栋楼每栋15层场景里就是几百个Mesh帧率会明显下降尤其在大屏显示设备上更明显。排查性能问题第一步是打开Three.js的Renderer统计看看draw calls到底有多少。如果draw calls超过500渲染压力已经很大了。当时我做的第一版优化是把每个楼层的墙体和楼板合并到一个几何体里这样一栋楼从十几个Mesh变成一个或者两个Meshdraw calls大幅下降帧率恢复稳定。这里分享一个平替方案如果楼层材质相同没有动态变化的需求可以直接用InstancedMesh来渲染整栋楼的相同楼层。它一次绘制就能渲染多个相同几何体性能非常高。但对需要单独改透明度的交互场景InstancedMesh用起来比较别扭因为你需要维护实例索引和楼层ID的映射。所以我最终用的是“按楼层分组楼层内部合并”的方式。5.4 API密钥泄露与配额账单热搜词里有“高德地图api收费坑人”这一点我确实也有体会。开发阶段偶尔弹个超限提示还能忍但如果你把Key写死在纯前端代码里并且没做域名白名单发布到生产环境后很可能被人调用每天把配额跑光。我的建议是措施说明域名白名单在高德开放平台控制台只允许自己的域名使用该Key配额监控控制台开启配额报警低于阈值及时通知Key轮换如果怀疑Key泄露立即禁用并在后台生成新Key生产环境评估商用授权项目上线前联系高德商务确认授权边界避免侵权风险这部分不是技术问题但往往比技术问题更致命。很多项目开发时一切正常上线后因为配额超限导致地图空白用户投诉后才发现问题出在Key管理上。6. 从Demo到业务系统数据接入、精细模型与上线注意6.1 用后端楼层数据驱动颜色和点击信息多楼层模型如果只做视觉展示价值有限。真正让它成为业务系统的是数据结构化。我在项目中把楼层数据设计成后端接口返回{ buildingId: A001, buildingName: A栋, position: { lng: 116.397428, lat: 39.90923 }, floors: [ { floorId: 1, name: 1F, function: 大堂, area: 1200, color: #3b82f6, company: 物业中心 }, { floorId: 2, name: 2F, function: 研发办公, area: 1100, color: #60a5fa, company: 某科技公司 } ] }前端拿到这个数据后用颜色映射function字段比如办公是蓝色、商业是橙色、食堂是绿色。点击楼层时弹窗显示面积、公司名称、当前入驻人数等信息。整个建模逻辑不需要改只需要把数据接入的位置替换成接口地址。如果后端没有这样的接口临时也能前端Mock数据但一定要在数据结构设计上预留扩展字段否则后期重新建模会很痛苦。6.2 引入glTF精细模型替代方块楼BoxGeometry毕竟只能做“方正”的建筑轮廓。如果建筑不是规则矩形比如弧形塔楼、阶梯式退台方块楼无法表达。这时候需要引入glTF/glb模型。高德官方Custom3DLayer的scene就是Three.js场景所以直接用Three.js的GLTFLoader加载模型然后放到对应经纬度即可const loader new THREE.GLTFLoader(); loader.load(models/building.glb, (gltf) { const model gltf.scene; const pos lngLatToLocal(buildingData.position.lng, buildingData.position.lat); model.position.copy(pos); scene.add(model); });需要留意的是模型比例。设计软件里导出的glTF模型通常以“米”为单位而高德地图里的建筑尺寸也是按米来算的一般能直接对齐。但有的建模师习惯用厘米或者毫米导入后建筑会巨大无比。定位到坐标后先看一眼模型包围盒的尺寸如果不对就手动scale。按照热搜里的“免费3d人物模型gltf”“tripoai图片生成3d模型价格”来看现在获取3D模型的渠道越来越多样但现在AI生成的模型主要还是出人物、物体建筑结构类模型还是需要靠专业人员用SketchUp、Blender、3ds Max导出。6.3 移动端和小程序端别直接照搬我在做完Web端之后甲方问能不能在微信小程序里也展示同样的效果。这里提前给想做跨端的读者提个醒小程序接入高德地图插件是一回事运行自定义Three.js场景又是另一回事。小程序端的WebGL能力、内存和CPU性能都比PC Web端受限不少。几十个楼层Mesh在PC上流畅运行在小程序里可能直接卡到没法用。我的处理思路是移动端先降低模型精度只保留楼体的简化轮廓不做楼层级交互或者把小程序定位成“只看总览信息”把楼层级交互引导到Web端完成。这样既保住了移动端的可用性又控制住了开发成本。6.4 上线前的自检清单最后整理一份我每次做这种高德3D多楼层项目都会走一遍的自检清单排查一遍基本不会出大问题地图Key是否绑定域名白名单测试Key有没有泄露到公网生产环境是否确认了高德API的商用授权边界官方楼块是否关闭自建模型有没有跟官方楼块视觉冲突建筑坐标换算是否统一使用同一个函数中心点是否一致透明楼层的depthWrite是否正确深度测试是否导致闪烁楼层多的时候是否做了合批draw calls是否在可接受范围低端设备上是否限制了像素比上限像素比这个细节我再多说一句。Three.js默认会取设备自己的像素比4K屏上如果让Renderer跟着设备像素比走GPU压力会非常大。适合的做法是把像素比限制在2以内renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));这个一行代码的改动在高分辨率屏幕上带来的性能提升非常明显值得养成习惯。高德地图的多楼层模型怎么做核心就是搞清Custom3DLayer的坐标链路然后用Three.js建楼层、做交互、控性能。真把第一个Demo跑通了后面的扩展基本都是沿着这条主线加深。希望我这篇实际踩出来的经验能让你少走几个弯路。