阴影映射、深度比较与 Bias 边界

阴影映射、深度比较与 Bias 边界:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释阴影映射的两遍流程:第一遍从光源视角渲一张深度图记下每个方向最近的遮挡距离,第二遍从相机渲场景、把每个片元变到光空间比深度——说得清「比什么、比出什么算在阴影里」
  • 能在下面的 demo 里把 depth bias 拖到最大造出 peter panning(影子与物体脚下脱节)再拖回修掉;并能解释 shadow acne 自遮挡条纹的成因和 depth bias 的修法(见图解);还能说出阴影边缘锯齿阴影图分辨率 / PCF 的关系
  • 能回答:一个片元在光空间算出的深度,比深度图里存的「最近遮挡距离」,这说明它被挡住了还是没被挡住?所以它在不在阴影里?

为什么没有影子,东西像飘在空中

前两篇你把高光调平滑、把颜色校正对了。可只要场景里没有影子,再精致的光照也透着一股假:一个球放在地面上,没有影子托着,它看起来不像「放」在地上,倒像悬浮在半空,分不清离地多高、到底贴没贴着。影子是我们判断「东西在哪、离得多远」的本能线索,少了它,整个画面就「飘」。

为什么会少?因为前面算光照时,每块表面只顾自己「正不正对着光」,从不管光在到达它之前有没有被别的东西挡住。可现实里光是走直线的:一堵墙挡在前面,墙后那片地就晒不到太阳——那片晒不到的地方,就是影子。

这一章要解决的就是这件事:怎么让程序知道「这块地被前面的墙挡住了、照不到光」,从而把它画暗。窍门是借一台「相机」站到光源的位置去看一眼——凡是它看不见的角落,就是被挡住的影子区。没有它,你的物体永远飘在空中;有了它,东西终于稳稳落了地。

核心思想:让光源也当一回「相机」

判断「一块表面有没有被挡住」,最聪明的办法是换到光的视角去看。想象把一台相机搬到光源的位置、朝场景拍一张——这台「光相机」能看见的表面,就是直接被照到的;被它挡在后面、拍不到的,就在影子里。这就是 (shadow mapping)的全部直觉:凡是光相机看不见的地方,就是影子

可「看得见/看不见」怎么用数字表示?办法是只记距离。从光源视角渲染时,我们不记颜色,只记「沿这个方向望过去,最近的那个遮挡物离光多远」,把每个方向的这个最近距离存成一整张图——这张图就叫 (深度图)。它的每个像素不存颜色、只存一个距离值:这个方向上,最近的遮挡有多远。

整套阴影映射就是两遍渲染:第一遍从光源视角渲出这张深度图,第二遍从相机正常渲场景、顺便拿深度图判每个片元在不在影子里。下面这个分步图把两遍法掰开,你跟着走一遍就懂了(下面交互 Demo 还能亲手玩):

分步1 / 4

① 第一遍:从光源视角,把每方向最近遮挡距离渲进深度图

光源地面(受光面)遮挡物最近遮挡深度图(shadow map)每方向「最近遮挡距离」亮 = 近,暗 = 远渲进深度图第一遍:从光源视角,把每方向最近遮挡距离渲进深度图
第一遍(光源视角):沿光的每条方向射线记下「最近遮挡物有多远」,写进一张深度图(亮 = 近、暗 = 远)。

站到光源的位置朝场景看,沿光的每条方向射线,记下「这条方向上离光 最近的遮挡物有多远」,写进一张深度图(shadow map)。这一遍 不画颜色,只存距离:越近的遮挡存的深度越小。

第二遍要做的关键动作:变到光空间 + 比深度

第二遍里,每个片元要做两件关键事。第一件是变换:把片元的世界坐标,用光源的观察矩阵和投影矩阵乘一遍,变到 ——也就是「光眼里」的坐标。这一步和第一遍渲深度图用的是同一套光源矩阵,所以两遍的坐标系严丝合缝对得上:变换后片元的 xy 正好对应它落在深度图上的哪个像素,z 正好是它在光眼里的深度。

第二件是 :拿片元算出的当前深度,去深度图对应像素取出存着的「最近遮挡距离」,两个一比。当前深度更大 ⇒ 它比最近遮挡还靠后 ⇒ 被挡 ⇒ 在阴影;当前深度不大于 ⇒ 它自己就是最近的 ⇒ 受光。这个判定每个片元做一次,结果(在阴影 0 / 受光 1)乘到光照上,影子就出来了。

听起来天衣无缝,但真写出来,你会立刻撞上两个经典翻车——一个让本该干净的表面爬满条纹,一个让影子飘起来。下面 Demo 里你能亲手把它们调出来再修掉,这正是本章最该上手的部分。

动手:拖 bias 造出又修掉 peter panning

猜一猜:下面的场景已经有了干净的阴影。把 depth bias 滑块拖到最大,你觉得物体的影子会怎样?是变得更黑,还是和物体脚下脱了节、整片往后缩、物体像重新飘起来?先动手拖一拖,再看下面的解释。

这个 demo 用的是 three.js 的内建 shadow map——它的底层做的正是本章讲的两遍法(先从光源视角渲一张深度图、再回相机比深度判阴影),只是 three 把它封装好了。我们把本章要教的参数全做成了实时控件,让你亲手拖出每个概念:光源角度绕场景转光、看阴影方向怎么变;阴影图分辨率切 256↔2048、看阴影边缘的锯齿;depth bias 往大拖、造出又修掉 peter panning 阴影脱离;PCF 软阴影开关、看硬边变软边。

可交互

阴影映射演示加载中…

谜底:把 bias 拖到最大,你会看到物体的影子和物体脚下脱了节、整片往后缩,球像重新飘了起来——这就是 peter panning。把 bias 往回拖到中间那段,影子重新贴回物体脚下、恢复干净。再试试把分辨率从 2048 切到 256:阴影边缘从平滑变成粗粗的阶梯锯齿;把 PCF 关掉,边缘是生硬的硬边,开开就软化了。每个控件都对着一个本章概念,多拖几次就有手感了。至于 bias 太小会出的 自遮挡条纹,这台 three.js 演示因内建偏移很稳、盲调不易拖出,下一节「容易踩的坑」用图解把它的成因和 bias 修法讲清。

代码对照:两遍法怎么落到 shader

上面的 demo 是 three 帮你封装好的;这一节把底层「裸写」一遍,看清两遍法每一步的代码。先看第一遍的两件事:① 算光空间矩阵 = 光的投影矩阵 × 光的观察矩阵(方向光用正交投影,lookAt 让光「看向」场景中心);② 绑定一个只挂深度纹理的 FBO,用这个矩阵当 MVP 把场景渲一遍——这一遍只写深度、不写颜色。

// ① 光空间矩阵 = 光的正交投影 × 光的观察矩阵(方向光用正交)
float near = 1.0f, far = 24.0f;
glm::mat4 lightProjection = glm::ortho(-9.0f, 9.0f, -9.0f, 9.0f, near, far);
glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0, 1, 0));
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
// ② 绑深度 FBO,用 lightSpaceMatrix 当 MVP 把场景渲一遍(只写深度)
glViewport(0, 0, SHADOW_W, SHADOW_H);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
depthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
renderScene(depthShader);                 // 只渲深度,得到 shadow map
glBindFramebuffer(GL_FRAMEBUFFER, 0);     // 绑回默认帧缓冲

第一遍的深度 FBO 只挂一张深度纹理当附件(没有颜色附件),渲完它就成了 shadow map。建纹理这步两端有个要注意的格式差异:

第一遍渲深度用的顶点着色器只有一行关键计算:用 lightSpaceMatrix 把模型顶点变到光的裁剪空间,剩下交给硬件写深度。两端 GLSL 几乎一样,只差版本声明:

#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main() {
  // 用光空间矩阵把顶点变到光的裁剪空间,硬件自动写深度
  gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}

第二遍是相机正常渲场景。顶点着色器除了照常算屏幕位置,额外把世界坐标乘一遍 lightSpaceMatrix、传给片段着色器当 FragPosLightSpace(就是这个片元在光空间的位置)。真正的判定全在片段着色器的 ShadowCalculation 里:透视除法 → 映射到 [0,1] → 采样深度图取 closestDepth → 和 currentDepth 比较,并加上 bias 防自遮挡:

float ShadowCalculation(vec4 fragPosLightSpace, vec3 N, vec3 L) {
  // 透视除法 → [-1,1],再映射到 [0,1](对齐深度图取样坐标)
  vec3 proj = fragPosLightSpace.xyz / fragPosLightSpace.w;
  proj = proj * 0.5 + 0.5;
  float currentDepth = proj.z;            // 片元在光眼里的深度
  // 斜面偏移:表面越斜 bias 越大,防 shadow acne
  float bias = max(0.005 * (1.0 - dot(N, L)), 0.0005);
  // 取出深度图里这方向存的「最近遮挡距离」
  float closestDepth = texture(shadowMap, proj.xy).r;
  // 当前深度比最近遮挡还远(+bias)⇒ 被挡 ⇒ 在阴影(1.0)
  return currentDepth - bias > closestDepth ? 1.0 : 0.0;
}

上面只采样了深度图一个像素,阴影边缘会是生硬的硬边、还带阶梯锯齿。(百分比邻近滤波)的修法是:不只看一个像素,而是采样周围 3×3 共 9 个像素各比一次深度,把「在阴影」的比例平均——边缘就从非黑即白变成一段灰色过渡,软化、抹平锯齿。把上面最后一行换成一个 3×3 循环:

// 用 PCF 替换「单像素比较」:采样 3×3 邻域,平均「在阴影」的比例
float shadow = 0.0;
vec2 texelSize = 1.0 / vec2(textureSize(shadowMap, 0));
for (int x = -1; x <= 1; ++x) {
  for (int y = -1; y <= 1; ++y) {
    // 偏移一个像素采样邻居,各自做一次深度比较
    float pcfDepth = texture(shadowMap, proj.xy + vec2(x, y) * texelSize).r;
    shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
  }
}
shadow /= 9.0;                             // 9 次比较取平均 → 软边

最后把 shadow(0 受光 ~ 1 全影)乘进光照:阴影里只留环境光、漫反射和镜面被 (1 - shadow) 压掉,比如 (ambient + (1.0 - shadow) * (diffuse + specular)) * color。这几段计算两端 GLSL 一字不差,差异只有版本声明:

容易踩的坑

第一个坑你在 demo 里已经亲手见过了,这里说清它为什么发生、怎么修——配图把成因和修法画在一起:

光源斜面被深度图划成若干格,每格只存「中央取样点」的一个最近深度远光半:current > stored被自己误判「在阴影」→ 暗条靠光半:判受光 → 亮条shadow acne:交替亮暗条纹(自遮挡)
深度图一格覆盖斜面一小片,整片只存取样点那一个最近深度:片内更远离光的半边 current > stored,被自己误判「在阴影」——一片片交替就成了shadow acne 条纹。
光源斜面被深度图划成若干格,每格只存「中央取样点」的一个最近深度depth bias:把 stored 往「更远」推一点点current 再也 > 不过被推远的 stored → 整片受光、条纹消失加了 bias:条纹消失,地面干净
把存的最近深度统一往更远推一个小偏移(bias),本该受光的斜面 current 再也越不过被推远的 stored,整片判受光、条纹消失

修法虽简单,但矫枉过正会引出第二个坑——这也是 demo 里把 bias 拖太大时你看到的「影子飘起来」:

小结

  • 阴影映射 = 两遍法:第一遍从光源视角渲一张深度图(shadow map),只记「每个方向最近的遮挡距离」;第二遍从相机渲场景顺便判阴影
  • 第二遍每个片元做两件事:用光空间矩阵把它变到光眼里(含透视除法 + 映射到 [0,1]),再做深度比较——current > 深度图存的 closest ⇒ 被挡 ⇒ 在阴影
  • shadow acne 自遮挡条纹是深度图分辨率有限 + 自遮挡所致(成因与 bias 修法见图解),加 depth bias(把存的深度往更远推一点)修掉;推过头则出 peter panning 阴影脱离——这一头在 demo 里把 bias 拖到最大就能亲手看到,bias 取够用就好
  • 单像素采样的阴影边缘是硬边 + 阶梯锯齿;PCF 采样 3×3 邻域取平均把边缘软化、抹平锯齿阴影图分辨率越高锯齿越细
  • 片元落在光锥外proj.xy 越界 / proj.z > 1)要 clamp 到边界白色并直接判受光,否则远处会整片全黑或全亮

练习

问题 1(改 Demo 代码型) 在上面的 demo 里,复现 peter panning 然后修掉它:先把影子和物体脚下拖到脱节、缩回去(物体像重新飘起来),再只动一个控件把它修回贴地。说出你怎么调,并解释为什么那样会出 peter panning、又为什么那一步能修掉。

问题 2(独立实现题 · C 型) 不依赖 three 这类封装,自己用裸 WebGL2 实现一个方向光的硬阴影(目标效果:一个立方体在地面上投下一块清晰的影子,无 acne、无 peter panning,边缘是硬边即可)。列出你需要的关键步骤(不必写全部代码,但要点要全)。

名词解释

本章出现的专业名词,用大白话再讲一遍。

阴影映射

一种做实时阴影的办法:先把一台「相机」搬到光源的位置朝场景拍一遍,记下「从光看过去,每个方向最近的遮挡物有多远」存成一张图;再从真正的相机渲染时,对每个片元换到光的视角下比一比——它比最近遮挡还远,就说明前面有东西先挡住了光,它就在影子里。一句话:凡是光相机看不见的地方就是影子。详见本章「核心思想」一节。

shadow map

从光源视角渲染场景得到的一张深度图(深度纹理)。它的每个像素不存颜色,只存一个距离:沿光的这个方向望过去,离光源最近的遮挡物有多远。第二遍渲染时拿它和片元的当前深度对比,判断片元是否被更近的东西挡住。详见本章「核心思想」一节。

光空间

把片元位置用光源的观察矩阵 × 投影矩阵变换后所处的坐标空间(也叫光的裁剪空间)。在这里做一次透视除法、再从 [-1,1] 映射到 [0,1],就能得到片元在「光眼里」的深度(z)和它对应深度图上的取样坐标(xy)。因为它和第一遍渲深度图用的是同一套光源矩阵,两遍的坐标系对得上。详见本章「关键动作」一节。

深度比较

阴影映射第二遍的核心判定:拿片元在光空间算出的当前深度,和深度图里同方向存的最近遮挡距离比大小。当前深度更大(比最近遮挡还远)⇒ 前面有更近的东西先挡住了光 ⇒ 它在阴影里;当前深度不大于 ⇒ 它自己就是最近的 ⇒ 受光。结果(在阴影 / 受光)乘到光照上,影子就出来了。详见本章「关键动作」一节。

shadow acne

阴影映射里本该干净的受光表面爬满交替亮暗条纹的自遮挡现象。根因:深度图分辨率有限,一个像素覆盖地面斜斜一小片,整片只存取样点一个最近深度,片内更远离光的半边 current > stored 被自己误判「在阴影」。修法是加 depth bias(把存的最近深度往更远推一点点),让本该受光的半边越不过这条线。详见本章「容易踩的坑」一节。

PCF

Percentage-Closer Filtering,百分比邻近滤波。判阴影时不只采样深度图上一个像素,而是采样它周围一圈(如 3×3 共 9 个)各做一次深度比较,把「在阴影」的比例平均成一个 0~1 的值。于是阴影边缘不再是非黑即白的硬边,而是一段从受光到全影的灰色过渡——边缘被软化、锯齿被抹平。详见本章「代码对照」一节。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • shadow mapping:在“阴影映射、深度比较与 Bias 边界”中由shadow-map FBO、light-space matrix 与采样 pass负责解释其输入、受控状态和可观察结果;运行时以depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask定位它的第一处变化。
  • light space:在“阴影映射、深度比较与 Bias 边界”中由shadow-map FBO、light-space matrix 与采样 pass负责解释其输入、受控状态和可观察结果;运行时以depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask定位它的第一处变化。
  • shadow acne:在“阴影映射、深度比较与 Bias 边界”中由shadow-map FBO、light-space matrix 与采样 pass负责解释其输入、受控状态和可观察结果;运行时以depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask定位它的第一处变化。
  • pcf:在“阴影映射、深度比较与 Bias 边界”中由shadow-map FBO、light-space matrix 与采样 pass负责解释其输入、受控状态和可观察结果;运行时以depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“从光源渲染 depth,再在相机 pass 投影坐标并采样比较”发生后,shadow-map FBO、light-space matrix 与采样 pass应怎样改变光空间 clip/NDC、depth texture、current depth、bias、PCF taps 和边界处理;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

阴影映射、深度比较与 Bias 边界:状态合同

把 light-space depth pass、相机 pass 深度比较、bias 与 PCF 放进同一坐标合同

验证场景

官方教程正式概念

logl-31 · 基线帧

shadow mapping固定 context、资源内容与输入事件,执行“从光源渲染 depth,再在相机 pass 投影坐标并采样比较”

状态所有者shadow-map FBO、light-space matrix 与采样 pass
受控状态/资源光空间 clip/NDC、depth texture、current depth、bias、PCF taps 和边界处理
触发命令从光源渲染 depth,再在相机 pass 投影坐标并采样比较

冻结输入:shadow mapping

shadow-map FBO、light-space matrix 与采样 pass记录光空间 clip/NDC、depth texture、current depth、bias、PCF taps 和边界处理

结果:得到可重复的初始 GL 状态与资源身份

观测:depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask中的初始快照

预期:shadow-map FBO、light-space matrix 与采样 pass得到可复查结果,并持续满足“写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“从光源渲染 depth,再在相机 pass 投影坐标并采样比较”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影”。

CPU command · GL state · GPU result

阴影映射、深度比较与 Bias 边界:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask中的初始快照

不变量:写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影

实验三:单故障与同输入恢复

注入“固定 bias 过大让物体阴影与接触面分离,形成 peter-panning”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask一起恢复才算修复。

Single fault · first divergence · replay

阴影映射、深度比较与 Bias 边界:反例与恢复

故障:固定 bias 过大让物体阴影与接触面分离,形成 peter-panning

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“固定 bias 过大让物体阴影与接触面分离,形成 peter-panning”

3. 定位首差一致

写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影

4. 清理并重放一致

depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask

最小可重放检查

unit: logl-31
owner: shadow-map FBO、light-space matrix 与采样 pass
state_or_resource: 光空间 clip/NDC、depth texture、current depth、bias、PCF taps 和边界处理
command: 从光源渲染 depth,再在相机 pass 投影坐标并采样比较
pass_invariant: 写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影
single_fault: 固定 bias 过大让物体阴影与接触面分离,形成 peter-panning
required_evidence: depth FBO 状态、light matrix、shadow coords、采样深度、bias 与阴影 mask

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…