我把 Three.js 地球上的 1000 个标记压到了 4 次 draw call

这篇文章里的每一行代码、每一个数字,都来自一个真实开源项目 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 坐标。

这条路会给你一条错误的曲线。

两个原因:

  1. 经纬度线性插值得到的是等角航线(rhumb line),不是大圆航线(great circle)。它不是球面上的最短路径,画出来的弧线和真实航线不重合。
  2. 越靠近极地越离谱。极地附近经度会剧烈变化,插值出来的路径会拧成麻花。

正确做法是在三维向量上做球面线性插值:

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 推导相位",除了保证视觉一致性,也让动画状态变成了可断言的。


总结:几条可以直接拿走的经验

  1. Draw call 数量比三角形数量重要得多。 优化的第一步永远是"能不能合并",而不是"能不能简化"。

  2. 能推给 GPU 的每帧计算,就不要放在 CPU。 广告牌旋转、流光动画、脉动相位,全部在着色器里,CPU 每帧只更新一个 uTime uniform。

  3. 当你的渲染在着色器里改变了几何形态,CPU 侧的拾取就会失效。 Raycaster 不是万能的,屏幕空间投影往往更简单也更正确。

  4. 球面上的插值必须用 slerp。 经纬度线性插值在任何严肃的地理可视化里都是 bug。

  5. 视觉 bug 也是可以量化的。 我在大气层上失败了三次,都是因为在盲目调参;解决它靠的是在渲染图上数像素。

  6. 确定性优于随机性。 用 id 推导出的伪随机,能同时给你视觉多样性、可复现性和可测试性。


项目地址github.com/GuMuChuan/d... 在线 Demodream-globe.pages.dev

MIT 协议开源,欢迎 star 和 issue。如果你在做数据大屏、地理可视化相关的项目,有商业授权或定制需求也可以直接找我。


作者:杨海彤 / GuMuChuan。文中所有代码均可在仓库中找到对应位置。

相关推荐
其美杰布-富贵-李8 小时前
第 2 篇:搭建第一个 Three.js 项目
javascript·three.js
用户83134859306984 天前
Vue+Three.js实现PCB电路板3D交互:元器件点击高亮、部件显隐、模型自动旋转
vue.js·webgl·three.js
答案answer4 天前
做了几年 Three.js,我为什么决定自己开发一款3D低代码编辑器
前端·three.js
_lucas8 天前
做了一个glsl在线调试工具
前端·javascript·three.js
云空12 天前
《升级版3D数字沙盘(点击弹窗+第一人称漫游双功能)》
3d·three.js
Strayer12 天前
拓扑管网 3D 可视化大屏demo:科技感(地图 + 拓扑 + 3D 空间)
前端·three.js·数据可视化
用户831348593069814 天前
Vue+Threejs实现人物奔跑场景
vue.js·webgl·three.js
云空15 天前
《Three.js 完整版3D魔方:带贴纸+中心固定+自动求解复原》
前端·javascript·3d·three.js
云空15 天前
《Three.js 可操控3D魔方(键盘转动+打乱+复原)》
javascript·学习·游戏·3d·three.js