一、硬阴影的基本原理
硬阴影(Hard Shadow)是指阴影边界清晰、明暗过渡不明显的阴影效果,通常由点光源产生。
在计算机图形学中,判断一个物体表面是否处于阴影中,关键在于判断它是否能够直接看到光源。如果光源与该表面之间存在其他遮挡物,那么该表面就无法接收到光源的直接照明,从而形成阴影。
在 OpenGL 中,阴影贴图 (Shadow Mapping)是一种常见的硬阴影实现方法。它的核心思想是:先从光源的视角记录场景深度,再从摄像机的视角比较深度,判断当前像素是否被遮挡。
二、阴影贴图的实现流程
阴影贴图主要分为两轮渲染,中间通过深度纹理保存第一轮的渲染结果。
1. 第一轮:从光源视角生成深度图
首先,将观察位置移动到光源处,并构建光源的观察矩阵和投影矩阵。随后,从光源的视角渲染场景中的所有物体。
这一轮不需要计算物体的颜色、材质或光照,只需要开启深度测试,将光源视角下每个位置最近的物体深度记录到深度缓冲区中。
为了让第二轮能够访问这些深度数据,需要将深度缓冲区的内容保存到阴影纹理中。实际实现中,可以使用自定义帧缓冲区(FBO),直接将深度纹理附加到帧缓冲区,省去额外的深度数据复制。
完成后,阴影纹理就记录了从光源视角观察场景时,各个方向上最近可见表面的深度。
2. 中间步骤:准备阴影纹理
第一轮得到的深度数据是在光源的投影空间中生成的,而第二轮需要根据当前像素的位置查询这张纹理。
因此,需要将光源空间中的坐标转换为阴影纹理使用的坐标。通常通过一个偏移矩阵(Bias Matrix)将归一化设备坐标从 (-1,1) 映射到 (0,1)。
这样,第二轮渲染时就能够根据物体的位置,找到阴影纹理中对应的深度值。
3. 第二轮:正常渲染并判断阴影
第二轮恢复正常摄像机视角,像平时一样渲染场景中的物体,并计算材质、光照、漫反射和镜面反射。
与此同时,还需要为每个物体计算一个从光源视角出发的变换矩阵,将顶点转换到阴影纹理对应的坐标空间。
在片段着色器中,利用这个坐标查询阴影纹理,并比较两个深度值:
-
当前片段的深度大于阴影纹理中记录的深度: 说明当前片段比光源方向上最近的物体更远,光线被遮挡,因此处于阴影中。
-
当前片段的深度小于或等于阴影纹理中记录的深度: 说明当前片段没有被前方物体遮挡,可以接收光源的直接照明。
实际比较需要使用一致的深度空间,并考虑深度偏移,以避免浮点精度误差造成阴影痤疮等问题。
最后,根据深度比较结果决定是否保留当前光源的直接光照贡献,从而生成硬阴影。
三、软阴影的基本原理
软阴影(Soft Shadow)的核心原理是:光源具有一定面积,物体表面不同位置能够接收到的光照程度不同,因此阴影边缘会产生由暗到亮的平滑过渡。
其基本过程大致如下:
-
确定表面交点:从相机发射主射线,找到射线与物体的交点。
-
对光源进行采样:将光源看作具有一定面积的面光源,在光源上选取多个采样点。
-
判断光线遮挡:从表面交点向各个采样点发射阴影射线,判断这些射线是否被其他物体遮挡。
-
计算光照贡献:根据未被遮挡的射线比例,估算该点接收到的直接光照。
-
生成软阴影:光源完全被遮挡的区域形成较暗的本影,部分被遮挡的区域形成较亮的半影,从而呈现柔和的阴影边缘。
例如,某个表面点向光源发射 16 条阴影射线,其中 8 条未被遮挡,那么可以通过采样结果近似估算该点接收到的光源贡献
四、光线追踪
在光线追踪中,递归追踪(Recursive Ray Tracing)是一种通过不断发射新的光线,计算物体之间光学交互效果的方法。
1、递归追踪
当一条光线从摄像机出发,击中场景中的某个物体后,除了计算该物体本身的颜色,还可以根据物体的材质继续发射新的光线,例如反射光线和折射光线。
这些新光线击中其他物体后,又可以继续产生新的光线。如此反复,就形成了递归追踪。

1.计算物体和光线交点
对于求光线对于物体的交点来说,也就是要求光线对于三角形的交点。
对于交点来说,假设有一条光线,假设光线的起点为 O,方向为 D,则光线可以表示为:
由于三角形所在的平面会无限延伸,因此可以把光线与三角形的求交拆分为两个步骤。
第一步:求光线与三角形所在无限平面的交点。
首先,根据光线方程和三角形所在平面的方程,求出它们的交点。
假设三角形的三个顶点分别为:
根据平面方面,Ax+By+Cz+D=0,这三个顶点都满足,因此三角形所在的平面方程为:
注意,这个平面会无限延伸,而三角形只占据其中一小块区域。
现在假设一条光线从点 出发,方向向量为
。
光线方程为:
将起点和方向向量代入,得到:
展开后:
由于三角形所在的平面满足 z=0,因此令光线的 z 坐标等于 0,解得:
将 t=3 代入光线方程,得到交点:
到这里,我们只知道光线与三角形所在的无限平面相交了,还不能直接判断光线是否击中了三角形。
接下来,需要判断交点 P=(1,1,0) 是否位于三角形内部。
如果光线与平面平行,且不在平面内,那么二者不会相交。如果交点对应的 t<0,则交点位于光线起点的后方,也不属于前方的有效光线范围。
第二步:判断交点是否位于三角形内部。
即使光线与无限平面相交,也不代表它击中了三角形。因为三角形只占据平面上的一小块有限区域。
因此,还需要判断交点是否落在三角形的三个顶点所围成的区域内。
-
如果交点位于三角形内部或边界上,则光线与三角形相交。
-
如果交点位于三角形外部,则光线虽然穿过了无限平面,但没有击中这个三角形。
-
如果光线没有与平面产生有效交点,则求交结束。
通过这两个步骤,就可以判断光线是否击中了指定的三角形。
当场景中只有少量三角形时,逐个判断光线与三角形是否相交,开销并不大。
但是,一个复杂的三维模型可能包含成千上万甚至更多的三角形。如果每条光线都与场景中的所有三角形进行求交测试,计算量就会非常大。
例如,假设场景中有 100,000 个三角形,而每条光线都需要逐个测试它们,那么一条光线最坏情况下就需要进行 100,000 次三角形求交测试。
而一幅图像通常需要发射大量光线,因此总的计算开销会迅速增加。
2、AABB包围盒
为了解决这个问题,可以使用 AABB 包围盒,先排除不可能被光线击中的三角形。
AABB 是 Axis-Aligned Bounding Box 的缩写,中文通常称为轴对齐包围盒。
它是一个在三维空间中沿着坐标轴对齐的长方体,其六个面分别与坐标轴垂直。
AABB 并不是一个平面,而是一个三维空间区域,可以用三个坐标轴上的最小值和最大值表示:
AABB 的作用是用一个简单的长方体包围三角形或一组三角形,从而快速判断光线是否有必要继续检测其中的几何体。
AABB 的基本求交思路:举例说明
假设场景中有一个三角形,其三个顶点分别为:
根据三个顶点的坐标,可以计算出包围这个三角形的 AABB。
X 轴方向的范围为:
Y 轴方向的范围为:
Z 轴方向的范围为:
因此,这个 AABB 在三个方向上的边界为:
这里的包围盒在 Z 轴方向上的厚度为 0,因为它只包围一个位于 z=1 平面上的三角形。实际实现中也可能为包围盒增加一点厚度,以应对浮点数精度问题。
现在假设一条光线从点 出发,方向向量
它的光线方程为:
展开后得到:
这条光线沿 X 轴正方向前进,Y 坐标始终为 3,Z 坐标始终为 1。
第一步:判断光线与 X 轴区间是否相交。
AABB 在 X 轴上的范围是 2,4。
因为光线的 X 坐标为 x=t,所以当 t=2 时进入这个区间,当 t=4 时离开这个区间。
因此:
第二步:判断光线与 Y 轴区间是否相交。
AABB 在 Y 轴上的范围是 2,4,而光线的 Y 坐标始终为 3。
因为:
所以,这条光线在前进过程中始终满足 Y 轴方向的范围条件。
第三步:判断光线与 Z 轴区间是否相交。
AABB 的 Z 坐标为 1,而光线的 Z 坐标也始终为 1,因此光线满足 Z 轴方向的范围条件。
综合三个方向的结果,这条光线会穿过 AABB 所在的区域,因此与这个包围盒相交。
但是,光线与 AABB 相交,并不代表它一定击中了三角形。还需要继续检测光线与三角形是否相交。
只有t符合x,y,z的取值范围内,才算当前光线的点进入了这个AABB包围盒内,当t不符合容易一个x,y,z的取值时,就算这个点已经超出了AABB的包围盒。
对需要绘制的所有物体都使用AABB包围盒后,这里的AABB包围盒也可以进行进一步的缩小,也就是一个物体里面的100个三角形做一个包围盒,当光线没有进入这个包围盒,那么里面的所有三角形也就不需要判断,当光线进入了这个包围盒,也就还是需要判断这个光线是否与这个包围盒内的所有三角形是否有交点。
上面说到了判断一个射线与一个包围盒有交点,就需要判断这条射线与里面的有三角形的交点,所以对于包围盒的划分也非常重要,怎么划分可以使得进行更加少次的三角形判断。
3、KD_TREE空间划分
KD-Tree(K-Dimensional Tree,K 维树)是一种通过空间划分来组织物体的加速结构。
它的基本思路是:选择一个坐标轴和一个划分位置,将当前空间分成两个子空间,然后递归划分,直到满足终止条件。
例如,在二维空间中,我们可以沿着 x 轴进行划分:
假设场景中有三个物体 A、B、C,分别分布在空间的不同位置。我们选择一条垂直于 x 轴的直线,将空间分成左、右两个区域。
-
左侧区域包含物体 A。
-
右侧区域包含物体 B 和 C。
当一条光线进入场景时,我们可以先判断它经过哪些子空间,只检测这些空间中的物体,而不必遍历整个场景。
这样就能够减少不必要的求交计算。
以三维空间为例,假设当前空间的 x 坐标范围为:
选择 (x=5) 作为划分位置,就会得到两个子空间:
左空间:
右空间:
之后,还可以在子空间内继续沿 y 轴或 z 轴划分,形成一棵树。
需要注意:KD-Tree 划分的是空间,而不是直接给每个物体建立一个包围盒。
1、KD-Tree 的问题一:物体与划分空间的关系不好处理

这里说的"不好处理",并不是无法判断物体与空间是否相交,而是指:当一个物体跨越划分边界时,需要决定如何将它分配到子空间中。
例如,有一个三角形,其顶点为:
现在使用平面 (x=5) 划分空间。
这个三角形的一部分位于左侧空间,另一部分位于右侧空间。
那么问题来了:这个三角形应该属于左侧空间,还是右侧空间?
如果只把它分配给左侧空间,那么光线从右侧空间击中这个三角形时,可能无法正确找到它。
如果只把它分配给右侧空间,那么光线从左侧空间击中它时,也可能出现类似问题。
因此,KD-Tree 必须采用合适的物体分配策略。
要注意:上面的数字节点同样要进行划分下去,只是这里只以一边举例。
2、KD-Tree 的问题二:一个物体可能被多个空间节点引用
继续使用前面的例子。

三角形 A、B、C 中,三角形 A 跨越了 (x=5) 的划分平面。
如果不对三角形进行切分,而是把整个三角形同时放入左右两个子节点,那么树中的结构就会变成:
-
左子节点:三角形 A。
-
右子节点:三角形 A、三角形 B、三角形 C。
此时,三角形 A 被两个节点引用。
如果后续继续划分空间,跨越更多划分平面的三角形,还可能被多个叶节点引用。
这会带来两个问题:
第一,存储开销增加。
如果许多物体跨越空间边界,就可能产生大量重复引用。
第二,求交过程需要避免重复计算。
当光线遍历多个叶节点时,同一个三角形可能被多次遇到。如果没有合理的去重或访问标记机制,就可能重复执行三角形求交计算。
当然,KD-Tree 可以通过设计良好的数据结构和遍历算法处理这些情况,但实现和优化会更加复杂。
所以 KD-Tree 的划分通过划分AABB包围盒的数量和连接关系可以大量减少判断射线与三角形的相交关系。
4、BVH空间划分
为了解决物体跨越空间划分边界带来的问题,可以使用另一种加速结构:BVH(Bounding Volume Hierarchy,层次包围体)。
BVH 的思路与 KD-Tree 不同。
KD-Tree 是不断切分空间;而 BVH 是给物体或物体集合建立包围盒,再将这些包围盒根据三角形的发布继续建立包围盒。
比如有下面的多个三角形,先将三角形分为两个节点。

分为两个节点后,可以继续对一个节点的三角形继续划分。

直到三角形的节点小于一定数量的三角形后就停止划分
光线遍历 BVH 时,首先检测根节点的 AABB。
如果光线与根节点的包围盒不相交,就可以直接跳过整个子树。
如果相交,就继续检测子节点的包围盒。
只有进入叶节点后,才需要对其中的三角形进行精确求交。
1、BVH 解决物体跨越划分边界
仍然使用前面那个跨越 (x=5) 的三角形。
在 KD-Tree 中,空间被 (x=5) 的平面切开,三角形跨越了这个平面,因此需要决定将它分配给哪个子空间,或者是否在两个子空间中重复引用它。
而在 BVH 中,情况不同。
BVH 不需要用 (x=5) 这样的空间平面把三角形所在的区域切开。它直接建立一个包围三角形的 AABB:
这个包围盒完整地包含了三角形 A、B、C 的所有顶点,因此也包含整个三角形。
即使三角形跨越了某个假想的空间分界线,也不会影响它在 BVH 中的归属。
因为 BVH 关心的是:
这个物体的包围盒在哪里,以及它应该属于哪个父节点。
而不是要求物体必须完整地位于某个固定大小的空间格子内。
因此,BVH 通常可以直接存储完整的三角形,不需要因为空间划分而将三角形切开,也不需要仅仅因为三角形跨越空间切分平面,就把它重复放入两个空间节点。
需要注意的是,BVH 的父包围盒可能重叠,物体也可能因为构建策略被有意重复引用;但标准 BVH 通常会让每个三角形只存储在一个叶节点中。