
本文旨在解决 Three.js 中渲染大量 2D 文本标签时遇到的性能瓶颈。通过采用实例几何体(InstancedBufferGeometry)结合纹理图集(Texture Atlas)和自定义着色器(ShaderMaterial)的方法,可以显著提升渲染效率,实现千级甚至更多文本标签的流畅显示,同时保持文本的对齐和可读性。
在 Three.js 应用中,当需要渲染数百甚至数千个 2D 文本标签时,传统的渲染方法如 TextGeometry、troika-three-text 或 CSS2DRenderer 往往会遭遇严重的性能问题,导致帧率骤降。这些方法通常为每个文本标签创建独立的几何体或 DOM 元素,从而增加了大量的绘制调用(draw calls)和 CPU/GPU 开销。为了解决这一挑战,一种高效的策略是利用 WebGL 的实例渲染(Instanced Rendering)能力,结合纹理图集来管理文本内容。
核心策略:实例渲染与纹理图集
该解决方案的核心思想是将所有文本标签渲染为同一批次的实例平面(instanced planes),并通过一个包含所有文本内容的纹理图集来为这些平面提供纹理。每个实例平面通过其唯一的 gl_InstanceID 从纹理图集中选择并显示对应的文本片段。这种方法将数千个绘制调用合并为少数几个,极大地减轻了渲染负担。
- 纹理图集(Texture Atlas): 首先,创建一个足够大的 Canvas 元素,将所有需要显示的文本内容绘制到这个 Canvas 上,形成一个包含多个文本块的“图集”。然后,将这个 Canvas 转换为 THREE.CanvasTexture,作为我们实例平面的纹理。
- 实例几何体(InstancedBufferGeometry): 使用 THREE.PlaneGeometry 作为基础几何体,并将其转换为 THREE.InstancedBufferGeometry。这意味着所有文本标签都将共享同一个几何体数据。
-
自定义着色器(ShaderMaterial):
- 顶点着色器 (Vertex Shader):为每个实例平面定义其独特的位置 (instPos)。关键在于,根据内置的 gl_InstanceID,计算出当前实例在纹理图集中的 UV 坐标偏移,从而确保每个实例能正确采样到图集中的特定文本。此外,为了实现文本始终面向摄像机(billboard effect),需要将平面相对于摄像机进行旋转。
- 片元着色器 (Fragment Shader):简单地使用顶点着色器传递过来的正确 UV 坐标,从纹理图集中采样颜色,并输出到屏幕。
实现步骤详解
以下是使用 Three.js 实现高性能 2D 文本标签渲染的详细步骤和示例代码。
1. 基础 Three.js 场景设置
首先,我们需要一个标准的 Three.js 场景、摄像机、渲染器和轨道控制器。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js 高性能2D文本标签</title>
<style>
body {
overflow: hidden;
margin: 0;
}
</style>
</head>
<body>
<script async src="https://ga.jspm.io/npm:es-module-shims@1.8.0/dist/es-module-shims.js" crossorigin="anonymous"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.158.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
console.clear();
let scene = new THREE.Scene();
scene.background = new THREE.Color(0xface8d); // 设置背景色
let camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000);
camera.position.set(3, 5, 8).setLength(40); // 设置摄像机位置
camera.lookAt(scene.position);
let renderer = new THREE.WebGLRenderer({
antialias: true // 开启抗锯齿
});
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
// 窗口大小调整事件
window.addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
let controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 开启阻尼效果
let light = new THREE.DirectionalLight(0xffffff, 0.5);
light.position.setScalar(1);
scene.add(light, new THREE.AmbientLight(0xffffff, 0.5)); // 添加光源
scene.add(new THREE.GridHelper()); // 添加辅助网格2. 创建文本纹理图集
getMarkerTexture 函数负责创建一个 Canvas,并将所有文本内容绘制到上面,最终生成一个 THREE.CanvasTexture。
function getMarkerTexture(size, amountW, amountH){
let c = document.createElement("canvas");
c.width = size;
c.height = size;
let ctx = c.getContext("2d");
ctx.fillStyle = "#fff"; // 背景填充白色
ctx.fillRect(0, 0, c.width, c.height);
// 计算每个文本块在图集中的步长
const stepW = c.width / amountW;
const stepH = c.height / amountH;
ctx.font = "bold 40px Arial";
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillStyle = "#000"; // 文本颜色
let col = new THREE.Color();
let counter = 0;
// 遍历并绘制所有文本到Canvas
for(let y = 0; y < amountH; y++){
for(let x = 0; x < amountW; x++){
let textX = (x + 0.5) * stepW;
let textY = ((amountH - y - 1) + 0.5) * stepH; // 注意y轴方向,Canvas原点在左上角
ctx.fillText(counter.toString(), textX, textY); // 绘制文本
// 绘制边框以区分不同的文本块
ctx.strokeStyle = '#' + col.setHSL(Math.random(), 1, 0.5).getHexString();
ctx.lineWidth = 3;
ctx.strokeRect(x * stepW + 4, y * stepH + 4, stepW - 8, stepH - 8);
counter++;
}
}
let ct = new THREE.CanvasTexture(c);
ct.colorSpace = THREE.SRGBColorSpace; // 设置颜色空间
return ct;
}在这个例子中,amountW 和 amountH 定义了纹理图集中文本块的网格布局(例如 32x64),size 定义了整个纹理图集的边长。counter 用于生成不同的文本内容。
3. 创建实例几何体和属性
我们创建一个 PlaneGeometry 作为基础,并将其转换为 InstancedBufferGeometry。然后,为每个实例添加一个 instPos 属性来定义其在世界空间中的位置。
let ig = new THREE.InstancedBufferGeometry().copy(new THREE.PlaneGeometry(2, 1)); // 基础平面几何体
ig.instanceCount = Infinity; // 实例数量设置为无限,实际由attribute的长度决定
const amount = 2048; // 文本标签的数量
let instPos = new Float32Array(amount * 3); // 每个实例有3个位置分量 (x, y, z)
for(let i = 0; i < amount; i++){
// 为每个实例生成随机位置
instPos[i * 3 + 0] = THREE.MathUtils.randFloatSpread(50);
instPos[i * 3 + 1] = THREE.MathUtils.randFloatSpread(50);
instPos[i * 3 + 2] = THREE.MathUtils.randFloatSpread(50);
}
// 将位置属性添加到实例几何体
ig.setAttribute("instPos", new THREE.InstancedBufferAttribute(instPos, 3));4. 定义自定义着色器材质
这是实现纹理图集和实例渲染的关键部分。
let im = new THREE.ShaderMaterial({
uniforms: {
quaternion: {value: new THREE.Quaternion()}, // 用于文本朝向摄像机
markerTexture: {value: getMarkerTexture(4096, 32, 64)}, // 纹理图集
textureDimensions: {value: new THREE.Vector2(32, 64)} // 纹理图集中的文本块网格尺寸
},
vertexShader: `
uniform vec4 quaternion; // 摄像机四元数,用于旋转
uniform vec2 textureDimensions; // 纹理图集网格尺寸 (amountW, amountH)
attribute vec3 instPos; // 实例位置属性
varying vec2 vUv; // 传递给片元着色器的UV坐标
// 四元数旋转函数
vec3 qtransform( vec4 q, vec3 v ){
return v + 2.0*cross(cross(v, q.xyz ) + q.w*v, q.xyz);
}
void main(){
// 将实例位置应用到顶点,并根据摄像机四元数旋转,实现billboard效果
vec3 pos = qtransform(quaternion, position) + instPos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.);
// 根据gl_InstanceID计算在纹理图集中的UV偏移
float iID = float(gl_InstanceID);
float stepW = 1. / textureDimensions.x; // 每个文本块的UV宽度
float stepH = 1. / textureDimensions.y; // 每个文本块的UV高度
float uvX = mod(iID, textureDimensions.x); // 当前实例在图集中的X索引
float uvY = floor(iID / textureDimensions.x); // 当前实例在图集中的Y索引
// 计算最终的UV坐标,uv是原始平面几何体的UV (0-1)
vUv = (vec2(uvX, uvY) + uv) * vec2(stepW, stepH);
}
`,
fragmentShader: `
uniform sampler2D markerTexture; // 纹理图集
varying vec2 vUv; // 从顶点着色器接收的UV坐标
void main(){
vec4 col = texture(markerTexture, vUv); // 从纹理图集采样颜色
gl_FragColor = vec4(col.rgb, 1); // 输出颜色
}
`
});
let io = new THREE.Mesh(ig, im); // 创建实例网格
scene.add(io); // 添加到场景5. 动画循环与渲染
在动画循环中,我们需要更新轨道控制器,并确保文本标签始终面向摄像机。这通过将摄像机的逆四元数传递给着色器实现。
let clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
controls.update(); // 更新轨道控制器
// 将摄像机的逆四元数传递给着色器,使平面始终面向摄像机
im.uniforms.quaternion.value.copy(camera.quaternion).invert();
renderer.render(scene, camera); // 渲染场景
});
</script>
</body>
</html>注意事项与最佳实践
- 纹理图集尺寸与文本质量: getMarkerTexture 函数中的 size 参数决定了纹理图集的总分辨率。更大的分辨率可以容纳更多文本或更高质量的文本。amountW 和 amountH 决定了图集中文本块的布局。需要根据实际文本数量和文本大小进行合理规划。
- 动态文本内容: 如果文本内容需要动态更新,则需要重新生成纹理图集。这可能是一个性能瓶颈,尤其是在频繁更新时。对于少量动态文本,可以考虑在图集中预留空白区域,并只更新局部纹理。对于大量动态文本,可能需要更复杂的策略,如多个纹理图集或异步更新。
- 文本裁剪与溢出: 示例代码中的文本是作为纹理绘制在平面上的。如果需要文本被其所在平面的边界裁剪(即“overflow hidden”),则当前的 UV 计算已经隐含了这一点,因为每个文本只占用其在图集中的指定区域。如果需要根据 另一个 3D 几何体的边界进行裁剪,则需要更复杂的着色器逻辑,例如使用 discard 片元或利用 Three.js 的裁剪平面功能。
- 性能优势: 实例渲染将所有文本标签的绘制合并为一个或少数几个绘制调用,显著减少了 CPU 和 GPU 之间的通信开销。同时,纹理图集避免了为每个文本单独加载纹理,进一步优化了性能。
- 文本样式与字体: 可以在 getMarkerTexture 函数中通过 ctx.font、ctx.fillStyle 等 Canvas 2D API 自由控制文本的字体、大小、颜色和样式。
- 替代方案考量: 对于数量较少(例如几十个)或需要与 DOM 元素交互的文本标签,CSS2DRenderer 仍是一个可行的选择。但对于千级以上的高性能需求,实例渲染与纹理图集是目前最有效的方法之一。
总结
通过 InstancedBufferGeometry 和 ShaderMaterial 结合纹理图集,我们能够高效地在 Three.js 中渲染大量的 2D 文本标签。这种方法将渲染性能瓶颈从每个文本标签的独立绘制调用转移到一次性生成纹理图集和一次性绘制所有实例上,从而在保持良好视觉效果的同时,实现了卓越的性能。在开发需要展示大量信息(如地图标注、楼层平面图中的房间名称等)的 3D 应用时,这种技术是不可或缺的。











