EtherTK 解构《武士零》 - 自动法线

关于本文所提及的蒙版光照相关的前置内容,详见:EtherTK 解构《武士零》 - 蒙版光照

2D 法线贴图?

对于这篇博客的标题,其实还是犹豫了一会儿的,因为在 2D 渲染过程中,对于“能够对光照效果产生影响的贴图”这种东西的叫法,就算是工作组内的同学意见也不一致;但在我看来,如果我们把不使用法线贴图的 2D 纹理看做是法线方向始终沿 Z 轴方向的话(即在光照响应时法线对光强的贡献始终为 1),那么当我们使用另外一张蒙版遮罩贴图对这张目标贴图响应光照强度的区域进行限定时,这张蒙版遮罩贴图本身就已经起到了法线的作用——如果我们的这张蒙版遮罩贴图使用 Alpha 值来描述目标纹理的光照响应区域强度,那么目标纹理在渲染时计算每个像素的光照响应强度时,与传统法线贴图使用 RGB 分量来描述空间向量相似,使用 Alpha 通道的值直接作为衰减系数对光照效果进行计算,所得到的渲染结果是一致的;综上所述,这种蒙版遮罩与法线本质相同,所以将其称之为法线也并不为过。

——听起来可能有点绕,咱们先看一下这种法线贴图在实际渲染中的效果:
启用法线(左)/ 关闭法线(右)

而渲染上图所使用的纹理原始素材如下:
目标纹理(左)/ 法线纹理(右)

我们可以很清晰地看出,在使用法线后,光照的响应强度开始受法线贴图的 Alpha 值影响,这不仅让光源的外形更加真实,也让被渲染的目标纹理在复杂光照环境下更加立体,让画面中的物体与光效“融合”到了一起,避免了使用贴图光的剥离感。

——那么,接下来的问题便是,如何由目标纹理得到对应的法线纹理?

自动法线生成

其实作为程序员这种很少与数位板打交道的角色,我们倒是可以大言不惭地让美术同学去把法线纹理画出来,即便 Alpha 通道的值并不直观,我们也可以让其使用 RGB 颜色通道中的任意分量进行绘制,于是乎这张图便成为了一张纯红或纯绿的图片——这不仅带来了几近双倍的工作量极大地影响了效率,对美术同学更是精神与肉体的双重折磨。在组内的策划同学通过 DaVinci Resolve、Photoshop 等一系列工具的奇技淫巧下生成了几乎完全一致的法线贴图后,我便猜想是否可以将整个过程算法化,通过程序直接生成目标纹理的法线——这不仅极大地减少了素材产出阶段的工作量,更是可以在发布版本使用即时生成的法线贴图从而减少资源包体积。

那么,首先要考虑的就是法线贴图生成的指导思想,或者说美学依据是什么,在经过一系列讨论后,我们总结出了如下结论:

这种 2D 法线贴图的作用主要在于强化边缘,而素材中的边缘或很少响应灯光的部分大多是亮度较低的像素——这也符合我们对于灯光效果的直觉,黑色或深色的物体在同样的灯光照射下会显得更暗;所以我们只需要根据 HSL 色彩空间下的 L 分量的值对原始素材进行处理,即可得到对应的法线贴图。

而在实际操作时,考虑到理论中的 HSL 色彩空间与编码中的 RGB 色彩空间转换存在额外的性能代价,我们对亮度的估值可以近似用 RGB 三个分量中的最大值进行替代;那么,接下来的问题便是,使用什么数学公式对 Max(R, G, B) -> Alpha 这种关系进行映射?

观察前文中的法线纹理,我们可以观察到:亮度大于一定颜色的像素,对应在法线贴图中的 Alpha 值直接变为了 0(也就是在白色底板下完全显示为白色的部分);同理,小于一定亮度的像素,对应的 Alpha 只被直接变为了 1;而在最小截断亮度和最大截断亮度之间,法线贴图中的 Alpha 值会随着原始素材中的像素亮度增大而减小——在使用取色器对数据进行详细比对时发现,这种衰减并非线性关系,而是一种先慢后快的过程,所以我们考虑使用二次函数对此区间的过程进行拟合。

我们将原始素材纹理中像素的 RGB 分量最大值设置为 X,最小截断值和最大截断值设置为 MinMax,那么公式组可以表示为:

法线贴图 Alpha 计算公式

下面最关键的就是二次函数的系数求解:考虑到衰减速度受二次项系数影响,我们将其预留为变量以方便调节(这里预留了一个隐患,后文中会提及),剩下的便是要求这个函数经过 (Min, 1)(Max, 0) 这两个点,于是乎我们便可以求解出此段方程:

Min < X < Max

可能的函数图像如下图所示:
a = -1, Min = 0.059, Max = 0.3333

我们可以看到,在中间取值范围的衰减过程中,曲线的弧度并不明显,近似为线性衰减,于是我们使用公式设计之初的思路,试图通过调节二次项系数使曲线更加平缓,但是在实践过程中发现,随着 a 值的变化,生成的法线素材效果并无改善,甚至起到了反作用—— Alpha 为 1 的区域过大导致丢失了很多边缘细节,这是因为当 a 值过大时,求解出的二次函数在 (Min, Max) 区间内可能并非单调递减,并且极大值可能远超 Alpha = 1,从而导致在最终写入颜色缓冲区时出现截断的问题,显然这与我们的设计初衷相悖。

于是,我们尝试了另外一种思路,在保持使用二次函数进行拟合的情况下,舍弃一次项(即 b = 0),从而确保在 a < 0 的情况下,在 (Min, Max) 区间内,函数一定单调递减,当我们代入两点进行求解时,便可以得到此段方程:

Min < X < Max

可能的函数图像如下图所示:
a = -1, Min = 0.059, Max = 0.3333

可以看到,在 (Min, Max) 区间内曲线更加平滑了一些;有了公式论证,对应的代码就显而易见了,我们只需要使用对应算法的 Shader 在帧缓冲中渲染一次,然后从其中读取颜色缓冲区的数据,再使用 stb 或其他图片编码库将像素数据保存为 .png 文件即可(如果生成的法线纹理只在运行时使用则不需要回读像素数据,此处用于演示作为自动处理工具时的代码流程):

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
26
27
28
29
30
31
-- 绘制蒙版纹理
local DrawMaskTexture = function(texture)
OpenGL.ClearColor(0, 0, 0, 0)
OpenGL.Clear(OpenGL.COLOR_BUFFER_BIT)
shader_process:Use()
local model = OpenGL.Mat4(1.0)
model = OpenGL.TranslateMat(model, OpenGL.Vec3(0, 0, 0))
model = OpenGL.RotateMat(model, OpenGL.Radians(0), OpenGL.Vec3(0, 0, 1.0))
model = OpenGL.ScaleMat(model, OpenGL.Vec3(texture_dst.width, texture_dst.height, 1.0))
shader_process:SetMat4fv("model", model)
local view = OpenGL.Mat4(1.0)
view = OpenGL.TranslateMat(view, OpenGL.Vec3(0, 0, 0))
shader_process:SetMat4fv("view", view)
local projection = OpenGL.OrthoMat(-texture_dst.width / 2, texture_dst.width / 2, -texture_dst.height / 2, texture_dst.height / 2, -1.0, 1.0)
shader_process:SetMat4fv("projection", projection)
OpenGL.ActiveTexture(OpenGL.TEXTURE0)
OpenGL.BindTexture(OpenGL.TEXTURE_2D, texture.id)
OpenGL.BindVertexArray(VAO)
OpenGL.DrawArrays(OpenGL.TRIANGLES, 0, 6)
end

-- 计算并返回蒙版数据
local GenerateMaskData = function()
OpenGL.BindFramebuffer(OpenGL.FRAMEBUFFER, fbo_dst)
OpenGL.Viewport(0, 0, texture_dst.width, texture_dst.height)
DrawMaskTexture(texture_src)
return OpenGL.ReadPixels(0, 0, texture_dst.width, texture_dst.height, OpenGL.RGBA, OpenGL.UNSIGNED_BYTE, texture_dst.width * texture_dst.height * 4)
end

local mask_data = GenerateMaskData()
assert(STB.WritePNG(dst_path, texture_src.width, texture_src.height, 4, mask_data, texture_src.width * 4), "写入图片失败")

对应的 Shader 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#version 330 core

in vec2 textureCoord;
out vec4 FragColor;
uniform sampler2D textureSrc;

const float threshold_min = 0.059; // 最小截断阈值
const float threshold_max = 0.3333; // 最大截断阈值

void main()
{
vec4 srcColor = texture(textureSrc, textureCoord);
float max_val = max(max(srcColor.r, srcColor.g), srcColor.b);
float alpha_val = 0;
if (max_val <= threshold_min)
alpha_val = 1;
else if (max_val >= threshold_max)
alpha_val = 0;
else
alpha_val = (pow(threshold_max, 2) - pow(max_val, 2)) / (pow(threshold_max, 2) - pow(threshold_min, 2));
FragColor = vec4(srcColor.rgb, alpha_val);
}

此时,生成的法线贴图也与游戏素材中所使用的法线贴图效果几乎一致:
生成结果(左)/ 原始素材(右)

到此为止,法线贴图自动生成的算法已经基本可以实装使用。

可控的透明度衰减

对比上文中所述两张图,我们可以看出,根据此算法思路生成的法线图片的整体 Alpha 似乎比原始素材中的值更大,这就需要我们对 (Min, Max) 区间内的曲线进行调控,使范围内的像素平滑地向着 Alpha = 0 进行偏移,在前文中我们试图通过控制二次项系数来实现这种效果,但是受限于二次函数,只是改变二次项系数很难比较直观地达到我们需要的效果。

一种可能的思路是使用二阶贝塞尔函数进行拟合,只需要确保函数在 (Min, 1) 区间内最大值小于 1 且单调递减即可,受限于时间该过程不再进行论证,借用一张来自网络的图片简单说明二阶贝塞尔曲线控制点与生成过程关系:

二阶贝塞尔曲线

源码链接

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

作者

Voidmatrix

发布于

2023-06-22

更新于

2023-06-22

许可协议

评论