EtherTK 解构《武士零》 - 蒙版光照

其实与光照的死磕在很久之前便已经开始了,在开发第一代使用 OpenGL 可编程渲染管线的引擎时,便实现了一套简单的 2D 光照算法,那时候更多的还是借鉴自 LearnOpenGL 的 3D 光照思路,但是在对《武士零》的研究过程中,发现如果仅制作 2D 游戏的话,使用源自上个游戏技术世代的蒙版贴图,配合现代化的渲染管线,可以更简明地实现更符合艺术效果的光照效果(至少在像素风格的 2D 游戏看来是这样),本文将对该过程的探索过程进行阐述。

本文及相关程序所使用的实现依然为 EtherTk,不过由于需要更精确自由的渲染逻辑,所以底层实现由 SDL Renderer 变更为了 OpenGL。

基于公式计算的光照

此节内容更多参考自:投光物 - LearnOpenGL CN

对于游戏中的光照公式可以简化为:物体受光 = 环境光 + 聚光灯(点光源可以看作是外切角为 360° 的聚光灯),而整个世界是一片漆黑没有任何光照的,环境光作为让场景中各物体雨露均沾接受光照的光源,为物体提供了最基础的光照贡献——简单理解,在不使用光照进行渲染的场景中,不经任何处理绘制的物体纹理贴图相当于被赋予了环境光强度为 1 的白色光照;而聚光灯的技术核心,更多的是使用统一的公式和参数描述指向性光源的外形,从而计算得到世界中受此影响的像素片段。

对于聚光灯的处理,首先要计算的便是处理其径向衰减,逆向思考一下,聚光灯也可以看做是指向性的点光源,所以在处理径向衰减的思路和点光源完全相同。虽然我向来不喜欢将晦涩的数学公式搬上台面,但是无奈光照这种基于物理和经验的模拟,很难找到除去公式之外的第二种表述方式——当然,这样方便了我们,即使不能完全理解,也是可以直接套用公式实现不错的效果:
径向衰减公式

同时,为了让光照更加真实,我们引入了切向衰减的处理,也就是说点光源的光照强度,会在其内切角和外切角之间进行切线方向的衰减,这样便可以使受此聚光灯影响的物体光照边缘更加柔和平滑,我们可以使用如下公式来计算衰减值:
切向衰减公式

在此基础上,我们便可以抽象出聚光灯的光源信息:除去最基础的颜色和强度等,描述其性质的参数如下图所示,这里依然借用 LearnOpenGL 中的示意图:
聚光灯参数

于是,我们便可以编写如下的 shader 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Spotlight {
vec2 lightPos; // 聚光灯位置
float lightRadiusAtten; // 聚光灯径向衰减速度
vec3 lightColor; // 聚光灯颜色
float lightRadius; // 聚光灯半径
float lightStrength; // 聚光灯强度
float lightCutOffOutside; // 聚光灯外切角余弦值
float lightCutOffInside; // 聚光灯内切角余弦值
vec2 lightDir; // 聚光灯方向
};

// 计算片段受单个聚光灯影响后的颜色
vec3 CalcSpotlight(Spotlight light, vec3 texture_color)
{
// 点光源强度
float I1 = pow(1 - clamp(pow(pow(distance(posCoord, light.lightPos), 2) / pow(light.lightRadius, 2), light.lightRadiusAtten), 0, 1), 2);

// 聚光灯强度
vec2 radialDir = normalize(posCoord - light.lightPos);
float theta = dot(radialDir, light.lightDir);
float epsilon = light.lightCutOffInside - light.lightCutOffOutside;
float I2 = clamp((theta - light.lightCutOffOutside) / epsilon, 0.0, 1.0);

return texture_color * light.lightColor * I1 * I2 * light.lightStrength;
}

这里有一个小小的优化处理,我们在描述外切角和内切角时,没有直接使用角度或者弧度,而是传入了其余弦值,这是因为 LightDirSpotDir 的点积返回的是余弦值而不是角度值,如果我们想要将其结果与内切角或外切角做运算,那么便需要将其点乘结果执行反余弦,这在着色器中是一个开销较大的操作,所以我们在 CPU 端计算切光角的余弦值传入进行比较;同时,还需要注意的是在 0~180° 区间内 cos 函数是递减的,所以需要在运算时当心符号问题。

如果同一个片段受场景中多个聚光灯影响,那么简单来说,我们只需要在绘制该片段时,遍历场景中所有灯光信息,将这些灯光的影响进行累加即可:

1
2
3
4
5
6
7
8
9
10
out vec4 FragColor;

vec3 texture_color = texture(texture1, TexCoord).rgb;
// 计算片段受环境光影响后的基础颜色
vec3 result = texture_color * ambientColor * ambientStrength;
// 对常见中所有的聚光灯影响效果进行累加
for(int i = 0; i < totalSpotlightNum; i++)
result += CalcSpotlight(light_list[i], texture_color);

FragColor = vec4(result, 1.0f);

在没有场景管理等特殊优化的前提下,如果场景中有 M 个物体,同时有 N 个独立光源,那么就需要执行 M * N 次渲染;对此,我们可以使用简单的光照烘焙算法进行优化。

简单光照烘焙

对于 M * N 次的渲染量,我们考虑将光源信息优先渲染到一张纹理缓冲区中进行存储,不仅可以减少不可避免的 M 次物体渲染时的单次开销,更是可以将光照信息保存到文件,以便在大量光照的场景中减少实时渲染的性能消耗。

考虑到光照强度可能是一个较大的数值,也就是说影响单个像素片段的颜色分量可能远超 1.0,如果直接将光照缓冲区的纹理色彩直接写入文件,那么分量高于 1.0 光照强度信息就会丢失——这里或许可以使用 HDR 格式进行存储,不过由于自己对 HDR 并无深入研究,于是便采用了一个小技巧:我们将光照缓冲区片段的颜色分量使用 y = x / (x + 1) 函数进行处理后存储;该函数的图像如下所示:
y = x / x + 1

于是我们便可以轻松写下光照烘焙的 shader 代码:

1
2
3
4
5
6
7
8
9
10
out vec4 FragColor;

vec3 result = vec3(0, 0, 0);
for(int i = 0; i < totalSpotlightNum; i++)
result += CalcSpotlight(light_list[i]);
result.x = result.x / (result.x + 1);
result.y = result.y / (result.y + 1);
result.z = result.z / (result.z + 1);

FragColor = vec4(result, 1);

经过此函数处理,我们便可以将尽可能大的颜色分量映射到 0 ~ 1.0 之间了;在绘制场景中的物体时,对光照烘焙后的纹理进行采样后,只需要通过反函数计算得到真正的光照强度即可,对应的渲染 shader 代码如下:

1
2
vec3 backedLightColor = texture(texture2, TexCoord).rgb;
result += texture_color * backedLightColor / (1 - backedLightColor);

显然,使用这样的函数进行映射会有浮点精度的问题,浮点精度所造成的误差会随着光照强度提高而提高,但是,考虑到在最终物体渲染的阶段,超出 1.0 部分的颜色分量本身便会在输出时被截断,而片段所受到的光照越强,就越容易造成这种阶段效应的出现,这样反而削弱了浮点精度造成的误差视觉影响,在极限测试中,肉眼几乎无法察觉到二者之间的区别。

到此为止,这套光照的实现看起来似乎已经比较完美了,但是,画师大大们可能不会这么想——

色彩矫正

我们首先来看一段极端下光照下的演示:
极端光照下的素材

在上图所示的素材中,我们可以清楚地看到,某些像素对光照的响应似乎并没有按照我们希望的结果呈现出来——他们似乎很难受到光照影响从而改变自己的颜色;红色的像素可能不是很明显,但是当我们看到黑色的像素出现这种情况时,就应该意识到问题所在了:当某些像素片段的分量为 0 时(或接近 0 时),使用乘法计算的光照就很难在累加时明显改变其值

想明白了这点之后,我们当然就可以让画师在产出素材时尽量避免使用颜色分量为 0 的像素——但是这有点太极端了,艺术创作不应该因程序的懒惰而被束缚手脚,于是我们便可以在着色器上面下功夫,将分量为 0 的颜色进行一定范围的矫正,使其能更好地响应光照,下面给出一种可行的 shader 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vec3 texture_color = texture(texture1, TexCoord).rgb;
float max_value = max(max(texture_color.x, texture_color.y), texture_color.z);
if (max_value == 0)
{
texture_color.x = correctionRange;
texture_color.y = correctionRange;
texture_color.z = correctionRange;
}
else
{
float correction = max_value * correctionRange;
if (texture_color.x == 0) texture_color.x = correction;
if (texture_color.y == 0) texture_color.y = correction;
if (texture_color.z == 0) texture_color.z = correction;
}

这样以来,我们便可以在特定的渲染阶段采用合适的色彩矫正,从而避免这种极端的像素颜色不响应光照的问题:
色彩矫正

基于蒙版贴图的光照

诚然,基于上述算法实现的光照确实可以相对真实地模拟出光照效果,但是在对比现有的像素游戏后发现,只是通过上述算法进行处理后的光照效果,并没有达到预期的艺术效果——它似乎只是从数学上是正确的,但是画面效果在直觉上总是有所欠缺,团队中负责画面设计的策划同学从 加色法和减色法 角度对此进行了阐释,但我个人更倾向于以下观点:

  • 在现实中,极强的颜色光会使得所有颜色的物体向着灯光的颜色偏移——即便是黑色,因为现实世界中几乎不存在 RGB 分量均为 0 的纯黑色物体,所以当光照,尤其是强光照射到绝大多数物体上时,都会让物体向着光的颜色偏移;而在前述算法中,由于色彩分量的累加,会导致在多种颜色的强光影响下,像素片段会快速到达白色,从而丢失这种色彩倾向的效果;
  • 空气的密度不同以及尘埃的存在,导致现实世界中的光存在或多或少的散射效果,这就让我们看到的光源周围都有一层朦胧的感觉,而不是赤裸裸的灯光颜色;而不使用体积雾等技术的情况下,计算得到的光照必然是按照固定规律均匀衰减变化的,这就使得画面中的光照过于生硬缺少质感。

下面给出比较典型的几张示例截图:
《夏至与梦》Demo 游戏截图

《武士零》游戏截图

在仔细斟酌讨论后,我们返璞归真地选择了使用蒙版贴图来实现这种光照效果,具体理由如下:

  • 蒙版光照可以解决前文所述的色彩倾向问题,经过 Alpha 混叠之后得到的色彩几乎不会在着色器阶段因为色彩分量溢出导致截断效应,同时也可以避免分量为 0 的像素对光照响应出现问题;
  • 蒙版光照本质为素材图片,所以艺术创作者们可以更好地控制光线的外形、衰减以及前文所说的朦胧质感,这是仅通过数学公式所很难达到的效果;
  • 蒙版光照在片段着色时仅需要对光照贴图进行采样,而不需要过多的计算,性能更优。

但是,蒙版光照的实现并非简单地将蒙版贴图覆盖到场景中,而是使用 Alpha 通道的值标定该区域光照的强度,所以光线的衰减和外形由蒙版贴图的透明度所决定,而灯光的具体颜色和光照强度则在程序中动态指定;这里我们依然将所有蒙版光优先渲染到光照缓冲区中,然后在后续的场景渲染时对该缓冲区纹理进行采样:

光照贴图烘焙片段着色器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
in vec2 textureCoord;

out vec4 FragColor;

uniform sampler2D textureMask; // 光照蒙版纹理

uniform vec3 color; // 环境光颜色
uniform float strength; // 环境光强度

void main()
{
vec4 maskColor = texture(textureMask, textureCoord);
FragColor = vec4(maskColor.a * color * strength, maskColor.a);
}

场景物体渲染片段着色器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
in vec2 textureCoord;

out vec4 FragColor;

uniform sampler2D textureBackgound; // 背景图片纹理
uniform sampler2D textureLight; // 光照贴图纹理
uniform sampler2D textureNormal; // 背景法线纹理

uniform vec3 ambientColor; // 环境光颜色
uniform float ambientStrength; // 环境光强度

void main()
{
vec4 backgorundColor = texture(textureBackgound, textureCoord);
vec4 lightColor = texture(textureLight, textureCoord);
vec4 normalColor = texture(textureNormal, textureCoord);
vec4 afterAmbientColor = vec4(backgorundColor.rgb * ambientColor * ambientStrength, 1);
FragColor = afterAmbientColor + vec4(lightColor.rgb * lightColor.a * (1 - normalColor.a), 1);
}

注意,如果使用上述 shader 代码对光照纹理进行烘焙,那么烘焙所使用的渲染缓冲区在启用 Alpha 混叠时,不能使用常用的 GL_SRC_ALPHAGL_ONE_MINUS_SRC_ALPHA 来指定混叠因子,因为我们在片段着色器中已经考虑到了其透明度,所以只需要使用 GL_ONE 设置源因子和目标因子均为 1 即可,对应的 EtherTK 代码为 OpenGL.BlendFunc(OpenGL.ONE, OpenGL.ONE)

这样便可以实现艺术效果与程序性能俱佳的光照效果了:

蒙版光照效果

源码链接

感兴趣的朋友可以在这里查看本项目源码:https://github.com/VoidmatrixHeathcliff/DeconstructKatanaZERO/tree/main/MaskLight

作者

Voidmatrix

发布于

2023-06-04

更新于

2023-06-04

许可协议

评论