14 Billboard 与纹理图集:图片怎样跟随相机

前置知识:第 03 章的透明纹理、第 13 章的点图元与贴图。
本章示例:平面、Sprite 与图集 · 源码。
预期画面:相机方位从 0° 转到约 75° 时,左侧普通平面变成窄片,右侧 Sprite 仍保持正面;选择星芒、雪花、烟团和魔法环时,两者及背景移动 Sprite 同步换图案。

先看现象:图案一样,朝向规则不同

示例中央两个图案取自同一张 2×2 图集。左边是 PlaneGeometry,法线固定朝世界 +Z;右边是 THREE.Sprite。当相机从正面绕到侧面,平面投影宽度明显变窄,Sprite 的面始终朝向相机。背景的 90 个小 Sprite 也保持正面,并用 deltaTime 向下移动、在越过下边界后复用位置。

Billboard(看板)是一种朝向规则,不是“粒子必须是一张图片”的规定。它用一个面朝相机的平面近似有体积的烟团、雪片或光斑。Points 的点图元也在屏幕上面向观察者,但尺寸和形状受点图元限制;Sprite 以对象的 scale 控制世界空间尺寸,还能设置图片在平面内的旋转。相机绕到它背后时仍能看见图案,正是这种近似的优点和限制。

1. Three.js 原生 Sprite 的最小结构

const material = new THREE.SpriteMaterial( {
  map: texture,
  transparent: true,
  depthTest: true,
  depthWrite: false
} );
const sprite = new THREE.Sprite( material );
sprite.position.set( 1.15, 0.25, 0.5 ); // 世界单位
sprite.scale.set( 1.35, 1.35, 1 );       // 世界空间宽高
scene.add( sprite );

这里没有在 animate() 里手动 lookAt(camera.position)。Sprite 的顶点着色器在渲染时按相机视空间构造朝向相机的四边形。若只需要所有点共用同一种小图案且数量较多,Points 更紧凑;若需要独立对象缩放、旋转或交互,Sprite 容易上手,但本例 90 个背景 Sprite 仍是 90 个场景对象与独立绘制提交。它不是阶段 13 的单个 Points 批次。

SpriteMaterial 的贴图 Alpha 负责裁掉方形图片的透明角。示例采用普通 Alpha 混合和 depthWrite:false;深度测试仍开启。透明 Sprite 之间依旧可能发生排序误差,不能靠“始终朝向相机”解决。烟团与地面相交时的硬边也不是这张带柔边的贴图能自动修复的;基于场景深度的软粒子留在第 17 章。

2. 一张图集放四个图案

纹理图集(texture atlas)把多个小图放到一张大图。本例运行时用 Canvas 画出 256×256 的 RGBA 图集,每格 128×128:左上星芒、右上雪花、左下烟团、右下魔法环。图像在本地代码生成,没有网络素材。CanvasTexture.colorSpace 设为 SRGBColorSpace,因为图案的白色 RGB 是颜色信息;Alpha 仍是线性覆盖率,不做 sRGB 转换。

每格宽高占整张纹理的 1/2。选格时,把 UV 缩放到半张图,再偏移到目标格:

const tile = atlas.clone();
tile.repeat.set( 0.5, 0.5 );
tile.offset.set( column * 0.5, rowOffset );
tile.needsUpdate = true;

Canvas 的行从上往下数,而纹理 UV 的 v 从下往上。示例的默认 flipY 配合 Canvas 上传后,上排使用 offset.y=0.5,下排使用 0;左右列分别用 0 和 0.5。在另一个贴图加载流程或 flipY 设置下,需重新确认哪格出现在屏幕上,不能死记偏移值。选择帧时,本例替换材质的 map 引用,几何形状和运动规则不变。

图案离格子边缘有留白;透明像素先写入白色 RGB、Alpha 0,减少线性过滤把黑色带到边缘的机会。示例关闭 mipmap,以免远处图案跨格混色。真实图集在启用 mipmap 时通常还需更宽的边缘填充,或单独处理每格的 mipmap。

3. 图集并不会自动合批

本例为教学易读使用 atlas.clone() 创建四个带不同 repeat/offset 的 Texture 对象。它们共享同一份 Canvas 图像来源,但不能据此声称只占一份 GPU 纹理或已经合批:WebGL 可能为这些 Texture 实例分别创建纹理资源。即便所有 Sprite 共用同一个材质,普通 Sprite 仍逐对象绘制。

真正把许多选不同帧的粒子放在一个调用里,需要同一个 GPU 图集纹理,以及每个点或四边形自己的图集索引/UV 变换。若用 Points,可以在自定义着色器里根据帧索引变换 gl_PointCoord;若用四边形,可直接写每个顶点的 UV。那会引入自定义属性和着色器,第 16 章再展开。图集的价值是统一管理图案、减少频繁换图的可能性;它本身不能保证绘制调用减少。

4. 如何选择 Billboard、Points 或网格

需求 更直接的起点 主要限制
大量小星点、同尺寸规则 Points + PointsMaterial 点大小范围、每点尺寸/图集帧不便
少量独立缩放/旋转的雪片或烟片 Sprite + SpriteMaterial 每对象提交,透明排序仍在
要绕三维轴转、接收复杂光照或做有厚度外形 Mesh/实例化网格 需自己决定面向相机与 UV 规则

本例雪片是世界空间缩放的 Sprite,风速是世界单位/秒;相机角度仅改变观察方向,风不会因相机转动而改向。选“烟团”只改变纹理与显示外观,不会自动把雪片的向下运动改成上升烟流。效果应该由出生位置、运动、尺寸曲线、纹理、混合和深度共同决定。

常见问题

为什么左侧平面在 75° 没完全消失? 相机并非恰好转到 90°,平面使用双面材质且透视投影仍有非零宽度。若转过背面并改用单面材质,还可能因为背面剔除而消失。

为什么烟团贴图边缘柔和,却和地面相交时仍可能硬? 贴图柔边沿图片轮廓变化,交界硬边取决于粒子深度和场景深度的差。软粒子需要读取场景深度并在近交界处衰减 Alpha。

为什么图集换帧后背景仍是“下雪”? 背景 Sprite 的运动代码仍固定向下。换纹理只换外观;想做烟流需要改出生和速度规则。

完成第 14 章练习,再读第 15 章:动态 BufferGeometry。参考:Sprite、SpriteMaterial、Texture。