第 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 的核心思维转换:
- 纹理不仅仅是图像:纹理(Texture)本质上是一个 2D 数组,可以存储任何数据(位置、速度、颜色等)。
- 渲染不仅是画图:在片段着色器中进行的计算,其结果(
gl_FragColor)可以直接写入到另一个纹理中(通过 FBO),这就是计算和输出数据的过程。 - 坐标是索引:
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) 的优势
- 自动 Ping-Pong:通过
setVariableDependencies自动管理输入/输出缓冲区的交替。 - 浮点纹理兼容:自动检测环境并配置 Float Texture 或 Half Float Texture,避免移动端兼容性报错。
- 状态管理清晰:多变量(如位置
texturePosition和 速度textureVelocity)互相依赖变得极其容易配置。
🔍 原理对比分析
1. 数据的载体与读写机制
原生 WebGL:
- 需要手动创建 Texture,并将输入数据以
Uint8Array或Float32Array形式传入。 - 需要手动创建 Framebuffer 并附加纹理。
- 如果数据需要累加(依赖上一帧),必须手动维持两个 FBO 和两个 Texture,每帧
bindFramebuffer并交换指向。
Three.js:
DataTexture或gpuCompute.createTexture()直接生成。GPUComputationRenderer在内部封装了双缓冲池。
2. 获取数据对应索引
原生 WebGL:
- 根据
gl_FragCoord.xy / dimensions来换算当前要写入哪个像素。 - 如果将 1D 数组映射为 2D 纹理,需要通过取模(
mod)和除法(floor)转换索引。
Three.js:
GPUComputationRenderer注入了内置 Uniformresolution,在 Shader 中直接通过gl_FragCoord.xy / resolution.xy获取 UV 坐标。
📝 编程作业
基础作业(⭐)
任务:使用原生 WebGL 实现一个数据矩阵的加法 要求:
- 创建两个 2x2 的数据纹理 A 和 B,存储
[1,2,3,4]和[5,6,7,8]。 - 编写片段着色器计算
A + B。 - 将结果渲染到 1x1 的 FBO 中(假设只需获取单像素结果或循环)。通过
readPixels在控制台打印出结果,应为[6,8,10,12]。
进阶作业(⭐⭐)
任务:Three.js GPGPU 鸟群模拟 要求:
- 使用
GPUComputationRenderer。 - 创建两个变量
texturePosition和textureVelocity。 - 根据 Boids 算法(排斥、对齐、凝聚),在 velocity Shader 中更新速度。
- 在 position Shader 中根据速度更新位置。
挑战作业(⭐⭐⭐)
任务:生命游戏 (Conway's Game of Life) 要求:
- 这是一个经典的基于网格的 GPGPU 案例。
- 纹理的每个像素代表一个细胞(黑死白生)。
- 编写 Fragment Shader 读取周围 8 个像素的状态,决定当前像素下一帧的生死。
- 不需要粒子渲染,直接将计算结果的纹理画在一个充满屏幕的矩形上。
🎓 本节总结
关键概念
- 数据纹理 (Data Texture):用纹理的 RGB(A) 通道存储数值型数据。
- 双缓冲 (Ping-Pong FBO):因为不能同读同写,所以使用两个纹理交替作为输入和输出。
- 计算着色器模拟:利用渲染一个全屏四边形(Quad),让 Fragment Shader 对每一个"数据单元"执行计算。
GPGPU 陷阱与限制
readPixels非常慢!尽可能让计算结果停留在 GPU 端被其他着色器直接消费(如 Vertex Texture Fetch)。- WebGL 1.0 对浮点纹理写入支持(
OES_texture_float等)依赖硬件扩展,在旧手机上容易遇到限制。WebGL 2.0 对此有更好支持。
下一步
恭喜!你已经完成了 WebGL 和 Three.js 的全部核心/高级特性学习。 下一节,我们将进入最终的:综合实战