5月28日 01:34

WebGL 帧缓冲区和离屏渲染的原理是什么?

核心回答

帧缓冲区(Framebuffer Object, FBO)是 WebGL 中用于离屏渲染的机制。它允许将渲染结果输出到纹理或渲染缓冲区,而非直接显示到屏幕上。离屏渲染则是利用 FBO 将场景先渲染到纹理,再对纹理做二次处理或合成的技术。

一个完整的 FBO 由三种附件组成:

  • 颜色附件:存储颜色信息,通常绑定纹理(以便后续采样)
  • 深度附件:存储深度值,通常绑定渲染缓冲区(写入性能更优)
  • 模板附件:存储模板值,用于遮罩测试

创建帧缓冲区的完整步骤

创建一个可用的 FBO 分为四步:创建帧缓冲区对象、创建颜色附件(纹理)、创建深度附件(渲染缓冲区)、绑定并检查完整性。

javascript
// 1. 创建帧缓冲区 const fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 2. 创建颜色附件 — 绑定纹理(后续需要采样) const colorTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, colorTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTex, 0); // 3. 创建深度附件 — 绑定渲染缓冲区(写入更快) const depthRb = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, depthRb); gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthRb); // 4. 检查完整性 const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); if (status !== gl.FRAMEBUFFER_COMPLETE) { console.error('Framebuffer 不完整:', status); } gl.bindFramebuffer(gl.FRAMEBUFFER, null);

checkFramebufferStatus 返回的可能值包括:FRAMEBUFFER_INCOMPLETE_ATTACHMENT(附件格式不支持)、FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT(没有任何附件)、FRAMEBUFFER_INCOMPLETE_DIMENSIONS(附件尺寸不一致)、FRAMEBUFFER_UNSUPPORTED(格式组合不被支持)。生产环境务必处理这些异常。

纹理与渲染缓冲区如何选择

特性纹理(Texture)渲染缓冲区(Renderbuffer)
可采样可以作为纹理在 shader 中读取不能直接读取
写入性能稍慢更快,专门为写入优化
典型用途颜色附件(需要后续采样)深度/模板附件(不需要读取)

选择原则很简单:如果渲染结果需要在后续 pass 中采样,用纹理;如果只是写入不读取,用渲染缓冲区。因此颜色附件几乎总是纹理,深度附件几乎总是渲染缓冲区。

离屏渲染的基本流程

离屏渲染的核心是"先渲染到纹理,再用纹理渲染到屏幕"的两 pass 模式:

javascript
// Pass 1: 渲染到 FBO gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.viewport(0, 0, fboWidth, fboHeight); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); drawScene(); // Pass 2: 用 FBO 的颜色纹理渲染到屏幕 gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvas.width, canvas.height); gl.bindTexture(gl.TEXTURE_2D, colorTex); drawFullscreenQuad();

关键点:每次切换渲染目标时必须调用 gl.viewport 对齐视口,否则渲染结果会被裁剪或拉伸。

多重渲染目标(MRT)

WebGL 2.0 支持同时输出到多个颜色附件,这是延迟渲染(Deferred Rendering)的基础:

javascript
// 创建 FBO 并附加多个颜色纹理 for (let i = 0; i < 4; i++) { const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0); } // 指定要绘制的附件 gl.drawBuffers([ gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2, gl.COLOR_ATTACHMENT3, ]);

对应的 fragment shader 使用 layout(location) 输出:

glsl
#version 300 es layout(location = 0) out vec4 gPosition; layout(location = 1) out vec4 gNormal; layout(location = 2) out vec4 gAlbedo; layout(location = 3) out vec4 gMaterial; void main() { gPosition = vec4(worldPos, 1.0); gNormal = vec4(normal, 0.0); gAlbedo = texture(u_diffuseMap, texCoord); gMaterial = vec4(roughness, metallic, ao, 1.0); }

注意 WebGL 1.0 不支持 MRT,只有一个 COLOR_ATTACHMENT0

常见应用场景

后期处理:先将场景渲染到纹理,再对该纹理做模糊、色调映射、Bloom 等效果。这是 FBO 最常见的用途。

阴影贴图:从光源视角渲染深度到 FBO 的深度纹理,然后在主渲染 pass 中采样该深度纹理判断像素是否在阴影中。

反射与折射:将环境渲染到立方体贴图的 6 个面(每个面对应一个 FBO),再在反射物体上采样该环境贴图。

延迟渲染:利用 MRT 将位置、法线、材质参数写入 G-Buffer(多个颜色附件),然后在光照 pass 中逐像素计算,避免对每个光源重复渲染几何体。

性能优化要点

  • 复用 FBO 而非每帧创建销毁,创建和销毁是昂贵的 GPU 操作
  • 颜色附件尺寸够用即可,过大的纹理浪费显存和带宽
  • 多个 FBO 可以共享同一个深度渲染缓冲区,减少内存占用
  • 深度附件用渲染缓冲区而非纹理,除非需要采样深度值(如阴影贴图)
  • 优先在初始化阶段创建所有 FBO,运行时只做 bind/unbind

封装帧缓冲区管理类

在实际项目中建议将 FBO 操作封装成类,管理生命周期和资源释放:

javascript
class Framebuffer { constructor(gl, width, height, options = {}) { this.gl = gl; this.width = width; this.height = height; this.fbo = gl.createFramebuffer(); this.textures = {}; this.renderbuffers = {}; gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); if (options.color !== false) { this._addColorAttachment(0); } if (options.depth) { this._addDepthAttachment(); } const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); if (status !== gl.FRAMEBUFFER_COMPLETE) { throw new Error(`Framebuffer 不完整: ${status}`); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); } _addColorAttachment(index) { const gl = this.gl; const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + index, gl.TEXTURE_2D, tex, 0); this.textures[`color${index}`] = tex; } _addDepthAttachment() { const gl = this.gl; const rb = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, rb); gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, this.width, this.height); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb); this.renderbuffers.depth = rb; } bind() { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.fbo); this.gl.viewport(0, 0, this.width, this.height); } unbind() { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } getTexture(name = 'color0') { return this.textures[name]; } resize(width, height) { this.destroy(); this.width = width; this.height = height; this.fbo = this.gl.createFramebuffer(); this.textures = {}; this.renderbuffers = {}; this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.fbo); this._addColorAttachment(0); this._addDepthAttachment(); this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } destroy() { const gl = this.gl; gl.deleteFramebuffer(this.fbo); Object.values(this.textures).forEach(t => gl.deleteTexture(t)); Object.values(this.renderbuffers).forEach(r => gl.deleteRenderbuffer(r)); } }

追问

Q: FBO 的颜色附件能绑定渲染缓冲区吗? 可以。如果颜色结果不需要在 shader 中采样(比如只做中间验证),绑定渲染缓冲区性能更好。但绝大多数场景下颜色附件需要采样,所以通常绑定纹理。

Q: WebGL 1.0 和 2.0 在 FBO 上有哪些关键差异? WebGL 1.0 只支持一个颜色附件(COLOR_ATTACHMENT0),不支持 MRT;WebGL 2.0 通过 drawBuffers 支持多个颜色附件。此外 WebGL 2.0 支持更多纹理格式作为附件,如浮点纹理和整数纹理。

Q: FBO 切换是否有性能开销? 有。每次 bindFramebuffer 都会导致 GPU 状态切换,频繁切换会降低性能。建议按渲染 pass 分组,尽量减少 bind/unbind 次数,或在同一 FBO 中用 MRT 替代多 pass。

标签:WebGL