39 · CPU 与 GPU 计算的选择
1. 先问结果由谁使用
选择计算位置时,不能只看数学运算是否适合并行,还要看输入在哪里、输出由谁消费、是否会产生大量数据传输。
| 需求 | 通常优先 | 原因及代价 |
|---|---|---|
| 少量角色位置、业务碰撞与拖拽 | CPU | JavaScript 立即需要结果;易调试,方便条件分支 |
| 数万顶点随时间作独立位移 | 顶点着色器 | 静态数据一次上传,每帧只更新时间;CPU 几何并不知道变形结果 |
| 逐像素光照与程序化图案 | 片元着色器 | 输出本来就是像素;成本随片元数和 overdraw 增长 |
| 大量相同模型不同姿态 | InstancedMesh + 实例数据 | 减少绘制提交;仍需考虑剔除、更新量和材质约束 |
GPU 计算后每帧读回 JavaScript 往往引入同步等待。如果业务必须频繁读取每个粒子位置,把模拟迁到 GPU 未必更快。
2. 一万个粒子的两种组织
CPU 方案使用连续数组,减少临时对象,并一次标记上传:
import * as THREE from 'three';
const count = 10000;
const positionData = new Float32Array(count * 3);
const velocityData = new Float32Array(count * 3);
const positionAttribute = new THREE.BufferAttribute(positionData, 3);
positionAttribute.setUsage(THREE.DynamicDrawUsage);
for (let index = 0; index < count; index++) {
velocityData[index * 3 + 1] = 1 + Math.random();
}
function step(dt) {
for (let index = 0; index < positionData.length; index++) {
positionData[index] += velocityData[index] * dt;
}
positionAttribute.needsUpdate = true;
}
该片段只展示数据更新;需将 attribute 挂到 BufferGeometry 并按需更新包围体才能参与渲染。每帧上传约 count * 3 * 4 字节的位置数据,还不含其他属性及驱动开销。DynamicDrawUsage 是使用提示,不会自动免除上传。
如果运动可以解析表达,例如抛体运动,可让 GPU 从初始条件重算:
attribute vec3 initialVelocity;
uniform float elapsed;
uniform vec3 gravity;
void main() {
vec3 animatedPosition = position
+ initialVelocity * elapsed
+ 0.5 * gravity * elapsed * elapsed;
gl_Position = projectionMatrix * modelViewMatrix * vec4(animatedPosition, 1.0);
gl_PointSize = 3.0; // 若用 THREE.Points 绘制,显式设置点大小(绘制缓冲像素)。
}
这里 position 存的是初始位置,速度和 gravity 也必须在同一局部空间。只更新 elapsed 就可以生成运动,但复杂碰撞、粒子间相互作用不再由这条解析公式解决。时间过大时精度和包围体也要处理,可按生命周期重置。
3. 不要机械地把一切搬进顶点阶段
可以在顶点阶段计算随顶点缓慢变化的量,通过 varying 传给片元,但插值不等于在每个片元重新执行非线性函数。例如 normalize、高光幂函数和纹理采样在插值前后通常结果不同。
Matrix3 法线变换可以在顶点阶段执行,插值后再归一化;点光源的单位方向通常在片元阶段根据插值位置求出。这样安排有几何原因,而不只是减少指令数量。
varying 也不是免费存储:它使用插值带宽和硬件资源。为降低片元计算而输出大量冗余向量,可能适得其反。
4. Shader 性能的实际约束
常规实践包括限制循环上界、避免不必要的纹理采样、控制透明粒子的 overdraw、为远距离效果降低细节,以及限制高 DPI 画布分辨率。动态分支是否昂贵取决于分支一致性、工作量和设备,不应一律改成同时计算两侧再 mix。
纹理尺寸和压缩格式需要结合可接受质量与设备支持选择;压缩纹理通常依赖合适的转码流程,不能仅改文件后缀。高分辨率大面积透明粒子可能主要受片元填充限制,优化 JavaScript 的叉积并不能改善这个瓶颈。
本项目的 r185 WebGLRenderer 以 WebGL2 为基础。不要把旧版 Three.js 的 WebGL1 降级建议当作本版本现成能力;如需支持旧设备,需要另外选择兼容版本或提供非 WebGL 后备体验。
5. 验证方法
先固定相机、粒子数、分辨率和设备,预热后比较 CPU 帧时间、绘制提交数、GPU 时间(工具和扩展可用时)、视觉误差及内存。FPS 被垂直同步限制时,60 FPS 不代表两个方案成本相同。
renderer.info.render.calls 能观察 draw call 数,不能等同于 GPU 耗时;renderer.info.memory 提供资源数量,并非完整显存字节数。改变一个主要变量,记录结果,再决定是否保留优化。
练习(不附答案)
- 对比 1000 和 10000 个粒子,分别记录 CPU 更新和总帧表现,保持画布尺寸一致。
- 将一个 CPU 正弦位移改成顶点着色器计算,列出拾取与包围体需要补充的处理。
- 用重叠透明平面构造 overdraw,比较减少片元覆盖与减少 JavaScript 运算的收益。
- 为一个需要每帧查询粒子碰撞的业务选择计算方案,并说明数据读回成本。