用离屏渲染做通用计算
WebGL2 没有计算着色器,通用的 GPU 计算只能借渲染管线完成: 把数据放进纹理,用全屏三角形逐像素算,结果再写进另一张纹理。 这套做法通常叫 GPGPU,也是早期 WebGL 做粒子与流体模拟的方式。
结构:两张纹理加一条管线
计算的关键约束是不能在同一帧里读写同一张纹理。因此准备两张同规格的纹理, 每轮从 A 读、往 B 写,下一轮交换角色,这就是乒乓。
function createField(width, height) {
return device.createTexture({
width: width,
height: height,
format: 'rgba32float',
usage: luma.Texture.SAMPLE | luma.Texture.RENDER | luma.Texture.COPY_DST,
sampler: { minFilter: 'nearest', magFilter: 'nearest', mipmapFilter: 'none' }
});
}
const SIZE = 512;
let src = createField(SIZE, SIZE);
let dst = createField(SIZE, SIZE);
const fboA = device.createFramebuffer({
width: SIZE,
height: SIZE,
colorAttachments: [src]
});
const fboB = device.createFramebuffer({
width: SIZE,
height: SIZE,
colorAttachments: [dst]
});
let writeFbo = fboB; // 本轮写入目标,初始写进 B
const sim = new luma.Model(device, {
vs: fullscreenVS,
fs: simFS,
attributes: { positions: quadBuffer },
bufferLayout: [
{ name: 'positions', stepMode: 'vertex', format: 'float32x2' }
],
bindings: { uState: src },
vertexCount: 3, // 全屏三角形
topology: 'triangle-list'
});
用三角形而不是四边形覆盖全屏,省掉两个顶点与一次对角线上的重复光栅化。
顶点坐标写成 [-1, -1]、[3, -1]、[-1, 3] 即可。
逐帧迭代
每轮重新绑定源纹理、切换到另一个帧缓冲,绘制一次,然后同时交换纹理与帧缓冲的引用。 两张纹理各配一个固定的帧缓冲,运行期不需要改动附件。
function step() {
const pass = device.beginRenderPass({
framebuffer: writeFbo,
clearColor: false // 全屏覆盖,不需要清除
});
sim.setBindings({ uState: src });
sim.draw(pass);
pass.end();
// 交换:本轮结果成为下一轮输入
const texTmp = src;
src = dst;
dst = texTmp;
writeFbo = (writeFbo === fboA) ? fboB : fboA;
}
const loop = new luma.AnimationLoop({
device: device,
onRender: function () { step(); }
});
clearColor: false 省掉一次全屏清除。全屏三角形覆盖每个像素,
清除在这里没有意义,还会多一次写带宽。
格式与精度
| 格式 | 精度 | 可用条件 |
|---|---|---|
rgba32float | 单精度浮点,每通道 32 位 | 需要浮点渲染支持,isTextureFormatSupported 判断 |
rgba16float | 半精度,范围有限 | 支持面更广,适合颜色与光照累积 |
rgba8unorm | 8 位定点,0 到 1 | 一定能用,只适合可视化结果 |
function pickFormat() {
if (device.isTextureFormatSupported('rgba32float')) return 'rgba32float';
if (device.isTextureFormatSupported('rgba16float')) return 'rgba16float';
return 'rgba8unorm';
}
const format = pickFormat();
console.log('计算精度', format);
console.log(device.limits); // 纹理尺寸等上限
浮点纹理的线性过滤在 WebGL 里还需要额外支持。计算阶段一律用
nearest,只有展示最终结果时才考虑 linear,
否则采样器可能被静默降级。
读回结果
计算完成后的结果仍在 GPU 上。需要交给 JavaScript 时, 先把纹理内容写进缓冲,再异步取出。
const readback = device.createBuffer({
byteLength: SIZE * SIZE * 16, // 4 通道 × 4 字节
usage: luma.Buffer.COPY_DST | luma.Buffer.COPY_SRC
});
src.readBuffer({}, readback);
const bytes = await readback.readAsync(0, readback.byteLength);
const floats = new Float32Array(bytes.buffer);
console.log(floats[0], floats[1], floats[2], floats[3]);
注意事项
反馈回路
把当前帧缓冲的颜色附件同时绑成采样源,是 WebGL 明确禁止的用法。 乒乓的意义就在这里:读的和写的永远不是同一张纹理。
浮点渲染不是必然可用
WebGL2 需要 EXT_color_buffer_float 才能把浮点纹理当渲染目标。
没有扩展时创建会失败,代码应回退到半精度或 8 位,
而不是直接让整条渲染循环抛错。
迭代轮数越多误差越大
半精度浮点的有效位数少,几百轮迭代后累积误差会让画面出现块状伪影。 长时间模拟改用 32 位浮点,或定期用 CPU 侧的结果重置状态。
经纬志(2026)。《用离屏渲染做通用计算》。https://www.jingweizhi.com/lumagl/tutorial/gpgpu-fbo.html(访问日期 2026-11-03)。