一种高效、简易、高质量的 2D 游戏 PBR 光照实现方法
本文将会介绍一个我正在制作的项目 PaperCraft 中有关游戏 2D 光照的实现。提供一个低成本的基于有符号距离场可用于实时 2D 光照渲染的可行思路。本文将会提供一种可能的代码来实现该方法,阐述该方法的优缺点。并在本文最后附上 EasyX 实现代码并给出性能测试。
一、基础知识简介
SDF(Signed distance function),有符号距离函数。是一个多元函数(具体几元取决于研究对象所处的维度),本文记作
法线贴图(Normal Texture),是一种凹凸贴图(Bump Map)。可以表示物体的表面细节(如凹凸、划痕)。一个常见的法线贴图如下所示:
法线贴图(图源:LearnOpenGL)
事实上,法线贴图就是将法向量
因此,可以将物体表面粗糙的法向量
由于使用了法线贴图,绿宝石方块表面表现出层次不齐的纹理
你可以清晰地看到法线贴图的效果,绿宝石块和石块表面看起来来凹凸不平,极具立体感。
关于法线贴图和 SDF 的详细描述,请分别参考 LearnOpenGL:法线贴图 和 知乎 Jacks0n - Rendering (Signed) Distance Function。
二、渲染管线概述
下图简单直观地展示了本方法的渲染管线:
这里再给出这几个步骤的简述:
1. 基础场景渲染
该流程使用原始贴图渲染出未经光照的原始地图。
2. 法线场景渲染
该流程使用法线贴图渲染出未经光照的原始地图。
3. 光照贴图渲染
该流程将会基于 SDF 函数计算出原始光照蒙版贴图。
4. 辉光处理
该流程基于高斯模糊,对阶段三生成的光线蒙版贴图进行辉光处理。
5. 混合
该步骤将会将步骤一、二、四得到的结果混合并计算出最终结果。
下文将会详细讲述步骤三至五。
三、基于 SDF 的蒙版光照计算
根据现实生活中的经验,可以发现,一个发光的物体照亮的区域形成一个圆圈。且先保持强度不变再衰减,具体示意图如下:
那么便可以通过 SDF 模拟该过程:
假设某点处的光源强度
我们假设有一个光源
注意(1)中
根据在 1986 年由 James T.Kajiya 提出的渲染方程:
这里我们无需过多关注这个公式本身(如果你想了解这个公式,可以参考 James T.Kajiya 的论文 The Rendering Equation),在 2D 平面中,我们可以认为所有光线都将射入摄像机,且任意点上收到的光照,等于所有光线辐射度的总和,可以将球面积分改写成很简洁的形式:
其中
之所以是在不考虑阴影的情况下才有上述等式成立,是因为当有一个物体遮挡时,光线就并不总是可以完全照射到指定点处。这时就要考虑可能的辐射度衰减。
因此,假设有
四、辉光处理
尽管在章节 Ⅲ 中已经引入了光照平滑函数
为了实现辉光效果,一个可行的思路就是通过高斯模糊来对原来的蒙蔽光照贴图进行处理。
高斯模糊使用到了二阶正态分布,通过在目标点指定半径大小内计算每个点相当对于该点的权重
关于高斯模糊的详细描述,请参考百度百科。事实上,此处对高斯模糊的要求仅局限于视觉效果,因此可以考虑使用更快的高斯模糊近似方法来代替原本的高斯模糊算法以提高程序运行效率。
五、混合
在最后阶段中,我们需要将前几个步骤得到的结果混合起来,完成光照渲染。混合公式为:
实际上就是把
六、可能的代码实现
请直接点解此处以下载实现代码以及资源文件。以下是代码编译需求:
最低需要使用的 C++ 标准 | ISO C++20 Standard (/std:c++20) |
使用的编译器 | MSVC |
Windows SDK 版本 | 10.0 |
最低 Visual Studio 版本 | 2022(17.10.4) |
注:代码中使用的素材重打包已经过原作者授权。
七、性能测试
测试平台:
CPU | 13th Gen Intel(R) Core(TM) i5-13500H |
内存 | 64GB(DDR5) |
编译器 | LLVM Clang-Cl |
编译平台 | Windows Release X64(Subsystem:Console) |
分辨率 | 640x480 |
注:六中提供的项目文件默认使用 MSVC 作为编译器,可右键“Demo”找到“Properties-General”处修改为“LLVM - clang-cl”(若没有这个选项,可以前往 Visual Studio Installer 中安装 Clang Cl,重启软件即可)。
测试方法:
通过在原代码中 if (clock() - timer > 1000) 后添加 printf 函数输出更新计数后的帧率(舍弃一开始的 0 帧),记录九次后停止测试,最终取平均帧率。
测试场景:
测试场景使用六中给出的可能的代码实现,为三个圆形光源绕着一圆心做圆周运动。
修改后的测试代码(部分):
float time = clock();
auto timer = clock();
int fpsCounter = 0;
int fps = 0;
int count = 0;
while (count != 9) {
float iTime = (clock() - time) / 1000.f;
cleardevice();
SetWorkingImage(lightMask);
cleardevice();
SetWorkingImage();
SumOn(redMask, lightMask, BlockRadius + BlockRadius / 4 + 30 * sin(iTime), BlockRadius * 2 + BlockRadius / 4 + 30 * cos(iTime));
SumOn(greenMask, lightMask, BlockRadius * 2 + BlockRadius / 4 + 30 * sin(iTime), BlockRadius / 4 + 30 * cos(iTime));
SumOn(blueMask, lightMask, BlockRadius / 2 - BlockRadius / 4 + 30 * sin(iTime), BlockRadius / 4 + 30 * cos(iTime));
BlendOn(lightMask, blendedMap, nullptr);
if (clock() - timer > 1000) {
fps = fpsCounter;
fpsCounter = 0;
timer = clock();
++count;
printf("%d\n", fps);
}
outtextxy(0, 0, std::format(_T("FPS : {}"), fps).c_str());
FlushBatchDraw();
++fpsCounter;
}
_flushall();
测试场景截图:
测试结果:
在上述场景中,测试结果为九次的平均帧率为 564.7FPS,每次计数均能稳定在 500FPS 以上。
八、总结
本文提供了一个低成本的基于有符号距离场可用于实时 2D 光照渲染的可行思路。事实上,如果优化得当,该方法在 CPU 上依然可以使用。因为本方法并不考虑阴影遮挡(这是因为使用了累加),因此可以说只要光源是确定(即全为静态光源)就可以考虑采用该方法预先进行光线烘培。即使是需要动态光源,在确保光源本身属性不变的情况下,依然可以预先渲染蒙版光源。
本方法的很多渲染并不一定需要实时进行,这也是为什么在 CPU 上实现这个方法成为了可能。在 CPU 的是实现上,只需要将步骤一~四提前执行,并保存为资源备用,最后需要被实时执行的只有步骤五。尽管需要提前的光照烘焙,但是这并不代表光源的位置不可以实时改变,详情请参考下文提供的可能代码实现。
添加评论
取消回复