阴影映射、深度比较与 Bias 边界
阴影映射、深度比较与 Bias 边界:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释阴影映射的两遍流程:第一遍从光源视角渲一张深度图记下每个方向最近的遮挡距离,第二遍从相机渲场景、把每个片元变到光空间比深度——说得清「比什么、比出什么算在阴影里」
- 能在下面的 demo 里把 depth bias 拖到最大造出 peter panning(影子与物体脚下脱节)再拖回修掉;并能解释 shadow acne 自遮挡条纹的成因和 depth bias 的修法(见图解);还能说出阴影边缘锯齿和阴影图分辨率 / PCF 的关系
- 能回答:一个片元在光空间算出的深度,比深度图里存的「最近遮挡距离」大,这说明它被挡住了还是没被挡住?所以它在不在阴影里?
为什么没有影子,东西像飘在空中
前两篇你把高光调平滑、把颜色校正对了。可只要场景里没有影子,再精致的光照也透着一股假:一个球放在地面上,没有影子托着,它看起来不像「放」在地上,倒像悬浮在半空,分不清离地多高、到底贴没贴着。影子是我们判断「东西在哪、离得多远」的本能线索,少了它,整个画面就「飘」。
为什么会少?因为前面算光照时,每块表面只顾自己「正不正对着光」,从不管光在到达它之前有没有被别的东西挡住。可现实里光是走直线的:一堵墙挡在前面,墙后那片地就晒不到太阳——那片晒不到的地方,就是影子。
这一章要解决的就是这件事:怎么让程序知道「这块地被前面的墙挡住了、照不到光」,从而把它画暗。窍门是借一台「相机」站到光源的位置去看一眼——凡是它看不见的角落,就是被挡住的影子区。没有它,你的物体永远飘在空中;有了它,东西终于稳稳落了地。
核心思想:让光源也当一回「相机」
判断「一块表面有没有被挡住」,最聪明的办法是换到光的视角去看。想象把一台相机搬到光源的位置、朝场景拍一张——这台「光相机」能看见的表面,就是直接被照到的;被它挡在后面、拍不到的,就在影子里。这就是 ↡一种实时阴影技术:先从光源视角把场景渲染一遍,记下「从光看过去,每个方向上最近的遮挡物有多远」存成一张深度图;再从相机正常渲染时,把每个片元变换到光的视角下,比较它的深度和深度图里存的最近遮挡距离——如果它比最近遮挡还远,说明前面有东西先挡住了光,它就在阴影里。是绝大多数实时引擎做阴影的基础办法。(shadow mapping)的全部直觉:凡是光相机看不见的地方,就是影子。
可「看得见/看不见」怎么用数字表示?办法是只记距离。从光源视角渲染时,我们不记颜色,只记「沿这个方向望过去,最近的那个遮挡物离光多远」,把每个方向的这个最近距离存成一整张图——这张图就叫 ↡从光源视角渲染场景得到的一张深度纹理:它的每个像素(texel)记录的不是颜色,而是「沿光的这个方向望过去,离光源最近的遮挡物的深度(距离)」。第二遍渲染时拿它来对比,判断相机看到的每个片元是否被更近的东西挡住。也叫深度图。(深度图)。它的每个像素不存颜色、只存一个距离值:这个方向上,最近的遮挡有多远。
整套阴影映射就是两遍渲染:第一遍从光源视角渲出这张深度图,第二遍从相机正常渲场景、顺便拿深度图判每个片元在不在影子里。下面这个分步图把两遍法掰开,你跟着走一遍就懂了(下面交互 Demo 还能亲手玩):
① 第一遍:从光源视角,把每方向最近遮挡距离渲进深度图
站到光源的位置朝场景看,沿光的每条方向射线,记下「这条方向上离光 最近的遮挡物有多远」,写进一张深度图(shadow map)。这一遍 不画颜色,只存距离:越近的遮挡存的深度越小。
第二遍要做的关键动作:变到光空间 + 比深度
第二遍里,每个片元要做两件关键事。第一件是变换:把片元的世界坐标,用光源的观察矩阵和投影矩阵乘一遍,变到 ↡把片元位置用光源的「观察矩阵 × 投影矩阵」变换后所处的坐标空间,也叫光的裁剪空间。在这个空间里做透视除法、再从 [-1,1] 映射到 [0,1],就能得到片元在光眼里的深度(z),以及它对应深度图上的取样坐标(xy)。第一遍渲深度图用的就是同一套光源矩阵,所以两遍的坐标系对得上。——也就是「光眼里」的坐标。这一步和第一遍渲深度图用的是同一套光源矩阵,所以两遍的坐标系严丝合缝对得上:变换后片元的 xy 正好对应它落在深度图上的哪个像素,z 正好是它在光眼里的深度。
第二件是 ↡阴影映射第二遍的核心判定:把当前片元在光空间算出的深度(current depth),和深度图里同方向存的最近遮挡距离(closest depth)做大小比较。current > closest 说明前面有更近的东西先挡住了光、该片元在阴影里;current ≤ closest 说明它自己就是最近的、直接受光。比较结果通常用 0/1 表示「在阴影 / 受光」,乘到光照上即可。:拿片元算出的当前深度,去深度图对应像素取出存着的「最近遮挡距离」,两个一比。当前深度更大 ⇒ 它比最近遮挡还靠后 ⇒ 被挡 ⇒ 在阴影;当前深度不大于 ⇒ 它自己就是最近的 ⇒ 受光。这个判定每个片元做一次,结果(在阴影 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 太小会出的 ↡阴影映射里本该干净的受光表面爬满交替亮暗条纹的自遮挡现象(像摩尔纹)。根因:深度图分辨率有限,一个像素覆盖地面斜斜一小片,整片只存取样点一个最近深度,片内更远离光的半边 current > stored 被自己误判「在阴影」。修法是加 depth 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); // 绑回默认帧缓冲// ① 光空间矩阵 = 光的正交投影 × 光的观察矩阵(方向光用正交)
const near = 1.0,
far = 24.0;
const lightProjection = mat4.ortho(-9, 9, -9, 9, near, far);
const lightView = mat4.lookAt(lightPos, [0, 0, 0], [0, 1, 0]);
const lightSpaceMatrix = mat4.multiply(lightProjection, lightView);
// ② 绑深度 FBO,用 lightSpaceMatrix 当 MVP 把场景渲一遍(只写深度)
gl.viewport(0, 0, SHADOW_W, SHADOW_H);
gl.bindFramebuffer(gl.FRAMEBUFFER, depthMapFBO);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(uLightSpaceMatrix, false, lightSpaceMatrix);
renderScene(depthProgram); // 只渲深度,得到 shadow map
gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 绑回默认帧缓冲第一遍的深度 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);
}#version 300 es
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;
}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;
}上面只采样了深度图一个像素,阴影边缘会是生硬的硬边、还带阶梯锯齿。↡Percentage-Closer Filtering,百分比邻近滤波:阴影判定时不只采样深度图上一个像素,而是采样它周围一圈(如 3×3 共 9 个)像素各做一次深度比较,把「在阴影」的比例平均成一个 0~1 的值。于是阴影边缘不再是非黑即白的硬边,而是从受光到全影的一段灰色过渡——边缘被软化、锯齿被抹平。(百分比邻近滤波)的修法是:不只看一个像素,而是采样周围 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 次比较取平均 → 软边// 用 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,整片判受光、条纹消失。修法虽简单,但矫枉过正会引出第二个坑——这也是 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 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 边界:五段轨迹
当前观测: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 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“固定 bias 过大让物体阴影与接触面分离,形成 peter-panning”
写入与读取使用同一 light-space/far range;超出光视锥不当作有阴影
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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。