在Three.js中渲染大量2D文本标签,如果采用传统的每个标签一个Sprite或Mesh的做法,当标签数量达到千级时,绘制调用(Draw Call)会急剧增加,导致渲染性能严重下降。为了解决这一问题,可以使用实例几何(InstancedBufferGeometry)配合纹理图集(Texture Atlas)技术,将千级文本标签的渲染合并为极少的绘制调用。

为什么传统文本标签渲染会遇到性能瓶颈
常见的文本标签实现方式是为每一个标签创建一个包含文字纹理的平面网格,或者使用Three.js的Sprite对象。这种方式的代码直观,适合几十条标签的场景。但当标签数量增长到一千以上时,每个对象都会产生独立的绘制调用,CPU需要不断向GPU提交绘制指令,瓶颈就出现在CPU与GPU的通信开销上。
除了绘制调用过多,传统方式还会造成显存浪费。每个标签如果单独生成一张画布纹理,哪怕文字内容很短,也会占用一块独立的显存区域,并且纹理切换会让GPU状态机频繁变动。在场景需要标签随时更新位置或内容时,这种做法几乎不可接受。因此必须换一种批量思路。
纹理图集技术的基本原理
纹理图集的本质是把所有可能需要显示的字形或整段标签文本,预先绘制到一张较大的画布上,并记录每个文本块在这张图集里的像素坐标与尺寸。运行时,所有标签共享这一张纹理,通过不同的UV范围来取用自己对应的那一部分图像。
使用纹理图集后,材质可以共用,不需要为每个标签单独创建纹理对象。下面是一段生成简单纹理图集的示例代码,它将多个标签文字绘制到一张1024x1024的画布中,并输出每个标签的UV信息:
// 生成文本纹理图集
function createTextAtlas(labels) {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 1024;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(0, 0, 1024, 1024);
ctx.fillStyle = '#ffffff';
ctx.font = '48px sans-serif';
const atlas = {};
let x = 0;
let y = 0;
const lineHeight = 64;
for (let i = 0; i < labels.length; i++) {
const text = labels[i];
const width = ctx.measureText(text).width;
if (x + width > 1024) {
x = 0;
y += lineHeight;
}
ctx.fillText(text, x, y + 48);
atlas[text] = {
u0: x / 1024,
v0: y / 1024,
u1: (x + width) / 1024,
v1: (y + lineHeight) / 1024
};
x += width + 10;
}
const texture = new THREE.CanvasTexture(canvas);
return { texture, atlas };
}
上面的代码把标签文字横向排布,并记录每个文字块的归一化UV坐标。实际项目中可以使用更紧凑的图集打包算法,比如基于二叉树的排布,以减少空白区域。图集生成后,所有实例只需要通过属性告诉着色器自己用哪一块UV即可。
实例几何如何批量绘制标签
Three.js的InstancedBufferGeometry允许我们基于一个基础几何体(例如一个单位平面)创建带有逐实例属性的几何体。常见的逐实例属性包括实例的偏移位置、缩放、以及对应图集的UV偏移与尺寸。GPU会针对同一个基础几何体执行多次顶点变换,但只产生一次绘制调用。
下面的代码展示了如何构建一个实例化的标签几何体,并为每个实例写入位置与UV区域信息:
// 构建实例化的文本标签几何
function createInstancedLabels(baseGeometry, count, atlasData) {
const geo = new THREE.InstancedBufferGeometry();
geo.index = baseGeometry.index;
geo.attributes.position = baseGeometry.attributes.position;
geo.attributes.uv = baseGeometry.attributes.uv;
const offsets = new Float32Array(count * 3);
const uvOffsets = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
offsets[i * 3 + 0] = Math.random() * 100 - 50;
offsets[i * 3 + 1] = Math.random() * 100 - 50;
offsets[i * 3 + 2] = 0;
const item = atlasData[i % atlasData.length];
uvOffsets[i * 4 + 0] = item.u0;
uvOffsets[i * 4 + 1] = item.v0;
uvOffsets[i * 4 + 2] = item.u1 - item.u0;
uvOffsets[i * 4 + 3] = item.v1 - item.v0;
}
geo.setAttribute('aOffset', new THREE.InstancedBufferAttribute(offsets, 3));
geo.setAttribute('aUvRange', new THREE.InstancedBufferAttribute(uvOffsets, 4));
return geo;
}
这里aOffset控制每个标签在场景中的位置,aUvRange描述该实例在图集中取用的矩形区域。由于这些属性是逐实例的,着色阶段可以把基础UV映射到图集的指定子区域,从而实现千级标签共用一张几何与材质。
自定义着色器完成最终渲染
为了让实例正确显示对应文本,需要写一段自定义ShaderMaterial。顶点着色器将基础位置加上实例偏移,并把基础UV按实例的UV区间进行换算;片元着色器从图集纹理中采样并输出颜色。
// 顶点着色器
attribute vec3 aOffset;
attribute vec4 aUvRange;
varying vec2 vUv;
void main() {
vUv = uv * aUvRange.zw + aUvRange.xy;
vec3 pos = position + aOffset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
// 片元着色器
uniform sampler2D map;
varying vec2 vUv;
void main() {
vec4 color = texture2D(map, vUv);
if (color.a < 0.1) discard;
gl_FragColor = color;
}
这段着色器逻辑非常直接:顶点阶段通过aUvRange把单位平面的uv缩放到图集对应区块,片元阶段直接采样。由于所有实例共享同一份着色器与纹理,整个标签系统只需一次绘制调用即可提交给GPU。
在实际项目中,还可以将标签始终朝向相机,这只需在顶点着色器中用相机右向量与上向量构造面向相机的四边形,而不依赖场景旋转。此外,如果标签文本会动态变化,可以只更新图集对应区块并标记纹理needsUpdate,不必重建几何。
方案优势与需要注意的问题
实例几何加纹理图集的方案把千级标签的Draw Call压缩到个位数,显存占用也因为共用纹理而大幅下降。在普通桌面GPU上,渲染两千个文本标签可以稳定保持六十帧以上。相比DOM标签叠加或独立Sprite,这种方案更契合三维场景的深度与透视关系。
但该方案并非没有限制。图集尺寸有限,若文本种类极多或字号变化大,需要合理规划图集容量;实例属性在频繁更新时也要注意避免每帧大量重写缓冲。另外,如果标签需要复杂排版或富文本,图集生成逻辑会明显变复杂。对这些情况,可以结合动态图集与分组合批策略来平衡灵活性与性能。
| 方案 | 千级标签Draw Call | 显存占用 | 适用场景 |
|---|---|---|---|
| 独立Sprite | 约1000+ | 高 | 少量动态标签 |
| 实例几何+图集 | 1~3 | 低 | 海量静态或准动态标签 |
通过上述技术组合,Three.js完全有能力在浏览器中流畅地呈现大规模2D文本标注,为数据可视化、三维地图标注等应用提供扎实的渲染基础。
Three.jsInstancedBufferGeometrytexture_atlas修改时间:2026-08-09 07:09:39