
本文旨在解决 three.js 中渲染上千个 2d 文本标签时遇到的性能瓶颈。通过深入探讨传统的 textgeometry、troika-three-text 和 css2drenderer 等方法的局限性,提出并详细阐述了使用 `instancedbuffergeometry` 结合纹理图集(texture atlas)的优化方案。该方案能够显著减少绘制调用,大幅提升渲染效率,为大规模场景下的文本显示提供了高效且专业的解决方案。
在 Three.js 应用中,当需要渲染大量 2D 文本标签时(例如,在楼层平面图上显示房间名称或区域信息,数量可能达到上千个甚至更多),传统的渲染方法往往会遭遇严重的性能问题。诸如 TextGeometry 生成复杂几何体、troika-three-text 虽有优化但仍需独立处理每个文本,以及 CSS2dRenderer 依赖 DOM 元素进行渲染,这些方法在处理海量文本时都会导致过高的绘制调用(draw calls)和浏览器性能负担,从而造成帧率骤降。
为了克服这一挑战,一个高效的解决方案是结合使用 Three.js 的实例化渲染(Instancing)和纹理图集(Texture Atlas)技术。
核心优化策略:实例化与纹理图集
该优化方案的核心思想是:
- 纹理图集(Texture Atlas):将所有需要显示的文本内容预先绘制到一个大型的纹理图片上。这张图片被划分为多个小区域,每个区域对应一个文本标签。
- 实例化渲染(Instanced Rendering):使用单个 PlaneGeometry 作为模板,并通过 InstancedBufferGeometry 以实例化方式渲染成千上万个平面。每个平面通过其唯一的实例 ID ( gl_InstanceID ) 从纹理图集中采样对应的文本区域,并根据需要进行定位和旋转。
这种方法将原本多个独立的绘制调用合并为极少数甚至一个绘制调用,极大地减轻了 GPU 的负担,从而实现高性能渲染。
实现步骤详解
以下将通过一个完整的 Three.js 示例代码,详细讲解如何实现这一高性能 2D 文本标签渲染方案。
1. HTML 与 CSS 基础设置
首先,确保页面的 body 元素没有默认的边距和溢出,以便 Three.js 渲染器能够全屏显示。
Three.js 高性能2D文本标签
将以下 Three.js 代码保存到 main.js 文件中。
2. Three.js 场景初始化
设置 Three.js 场景、相机、渲染器、轨道控制器和光源。
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", (event) => {
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()); // 添加网格辅助线3. 生成文本纹理图集
这是关键一步,我们需要创建一个 Canvas 元素,将所有文本绘制到这个画布上,然后将其转换为 THREE.CanvasTexture。
/**
* 生成包含所有文本标签的纹理图集
* @param {number} size 纹理图集的边长(正方形)
* @param {number} amountW 纹理图集横向文本数量
* @param {number} amountH 纹理图集纵向文本数量
* @returns {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;
// 遍历并绘制每个文本标签
for (let y = 0; y < amountH; y++) {
for (let x = 0; x < amountW; x++) {
// 计算文本绘制中心点
let textX = (x + 0.5) * stepW;
// 注意:Canvas的Y轴方向与Three.js UV坐标可能相反,这里调整了Y轴计算
let textY = ((amountH - y - 1) + 0.5) * stepH;
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;
}在 getMarkerTexture 函数中,我们将文本内容(这里是递增的数字)绘制到画布上,并为每个文本单元绘制了一个彩色边框,以便于调试和观察纹理图集。
4. 创建实例化几何体与着色器材质
实例化渲染的核心是 InstancedBufferGeometry 和自定义的 ShaderMaterial。
// 创建实例化几何体:基于 PlaneGeometry
let ig = new THREE.InstancedBufferGeometry().copy(new THREE.PlaneGeometry(2, 1)); // 每个平面大小为 2x1
ig.instanceCount = Infinity; // 实例数量可以无限,实际由 instPos 决定
const amount = 2048; // 实例数量
let instPos = new Float32Array(amount * 3); // 存储每个实例的位置
for (let i = 0; i < amount; i++) {
instPos[i * 3 + 0] = THREE.MathUtils.randFloatSpread(50); // X 坐标
instPos[i * 3 + 1] = THREE.MathUtils.randFloatSpread(50); // Y 坐标
instPos[i * 3 + 2] = THREE.MathUtils.randFloatSpread(50); // Z 坐标
}
// 将位置属性添加到实例化几何体
ig.setAttribute("instPos", new THREE.InstancedBufferAttribute(instPos, 3));
// 创建着色器材质
let im = new THREE.ShaderMaterial({
uniforms: {
quaternion: { value: new THREE.Quaternion() }, // 用于文本朝向相机的四元数
markerTexture: { value: getMarkerTexture(4096, 32, 64) }, // 纹理图集,这里假设 4096x4096 大小,包含 32x64 个文本
textureDimensions: { value: new THREE.Vector2(32, 64) } // 纹理图集的布局尺寸
},
vertexShader: `
uniform vec4 quaternion;
uniform vec2 textureDimensions;
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(){
// 旋转顶点,使其始终面向相机(billboarding效果),然后加上实例位置
vec3 pos = qtransform(quaternion, position) + instPos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.);
// 根据实例ID计算在纹理图集中的 UV 偏移
float iID = float(gl_InstanceID); // 当前实例的 ID
float stepW = 1. / textureDimensions.x; // 单个文本单元在 U 方向的宽度
float stepH = 1. / textureDimensions.y; // 单个文本单元在 V 方向的高度
float uvX = mod(iID, textureDimensions.x); // 计算文本单元在图集中的列索引
float uvY = floor(iID / textureDimensions.x); // 计算文本单元在图集中的行索引
// 结合原始 UV 坐标和偏移,得到在纹理图集中的最终 UV 坐标
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); // 添加到场景着色器代码解析:
-
顶点着色器 (vertexShader):
- uniform vec4 quaternion;: 接收一个四元数,用于将每个文本平面旋转,使其始终面向相机(billboarding 效果)。
- attribute vec3 instPos;: 这是一个实例属性,每个实例都有一个独立的 instPos 值,用于定位。
- gl_InstanceID: 内置变量,表示当前正在渲染的实例的唯一 ID。
- 通过 gl_InstanceID 和 textureDimensions 计算出当前实例在纹理图集中的 UV 偏移量,然后与原始的 uv 坐标相加,得到正确的纹理采样坐标 vUv。
-
片元着色器 (fragmentShader):
- uniform sampler2D markerTexture;: 接收纹理图集。
- 使用从顶点着色器传递的 vUv 坐标从 markerTexture 中采样颜色,并作为最终的片元颜色输出。
5. 动画循环
在动画循环中,更新轨道控制器,并确保文本始终面向相机。
let clock = new THREE.Clock();
renderer.setAnimationLoop((_) => {
let t = clock.getElapsedTime();
controls.update(); // 更新轨道控制器
// 使文本平面始终面向相机
im.uniforms.quaternion.value.copy(camera.quaternion).invert();
renderer.render(scene, camera); // 渲染场景
});im.uniforms.quaternion.value.copy(camera.quaternion).invert(); 这一行是实现文本平面“billboarding”(始终面向相机)效果的关键。它将相机的旋转四元数取反,然后应用到每个实例的顶点上,使得每个平面在本地坐标系中被旋转,从而在世界坐标系中看起来始终正对着相机。
关键概念与优势
- 高性能:通过实例化渲染,将成千上万个文本标签的绘制调用合并为一次,极大地减少了 CPU 和 GPU 之间的通信开销,显著提升了帧率。
- 灵活性:虽然文本内容是预先绘制在纹理图集上的,但通过调整 getMarkerTexture 函数,可以动态生成不同字体、颜色、大小或内容的文本。
- Billboarding效果:通过着色器中的四元数旋转,文本平面可以自动调整方向,始终面向相机,保证了文本的可读性。
- 内存优化:所有文本共享同一个几何体和材质,只在 InstancedBufferAttribute 中存储每个实例的少量独特数据(如位置),节省了内存。
- 文本裁剪:由于文本被绘制在纹理图集中的特定区域,如果文本内容超出该区域,它将在纹理层面被裁剪,实现了“溢出隐藏”的效果。
注意事项与局限性
- 纹理图集大小限制:纹理图集的大小受限于 GPU 的最大纹理尺寸。需要合理规划文本单元的大小和数量。
- 动态文本更新:如果文本内容需要频繁动态改变(例如,实时更新的数字或状态),每次更新都可能需要重新生成部分或整个纹理图集,这会带来一定的性能开销。对于高度动态的文本,可能需要考虑更复杂的纹理更新策略(如子纹理更新)。
- 文本复杂度:对于非常复杂的文本样式或多行文本,在 Canvas 上精确布局可能需要更精细的 getMarkerTexture 实现。
- 3D遮挡:本方案的文本是 2D 平面,如果场景中有复杂的 3D 几何体遮挡,可能需要额外的深度测试或透明度处理来确保正确的渲染顺序。
总结
通过结合 Three.js 的实例化渲染和纹理图集技术,我们可以高效地在 3D 场景中渲染大量 2D 文本标签。这种方法通过减少绘制调用、优化数据传输,显著提升了大规模场景的渲染性能,为开发者提供了强大的工具来构建复杂且流畅的交互式 3D 应用。虽然存在一些动态更新和复杂文本处理的考量,但对于大多数需要显示大量静态或半静态文本标签的场景,这无疑是一个卓越的解决方案。











