简介:一套将three.js与高德地图结合并引入OBJ格式模型的完整示例代码包,面向Web前端与GIS可视化初学者,重点解决在三维地图场景中加载外部OBJ模型并同步渲染的常见问题。资源共6个文件,核心包含three.js主库、OBJ模型加载脚本、可直接运行的HTML示例页面,同时附带OBJ格式模型文件、页面预览图以及项目启动说明文档。压缩包整体约1.41MB,体量轻、目录结构清晰,便于快速下载、按需修改和本地调试。目前已有2981人学习,适合需要在高德地图上叠加三维物体、尝试模型导入渲染的开发者参考使用。通过阅读启动说明并对照HTML示例,可以直观看到OBJ模型的加载调用方式、模型与地图场景的坐标对齐思路,以及three.js相机与渲染器在地图容器内的配置方法;还能进一步理解坐标转换、模型缩放与场景融合的细节,帮助开发者少走弯路,快速完成从模型文件到地图可视化的项目落地。
1. three.js 与高德地图结合:为什么要在 WebGIS 里加载 OBJ 模型
做三维可视化的人迟早会撞上一个需求:把高德地图的底图跟 three.js 的场景叠在一起,再往里塞一个 OBJ 格式的模型。这套组合不是炫技,是实实在在的业务场景——智慧城市项目里要把建筑单体放回真实经纬度,园区可视化要把设备模型钉在地图上,甚至是游戏地图的 Web 化原型,都需要 WebGIS 底图加真实模型的双层结构。
我最早接触这个需求是在做一个物流园区的数字孪生项目,甲方要求「模型必须落在真实地图上,不能飘」,当时选型就卡在高德地图 JS API 和 three.js 的坐标系换算上。OBJ 格式虽然老,但胜在通用,Blender、3ds Max 导出的模型几乎都能直接转出来,而且 three.js 的 OBJLoader 加载逻辑足够成熟。这套方案的难点从来不在加载文件本身,而在两套坐标系怎么对齐、层级怎么组织、模型怎么随地图拖动同步更新。本篇就把这条完整的落地链路拆开,从坐标系换算到实际渲染,再把踩过的坑一个个列出来。
2. 坐标系换算:把 three.js 世界坐标钉在高德地图经纬度上
2.1 高德地图投影与 three.js 坐标系的对应关系
高德地图 JS API 默认用的是 GCJ-02 坐标系,也就是俗称的「火星坐标」,它在 Web 墨卡托投影下渲染。three.js 这边则是标准的右手坐标系,X 向右、Y 向上、Z 向外,单位是米。要让模型落在正确位置,核心动作是把经纬度坐标转成以某个参考点为原点的平面偏移量,再映射到 three.js 的 XZ 平面。
常见做法是选一个地图中心点作为 three.js 场景的原点,然后对每个模型的经纬度做差值计算。高德 API 提供了AMap.LngLat对象的distance方法,能直接算两个经纬度点之间的平面距离,但需要注意它返回的是球面距离近似值,在几公里范围内误差可以忽略,超出 10 公里就需要分块处理。
我在项目里一般这样处理:先选定中心点,然后把所有模型的经纬度减掉中心点经纬度,再乘以固定比例转成米。这个比例在高德地图下取 111319.49 米/度(赤道附近),但更稳妥的做法是直接用AMap.LngLat.distance算两点间距离,因为 GCJ-02 在不同纬度上的比例尺不完全一样。
2.2 将经纬度偏移转为 three.js 场景坐标
// 中心点:模型要围绕的经纬度 const centerLng = 116.397428; const centerLat = 39.90923; // 计算模型坐标与中心点的偏移(单位:米) function lngLatToScenePosition(lng, lat) { const center = new AMap.LngLat(centerLng, centerLat); const target = new AMap.LngLat(lng, lat); // 计算东西方向距离(保持纬度不变) const eastWest = center.distance(new AMap.LngLat(lng, centerLat)); // 计算南北方向距离(保持经度不变) const northSouth = center.distance(new AMap.LngLat(centerLng, lat)); // 判断方向:目标在中心点东侧还是西侧 const dx = lng >= centerLng ? eastWest : -eastWest; const dy = lat >= centerLat ? northSouth : -northSouth; // three.js 中:X 轴向东,Z 轴向南(注意方向) return { x: dx, z: -dy }; }这段代码的核心逻辑是分别算经度方向和纬度方向的距离,再根据目标点相对中心点的方位确定正负号。AMap.LngLat.distance接受两个参数都是AMap.LngLat实例,所以我在计算东西距离时把纬度固定为centerLat,计算南北距离时把经度固定为centerLng。
注意这里 Z 轴取负值:three.js 中默认 Z 轴正方向朝向屏幕外,而地图上北方向是 Y 轴正方向,为了保持「北为正」的直觉,我把南方向映射为 Z 轴负向。实际项目中你也可以反过来,只要保证整个场景里方向统一即可,否则模型会旋转 180 度。
2.3 中心点选取与精度边界
中心点不是随便选一个经纬度就行,它决定了整个 three.js 场景的原点位置和数值精度。three.js 底层用 Float32 存储顶点坐标,当坐标绝对值超过 10000 米时,浮点数精度会明显下降,表现为模型顶点抖动、纹理错位。
所以我的习惯是把中心点选在所有模型的几何中心附近,而不是地图中心。先用所有模型的经纬度求平均值,再把这个平均值作为中心点,这样能把坐标数值控制在正负 5000 米以内,精度问题基本不会出现。如果项目范围超过 20 公里,建议按区域拆成多个场景分别加载,不要硬塞进一个场景里。
3. 加载 OBJ 模型到 three.js 场景:从引入到落位
3.1 OBJLoader 使用与模型路径处理
three.js 从 r95 之后把 OBJLoader 拆到了examples/jsm/loaders/OBJLoader.js,使用 ES Module 方式引入。如果用的是 CDN 方式,需要注意版本号对应关系,旧版可能在examples/js/loaders/目录下。
import * as THREE from 'three'; import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; // 创建加载器 const loader = new OBJLoader(); // 加载 OBJ 模型 loader.load( '/models/building.obj', (object) => { scene.add(object); console.log('模型加载成功,顶点数:', object.children[0].geometry.attributes.position.count); }, (xhr) => { const percent = (xhr.loaded / xhr.total) * 100; console.log(`加载进度:${percent.toFixed(2)}%`); }, (error) => { console.error('OBJ 模型加载失败:', error); } );OBJLoader.load有四个参数:模型 URL、加载成功回调、进度回调、失败回调。成功回调里拿到的是一个THREE.Group对象,因为 OBJ 格式里可能有多个子网格,直接 add 到场景即可。进度回调的xhr.loaded和xhr.total可以用来做进度条。
这里有个细节容易被忽略:OBJ 文件通常会伴随一个 MTL 材质文件,OBJLoader 在 three.js r150 之后默认不会自动加载 MTL,需要单独用MTLLoader配合。如果你的 OBJ 文件没有 MTL,模型会显示为默认的白色材质,需要手动赋材质。
3.2 OBJ 模型尺寸归一化
OBJ 文件里的模型单位可能是厘米、米甚至任意自定义单位,从建模软件导出的模型尺寸跟实际场景完全对不上是常态。我遇到过从 SketchUp 导出的 OBJ,一个 10 米高的建筑在场景里显示成 0.01 单位,直接看不清。
// 模型加载后做尺寸归一化 function normalizeModel(object, targetHeight) { const box = new THREE.Box3().setFromObject(object); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); // 计算缩放比例:让模型最大边长等于 targetHeight const scale = targetHeight / maxDim; object.scale.set(scale, scale, scale); // 重新计算包围盒 box.setFromObject(object); const center = box.getCenter(new THREE.Vector3()); // 把模型底部中心对齐到原点,方便后续定位 object.position.set(-center.x, -box.min.y, -center.z); }归一化逻辑分三步:先用Box3.setFromObject算出模型包围盒,再按最大边决定缩放比例,最后把模型底部中心移到原点。这里把底部对齐而不是中心对齐,是为了后续经纬度定位时方便——建筑模型站到地面高度 0 的位置,视觉上更符合直觉。
targetHeight的取值取决于你的业务单位。如果项目里约定「1 单位 = 1 米」,那 targetHeight 就取实际建筑高度,比如 30 米。如果没有实际高度数据,可以统一归一化到 10 单位,先保证模型能看见、比例协调。
3.3 模型单次加载与缓存策略
OBJ 文件一般不算大,但如果是在线加载,每次刷新页面都重新下载文件会拖慢首屏速度。常规做法是用浏览器的 HTTP 缓存,给模型 URL 加版本号参数:
// 带版本号的模型加载,避免缓存旧文件 function loadModelWithVersion(url, version) { const separator = url.includes('?') ? '&' : '?'; const fullUrl = `${url}${separator}v=${version}`; return loader.loadAsync(fullUrl); }loadAsync是load的 Promise 封装版本,更适合配合async/await写流程控制。版本号参数v在开发环境下每次改模型递增,生产环境固定一个版本号即可。
另外一个经验是:如果同一个 OBJ 要被多个场景实例复用,先用loader.loadAsync加载一次,拿到对象后clone()放入不同位置。直接scene.add同一个对象引用会导致多个位置共享同一个几何体,改一个模型所有实例都变,这在 three.js 里是个隐蔽的坑。
4. 高德地图图层与 three.js 场景融合渲染
4.1 地图容器与 three.js canvas 的层级方案
这是整个方案里最容易翻车的环节。高德地图 JS API 默认在容器里创建自己的 canvas 或 DOM 元素,three.js 也想创建一个 canvas,两者叠在一起就会出现层级问题:要么模型被地图盖住,要么地图绑定的事件(拖拽、缩放)失效。
我在项目里用的方案是双容器叠加:底层放地图容器,顶层放 three.js 的 canvas,然后给 canvas 设置pointer-events: none,让鼠标事件穿透到地图层。这样地图能正常拖拽缩放,three.js 场景在地图上叠加渲染。
<div id="map-container" style="width: 100%; height: 100vh; position: relative;"> <div id="map" style="width: 100%; height: 100%;"></div> <canvas id="three-canvas" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10;"></canvas> </div>pointer-events: none是关键属性,它让鼠标事件完全穿透 canvas,落到地图层上。three.js 场景只需要做渲染,不需要处理鼠标事件——比如点击模型这类交互,需要穿透后通过地图的坐标换算回 three.js 世界坐标来处理,后续会讲思路。
这里需要处理的一个问题是:地图拖拽时,底图会平移,但 three.js canvas 不会自动跟着动。解决方案是在地图的moveend事件或map.on('move')回调里,重新计算模型的 world 坐标并把相机位置同步移动。高德地图的map.getCenter()会返回当前视口中心的经纬度,以此为基准重新计算所有模型的场景坐标。
4.2 地图事件同步相机
// 初始化高德地图 const map = new AMap.Map('map', { zoom: 16, center: [centerLng, centerLat], viewMode: '2D' }); // three.js 场景初始化 const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), alpha: true, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); // 同步函数:地图中心变化时更新 three.js 相机 function syncMapWithScene() { const mapCenter = map.getCenter(); const delta = lngLatToScenePosition(mapCenter.lng, mapCenter.lat); // 把三维场景原点平移到当前地图中心对应的位置 scene.position.set(delta.x, 0, delta.z); } // 监听地图移动事件(拖动时连续触发) map.on('moving', syncMapWithScene); // 移动结束后再确认一次,避免出现残影 map.on('moveend', syncMapWithScene);这个同步的核心思想是:不去移动单独的某个模型,而是整体移动整个 three.js 场景的根节点scene.position。当地图中心变化时,算出一个新的场景偏移量,把所有模型整体平移过去,视觉上就是模型跟着地图移动。
这种方式比移动每个模型高效得多,因为只改了一个 Transform 矩阵,GPU 端重新计算一次即可。moving事件在地图拖动过程中频繁触发,会有一定性能开销,如果感觉卡顿,可以只监听moveend——代价是拖动过程中模型会滞后,直到松手才跳到位。
4.3 层级遮挡与透明度处理
模型要显示在地图建筑上方,视觉上通常希望地图底图是半透明的或者模型覆盖区域不显示底图。高德地图支持map.setMapStyle切换样式,可以用amap://styles/light等内置样式,也可以自定义 JSON 样式把地图建筑透明度调低。
如果我做的话,会优先把地图的矢量建筑图层关掉或调透明,只留道路和标注,这样叠加的 OBJ 模型不会跟底图的建筑图层「打架」。高德地图自定义样式配置项不少,但核心就两条:"building"图层的透明度改到 0.1 左右,道路文字标注保留。这样模型在视觉上是站在地图路面上的,而不是被底图建筑顶穿。
5. 避坑指南:OBJ 模型不显示、错位、闪烁的排查路径
5.1 现象一:模型加载成功但看不到
原因:最常见是模型坐标离场景原点太远,超出了相机的可见范围。我排过很多次这种问题,最后发现模型坐标是 500 万米开外(经纬度直接当坐标用了),相机默认在原点附近看向 -Z,模型在视野外自然不会显示。
解决:在成功回调里先打印模型的世界坐标和包围盒,确认模型位置在相机视野内。用console.log(object.position)加上object.geometry.computeBoundingBox()打印包围盒坐标。超过 10000 就检查经纬度转坐标的逻辑。
5.2 现象二:模型加载时提示 404
原因:OBJLoader 内部用 fetch 加载文件,URL 路径是相对于当前页面的,不是相对于 JS 文件的。很多人把 OBJ 放在跟 JS 同一个目录,然后写相对路径,但页面在另一个目录,就 404 了。
解决:统一用绝对路径或者从站点根目录开始的路径,比如/static/models/building.obj。注意 MTL 文件的引用路径是写在 OBJ 文件内部的mtllib指令里,由 MTL 加载器根据 OBJ 文件的 URL 去拼,如果 OBJ 在 CDN 而页面在另一个域名,可能触发跨域问题,需要服务端加 CORS 头。
5.3 现象三:模型位置对不上,偏移量固定且方向一致
原因:经纬度计算时光考虑了经度方向距离,没有做纬度修正。高德地图的AMap.LngLat.distance在南北方向计算时已经考虑了不同纬度下的比例,但如果你自己用「经度差乘以固定系数」来算,纬度越高误差越大,在哈尔滨和在三亚,同一个经度差对应的实际距离完全不同。
解决:统一改用AMap.LngLat.distance按东西、南北两个方向分别计算,不要用固定系数。如果离线环境下不能用高德 API,就用 Haversine 公式自己算,误差控制在厘米级。
5.4 现象四:模型闪烁或纹理错位
原因:坐标绝对值超过 Float32 精度可靠范围。three.js 默认的 Vector3 是 Float32 存储,坐标值在 100000 以上时,相邻两个浮点数之间的间隔大于视觉可感知阈值,导致顶点位置在每帧重算时不稳定。
解决:把场景原点移动到模型几何中心附近,让所有模型坐标保持在正负 10000 米范围内。如果模型分布范围实在太大,就拆场景加载,每个子场景独立原点。
5.5 现象五:地图拖拽时模型撕裂或闪黑
原因:moving事件里直接修改scene.position后,three.js 渲染循环和地图渲染循环在时间上不同步,浏览器合成帧时出现半帧状态。另外 Windows 浏览器在 GPU 负载高时有概率出现 canvas 撕裂。
解决:把场景位置更新放在requestAnimationFrame回调里做一次批量更新,不要在地图事件回调里直接改。渲染循环里加一个needsUpdate标志位,只有地图事件触发时设为 true,下一帧再执行位置更新,能明显减少闪黑。
6. 进阶技巧:用轨迹球控制相机并与高德地图交互
6.1 相机跟随地图缩放与旋转
地图缩放时,three.js 相机视角要同步变化,否则模型大小和地图比例会不协调。高德地图的zoom事件返回缩放级别,可以映射到 three.js 相机的position和fov上。
// 地图缩放时同步调节 three.js 相机 map.on('zoomchange', () => { const zoom = map.getZoom(); // zoom 范围通常 3-20,映射到合适的相机高度 const cameraHeight = 800 / Math.pow(2, zoom - 16); camera.position.set(0, cameraHeight, 0); camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); });这里的思路是用高度控制缩放比例:zoom 越大代表视口范围越小、细节越多,相机就越靠近地面。Math.pow(2, zoom - 16)让 zoom=16 时高度为 800,每放大一级高度减半,对应视野范围减半,跟地图缩放的视觉效果基本一致。具体系数可以根据项目调,但思路就是这样。
6.2 鼠标穿透后的模型拾取
前面提到 canvas 设置了pointer-events: none,如果要实现点击模型高亮,不能直接用 raycaster 的鼠标坐标,因为获取不到鼠标在 canvas 里的位置。常见做法是监听地图容器上的鼠标事件,把鼠标相对窗口的坐标换算成 canvas 内的归一化坐标。
// 在地图容器上监听点击 const container = document.getElementById('map-container'); container.addEventListener('click', (event) => { const rect = container.getBoundingClientRect(); const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1; const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1; const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(mouseX, mouseY); raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); if (intersects.length > 0) { console.log('选中模型:', intersects[0].object.name); } });鼠标坐标转成 three.js 的 NDC 坐标(-1 到 1)后,raycaster 就能正常工作了。intersectObjects的第二个参数true表示递归查找子对象,因为 OBJ 模型的网格通常嵌套在 group 里。
这段逻辑放到了地图容器上而不是 canvas 上,所以即使 canvas 穿透了鼠标事件,点击模型依然能触发。不过这里有一个代价:地图本身的点击事件也会同时触发,比如点击地图上的 POI 标记。如果不需要拾取模型的时候,要加标志位或者手动移除事件监听,避免两者互相干扰。
6.3 内置调试面板与模型热替换
最后分享一个我最依赖的调试方式:在页面上挂一个简单的状态面板,展示当前地图中心经纬度、场景原点、模型数量和 FPS 帧率。没有这个面板,排查「模型为什么没显示」这类问题的效率会低非常非常多。
每隔 500ms 采样一次 FPS:
let lastTime = performance.now(); let frames = 0; function trackFPS() { const now = performance.now(); if (now - lastTime >= 500) { const fps = (frames * 1000) / (now - lastTime); document.getElementById('fps').textContent = fps.toFixed(1); lastTime = now; frames = 0; } frames++; requestAnimationFrame(trackFPS); }从那以后,我每次离开办公室之前都会强制检查一遍坐标换算脚本的单元测试输出——这个习惯是从那次模型全部飞到地图外的深夜事故里学到的。当时项目上线前一天发现所有模型偏移了几公里,排查了两个小时才定位到经纬度正负号写反。从那以后我每次做坐标系换算都会先打印出转换前后的值做人工核对,确认训练模型已经落在中心点周围的正方形区域里才继续往下调。希望这篇拆解能帮你少走这几公里的弯路。
本文还有配套的精品资源,点击获取