动态场景照明
实时光照
回顾一下上一节的内容,我们成功在延迟处理实现了基本的光照,但是光照方向是固定的,而且如果你尝试过将时间切换至夜晚或者放置几个光源方块就会发现,场景并不会随着光照的变化而变化。
基本光照
在原版中,我们的天空光照除了在日出和日落时会进行视觉变化,光照的方向实际上是固定的,也就是说明暗关系始终不变。光是还原原版渲染肯定不能满足我们,因此我们将利用 OptiFine 提供的其他数据来编写一个随光源位置而动态响应的光照。
你应该知道,Minecraft 的太阳和月亮实际上不会随着日落和日出而被替换,而是始终存在于世界中,只不过白天时月亮位于地平线以下,夜晚则反之。OptiFine 提供了它们的位置,并且为了和它的阴影(下一节我们就将实现它)联动,还给出了目前用于投影的光源位置:
由于下一小节我们将会绘制阴影,因此我们直接使用 shadowLightPosition
了,在没有实现阴影之前,夜晚的场景可能会显得有些奇怪,看起来像光源来自地底。
上一节我们使用了原版的光照函数,它接受两个光照方向、法线和反照率,并返回运算之后的场景颜色。现在忘了它吧,我们将自己手动一步步实现光照函数,并进行手动混合。
光照的核心是点乘 ,它们可以描述两个向量的同向程度,希望你还记得,我们只要将向量转化到单位向量,就能将其结果限制在
于是只需要将表面法线与光照方向做点乘,就能求到光照强度了。但是当法线与光照的夹角大于
这样,我们就拿到了场景的光照数据了,我们将它和反照率(所谓反射光的概“ 率 ”或者占比)相乘,就能得到应用了光照之后的场景颜色了:

不过你会看到,场景没有被光照射到的区域一片纯黑,这可不是我们所希望的。为此,我们可以添加一个环境光亮度,基本上就是手动给计算好的光照加一个小值:
然后,你就能看到随着光源角度变化的场景光照了:

如果你还没忘记怪可怜的原版 AO,可以将它乘入环境光照明,让它发挥本来的作用:模拟环境光的遮挡。
光照贴图
当你进入矿洞之类本应遮挡阳光的地方你就会发现场景分外的亮,这是因为光照强度只与光照的方向相关。
在原版中,场景随着时间和位置改变天空光照亮度(或者说阳光/月光亮度)的行为和光源方块的照明行为均由光照贴图(Light Map)完成,它们表征了方块受到的光照强度,由游戏自动生成。OptiFine 为我们提供了相关数据:
值得注意的是, lightmap
提供了混合天空光照和方块光照后的光照颜色 ,当 vaUV2
实际上就是以 s
分量表示方块光照、 t
分量表示天空光照。
这个坐标实际上由原版提供,但是它的处理方式却有些奇怪。如果你在本章第一节跟着我们查看了原版着色器就会发现,原版着色器处理光照贴图的方法是直接在顶点着色器中使用 texelFetch(Sampler2, UV2 / 16, 0)
采样光照贴图,原版中的 Sampler2
即 OptiFine 的 lightmap
,而 UV2
则对应 vaUV2
。
将 UV2
除以 16 再进行采样我们勉强可以用光照强度等级在游戏中有
信息传入到片段着色器时会进行插值 ,除非你在传出着色器(顶点着色器或者几何着色器,取决于你有没有使用几何着色器)和片段着色器都声明了这个变量以 flat
形式传入。而在顶点着色器上进行纹理采样,基本上就只是利用顶点的原始纹理坐标进行了采样,因为这时候还没开始数据插值。
因此,Mojang 的思路是只在每个顶点做一次光照颜色采样,然后把剩下的活都交给顶点插值完成。这种方法只需要在每个顶点上而不是每个片段上执行一次采样,而且可以保证一个方块上的光照过渡均匀,我们没什么理由不仿效。
于是我们的顶点着色器就是:
然后我们在片段着色器传入光照贴图颜色,并将它们传出到额外的缓冲区备用:
最后在 final.fsh
中,我们声明并采样这张纹理,然后将光照颜色乘到之前的光照强度上:
然后你就能在矿洞中看出场景变化了:

如果将我们之前的 lightDir
赋值内容替换为 normalize(moonPosition)
然后把时间切换到晚上,你就会发现场景正确地变暗了。
现在还剩下一个 小 问题:我们采样的光照颜色是天空和方块光照的叠加,而我们之前写的光照强度会根据太阳的方向变化:我们没有理由让方块光照强度也随着光照角度的变化而变化!
其中一个解决办法是在几何缓冲中处理一次表面光照,然后根据光照的方向动态地变化我们在光照贴图上的采样纵坐标。这种方法限制较多,没有多少光影使用,我们通常只保存纹理坐标作为光照强度,然后自行处理光照色彩。
虽然在下一小节中绘制实时阴影之后,我们不必再依赖光照贴图遮挡阳光或月光,但是天空光照强度数据仍可用于环境光或另作他用。我们会将输出光照强度留作习题。
块输出
随着顶点着色器的输出变量变多,我们的声明区域不堪重负,变量名也杂乱无章,因此我们将进行第二次瘦身。不过不用担心,这一次的瘦身还没达到单开一节的程度。
GLSL 支持块传入和传出(注意不是结构体!),我们将几何缓冲的顶点着色器中所有需要传出的变量打包:
然后在片段着色器中传入:
然后就可以像 C 那样调用结构体变量了:
和结构体不同,块输出和输入允许我们使用任意变量名,只要块类型对应即可。比如在顶点着色器使用 vs_out
然后在片段着色器使用 fs_in
,这样也可以避免名称误导,比如明明是 in
进来的变量名却是 out
。
由于 GL 不支持在片段着色器中定义输出块,因此它们还是保持原样。
实时阴影
终于来到了我们的重头戏了。光影之所以如此受欢迎,实时阴影绝对是头号功臣。原版游戏没有为我们提供任何能用以绘制阴影的 常规 手段,这也是我们会使用 OptiFine 的重要原因。
要想进行投影,本质上就是靠近光源的物体将光源遮挡,从而在远离光源的物体上投下了阴影。说到远近关系,我们第一个想到的就是深度图!我们只需要设法从光源方向观察场景,就能获取到场景和光源的距离关系了。
阴影几何缓冲
OptiFine 为我们提供了额外的阴影几何缓冲程序用于处理阴影数据,名为 shadow
,在屏幕几何缓冲之前运行。它的顶点变换结束之后实际上就是从光源所在视角望向玩家所在方块的等轴场景,这个场景下的顶点坐标称为阴影坐标 (Shadow Coordinate),对应的空间称为阴影空间 (Shadow Space)。
和我们常规视角的几何缓冲类似,在 shadow.vsh
中我们也需要进行顶点变换:
在片段着色器中,我们也可以向两张阴影专用缓冲区 shadowcolor0
和 shadowcolor1
输出数据,它们只能也是仅有的在阴影相关的程序中可以写入的缓冲区, DRAWBUFFERS
索引分别是 0
和 1
。
不过我们目前不需要向它们输出内容,因为我们也有专用的深度缓冲区 shadowtex0
和 shadowtex1
。和 depthtex
类似, 0
中包含了半透明几何的深度。我们只需要在片段着色器中将深度数据(希望你还记得是 gl_FragCoord.z
)写入 gl_FragDepth
就行了,就像 GL 默认的那样。但是还有一件事:记得做 Alpha 测试,不然场景中本应透明的植被等区域也会被阴影覆盖!这也是我们在顶点着色器程序传出 uv
的原因。
如果你在 final.fsh
中直接声明 shadowtex0
然后用屏幕坐标采样它,看起来会像是这样:

如果你遇到大面积没有场景信息的情况,可以尝试打开 F3 调试界面看着屏幕中间的参考坐标系来回转头加载场景,在某些版本的 OptiFine 上你可能会遇到深度数据随着转头被裁切的情况,可以在配置文件(shaders.properties
,希望你还记得)中添加
来强制禁用阴影空间的摄像机视锥体裁切。
绘制阴影
现在我们有场景和光源之间的位置关系了,继续思考,深度图实际上是表示了当前位置上距离观察点最近的深度,现在我们相当于有了两个视角的深度信息……因此我们只需要设法将阴影深度映射 (Mapping)到场景中,作为场景对应位置与光源连线上距离光源最近的深度 closestDepth
,然后将视口深度也转化到阴影空间作为对应位置在阴影空间下的实际深度 currentDepth
,最后将这两个深度做比较,就能知道片段所在位置是否处在阴影中了!
重建坐标系
回到 final
,现在有屏幕的归一化坐标 uv
和场景的非线性深度 depth
,这些坐标实际上都是 NDC 坐标简单的线性归一化得到的,称为屏幕空间 (Screen Space),在上一节中我们又知道了局部空间变换到 NDC 的方法,于是我们就可以利用 OptiFine 提供的逆矩阵(上一节有提到)进行逆变换:
其中 NDC 变换到裁切空间之后才进行透视除法,与正变换略有差别。变换完成之后,OptiFine 还为我们提供了阴影空间的相关矩阵,于是我们就可以直接进行阴影变换:
虽然阴影空间是等轴的,但是我们还是需要进行“透视”除法来归一化。
就像刚才说的那样重建 NDC 就很简单:
我们这里将第四分量设置为了 1.0
,也和第三节的理由一致。接着将它转化到裁切空间:
这一步使用逆矩阵与坐标相乘,同时也更改了
在这一步我们直接将它的 1.0
。最后再转化到世界空间,我们的逆变换就结束了:
然后,我们利用阴影空间的相关矩阵,将世界坐标变换到阴影空间坐标:
别忘记进行透视除法:
最后将它转换到阴影屏幕空间坐标,这样我们就在屏幕空间中和阴影屏幕空间的坐标对齐了:
这就我们所需要的对应屏幕空间位置的阴影贴图纹理坐标了,而它的第三分量则是屏幕空间中的像素在阴影空间下的深度,即
我们之前需要的 closestDepth
就可以通过 uv_shadowMap
采样 shadowtex0
得到:
绘制与优化
最后,我们将 closestDepth
和 currentDepth
做比较,如果后者大于前者(即当前深度不是在光源连线上的最近深度),则处于阴影中。
最后,我们将这个阴影系数乘到我们之前的光照强度上,就可以产生阴影了:

虽然看起来确实不怎么美观……这是因为默认的阴影贴图覆盖范围高达
要想解决很简单,OptiFine 允许我们定义特定名称和类型的常量来设置这些内容,由于阴影贴图永远是正方形且像素量必定是整数,所以尺寸只需要一个整型值,让我们先尝试将它扩大一倍:
阴影渲染距离也类似,它规定了阴影的渲染半径,以方块计,我们可以暂时设置成一个小的值,比如 2 个区块:
回到游戏重载一下光影试试:

看起来要好些了,但是如果凑近观察会发现有很多莫名的锯齿阴影(如果你把阴影分辨率调低,或者把阴影渲染距离调高还会更明显)。这是因为阴影贴图的分辨率是有限的,每个阴影贴图覆盖的像素对应的其实是屏幕上的一小块区域,而不是精确的一个点,因此当覆盖区域中央的最近深度小于了四周的实际深度时就会产生自阴影。
要想解决这个问题,最简单的方法就是手动将场景的实际坐标向光源方向“推”一个小值(也可以将最近深度往远处推一个小值):

现在好多了,但是你会发现几何接缝出现了一些漏光导致视觉悬空,这是不可避免的。在极端情况下这个偏移值可能过小,你当然可以直接调大它,但是一个更明智的方法是根据表面的法线动态地调整偏移量。
可以思考一下,当场景表面的法线与光源越垂直,一个阴影像素覆盖的区域的深度差就会越大,因此偏移量就要越大!

因此,我们还是请出之前已经点乘好的值 lit
,当光照与法线方向夹角越小,我们的偏移量应该越小,因此要将其取反。我们不关心背光面,它们本来就全是阴影。同时我们应该保证一个最小的偏移量来确保某些极端角度不会产生自阴影:

这是在 1024x 阴影分辨率下渲染半径 16 区块接近正午的阴影效果,可以看到自阴影的现象几乎看不见了。
此外,我们还可以控制阴影在南北方向上的倾斜,让阴影边缘不再一直和南北的表面对齐,从而让光照更有层次:

其中负值代表太阳向南偏移,正值代表向北偏移。
如果你望向远处,可能会发现场景被错误遮蔽了,这是因为阴影采样坐标超出了场景坐标。还记得缓冲区的边缘行为吗?超出缓冲区范围的场景相当于一直拿缓冲区边缘的深度信息和实际深度做对比,因此始终被判断为阴影。

图中泛红的区域即阴影空间坐标不属于
要想解决这个问题很简单,我们只要不比较阴影纹理坐标不属于
当然,我们可以用之前封装的函数 uv_OutBound()
来替换它们,如果你还记得的话:
而如果你飞向高空,你会发现大块的阴影又回来了(真难杀啊),这是因为在阴影几何缓冲中场景超出了裁切远平面,最近的阴影空间深度始终被视为了 1.0
,而场景的实际阴影空间深度已经超过了 1.0
。因此我们还需要裁切掉大于 1.0
深度的坐标:

习题
整理你的
final.fsh
,将重建阴影坐标系的一大坨内容封装成函数,剔除不必要的变量。重载
minComponent()
和maxComponent()
函数,让它们可以返回vec3
和vec4
类型的最大分量。重载完成后,你会发现我们之前重载的三维 UV 边界判定函数也可以写成:
bool uv_OutBound(vec3 uv) { return (maxComponent(uv) > 1.0 || minComponent(uv) < 0.0); }于是我们可以直接用
#define
定义:#define uv_OutBound(uv) (maxComponent(uv) > 1.0 || minComponent(uv) < 0.0)这样变量
uv
的类型就被maxComponent()
和minComponent()
限定,而uv_OutBound()
则不必重载了。将
vaUV2
处理之后传入像素着色器,将光照强度独立拆分到两个通道中输出,不要使用lightmap
,然后在final.fsh
中仅将天空光照强度乘以环境光强度,并在最终光照强度上独立叠加方块光照强度。注意:OptiFine 要求整型类变量必须以
flat
形式传出,不能进行插值,因此你可能需要将其转化到vec2
以确保进行了正确插值。你需要根据光照贴图的尺寸将整型坐标转化到归一化坐标来确保不会过曝,你可以使用
textureSize(tex, lod)
来获取纹理尺寸,第一个参数传入要查询尺寸的采样器,第二个参数lod
则是 MipMap 等级,在这里只需要设置为0
。它会返回每一个维度上的纹理尺寸,因此你将它与整型坐标相除就可以获取归一化坐标。
复习第二章的内容。
至此,你已经了解了阴影渲染的大概,我们的第二章也就接近尾声了。相信现在的你已经对光影细节有了一些概念,那么在下一章我们将会更进一步,深入光影配置、认识半透明几何、重建场景数据。
To Be Continued.