WebGL Programing Guide 学习笔记

之前我们已经了解了:

在光照环境中,除了物体表面的颜色会发生变化,另一个最直观的效果就是阴影了。 要了解 WebGL 中的阴影创建方法,首先要引入一个新的概念:Framebuffer。

Framebuffer

最近在「游戏设计模式」中看到了一种「双缓冲」模式,为了避免未完成的计算的中间结果输出到屏幕上,可以使用两个缓冲区的做法。 计算结果输出到一个缓冲中,屏幕输出从另一个缓冲中获取,前者准备就绪才同步到后者。

在「Interactive.Computer.Graphics.Top.Down.Approach」这本书的第三章也介绍了 WebGL 中双缓冲模式的应用,通过定时器或者 rAF 就可以触发缓冲区的交换:

A typical rendering starts with a clearing of the back buffer, rendering into the back buffer, and finishing with a buffer swap

同样的道理,经过 shader 处理的计算结果也不一定需要直接输出到屏幕上,可以输出到缓存中作为后续计算的 texture,这种技术也叫做 offscreen drawing。

FrameBuffer

FrameBuffer

Framebuffer 可以向两类对象输出绘制结果。texture obj 可以作为 texture image 使用,而 renderbuffer obj 有更广泛的用途。

FrameBuffer

FrameBuffer

创建 texture obj 和之前学过的没啥不同,:

framebuffer = gl.createFramebuffer();
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
framebuffer.texture = texture; // Store the texture object

接下来创建 renderbuffer obj,尺寸和 texture obj 保持一致:

depthBuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
// 第二个参数表明格式用途
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT);

最后将两者关联到 Framebuffer 对象上,至此完成了创建过程:

// Attach the texture and the renderbuffer object to the FBO
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);

下一步就是使用创建好的 Framebuffer 对象了

gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);              // Change the drawing destination to FBO
gl.viewport(0, 0, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT); // Set a viewport for FBO

gl.clearColor(0.2, 0.2, 0.4, 1.0); // Set clear color (the color is slightly changed)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);  // Clear FBO

drawTexturedCube(gl, gl.program, cube, angle, texture, viewProjMatrixFBO);   // Draw the cube

gl.bindFramebuffer(gl.FRAMEBUFFER, null);        // Change the drawing destination to color buffer
gl.viewport(0, 0, canvas.width, canvas.height);  // Set the size of viewport back to that of <canvas>

gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the color buffer

drawTexturedPlane(gl, gl.program, plane, angle, fbo.texture, viewProjMatrix);  // Draw the plane

有了预备知识,下面我们来看具体在绘制阴影中的应用。

Shadow Mapping

Shadow Map 概念

Shadow Map 概念

由于光栅化的渲染管线相比基于光线追踪的实现方式缺少全局性信息,每个 fragment 并不清楚全局的光照情况,无法直接判断自己是否处于阴影中,因此需要额外预渲染阶段。具体来说我们需要两对 shader。第一对 shader 负责计算光源到物体的距离,而第二对 shader 负责真正绘制阴影,其中把第一对 shader 的计算结果传递到第二对中,就需要用到 Framebuffer 来存储结果。

下面我们先来看第一对 shader 的实现。

shadow shader

为了取得光源照射下每个 fragment 在 z-buffer 中存储的最近距离,需要将摄像机移动到光源处,此时需要生成一个位于光源处的 View 矩阵:

var viewProjMatrixFromLight = new Matrix4();
viewProjMatrixFromLight.setPerspective(70.0, OFFSCREEN_WIDTH/OFFSCREEN_HEIGHT, 1.0, 100.0);
viewProjMatrixFromLight.lookAt(LIGHT_X, LIGHT_Y, LIGHT_Z, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);

在 fragment shader 中,将 z-buffer 中存储的最近距离保存到 r 分量中。需要注意的是,保存到 rgb 任何分量甚至三个一起都是可以的,完全取决于后续 display shader 约定的读取规范。另外,rgb 分量的数据精度是有限的,在实际存储 z 距离时会有精度丢失。

gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);

渲染结果也就是 shadow map 保存在 framebuffer 中。

display shader

在真正负责渲染的 shader 中,在 shadow shader 中使用过的基于光源的变换矩阵仍然需要传入 vertex shader 中,便于后续插值。

attribute vec4 a_Position;
// 摄像机重新移动回原始视点使用的 MVP 矩阵
uniform mat4 u_MvpMatrix;
// shadow shader 中使用过的 MVP 矩阵
uniform mat4 u_MvpMatrixFromLight;
varying vec4 v_PositionFromLight;
void main() {
    gl_Position = u_MvpMatrix * a_Position;
    v_PositionFromLight = u_MvpMatrixFromLight * a_Position;
}

在读取 shadow map 当前位点数据的过程中涉及到两次坐标系的变换,首先需要转换到 NDC(通过除以 w 分量得到)。其次 texture 坐标取值范围是 [0,1],从 [-1,1] 转换而来时需要除以二再加 0.5。判定当前 fragment 是否处于阴影下,只需要用当前距离光源距离和 shadow map 中保存最小 z 距离进行比较,如果大于则说明前方有物体遮挡处于阴影中。另外,之前提到过使用 r 分量存储丢失精度问题,需要加上一个小的偏移量,防止出现 Mach band 现象。

uniform sampler2D u_ShadowMap;
varying vec4 v_PositionFromLight;
varying vec4 v_Color;
void main() {
    // Clipped Coord -> NDC -> texture Coord
    vec3 shadowCoord = (v_PositionFromLight.xyz / v_PositionFromLight.w) / 2.0 + 0.5;
    vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy);
    float depth = rgbaDepth.r;
    float visibility = (shadowCoord.z > depth + 0.005) ? 0.7 : 1.0;
    gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);
}

参考资料