Skip to main content

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 中完成:

  1. 输入:读取“旧位置纹理”。
  2. 计算:Shader 计算出新颜色(即新位置)。
  3. 输出:渲染到一张新的纹理上(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 强依赖 FloatTypeHalfFloatType 纹理(因为位置坐标通常不是 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 的优势:

  1. 真正的并行计算架构:不再需要伪装成像素和纹理,你可以直接定义结构体(Structs)、数组(Storage Buffers)并在 GPU 线程(Workgroups)中直接读写它们。
  2. 读写同一块内存:告别繁琐的 Ping-Pong FBO 交换!Compute Shader 可以直接读写同一块 Storage Buffer。
  3. 结合 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 高效读写计算。

思考与练习

  1. 在传统的 WebGL GPGPU 方案中,为什么必须使用两张纹理交替(Ping-Pong FBO)来进行状态更新,而不能直接在一张纹理上边读边写?
  2. 在 GPGPU 粒子模拟中,如果需要存储粒子的 x, y, z 坐标、当前速度 vx, vy, vz,以及生命周期 life,使用传统的纹理方案需要分配几张什么样的纹理?
  3. 对比传统的 GPUComputationRenderer 和现代 WebGPU 的 Compute Shader,列出 Compute Shader 在数据结构设计和内存读写方面的三大优势。
  4. 如果让你用 GPGPU 或 Compute Shader 实现一个简单的流体模拟(每个粒子会受到重力和周围粒子的排斥力),你需要设计哪些数据结构(Buffer/纹理)传入 GPU?