13 THREE.Points:一个对象绘制整片星空

前置知识:第 11 章的池化与秒制更新、第 12 章的随机分布。
本章示例:Points 星空 · 源码。
预期画面:一片带颜色差异的星空。调节点数仍只有一个点云绘制调用;关闭 sizeAttenuation 时近点和远点屏幕大小相近,开启后随距离变化。

先看现象:从很多对象变成一个对象

阶段 05 的粒子池预创建了 Sprite,但每个活粒子仍是场景中的独立对象。本页把 3000 个点的位置和颜色放进同一个 BufferGeometry,再由一个 THREE.Points 与一个 PointsMaterial 绘制。右侧“绘制调用”显示为 1;点数滑杆改到 6000 或 9000,调用数仍为 1。这个数字只描述本例当前场景,不能推出“任意场景用 Points 都只有一次调用”。

Points 不会替我们发射、更新或回收粒子。它是渲染容器:一个顶点对应一个点图元;要让点移动,必须更新几何数据、改变整个对象变换,或在着色器里计算位置。本文的星空位置不逐帧修改,拖动相机距离只改变观察方式。

1. 最小结构:位置属性、材质与点对象

每个位置占 Float32Array 的三个元素,单位是 Three.js 场景的世界单位。itemSize=3 告诉 Three.js 按 (x,y,z) 解释连续数据:

const positions = new Float32Array( count * 3 );
for ( let i = 0; i < count; i ++ ) {
  const j = i * 3;
  positions[ j ] = /* x */;
  positions[ j + 1 ] = /* y */;
  positions[ j + 2 ] = /* z */;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
const material = new THREE.PointsMaterial( { color: 0xffffff, size: 6 } );
const points = new THREE.Points( geometry, material );
scene.add( points );

示例还建立 color 属性,也是每点三个浮点数,并设置 vertexColors:true。这允许一个材质内的点有不同颜色;材质的 color 会与顶点颜色共同作用。位置与颜色各用 count × 3 × 4 字节,本页 3000 点约为 70 KiB 的 CPU 数组数据,不包含纹理、对象及 GPU 驱动开销。改点数时示例重新建数组和几何体,并 dispose() 旧几何体;这只发生在控件交互时,不是每帧重建。

2. PointsMaterial 的大小并非简单世界单位

默认 sizeAttenuation:true;透视相机下,顶点离相机越远,屏幕点大小通常越小。示例默认关闭它,使 size 近似表示 CSS 像素大小:同一材质中的近点和远点看起来大小相近。开启后移动“相机距离”,就能看到缩放关系。正交相机没有同样的透视距离衰减。

在 r174 的 WebGLRenderer 路径中,Three.js 传给内置 Points 着色器的 size 已乘 renderer.getPixelRatio()。因此本例不再手动乘像素比。sizeAttenuation:true 时,着色器还会按透视投影、画布高度和视空间深度缩放点大小;不要把材质 size=6 理解成一个固定的世界长度。第 16 章写自定义 gl_PointSize 时,才需自己处理像素比及投影公式。

WebGL 点图元有硬件支持的大小范围,可通过 gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) 查询。超过上限的值可能被截断。巨大烟片、需要任意长宽比或可旋转的图片,通常更适合 Sprite、四边形或实例化网格。

3. 贴图、混合和深度仍决定最终像素

示例使用本地代码生成的软圆 DataTexture 作为 map,设置 transparent:true、AdditiveBlending、depthTest:true、depthWrite:false。贴图 Alpha 决定星点边缘的透明度;加法混合让重叠的星光变亮;深度测试仍会让不透明遮挡物挡住星点;不写深度避免半透明点互相硬裁。第 02、05 章的混合和深度规则对 Points 一样有效。

一个 Points 对象内部的各点不会被 Three.js 当作独立透明对象排序。renderOrder 只能改变整个 Points 对象相对其他对象的提交顺序,不能逐点排。发光星空常用加法混合降低排序错误的可见性;烟的普通 Alpha 混合更容易暴露前后顺序问题。

4. 参数变化与路线边界

调高点数时,位置和颜色数组、点图元处理以及像素覆盖都会增加,虽然调用数仍为 1。PointsMaterial 给同一批点一个共同的 size、透明度和贴图;本例可用顶点颜色改变每点颜色,但不能只凭它给每个点独立的寿命、尺寸曲线或图集帧。若每点尺寸不同,可改用自定义着色器属性,或用 Sprite/四边形方案。第 15 章会先在 CPU 上逐帧更新 position 数组,第 16 章再讲 GPU 上的解析式运动。

本例的星点分布是盒形随机位置,并非天文学意义上的星表。所有随机位置只在创建或改点数时生成。由于几何体静态,geometry.computeBoundingSphere() 给对象级视锥裁剪提供边界;动态点云如果移出旧边界,需要重新计算、提供保守边界或按需求禁用对象级视锥裁剪。

常见问题

为什么调点数后星座形状变了? 本例重建整个位置与颜色数组并重新取样。真实项目若要保留旧点,可预分配容量、只改变 geometry.setDrawRange(),新增点再初始化。

一个绘制调用是否意味着一定更快? 不一定。JS 更新、GPU 上传、顶点处理、纹理采样和 overdraw 仍会增长。尤其点很大、互相重叠时,像素填充可能比调用数更重要。

为什么材质 size 不能当成米? 它最终写入 gl_PointSize 的屏幕点大小。开启距离衰减后与相机和画布有关,关闭时更接近 CSS 像素;两种情况都不是普通 Mesh 的世界单位缩放。

完成第 13 章练习,继续第 14 章:Billboard 与纹理图集。参考:Points、PointsMaterial、BufferGeometry。