20 性能诊断与 three.quarks:怎样选择粒子实现路线

前置知识:第 15 章的动态属性上传、第 16 章的解析式 GPU 运动、第 18 章的效果配方。
本章示例:性能实验 · 原生与库火焰对照;源码分别见 20-performance-lab.js 与 20-quarks-compare.js。
预期画面:性能实验可切换 CPU position 更新和 GPU 解析式路线,点数/点大小/像素比各自改变不同成本;库对照页左右都出现持续上升的暖色火焰,并显示活粒子、JS 更新和绘制调用。

先看现象:相同绘制调用,不代表相同成本

性能实验始终只有一个 THREE.Points。CPU 路线每帧遍历活跃点并更新 position;GPU 路线使用初始位置、速度与统一时间在顶点着色器中计算。切换时绘制调用仍为 1,但页面显示的 JS 位置循环与理论 position 更新字节变成 0。把点尺寸从 6 增到 20 CSS 像素,即使点数和 JS 路线不变,屏幕上被提交的点方片面积也会大幅增加。这正是需要把四类成本分开看的原因。

成本 本页可观察量 它没有包含什么
CPU 模拟 performance.now() 包住的逐点位置循环 浏览器调度、Three.js 渲染器内部、属性提交、GPU 执行
CPU→GPU 缓冲更新 N×3×4 字节的理论连续 position 范围 首次分配、驱动开销、其他属性、纹理与实际总带宽
绘制提交 renderer.info.render.calls 每次调用内有多少顶点、片元和混合工作
屏幕覆盖 / overdraw N×(size×DPR)² 的点方片面积估算 视口外裁剪、圆形丢弃、重叠的真实片元数与 GPU 时间

最后一列尤其重要:页面的“帧间隔均值”是相邻动画回调开始时间之差,包含调度、显示同步和后台标签页节流;它不是 GPU 时间。没有 GPU 计时查询或浏览器性能工具时,不能从它推断某个着色器花了多少毫秒。切换路线后第一帧的缓冲恢复、着色器编译等也会污染短窗口结果。

1. 一次只改一个参数的诊断顺序

先固定浏览器、设备、画布 CSS 尺寸和像素比,等待画面稳定,再记录点数、点尺寸、路径和几项读数。比较 CPU/GPU 路线时,两者使用同一组初始位置和速度,当前时间对应相同的 p₀+v₀t 轨迹;CPU 路线改数组,GPU 路线在着色器加位移。CPU 路线使用 addUpdateRange(0,count*3),范围单位是 Float32Array 元素数,不是字节;首次创建仍为 40,000 容量分配 GPU 缓冲。GPU 路线切换时会一次性恢复初始 position,普通帧不再上传粒子属性。

接着保持点数与路径不变,只调尺寸或像素比。自定义 gl_PointSize 乘 renderer.getPixelRatio();DPR 从 1 到 2,点方片边长翻倍、面积约四倍,画布物理像素也约四倍。这只是提交面积估算,实际 GPU 耗时受裁剪、缓存、硬件点大小上限、片元丢弃与混合影响。若画面变慢而 JS 循环时间基本不动,应重点查看填充、透明层数与分辨率。把烟点换成小星点后再测,结论可能反转。

最后调点数。CPU 更新与上传字节约随 N 线性增加,GPU 顶点工作也随 N 增加;透明粒子重叠时片元成本可能增长得更快。几万粒子不是固定阈值:大点、2× DPR 与高透明重叠可能先受 GPU 限制;小点且简单着色器可能先受 JS 和上传限制。正式评估应使用目标设备、稳定场景和浏览器性能分析工具;需要精确 GPU 时间时可使用 WebGL2 的 EXT_disjoint_timer_query_webgl2,并异步读取查询结果及检查 disjoint 状态,不能在每帧同步等待 GPU。

2. 按瓶颈选结构

观察到的主要压力 优先检查 可能的调整及边界
JS 更新循环长、GC 抖动 是否每点 new Vector3()、是否逐点查询 DOM、是否可复用数组 预分配/池化、紧凑活跃区;复杂碰撞仍需要有状态更新
属性上传量大 每帧是否全量 needsUpdate、更新范围是否正确 连续 addUpdateRange、把可解析运动放到 shader;GPU 解析式不能自动保存历史
绘制调用多 是否每粒子一个 Sprite/Mesh Points、InstancedMesh 或批渲染;合批不减少实际片元覆盖
大柔边粒子慢 尺寸、DPR、透明重叠层数 缩小粒子、降低渲染分辨率、减少屏幕覆盖;加法混合不能代替物理正确的烟遮挡
透明交界/排序错误 depthTest、depthWrite、排序单位、深度纹理来源 参考第 05、07、17 章;性能优化不能靠关闭必要深度测试掩盖错误

以上是定位顺序,不是一个总能提升帧率的开关表。renderer.info.render.calls 只能说明提交数量,不能告诉你 GPU 顶点/片元时间。理论上传字节不等于测到的总线传输;浏览器后台与双画布对照的帧间隔也不能当基准报告。

3. 为什么选择 three.quarks@0.16.0

本教程固定 Three.js r174 / 0.174.0。已核对 npm 包元数据:three.quarks@0.16.0 声明 peer dependency three >=0.165.0,依赖 quarks.core@^0.16.0;本页把两个 0.16.0 ESM 构建及 MIT 许可固定在 vendor/ 中,使用 import map 将包名映射到本地文件,并把包内的 three 映射到示例目录的 three.module.js。因此从 particle-system/examples/ 目录启动静态服务器即可直接运行,不需 CDN、npm 安装或打包器。实际项目使用包管理器时,应同时锁定 three@0.174.0 和 three.quarks@0.16.0,检查锁文件解析到的 quarks.core 版本,并按项目构建器配置导入。

这是一个基于 JavaScript 的有状态粒子系统与批渲染器,不是“开启后所有运动自动在 GPU 中模拟”。它为粒子保存状态,按行为更新,再把实例数据交给 GPU 绘制。第 16 章的解析式 ShaderMaterial 适合无历史依赖的大量独立点;库适合把发射、寿命、行为和批渲染组织为可复用配置。选库后仍要检查粒子对象、实例缓冲、透明混合和 overdraw 成本。

4. 把原生火焰逐项映射到库 API

对照页左侧预分配 700 个槽:发射率余数决定出生,age/life 判断死亡,活跃区用末尾槽交换保持紧凑,position 局部更新;一个 PointsMaterial 绘制柔圆。右侧通过包的公开 API 配置相近的上升火焰:

import {
  ParticleSystem, BatchedRenderer, RenderMode, ConeEmitter,
  ConstantValue, IntervalValue, ConstantColor, ApplyForce,
  Vector3 as QuarksVector3, Vector4 as QuarksVector4
} from 'three.quarks';

const emission = new ConstantValue( 120 );
const system = new ParticleSystem( {
  duration: 1, looping: true, worldSpace: true,
  shape: new ConeEmitter( { radius: 0.18, angle: 0.22 } ),
  startLife: new IntervalValue( 1.2, 1.8 ),
  startSpeed: new IntervalValue( 1.1, 1.7 ),
  startSize: new IntervalValue( 0.15, 0.22 ),
  startColor: new ConstantColor( new QuarksVector4( 1, 0.48, 0.16, 0.78 ) ),
  emissionOverTime: emission,
  behaviors: [ new ApplyForce( new QuarksVector3( 0, 1, 0 ), new ConstantValue( 0.3 ) ) ],
  material: quarksMaterial,
  renderMode: RenderMode.BillBoard
} );
system.emitter.position.set( 0, - 1.4, 0 );
system.emitter.rotation.x = - Math.PI / 2;
scene.add( system.emitter );
const batch = new BatchedRenderer();
batch.addSystem( system );
scene.add( batch );

ConeEmitter 默认沿本地 +Z 发射,示例把 emitter 绕 x 轴旋到世界 +Y;worldSpace:true 使新粒子的世界位置在发射时确定。库自己的 Vector3/Vector4 供 ApplyForce 和 ConstantColor 使用,场景对象与材质仍使用本仓库的 Three.js。每帧以秒为单位调用 batch.update(dt),再 renderer.render(scene,camera);更新顺序不能倒过来。emission.value 对应发射率滑杆,system.restart() 重置库侧粒子和发射时钟,system.particleNum 是当前活粒子数。BatchedRenderer 可以容纳多个系统,但是否合到同一批还取决于材质与渲染设置;本例一个系统、一个批次时显示一个绘制调用。

原生示例中的职责 three.quarks 对应 API 仍须由应用处理
发射余数与出生槽 emissionOverTime、ConeEmitter 输入控件、场景挂载、运行时配置
初始寿命/速度/大小/颜色 IntervalValue、ConstantColor 选择合适的世界单位、贴图和色彩空间
每帧速度改变 ApplyForce behavior 秒制 dt、效果配方与目标设备调优
活跃数组与批渲染 ParticleSystem、BatchedRenderer 将 system.emitter 和 batch 加入场景,调用 update(dt)
透明与深度 传入透明材质、批材质重建 核对实际 depthTest/depthWrite、排序和背景深度

库对照页两侧用相近的发射率、寿命和上升力,但不是逐像素一致的基准:原生侧是点图元,库侧是 Billboard 四边形;出生位置、随机数、尺寸曲线和内部缓冲布局不同。两套 WebGL 画布还共享浏览器资源。页面的 JS 更新数只包住 updateNative(dt) 与 batch.update(dt),没有量化各自的 GPU 时间;“库管理缓冲更新”也没有伪造一个不可直接得知的字节数。若做严谨选型,应让两路线渲染近似相同的屏幕面积、相同活粒子数和贴图,再用目标设备上的 CPU profiler、GPU timer 与内存观察。

5. 库的软粒子、透明与生命周期边界

ParticleSystemParameters 在 0.16.0 提供 softParticles、softNearFade、softFarFade;BatchedRenderer 还提供 setDepthTexture(texture)。这些 API 不会替应用凭空生成正确的场景深度:仍要按第 17 章渲染不透明预通道,并保持相机、尺寸、深度空间与帧同步。本对照页没有开启库软粒子。透明材质会由库创建批次材质;以实际批次材质核对深度与混合行为,不应只凭传入材质的字段推断。单个批次内透明实例仍有排序和叠色限制。

库在活粒子数量增长时会扩充内部存储,随后复用粒子对象;这与“完全不分配内存”不同。停止发射、暂停和重启也各有语义:pause() 冻结系统更新,restart() 清掉现有粒子并重新发射,dispose() 用于彻底清理。对照页的暂停控件选择跳过两侧 update,但仍继续渲染静止画面;重播调用 system.restart()。项目销毁页面或效果时还应释放 Three.js 纹理、材质、几何体和渲染器。

常见问题

装了最新版却与 r174 冲突? 先查包的 peer dependency 与锁文件,不要把教程的 0.16.0 API 直接套到不兼容的新版本。浏览器示例已固定两个 ESM 文件,避免隐式升级。

粒子系统创建了但看不到? 查 system.emitter 与 BatchedRenderer 是否都入 scene、是否调用 addSystem 和 update(dt)、相机与 emitter 坐标、材质透明及控制台着色器错误。

只看绘制调用或 JS 毫秒能比较原生与库吗? 不能。屏幕覆盖、图元种类、内部属性上传、GPU 时间与两个画布的调度都不同。先使条件接近,再分层测量。

完成第 20 章练习。按症状查全系列排错附录,需要回顾概念与示例可返回教程目录。