这篇文章里的每一行代码、每一个数字,都来自一个真实开源项目 DreamGlobe(在线 Demo:dream-globe.pages.dev)。没有伪代码,没有「大概是这样」。
起因:一个看起来很简单的需求
"做一个 3D 地球,上面打点标记城市,点击弹出信息。"
这个需求你能在 GitHub 上找到几十个现成库。但把它们跑到 1000 个标记的时候,绝大多数会在中端手机上掉到个位数帧率。
原因不复杂:它们给每个标记建了一个 Mesh 或 Sprite。
1000 个 Mesh = 1000 次 draw call。而 draw call 是 CPU 向 GPU 提交绘制命令的开销,跟你的几何体有多简单没有关系。一个只有两个三角形的标记,和一个十万面的模型,各自提交一次的 CPU 成本是同一个量级的。
这就是本文要讲的核心事实:
性能瓶颈不在"画了多少三角形",在"提交了多少次"。
最终的结果先放这里:
| 场景 | Draw Calls | 每帧耗时 | 帧率 |
|---|---|---|---|
| 1,000 标记 + 200 弧线 | 4 | 6.1 ms | 166 fps(受显示器上限) |
四次分别是:地球本体、大气层、所有标记 、所有弧线。
下面讲这四次是怎么来的。
一、标记层:InstancedMesh + 顶点着色器广告牌
1.1 先把 1000 个 Mesh 变成 1 个
Three.js 提供了 InstancedMesh:一份几何体 + 一份材质 + N 个实例矩阵,GPU 一次绘制全部。
js
const geometry = new PlaneGeometry(this.size, this.size)
const colors = new Float32Array(capacity * 3)
const phases = new Float32Array(capacity)
const scales = new Float32Array(capacity)
geometry.setAttribute('instanceColor', new InstancedBufferAttribute(colors, 3))
geometry.setAttribute('instancePhase', new InstancedBufferAttribute(phases, 1))
geometry.setAttribute('instanceScale', new InstancedBufferAttribute(scales, 1))
const mesh = new InstancedMesh(geometry, material, capacity)
mesh.instanceMatrix.setUsage(DynamicDrawUsage)
mesh.count = 0
mesh.frustumCulled = false
三个细节值得单独说:
① 几何体用 PlaneGeometry,不用 SphereGeometry。 标记视觉上是一个朝向相机的发光点,两个三角形就够。1000 个标记总共 2000 个三角形------这在现代 GPU 上是零成本。很多人本能地想用小球体,那是把三角形数量放大了两个数量级去换一个看不出来的效果。
② frustumCulled = false 是必须的。 InstancedMesh 的包围盒是按原始几何体算的,而实例被矩阵散布到了整个球面上。Three.js 会用那个错误的小包围盒去做视锥剔除,结果就是转动地球时整层标记莫名其妙地整体消失。这个 bug 很难 debug,因为它只在特定角度出现。
③ 容量预分配 + 倍增扩容。
js
addMarker(lat, lng, data = {}, { color, scale = 1 } = {}) {
// ...
this.markers.push(marker)
if (this.markers.length > this._capacity) this._grow()
this._writeInstance(this.markers.length - 1)
this.mesh.count = this.markers.length
this.mesh.instanceMatrix.needsUpdate = true
return marker.id
}
_grow() 里容量翻倍(GROWTH_FACTOR = 2)。这样最常见的场景------数据一次性加载完------是零次扩容的。
1.2 广告牌效果:为什么必须在着色器里做
标记要永远正对相机(billboard)。常规做法是每帧遍历所有实例,让每个矩阵去 lookAt 相机。
1000 个实例 × 每秒 60 帧 = 每秒 6 万次矩阵运算,全在 CPU 主线程上。
正确的做法是把这件事推给 GPU。顶点着色器里,从实例矩阵中只取平移,丢掉旋转:
glsl
void main() {
vUv = uv;
vColor = instanceColor;
vPulse = 0.75 + 0.25 * sin(uTime * 2.2 + instancePhase);
// 从实例矩阵第四列取出位置,旋转部分直接不要
vec3 instancePosition = vec3(
instanceMatrix[3][0],
instanceMatrix[3][1],
instanceMatrix[3][2]
);
// 在「视图空间」里把顶点偏移量加上去------
// 视图空间的 xy 平面天然就与屏幕平行,所以这一步等价于让四边形正对相机
vec4 viewCenter = modelViewMatrix * vec4(instancePosition, 1.0);
float scale = instanceScale * vPulse;
vec4 viewPosition = viewCenter + vec4(position.xy * scale, 0.0, 0.0);
gl_Position = projectionMatrix * viewPosition;
}
关键在 viewCenter + vec4(position.xy * scale, 0.0, 0.0) 这一行。
先把实例中心变换到视图空间,然后在视图空间里加上顶点的平面偏移。视图空间的 XY 平面就是屏幕平面,所以偏移出来的四边形必然朝向相机。整个过程零 CPU 参与。
1.3 一个不起眼但很关键的细节:呼吸动画的相位
每个标记会脉动发光。如果所有标记同时亮同时暗,效果非常假------像一排同步闪烁的 LED。
所以每个标记需要一个相位偏移。最直接的写法是 Math.random(),但那会带来一个问题:同一份数据每次刷新页面的动画都不一样,截图对不上,测试没法断言。
js
// 从 id 推导相位:不用 Math.random,同一份数据永远动画一致
// 乘数接近黄金角,保证相邻 id 的相位差得足够远
phase: (id * 2.399963) % (Math.PI * 2),
2.399963 弧度约等于 137.5°,就是黄金角。用它做步长,连续的整数序列在圆周上分布得最均匀------这是植物叶序排列的同一个数学原理。相邻 id 的两个标记(通常在数据里也物理相邻)不会同步闪烁。
弧线层用的是同一个思路,只是换成了 0~1 区间的黄金比共轭:
js
offset: (id * 0.381966) % 1,
二、点击检测:为什么不能用 Raycaster
这是整个项目里最反直觉的一个决定。
Three.js 标准的点击检测是 Raycaster:从相机发射射线,跟场景里的几何体求交。
但在这里它是错的。
原因在 1.2 节:标记的广告牌旋转是在顶点着色器 里完成的。GPU 画出来的四边形朝向相机,而 CPU 侧的几何体数据里,那个四边形还躺在它原始的朝向上(Z 轴正方向)。Raycaster 读的是 CPU 侧的数据,它测的是一个用户根本看不见的朝向。
结果就是:点击总是打偏,而且偏的方式随视角变化。
正确做法是反过来------不把射线投进 3D 空间,而是把标记投影到 2D 屏幕:
js
pick(event, camera, domElement, { thresholdPx = 18 } = {}) {
if (this.markers.length === 0) return null
const rect = domElement.getBoundingClientRect()
const pointerX = event.clientX - rect.left
const pointerY = event.clientY - rect.top
camera.getWorldPosition(this._cameraPosition)
let best = null
let bestDistanceSq = thresholdPx * thresholdPx
for (const marker of this.markers) {
latLngToVector3(marker.lat, marker.lng, this.radius * 1.002, this._position)
// 背面剔除:标记在地球背面时,屏幕上可能和正面某点重合,但它不该可点击
this._toCamera.copy(this._cameraPosition).sub(this._position)
if (this._position.dot(this._toCamera) <= 0) continue
this._projected.copy(this._position).project(camera)
if (this._projected.z > 1) continue
const screenX = (this._projected.x * 0.5 + 0.5) * rect.width
const screenY = (-this._projected.y * 0.5 + 0.5) * rect.height
const dx = screenX - pointerX
const dy = screenY - pointerY
const distanceSq = dx * dx + dy * dy
if (distanceSq < bestDistanceSq) {
bestDistanceSq = distanceSq
best = marker
}
}
if (!best) return null
return { id: best.id, lat: best.lat, lng: best.lng, data: best.data }
}
这个方案额外带来两个好处:
① 移动端可用性是免费的。 thresholdPx = 18 意味着点在标记附近 18 像素内就算命中。一个 3 像素的光点在手机上根本点不中------阈值不是"顺手加的容错",它是这个交互在触屏上能不能用的决定性因素。
② 背面剔除靠一个点积就解决了。 this._position.dot(this._toCamera) <= 0------球面上某点的位置向量恰好就是该点的法线(球心在原点)。它和"指向相机的向量"点积为负,说明这个点背朝相机,在地球另一面。一行代码。
复杂度是 O(n),1000 个标记一次点击不到 1 毫秒。点击不是热路径,每秒最多几次,用 O(n) 换正确性完全值得。
注意所有临时向量(_position、_projected、_toCamera、_cameraPosition)都是构造函数里预分配的成员,循环里零分配------避免每次点击产生几千个临时对象喂给 GC。
三、弧线层:合并几何体 + 球面插值
3.1 200 条弧线,一次 draw call
跟标记同样的逻辑:一条 Line 就是一次 draw call,航线图动辄几百条。所以全部弧线烘焙进一个 LineSegments 几何体。
代价是新增一条弧线要重建整个 buffer。这个取舍是对的:弧线通常在初始化时从数据集一次性建好,之后只在 GPU 上动画;而标记是会被交互式增删的。所以两层用了不同的策略。
重建做了懒执行------一批 addArc 只触发一次重建:
js
update(elapsedSeconds) {
if (this._dirty) this._rebuild()
this.material.uniforms.uTime.value = elapsedSeconds
}
3.2 分段数随弧长自适应
js
const MIN_SEGMENTS = 16
const MAX_SEGMENTS = 128
const omega = angularDistance(arc.from[0], arc.from[1], arc.to[0], arc.to[1])
const segments = Math.round(
MIN_SEGMENTS + (MAX_SEGMENTS - MIN_SEGMENTS) * Math.min(1, omega / Math.PI),
)
固定分段数在两端都错:一段 200 公里的短程用 128 段是浪费顶点,一条跨太平洋航线用 16 段会看出明显的折线。
3.3 为什么必须用 slerp,不能直接插值经纬度
这是地理可视化里最容易踩的坑。
想画北京到纽约的航线,最直觉的写法是把两地的经纬度线性插值,再逐点转成 3D 坐标。
这条路会给你一条错误的曲线。
两个原因:
- 经纬度线性插值得到的是等角航线(rhumb line),不是大圆航线(great circle)。它不是球面上的最短路径,画出来的弧线和真实航线不重合。
- 越靠近极地越离谱。极地附近经度会剧烈变化,插值出来的路径会拧成麻花。
正确做法是在三维向量上做球面线性插值:
js
function slerpOnSphere(start, end, omega, t, radius) {
const sinOmega = Math.sin(omega)
const a = Math.sin((1 - t) * omega) / sinOmega
const b = Math.sin(t * omega) / sinOmega
return new Vector3(
a * start.x + b * end.x,
a * start.y + b * end.y,
a * start.z + b * end.z,
).setLength(radius)
}
这个公式保证插值出来的点始终落在两个端点确定的那个大圆上,而且参数 t 均匀对应弧长均匀------这正好是弧线上流光动画能匀速跑的前提。
还得处理退化情况。两点重合或正好对跖(antipodal,地球两端)时,sinOmega 趋近 0,公式会除零:
js
const degenerate = omega < 1e-9 || Math.abs(omega - Math.PI) < 1e-9
const point = degenerate
? start.clone().lerp(end, t).setLength(radius)
: slerpOnSphere(start, end, omega, t, radius)
对跖点在数学上有无穷多条大圆路径连接(想想北极到南极,任何一条经线都行),所以随便选一条合理的即可。
顺带一提,两点角距离用的是 haversine 公式而不是球面余弦定理:
js
export function angularDistance(lat1, lng1, lat2, lng2) {
const dLat = (lat2 - lat1) * DEG2RAD
const dLng = (lng2 - lng1) * DEG2RAD
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * DEG2RAD) * Math.cos(lat2 * DEG2RAD) * Math.sin(dLng / 2) ** 2
return 2 * Math.asin(Math.min(1, Math.sqrt(a)))
}
球面余弦定理在短距离时会丢失精度 (acos 在自变量接近 1 时数值不稳定)。当两个标记在同一个城市里时,这个误差就会暴露出来。
3.4 流光动画:为什么尾迹必须不对称
glsl
float head = fract(uTime * uSpeed + vOffset);
// 顶点到光头的距离,做环绕处理,让流光跨过接缝时不跳变
float d = vProgress - head;
d = d - floor(d + 0.5);
// 尾迹只拖在光头后面:对称衰减看起来像一颗发光的珠子,
// 不对称衰减才像「在运动」
float tail = d < 0.0 ? exp(d * 9.0) : exp(-d * 42.0);
两侧衰减系数 9 和 42 差了将近 5 倍,这就是"运动感"的全部来源。对称的高斯衰减画出来是一颗静止的光珠,人眼读不出方向。
另外,WebGL 在几乎所有平台上都忽略 gl_LineWidth------线宽永远是 1 像素。你没法把线画粗,但可以把它画亮:
glsl
// 把颜色推过 1.0,让加法混合在线的核心饱和到白色。
// 1 像素的线没法变粗,但可以让它「泛光」
gl_FragColor = vec4(vColor * (1.35 + tail * 1.8), alpha);
四、大气层:一个被重写四次的着色器
这一节讲的不是性能,是如何定位一个视觉 bug 的真实根因。我觉得比前面的优化更有价值。
大气层是一个比地球稍大的反面球壳(side: BackSide),加法混合。目标是边缘泛光,向外柔和消失。
第一版:Fresnel(失败)
标准做法是 Fresnel 项------用法线和视线方向的点积算边缘强度。
问题 :Fresnel 值在球壳自己的轮廓处不收敛到 0 。几何体已经到边了,辉光还是亮的,于是它以一个硬边结束------渲染出来是一个画在星球外面的圆环。
第二版:用片元到相机轴的距离(失败得更彻底)
改用"这个片元离相机-球心连线有多远"来做衰减。听起来很合理。
问题 :这里渲染的是球壳的背面 (BackSide)。在掠射角下,透视投影会让这个距离反向增长 。实测在一条扫描线上,亮度是向外递增的,然后被网格边缘以满强度硬切断。
第三版:屏幕空间径向距离(方向对了)
真正无歧义的度量是:这个像素在屏幕上,处于"可见光环"的百分之多少。
所以把球心和一个恰好在星球边缘(limb)的点,用同一个投影矩阵推到裁剪空间,拿当前片元跟它们比:
glsl
vec4 centreClip = projectionMatrix * modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec4 limbClip = projectionMatrix * (modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0)
+ vec4(uRadius, 0.0, 0.0, 0.0));
vec2 centreNdc = centreClip.xy / centreClip.w;
float limbNdc = abs(limbClip.x / limbClip.w - centreNdc.x);
float outerNdc = limbNdc * uScale;
vec2 offsetNdc = clipPosition.xy / clipPosition.w - centreNdc;
float here = length(offsetNdc);
vRadial = (here - limbNdc) / max(outerNdc - limbNdc, 1e-6);
第四版:修掉 NDC 的各向异性(最后一块)
第三版在开发用的横屏视口上是对的,竖屏上大气层却渲染成一条又厚又饱和的色带。
根因:NDC 空间不是各向同性的 。x 和 y 都是 −1..1,跟视口宽高比无关。所以在竖屏上,一个 NDC 单位在水平方向和垂直方向对应的像素数不一样。
而上面 limbNdc 只沿 x 轴测量,拿它去跟一个二维长度 length(offsetNdc) 比较,就在较长的那个轴上悄悄拉伸了衰减范围 。横屏时误差方向相反,被 uIntensity 的调参吸收掉了------所以它在开发环境里"看起来是对的"。
修复只有一行:
glsl
float aspect = uResolution.x / max(uResolution.y, 1e-6);
vec2 offsetNdc = clipPosition.xy / clipPosition.w - centreNdc;
offsetNdc.y /= aspect; // ← 把 y 换算回各向同性的屏幕单位
float here = length(offsetNdc);
还有两条边距,各自解决一个亮线
glsl
float t2 = clamp((t - 0.06) / (0.82 - 0.06), 0.0, 1.0);
内边距 0.06 :球壳的 t = 0 环和地球的实际轮廓不落在同一个像素上 。在 1200px 的渲染上实测,地球圆面直径 502px,球壳 570px,最内侧的全亮片元溢出到了球体边缘外约 2 像素。那 2 像素就是一条勾勒星球轮廓的白线------这个瑕疵挺过了前面三次重写。
外边距 0.82 :辉光必须在几何体消失之前 先归零。如果衰减到球壳边缘才好不容易到 0,最外圈片元上还剩一个很小的非零值,而再往外一个像素就没有网格了------亮度在一个像素内从有到无,球壳自己的轮廓就显形了。
glsl
// 用 smoothstep 不用 pow:它两端导数都是 0,
// 梯度不会突然中断成一条可见的分界带
float falloff = 1.0 - smoothstep(0.0, 1.0, t2);
这一节的方法论 :三次失败的共同点是我都在调参数(改强度、改颜色、改缩放)。真正解决问题的是在渲染结果上量像素------502px、570px、溢出 2px。视觉 bug 也是可以测量的,只要你肯把它当成一个数值问题。
五、其他几个花了时间才想明白的点
5.1 帧率无关的阻尼
相机拖拽的阻尼,常见写法是 current += (target - current) * k。
问题 :这个式子在 144Hz 屏幕上每秒执行 144 次,60Hz 上只执行 60 次。同样的 k,高刷屏上镜头收敛快一倍多------手感跟显示器绑定了。
js
// 帧率无关的阻尼:指数形式保证两种刷新率下收敛时间一致
const alpha = 1 - Math.exp(-this.damping * (deltaMs / 16.667))
this.spherical.radius = lerp(this.spherical.radius, this.targetSpherical.radius, alpha)
5.2 flyTo 的方位角展开
从伦敦飞到东京,如果直接对方位角做插值,镜头会绕地球走远路(因为原始角度差超过 π)。
js
// 选取距离当前方位角最近的等价表示
let theta = destination.theta
const current = this.spherical.theta
while (theta - current > Math.PI) theta -= Math.PI * 2
while (theta - current < -Math.PI) theta += Math.PI * 2
5.3 flyTo 返回的 Promise 不能丢
js
// 替换飞行前必须先结算上一段。直接覆盖 this._flight 会把上一个
// Promise 的 resolve 丢掉------调用方 `await globe.flyTo(...)` 会永久挂起
this._cancelFlight()
一个 await 永远不 resolve 的 bug,在生产环境里极难排查。第二次 flyTo、或者用户中途拖了一下地球,都会触发它。
5.4 极角要留出安全边距
js
const POLE_EPSILON = 0.05 // 距离两极保留的弧度
把相机的极角限制在两极之外一点点。这是防止"地球突然翻转"抖动的关键------在正极点处方位角是奇异的。
5.5 首帧必须先摆好相机
js
// 在第一个动画帧之前放置相机。PerspectiveCamera 初始在原点------也就是地球内部,
// 而 requestAnimationFrame 在后台标签页里不会触发。
// 任何在那之前读相机的代码(截图、拾取、调用方自己的渲染)
// 都会拿到一个位于地心的相机,渲染出一片空白
this.controls.update(0)
5.6 纹理的 wrapS 和 wrapT 必须区别对待
js
// 经度环绕,纬度不环绕。
// u 用 Clamp 会让过滤器拿最后一列纹素去跟边缘混合,而不是跟第一列混合,
// 于是即使贴图内容本身是无缝的,也会留下一条淡淡的接缝
texture.wrapS = RepeatWrapping
texture.wrapT = ClampToEdgeWrapping
5.7 移动端的两级降级
js
// 球体细分随视口缩放。96 段在桌面显示器上完全平滑;
// 在 360px 的手机上,同样的网格把三角形花在了小于一个像素的曲率上
const segments = (container.clientWidth || 1280) <= 600 ? 48 : 96
js
// 手机 GPU 普遍上限 4096,8K 贴图带完 mipmap 在中端机上要 ~180MB 显存
// ------足够让标签页被系统杀掉。按视口宽度选,不要嗅探 UA
export function pickTextureSize(viewportWidth = 1280) {
if (viewportWidth <= 480) return 2048
if (viewportWidth <= 1024) return 4096
return 8192
}
DPR 也要封顶:
js
this.renderer.setPixelRatio(Math.min(this._window.devicePixelRatio || 1, this.maxPixelRatio))
DPR 3 的手机渲染的像素量是 DPR 1 的九倍,换来的差别肉眼看不出来。
六、这些东西是怎么保证不退化的
项目里有 86 个单元测试,跑在 vitest 上:
bash
test/coords.test.js 17 项
test/arcs.test.js 17 项
test/markers.test.js 25 项
test/controls.test.js 11 项
test/earth.test.js 10 项
test/textures.test.js 6 项
坐标转换、大圆插值、阻尼收敛这些纯函数是最值得测的:它们没有 DOM 依赖,出错时表现为"标记位置偏了一点点",肉眼极难发现,而单元测试一秒就能抓到。
这也是为什么 coords.js 被单独拆成一个不依赖 Three.js 场景(只用了 Vector3 这个值类型)的模块------可测性是设计出来的,不是补出来的。
同理,前面提到的"不用 Math.random,用 id 推导相位",除了保证视觉一致性,也让动画状态变成了可断言的。
总结:几条可以直接拿走的经验
-
Draw call 数量比三角形数量重要得多。 优化的第一步永远是"能不能合并",而不是"能不能简化"。
-
能推给 GPU 的每帧计算,就不要放在 CPU。 广告牌旋转、流光动画、脉动相位,全部在着色器里,CPU 每帧只更新一个
uTimeuniform。 -
当你的渲染在着色器里改变了几何形态,CPU 侧的拾取就会失效。 Raycaster 不是万能的,屏幕空间投影往往更简单也更正确。
-
球面上的插值必须用 slerp。 经纬度线性插值在任何严肃的地理可视化里都是 bug。
-
视觉 bug 也是可以量化的。 我在大气层上失败了三次,都是因为在盲目调参;解决它靠的是在渲染图上数像素。
-
确定性优于随机性。 用 id 推导出的伪随机,能同时给你视觉多样性、可复现性和可测试性。
项目地址 :github.com/GuMuChuan/d... 在线 Demo :dream-globe.pages.dev
MIT 协议开源,欢迎 star 和 issue。如果你在做数据大屏、地理可视化相关的项目,有商业授权或定制需求也可以直接找我。
作者:杨海彤 / GuMuChuan。文中所有代码均可在仓库中找到对应位置。