
第一次在浏览器里铺开一份由上万个面组成的行政区划数据时我差点被传统方案卡到怀疑人生拖动地图要等两三秒鼠标多悬停几次页面就发烫。后来把渲染层换成 Three.js用 WebGL 去跑 GeoJSON同样的数据量在普通笔记本上也能保持很顺手的帧率。这就是我想分享的入门路线学习 Three.js基于 GeoJSON 绘制 2D 矢量地图。这篇文章会把从数据到画面的完整链路拆开包括 GeoJSON 坐标细节、经纬度转平面坐标、ShapeGeometry 画区域、描边与文字、点击拾取高亮以及数据量上来后的合并优化。适合刚准备接触 Three.js 的前端也适合从 GIS 方向转过来的朋友参考。我会尽量写实际操作时才会碰到的坑而不是只给你一套跑得通但完全不知道为什么不动的代码。1. 为什么用 Three.js 画 2D 地图而不是 Leaflet 或纯 SVG很多人一听矢量地图第一反应是 Leaflet 加 GeoJSON 图层或者自己用 SVG 拼。这两种方式在小规模数据下没啥问题但数据量一上来瓶颈就非常明显。我要先讲清楚 Three.js 在这里的定位不然你学完之后容易用错地方。1.1 三种常见方案的差距方案渲染原理万级面数据拾取交互学习成本Leaflet GeoJSONSVG DOM 或 Canvas明显卡顿成熟但受 DOM 数量限制低原生 Canvas 2DCPU 绘制路径中等勉强可以要自己写命中测试中Three.jsWebGL GPU 渲染明显更流畅Raycaster 拾取中高Leaflet 本身是给在线瓦片地图设计的好东西它地图交互、缩放平移这套做得非常完整。但当你把一个几 MB 的 GeoJSON 丢给它的矢量图层它会生成大量路径对象。这些对象要么是 DOM要么是 Canvas path真正动起来时全部由 CPU 重新计算。上万个多边形同时拖拽浏览器处理起来肯定吃力。原生 Canvas 2D 好一些因为你把绘制逻辑握在手里可以自己控制重绘范围。但命中测试得自己解决鼠标点下去要知道点到哪个区了这个在 Canvas 2D 里没有现成工具。Three.js 的优势不在于名字里有 3D而在于它的 WebGL 渲染管线天然适合大量三角形。一个多边形不管多复杂三角化之后在 GPU 眼里只是几千几万个填充了的三角形移动相机视角时不需要重走路径解析逻辑。对做 2D 地图的人来讲你等于白拿了一套成熟的场景管理、相机控制和拾取系统。1.2 从放地图到摆模型的思维转换用 Three.js 画地图第一个思维跳跃是不要想地图图层而是想场景里的一组平面模型。传统 GIS 思维里地图由图层组成每个图层有自己的坐标系和样式。Three.js 思维里所有东西都在同一个三维空间中你只是把经纬度坐标换算成这个空间的 xy 值然后放在 z0 这个平面上。省界线是一条线几何体行政区是一个 ShapeGeometry城市名是一张贴了文字的 Sprite。这种思维一开始可能别扭但想通之后地图就变成了 3D 场景里的装饰物后面想加高度、做飞线、做热力柱都是同一个场景里加东西而已。你学的 Three.js 技能不会浪费矢量地图只是入门的第一关。1.3 也不是什么 map 都得用 Three.js说实话如果只是给几十个点做标注型地图我不会用 Three.jsLeaflet 加上图标 Marker 五分钟搞定没必要写一堆场景初始化代码。Three.js 的适用场景是面数量大、交互频繁、还要在地图上叠加自定义可视化效果的 Web 项目。判断标准很简单——先把数据量数一数几千个面是起步几百个面的简单页面别折腾。2. 动手前先吃透 GeoJSON坐标顺序、闭合环与 MultiPolygon数据是地图的原材料。GeoJSON 这个格式看着简单但里面藏了好几个会让新手画图失败的点。我见过太多人花一下午调代码最后发现是坐标顺序写反了。所以这块值得单独说。2.1 经纬度顺序GeoJSON 是先经度后纬度这是最容易踩的坑。绝大多数人熟悉的是地图应用里常见的写法——北京大概在北纬 39.9、东经 116.4于是习惯性写成[39.9, 116.4]。但 GeoJSON 的规范明确规定坐标数组顺序是经度在前、纬度在后即[longitude, latitude]。一条正确的点数据应该是[116.4, 39.9]。写一段最简单的 Polygon{ type: Feature, properties: { name: 示例区 }, geometry: { type: Polygon, coordinates: [ [ [120.1, 30.2], [120.3, 30.2], [120.3, 30.4], [120.1, 30.2] ] ] } }如果你把经纬度写反渲染出来的图形会和原始形状关于对角线镜像看起来像一个被翻转的省份。尤其当数据是真实行政区划时这种错位很隐蔽因为你一眼看过去还是有点像但和底图对不上。我自己的习惯是在加载数据后立刻打印几个坐标值确认是lon在lat前面再往下写解析逻辑。2.2 闭合环第一个点和最后一个点重复GeoJSON 的 Polygon 是由线性环组成的线性环要求起点和终点是同一个点逻辑上形成闭合。上面例子里最后一个坐标[120.1, 30.2]和第一个坐标完全一样就是为了满足这个要求。但 Three.js 的THREE.Shape不需要这个重复的收尾点。你调用moveTo和lineTo把点搞进去最后调用closePath()自动闭合。所以解析 GeoJSON 的时候要跳过每个环的最后一个点。千万别把整个环原封不动丢进去否则会出现一个极大或者重复的线段还可能让三角化算法产生错误的孔洞判断。2.3 MultiPolygon 和带洞行政区行政区划里常出现特殊形状一个区可能有几块互不相连的飞地这叫MultiPolygon也可能中间挖掉一块比如有的城市内部有一个单独托管区域那这个洞就是 Polygon 坐标数组里的第二段。嵌套结构{ type: Polygon, coordinates: [ [[120.1, 30.2], [120.3, 30.2], [120.3, 30.4], [120.1, 30.2]], [[120.15, 30.25], [120.2, 30.25], [120.2, 30.3], [120.15, 30.25]] ] }在 GeoJSON 中coordinates[0]是外环coordinates[1]开始是洞。解析成 THREE.Shape 时外环作为 Shape 的主路径洞塞进shape.holes数组。三个坐标层级容易搞混Polygoncoordinates是一个数组的数组的数组最外层是环的列表。MultiPolygoncoordinates是多个 Polygon 坐标的集合也就是再多一层嵌套。FeatureCollection包含多个 Feature每个 Feature 有自己的 properties 和 geometry。写解析函数时务必支持这几种类型否则一份数据有一部分画不出来你还在那儿找 bug 找半天。2.4 顺手验证数据我在开始写解析前通常先用一个轻量工具把 GeoJSON 打印简化版const summary features.map((f) ({ name: f.properties.name, type: f.geometry.type, count: f.geometry.coordinates.length })); console.table(summary);这样你能快速看到数据里有没有 GeometryCollection、有没有空几何、有没有 Polygon 的环数异常。数据质量过关后面的渲染代码才不会莫名其妙。3. 拉一个最小可跑工程坐标转换与第一张区域面现在进入实操。目标是先跑出一个最简单的画面一个来自 GeoJSON 的区域被渲染成一张彩色平面。我们不急着做交互、描边、标签先把链路打通。3.1 快速创建项目CDN 方式最简单如果你只是学习不想一开始就折腾 npm 和构建工具直接用 ES Module 加 importmap 引入 Three.js 就行。新建一个index.html!DOCTYPE html html head meta charsetutf-8 / titleGeoJSON 2D Map/title style body { margin: 0; overflow: hidden; background: #eef2f6; } /style /head body script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js } } /script script typemodule import * as THREE from three; // 后面代码都写在这里 /script /body /html这个方式对初学者最友好刷新就能看到效果不用被 Vite、Webpack 打扰。如果做正式项目再换成 npm 安装three配合 Vite 即可。3.2 初始化场景和相机2D 地图推荐用OrthographicCamera也就是正交相机。它没有透视近大远小适合俯视查看 GeoJSON 面数据。const scene new THREE.Scene(); scene.background new THREE.Color(0xeef2f6); const width window.innerWidth; const height window.innerHeight; const camera new THREE.OrthographicCamera( -width / 2, width / 2, height / 2, -height / 2, 0.1, 10000 ); camera.position.set(0, 0, 1000); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(width, height); document.body.appendChild(renderer.domElement); function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();正交相机初始 frustum 设成屏幕宽高是为了让后续地图坐标可以直接以像素级别出现。如果不做缩放这是最直观的方式。3.3 经纬度转平面坐标Three.js 的场景单位是无意义的数字但双方必须一致。我们不能直接把经纬度塞进去因为经纬度是球面角度值直接使用会导致图形坐标偏移到十万百万的尺度浮点精度也会受影响。做法是取数据的包围盒把中心点移动到原点附近。function getBounds(features) { let minLon Infinity, minLat Infinity; let maxLon -Infinity, maxLat -Infinity; function walk(coord) { for (const item of coord) { if (typeof item[0] number) { const [lon, lat] item; minLon Math.min(minLon, lon); maxLon Math.max(maxLon, lon); minLat Math.min(minLat, lat); maxLat Math.max(maxLat, lat); } else { walk(item); } } } features.forEach((f) walk(f.geometry.coordinates)); return { minLon, minLat, maxLon, maxLat }; }再用一个投影函数把经纬度换算成 xyconst bounds getBounds(features); const centerLon (bounds.minLon bounds.maxLon) / 2; const centerLat (bounds.minLat bounds.maxLat) / 2; const centerLatRad centerLat * Math.PI / 180; const R 6371000; // 地球半径单位米用来做合适的尺度 function lonLatToXY(lon, lat) { // Web Mercator 的 x 公式乘以 cos(centerLat) 做局部校正 const x (lon - centerLon) * R * Math.PI / 180 * Math.cos(centerLatRad); // 使用正球面墨卡托的 y 公式保证北距不与经线产生明显拉伸 const y R * Math.log(Math.tan(Math.PI / 4 lat * Math.PI / 360)); return { x, y }; }如果你的数据只是一个小城市、小县城的范围简单等距投影就够用经纬度差值直接乘以 111320 再乘cos(centerLat)。数据范围大到覆盖中国全省或全国时我会用正球面墨卡托的 y 公式避免高纬度地区纵向拉伸过于明显。3.4 把 Polygon 坐标转成 THREE.Shape拿到 GeoJSON 后核心是把它变成 Three.js 的 Shapefunction buildShapeFromCoordinates(coordinates, lonLatToXY) { const shape new THREE.Shape(); const outerRing coordinates[0]; let first true; for (let i 0; i outerRing.length - 1; i) { const [lon, lat] outerRing[i]; const p lonLatToXY(lon, lat); if (first) { shape.moveTo(p.x, p.y); first false; } else { shape.lineTo(p.x, p.y); } } shape.closePath(); for (let h 1; h coordinates.length; h) { const hole coordinates[h]; const path new THREE.Path(); first true; for (let i 0; i hole.length - 1; i) { const [lon, lat] hole[i]; const p lonLatToXY(lon, lat); if (first) { path.moveTo(p.x, p.y); first false; } else { path.lineTo(p.x, p.y); } } path.closePath(); shape.holes.push(path); } return shape; }然后生成几何体并丢到场景里const shape buildShapeFromCoordinates(feature.geometry.coordinates, lonLatToXY); const geometry new THREE.ShapeGeometry(shape); const material new THREE.MeshBasicMaterial({ color: 0x7fd0a0, side: THREE.DoubleSide }); const mesh new THREE.Mesh(geometry, material); scene.add(mesh);THREE.ShapeGeometry会帮我们做三角化把带洞和不带洞的多边形都变成三角形网格。MeshBasicMaterial不依赖光照画 2D 地图最合适颜色就是纯色。到这里如果你刷新页面应该能看到一块彩色区域出现在画布中央。如果看不到先检查你的坐标顺序和包围盒计算这两个地方最容易出错。4. 面、线、文字与配色凑出一个能看的地图只有一块纯色区域当然不够。一张完整的地图还需要分区域配色、边界线、名称标注。这一章把地图从能显示做到能看。4.1 给每个区县独立颜色行政区划地图通常需要对每个 Feature 按属性着色。比如按人口、GDP 或者自定义的数值字段做分级配色。先定义一个颜色映射函数function getColorByValue(value, min, max) { const t (value - min) / (max - min); const r Math.round(t * 255); const g Math.round((1 - t) * 255); return new THREE.Color(rgb(${r}, ${g}, 128)); }然后遍历所有 Feature各自生成几何体和材质features.forEach((feature, index) { const shape buildShapeFromCoordinates(feature.geometry.coordinates, lonLatToXY); const geometry new THREE.ShapeGeometry(shape); const value feature.properties.value || index; const material new THREE.MeshBasicMaterial({ color: getColorByValue(value, minValue, maxValue), side: THREE.DoubleSide }); const mesh new THREE.Mesh(geometry, material); mesh.userData feature.properties; scene.add(mesh); });这里有一点很重要mesh.userData用来存 GeoJSON 的 properties后面做交互全靠它。userData是 Three.js 对象的通用挂载区不会参与渲染但会在射线检测时跟着 mesh 一起返回。4.2 边界描边LineLoop 和线宽问题区域之间如果没有边界线接壤处会看不清楚。边界线的标准做法是加一条THREE.LineLoopfunction createBoundaryLine(coordinates, lonLatToXY) { const outer coordinates[0]; const points outer.map(([lon, lat]) { const p lonLatToXY(lon, lat); return new THREE.Vector3(p.x, p.y, 0.5); }); const geometry new THREE.BufferGeometry().setFromPoints(points); const material new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 }); return new THREE.LineLoop(geometry, material); }LineLoop会自动把最后一个点和第一个点连起来所以即使 GeoJSON 的线性环已经闭合也不需要手动处理重复点。但注意 WebGL 的线宽有个尴尬限制大部分浏览器里面LineBasicMaterial的线宽永远只有 1 像素linewidth属性基本不起作用。如果你需要明显一点的边界有三个思路接受 1 像素白边这是最常见也够用的方案。用THREE.Line2配合LineMaterial来自 three/examples 的 Line2 方案支持真实线宽。描边先画一次扩边面再叠加原面也就是拿外环坐标做多边形偏移作为底衬底层。对大多数地图场景1 像素边界清晰简洁我不会过度设计。4.3 添加名称标签CanvasTexture 加 Sprite区域名称放在地图上才像一张完整地图。Three.js 里最简单的方案是利用 Canvas 画文字生成纹理再用 Sprite 显示。function createLabel(text, position) { const canvas document.createElement(canvas); canvas.width 256; canvas.height 64; const ctx canvas.getContext(2d); ctx.fillStyle #333; ctx.font bold 28px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(text, 128, 32); const texture new THREE.CanvasTexture(canvas); texture.colorSpace THREE.SRGBColorSpace; const material new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); const sprite new THREE.Sprite(material); sprite.position.set(position.x, position.y, 1); sprite.scale.set(2, 0.5, 1); return sprite; }标签文字放在哪个坐标通常取 Polygon 外环的中心也就是先把外环所有顶点求平均得到一个粗略的中心点function getRingCenter(coordinates, lonLatToXY) { const outer coordinates[0]; let x 0, y 0; for (let i 0; i outer.length - 1; i) { const p lonLatToXY(outer[i][0], outer[i][1]); x p.x; y p.y; } const count outer.length - 1; return { x: x / count, y: y / count }; }这个中心不考虑多边形形状但作为标签位置基本够用。如果某个多边形长得像弯月牙这个粗中心会跑到图形外那就需要用更稳妥的多边形质心算法或者手工修正标签位置。4.4 层级与 Z 轴2D 地图是在三维引擎里所以所有东西放在不同 z 值上可以形成前后顺序。我习惯的面层 z0边界线 z0.5标签 z1。因为正交相机自带深度排序只要每个对象的 z 值不同就不会出现闪烁或遮挡混乱。有一个常见问题是标签文字被某个面挡住。只要把标签的depthTest设成 false或者离面层更远一点就能保持始终可见。5. 让地图可交互Raycaster 拾取、高亮和 Tooltip静态地图只能看交互地图才有灵魂。Three.js 的Raycaster可以很方便地实现鼠标悬停高亮和点击获取区域信息。5.1 鼠标坐标和射线屏幕坐标需要换算成归一化设备坐标NDC范围是 -1 到 1const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); function updatePointer(event) { pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; } renderer.domElement.addEventListener(pointermove, (event) { updatePointer(event); raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObjects(scene.children); if (hits.length 0) { // 处理高亮 } });注意raycaster.intersectObjects遍历的数组最好是你专门维护的可交互层比如一个叫regionMeshes的数组。直接传scene.children会连标签、边界线、辅助物体一起检测既浪费性能也容易误判。5.2 把 GeoJSON 属性绑定到 Mesh前面我把feature.properties存进了mesh.userData。拾取后命中对象的 userData 就是原始属性const hitRegion hits[0].object; console.log(hitRegion.userData.name, hitRegion.userData.value);这样做的好处是你不用维护一份对象索引号到 GeoJSON 属性的映射表数据跟着模型走代码最自然。5.3 高亮的正确写法高亮最直接的方案是改材质颜色let lastHovered null; function highlightMesh(mesh) { if (lastHovered) { lastHovered.material.color.set(lastHovered.userData._originalColor); } if (mesh) { if (mesh.userData._originalColor undefined) { mesh.userData._originalColor mesh.material.color.getHex(); } mesh.material.color.set(0xff7f50); } lastHovered mesh; }这里有个容易踩的坑如果你给所有区域 Mesh 共用一个 material 实例修改一个颜色会全部一起变色。所以要让每个可交互的区域拥有自己的 material 实例也就是在创建 mesh 时new THREE.MeshBasicMaterial放在循环里不要抽到循环外共享。几千个面每人一个简单 material 实例内存压力可以忽略但换来的是随心所欲独立控制颜色。5.4 点击信息与 Tooltip点击比悬停简单监听click事件后同样用 raycast命中后弹出一个 HTML 元素展示信息即可const infoDiv document.getElementById(info); renderer.domElement.addEventListener(click, (event) { updatePointer(event); raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObjects(regionMeshes); if (hits.length 0) { const props hits[0].object.userData; infoDiv.style.display block; infoDiv.style.left event.clientX 12 px; infoDiv.style.top event.clientY 12 px; infoDiv.innerHTML strong${props.name}/strongbr/数值${props.value}; } else { infoDiv.style.display none; } });注意不要在pointermove里每次都做 raycast。普通做法是限定帧率用 requestAnimationFrame 加锁let canPick true; renderer.domElement.addEventListener(pointermove, (event) { if (!canPick) return; canPick false; requestAnimationFrame(() { updatePointer(event); raycaster.setFromCamera(pointer, camera); const hits raycaster.intersectObjects(regionMeshes); highlightMesh(hits.length ? hits[0].object : null); canPick true; }); });数据面数量大时raycast 本身也有成本不加限制会让帧率掉得很难看。6. 数据一多就开始卡合并几何体与图层拆分优化页面能显示、能点击之后下一个问题就是大数据量的性能。想象一份全国区县数据几万块面如果每个面是一个独立 meshThree.js 每帧都要创建数次甚至更多的 draw call这是 WebGL 性能的最大杀手。6.1 瓶颈在 draw callGPU 的运算力通常不是瓶颈瓶颈在于每帧向 GPU 提交绘制命令的次数。一个 mesh 一个 draw call几万个面就是几万次 draw call即便每个面只有几十个三角形整体也会卡。解决办法非常简单粗暴把不需要独立操作的东西合并成一个几何体。6.2 合并区域面几何体Three.js 官方工具里有mergeGeometries位于three/examples/jsm/utils/BufferGeometryUtils.js。import { mergeGeometries } from three/examples/jsm/utils/BufferGeometryUtils.js; const geometryList []; const vertexRanges []; let offset 0; features.forEach((feature) { const shape buildShapeFromCoordinates(feature.geometry.coordinates, lonLatToXY); const geometry new THREE.ShapeGeometry(shape); // 给每个顶点上色 const colorValue getColorByValue(feature.properties.value, minValue, maxValue); const vertexCount geometry.attributes.position.count; const colors new Float32Array(vertexCount * 3); for (let i 0; i vertexCount; i) { colors[i * 3] colorValue.r; colors[i * 3 1] colorValue.g; colors[i * 3 2] colorValue.b; } geometry.setAttribute(color, new THREE.BufferAttribute(colors, 3)); vertexRanges.push({ start: offset, count: vertexCount }); offset vertexCount; geometryList.push(geometry); }); const mergedGeometry mergeGeometries(geometryList, false); const mergedMaterial new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }); const mapLayer new THREE.Mesh(mergedGeometry, mergedMaterial); scene.add(mapLayer);这个方案把几千个面合并成一个 mesh一次 draw call 完成绘制。颜色信息保存在顶点颜色 attribute 里所以不需要多个材质。6.3 合并边界线层边界线照样可以合并把所有边界的点对拼成一个大的LineSegmentsconst positions []; features.forEach((feature) { const outer feature.geometry.coordinates[0]; for (let i 0; i outer.length - 1; i) { const p1 lonLatToXY(outer[i][0], outer[i][1]); const p2 lonLatToXY(outer[i 1][0], outer[i 1][1]); positions.push(p1.x, p1.y, 0.6); positions.push(p2.x, p2.y, 0.6); } }); const lineGeometry new THREE.BufferGeometry(); lineGeometry.setAttribute(position, new THREE.Float32BufferAttribute(positions, 3)); const boundaryLayer new THREE.LineSegments( lineGeometry, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.7 }) ); scene.add(boundaryLayer);注意合并时只使用outer.length - 1个点因为我按两个相邻点作为一段线段存储重复点会导致线段重叠虽然不影响视觉但会浪费性能。6.4 合并之后怎么做点击高亮这是合并方案唯一的痛点合并成一个 mesh 后raycast 命中的是整个大 mesh不再能直接拿到具体是哪个区域。但 Raycaster 会返回每个面片的信息第一个命中结果里其实带着face信息可以通过三角形索引反推出这个三角形属于哪个区。方法是在合并前保留每个区域的三角形索引范围let vertexOffset 0; features.forEach((feature, index) { const geometry new THREE.ShapeGeometry(shape); const indexCount geometry.index ? geometry.index.count : geometry.attributes.position.count; feature._triangleStart vertexOffset; feature._triangleEnd vertexOffset indexCount; feature._vertexOffset vertexOffset; // 大概表示在这个面开始前的顶点数 vertexOffset indexCount; geometryList.push(geometry); });但实际操作起来mergeGeometries会对几何体做索引重排你要精确追踪不太容易。更省心的做法是底图合并交互层单独维护一份精简网格。也就是把数据分成两层静态底图层全部区域的合并几何体负责大批量绘制。交互层只包含当前 hover 或点击目标的区域模型数量极少。交互层可以动态更新把被点中的区域单独用一个新的 ShapeGeometry 加进来不放到底图层里。或者更简单在合并前选出一部分需要高频交互的区域比如重点区域单独保留 mesh其他合并。我实际项目里比较常用的是合并全部面同时在每个区域的中心点附近放一个不可见的、很小的拾取 Collider Mesh。这个 Collider Mesh 体积小但每个区域一个raycast 时只检测这几个小格子速度和准确性都很高。代价是需要多维护一层数据换来的是可以轻松获取到 userData。6.5 还能做的优化数据分块按视野范围只渲染可视区域的面缩小时不加载没在屏幕里的区域。LOD全国视图用粗糙简化边界放大后加载精细边界。预计算把 GeoJSON 解析和 Shape 生成结果缓存起来或者在构建时预生成 geometry 数据。离屏渲染把静态底图先渲染成一张纹理交互时只在上面叠加高亮。适合拓扑性要求很低的大数据场景。具体到你的项目先看数据规模几千个区域合并几何体就能解决几十万甚至百万个面那必须考虑按视野筛选。7. 热词答疑ArcGIS、快速建项目、贴图不显示、下载资源最后回应几个大家搜索时经常遇到的问题。有些是 Three.js 新手常见的坑有些是 GeoJSON 本身的疑问我一起解答。7.1 GeoJSON 能用 ArcGIS 打开吗可以。ArcMap和ArcGIS Pro都支持读取 GeoJSON一般直接拖进目录窗口或右键添加数据即可。如果你在 ArcGIS 里打开后发现图形是一条奇怪的折线或者偏移到很远最常见的两个原因坐标顺序反了。ArcGIS 默认按 x,y 表示经度、纬度也就是经度在前正好和 GeoJSON 规范一致。但如果你的数据文件本身就让经纬度顺序不一致ArcGIS 会按它的规则解析当然会错。缺少坐标参考系信息。GeoJSON 默认 WGS84也就是 EPSG:4326。如果你的数据混用了其他坐标系建议先转成 4326 再交给 ArcGIS。ArcGIS 的JSON to Features工具也可以把 GeoJSON 转成要素类适合需要进一步做 GIS 分析的场景。7.2 Three.js 快速创建项目的小窍门学习阶段用 importmap 引入 Three.js 最省事但做正式项目还是建议用 Vite 初始化前端工程然后npm install three维护依赖更规范。npm create vitelatest my-map -- --template vanilla cd my-map npm install npm install three然后用npm run dev启动开发服务器。Three.js 本身是一个 npm 包不需要像以前那样手写一堆 script 标签。你只用关心import * as THREE from three这一行。如果只是想测试某个 API 或临时演示用 CDN importmap 完全 OK但注意版本依赖别把不同版本的 three 混用尤其别 old examples 和 new core 混一起很容易出现THREE.foo is not a function。7.3 贴图开始不显示先查这三处很多人一上来就给行政区面贴纹理图结果模型出来了但颜色一片白或一片黑。画地图时最常见的贴图问题有三个图片异步加载直接用TextureLoader加载图片几何体已经渲染了但纹理还没加载完需要给 material 一个loading回调或者监听texture.onLoad。色彩空间不正确纹理贴图默认要设置texture.colorSpace THREE.SRGBColorSpace否则颜色发灰变暗。这个在早期 Three.js 版本里叫encoding新版本改名后容易漏。flipY上下翻转Canvas 生成的纹理或者某些图片在 WebGL 里默认对齐方式和预期不同。texture.flipY false或 true 试一下就知道了。如果是 CanvasTexture 标签不显示我一般先检查 canvas 的尺寸和像素比。高 DPI 屏上 Canvas 太小文字会糊成一团把 canvas 内部尺寸乘上devicePixelRatio再画。7.4 three.js 下载相关Three.js 没有单独的安装包官方推荐用 npm。你可以在 npm registry 里找到也可以用 unpkg 直接引用指定版本的文件地址格式https://unpkg.com/three0.160.0/build/three.module.js尽量锁定版本号不要用不带版本号的latest否则换个版本 API 变化可能让你的代码突然失效。Three.js 版本迭代很快很多老教程的代码在新版本里不能直接跑。7.5 地图不显示的最常见根因如果前面代码写完页面一片空白不用急着怀疑 Three.js 有问题先按顺序排查数据解析有没有报错坐标顺序是否反了。经纬度转换后坐标是否跑到几十万以外。检查一下 mesh.position 或 geometry 的包围盒如果中心点距离原点过远说明没做平移到中心附近的操作。正交相机的 frustum 是否太小。我习惯把相机 frustum 直接设成窗口宽高这样坐标尺度和屏幕像素对齐不容易发生模型在视野外的问题。材质颜色是否和背景色一样。你画的是绿色区域背景也是浅绿很容易以为没渲染出来。面是否被裁剪。ShapeGeometry默认只渲染正面如果你的环方向反了设置side: THREE.DoubleSide就能解决。其中第五个点尤其容易遇到。GeoJSON 里有些数据的外环按顺时针有些按逆时针标准里允许两种但 Three.js 的 ShapeGeometry 三角化时可能受到影响。不想纠结环方向直接用THREE.DoubleSide是最省心的办法。我个人在实际操作中的体会是3D 引擎画 2D 地图的难点不在渲染而在数据和坐标的转换。先把 GeoJSON 的坐标结构吃透再谈边缘描边、文字标签和交互整个过程就会顺畅很多。最后留一个小建议如果你的数据量短期内达不到万级真不用急着做几何体合并先保证渲染逻辑清晰、交互代码好维护以后数据量上来再优化也不迟。