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

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

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

如果将我们之前的 lightDir
赋值内容替换为 normalize(moonPosition)
然后把时间切换到晚上,你就会发现场景正确地变暗了。
现在还剩下一个 小 问题:我们采样的光照颜色是天空和方块光照的叠加,而我们之前写的光照强度会根据太阳的方向变化:我们没有理由让方块光照强度也随着光照角度的变化而变化!
其中一个解决办法是在几何缓冲中处理一次表面光照,然后根据光照的方向动态地变化我们在光照贴图上的采样纵坐标;也可以存下光照贴图 UV ,消耗一个缓冲区导入原版光照贴图的数据(见 附录 3 - 自定义纹理 - 纹理来源 ),这样就可以在延迟处理中间接访问 lightmap
了。这种方法限制较多,没有多少光影使用,我们通常只保存光照 UV 作为光照强度,然后自行处理光照色彩。
虽然在下一小节中绘制实时阴影之后,我们不必再依赖光照贴图遮挡阳光或月光,但是天空光照强度数据仍可用于环境光或另作他用。我们会将输出光照强度留作习题。
块输出
随着顶点着色器的输出变量变多,我们的声明区域不堪重负,变量名也杂乱无章,因此我们将进行第二次瘦身。不过不用担心,这一次的瘦身还没达到单开一节的程度。
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 变换到裁切空间之后才进行透视除法 1 ,与正变换略有差别。变换完成之后,OptiFine 还为我们提供了阴影空间的相关矩阵,于是我们就可以直接进行阴影变换:
虽然阴影空间是等轴的,但是我们还是需要进行“透视”除法来归一化。
[*], [1] NDC 的
那么,就让我们从屏幕空间开始吧,就像前文所述,重建 NDC 非常简单:
我们这里将 1.0
,与第三节有些微差别,它用于交给逆投影矩阵设置为透视除法的除数,也只能为 1。接着将它转化到裁切空间:
这一步使用逆矩阵与坐标相乘,同时也更改了
在这一步我们直接将它的 1.0
(你也可以手动设置)。最后再转化到世界空间,我们的逆变换就结束了:
然后,我们利用阴影空间的相关矩阵,将世界坐标变换到阴影空间坐标:
别忘记进行透视除法:
最后将它转换到阴影屏幕空间坐标,这样我们就在屏幕空间中和阴影屏幕空间的坐标对齐了:
这就我们所需要的对应屏幕空间位置的阴影贴图 UV 了,而它的第三分量则是屏幕空间中的像素在阴影空间下的深度,即
我们之前需要的 closestDepth
就可以通过 uv_shadowMap
采样 shadowtex0
得到:
绘制与优化
最后,我们将 closestDepth
和 currentDepth
做比较,如果后者大于前者(即当前深度不是在光源连线上的最近深度),则处于阴影中。
最后,我们将这个阴影系数乘到我们之前的光照强度上,就可以产生阴影了:

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

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

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

因此,我们还是请出之前已经点乘好的值 lit
,当光照与法线方向夹角越小,我们的偏移量应该越小,因此要取其与 1 的差。我们不关心背光面,它们本来就全是阴影 1 。同时我们应该保证一个最小的偏移量来确保某些极端角度不会产生自阴影:
[1] 如果你想的话,可以在检查到是背面时完全禁用偏移:

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

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

图中泛红的区域即阴影空间坐标不属于
要想解决这个问题很简单,我们只要不比较阴影 UV 不属于
当然,我们可以用之前封装的函数 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
。它会返回每一个维度上的纹理尺寸,因此你将它与整型坐标相除就可以获取归一化坐标。最后,再根据光照等级再次除以 15,就可以得到归一化的光照强度。
复习第二章的内容。