15 动态 BufferGeometry:CPU 更新雪与烟
前置知识:第 11 章的池化、第 13 章的
Points与属性数组。
本章示例:动态雪与烟 · 源码。
预期画面:雪片向下漂移、烟团向上扩散,活跃点数可在 1000—20000 间调整;绘制调用保持 1,JS 更新时间和理论位置上传字节随设置变化。切换“只上传活跃范围 / 上传完整容量”改变上传范围,不改变当前活跃点数。
先看现象:少了对象提交,还有两笔账
阶段 05 为每个粒子维护 Sprite,阶段 13 用一个 Points 绘制静态星空。本页把两者结合:预留 20000 个位置与颜色槽位,CPU 每帧只更新前 activeCount 个位置,一个 Points 对象负责绘制。右侧“绘制调用”显示 1,但这不等于整帧开销恒定。点数越多,JavaScript 更新循环越长,更新后的属性数据还要从 CPU 传给 GPU;烟点很大时也会增加像素覆盖和 overdraw。
把活跃点数设为 4000:位置属性的活跃部分是 4000 × 3 × 4 = 48000 字节,约 47 KiB;整个预留容量是 20000 × 3 × 4 = 240000 字节,约 234 KiB。切换上传模式,右侧显示这两个理论 position 数据量。它不是精确的总线流量或 GPU 时间,首次创建缓冲时也会上传整个预留数组。颜色属性另占 234 KiB,但本例只在切换效果或调整活跃数量时标记上传。
1. 预分配与活跃范围
页面初始化时创建五个固定数组:positions、colors、vx、vy、phase。前两个成为 GPU 属性;后三个只在 CPU 上保存运动状态。每个粒子第 i 个位置从 j=i×3 开始,不需要 new Vector3():
const capacity = 20000;
const positions = new Float32Array( capacity * 3 );
const geometry = new THREE.BufferGeometry();
const positionAttribute = new THREE.BufferAttribute( positions, 3 );
positionAttribute.setUsage( THREE.DynamicDrawUsage ); // 首次上传前设置
geometry.setAttribute( 'position', positionAttribute );
geometry.setDrawRange( 0, activeCount );
setDrawRange() 只控制绘制多少个点,不会缩小已分配的数组或 GPU 缓冲,也不会自动只上传活跃部分。数组和属性对象在每帧更新中不重建;效果切换时重新初始化内容,位置、颜色和速度状态都写回原来的槽位。上限 20000 只是本示例的可调范围,不是 Three.js 或浏览器的通用粒子数上限。
本例在 CPU 上用速度和 dt 改变位置,越过边界时把该槽位放回起始区域;因此复用的是数组下标,不需要删除一个对象再创建对象。雪的 y 下降,烟的 y 上升并扩散;颜色和材质设置分别让雪更亮、烟更柔和。动态位置可能超出初始化时算出的包围球,所以示例设 points.frustumCulled=false。更大的项目可维护保守包围球或按需更新边界,以保留对象级视锥剔除收益。
2. 修改数组后必须通知 Three.js 上传
直接写 positions[j] 只改变 CPU 内存;GPU 上的旧缓冲不会因此自动同步。每帧更新后设 positionAttribute.needsUpdate=true,它使属性版本增加,让渲染器提交更新:
for ( let i = 0; i < activeCount; i ++ ) {
const j = i * 3;
positions[ j ] += vx[ i ] * dt;
positions[ j + 1 ] += vy[ i ] * dt;
}
positionAttribute.clearUpdateRanges();
positionAttribute.addUpdateRange( 0, activeCount * 3 );
positionAttribute.needsUpdate = true;
r174 的 addUpdateRange(start,count) 按TypedArray 元素数计,不按顶点数也不按字节数计;三个浮点数才是一个位置。渲染器通过 bufferSubData 更新指定范围,随后清除已消费的范围。若不提供范围而只设 needsUpdate,本版 WebGL 路径会提交整个属性数组。本例的“全量上传”正是如此。DynamicDrawUsage 是缓冲使用提示,应在首次上传前设置;它不会代替 needsUpdate,也不能保证 GPU 一定更快。
本例活跃下标连续,故一个 [0,activeCount×3) 范围就够了。若粒子散落在不连续槽位,可以提交多个更新范围,但太多小范围也有调用成本;可考虑紧凑活跃区或直接上传一个覆盖区间。addUpdateRange() 自身每帧仍产生少量范围元数据,和内层循环每粒子分配对象不是同一量级。
3. 颜色、点大小与透明排序的边界
color 属性允许每点不同的 RGB。示例只在重设效果或调整数量时上传颜色;普通帧只动 position。共享 PointsMaterial 的 size 和 opacity 则对所有点统一生效:雪选较小、加法混合的亮点;烟选较大、普通 Alpha 混合的软点。若需要每点独立的尺寸、Alpha、寿命曲线、速度属性在 GPU 中使用,就需要自定义着色器并加入相应属性;第 16 章会从这个方向继续。
烟模式的 depthTest:true, depthWrite:false 保留与不透明场景的深度关系,但一个 Points 内部不会逐点透明排序。普通 Alpha 混合的烟在交叉与重叠处可能不符合严格的前后合成;renderOrder 只能改变整个对象的绘制顺序。提高点数时烟会越来越浓,也可能首先受 overdraw 限制。图中的柔边来自贴图 Alpha,并非与场景几何相交时的软粒子深度淡化。
4. 怎样解读右侧的性能数
“JS 更新 / 帧”只包住 CPU 循环的 performance.now() 差值,按约 350 ms 的窗口求平均。它不包含浏览器动画调度、材质处理、缓冲提交、GPU 绘制和显示同步;低于毫秒的读数尤其容易受计时精度和系统负载扰动。renderer.info.render.calls 是本场景的绘制调用数,不是帧耗时。理论上传字节也只计算 position 数据,不包括首帧创建、颜色变更或驱动开销。
比较不同设置时至少记录:活跃数、画布 CSS 尺寸、像素比、浏览器与 GPU/设备、效果模式和点的屏幕大小。此页默认画布宽度随页面布局变化,像素比最多取 2。雪和烟的点大小、混合模式不同,因此两者帧率差不能简单归因为 CPU 更新。十万粒子的 JS 循环与每帧属性上传在一些设备上会明显卡顿;“几万以后考虑着色器”是架构提醒,不是独立于设备与像素覆盖的固定阈值。
常见问题
为什么 setDrawRange() 后上传字节没有自动下降? 绘制范围和属性同步是两套机制。要减少每帧属性上传,必须正确设置更新范围;首次创建 GPU 缓冲仍按完整容量分配。
为什么数据已改而画面不动? 检查是否写到了属性持有的同一个 TypedArray、是否设置 needsUpdate=true、范围是否覆盖被改元素,以及 setDrawRange() 是否包含该点。
为什么每帧重建 BufferGeometry 仍然卡? 那会反复分配数组、属性和 GPU 缓冲,失去预分配价值。本例只修改原数组;只有资源彻底不用时才 dispose() 几何体、材质和纹理。
为什么一个点云仍会整批消失? 若对象级包围球过时,视锥剔除可能误判整个 Points。本例禁用了对象级视锥剔除以保证演示正确;正式项目应维护正确边界。
完成第 15 章练习。继续第 16 章:GPU 解析式粒子和第 17 章:软粒子,分别看怎样减少每帧属性上传,以及怎样用场景深度淡化交界。参考:BufferAttribute、BufferGeometry、DynamicDrawUsage。