Skip to main content

第 10 节:GPGPU 基础与应用

🎯 学习目标

  • 理解 GPGPU (General-Purpose computing on Graphics Processing Units) 的概念
  • 掌握如何将纹理作为数据存储结构(Data Textures)
  • 学会使用帧缓冲(FBO)和片段着色器进行通用计算
  • 实现基于 GPU 的粒子系统(GPGPU Particles)
  • 对比原生 WebGL 和 Three.js 的 GPGPU 实现

📖 理论:GPGPU 核心思想

GPGPU 指的是使用 GPU 进行非图形渲染的通用计算。GPU 拥有成百上千个计算核心,非常适合处理大规模并行计算(例如物理模拟、粒子系统、碰撞检测)。

在 WebGL 中实现 GPGPU 的核心思维转换:

  1. 纹理不仅仅是图像:纹理(Texture)本质上是一个 2D 数组,可以存储任何数据(位置、速度、颜色等)。
  2. 渲染不仅是画图:在片段着色器中进行的计算,其结果(gl_FragColor)可以直接写入到另一个纹理中(通过 FBO),这就是计算和输出数据的过程。
  3. 坐标是索引gl_FragCoord 可以用来计算当前处理的是哪个数据元素。

工作流:Ping-Pong 缓冲

由于 WebGL(以及底层图形 API)不允许同时读取和写入同一个纹理,我们需要两个纹理交替使用,这被称为 Ping-Pong 技术

  • Frame 1: 读取 Texture A,计算更新后写入 Texture B
  • Frame 2: 读取 Texture B,计算更新后写入 Texture A
  • 以此类推...

💻 原生 WebGL 实现

我们将实现一个简单的 GPU 计算:将数组中的每个数值乘以 2。

1. 数据准备与纹理创建

首先,将数据存入纹理。这里我们使用 LUMINANCE(单通道)或 RGBA 格式。

// 假设有 6 个数据点,组织成 3x2 的纹理
const srcData = new Uint8Array([
1, 2, 3,
4, 5, 6
]);

const srcWidth = 3;
const srcHeight = 2;

const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.LUMINANCE, srcWidth, srcHeight, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, srcData
);
// 对于数据纹理,关闭过滤和 Mipmap 非常重要
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

2. 计算着色器 (Compute Shader)

在 WebGL 1.0 中,我们没有专门的 Compute Shader,所以用 Fragment Shader 模拟。 绘制一个覆盖全屏幕(或对应 FBO 尺寸)的四边形。

// 片段着色器
precision highp float;

uniform sampler2D u_srcTex;
uniform vec2 u_srcDimensions;

void main() {
// 根据 gl_FragCoord 计算当前像素的纹理坐标 (0.0 ~ 1.0)
vec2 texcoord = gl_FragCoord.xy / u_srcDimensions;

// 读取旧数据
vec4 value = texture2D(u_srcTex, texcoord);

// 进行计算(乘以 2)
gl_FragColor = value * 2.0;
}

3. 读取计算结果

通过 FBO 渲染完成后,可以使用 gl.readPixels 将结果读回 CPU。

⚠️ 注意:频繁调用 readPixels 会阻塞 CPU 和 GPU 的同步,导致性能大幅下降。GPGPU 的最佳实践是尽量让数据留在 GPU 端(例如计算完粒子位置后,直接作为顶点数据传递给渲染管线)。


🎨 Three.js 对比实现:GPGPU 粒子系统

在 Three.js 中,使用 GPGPU 最经典的场景是粒子系统模拟。Three.js 提供了专门的扩展库 GPUComputationRenderer 来简化 Ping-Pong 缓冲和 Shader 管理。

核心实现步骤

<!DOCTYPE html>
<html>
<head>
<title>GPGPU Particles - Three.js</title>
<style>body { margin: 0; }</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>

<script type="module">
import * as THREE from 'three';
import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js';

// 1. 基础场景设置
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.z = 100;

// 2. 初始化 GPGPU
const WIDTH = 64; // 粒子纹理宽度 (64x64 = 4096个粒子)
const gpuCompute = new GPUComputationRenderer(WIDTH, WIDTH, renderer);

// 创建初始位置数据纹理 (FloatType 需要硬件支持 OES_texture_float)
const dtPosition = gpuCompute.createTexture();
const posArray = dtPosition.image.data;
for (let i = 0; i < posArray.length; i += 4) {
posArray[i + 0] = (Math.random() - 0.5) * 50; // x
posArray[i + 1] = (Math.random() - 0.5) * 50; // y
posArray[i + 2] = (Math.random() - 0.5) * 50; // z
posArray[i + 3] = 1; // w (1)
}

// 3. 编写位置更新的 Shader (计算 Shader)
const computePositionShader = `
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
// 读取当前位置
vec4 tmpPos = texture2D( texturePosition, uv );
vec3 pos = tmpPos.xyz;

// 简单的运动逻辑:不断向上移动,超出边界则重置
pos.y += 0.5;
if(pos.y > 50.0) pos.y = -50.0;

// 写入新位置
gl_FragColor = vec4(pos, 1.0);
}
`;

// 将变量加入计算图
const positionVariable = gpuCompute.addVariable("texturePosition", computePositionShader, dtPosition);
// 让 position 依赖自己,实现状态累加 (Ping-Pong)
gpuCompute.setVariableDependencies(positionVariable, [positionVariable]);

// 初始化计算引擎
gpuCompute.init();

// 4. 将 GPGPU 结果应用到渲染粒子
const geometry = new THREE.BufferGeometry();
// 创建 UV 属性用于在 Vertex Shader 中采样 GPGPU 数据
const uvs = new Float32Array(WIDTH * WIDTH * 2);
for (let j = 0; j < WIDTH; j++) {
for (let i = 0; i < WIDTH; i++) {
const index = (j * WIDTH + i) * 2;
uvs[index + 0] = i / (WIDTH - 1);
uvs[index + 1] = j / (WIDTH - 1);
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(WIDTH * WIDTH * 3), 3)); // 占位
geometry.setAttribute('gpuUv', new THREE.BufferAttribute(uvs, 2));

const material = new THREE.ShaderMaterial({
uniforms: {
texturePosition: { value: null } // 渲染时由 GPUCompute 提供
},
vertexShader: `
uniform sampler2D texturePosition;
attribute vec2 gpuUv;
void main() {
// 从 GPGPU 纹理中读取最新位置
vec4 pos = texture2D(texturePosition, gpuUv);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos.xyz, 1.0);
gl_PointSize = 2.0;
}
`,
fragmentShader: `
void main() {
gl_FragColor = vec4(0.0, 1.0, 1.0, 1.0);
}
`
});

const particles = new THREE.Points(geometry, material);
scene.add(particles);

// 5. 渲染循环
function animate() {
requestAnimationFrame(animate);

// 执行 GPGPU 计算 (更新数据纹理)
gpuCompute.compute();

// 将计算结果传递给渲染材质
material.uniforms.texturePosition.value = gpuCompute.getCurrentRenderTarget(positionVariable).texture;

renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

Three.js (GPUComputationRenderer) 的优势

  1. 自动 Ping-Pong:通过 setVariableDependencies 自动管理输入/输出缓冲区的交替。
  2. 浮点纹理兼容:自动检测环境并配置 Float Texture 或 Half Float Texture,避免移动端兼容性报错。
  3. 状态管理清晰:多变量(如位置 texturePosition 和 速度 textureVelocity)互相依赖变得极其容易配置。

🔍 原理对比分析

1. 数据的载体与读写机制

原生 WebGL

  • 需要手动创建 Texture,并将输入数据以 Uint8ArrayFloat32Array 形式传入。
  • 需要手动创建 Framebuffer 并附加纹理。
  • 如果数据需要累加(依赖上一帧),必须手动维持两个 FBO 和两个 Texture,每帧 bindFramebuffer 并交换指向。

Three.js

  • DataTexturegpuCompute.createTexture() 直接生成。
  • GPUComputationRenderer 在内部封装了双缓冲池。

2. 获取数据对应索引

原生 WebGL

  • 根据 gl_FragCoord.xy / dimensions 来换算当前要写入哪个像素。
  • 如果将 1D 数组映射为 2D 纹理,需要通过取模(mod)和除法(floor)转换索引。

Three.js

  • GPUComputationRenderer 注入了内置 Uniform resolution,在 Shader 中直接通过 gl_FragCoord.xy / resolution.xy 获取 UV 坐标。

📝 编程作业

基础作业(⭐)

任务:使用原生 WebGL 实现一个数据矩阵的加法 要求

  1. 创建两个 2x2 的数据纹理 A 和 B,存储 [1,2,3,4][5,6,7,8]
  2. 编写片段着色器计算 A + B
  3. 将结果渲染到 1x1 的 FBO 中(假设只需获取单像素结果或循环)。通过 readPixels 在控制台打印出结果,应为 [6,8,10,12]

进阶作业(⭐⭐)

任务:Three.js GPGPU 鸟群模拟 要求

  1. 使用 GPUComputationRenderer
  2. 创建两个变量 texturePositiontextureVelocity
  3. 根据 Boids 算法(排斥、对齐、凝聚),在 velocity Shader 中更新速度。
  4. 在 position Shader 中根据速度更新位置。

挑战作业(⭐⭐⭐)

任务:生命游戏 (Conway's Game of Life) 要求

  1. 这是一个经典的基于网格的 GPGPU 案例。
  2. 纹理的每个像素代表一个细胞(黑死白生)。
  3. 编写 Fragment Shader 读取周围 8 个像素的状态,决定当前像素下一帧的生死。
  4. 不需要粒子渲染,直接将计算结果的纹理画在一个充满屏幕的矩形上。

🎓 本节总结

关键概念

  1. 数据纹理 (Data Texture):用纹理的 RGB(A) 通道存储数值型数据。
  2. 双缓冲 (Ping-Pong FBO):因为不能同读同写,所以使用两个纹理交替作为输入和输出。
  3. 计算着色器模拟:利用渲染一个全屏四边形(Quad),让 Fragment Shader 对每一个"数据单元"执行计算。

GPGPU 陷阱与限制

  1. readPixels 非常慢!尽可能让计算结果停留在 GPU 端被其他着色器直接消费(如 Vertex Texture Fetch)。
  2. WebGL 1.0 对浮点纹理写入支持(OES_texture_float 等)依赖硬件扩展,在旧手机上容易遇到限制。WebGL 2.0 对此有更好支持。

下一步

恭喜!你已经完成了 WebGL 和 Three.js 的全部核心/高级特性学习。 下一节,我们将进入最终的:综合实战


📚 参考资源