16 GPU 解析式粒子:按初始状态和时间重算画面
前置知识:第 12 章的运动公式、第 15 章的动态属性数组。
本章示例:GPU 星尘 · 源码。
预期画面:大量星尘从下方扩散、上升并逐渐消失后循环。调活跃数到 40,000,绘制调用仍为 1;重力和风改变轨迹,暂停后模拟时间停止。每帧 CPU 粒子属性写入显示为 0。
先看现象:不再逐点把新位置传给 GPU
第 15 章每帧在 JavaScript 循环中更新 position,再上传属性。本页一开始就把每个点的初始位置、速度、寿命、出生偏移、大小和颜色上传。之后动画帧只增加统一时间 uTime,并设置重力、风及像素比等 uniform。顶点着色器根据当前时间给每个顶点重新算出位置。浏览器里将点数从 20,000 调到 40,000,geometry.setDrawRange() 改变参与绘制的点数,但不用重建或重新上传属性。
“每帧 CPU 粒子属性写入 0”指页面动画循环没有逐粒子写 TypedArray,不等于没有 CPU 工作或 GPU 上传。JavaScript 仍运行渲染循环、更新 uniform,GPU 仍要处理每个活跃顶点并绘制覆盖的像素。点击“重新取样初始属性”会重写并上传整组属性;这不是普通动画帧的操作。
1. 从 CPU 状态变成初始属性
每个粒子的输入数据分成六项:
| 属性 | 本例含义 | 单位/范围 |
|---|---|---|
position |
初始位置 p₀ |
世界单位,作为 aOrigin 使用 |
aVelocity |
初始速度 v₀ |
世界单位/秒 |
aLife |
一次循环寿命 | 秒,始终大于 0 |
aBirth |
出生时间偏移 | 秒,本例取负值以错开初始年龄 |
aSize |
基础屏幕点大小 | 近似 CSS 像素,后乘像素比 |
aColor |
每点 RGB | 线性颜色数值,供片元着色器使用 |
这些属性在一个 BufferGeometry 中按相同顶点序号排列。r174 的 ShaderMaterial 可直接用 attribute 声明读取自定义属性:
geometry.setAttribute( 'position', new THREE.BufferAttribute( origins, 3 ) );
geometry.setAttribute( 'aVelocity', new THREE.BufferAttribute( velocities, 3 ) );
geometry.setAttribute( 'aLife', new THREE.BufferAttribute( lives, 1 ) );
geometry.setAttribute( 'aBirth', new THREE.BufferAttribute( births, 1 ) );
geometry.setAttribute( 'aSize', new THREE.BufferAttribute( sizes, 1 ) );
geometry.setAttribute( 'aColor', new THREE.BufferAttribute( colors, 3 ) );
六项合计每点 12 个 float32,约 48 字节;预留 40,000 点的 CPU TypedArray 内容约 1.83 MiB,GPU 也需要对应属性缓冲。实际资源占用还包括缓冲管理、材质与画布。本例通过 setDrawRange(0,count) 控制活跃点数,不因此缩小预留容量。
2. 顶点着色器直接求 p(t)
恒定加速度下,连续运动解析式为 p(t)=p₀+v₀t+½at²。本例取 ax=uWind、ay=uGravity,其中向下重力传入负数。每个粒子的局部年龄由统一时间、出生偏移和寿命计算:
float age = mod( max( uTime - aBirth, 0.0 ), aLife );
float progress = age / aLife;
vec3 current = position + aVelocity * age;
current.x += 0.5 * uWind * age * age;
current.y += 0.5 * uGravity * age * age;
gl_Position = projectionMatrix * modelViewMatrix * vec4( current, 1.0 );
当 age 到达寿命时,mod 使它回到 0,点从初始位置重新开始。aBirth 取 [-aLife,0] 内的负值,让打开页面时已经有不同年龄的粒子;“时间归零”仍能看见一片分散的星尘。这个循环是一条数学轨迹的重复播放,不是真实的死亡回调、池释放或状态保存。需要“一次爆炸后彻底消失”时,应增加是否存活的判定,并避免取模循环。
星尘渐隐由 progress 控制,片元着色器再用 gl_PointCoord 画柔边圆点。它是程序生成的圆点,不依赖外部贴图。材质设置 AdditiveBlending、transparent:true、depthTest:true、depthWrite:false,所以叠加时发亮,与不透明物体仍遵守深度测试,但点云内部不做逐点透明排序。
3. 自定义 gl_PointSize 要自己管像素比
第 13 章的 PointsMaterial 在 r174 WebGL 路径内部处理了像素比;本章改用 ShaderMaterial,顶点着色器直接写 gl_PointSize。示例把 renderer.getPixelRatio() 传成 uPixelRatio:
gl_PointSize = max( 1.0, aSize * uPixelRatio * ( 0.5 + 0.5 * vAlpha ) );
本例使用正交相机,aSize 可以近似理解为 CSS 像素的基础直径;乘像素比后得到物理像素直径。若改透视相机并希望随距离缩小,还要乘投影相关的衰减项,不能只复制这一行。点图元大小又受 ALIASED_POINT_SIZE_RANGE 硬件上限限制,页面显示当前 WebGL 上限;超限时换大 gl_PointSize 不一定有用,应考虑 Sprite 或四边形。points.frustumCulled=false 是因为静态初始位置的包围球不能覆盖解析式运动后的全部位置;正式项目可维护保守运动边界。
4. uniform 改变的是整条轨迹参数
拖动重力或风时,页面把新值传给 uniform。顶点着色器随即用当前 age 和新加速度从 p₀ 重新求 p(age)。因此已存在粒子可能瞬间换到“假设从出生起一直受这个新风”的位置。这和第 12 章半隐式 Euler 每帧修改速度的有状态更新不同:后者在改变风后,旧速度保留,新风只影响后续运动。
若只是无碰撞的喷泉、星尘或循环装饰,解析式通常足够;若粒子要与墙碰撞、受邻居影响、积累噪声力或在运行时改变力而保持速度连续,必须保存状态。可选择 CPU 数组继续上传,或用纹理/缓冲反馈在 GPU 上保存上一帧位置和速度,再读写下一帧。顶点着色器的输出不会自动成为下帧输入属性。 ShaderMaterial 并不等于有状态 GPU 模拟。
本例用 dt = min(clock.getDelta(),0.05) 增加统一时间,后台恢复时会丢弃被截去的墙钟时间,和前几章的限幅策略一致。长时间运行还需考虑单精度 uTime 精度逐渐下降,可按周期安全地重基时间与出生偏移,或在应用层重建时间基准。
5. 性能应比较哪些成本
第 15 章 CPU 路线每帧有 O(N) 的 JavaScript 位置更新和属性上传;本例每帧只更新少量 uniform,但 GPU 顶点计算仍为 O(N),点图元较大时片元填充仍可能更贵。预留属性的一次性上传、重新取样时的上传、浏览器事件与 Draw Call 也都存在。一个 Points 对象在此场景中为一个绘制调用;不能单凭这一指标断言 GPU 路线一定更快。
比较时固定点数、画布 CSS 尺寸、像素比、点大小、浏览器和 GPU;分别看 CPU JS 时间、属性上传、GPU 时间及 overdraw。第 15 章与本页的效果配方不同,直接对比页面帧率不能当严谨基准。本页 40,000 的最大滑杆值仅是可操作的演示范围。
常见问题
为什么暂停后画面仍渲染? 暂停只冻结 uTime,页面仍提交静止画面,浏览器重绘和控件仍可工作。要省下渲染工作,可在静止时改为按需渲染。
为什么重力一改,已有粒子像跳了一下? 新 uniform 被用于整段解析式,过去轨迹被重新解释。需要连续历史响应时,用状态积分或分段保存改变时刻的状态。
为什么没有每帧数组上传,却仍可能卡? 顶点数、点大小、重叠面积、混合与 GPU 性能仍决定耗时。请先定位瓶颈,再决定 CPU/GPU 路线。
完成第 16 章练习,继续第 17 章:基于场景深度的软粒子。参考:ShaderMaterial、Points、BufferGeometry。