☰
一行代码创建地球:从零封装Three.js+TypeScript的3D地球SDK
2026/10/9 3:04:32 网站建设 项目流程

这个系列写到第四篇,我终于憋了个大招:一行代码创建地球。这不是标题党——把渲染、纹理、坐标换算、事件交互全部封装进一个模块之后,使用者那边真的只需要一行new Earth('container'),就能在页面上得到一个可以旋转、缩放、打点的3D地球。很多人听到SDK会觉得高不可攀,其实拆开就俩字:封装。把复杂逻辑藏进内部,把简单接口露在外面,让别人完全不用关心你里面是WebGL还是Canvas,也不用关心经纬度怎么换算、纹理怎么加载。这篇文章没有废话,直接带你从零封装一个可复用的地球SDK模块,全程TypeScript + Three.js,从接口设计讲到坐标计算,从生命周期管理讲到线上排查。适合有前端基础、想深入理解模块化和面向对象封装的人,也适合正在做数据可视化大屏、需要三维地球能力的开发者。

1. 动手之前,先想清楚SDK要解决什么问题

1.1 SDK和普通工具函数的本质区别

很多人写过工具函数,比如formatDate、debounce,但工具函数不是SDK。工具函数是无状态的,调用一次给一次结果,用完就扔;而SDK要管理状态、管理资源、管理生命周期。一个 Earth 实例内部持有渲染器、纹理对象、事件监听器,这些都是有生命的资源,使用者创建了它,就得有办法销毁它,否则浏览器的GPU显存和CPU会被慢慢吃光。

这个道理和硬件圈说“封装”特别像。你看那些硬件模块,无论是max485串口芯片还是TP4056充电模块,引脚定义都写在数据手册里,外围电路照着接就能用。软件SDK的API就是引脚,内部实现就是晶圆上的电路,使用者只需要知道输入什么、得到什么。电子工程师看惯了“0603封装”“symbol封装”这些板级概念,其实软件SDK做的就是同一件事:定义清楚接口形状,内部随你怎么折腾。

回看面向对象的三件套——封装、继承、多态。封装是今天的主角,把地球渲染的一大堆细节关进Earth类里;继承可以留到后面做BaseEarth抽公共能力,CityEarth、TrackEarth分别扩展不同场景;多态体现在事件回调上,同样的on('click')在不同业务里可以响应完全不同的行为。初学者听到这些名词就头大,但放在一个具体项目里,它们就是很自然的设计手段。

1.2 划清模块边界:渲染、数据、交互三分离

设计SDK的第一步不是写代码,而是划清边界。做地球渲染这个需求,我把它切成三层。

渲染层:负责一切和GPU打交道的事,创建场景(Scene)、相机(Camera)、渲染器(Renderer),摆放光照,挂载地球网格,画飞线轨迹。

数据层:负责把业务数据转成三维空间可用的数据。经纬度坐标要换算成三维坐标,城市数据要挂到点位上去,飞线路径要生成曲线控制点。

交互层:负责鼠标拖拽旋转、滚轮缩放、点击拾取、窗口尺寸变化后的自适应。

为什么要分这三层,而不是把所有逻辑揉进一个createEarth函数?因为改起来互不干扰。产品经理说“城市选中要高亮”,数据层加一个选中状态字段就行;说“地图要能拉近看城市细节”,交互层改一下缩放阻尼就行。如果所有代码挤在一坨,任何一处需求变更都是牵一发动全身,改到后面你自己都不敢动。

1.3 技术选型:为什么用Three.js而不是原生WebGL

从零手写WebGL可以做吗?可以,但代价极大。顶点着色器、片元着色器、透视矩阵、深度测试,这些基础环节没有几百行代码根本走不通,而且大多是在重复造轮子。Three.js把这些底层能力全部封装好了,纹理映射、几何体、材质、光照都有现成的对象可用,生态也成熟,国内做数字孪生、可视化大屏的前端团队基本都在用它。

我做这个SDK的内部实现选Three.js,但对使用方完全不可见。将来某天想换成Babylon.js,只要内部重构,外部API一行不用动,这就是封装的价值。也可以用CSS 3D配合transform-style: preserve-3d做简易地球,但性能上限低,光照、粒子、大量飞线都做不了,拿来当玩具可以,做正式项目不行。

2. 核心原理拆解:一个3D地球是怎么画出来的

2.1 场景、相机、渲染器,三板斧搭起舞台

Three.js渲染画面的核心是三样东西:Scene(场景)、Camera(相机)、WebGLRenderer(渲染器)。可以这样理解:场景就是摄影棚,所有物体、灯光、摄像机都摆在里面;相机就是摄影师的镜头,决定从哪个角度看、视角多宽;渲染器就是那台胶片机,每一帧把摄影棚里的画面拍下来,画到<canvas>上。

地球模型本身是一个SphereGeometry球体,半径设成100单位。相机放在球外339个单位处,用透视相机PerspectiveCamera模拟人眼的近大远小。渲染器开启抗锯齿antialias: true和透明背景alpha: true,这样地球可以叠加到任何页面背景上,而不是带一个白底的黑盒子。

2.2 经纬度到三维坐标的换算,这一步最容易出错

球面上一个点通常用经纬度描述,但3D空间里是直角坐标系。要把经纬度换算成x、y、z坐标,公式并不复杂:先把纬度和经度从角度转成弧度,再套用球坐标公式。以球心为原点,y轴朝上:

function latLngToPosition(lat: number, lng: number, radius: number) { const phi = (90 - lat) * (Math.PI / 180); const theta = (lng + 180) * (Math.PI / 180); return { x: -radius * Math.sin(phi) * Math.cos(theta), y: radius * Math.cos(phi), z: radius * Math.sin(phi) * Math.sin(theta), }; }

为什么phi要写成90 - lat?因为纹理贴图的纵向坐标对应的是球面的北极到南极,而纬度的定义是赤道为0、北极90,角度方向正好相反。如果不处理这个翻转,点位会全部跑错位置,看起来就是“城市长到了海中央”。theta加180是为了和等距圆柱投影纹理的横向零点对齐。这一行公式,是从GIS代码迁移到前端时最容易踩的坑之一。

2.3 纹理贴图、材质与光照,让球体真正像地球

光有球体还不行,没有贴图的球就是一个白色塑料模型。我准备了一张世界地图的等距圆柱投影(Equirectangular)纹理,用TextureLoader异步加载,再赋给MeshPhongMaterial的map属性。

为什么用Phong材质而不是Basic?Basic材质不受光照影响,做出来的地球是平的、没有立体感;Phong材质支持漫反射和镜面反射,配合方向光可以产生明暗过渡,球面的立体感一下就出来了。光照放一盏DirectionalLight(方向光)模拟太阳,再加一盏AmbientLight(环境光)给背光面补一点亮度,免得阴影面黑成一片。光源位置放在相机同侧偏上,让用户看到的正面刚好是亮面,细节分毫毕现。

3. 实操过程:封装第一个地球SDK模块

3.1 初始化项目,搭一个干净的开发环境

我习惯先建一个空项目,目录结构按“入口 + 核心类 + 类型定义 + 工具函数”分开,不让代码堆成一座屎山:

mkdir earth-sdk && cd earth-sdk npm init -y npm install three npm install -D typescript @types/three vite
earth-sdk/ ├── src/ │ ├── index.ts # 导出入口 │ ├── Earth.ts # 核心类 │ ├── types.ts # 类型定义 │ └── utils/ │ └── geo.ts # 经纬度换算工具 ├── assets/ │ └── earth-texture.jpg # 地球纹理 ├── examples/ │ └── basic.ts # 一行代码调用示例 ├── package.json └── tsconfig.json

TypeScript是必须的。对外SDK没有类型定义等于裸奔,使用者一边翻文档一边猜参数,体验太糟糕。类型本身就是最好的文档。

3.2 核心类 Earth 的骨架:接口克制,实现丰富

核心类是Earth,对外暴露的接口极其克制。构造函数接收一个容器选择器和可选的配置项,内部自动完成初始化。所有内部方法都用ES2022的私有字段#开头,把实现细节封死,使用方碰不到也改不了:

// Earth.ts import * as THREE from 'three'; import { latLngToPosition } from './utils/geo'; export interface EarthOptions { radius?: number; // 球体半径 texture?: string; // 纹理图片地址 autoRotate?: boolean; // 是否自动旋转 autoRotateSpeed?: number; // 自动旋转速度 backgroundColor?: string; // 背景色(留空为透明) } export class Earth { #container: HTMLElement; #scene: THREE.Scene; #camera: THREE.PerspectiveCamera; #renderer: THREE.WebGLRenderer; #earthMesh: THREE.Mesh; #animFrameId = 0; #listeners: Array<() => void> = []; #options: Required<EarthOptions>; constructor(selector: string, options: EarthOptions = {}) { this.#options = Object.assign( { radius: 100, texture: '', autoRotate: true, autoRotateSpeed: 0.002, backgroundColor: '', }, options, ); const el = document.querySelector(selector); if (!el) throw new Error(`找不到容器: ${selector}`); this.#container = el as HTMLElement; this.#initScene(); this.#initCamera(); this.#initRenderer(); this.#initEarth(); this.#initLights(); this.#bindInteraction(); this.#startLoop(); } // 后续小节逐步补齐各私有方法 }

Object.assign合并默认配置的写法,本质是给使用方“兜底”:不传参数也能跑,传了再覆盖。设计SDK时要记住一件事:别人调用你的接口,怎么省事怎么来,永远把默认行为设计成“开箱即用”。如果querySelector找不到容器,直接抛一个带明确信息的错误,而不是让使用者对着空页面发呆。

3.3 初始化方法拆解:每个方法只干一件事

初始化方法分开写,比一锅炖在构造函数里清晰太多:

#initScene() { this.#scene = new THREE.Scene(); if (this.#options.backgroundColor) { this.#scene.background = new THREE.Color(this.#options.backgroundColor); } } #initCamera() { const width = this.#container.clientWidth || 600; const height = this.#container.clientHeight || 400; this.#camera = new THREE.PerspectiveCamera(45, width / height, 1, 2000); this.#camera.position.set(0, 0, 320); this.#camera.lookAt(0, 0, 0); } #initRenderer() { this.#renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.#renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.#renderer.setSize(this.#container.clientWidth, this.#container.clientHeight); this.#container.appendChild(this.#renderer.domElement); }

setPixelRatio这个参数极度重要:不做的话高分屏(比如 MacBook Pro)上画面会模糊。做了之后清晰度上来了,代价是GPU压力变大。我习惯限制最大像素比为2,避免4K屏上渲染开销过大变成PPT。相机远近裁剪面设为1到2000,超出这个范围的物体不渲染,这个值要和场景尺寸匹配,否则会莫名出现东西消失的情况。

3.4 地球几何体、纹理与光照的装载顺序

地球网格的创建要注意加载顺序。TextureLoader是异步加载,如果不等纹理加载完就把球加入场景,很可能会白屏或者纹理慢慢浮现。我在回调里给材质重新赋map:

#initEarth() { const geometry = new THREE.SphereGeometry(this.#options.radius, 64, 64); const material = new THREE.MeshPhongMaterial({ color: 0xffffff }); if (this.#options.texture) { const loader = new THREE.TextureLoader(); loader.load(this.#options.texture, (texture) => { material.map = texture; material.needsUpdate = true; }); } this.#earthMesh = new THREE.Mesh(geometry, material); this.#scene.add(this.#earthMesh); } #initLights() { const ambient = new THREE.AmbientLight(0xffffff, 0.4); this.#scene.add(ambient); const directional = new THREE.DirectionalLight(0xffffff, 0.9); directional.position.set(200, 150, 300); this.#scene.add(directional); }

这里needsUpdate = true是Three.js的老规矩:材质创建之后再去修改map,必须手动告诉内部“材质变了,请重新编译shader”。很多初学者不知道这一点,改完贴图发现球还是白的,就是这个原因。光照强度上,环境光0.4是给背光面兜底,方向光0.9提供主立体感,两个数值配合Phong材质才协调,单独拧任何一个都可能过曝或死黑。

3.5 手势交互:亲手实现拖拽旋转和滚轮缩放

OrbitControls是官方提供的交互控制器,功能全但比较重,而且绑定的事件和销毁逻辑不易完全掌控。封装SDK时我选择自己实现一套精简交互:鼠标左键拖拽旋转地球,滚轮缩放相机距离,触摸设备做兼容。

#bindInteraction() { const dom = this.#renderer.domElement; let dragging = false; let prevX = 0; let prevY = 0; const onPointerDown = (e: PointerEvent) => { dragging = true; prevX = e.clientX; prevY = e.clientY; }; const onPointerMove = (e: PointerEvent) => { if (!dragging) return; const dx = e.clientX - prevX; const dy = e.clientY - prevY; this.#earthMesh.rotation.y += dx * 0.005; this.#earthMesh.rotation.x += dy * 0.005; prevX = e.clientX; prevY = e.clientY; }; const onPointerUp = () => { dragging = false; }; const onWheel = (e: WheelEvent) => { e.preventDefault(); const factor = e.deltaY > 0 ? 1.1 : 0.9; const z = this.#camera.position.z * factor; this.#camera.position.z = Math.min(600, Math.max(150, z)); }; const onResize = () => { const w = this.#container.clientWidth; const h = this.#container.clientHeight; this.#camera.aspect = w / h; this.#camera.updateProjectionMatrix(); this.#renderer.setSize(w, h); }; dom.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); dom.addEventListener('wheel', onWheel, { passive: false }); window.addEventListener('resize', onResize); this.#listeners.push(() => { dom.removeEventListener('pointerdown', onPointerDown); window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); dom.removeEventListener('wheel', onWheel); window.removeEventListener('resize', onResize); }); }

为什么不把pointermove绑在 canvas 上而是绑在 window 上?因为拖拽过程中鼠标移出画布再松开,如果只监听 canvas 的pointerup,松开事件就丢失了,拖拽状态会卡死,下次点击直接跳变。

滚轮监听的{ passive: false }也是必须的,不写的话浏览器默认把 wheel 当主动滚动事件,preventDefault()会失效,页面背景也跟着滚。缩放范围限制在相机z轴150到600之间,球半径100,太近了画面穿模,太远了地球变成一粒米。

3.6 帧循环与自动旋转的节奏控制

地球要动起来,靠requestAnimationFrame驱动渲染循环:

#startLoop() { const loop = () => { if (this.#options.autoRotate) { this.#earthMesh.rotation.y += this.#options.autoRotateSpeed; } this.#renderer.render(this.#scene, this.#camera); this.#animFrameId = requestAnimationFrame(loop); }; loop(); }

0.002是每帧旋转的弧度值,换算下来大约每秒0.12弧度,转一圈约52秒,属于“能看出在转但不眩晕”的节奏。这个数值我反复调过,太大像陀螺,太小看不出效果,把它做成配置项让使用方自己调才是最合理的做法。

3.7 扩张能力:打点和飞线

只有地球还不够,数据可视化场景里,打点和飞线是标配。继续往SDK里加两个公开方法:

addMarker(pos: { lat: number; lng: number }, color = '#ff5a5a') { const position = latLngToPosition(pos.lat, pos.lng, this.#options.radius); const marker = new THREE.Mesh( new THREE.SphereGeometry(2, 16, 16), new THREE.MeshBasicMaterial({ color }), ); marker.position.copy(position); this.#earthMesh.add(marker); return marker; } addFlyLine( from: { lat: number; lng: number }, to: { lat: number; lng: number }, color = '#66ccff', ) { const start = latLngToPosition(from.lat, from.lng, this.#options.radius); const end = latLngToPosition(to.lat, to.lng, this.#options.radius); const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5); mid.normalize().multiplyScalar(this.#options.radius * 1.4); const curve = new THREE.QuadraticBezierCurve3(start, mid, end); const points = curve.getPoints(50); const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color }); const line = new THREE.Line(geometry, material); this.#earthMesh.add(line); return line; }

点位标记我用MeshBasicMaterial而不是Phong,是因为打点颜色要醒目,不受光照影响才能保证在任何角度都是亮色。飞线的核心是QuadraticBezierCurve3:起点终点都在球面上,控制点取两点中点在球外1.4倍半径处,曲线自然拱起,看起来像贴着地球表面飞出去的弧线。getPoints(50)把曲线采样成50个点,点数太少折线感太强,太多浪费性能,50是个平衡点。

3.8 终极体验:一行代码创建地球

所有封装完成之后,使用方视角就是这样:

// basic.ts import { Earth } from './Earth'; const earth = new Earth('#app', { texture: './assets/earth-texture.jpg', autoRotate: true, }); earth.addMarker({ lat: 39.9042, lng: 116.4074 }, '#ff5a5a'); // 北京 earth.addMarker({ lat: 31.2304, lng: 121.4737 }, '#5ac8fa'); // 上海 earth.addFlyLine( { lat: 39.9042, lng: 116.4074 }, { lat: 31.2304, lng: 121.4737 }, );

“一行代码创建地球”在调用侧真的就是new Earth('#app', ...)这一句,剩下的都是配置选项。这是SDK封装的目标:把复杂度关进笼子里,把简单留给使用者。使用者不用知道什么是球坐标,不用知道什么是Phong材质,更不用知道渲染循环怎么跑,他要的只是“给我一个地球,在上面标两个城市,连一条线”。

3.9 destroy方法:不清理资源的SDK是耍流氓

最后也是最重要的一件事:销毁机制。SDK使用者切页面、关弹窗时必须能干净地释放资源,否则多开几次大屏页面,浏览器内存和GPU显存会被吃光,页面直接卡死。

destroy() { cancelAnimationFrame(this.#animFrameId); this.#listeners.forEach((off) => off()); this.#scene.traverse((obj) => { if (obj instanceof THREE.Mesh) { obj.geometry.dispose(); if (Array.isArray(obj.material)) { obj.material.forEach((m) => m.dispose()); } else { obj.material.dispose(); } } }); this.#renderer.dispose(); const canvas = this.#renderer.domElement; canvas.parentElement?.removeChild(canvas); }

geometry.dispose()释放显存中的几何数据,material.dispose()释放材质贴图,renderer.dispose()释放渲染上下文,cancelAnimationFrame停掉渲染循环,#listeners里存的卸载函数把绑在window上的事件一个个摘掉。核心就一句话:你创建了什么,销毁时就要把它们全都释放掉,漏一个就是内存泄漏的隐患。

4. 实际封装中踩过的坑与排查技巧

4.1 白屏:纹理没加载完成就急着渲染

第一次跑通代码时,我做了一件蠢事:把纹理加载的异步回调忽略了,直接在构造函数里调用渲染器。结果球体出来了,但表面一片白,过一两秒纹理才突然浮现。解决办法就是前面写的:把“设置纹理并更新材质”放到加载回调里执行,加载完成前继续渲染,只是先显示一个占位色,用户感知不到延迟。

纹理加载还有一个高频坑:跨域。如果纹理放在CDN或者其他域名下,加载时需要在TextureLoader上设置setCrossOrigin('anonymous'),否则浏览器出于安全策略会污染画布,导致canvas.toDataURL()直接报错。这个坑在截图导出功能里会突然冒出来,让人非常难受。

4.2 GPU资源泄漏:多开几个实例把页面搞卡

我测过一种场景:在单页应用里反复进入退出“全球数据中心”页面,每次进入都new一个 Earth,退出时忘记调用destroy。开10次之后,整个Tab的帧率从60掉到十几,内存曲线一路上涨,最后页面完全卡死。这个问题后来就是靠destroy()解决的。

经验是:SDK文档里必须把销毁方法写在显眼位置,甚至可以在模块内部做保护——实例数量超过3个时,在控制台警告使用方检查是否漏了destroy()。好的SDK不只是给人用的,还要在用户犯错时提醒他。

4.3 WebGL上下文丢失:切后台再回来画面全黑

移动端和低配电脑上,浏览器在Tab切到后台时会回收WebGL上下文,恢复之后如果不做处理,页面就是黑的。处理方式是监听 canvas 上的webglcontextlost和webglcontextrestored事件:前者阻止默认行为并暂停渲染循环,后者触发一次重新渲染重建画面。这个坑测试时通常发现不了,上线后被用户反馈才暴露,做SDK时最好一开始就处理掉。

还有一类问题不是代码本身的bug,而是运行环境问题。就像Windows上运行程序提示找不到msvcp140.dll,多数是缺了运行库;SDK拿进使用方项目里跑不起来,先排查依赖版本、构建配置、浏览器兼容,很多时候问题出在环境而不是上层的调用代码。排查这类问题,看控制台报错永远排在第一位,不要凭感觉猜。

4.4 常见问题速查表

症状可能原因排查与解决
页面白屏纹理未加载完就渲染、容器高度为0纹理加载回调里设置map;检查容器尺寸
点位全部偏移到海里经纬度换算公式有误核对phi = 90 - lat与theta = lng + 180
地图模糊未设置setPixelRatio按设备像素比设置渲染分辨率
纹理更新后不显示材质needsUpdate未置true修改map后手动置位
页面卡死实例未销毁,资源泄漏检查destroy()是否调用
截图为黑色画布被跨域纹理污染设置setCrossOrigin('anonymous')
网页背景跟着滚wheel事件未阻止默认行为使用{ passive: false }

我自己的体会是,封装SDK带来最大的回报不是“方便别人”,而是强迫你想清楚每一行代码服务于谁、每个接口要不要暴露。写业务代码时我可以偷懒,但写SDK不行——使用方不看你源码,你的接口长什么样,你的产品就是什么样。最后分享一个小技巧:我在SDK内部留了一个#debug开关,开启后会在控制台打印纹理加载进度、渲染帧率、实例数量这些内部状态,排查线上问题时能省一大半时间。这个系列后面几篇,我打算继续把这个地球SDK完善,加上柱状图、热力图、事件系统,让它真正成为能拿上生产环境用的完整模块。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询