WebGL 中的后期处理(Post-processing)是如何实现的?
为什么面试会问后期处理
后期处理是 WebGL 从「能渲染」到「能做出好效果」的关键一步。面试官问这个问题,本质上是想确认你是否理解渲染管线的完整流程,以及是否具备在帧缓冲区和着色器层面解决实际问题的能力。
后期处理的核心原理
后期处理的核心思路只有三步:
- 把场景渲染到纹理(离屏渲染),而不是直接渲染到屏幕
- 用着色器对这张纹理做图像处理
- 把处理后的结果画到屏幕上
整个流程可以理解为:
shell场景渲染 → 颜色纹理(FBO) → 后期处理着色器链 → 屏幕 ↓ 深度/法线纹理(可选)
这里的关键概念是帧缓冲区对象(FBO)。默认情况下,WebGL 渲染到屏幕缓冲区;绑定 FBO 后,渲染结果写入绑定的纹理,供后续着色器采样。
从零搭建后期处理框架
创建 FBO 和全屏四边形
javascriptclass PostProcess { constructor(gl, width, height) { this.gl = gl; // 创建帧缓冲区 this.framebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); // 颜色纹理:存储场景渲染结果 this.colorTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.colorTexture); 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, this.colorTexture, 0); // 深度渲染缓冲区 this.depthBuffer = gl.createRenderbuffer(); gl.bindRenderbuffer(gl.RENDERBUFFER, this.depthBuffer); gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, this.depthBuffer); gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 全屏四边形:覆盖整个视口的矩形,用于后期处理着色器绘制 this.quadVertices = new Float32Array([ -1, 1, 0, 1, // 左上:位置 + 纹理坐标 -1, -1, 0, 0, 1, 1, 1, 1, 1, -1, 1, 0 ]); } // 开始离屏渲染 begin() { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.framebuffer); this.gl.viewport(0, 0, this.width, this.height); this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT); } // 结束离屏渲染 end() { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } // 用指定着色器处理纹理 apply(program) { const gl = this.gl; gl.useProgram(program); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.colorTexture); gl.uniform1i(gl.getUniformLocation(program, u_texture), 0); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } }
全屏四边形的顶点着色器
后期处理着色器的顶点部分几乎总是相同的——把全屏四边形的顶点映射到裁剪空间,并传递纹理坐标:
glslattribute vec2 a_position; attribute vec2 a_texCoord; varying vec2 v_texCoord; void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_texCoord = a_texCoord; }
面试高频:6 种经典后期处理效果
灰度效果
最简单的入门效果,核心是对 RGB 三通道做加权平均,权重来自人眼对不同颜色的敏感度:
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; void main() { vec4 color = texture2D(u_texture, v_texCoord); // ITU-R BT.601 标准权重 float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); gl_FragColor = vec4(vec3(gray), color.a); }
高斯模糊
高斯模糊是后期处理的基石——Bloom、景深、SSAO 都依赖它。关键优化是双通道分离:将二维卷积拆成水平 + 垂直两次一维卷积,采样次数从 N*N 降到 2N。
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform vec2 u_texelSize; // = 1.0 / textureSize uniform vec2 u_direction; // 水平 (1,0),垂直 (0,1) void main() { // 5 点高斯核权重 float w[5] = float[](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); vec4 color = texture2D(u_texture, v_texCoord) * w[0]; for (int i = 1; i < 5; i++) { vec2 offset = u_direction * u_texelSize * float(i); color += texture2D(u_texture, v_texCoord + offset) * w[i]; color += texture2D(u_texture, v_texCoord - offset) * w[i]; } gl_FragColor = color; }
JavaScript 端依次调用水平和垂直两个 pass:
javascript// 水平模糊 horizontalProgram.setUniform(u_direction, [1, 0]); postProcess.apply(horizontalProgram); // 垂直模糊(把上一步的输出作为输入) verticalProgram.setUniform(u_direction, [0, 1]); postProcess.apply(verticalProgram);
边缘检测(Sobel 算子)
边缘检测在非真实感渲染和描边效果中常用。Sobel 算子通过两个 3x3 卷积核分别计算水平和垂直方向的梯度:
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform vec2 u_texelSize; void main() { // 3x3 采样 float texel[9]; for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { vec2 coord = v_texCoord + vec2(x, y) * u_texelSize; float gray = dot(texture2D(u_texture, coord).rgb, vec3(0.299, 0.587, 0.114)); texel[(y + 1) * 3 + (x + 1)] = gray; } } // Sobel 水平核和垂直核 float edgeX = texel[2] + 2.0*texel[5] + texel[8] - texel[0] - 2.0*texel[3] - texel[6]; float edgeY = texel[0] + 2.0*texel[1] + texel[2] - texel[6] - 2.0*texel[7] - texel[8]; float edge = sqrt(edgeX * edgeX + edgeY * edgeY); gl_FragColor = vec4(vec3(edge), 1.0); }
辉光效果(Bloom)
Bloom 是面试最爱考的后期效果之一。它由三步组成:
- 亮度提取:把超过阈值的亮区提取出来
- 模糊:对提取的亮区做高斯模糊
- 合成:把模糊结果叠加回原始场景
亮度提取着色器:
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform float u_threshold; void main() { vec4 color = texture2D(u_texture, v_texCoord); float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); gl_FragColor = brightness > u_threshold ? color : vec4(0.0); }
合成着色器:
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_sceneTexture; uniform sampler2D u_bloomTexture; uniform float u_bloomIntensity; void main() { vec4 scene = texture2D(u_sceneTexture, v_texCoord); vec4 bloom = texture2D(u_bloomTexture, v_texCoord); gl_FragColor = scene + bloom * u_bloomIntensity; }
色调映射
当场景使用 HDR 渲染时,需要色调映射将高动态范围压缩到 [0,1] 显示。两种常见算法:
- Reinhard:简单但高亮区细节丢失较多
- ACES:工业标准,高光过渡更自然
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform float u_exposure; // ACES 色调映射(推荐) vec3 aces(vec3 x) { return clamp( (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0 ); } void main() { vec4 color = texture2D(u_texture, v_texCoord); vec3 mapped = aces(color.rgb * u_exposure); // Gamma 校正 mapped = pow(mapped, vec3(1.0 / 2.2)); gl_FragColor = vec4(mapped, color.a); }
色彩调整(亮度/对比度/饱和度)
这是最实用的后期效果之一,几乎所有游戏和应用都会用到:
glslprecision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform float u_brightness; // 通常 [-1, 1] uniform float u_contrast; // 通常 [0, 2],1 为原始 uniform float u_saturation; // 通常 [0, 2],1 为原始 void main() { vec4 color = texture2D(u_texture, v_texCoord); // 亮度:直接偏移 color.rgb += u_brightness; // 对比度:以 0.5 为中心缩放 color.rgb = (color.rgb - 0.5) * u_contrast + 0.5; // 饱和度:向灰度混合 float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); color.rgb = mix(vec3(gray), color.rgb, u_saturation); gl_FragColor = color; }
多效果链式处理:乒乓缓冲
当需要叠加多个后期效果时,需要两个 FBO 交替作为输入和输出,这就是乒乓缓冲(Ping-Pong Buffering):
javascriptclass PostProcessChain { constructor(gl, width, height) { this.readFBO = new PostProcess(gl, width, height); this.writeFBO = new PostProcess(gl, width, height); this.effects = []; } addEffect(effect) { this.effects.push(effect); } render(sceneRenderFunc) { // 1. 渲染场景到 readFBO this.readFBO.begin(); sceneRenderFunc(); this.readFBO.end(); // 2. 逐效果处理 for (const effect of this.effects) { this.writeFBO.begin(); effect.apply(this.readFBO.colorTexture); this.writeFBO.end(); // 交换读写缓冲区 [this.readFBO, this.writeFBO] = [this.writeFBO, this.readFBO]; } // 3. 最终输出到屏幕 gl.bindFramebuffer(gl.FRAMEBUFFER, null); finalPass.apply(this.readFBO.colorTexture); } }
面试常考的进阶话题
SSAO 需要什么额外信息
SSAO(屏幕空间环境光遮蔽)不能只用颜色纹理,还需要深度纹理和法线纹理。这意味着你在场景渲染阶段需要额外输出这些信息,通常通过 MRT(Multiple Render Targets)一次完成。
WebGL2 带来的改进
WebGL2 对后期处理有几个重要提升:
gl.blitFramebuffer():可以在帧缓冲区之间快速拷贝,无需着色器- MRT(Multiple Render Targets):一次渲染同时输出颜色、深度、法线到不同纹理
texStorage2D:不可变纹理,驱动层可以提前优化内存布局- 3D 纹理:可用于体积雾等高级后期效果
与 Three.js 后处理的对比
面试中可能追问:Three.js 的 EffectComposer 做了什么?本质上是把上面的框架封装了——它管理 RenderPass、ShaderPass 和乒乓缓冲,但底层原理完全相同。理解原生实现后,使用任何引擎的后处理系统都能快速上手。
性能优化的五个要点
- 降分辨率渲染:模糊、Bloom 等效果对分辨率不敏感,可以用半分辨率甚至 1/4 分辨率,帧时间减少 60% 以上
- 合并简单效果:亮度/对比度/饱和度可以合并在一个着色器中,减少一个 pass
- 利用 Mipmap:模糊效果可以先生成 mipmap,再从低层级采样,大幅减少采样次数
- 按需更新:静态场景不需要每帧都跑后期处理,可以缓存上一帧的结果
- 移动端降级:移动端 GPU 带宽有限,减少采样次数(如用 3 点高斯替代 5 点),或直接禁用 SSAO 等高开销效果