点阴影、深度立方图与距离域比较

点阴影、深度立方图与距离域比较:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释为什么点光源要用「深度立方体贴图」:点光向四面八方发光,上一章方向光那张 2D 深度图只罩得住一个方向,要用 6 个面各渲一张距离图把光源 360° 全包住——说得清「6 个面分别朝哪、为什么单张 2D 不够」
  • 能在下面的 demo 里把点光源在房间内移动,让四面墙和地面的影子全向同时变化;并能说出第一遍存的是片元到光源的线性距离(不是裁剪空间深度)、第二遍是用方向向量 fragPos − lightPos 去 cubemap 采样判阴影
  • 能回答:第二遍判阴影时,从 cubemap 采样回来的「最近距离」是除以 far_plane 归一化存的,那要和片元到光源的实际距离比之前,得先对它做一步什么操作?漏了这步会怎样?

为什么一台相机不够,得朝 6 个方向各拍一张

上一章你让太阳那样的方向光投出了影子:把一台「相机」搬到光的位置朝场景拍一张,拍不到的角落就是影子。可那台相机只朝一个方向看——太阳的光是平行的、就一个方向,一张就够。

但屋里那盏灯泡不一样:它向四面八方发光,左边的墙、右边的墙、头顶、脚下,处处都该有影子。这时只朝一个方向拍的那一张,就只罩得住一个方向,其余方向全是没记录的盲区——墙上该有的影子直接没了。

这一章要解决的就是这件事:怎么让一盏向 360° 发光的灯,把四面八方的遮挡全都记下来。办法很直接——既然一张照片只看一个方向,那就让相机站在灯的位置、朝上下左右前后 6 个方向各拍一张,6 张拼起来正好把灯整个包住。没有它,点光源的影子只能照顾一个方向;有了它,屋里每面墙才都稳稳落上影子。

核心思想:让光源朝 6 个方向各看一眼

上一章的整套两遍法你已经会了:第一遍从光源视角渲一张深度图记下每个方向最近的遮挡距离,第二遍从相机渲场景、把片元变到光的视角下做深度比较判阴影。点阴影没有推翻这套,它只改了一处——把「一张深度图」换成「6 张」,好让点光源向四面八方的遮挡都被记下来。这种把光源周围所有方向的遮挡都覆盖到的阴影,就叫 (omnidirectional shadows)。

为什么一张不够?想象灯泡在屋子正中:朝右拍的那张只记得住右墙方向的遮挡,左墙、天花板、地板方向它一概看不见。下面这张图把方向光(一张就够)和点光源(一张只罩一个方向、其余漏掉)摆在一起,一眼就明白差在哪:

方向光:只朝一个方向一张 2D 深度图 ✓ 够点光源:向 360° 发光单张 2D 只罩一个方向 ✗其余方向没有遮挡记录点光源四面八方都发光,单张 2D 深度图罩不住
为什么单张 2D 不够:方向光只朝一个方向,一张 2D 深度图就罩住;点光源向 360° 发光,单张 2D 只记得住一个方向,其余方向没遮挡记录。

补这个缺口的办法是:让光源朝 +X / −X / +Y / −Y / +Z / −Z 这 6 个方向各放一台朝外的小相机,每台各渲一张深度图,6 张拼成一个把光源严严实实包住的立方体——这就是 (depth cubemap)。它和上一章用过的立方体贴图(天空盒那种)是同一种结构——6 张面图贴在立方体的 6 个内壁上,只不过这里每面存的不是颜色,而是距离:

用 6 个面把光源包起来 = 深度立方体贴图+Y−X+Z+X−Z−Y6 张面图拼成一个立方体+Y−X+X6 台朝外的小相机各渲一张六个方向各渲一张深度图,三百六十度全包住
深度立方体贴图:把光源用 6 个面(+X/−X/+Y/−Y/+Z/−Z)的朝外小相机包住、各渲一张深度图,拼成一个立方体把光源周围全包住。

存什么、怎么查:距离 + 方向向量

6 个面里到底存什么?上一章方向光存的是「裁剪空间深度」(一个被透视/正交投影压过的非线性 z)。点光源换了个更自然的量:直接存 ——也就是每个遮挡点离灯泡有多远(length(fragPos − lightPos))。点光向所有方向对称地发光,用「直线距离」描述遮挡比某个方向的投影深度更自然。存进 cubemap 前把这个距离除以 far_plane 归一化到 [0,1](深度纹理只存 [0,1]),第二遍采样回来再乘回 far_plane 还原成真实距离:

每个面存「到光源的最近距离」(不是裁剪空间深度)光源近 → 距离小远 → 距离大+X 面 · 距离图亮 = 近暗 = 远距离 ÷ far 归一化到 [0,1] 存进去存「线性距离」而非深度:点光全向,距离更自然
每面存「到光源的最近距离」:点光全向,存线性距离比裁剪空间深度更自然;距离 ÷ far 归一化到 [0,1] 存储(亮 = 近、暗 = 远)。

第二遍判阴影时,上一章是用片元在光空间的 proj.xy 当 2D 坐标去采样。点光的 cubemap 不用 2D 坐标,而是和天空盒一样用一根方向向量去采样——这就是 。具体地,对每个片元算出「从光源指向它」的方向 fragToLight = fragPos − lightPos,用这根方向去 cubemap 采样,取回这个方向上存的最近距离(乘回 far_plane 还原),再和片元自己到光源的实际距离 length(fragToLight)深度比较:实际距离更大 ⇒ 前面有更近的东西先挡住了光 ⇒ 在阴影;两者基本相等 ⇒ 它自己就是最近的 ⇒ 受光。

方向 = fragPos − lightPos 采样 cubemap,比距离判阴影光源遮挡物方向 R₁方向 R₂实际距离 > 存的最近距离 ⇒ 在阴影实际距离 ≈ 存的最近距离 ⇒ 受光采样回的最近距离 × far 还原,再和 length(fragToLight) 比
方向向量采样判阴影:用 方向 = fragPos − lightPos 去 cubemap 取最近距离,乘回 far 还原,和片元到光的实际距离比 ——更大 = 在阴影 / 相等 = 受光

单步建立:从六面深度到全向判断

猜一猜:点光源的每一面都必须记录什么,第二遍为什么不能拿一张面图的二维坐标继续比较?按下面三步走一遍,再回看 6 面立方图的职责。

分步1 / 3

① 六个 90 度面包住光源

方向光:只朝一个方向一张 2D 深度图 ✓ 够点光源:向 360° 发光单张 2D 只罩一个方向 ✗其余方向没有遮挡记录点光源四面八方都发光,单张 2D 深度图罩不住
为什么单张 2D 不够:方向光只朝一个方向,一张 2D 深度图就罩住;点光源向 360° 发光,单张 2D 只记得住一个方向,其余方向没遮挡记录。

点光要覆盖完整 360 度,每一面都使用同一个 90 度透视投影;否则 cubemap 面之间会留下缺口或重叠失配。

这一步的判定逻辑和上一章的深度比较一模一样,只是「比的量」从「投影深度」换成了「到光源的距离」、「取数据的方式」从「2D 坐标」换成了「方向向量」。上一章踩过的 shadow acne(自遮挡条纹)、peter panning(bias 过大阴影脱离)、PCF(采样邻域软化边缘)在点阴影里全都还在——只是 PCF 这次要沿多个三维方向偏移采样 cubemap,而不再是在 2D 平面上偏一圈。下面 Demo 先让你把点光源在屋里移起来、看四壁全向变化,代码对照再把 6 个面和方向向量采样落到 shader。

动手:在房间里移动点光源,看四壁全向变

猜一猜:下面是一间四壁朝内的房间,正中飘着一盏点光源,地上摆了几个物体。把光源方位拖一圈、再把光源移到房间一个角落,你觉得哪面墙上物体的影子会被拉得最长?是离光最近的墙,还是离光最远的那面墙?先动手拖一拖,再看下面的解释。

这个 demo 用的是 three.js 的内建点光阴影PointLightcastShadow)——它的底层做的正是本章讲的事:朝 6 个方向各渲一张距离图、拼成深度立方体贴图把光源 360° 全包住,再用方向向量采样判阴影。我们把要教的参数全做成了实时控件:光源方位 / 高度让光在房间里移动、看四面墙和地面的影子全向同时变(这是点阴影 vs 上一章方向光单向阴影最直观的差异,也是本 demo 头牌);阴影图分辨率切 256↔2048(点光是 6 面 cubemap、每面这个尺寸)、看阴影边缘的锯齿;depth bias 往大拖、造出又修掉 peter panning 阴影脱离;物体自转让影子随物体动。

可交互

点阴影演示加载中…

谜底:把光源移到房间一角,离光最远的那面墙上影子最长——光从角落斜斜地照过去,物体投在远墙上的影子被拉得又细又长(就像傍晚太阳低垂时影子拖得老远)。最该上手的是头牌那条:拖「光源方位」转一圈,盯着四面墙看——你会看到每面墙上的影子方向、长短都在同时变化,没有哪个方向是「盲区」,这正是 6 面深度立方图把光源全包住的效果。再把分辨率从 2048 切到 256,阴影边缘从平滑变成粗粗的阶梯锯齿;把 bias 拖到最大,影子和物体脚下脱节、往后缩,物体像重新飘起来——这就是上一章见过的 peter panning,点光的 bias 比方向光更娇气,得更细地调(原因见误区)。

代码对照:6 个面 + 方向向量采样怎么落到 shader

上面的 demo 是 three 帮你封装好的;这一节把底层「裸写」一遍。先看第一遍:点光阴影相机是透视投影(视场角 90°、宽高比 1,正好一格一面无缝拼接),然后为 6 个方向各算一个 lookAt 观察矩阵——+X 看向右、−X 看向左、+Y 看向上……6 个朝向把光源周围全覆盖。注意每个面的 up 向量要选对,否则相邻面会扭转拼不上:

// 点光阴影相机:透视、90° 视场、宽高比 1(一格一面无缝拼接)
float aspect = (float)SHADOW_W / (float)SHADOW_H;     // = 1
float near = 0.1f, far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far);
// 6 个朝向的观察矩阵:+X/−X/+Y/−Y/+Z/−Z,注意每面的 up 选对
std::vector<glm::mat4> shadowTransforms;
glm::vec3 P = lightPos;
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3( 1,0,0), glm::vec3(0,-1, 0)));
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3(-1,0,0), glm::vec3(0,-1, 0)));
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3(0, 1,0), glm::vec3(0, 0, 1)));
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3(0,-1,0), glm::vec3(0, 0,-1)));
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3(0,0, 1), glm::vec3(0,-1, 0)));
shadowTransforms.push_back(shadowProj * glm::lookAt(P, P + glm::vec3(0,0,-1), glm::vec3(0,-1, 0)));

有了这 6 个矩阵就该渲深度立方图了——这里桌面和 WebGL2 走的是两条不同的路,是本章最大的一处跨端差异:

第一遍的片段着色器是关键:它不靠硬件的投影深度,而是手动算「片元到光源的距离」,除以 far_plane 归一化后写入深度。两端这段 GLSL 逻辑一致,只差版本声明:

#version 330 core
in vec4 FragPos;              // 几何着色器透传的世界坐标
uniform vec3 lightPos;
uniform float far_plane;
void main() {
  // 片元到光源的真实直线距离
  float lightDistance = length(FragPos.xyz - lightPos);
  // 除以 far_plane 归一化到 [0,1] 再写入(深度图只存 [0,1])
  gl_FragDepth = lightDistance / far_plane;
}

第二遍是相机正常渲场景,判阴影全在片段着色器里。核心三步:① 算方向 fragToLight = fragPos − lightPos;② 用它去 cubemap 采样取存的最近距离、乘回 far_plane 还原;③ 和片元到光的实际距离 length(fragToLight) 比 + bias。注意采样直接传方向向量,不需要归一化、也不需要 2D 坐标:

float ShadowCalculation(vec3 fragPos) {
  // ① 从光源指向片元的方向向量(cubemap 就用它采样)
  vec3 fragToLight = fragPos - lightPos;
  // ② 用方向采样 cubemap,取回存的最近距离,乘回 far_plane 还原成真实距离
  float closestDepth = texture(depthCubemap, fragToLight).r * far_plane;
  // ③ 片元到光的实际距离
  float currentDepth = length(fragToLight);
  // 实际距离比最近遮挡还远(+bias 防自遮挡)⇒ 被挡 ⇒ 在阴影
  float bias = 0.05;
  return currentDepth - bias > closestDepth ? 1.0 : 0.0;
}

上面只采样了一个方向,阴影边缘会是硬边、还带锯齿。点阴影的 PCF 修法和上一章同理——多采样几次取平均,只是这次沿多个三维方向偏移采样 cubemap(在 fragToLight 周围的小立方体邻域里取一圈样),而不是在 2D 平面上偏一圈。把上面的单次采样换成对一组方向偏移的循环:

// 沿一组三维方向偏移采样 cubemap,平均「在阴影」的比例(软化边缘)
vec3 sampleOffsets[20] = vec3[](
  vec3( 1, 1, 1), vec3( 1,-1, 1), vec3(-1,-1, 1), vec3(-1, 1, 1),
  vec3( 1, 1,-1), vec3( 1,-1,-1), vec3(-1,-1,-1), vec3(-1, 1,-1),
  vec3( 1, 1, 0), vec3( 1,-1, 0), vec3(-1,-1, 0), vec3(-1, 1, 0),
  vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0,-1), vec3(-1, 0,-1),
  vec3( 0, 1, 1), vec3( 0,-1, 1), vec3( 0,-1,-1), vec3( 0, 1,-1));
float shadow = 0.0, diskRadius = 0.05, bias = 0.05;
float currentDepth = length(fragToLight);
for (int i = 0; i < 20; ++i) {
  float d = texture(depthCubemap, fragToLight + sampleOffsets[i] * diskRadius).r * far_plane;
  shadow += currentDepth - bias > d ? 1.0 : 0.0;
}
shadow /= 20.0;                         // 20 次取平均 → 软边

最后把 shadow(0 受光 ~ 1 全影)乘进光照,和上一章一样:(ambient + (1.0 - shadow) * (diffuse + specular)) * color。这几段计算两端 GLSL 一字不差,差异只有老几样:

容易踩的坑

小结

  • 点阴影 = 阴影映射的全向版:点光向 360° 发光,一张 2D 深度图只罩一个方向,要用 6 个面(+X/−X/+Y/−Y/+Z/−Z)各渲一张拼成深度立方体贴图把光源全包住
  • 第一遍每个面存的是片元到光源的线性距离(不是裁剪空间深度),存前 ÷ far_plane 归一化、采样回来 × far_plane 还原——这两步必须成对
  • 第二遍用方向向量 fragToLight = fragPos − lightPos 去 cubemap 采样取最近距离,再和片元的实际距离 length(fragToLight)深度比较 + bias 判阴影
  • 上一章的 shadow acne / peter panning / PCF 在点阴影里照旧:bias 仍是双刃剑(点光更需细调),PCF 改为沿多个三维方向偏移采样 cubemap 软化边缘
  • 跨端最大差异在第一遍:桌面可用几何着色器一遍渲 6 面WebGL2 无几何着色器循环渲 6 次;房间盒子要 side = BackSide 才能从里面看到四壁并 receiveShadow

练习

问题 1(改 Demo 代码型) 在上面的 demo 里,先验证「全向」:把光源方位拖一整圈,盯着四面墙看——说出你观察到墙上影子怎么变,并解释为什么没有哪个方向是「盲区」。再把光源移到房间一角,说出哪面墙影子最长、为什么。最后切阴影图分辨率到 256,描述阴影边缘的变化并解释成因。

问题 2(独立实现题 · C 型) 不依赖 three 这类封装,自己用裸 WebGL2(无几何着色器)渲一张点光源的深度立方体贴图。列出你需要的关键步骤(不必写全部代码,但要点要全,尤其是「WebGL2 怎么补几何着色器的缺」)。

问题 3(距离域排错) 第一遍把 length(FragPos - lightPos) / far_plane 写进深度立方图;第二遍你直接把 texture(depthCubemap, fragToLight).rlength(fragToLight) 比较。会发生什么?修复时除了乘回 far_plane,还要不要把 fragToLight 归一化?

名词解释

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

全向阴影

点光源(向四面八方 360° 发光的光源,比如屋里一盏灯泡)产生的阴影。和上一章方向光只朝一个方向不同,点光的光线射向所有方向,所以它的遮挡必须在所有方向上都被记录下来——一张只看一个方向的 2D 深度图罩不住,要用一个把光源整个包住的「深度立方体贴图」。详见本章「核心思想」一节。

深度立方体贴图

把点光源周围 6 个方向(+X/−X/+Y/−Y/+Z/−Z,也就是上下左右前后)各渲染一张深度图,拼成的一个立方体贴图。它和天空盒那种立方体贴图是同一种结构——6 张面图贴在立方体的 6 个内壁上,只是这里每面存的不是颜色,而是「沿这个方向望出去,离光源最近的遮挡物有多远」。6 个面合起来正好把光源 360° 全包住,点光任何方向的遮挡都能查到。也叫 depth cubemap。详见本章「核心思想」一节。

到光源的距离

第一遍渲深度立方图时,每个面里存的那个值。上一章方向光存的是被投影压过的「裁剪空间深度」,点阴影换成更自然的量——直接存「这个方向上,最近的遮挡物到光源的真实直线距离」(length(fragPos − lightPos))。因为点光向所有方向对称发光,用「距离」描述遮挡比某个方向的投影深度更直接。存的时候除以 far_plane 归一化到 [0,1],采样回来再乘回 far_plane 还原。详见本章「存什么、怎么查」一节。

方向向量采样

从立方体贴图取值的方式:不用二维 uv 坐标,而是用一根从立方体中心射出的方向向量——方向指向哪个面的哪个点,就取那一点的值。点阴影里这根方向向量就是「从光源指向当前片元」的 fragToLight = fragPos − lightPos:用它去深度立方图采样,取回的就是「这个方向上离光源最近的遮挡距离」。方向不必归一化,cubemap 采样只看方向。详见本章「存什么、怎么查」一节。

版本、来源与运行边界

本章以 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 结果的验证。

正式概念与状态责任

  • point shadows:在“点阴影、深度立方图与距离域比较”中由depth cubemap、六个 light-space transforms 与相机 pass负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图定位它的第一处变化。
  • depth cubemap:在“点阴影、深度立方图与距离域比较”中由depth cubemap、六个 light-space transforms 与相机 pass负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图定位它的第一处变化。
  • omnidirectional:在“点阴影、深度立方图与距离域比较”中由depth cubemap、六个 light-space transforms 与相机 pass负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“一次几何阶段或六次 pass 写距离,再按方向采样并还原真实深度”发生后,depth cubemap、六个 light-space transforms 与相机 pass应怎样改变六面矩阵、片段到光源距离、far_plane、cubemap depth 和 PCF 偏移;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

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

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

Context · resource · observable result

点阴影、深度立方图与距离域比较:状态合同

从点光源向六个方向写入线性距离 cubemap,并用同一 far_plane 恢复比较

验证场景

官方教程正式概念

logl-32 · 基线帧

point shadows固定 context、资源内容与输入事件,执行“一次几何阶段或六次 pass 写距离,再按方向采样并还原真实深度”

状态所有者depth cubemap、六个 light-space transforms 与相机 pass
受控状态/资源六面矩阵、片段到光源距离、far_plane、cubemap depth 和 PCF 偏移
触发命令一次几何阶段或六次 pass 写距离,再按方向采样并还原真实深度

冻结输入:point shadows

depth cubemap、六个 light-space transforms 与相机 pass记录六面矩阵、片段到光源距离、far_plane、cubemap depth 和 PCF 偏移

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

观测:六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图中的初始快照

预期:depth cubemap、六个 light-space transforms 与相机 pass得到可复查结果,并持续满足“写入归一化距离和读取乘数使用同一个 far_plane,六面接缝方向一致”

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

逐段执行“一次几何阶段或六次 pass 写距离,再按方向采样并还原真实深度”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“写入归一化距离和读取乘数使用同一个 far_plane,六面接缝方向一致”。

CPU command · GL state · GPU result

点阴影、深度立方图与距离域比较:五段轨迹

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

当前观测:六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图中的初始快照

不变量:写入归一化距离和读取乘数使用同一个 far_plane,六面接缝方向一致

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

注入“depth pass 使用 far_plane=25,lighting pass 却按 100 还原,几乎全场误判阴影”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图一起恢复才算修复。

Single fault · first divergence · replay

点阴影、深度立方图与距离域比较:反例与恢复

故障:depth pass 使用 far_plane=25,lighting pass 却按 100 还原,几乎全场误判阴影

1. 冻结输入一致

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

2. 注入单故障一致

保持其余输入不变,仅注入“depth pass 使用 far_plane=25,lighting pass 却按 100 还原,几乎全场误判阴影”

3. 定位首差一致

写入归一化距离和读取乘数使用同一个 far_plane,六面接缝方向一致

4. 清理并重放一致

六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图

最小可重放检查

unit: logl-32
owner: depth cubemap、六个 light-space transforms 与相机 pass
state_or_resource: 六面矩阵、片段到光源距离、far_plane、cubemap depth 和 PCF 偏移
command: 一次几何阶段或六次 pass 写距离,再按方向采样并还原真实深度
pass_invariant: 写入归一化距离和读取乘数使用同一个 far_plane,六面接缝方向一致
single_fault: depth pass 使用 far_plane=25,lighting pass 却按 100 还原,几乎全场误判阴影
required_evidence: 六面矩阵、写入距离、far_plane uniforms、采样方向、比较值与阴影截图

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

出处声明

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

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

讨论

评论区加载中…