D3.js 投影配 Three.js 渲染:可交互三维地图实现

发布时间:2026/10/31 8:43:42

D3.js 投影配 Three.js 渲染:可交互三维地图实现 前阵子做数据大屏需求文档里躺着一块倾斜的立体地图区块要有厚度鼠标移上去要顶起来上面还要叠光柱和飞线。我第一反应当然是 ECharts毕竟 geo 组件加 map3D 一套配置就能出图团队里谁都能接手改。但试了两天我就把这条路线放下了——不是它不行而是它的天花板恰好卡在这个需求最要命的地方光柱的朝向、飞线的曲率、拾取时的层级关系、hover 的过渡曲线全都锁死在配置项里能调的调完还是那个味。后来我把渲染链路整个换成 Three.js 自己管场景只把 D3.js 里的d3-geo单独拎出来做投影计算。这个组合听起来像是两个大库硬凑实际上分工特别干净D3 负责把球面上的经纬度掰成平面坐标Three.js 负责把这个平面图形挤出成有厚度的实体、打光、做拾取。整条链路跑通之后全国行政区划级别的三维地图几十个区块独立可点击帧率稳在 60fps 上下代码量也就六七百行。这篇东西写给两类人一类是被 ECharts 的定制能力卡住、想自己接管渲染又不知道从哪下手的另一类是 Three.js 用过一点、但一碰到地图这种二维数据转三维几何的场景就发怵的。我会把投影、挤出、拾取、性能这几块拆开讲每个关键参数为什么这么设都说清楚最后贴上可直接跑的源码骨架。地图数据用公开渠道的行政区划 GeoJSON用之前记得看清授权这块后面会提。1. 把 ECharts 请出主渲染链路之前我先算了一笔账1.1 ECharts 到底能撑到哪一步先把话说公道如果需求只是一块平的彩色地图hover 变色点一下触发跳转ECharts 是眼下性价比最高的方案没有之一。series-map配geoJSON注册十几行配置出图连响应式适配和移动端手势都给你兜住了团队成员换谁都能改。这套东西在 90% 的业务场景里是完全够用的。真正开始拧巴是从要厚度这一步开始的。上了map3D之后你会发现厚度是有了但它是整体厚度想让某个区块单独挤出得走regions配置而regions里能给的自由度仅限于高度和颜色。再往下走一步——区块侧面想要渐变、顶面想要描边、侧面和顶面用不同材质——就开始出现改不动的窒息感了。再往后是动画。飞线的流动效果ECharts 里能给你一个effect的trailLength和period看着还行但曲率不能自己定起终点的高度不能单独控制多条线速度不同步就得拆成多个 series。光柱更麻烦bar3D出来的柱体是方的想要圆的、想要顶部渐隐、想要加一层辉光全都没门。我当时的判断标准很简单把需求里必须实现和尽量实现分成两列然后逐条对着 ECharts 的配置项找。找到第六条没着落的时候我就知道该换路了。1.2 那些 ECharts 给不了的东西具体列一下我当时卡住的几处也方便你对照自己的需求判断要不要换需求点ECharts 方案卡在哪区块顶面与侧面不同材质材质数组配置项不暴露hover 时区块沿法线顶起无法实现需要改几何体顶点飞线曲率自定义effect系列贝塞尔控制点不可调圆柱光柱加渐隐辉光bar3D仅方形柱需自定义几何与混合模式区块描边宽度随距离变化不支持需要自定义 Shader相机轨道带惯性阻尼与角度限制viewControl有限参数粒度不够这张表里最要命的是第一行和第三行。顶面侧面分材质本质上决定了你能不能做出深色侧壁 亮色顶面的立体块感——这几乎是所有倾斜大屏地图的标配视觉。飞线曲率则是好不好看的分水岭固定曲率出来的线全是一个弧度密集区域挤成一团。1.3 最终的技术分工D3 只做它最擅长的那一件事定了换路之后第二个问题来了地图投影要不要自己写答案是不要。墨卡托投影、等距圆柱投影、方位投影这些公式自己推一遍不是不行但没必要而且极容易在极点附近和跨越 180 度经线的地方翻车。d3-geo这个包就是干这个的它跟 d3 整个生态是解耦的可以单独装npm i d3-geo three注意我装的是d3-geo而不是d3。d3那个大包会把d3-selection、d3-transition、d3-scale一大堆东西一起拖进来打包体积直接多出好几百 KB而我们只用得上投影和路径这两个函数。这个细节在做移动端的时候特别重要。分工就这样定下来了d3-geogeoMercator()建投影fitSize()自适应范围把[lng, lat]转成像素级的[x, y]Three.js把上面的[x, y]序列喂给THREE.Shape用ExtrudeGeometry挤出厚度再交给场景、光照、Raycaster一句话概括就是D3 算坐标Three 管渲染中间用Shape这个数据结构对接。2. 三维地图的坐标系里藏着三道坎2.1 经纬度、墨卡托与屏幕像素GeoJSON 里的坐标是[经度, 纬度]注意顺序是经度在前。这个坑我在别的项目里踩过不止一次——[lat, lng]反着写地图会出现在完全错位的位置而且因为投影之后的形状还是像那么回事排查起来特别费时间。判断方法很简单如果地图整体被横向压扁、纵向拉长八成就是顺序反了。geoMercator()默认会以[0, 0]为中心直接拿来用坐标会跑到很远的地方。正确做法是调fitSize让它读一遍 GeoJSON 的边界框自动算出一个刚好装下的投影import { geoMercator } from d3-geo; const W 960, H 600; const projection geoMercator() .fitSize([W, H], geojson);fitSize有两个兄弟fitExtent和fitSize。前者接受[[x0, y0], [x1, y1]]这样的外框后者只接受[width, height]原点固定在左上角。地图场景里fitSize就够了因为 Three.js 那边我们会自己做居中平移。投影之后任何一个坐标点都能这么算const [px, py] projection([lng, lat]);出来的px落在0 ~ Wpy落在0 ~ H。这时候它还是一组屏幕坐标原点在左上y 轴朝下。2.2 Y 轴翻转d3 的世界是朝下的这是第一个真正的坎。d3 的坐标系沿用的是 SVG 那一套原点在左上角y 值往下增长。而 Three.js 用的是右手坐标系原点居中y 轴朝上。如果直接把projection()的返回值当成Vector2的 y 坐标去建Shape地图会整个上下颠倒——而且颠得挺自然第一眼看过去不一定能反应过来容易误判成数据有问题。正确做法是在转换的时候翻一次const SCALE 0.1; // 统一缩放让地图落在合理的三维尺寸内 function toWorld(lng, lat) { const p projection([lng, lat]); if (!p) return null; return [ (p[0] - W / 2) * SCALE, -(p[1] - H / 2) * SCALE, ]; }这段代码做了三件事逐一说第一减掉W/2和H/2把原点从左上角挪到画布中心。三维场景里的相机默认看向原点地图不居中就得额外给相机设 target后面做旋转和缩放都会别扭。第二把 y 取负。这一步就是把 d3 的向下翻成 Three 的向上。第三乘SCALE。fitSize([960, 600])出来的坐标是几百的量级直接扔进 Three.js 里地图宽度接近 1000 个单位相机得拉到一千多才能看全光照的距离衰减、阴影相机范围、飞线的高度全都要跟着放大调起来很痛苦。缩到 0.1 之后地图宽度大约 96 个单位相机放在 150 左右就能看全所有参数都在一个舒服的量级上。那个if (!p) return null别省。投影在某些边界经纬度上会返回 null不判断的话后面new THREE.Vector2(undefined, undefined)会静默生成 NaN 顶点表现是整块几何消失报错都不给你。2.3 挤出方向地图到底躺在哪个平面ExtrudeGeometry的工作方式是接收一个二维的Shape位于 XY 平面沿着Z 轴挤出depth的厚度。这意味着地图会自然躺在 XY 平面上向 Z 轴正方向生长。听起来好像跟地图应该平放的直觉不符但实际用起来反而是最省事的因为 d3 投影出来的就是 XY 平面上的点中间不用做任何旋转Raycaster 的射线从相机出发命中 XY 平面上的图形数学上最直接。相机的位置这样设camera.position.set(0, -120, 90); camera.lookAt(0, 0, 0);相机在-Y方向看向原点同时抬高了 90 的高度。这样画面里Y是屏幕上方北X是右方东Z是朝向观众的方向——挤出的厚度就变成了从地面往上长视觉效果跟斜俯视看一块立起来的沙盘完全一致。如果你的设计稿要求相机能绕地图转加OrbitControls的时候记得限制一下极角不然能转到地图背面去看一堆空心壳import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); controls.minPolarAngle 0.15; controls.maxPolarAngle Math.PI / 2 - 0.08; controls.enablePan false;maxPolarAngle卡在接近 90 度但不到 90 度是为了防止相机降到跟地图同一个平面——那个角度看过去所有几何都退化成一条线加上描边之后满屏噪点。3. GeoJSON 到 ExtrudeGeometry 的完整转换链路3.1 数据清洗先砍掉一半用不上的东西拿到的行政区划 GeoJSON 通常是个FeatureCollection每个 feature 里除了geometry之外还有一大堆properties名称、编码、中心点、层级、父级编码、面积……真正用得上的其实就两三个。先做一遍瘦身好处有两个体积能小一半以上后面遍历的时候心里也清爽。function slimGeoJSON(raw) { return { type: FeatureCollection, features: raw.features .filter(f f.geometry f.geometry.coordinates.length) .map(f ({ type: Feature, properties: { name: f.properties.name, code: f.properties.adcode || f.properties.code, }, geometry: f.geometry, })), }; }那个filter不是摆设。实际数据里经常混着几个geometry: null的占位 feature或者是坐标数组为空的记录——一般是数据源在导出时把某些特殊区域留了空壳。这些东西不滤掉后面建 Shape 的时候会生成空几何体不报错但会在场景里留下几个不可见的 MeshRaycaster 每次都要白跑一遍。3.2 Polygon 与 MultiPolygon 的统一处理GeoJSON 的多边形有两种类型Polygon和MultiPolygon。Polygon的coordinates是[[[lng, lat], ...]]三层环 → 点 → 坐标MultiPolygon的coordinates是[[[[lng, lat], ...]]]四层多边形 → 环 → 点 → 坐标一个行政区经常是多块的——主体一块再加若干岛屿或飞地所以在数据里就是MultiPolygon。为了让代码只走一条路径统一包装成多边形数组function toPolygonList(geometry) { if (geometry.type Polygon) return [geometry.coordinates]; if (geometry.type MultiPolygon) return geometry.coordinates; return []; }这样后面拿到的永远是一个polygons数组每一项是一个环的数组第一项是外环剩下的全是内环洞。3.3 孔洞与绕序填错就变成实心色块这是第二个大坎也是 3D 地图里最容易出视觉事故的地方。THREE.Shape处理孔洞的方式是shape.holes.push(path)。但ExtrudeGeometry在三角化的时候对环的绕序有要求外环逆时针内环顺时针。绕序反了要么孔洞被忽略本该镂空的地方实心要么整个三角化结果错乱表面出现莫名的黑色三角面。d3 的投影不会帮你统一绕序GeoJSON 规范里虽然建议外环逆时针但实际数据五花八门。所以得自己算一遍有向面积function signedArea(points) { let area 0; for (let i 0, n points.length; i n; i) { const [x1, y1] points[i]; const [x2, y2] points[(i 1) % n]; area x1 * y2 - x2 * y1; } return area / 2; }返回值大于 0 是逆时针小于 0 是顺时针。然后按需要翻转function ensureWinding(points, clockwise) { const isCW signedArea(points) 0; return isCW clockwise ? points : points.slice().reverse(); }外环调ensureWinding(ring, false)内环调ensureWinding(ring, true)。完整的建 Shape 流程长这样import * as THREE from three; function buildShapes(feature, toWorld) { const shapes []; const polygons toPolygonList(feature.geometry); for (const rings of polygons) { const outer toWorldRing(rings[0], toWorld); if (outer.length 3) continue; const ordered ensureWinding(outer, false); const shape new THREE.Shape( ordered.map(([x, y]) new THREE.Vector2(x, y)) ); for (let i 1; i rings.length; i) { const holeRing toWorldRing(rings[i], toWorld); if (holeRing.length 3) continue; const orderedHole ensureWinding(holeRing, true); shape.holes.push( new THREE.Path(orderedHole.map(([x, y]) new THREE.Vector2(x, y))) ); } shapes.push(shape); } return shapes; } function toWorldRing(ring, toWorld) { const out []; for (const coord of ring) { const p toWorld(coord[0], coord[1]); if (p) out.push(p); } return out; }判断length 3而不是 4。GeoJSON 规范里闭合环的第一个点和最后一个点是重复的所以正常是 4 个点起但简化过的数据经常把尾点砍掉ExtrudeGeometry会自动闭合不强求尾点重复。但如果只剩 2 个点那就真的构不成面了必须跳过。3.4 几何简化与顶点预算原始行政区划数据的精度高得离谱——海岸线那种地方一个小区块能给你几千个顶点。整份数据跑下来几十万顶点起步ExtrudeGeometry一挤顶点数直接翻几倍内存和 GPU 都吃不消。处理办法有两条可以叠加用第一条是预简化在构建之前把 GeoJSON 过一遍简化工具把精度砍到合理范围。命令行工具或者在线服务都行目标是把整个文件压到 1MB 以内。这个环节不要心软大屏地图在 1080p 分辨率下一个区块占的像素可能就几十个多余的精度肉眼根本看不出来。第二条是运行时跳过微小型在buildShapes里加一个面积阈值const MIN_AREA 0.0006; if (Math.abs(signedArea(outer)) MIN_AREA) continue;这个阈值需要根据你最后用的SCALE来调。我的做法是先跑一遍不做过滤的版本把所有区块的面积算出来打印一遍观察分布——通常会看到主体区块在 0.1 到 10 之间然后一大串 0.0001 以下的碎块。阈值定在碎块和主体之间的空档里就行。过滤掉的小碎块要不要保留如果业务上需要点击那还是得留但可以降低精度或者只保留一个点标记。如果只是视觉完整性扔掉完全没影响。4. 场景、光照与描边让地图真正立起来4.1 相机参数的推导相机是整套东西里最容易被随手一填的参数。position.set(0, 0, 500)能看见图就交差了。但换个屏幕比例或者改个地图尺寸整个构图就崩了。先确定视场角。三维地图这种场景fov建议在 35 到 45 之间。太大了透视变形夸张区块侧面会变形成梯形太小了接近正交投影立体感又弱。我一般用 40。再算距离。假设地图缩放后的宽度是mapW相机要能看到完整宽度那么const fov 40; const fitHeightDistance (mapH / 2) / Math.tan((fov * Math.PI / 180) / 2); const fitWidthDistance (mapW / 2) / Math.tan((fov * Math.PI / 180) / 2) / aspect; const distance Math.max(fitHeightDistance, fitWidthDistance) * 1.15;最后的1.15是留白系数——不留白的话地图会顶到画面边缘视觉上很挤。注意因为我们看的是倾斜视角实际还需要把这个距离分解到Y和Z两个轴上const tilt Math.PI / 5; // 约 36 度俯角 camera.position.set( 0, -distance * Math.cos(tilt), distance * Math.sin(tilt) ); camera.lookAt(0, 0, 0);窗口尺寸变化的时候记得同时更新camera.aspect、camera.updateProjectionMatrix()和renderer.setSize()。这三步少一步画面就是拉伸或者模糊的。function onResize() { const w container.clientWidth; const h container.clientHeight; camera.aspect w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); }setPixelRatio那行里的Math.min(..., 2)是个重要的限制。有些高分辨率屏幕的devicePixelRatio到 3 甚至更高渲染像素直接翻 9 倍帧率断崖式下跌。限制在 2 以内肉眼几乎看不出差别。4.2 光照组合想让地图有块感光照不能只用一盏环境光。环境光打满的话所有面亮度一样厚度完全体现不出来。我的标准组合是三盏const ambient new THREE.AmbientLight(0x88aaff, 0.6); scene.add(ambient); const keyLight new THREE.DirectionalLight(0xffffff, 1.2); keyLight.position.set(-60, -80, 120); scene.add(keyLight); const rimLight new THREE.DirectionalLight(0x4488ff, 0.5); rimLight.position.set(80, 60, -40); scene.add(rimLight);环境光负责兜底亮度颜色偏冷一点让暗部不会死黑主平行光从左前上方打下来照亮顶面同时让右侧壁产生阴影轮廓光从反方向打用来把小岛的轮廓从背景里勾出来——这个在深色背景的大屏上效果特别明显不加的话远处的碎块会糊成一片。材质用MeshStandardMaterial金属度别调太高const baseMaterial new THREE.MeshStandardMaterial({ color: 0x1f4e8c, metalness: 0.25, roughness: 0.65, emissive: 0x0a1628, emissiveIntensity: 0.6, side: THREE.DoubleSide, });metalness超过 0.5 之后材质会开始强烈反射环境没有环境贴图的情况下会变成一片死灰灯光怎么加都没用。roughness保持在 0.6 附近既能吃到高光又不会显得油腻。side: THREE.DoubleSide这个设置值得单独说一下。地图挤出之后理论上所有面都是封闭的用FrontSide就行。但实际数据里经常有绕序混乱的洞导致某些面朝向反了从外面看就变成透明的窟窿。开DoubleSide兜底代价是渲染量增加如果确认数据干净可以改回FrontSide省点性能。4.3 描边方案对比没有描边的立体地图看着像一块塑料板加了描边之后立刻有图的感觉了。三种做法我试过前两种方案实现方式优点缺点EdgesGeometry提取几何体的折边简单几行代码会把挤出侧面的竖边也画出来线太碎顶面轮廓线单独建 ShapeGeometry 取边界干净只有轮廓需要多一份几何额外 draw callShader 菲涅尔在片元着色器里算边缘效果好能控制宽度需要写 Shader亮度随视角变化我最后选的是第二种——建一份顶面的轮廓线。具体做法是把Shape转成Path用getPoints()取样然后喂给LineLoopfunction buildOutline(shapes, zOffset) { const group new THREE.Group(); const mat new THREE.LineBasicMaterial({ color: 0x6ec6ff, transparent: true, opacity: 0.85, depthTest: true, }); for (const shape of shapes) { const pts shape.getPoints(64); const geo new THREE.BufferGeometry().setFromPoints( pts.map(p new THREE.Vector3(p.x, p.y, zOffset)) ); group.add(new THREE.LineLoop(geo, mat)); } return group; }zOffset传挤出深度加一点点比如depth 0.02让线浮在顶面之上避免和面重合产生 z-fighting。getPoints(64)里的 64 是取样段数太小了圆滑的地方会变成折线太大了顶点浪费64 是个够用的值。材质要复用同一个实例——LineBasicMaterial只有一个颜色和透明度参数每个区块新建一个纯属浪费内存而且后面调颜色还得一个个改。5. Raycaster 拾取让鼠标精准点到每一个区块5.1 从屏幕坐标到 NDCThree.js 的Raycaster要的是标准化设备坐标NDC范围是-1 ~ 1原点在画布中心y 轴朝上。浏览器给的clientX/clientY原点在左上y 朝下。转换公式const pointer new THREE.Vector2(); function updatePointer(event) { const rect renderer.domElement.getBoundingClientRect(); pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1; }这里必须用getBoundingClientRect()而不能直接用clientWidth因为大屏容器经常有偏移或者被transform缩放过用clientWidth算出来的坐标系跟实际渲染的坐标系对不上表现是鼠标位置整体偏移。那个 y 轴的负号是第二个容易漏的地方。漏了的话鼠标在画面上方时拾取到的是下方的区块整个交互上下一翻测试的时候一眼就能看出来但在代码里特别不显眼。5.2 高亮实现与材质共享的陷阱拾取到之后要高亮。最常见的写法是const hits raycaster.intersectObjects(provinceMeshes, false); if (hits.length) { const mesh hits[0].object; mesh.material.color.set(0xffaa33); }这么写大概率会出问题。因为如果你在建 Mesh 的时候复用了同一个baseMaterial改一个的color所有区块的颜色一起变。这个 bug 特别隐蔽——因为你会看到高亮效果是有的只是全亮了。两个修法。第一个是建 Mesh 的时候克隆材质mesh.material baseMaterial.clone();几十个区块几十份材质内存开销可以接受draw call 会多一些但都在毫秒级。第二个方案是只维护一份高亮材质hover 时切换引用const highlightMaterial baseMaterial.clone(); highlightMaterial.color.set(0xffaa33); highlightMaterial.emissive.set(0x552200); let hovered null; function setHover(mesh) { if (hovered mesh) return; if (hovered) hovered.material hovered.userData.baseMaterial; if (mesh) mesh.material highlightMaterial; hovered mesh; }第二个方案更省但要注意把原始材质存在userData里切换的时候能还原。我用的就是这个。还要处理一个边界情况鼠标移到空白处时hits为空得把上一个 hover 的还原回去。所以setHover(null)这条路径必须走通。5.3 点击下钻与飞入动画区块要有顶起的反馈。最容易想到的是改mesh.position.z但这样反而露怯——整个块平移上去底下的空腔就露出来了看着像飘着的。正确的做法是改几何体的挤出深度或者给scale.z做动画function animateLift(mesh, targetScale) { const from mesh.scale.z; const start performance.now(); const duration 260; function tick(now) { const t Math.min((now - start) / duration, 1); const eased 1 - Math.pow(1 - t, 3); // easeOutCubic mesh.scale.z from (targetScale - from) * eased; if (t 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }scale.z拉伸的好处是底面位置不变只有顶面往上走视觉上就是这块地被抽起来了。缺点是如果材质有贴图拉伸会让贴图变形——所以地图材质尽量用纯色或者顶点色别贴图。点击跳转如果需要相机飞过去用camera.position和controls.target分别做补间注意两者速度要一致否则相机轨迹会画一个奇怪的弧线。简单办法是把两个Vector3一起插值function flyTo(targetPos, cameraPos, duration 700) { const fromTarget controls.target.clone(); const fromPos camera.position.clone(); const start performance.now(); function tick(now) { const t Math.min((now - start) / duration, 1); const e t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; controls.target.lerpVectors(fromTarget, targetPos, e); camera.position.lerpVectors(fromPos, cameraPos, e); controls.update(); if (t 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }e那个公式是 easeInOutCubic两头慢中间快比线性过渡自然得多。6. 在底图上叠业务数据柱体、飞线、光柱6.1 数值到高度的映射底图只是骨架业务数据要往上叠。最常见的表达是按区块的数值挤出不同高度的柱体。第一步是把数值映射到高度区间。别直接线性映射因为业务数据通常是长尾分布——一个区块的数值可能是别人的二十倍线性映射之后其他区块的柱子全成了小土包。function makeHeightScale(values, minH 2, maxH 26) { const sorted values.slice().sort((a, b) a - b); const p10 sorted[Math.floor(sorted.length * 0.1)]; const p90 sorted[Math.floor(sorted.length * 0.9)]; const span p90 - p10 || 1; return v { const t Math.min(Math.max((v - p10) / span, 0), 1); return minH (maxH - minH) * Math.sqrt(t); }; }这里用了两个处理。分位数裁剪把 10% 以下和 90% 以上的异常值削平避免极端值压缩中间段Math.sqrt是幂变换把低值区拉开距离让差异看得见。如果你的数据分布比较均匀直接用线性映射也行先用控制台打印一下分位数分布再决定。柱体本身用CylinderGeometry六边形或者八边形比圆形更省顶点视觉上在斜视角下也更像棱柱跟地图的立体感更搭const barGeo new THREE.CylinderGeometry(1.2, 1.2, 1, 6); barGeo.translate(0, 0.5, 0); // 让底部在原点方便按高度缩放translate(0, 0.5, 0)这步很关键。默认的圆柱是居中在原点按高度缩放的时候会向上下两个方向同时长底座就沉到地图下面去了。把底部挪到原点之后scale.y就等于实际高度。柱体的朝向要跟地图平面匹配——地图躺在 XY 平面上柱体默认是沿 Y 轴生长的所以得转 90 度mesh.rotation.x Math.PI / 2;6.2 飞线的曲线选型与流动动画飞线就是两个坐标点之间的一条弧线。可选的曲线类型有三种LineCurve3直线没有弧度视觉上很生硬QuadraticBezierCurve3一个控制点的二次贝塞尔够用CatmullRomCurve3多点样条可以做过山车式的曲线地图飞线用二次贝塞尔最合适。控制点取两点连线中点然后抬高function buildFlyLine(from, to, height) { const mid new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); const dist from.distanceTo(to); // 距离越远弧线抬得越高视觉上更自然 mid.z height dist * 0.25; const curve new THREE.QuadraticBezierCurve3(from, mid, to); const points curve.getPoints(60); return { curve, points }; }dist * 0.25这个系数让远距离的线抬得更高。如果所有线都抬一样的高度长线会显得很塌跟短线挤在同一层。流动效果我不建议用LineDashedMaterial加dashOffset动画那个在曲线上表现很差。更稳的做法是画一条底色线再放一个沿曲线运动的粒子const flyerGeo new THREE.SphereGeometry(0.35, 8, 8); const flyerMat new THREE.MeshBasicMaterial({ color: 0x66ddff }); const flyer new THREE.Mesh(flyerGeo, flyerMat); // 每帧更新 const t (elapsed * 0.4 offset) % 1; const pos curve.getPoint(t); flyer.position.copy(pos);每个飞线配一个offset让它们的运动不同步视觉上才有数据在流动的感觉。全同步的话看着像整条线在整体平移很怪。线的材质加个渐隐会更高级——靠近起点亮、终点淡。这个需要给BufferGeometry加一个alpha属性配自定义 Shader如果不想写 Shader简单办法是让线的透明度整体降下来配合粒子效果也够用。6.3 标签与遮挡区块名称的标签两个方案一是CSS2DRenderer把 HTML 元素贴到三维位置上。好处是文字清晰、可以用 CSS 做样式、字体渲染没损耗。坏处是元素多了之后 DOM 操作成本高而且完全不参与深度测试——标签永远浮在最上层地图转到背面也看得到。二是Sprite加 Canvas 纹理。好处是参与三维渲染可以被遮挡。坏处是文字缩放后会糊得根据相机距离动态调整 Canvas 分辨率。地图场景我推荐CSS2DRenderer因为标签数量通常在几十个以内DOM 压力不大而且大屏上文字清晰度优先级很高。遮挡问题用一个简单的距离或视角判断来手动控制显示隐藏if (camera.position.y -80) label.visible false;如果用Sprite那个 Canvas 纹理的生成要注意一点——CanvasTexture的分辨率要跟屏幕像素比挂钩const dpr Math.min(window.devicePixelRatio, 2); canvas.width 256 * dpr; canvas.height 64 * dpr; ctx.scale(dpr, dpr);不乘 dpr 的话高分屏上标签会明显发虚。7. 六个坑的完整排查链路7.1 地图整块发黑光照怎么调都没反应现象所有区块渲染出来是接近纯黑的加大AmbientLight的强度会变亮一点点但整体还是暗而且没有立体感。排查的第一步是把材质换成MeshBasicMaterial。这个材质完全不吃光照如果能正常显示颜色说明几何体和相机都没问题问题一定在光照或法线。换成 Basic 之后确实正常了那就锁定了。接下来查法线ExtrudeGeometry生成几何体的时候不带法线得手动调一次computeVertexNormals()。但注意ExtrudeGeometry其实内部已经算过了问题往往出在别的地方——绕序。绕序反了的几何体computeVertexNormals算出来的法线是朝内的。MeshStandardMaterial用side: THREE.FrontSide默认的时候背面剔除了你看到的是内壁光照方向全反。开side: THREE.DoubleSide能立刻验证——如果开了之后变亮那就是绕序问题。根治还是要回头统一环的方向用 3.3 节那个ensureWinding。DoubleSide只当兜底。顺带说一句ambient强度设到 2 以上还没反应的话检查一下是不是renderer.outputColorSpace没设。Three.js 新版本默认是SRGBColorSpace但如果手动改过颜色映射会出问题整场景偏暗。7.2 鼠标点在区块上没反应或者点到了背后的元素Raycaster不工作按这个顺序查第一raycaster.intersectObjects(meshes, false)的第二个参数。传false表示不递归子对象如果你的 Mesh 包在一个 Group 里而你把 Group 传进去了那就永远命中不了。要么传真正的 Mesh 数组要么把第二个参数设true。第二pointer的坐标系。如果用的是clientWidth而不是getBoundingClientRect()容器有偏移的时候会整体错位。测试方法在画布正中间点一下如果命中的是偏右下或偏左上的区块就是这个原因。第三raycaster的near和far。默认是0和Infinity一般没问题但如果你手动设过far而且设得比较小远处的区块就拾取不到。第四mesh.frustumCulled。Three.js 默认会对视锥外的物体做剔除如果你的相机参数算错了导致物体被判在视锥外虽然还渲染着可能有别的原因但 Raycaster 会跳过它。调试时可以先设mesh.frustumCulled false排除这个因素。第五CSS 层级遮挡。如果画布上面盖了一层透明的div用来放图例或标题pointerdown事件根本到不了 canvas。用开发者工具的元素检查看一下鼠标位置下面到底是什么元素。7.3 描边闪烁一动就出现虚线感典型 z-fighting。原因是描边线和顶面在同一个深度上深度缓冲精度不够逐帧判定的结果不一致。三个解法从简单到复杂一是抬高描边的z就是我们前面做的zOffset depth 0.02。缺点是无法量化视角拉远了还是可能闪。二是给描边材质加polygonOffsetconst mat new THREE.LineBasicMaterial({ color: 0x6ec6ff, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, });polygonOffsetFactor为负值是把深度往相机方向拉让线赢在深度测试上。三是调相机的near和far。深度缓冲的精度分布是近处高远处低把near从 0.1 调到 1far从 10000 调到 500精度立刻提升一个数量级。这个改动对整体画面的深度精度有好处建议都做。camera.near 1; camera.far 500; camera.updateProjectionMatrix();7.4 hover 卡顿鼠标一动帧率就掉mousemove事件的触发频率远高于渲染帧率。有些鼠标一秒钟能触发几百次如果每次都在里面做 Raycaster 遍历几十个 Mesh 的包围盒检测加上三角形级别的相交测试一帧能干掉好几毫秒。标准解法是事件只在回调里记坐标实际计算放到渲染循环let pendingPointer null; renderer.domElement.addEventListener(mousemove, e { pendingPointer { x: e.clientX, y: e.clientY }; }); function animate() { requestAnimationFrame(animate); if (pendingPointer) { updatePointer(pendingPointer); raycast(); pendingPointer null; } renderer.render(scene, camera); }这样一帧最多算一次鼠标移动再快也不会超过渲染频率。如果区块数量到几百个还可以再加一层包围盒预筛。Raycaster内部其实已经做了但如果能自己先按屏幕空间的距离排序优先测试最近的几个还能再省一点。实际项目里几十个区块做到上一层的优化就够了。7.5 移动端掉帧明显但桌面端很流畅移动端的问题通常出在三个地方按影响从大到小第一是像素比。前面提过限制在 2 以内。如果设备是 3 倍屏限制到 2 能省下 55% 的像素填充量对 GPU 负载的影响非常大。第二是抗锯齿。antialias: true在桌面端几乎免费在移动端可能是压死骆驼的最后一根稻草。可以用能力判断动态关掉const isMobile /Android|iPhone|iPad/i.test(navigator.userAgent); const renderer new THREE.WebGLRenderer({ antialias: !isMobile, alpha: true, });关掉之后边缘会有一点锯齿可以靠setPixelRatio(2)弥补一部分。第三是材质。MeshStandardMaterial是 PBR 材质光照计算比MeshLambertMaterial贵不少。移动端可以降级成MeshLambertMaterial视觉上差一档但性能好很多。加个开关const MaterialClass isMobile ? THREE.MeshLambertMaterial : THREE.MeshStandardMaterial;7.6 数据刷新后内存持续增长地图要支持切换年份或切换指标每次切换重新建几何体。如果只清空场景不清几何体旧数据会一直留在显存里。完整的清理要覆盖三样东西function disposeMap(oldGroup) { oldGroup.traverse(obj { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(m m.dispose()); } else { obj.material.dispose(); } } if (obj.texture) obj.texture.dispose(); }); scene.remove(oldGroup); }注意几点几何体和材质要分别dispose材质里如果挂了贴图贴图也要单独dispose因为贴图是独立的 GPU 资源。scene.remove只负责从场景树上摘下来不释放 GPU 资源。如果你在requestAnimationFrame里跑着动画切换的时候记得把旧的cancelAnimationFrame不然旧循环还在跑一直引用着已经 dispose 的对象控制台会刷一片警告。8. 源码组织与复用把地图封装成组件8.1 目录与模块划分这套东西如果只是做一次写一个文件也无所谓。但只要预期会复用模块拆开是值得的。我的划分是这样map3d/ ├── index.js // 对外入口暴露 init / update / dispose ├── projection.js // d3-geo 投影与坐标转换 ├── geometry.js // GeoJSON → Shape → BufferGeometry ├── scene.js // 场景、相机、光照、控制器 ├── interaction.js // Raycaster、hover、click ├── layers/ │ ├── provinces.js // 底图区块层 │ ├── bars.js // 柱体层 │ ├── lines.js // 飞线层 │ └── labels.js // 标签层 └── utils/ ├── dispose.js // 资源释放 └── easing.js // 缓动函数分层的关键在于几何生成和场景组装完全解耦。geometry.js只接受 GeoJSON 和一个坐标转换函数返回BufferGeometry数组它不关心 Three 场景。这样一来几何这部分可以单独写测试也方便换别的渲染目标。8.2 配置项设计对外暴露的配置我倾向于只留真正需要改的其余全用默认值兜底const DEFAULT_OPTIONS { container: null, geojson: null, depth: 6, scale: 0.1, palette: { base: 0x1f4e8c, hover: 0xffaa33, outline: 0x6ec6ff, }, camera: { fov: 40, tilt: Math.PI / 5, padding: 1.15, }, interaction: { hoverLift: 1.35, clickFly: true, }, };depth和scale是最常被调的两个。scale决定地图在三维空间里的物理尺寸depth决定厚度感。这两个值是耦合的——scale变小之后depth也得跟着变小否则地图会变成一堆又薄又大的方块。我一般按depth mapW * 0.06来估算mapW是地图缩放后的宽度。8.3 销毁与重入组件要能反复创建销毁比如在单页应用里切换路由。销毁函数要清干净这几样function destroy() { cancelAnimationFrame(rafId); controls.dispose(); disposeMap(rootGroup); scene.clear(); renderer.dispose(); renderer.forceContextLoss(); renderer.domElement.remove(); window.removeEventListener(resize, onResize); renderer.domElement.removeEventListener(mousemove, onMouseMove); }renderer.forceContextLoss()这行经常被忘。它主动丢弃 WebGL 上下文防止反复创建销毁时把浏览器的上下文数量耗尽——浏览器对同时存在的 WebGL 上下文有硬限制超过之后新建的直接返回 null表现就是地图第二次加载就白屏。事件监听也要对应地摘掉。resize挂在window上mousemove挂在画布上两处都得处理不然旧实例的回调还在响应新实例的事件会出现两个地图同时在动的情况。最后分享一个实操里的小技巧scale和depth这两个参数别凭感觉调先在控制台里把地图的包围盒打出来const box new THREE.Box3().setFromObject(rootGroup); console.log(地图尺寸, box.getSize(new THREE.Vector3())); console.log(中心点, box.getCenter(new THREE.Vector3()));看到实际数值之后再定depth和相机距离比反复刷新调参数快得多。我一开始就是靠猜改一次刷新一次折腾了半天后来打印出来发现地图宽度实际是 96 但我按 60 估的所有参数全得重算。还有个小细节如果地图的包围盒中心不在原点比如某些区块特别靠某一边相机得跟着平移一点不然构图会偏。这种情况用box.getCenter()的值来算相机的偏移量比手动试要省事。
延伸阅读

更多相关文章

2026/10/31 8:43:42

分布式KV存储核心原理与工业级实践指南

1. 分布式KV存储的本质误区解析 "把分布式KV当网盘同步用"这个说法确实精准戳中了行业常见误区。我见过太多团队在技术选型时,简单认为KV存储就是"能跨机器存数据的字典",结果在DDM(分布式数据管理)场景下翻车…

2026/10/31 8:38:42

1000-1099题号段AC代码包:判题机制与输入输出避坑

简介:杭州电子科技大学在线OJ 1000~1099题解代码包,面向正在刷题、准备竞赛或系统学习C/C算法的编程学习者,集中提供该题号区间内题目的可运行实现,帮助对照题目理解算法思路并完成查漏补缺。编号跨度从1000到1099&…

2026/10/31 8:38:42

智能代理(Agent)开发指南:从原理到实践

1. 什么是Agent?从概念到实践的全景解析 Agent(智能代理)这个概念在计算机科学领域已经存在了数十年,但直到最近AI技术的爆发式发展,它才真正从学术论文走向工程实践。简单来说,一个Agent就是一个能够感知环…

2026/10/31 13:14:13

SAP按单生产委外加工:两层与三层BOM库存账务全链路

简介:围绕SAP按单生产(MTO)委外加工整理的实操总结文档,面向已具备MM/PP基础、需要处理外协采购与库存移动的顾问及企业关键用户,重点解答自供材料如何发往供应商库存、成品如何按销售订单入库等常见难题。文档以两个典…

2026/10/31 13:14:13

国企信息化技术空心化问题与数字化转型路径

1. 国企信息化公司的技术空心化现象剖析 在国企信息化领域,"技术空心化"已成为一个不容忽视的结构性问题。这种现象表现为企业虽然投入大量资源建设信息系统,但核心技术能力却始终未能真正沉淀。我曾参与过某省级能源集团的数字化转型评估&…

2026/10/31 13:14:13

自适应滤波器LMS/RLS算法MATLAB复现与工程调参

简介:这是一份面向通信、电子与信号处理方向本科生及研究生的自适应滤波器毕业设计参考文档,围绕LMS与RLS两类经典算法展开原理推导、性能比较和MATLAB仿真验证。压缩包仅含1个doc文件,约1.67MB,主体为完整论文,覆盖目…

2026/10/31 13:14:13

变压器反馈式振荡电路设计:变压器分类、选型与仿真实操指南

1. 从一台“叫不上名字”的变压器说起搞模拟电路的人大概都有过这种经历:在仿真软件里搭一个振荡电路,翻遍元件库找到变压器,拖出来一看,四个引脚、两个线圈,参数栏里孤零零几个电感值,心里就开始打鼓——这…

2026/10/31 13:14:13

EEG情绪识别:DEAP/SEED-IV的SVM特征工程与评估避坑

简介:这份资源是一篇围绕脑电信号情绪识别的研究论文,面向情感计算、脑机接口方向的高校学生与科研入门者,尤其适合做毕业设计或课题复现的读者参考,用于理解公开数据集上的情绪检测流程与可行性验证思路。压缩包内为 1 个 PDF 文…

2026/10/31 13:09:13

AI应用底座是什么?企业AI落地为何绕不开它

过去一年,我帮几家企业落地AI应用,见了太多“拿着大模型当业务跑”的翻车案例。很多人觉得,把大模型的接口接进来,让员工对着窗口问几句,就算“AI落地”了。但真跑起来才发现,麻烦从来不在模型本身&#xf…

2026/10/30 20:55:10

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/30 20:55:10

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/30 20:55:09

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/31 0:02:41

猫狗目标检测数据集:VOC/COCO/YOLO三格式一体化实战包

简介:本资源是一套面向计算机视觉初学者与YOLO目标检测实践者的猫狗图像数据集及配套开发工具包,专为快速上手目标检测模型训练而设计。资源包含1000张真实场景高清猫狗图片,标注质量高,同时提供VOC(XML)、…

2026/10/31 0:02:41

HDU2017多校标程复现:算法竞赛工程化真题沙盒

简介:本资源为2017年HDU多校联合训练第一场官方配套资料,面向ACM程序设计竞赛备赛学生、算法爱好者及高校编程集训队成员,旨在提供权威解题参考与完整测试验证环境。压缩包共40个文件,含15份C标程(覆盖全部赛题&#x…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑