
想做一套3D地球可视化用Three.js从零手撸其实没有想象中那么难。这个项目标题看着简单但背后的东西不少——坐标转换、纹理贴图、飞线动画、鼠标交互、数据绑定哪一步拎出来都能写一篇完整的踩坑记录。我前前后后用Three.js做过好几版3D地球从最基础的球体加贴图到后来的数字孪生大屏、多城市飞线、点击下钻中间绕了不少弯路今天干脆把能公开的部分整理成一篇完整的实操记录照着做基本能跑通一个从无到有的3D地球。这套方案最适合的人有两类一是前端开发想接可视化大屏项目、但之前只碰过2D图表的人二是做数字孪生、智慧城市、物流调度这类业务、需要一块3D底图的工程师。文章里会用具体代码和参数说明每一步在干什么、为什么这么干而不是丢一堆官方demo让你自己猜。1. 项目拆解3D地球到底要做什么1.1 先从需求倒推技术选型在我开始写代码之前总会先问自己一个问题这个3D地球是给谁看的、要呈现出什么效果、数据量有多大。这三个问题直接决定了技术选型。市面上的3D地图方案其实不少。Cesium在地形、影像、切片数据上确实强但它的API风格偏重GIS前端同学上手需要适应一阵子Mapbox GL JS适合2.5D和简单的圆柱投影做真3D球体反而别扭。如果需求是一块展示型的大屏或者是一个需要深度定制的Web应用Three.js反而是最顺手的WebGL渲染性能可控材质、动画、交互都能从底层把控而且生态成熟npm直接安装、文档齐全遇到问题几乎都能搜到答案。那为什么不用Cesium我自己的体会是Cesium自带了一套完整的坐标系和相机控制逻辑你用它的同时也要接受它的约束。比如想给地球上某个点做一个自定义的飞线动画或者加一些独特的粒子效果Cesium的扩展成本比Three.js高。Three.js更像一张白纸——球体、纹理、光照、镜头所有东西都摆在你面前你说了算。1.2 功能清单和整体架构我们这次要实现的3D地球最终效果包括一个带纹理贴图的球体支持鼠标拖拽旋转、滚轮缩放在特定经纬度位置显示标记点可以自定义样式点击标记点弹出信息面板显示业务数据用飞线连接多个城市模拟数据流转或航线路径整体性能稳定六七十个标记点加十条飞线不掉帧这种组合在真实项目里非常常见——物流轨迹、航班航线、营销活动全国热力图、景点分布都是同一个模板换数据而已。架构上我习惯分四层渲染层Scene、Camera、Renderer负责整个画布的初始化和渲染循环场景层地球球体、大气光晕、星空的背景搭建视觉底盘数据层统一管理经纬度坐标、业务数据和图层配置方便后续对接真实接口交互层鼠标事件、射线检测、弹窗组件负责用户怎么和地球交互每一层保持独立后面加功能比如多加一个柱状图图层、或者换成深色科技风就不用推翻重来。2. 核心细节球体、相机、光照怎么配才不出问题2.1 场景、相机、渲染器的标准配置Three.js的项目都有一个固定的起手式。我先给出一段基础代码然后在下面解释每个参数的含义。import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 300); const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.getElementById(container).appendChild(renderer.domElement); const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.05; controls.minDistance 150; controls.maxDistance 600; function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate();这里有几个参数是新手最容易忽略的。第一个是透视相机的第四个参数far。有人习惯随手写1000甚至100但如果场景里后续要加星空背景、远处的光晕far太小会导致远处物体被裁剪掉。我经常看到有人明明添加了星星却看不到查了很久才发现是far设小了。第二个是setPixelRatio(Math.min(window.devicePixelRatio, 2))。这句话的意思是让渲染分辨率跟随设备像素比但上限卡在2倍。因为设备像素比超过2之后肉眼几乎分辨不出差异但GPU的负担会翻倍大屏上尤其明显。这是我从实际项目里总结出来的性能经验。第三个是OrbitControls的阻尼参数。enableDamping开启后地球在松手后会有惯性滑动视觉上很柔和但必须记得在渲染循环里调用controls.update()否则阻尼根本不生效。这是我早期踩过的一个很典型的坑代码写了但忘记调用update拖拽时只能一格一格地跳。2.2 球体、材质和光照的配合地球本身用SphereGeometry创建半径我一般取100这个数字后面算坐标时会非常方便。const earthGeometry new THREE.SphereGeometry(100, 64, 64); const textureLoader new THREE.TextureLoader(); const earthTexture textureLoader.load(/textures/earth_day.jpg); const earthMaterial new THREE.MeshPhongMaterial({ map: earthTexture, bumpScale: 0.5, specular: new THREE.Color(0x333333), shininess: 5 }); const earth new THREE.Mesh(earthGeometry, earthMaterial); scene.add(earth);球体分段数64乘64在大多数场景下够用了。如果贴图是高清的且镜头会怼得很近可以把分段提高到128但如果只是大屏展示64够了再多分段就纯粹是浪费性能。材质我选了MeshPhongMaterial因为它支持光照反射地球看起来会有立体感不会像一张糊在球上的纸。bumpScale控制凹凸纹理的强度我这里用0.5让陆地地形隐约有一点点起伏感又不至于太夸张。specular和shininess控制高光模拟海洋对日光的反射效果比默认值自然很多。光照系统这里要说一下很多人做完球体发现是黑乎乎的就是因为场景里没有加光。Three.js默认没有环境光物体材质对光照的反应取决于光源类型和位置。const ambientLight new THREE.AmbientLight(0xffffff, 0.3); scene.add(ambientLight); const directionalLight new THREE.DirectionalLight(0xffffff, 1); directionalLight.position.set(5, 3, 5); scene.add(directionalLight);环境光负责整体提亮方向光负责模拟太阳照射的角度感。两个配合起来地球才会有白天和夜间的明暗变化。如果你想要更立体的效果还可以加一个DirectionalLight的辅助对象去调试照射角度不过我一般直接写死一个斜上方的位置这样阴影和亮面分布比较好看。2.3 纹理贴图的选择与处理贴图是3D地球效果好坏的关键。网上能找到的公开地球纹理基本都长一个样蓝色海洋、绿色陆地、白色云层比如NASA的Blue Marble系列。这类纹理是等距圆柱投影Equirectangular projection也就是宽高比2比1的照片贴到球体上正好无缝覆盖。我自己在项目中遇到的一个问题是直接下载的高清贴图动辄十几MB加载慢而且占显存。后来做了两件事来解决一是用工具把贴图压缩到2048乘1024以内肉眼几乎看不出区别但体积能压缩到1到2MB二是走CDN或者本地静态资源加载不要直接扔一个大Base64字符串在代码里会卡死浏览器。有时候项目要求暗黑科技风那贴图也得换一套思路。不需要去满世界找现成的暗黑纹理更好的办法是在加载贴图后用颜色矩阵调色或者直接叠加一层纯色发光材质。我在一个项目里就干过这事白天纹理加一个深蓝色的半透明叠加层瞬间变成科技感大屏风格完全不需要换图片。还有一个容易被忽视的细节是贴图的Y轴翻转。Three.js的纹理默认按OpenGL约定与普通图片的坐标方向相反对于球体贴图texture.wrapS和wrapT要设置成THREE.ClampToEdgeWrapping否则球体的两极会出现一条接缝或者奇怪的拉伸。具体代码如下earthTexture.wrapS THREE.ClampToEdgeWrapping; earthTexture.wrapT THREE.ClampToEdgeWrapping;不夸张地说百分之四五十的贴图问题都和wrap设置有关。尤其是赤道附近出现一条细线的时候十有八九就是它。3. 核心实现从经纬度坐标到三维世界的换算和渲染3.1 经纬度转三维坐标公式和边界情况要在球体上放置标记点、飞线第一步就是把经纬度转成Three.js的三维坐标。这个公式我几乎是写在肌肉记忆里的function lonLatToVector3(lon, lat, radius) { const phi (90 - lat) * Math.PI / 180; const theta (lon 180) * Math.PI / 180; return new THREE.Vector3( -radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta) ); }这个公式里有两个地方容易算错。一是经度的正负号国内的习惯是东经为正Three.js里默认X轴向右Z轴向外所以经度和Theta的关系要对齐不然标记点会跑到完全相反的另一边去。另一个地方是纬度转换成Phi时用90减因为Phi是从北极开始算的而通常我们习惯的纬度是从赤道开始的。写完后我一般会拿北京东经116.4北纬39.9验证一下给一个半径为100的球得到的坐标应该大约在(-50, 76, 20)这个数量级。如果出来的数字差异太大就一定是公式的某个符号写反了。3.2 在地球上放标记点Sprite与Mesh的选择标记点有几种实现方式。最简单的是用THREE.Sprite本质是一张永远面向相机的贴图function createMarker(lon, lat, color 0xff3333) { const canvas document.createElement(canvas); canvas.width 64; canvas.height 64; const ctx canvas.getContext(2d); const gradient ctx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, rgba(255, 255, 255, 1)); gradient.addColorStop(0.3, rgba(255, 77, 77, 1)); gradient.addColorStop(1, rgba(255, 77, 77, 0)); ctx.fillStyle gradient; ctx.beginPath(); ctx.arc(32, 32, 30, 0, Math.PI * 2); ctx.fill(); const texture new THREE.CanvasTexture(canvas); const material new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); const sprite new THREE.Sprite(material); sprite.scale.set(12, 12, 1); sprite.position.copy(lonLatToVector3(lon, lat, EARTH_RADIUS 0.5)); return sprite; }这里有两个关键参数值得展开讲。第一个是depthTest: false意味着这个Sprite不会被其他物体遮挡。这样标记点不论转到地球背面还是被云层挡到都能一直显示。这样做在视觉上更醒目但缺点是不够真实。如果你的场景需要体现遮挡关系比如让目标点转到背面时消失那就得把depthTest设回默认的true这就取决于业务需求了。第二个是sprite.scale.set(12, 12, 1)这个缩放值跟相机距离是联动的。同一个标记相机拉远后它会变得很小近的时候又太大。所以如果项目里允许用户任意缩放最好在渲染循环里根据相机距离动态调整Sprite的scale否则“镜头拉远后标记点糊成一坨拉近后又盖住整个城市”的体验会很糟糕。我还有一个更高级的做法供参考如果标记点样式比较复杂比如要带光晕、动画、图文信息Sprite画布方式就不够用了。我习惯用CSS2DRenderer配合HTML元素实现复杂的信息标签CSS2D的好处是能直接写CSS文字、边框、阴影都是浏览器渲染的清晰又省心。它和Three.js的结合方式也很成熟网上搜CSS2DRenderer就有很多例子。3.3 飞线效果三次贝塞尔曲线和动态光点飞线是我个人最喜欢的一个部分也是客户看到最直观“哇”出来的地方。飞线的本质是一条在空间中的曲线真实项目里最常见的是弧线形状从起点到终点微微抬离地球表面。弧线我用QuadraticBezierCurve3实现它需要三个点起点、终点和控制点。控制点在起点和终点的中点上再向外抬升一段距离function createFlightLine(start, end) { const startPos lonLatToVector3(start[0], start[1], EARTH_RADIUS); const endPos lonLatToVector3(end[0], end[1], EARTH_RADIUS); const mid startPos.clone().add(endPos).multiplyScalar(0.5); const distance startPos.distanceTo(endPos); const height distance * 0.3; mid.normalize().multiplyScalar(EARTH_RADIUS height); const curve new THREE.QuadraticBezierCurve3(startPos, mid, endPos); const points curve.getPoints(50); const geometry new THREE.BufferGeometry().setFromPoints(points); const material new THREE.LineBasicMaterial({ color: 0x00aaff, transparent: true, opacity: 0.6 }); const line new THREE.Line(geometry, material); const movingPoint createMovingPoint(curve); return { line, movingPoint }; }这里的关键参数是height控制弧线的抬升高度。我一般取两点直线距离的30%视觉效果最自然。太大的话弧线会顶到地球外面像个拱桥太小的话看起来像贴地平移。动态光点是飞线的点睛之笔。用一个球体或Sprite把它的位置按曲线轨迹移动也就是在渲染循环里不断执行curve.getPoint(t)其中t从0到1递增1之后再回到0。循环起来一行一行的数据就像沿着航线流动的光点特别适合表达“数据在传输”的概念。我自己实现动态光点的时候一开始用的是THREE.Mesh一个小球体。后来发现用Sprite更好因为不管镜头角度怎么变换光点都保持圆形。如果你想让飞线看起来更有层次还可以在同一条曲线上叠加两条线——底下一条粗的、透明度低的作为光晕上面一条细的、不透明的作为主线实际效果接近高端的可视化大屏那种发光感。3.4 交互射线检测点击标记点Three.js里判断鼠标点到了哪个物体用的是Raycaster射线检测。这个思路很简单从相机位置发出一条射线穿过鼠标位置看它碰到了哪些物体。const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); function onMouseClick(event) { mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObjects(markerGroup.children); if (intersects.length 0) { const marker intersects[0].object; showInfoPanel(marker.userData.businessData); } } window.addEventListener(click, onMouseClick);这里有一个从项目里沉淀下来的经验把所有的标记点放进一个THREE.Group然后射线检测只针对这个Group里的子对象而不是全场景。不然的话点击地球本身也会触发一堆计算尤其是场景里物体多了之后性能会下滑。另一个经验是关于气泡标记点命中区域的。因为Sprite是扁平的平面又是始终面向相机如果射线检测时点到了Sprite的透明边角也会被算作命中。所以我在每个Sprite的userData里记录它的经纬度和业务数据点击后再根据距离判断是否真正选中而不是直接信射线检测的结果。代码如下const distance marker.position.distanceTo(intersects[0].point); if (distance marker.scale.x * 0.6) { showInfoPanel(marker.userData.businessData); }这样处理后用户点击的时候体验会好很多不会出现“明明点的是地球表面弹窗却出来了”的尴尬。4. 性能优化与踩坑实录在大屏和手机上都不卡4.1 顶点数、纹理尺寸和渲染像素比3D可视化最怕的不是功能多而是卡。Three.js项目一旦卡起来绝大多数原因并不是Three.js本身不行而是某些默认参数没有被正确调整。我总结下来最影响性能的三个点就是顶点数、纹理尺寸和渲染像素比。顶点数方面球体分段在64乘64够了但如果你在场景里放了很多自定义几何体比如用THREE.ExtrudeGeometry做立体柱状图那需要时刻留意每个几何体的顶点数。我能给的建议是在开发阶段打开统计面板比如Stats.js看每帧的DrawCall和三角形数量做到心里有数。纹理尺寸方面贴图不是越大越好。一张4096乘2048的NASA高清纹理和一张2048乘1024的普通纹理在正常缩放范围内看几乎没有区别但显存占用差了一倍。我做项目时通常把地球纹理压到2048标记点用的Canvas纹理控制在64到128像素。这个尺寸已经能应付绝大多数大屏了。渲染像素比前面说过了Math.min(window.devicePixelRatio, 2)是底线我甚至在一些超大屏项目里直接设成1。大屏一般离人远看不太出区别但GPU压力能少一半。4.2 标记点数量多的时候怎么办如果你的需求是要在地球上撒几百上千个点那么每个点都是THREE.Sprite的做法就不太行了。我做过一个省份数据大屏要在全国范围内显示几百个业务网点直接用Sprite做的时候拖拽出现了肉眼可见的掉帧。后来我改成了THREE.Points粒子系统。把所有网点的坐标打包成一个BufferGeometry的position属性用PointsMaterial一次性渲染。粒子系统最大的优势是DrawCall只有1次几百个点完全是同一个批次性能几乎不变。const positions []; pointsData.forEach(item { const v lonLatToVector3(item.lon, item.lat, EARTH_RADIUS 1); positions.push(v.x, v.y, v.z); }); const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.Float32BufferAttribute(positions, 3)); const material new THREE.PointsMaterial({ color: 0xff3333, size: 2, sizeAttenuation: true }); const points new THREE.Points(geometry, material); scene.add(points);粒子系统不能像Sprite那样做丰富的光晕效果但它的性能优势太明显了。实际项目中我经常是“粒子系统做底Sprite做重点”几百个普通点用粒子几个核心城市用Sprite加光晕加弹窗。这样性能和展示效果都兼顾到了。4.3 我的排查工具和心法做3D排查问题时我会在开发阶段打开几个辅助工具它们能帮我省下大把时间。第一个是大纲面板类似浏览器开发者工具里的Elements能列出场景里所有的对象树。Three.js的scene.children打印出来就能看到结构如果某个物体没显示第一件事就是确认它的位置和可见性——visible false或者父级Group被隐藏这种低级错误我犯过不止一次。第二个是坐标可视化。在调试阶段给球体加上AxesHelper它能显示X、Y、Z轴的方向和单位长度。早期我经常忘记Three.js里Y轴是向上的经纬度坐标换算出来后位置完全对不上一加AxesHelper就全明白了。第三个是调试相机位置。遇到“场景是黑的”这种问题十个里有八个是相机位置不对——要么插入到了球体内部要么相机的near/far设置有问题。我习惯把相机初始位置放在(0, 0, 300)这样球体半径100的模型刚好能看到完整的地球镜头不会穿模。4.4 和真实业务数据集成的经验写完了纯前端的Demo下一步往往要对接真实数据。这个阶段有几个坑需要提前避开。第一经纬度数据格式要统一。市面上数据源五花八门有WGS84的、有GCJ02的、有BD09的。如果你的业务数据是国测局坐标GCJ02直接拿去做可视化点位会偏移一百米到几百米不等。在大屏上肉眼可能不明显但如果点位要叠加到一个精确的地图底图上偏差就会很尴尬。处理方式很简单写一个坐标转换工具函数把所有数据统一到WGS84再传给前端。第二弹窗业务数据的绑定。我在标记点上的userData里放了所有业务数据这样点击时能直接拿出来展示不需要再发起一次网络请求。如果数据量大也可以在点击时才请求接口但那样交互会有延迟。我通常的做法是如果点位数量小于200一次性把业务数据挂载到userData如果数量更大就只在初始化时建点点击时才异步加载详情。第三在地球上叠加业务图层后一定要保留一个纯展示模式。这个建议来自一次真实项目事故——数据接口挂了大屏上一片空白客户当场就不干了。后来我把纹理地球、星空背景、基础标记点这些静态要素和业务数据图层分开管理数据加载失败时至少还能看到地球转不至于全场黑屏。这是一个很朴素的容灾思路但很有效。5. 扩展玩法从地球可视化到数字孪生大屏5.1 暗黑科技风与自定义材质很多可视化大屏要求暗黑科技风蓝色、紫色、发光的线条。这种风格下原来的白天纹理就完全不适用了。我有两种方案。第一种是在贴图加载后用Canvas对纹理做一些处理让它偏暗、偏蓝。比如把纹理画到Canvas上用globalCompositeOperation叠加一层蓝色再重新生成Texture效果可控但要写一些Canvas代码。第二种是直接用Three.js的ShaderMaterial实现对发光效果的自定义控制。比如地球外层的“大气光晕”用菲涅尔Fresnel效果做出来的发光圈看起来像科幻电影里的地球。核心思路是根据视角方向和法线的角度差动态控制边缘的透明度与亮度。这个效果用ShaderMaterial实现其实很简洁网上一搜就有很多现成的fresnel shader拿过来改改uniform就能用。我做过多个大屏大气光晕基本是标配它让球体边缘有一个渐变的光圈看着就很高级。5.2 接入3D高斯泼溅与传统3D模型热词里出现了splat.js和3D高斯泼溅Gaussian Splatting我不展开讲算法只谈怎么用在3D地球里。高斯泼溅可以把实景扫描的点云数据实时渲染出来如果我需要把某个实体区域的实景模型嵌入到3D地球的某个位置作为“局部下钻”的视觉载体这确实是个可行方向。不过实测下来高斯泼溅的数据量往往不小就算压缩了也占显存适合作为某一两个核心地标的增强展示不适合满地球铺开。还有一种常用需求是在地球上的某个城市位置放置一个3D模型比如一栋楼、一个风机的白模、一个设备模型。这类需求我一般走glTF/GLB格式。用GLTFLoader加载模型后把模型的位置设置到经纬度换算出的坐标上再根据实际朝向调整旋转角度。注意模型的缩放尺度和地球半径要匹配否则放上去不是太小就是太大。如果模型带PBR材质别忘了场景里的光照要匹配否则模型的金属度和粗糙度看起来会非常奇怪。5.3 对接工业数字孪生和园区监控热词里出现了“three.js、cesium 工业数字孪生”。数字孪生是3D地球一个非常大的应用方向工业场景里最常见的就是在3D地球或者园区沙盘上叠加实时设备状态、能耗数据、告警信息。这种项目的架构通常是Three.js负责渲染WebSocket或者MQTT负责推送实时数据数据一变动就驱动3D层面对应的物体变化。比如设备正常时点位是绿色温度超限变成红色再飘一个告警图标。我在做类似项目时的一个经验是不要把数据监听和3D渲染混在同一个模块里用一个简单的事件总线比如mitt解耦。后端推送一条数据前端发出一个事件渲染层监听事件后更新对应点位。这样写出来的代码调试和扩展都非常舒服。如果项目里需要叠加真实地图影像、地形数据我会考虑把Three.js和Cesium结合使用Three.js负责业务图层的炫酷效果Cesium负责底图数据中间通过坐标对齐的方式联动。这种混合方案调研过不少次虽然复杂度高但效果上限也高。如果只是为了做3D地球可视化展示用纯Three.js完全够用没必要为了堆技术栈而上Cesium。5.4 移动端适配和未来方向最后说说移动端适配。Three.js在手机上跑性能瓶颈往往不在GPU而在内存和CPU单核能力。一个场景里同时存在大量纹理和几何体页面加载就容易白屏。我能给的建议是尽可能用CDN和静态资源不要在大Bundle里塞一堆贴图贴图尺寸压缩到移动端能接受的级别用IntersectionObserver或路由懒加载确保只有需要展示3D的时候才初始化Three.js场景手机端触控手势和桌面端不一样OrbitControls默认支持触控但旋转灵敏度要单独调跑一遍真机测试再上线Three.js做3D地球的可视化在当前的前端领域已经是一项很成熟的技术了但它绝非是“调个参数就完事”的活。坐标换算、贴图处理、交互优化、性能调优每一步背后都有值得深挖的细节。而且一旦你把上述这套都跑通了后面无论做什么可视化大屏、数字孪生、营销酷站都会发现它们的内核几乎一致——渲染层、数据层、交互层三层分离只要架构搭得好换场景只是换数据和换样式而已。最后分享一个我平时干活的小习惯我会在地球场景里保留一个轴辅助对象和网格辅助对象但默认不显示。每次遇到模型位置不对、旋转角度不对这类问题就临时把它们显示出来确认好后再次隐藏。这看起来是个不起眼的操作但在调试坐标问题时它的作用和一个靠谱的console.log一样大。