07. GPGPU 与离屏计算
Web 前端性能的终极瓶颈通常在 CPU(JS 单线程)。当我们需要模拟 10 万个雨滴的运动、复杂的鸟群行为或流体模拟时,CPU 循环根本跑不动。
GPGPU (General-Purpose computing on Graphics Processing Units) 就是让 GPU 帮我们算数学题。
1. 原理:用颜色存储数据
GPU 是并行计算的怪兽,它不知道什么是“位置”或“速度”,它只知道“纹理”和“颜色”。
- 我们可以创建一张
100x100的纹理。 - 每个像素有 R, G, B, A 四个通道(浮点数)。
- 我们可以约定:R=x 坐标, G=y 坐标, B=z 坐标, A=速度。
- 这张图就存储了 10,000 个粒子的位置信息!
2. 核心技术:FBO Ping-Pong
要让粒子动起来,我们需要更新这些数据:
新位置 = 旧位置 + 速度 * time
这在 Fragment Shader 中完成:
- 输入:读取“旧位置纹理”。
- 计算:Shader 计算出新颜色(即新位置)。
- 输出:渲染到一张新的纹理上(Frame Buffer Object, FBO)。
因为不能同时读写同一张纹理,我们需要两张纹理交替使用(Ping-Pong)。
3. Three.js 实现:GPUComputationRenderer
手动管理 FBO 和 Shader 比较繁琐,Three.js 官方示例提供了 GPUComputationRenderer 工具类。
实战步骤
import { GPUComputationRenderer } from "three/examples/jsm/misc/GPUComputationRenderer.js";
// 1. 初始化
const gpuCompute = new GPUComputationRenderer(WIDTH, WIDTH, renderer);
// 2. 创建初始数据
const dtPosition = gpuCompute.createTexture();
const positions = dtPosition.image.data;
// ... 填充随机位置到 positions 数组 ...
// 3. 添加计算变量
const positionVariable = gpuCompute.addVariable(
"texturePosition",
document.getElementById("fragmentShaderPosition").textContent, // 计算逻辑 Shader
dtPosition,
);
gpuCompute.init();
// 4. 渲染循环
function animate() {
// 4.1 GPU 计算下一帧位置
gpuCompute.compute();
// 4.2 将计算结果传给渲染材质
// 获取最新的位置纹理
const currentPositionTexture =
gpuCompute.getCurrentRenderTarget(positionVariable).texture;
// 传给用于显示的 ShaderMaterial
particleMaterial.uniforms.positionTexture.value = currentPositionTexture;
renderer.render(scene, camera);
}
显示部分
在渲染粒子的 Vertex Shader 中:
uniform sampler2D positionTexture; // 来自 GPGPU 的计算结果
void main() {
// 根据顶点索引 (uv) 从纹理中通过 texture2D 读取对应的位置
vec4 posTemp = texture2D(positionTexture, referenceUV);
vec3 pos = posTemp.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
4. 典型场景实战解析
除了基础的粒子位置更新,GPGPU 还可以处理更多复杂的逻辑。
场景一:Boids 鸟群/鱼群模拟
挑战:每只鸟需要根据周围鸟的状态来决定自己的飞行方向(分离、对齐、凝聚)。 GPGPU 方案:
- 数据结构:
texturePosition(位置)textureVelocity(速度)
- Shader 逻辑:
- 当前像素代表“我”这只鸟。
- 在 Fragment Shader 中写一个
for循环,遍历(或部分采样)纹理中的其他像素(其他鸟)。 - 计算距离,如果小于阈值,则计算排斥力/吸引力。
- 最终输出新的速度向量。
场景二:布料模拟 (Cloth Simulation)
挑战:布料由成千上万个质点组成,质点之间有弹簧连接。 GPGPU 方案:
- 数据映射:纹理的坐标 (u, v) 天然对应布料网格的行列。
- Shader 逻辑:
- 读取当前像素 (x, y) 的位置。
- 读取上下左右四个相邻像素
(x+1, y),(x-1, y)... 的位置。 - 计算弹簧拉力(Hooke's Law):
F = k * (distance - restLength)。 - 加上重力,计算加速度,更新速度和位置。
- 优势:GPU 并行处理所有质点的受力,性能极高。
场景三:康威生命游戏 (Game of Life)
挑战:元胞自动机,每个细胞的状态取决于周围 8 个细胞。 GPGPU 方案:
- 数据:纹理像素的 R 通道存储状态(0=死, 1=生)。
- Shader 逻辑:
- 采样周围 8 个像素的值,求和得到邻居数量。
- 根据经典规则(<2死, 2-3生, >3死)输出 0 或 1。
- 这是一个离散逻辑计算的完美例子。
场景四:高度图与地形生成
挑战:实时生成无限地形或动态沙丘。 GPGPU 方案:
- 数据:FBO 输出一张灰度图(Height Map)。
- Shader 逻辑:
- 利用噪声函数(如 Simplex Noise)在 Shader 中生成高度值。
- 可以叠加侵蚀算法(Erosion):读取相邻像素高度,模拟水流带动泥土的过程。
- 渲染:将这张生成的 Height Map 传给 StandardMaterial 的
displacementMap,直接驱动顶点偏移。
5. 浮点纹理支持
GPGPU 强依赖 FloatType 或 HalfFloatType 纹理(因为位置坐标通常不是 0-1 的小数,且需要高精度)。
需要检查设备支持:
if (!renderer.capabilities.isWebGL2) {
alert("GPGPU requires WebGL 2 (or extension support)");
}
6. 前沿技术:WebGPU 与 Compute Shader (计算着色器)
传统的 GPGPU(如 GPUComputationRenderer)本质上是一种 “黑客式” (Hack) 的变通方案:因为 WebGL 只有 Vertex 和 Fragment Shader(专门用来画图的),我们只能假装在画图(Ping-Pong FBO),实际上是在算数学题。
在 WebGPU 标准下,引入了专门为通用计算设计的 Compute Shader (计算着色器),彻底改变了游戏规则。
Compute Shader 的优势:
- 真正的并行计算架构:不再需要伪装成像素和纹理,你可以直接定义结构体(Structs)、数组(Storage Buffers)并在 GPU 线程(Workgroups)中直接读写它们。
- 读写同一块内存:告别繁琐的 Ping-Pong FBO 交换!Compute Shader 可以直接读写同一块 Storage Buffer。
- 结合 TSL (Node 节点系统):在现代 Three.js 中,通过 TSL 编写 Compute Shader 逻辑变得极其简单和类型安全。
TSL 中的 Compute Shader 示例:
import { storage, instanceIndex, vec3 } from 'three/tsl';
// 1. 定义存储在 GPU 上的位置数组 Buffer
const positionBuffer = storage( new Float32Array( particleCount * 3 ), 'vec3', particleCount );
const velocityBuffer = storage( new Float32Array( particleCount * 3 ), 'vec3', particleCount );
// 2. 编写计算逻辑 (TSL 节点)
const computeLogic = tslFn( () => {
// 根据当前处理的实例索引获取对应的数据
const position = positionBuffer.element( instanceIndex );
const velocity = velocityBuffer.element( instanceIndex );
// 物理计算:新位置 = 老位置 + 速度
position.addAssign( velocity );
// 边界碰撞反弹逻辑...
} )();
// 3. 将计算逻辑包装为 Compute Node,并推入渲染器的 Compute 队列
const computeNode = computeLogic.compute( particleCount );
renderer.compute( computeNode ); // 在 render() 之前调用
(注意:需要使用 WebGPURenderer 才能支持真实的 Compute Shader)
总结
- 当粒子数量 > 10,000 时,放弃 CPU 计算,拥抱 GPU 运算。
- 传统 WebGL:利用浮点纹理存数据,利用 Fragment Shader 做计算,利用 Ping-Pong FBO 做迭代。
- 现代 WebGPU:使用 Storage Buffer 存数据,直接使用 Compute Shader 高效读写计算。
思考与练习
- 在传统的 WebGL GPGPU 方案中,为什么必须使用两张纹理交替(Ping-Pong FBO)来进行状态更新,而不能直接在一张纹理上边读边写?
- 在 GPGPU 粒子模拟中,如果需要存储粒子的
x, y, z坐标、当前速度vx, vy, vz,以及生命周期life,使用传统的纹理方案需要分配几张什么样的纹理? - 对比传统的
GPUComputationRenderer和现代 WebGPU 的Compute Shader,列出 Compute Shader 在数据结构设计和内存读写方面的三大优势。 - 如果让你用 GPGPU 或 Compute Shader 实现一个简单的流体模拟(每个粒子会受到重力和周围粒子的排斥力),你需要设计哪些数据结构(Buffer/纹理)传入 GPU?