Three.js 高性能2D文本标签渲染:利用实例化与纹理图集优化千级元素显示

本文针对three.js中渲染大量2d文本标签时遇到的性能瓶颈,提出了一种高效的解决方案。通过结合instancedbuffergeometry和纹理图集技术,可以在场景中流畅地显示千级甚至更多带有文本的2d平面,同时实现文本裁剪效果,显著提升渲染性能,避免传统方法的卡顿问题。

理解传统方法的性能瓶颈

在Three.js应用中,当需要渲染数百甚至数千个2D文本标签时,常见的TextGeometry、troika-three-text或CSS2dRenderer等方法往往会遇到严重的性能问题,导致帧率下降和用户体验不佳。这主要是因为:

  • TextGeometry: 为每个文本生成复杂的几何体,增加了顶点数量和渲染开销。
  • troika-three-text: 虽然优化了文本渲染,但每个文本仍然可能是一个独立的网格,导致大量的绘制调用(Draw Call)。
  • CSS2dRenderer: 使用DOM元素进行渲染,浏览器在处理大量DOM元素时性能会急剧下降,且与WebGL场景的深度排序和裁剪集成较为复杂。

这些方法在元素数量较少时表现良好,但面对千级规模的文本标签时,其为每个元素独立进行的计算、绘制调用或DOM操作会迅速累积,成为性能瓶颈。

核心优化策略:实例化与纹理图集

为了高效渲染大量2D文本标签,我们可以采用实例化(Instancing)纹理图集(Texture Atlas)相结合的策略。

  1. 实例化(Instancing): THREE.InstancedBufferGeometry允许我们使用一个几何体定义来渲染多个对象。所有实例共享相同的几何体数据,但可以通过额外的实例属性(如位置、旋转、颜色、纹理偏移等)来区分它们。这极大地减少了CPU到GPU的数据传输量和GPU的绘制调用次数,因为所有实例都在一次绘制调用中完成渲染。

  2. 纹理图集(Texture Atlas): 纹理图集是将多个小纹理(例如本例中的不同文本标签)打包到一个大纹理中。通过在着色器中计算每个实例的UV坐标偏移,我们可以从同一个大纹理中选择性地渲染不同的子区域。这减少了纹理切换的开销,进一步优化了渲染性能。

结合这两种技术,我们可以创建一个单一的实例化网格,其几何体是一个简单的PlaneGeometry,而每个平面上的文本则通过从预先生成的纹理图集中采样来显示。

实现步骤

以下是使用实例化和纹理图集在Three.js中高性能渲染大量2D文本标签的详细步骤。

1. 环境准备

首先,确保你的HTML文件中包含Three.js库的引入,并设置好基本的场景、相机、渲染器和控制器。




    
    Three.js 高性能2D文本标签渲染
    


    
    
    

2. 创建纹理图集

使用HTML canvas 元素在JavaScript中动态生成包含所有文本标签的纹理图集。每个文本标签将被绘制到图集的一个小区域内。

        // ... (接上文代码) ...

        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;
                    let textY = ((amountH - y - 1) + 0.5) * stepH; // 注意Y轴方向可能需要翻转

                    // 绘制文本
                    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++;
                }
            }

            // 创建Three.js纹理
            let ct = new THREE.CanvasTexture(c);
            ct.colorSpace = THREE.SRGBColorSpace; // 设置颜色空间
            return ct;
        }
  • size: 纹理图集的总尺寸(例如4096x4096)。
  • amountW, amountH: 图集横向和纵向可以容纳的文本数量。例如,如果 size 是4096,amountW 是32,那么每个文本的宽度区域是 4096/32 = 128 像素。
  • ctx.fillText(counter.toString(), textX, textY): 绘制文本内容。
  • ctx.strokeRect: 可选地为每个文本区域绘制一个边框,有助于调试和可视化。
  • THREE.CanvasTexture: 将生成的canvas转换为Three.js纹理。

3. 构建实例化几何体和材质

使用InstancedBufferGeometry作为基础几何体,并创建一个ShaderMaterial来处理实例属性和纹理图集采样。

        // ... (接上文代码) ...

        // 创建实例化几何体,基于一个简单的平面
        let ig = new THREE.InstancedBufferGeometry().copy(new THREE.PlaneGeometry(2, 1));
        const amount = 2048; // 需要渲染的实例数量
        ig.instanceCount = amount; // 设置实例数量

        // 为每个实例添加位置属性
        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));

        // 获取纹理图集
        const textureAtlasSize = 4096;
        const textureAtlasAmountW = 32;
        const textureAtlasAmountH = 64; // 32 * 64 = 2048,正好对应实例数量
        let markerTexture = getMarkerTexture(textureAtlasSize, textureAtlasAmountW, textureAtlasAmountH);

        // 创建着色器材质
        let im = new THREE.ShaderMaterial({
            uniforms: {
                quaternion: { value: new THREE.Quaternion() }, // 用于Billboard效果
                markerTexture: { value: markerTexture },
                textureDimensions: { value: new THREE.Vector2(textureAtlasAmountW, textureAtlasAmountH) }
            },
            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(){
                  // 应用四元数旋转,使平面始终面向相机(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; // 每个单元格在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); // 输出颜色,这里假设文本背景是白色,文本是黑色,所以直接用RGB
                }
            `
        });

        // 创建实例化网格并添加到场景
        let io = new THREE.Mesh(ig, im);
        scene.add(io);
  • InstancedBufferGeometry().copy(new THREE.PlaneGeometry(2, 1)): 使用PlaneGeometry作为每个实例的基础形状。
  • ig.setAttribute("instPos", new THREE.InstancedBufferAttribute(instPos, 3)): 添加一个名为instPos的实例属性,它包含每个实例的3D位置。
  • ShaderMaterial:
    • uniforms: 传递全局数据到着色器,如纹理图集、图集尺寸和用于billboard效果的相机四元数。
    • vertexShader:
      • attribute vec3 instPos: 接收每个实例的位置。
      • qtransform: 这个函数将几何体的顶点(position)根据相机的四元数进行旋转,实现平面始终面向相机的Billboard效果
      • gl_InstanceID: WebGL内置变量,表示当前正在渲染的实例的ID。
      • uvX, uvY: 根据gl_InstanceID计算出当前实例在纹理图集中的行和列索引。
      • vUv = (vec2(uvX, uvY) + uv) * vec2(stepW, stepH): 关键步骤,将原始几何体的UV坐标(uv)缩放到纹理图集中的一个单元格内,并偏移到正确的位置。
    • fragmentShader:
      • uniform sampler2D markerTexture: 接收纹理图集。
      • texture(markerTexture, vUv): 使用计算出的UV坐标从纹理图集采样颜色。

注意事项与扩展

  1. 文本裁剪(Overflow Hidden): 这种方法天然地实现了文本的裁剪效果。因为文本是绘制在纹理图集中的一个固定大小区域内,然后映射到一个固定大小的PlaneGeometry上。如果文本内容超出了这个区域,它在纹理上就会被裁剪掉,进而显示在平面上时也会被裁剪。你可以通过调整PlaneGeometry的尺寸和getMarkerTexture中stepW/stepH来控制文本区域的大小。

  2. 文本质量与图集分辨率: 纹理图集的分辨率(size)和每个文本单元格的大小(stepW, stepH)直接影响文本的清晰度。如果文本过小或图集分辨率不足,文本可能会模糊。需要根据实际需求和性能预算进行权衡。

  3. 动态文本更新: 如果文本内容需要频繁动态更新,此方法会比较复杂。每次文本内容变化可能需要重新生成部分或整个纹理图集,这会带来一定的CPU和GPU开销。对于不经常变化的文本,此方法非常高效。

  4. Billboard效果: 顶点着色器中的qtransform函数确保了每个2D文本平面始终面向相机,无论相机如何移动,文本都不会出现透视变形,保持良好的可读性。

  5. 其他实例属性: 除了位置,你还可以为每个实例添加更多属性,如颜色、旋转、缩放等,通过InstancedBufferAttribute传递到着色器,实现更丰富的效果。

总结

通过将Three.js的实例化渲染纹理图集技术相结合,我们能够以极高的性能渲染成千上万个2D文本标签。这种方法通过减少绘制调用和优化纹理访问,有效解决了传统方法在处理大量元素时的性能瓶颈。它不仅提供了流畅的渲染体验,还自然地实现了文本裁剪和Billboard等实用效果,是构建复杂三维场景中大量2D信息展示的理想选择。