前言
问题是这样的,移动端开放世界的全局光照(GI)方案应该如何实现?实时的光照计算(Realtime GI)肯定少不了,可是来自天空盒,地表以及四周山石森林等的环境光漫反射又如何表现呢?关于这点项目组的同学都缺乏经验,于是我本着探索精神结合Unity框架调研了一番,期间总结了几点感觉对大家有益的经验,发在这里以供查阅。
1.1起点
但是在展开之前需要先明确下限制条件:
- 首先,平台是移动端的,也就意味着较低的算力和数据带宽,那么很多算法复杂且消耗大量资源的方案就不用考虑了(光追?)
- 其次是开放世界,简言之场景数百甚至数千倍于传统,那么为了不让执行烘焙的设备暴毙,就得着手给单次烘焙降降压:降低品质+分治。
- 再然后是前文没有提到的,游戏时间需要支持日夜交替(Time of Day)和/或天气系统(Weather System)。
1.2最简单的方案
OK,现在我们围绕Unity引擎来考量下能有哪些方案可用吧。
Unity有一套完整的GI方案,可以在Window -> Rendering -> LightSetting中找到它的设计面板。这套官方的方案总的来说比较复杂,因为它要适应不同用户的需求,比如依据不同的设置,GI会在动态和静态之间,细节丰富程度和拟真程度上形成区别,而且越是好的效果,对存储和计算的开销越大。可以概括起的说,Unity通过烘焙(基于PBR的预计算)的方式,重建了场景中各个表面的光照数据,并分类存储到诸如Lightmap、ShadowMask、LightProbe等数据载体中,在运行的时候,再快速反解出这些光照数据,以供顶点和片元使用。具体细节内容不是这篇博文的重点,就不再单独深入下去了,之后有机会再单开一篇介绍这套GI方案,这里丢一个个人觉得比较好的官方解释文档。
基于Unity提供的方案来说,什么是最省时省力的做法呢?我觉得是放弃使用任何额外的预计算光照,只在计算物体表面颜色时,附带一层定制的环境光底色(Ambient),或者复杂一些,为角色的头顶+侧面+脚底3个方向各附加一层专门的环境光底色,这种做法的复杂点在于需要利用角色模型的法线纹理,过滤出朝上的部分,朝上的反方向部分,以及其余所有部分作为遮罩。虽然效果比预计算GI显得单调了些,也不支持随场景变化,但是贵在节约性能,操作简便。所以只要美术同学不在意这部分模糊的环境光,那么大可以省去烘焙的部分。
1.3 Lightmap的取舍和原因
说到Unity全局光照方案,就绕不过光照贴图(Lightmap)。Lightmap的本质是一系列的等尺寸的贴图纹理,纹理上存储的是经过预计算后得到的物体表面光照信息(一般包含了直接光照和间接光照的效果)。更加形象点,Lightmap上存储的是所有参与烘焙的物体的表面颜色在2D空间上展开的贴图,运行时配合着物体第二套UV以及一份特有的参数(缩放+下标),直接读取贴图上的颜色进行显示。可以说在处理室内静态场景时,使用Lightmap会带来性能和效果上的巨大优化,然而对于空间庞大的室外场景来说,我们有太多需要烘焙的物体,如果把一个开放世界看做一个场景,那么无脑烘焙的结果必然是海量的光照贴图(假如没爆内存的话)。那么分场景烘焙+运行时动态载入的方法可行么?首先回答是可行,虽然在Unity的这套框架里Lightmap是跟着场景(Scene)走的,但是不妨碍我们每次只Load一小块地块的Lighting Data Asset到当前的全局场景,然后可以参考这篇博文的方法,流式的加载场景上的物体,只要这些物体预设了修正过的UV2,就能正确的采样到Lightmap。然而如博文最后所说的,这种方法的最大问题是人为打断了Unity对资源的合批,导致包体膨胀,且由于Mesh不同,会影响到渲染的合批(URP是否有影响待考),影响性能。从另一方面说,烘焙好的表面贴图灵活性也不够,不适合有强烈的动态的明暗变化的场景,所以综合考量下来,我们决定弃用Lightmap。
2.0 总体GI方案
使用实时计算的直接光照和阴影,再辅以lightProbe补全间接光照。
2.1 切割场景
鉴于超大地图的特效,需要分场景烘焙,建议拆分出的子场景地块边长相等且场景与场景之间大小也相等,能给予以后管理和加载数据不少便利。以当前DEMO为例,其Base场景在加载余下9块子场景后,可以视为一个9宫格,每个宫格都是一块正方形平面外加4个几何物件组成。
如下图所示,一块拆分好的子场景占示例场景的1/9:
在当前示例中,场景由全局主场景Base和9个子场景Test_0 ~ Test_8构成:
关于场景上物件,一般情况下所有静态物体的Mesh Renderer组件中需要勾选 Contribute GI,不过决定哪个物体要贡献GI哪个不要的应该是美术同学,而且最好是它们在构建模型的时候就打上Tag,当导入Unity后由脚本识别Tag,自动同步到烘焙属性设置去;另一方面因为我们不需要lightmap,可以在Receive GI下拉栏中,选择Light Probes(而不是lightmap),这样做可以一定程度的加速烘焙。
2.2 布置探针
场景拆分完成后我们需要找一个空场景作为烘焙用主场景,然后把所有子场景拖入Base Scene(以Additive模式追加打开场景)
需要注意的是,主光源只保留一份即可,建议使用主场景中的方向光作为主光源,删除或者失活子场景中相对应的方向光。当上述准备完成,接下来才是布置探针,示例DEMO中探针是布置在主场景中的,因为待烘焙子场景是以Additive模式打开的,光照烘焙时探针对象被放置在哪个场景并不影响烘焙效果。
放置探针有一些比较通用的建议,比如:不要将探针放置在物体内部,不要将所有探针放置在一个平面上,在稀疏空间上可以少布置探针,光照变化丰富的地方最好多布置探针等等。但是不论如何,面对超大地图,最好还是采用脚本布设+手工后期调整的方式比较靠谱。这里推荐一些工具供大家参考:
2.3 烘焙 x N
DEMO场景比较简单,所以直接手K了一组Light Probe Group,我们以烘焙9宫格左下角子场景为例,摆放好探针的效果如图
可以看到,我们的探针覆盖了这块子场景的全部区域还有余量,这很好理解,因为未来在运行时我们会按照角色是否踏入子场景地块边界来决定替换新旧探针组,留一些余量可以减少这种探针切换时带来的可能的环境光跳变,同时也为我们做“延后切换”提供了数据保障。
在按下Generate Lighting按钮前,我们还有一件非常可以做的事情:失活掉当前光照探针没有触及到的场景,如下图所示:
可以想象,这些被unload的场景对当前光照探针的贡献微乎其微,将其暂时卸载以提高光照烘焙的速度,降低烘焙时的内存开销,才能使得大场景烘焙成为可能。只不过这样的单场景烘焙要执行N次,需要反复加载和卸载一些场景,所以建议工程化后用脚本替代这些手操比较好。
2.4 导出lightProbes.Asset
当每烘焙完成一个子场景,都要即时从LightmapSettings中导出并保存探针数据:
AssetDatabase.CreateAsset(Instantiate(LightmapSettings.lightProbes), defaultPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
导出后保存为Asset资源,以地块编号辅助命名:
这些保存的Asset资源分别记录了每次光照烘焙得到的探针数据,可以用记事本打开查看其中数据:
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!258 &25800000
LightProbes:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: lightProbe_0
m_Data:
m_Tetrahedralization:
m_Tetrahedra:
- indices[0]: 47
indices[1]: 8
indices[2]: 19
indices[3]: 17
neighbors[0]: 114
neighbors[1]: 93
neighbors[2]: 148
neighbors[3]: 132
matrix:
e00: 0.07278019
e01: 0
e02: 0
e03: 0
e10: -0
e11: -0
e12: -0.07358351
e13: 0
e20: -0.0727802
e21: -0.16666667
e22: -0
e23: 0
...
m_ProbeSets:
- m_Hash:
serializedVersion: 2
Hash: a59bcf93405ea129891b07a70413d3af
m_Offset: 0
m_Size: 72
m_Positions:
- {x: -17.24, y: 8, z: -46.59}
- {x: -17.24, y: 8, z: -16.67}
- {x: -17.24, y: 2, z: -46.59}
- {x: -17.24, y: 2, z: -16.67}
- {x: -17.24, y: 8, z: -27}
...
m_BakedCoefficients:
- sh[ 0]: 0.2021866
sh[ 1]: -0.027633084
sh[ 2]: 0.0001353545
sh[ 3]: 0.014740911
sh[ 4]: -0.004248155
sh[ 5]: -0.0082279835
sh[ 6]: 0.010135704
sh[ 7]: -0.015571148
sh[ 8]: 0.04651142
sh[ 9]: 0.27547473
sh[10]: -0.02017048
sh[11]: -0.016495945
sh[12]: 0.062124733
sh[13]: -0.025204908
sh[14]: -0.0073637315
sh[15]: 0.010615408
sh[16]: -0.06704397
sh[17]: 0.05697039
sh[18]: 0.31635872
sh[19]: 0.05582881
sh[20]: -0.00720126
sh[21]: 0.009610832
sh[22]: 0.008846933
sh[23]: -0.034047075
sh[24]: 0.018298429
sh[25]: 0.002981733
sh[26]: 0.035521053
...
m_BakedLightOcclusion:
- m_ProbeOcclusionLightIndex: ffffffffffffffffffffffffffffffff
m_Occlusion:
- 0
- 0
- 0
- 0
...
除去头部的基础信息外,数据主要分为4个部分:
- 第一部分m_Tetrahedralization记录了四面体网络,以便运行时快速定位热点区域;
- 第二部分是m_Positions,自然是存放每个Probe的空间位置信息;
- 第三部分是m_BakedCoefficients,既存放了我们通常认识中LightProbe应该存放的球谐系数,每个共3*9=27个浮点数值;
- 最后一部分叫m_BakedLightOcclusion,存放的可能是一些遮挡关系,方便Unity做快速剔除用(待考)。
3.1 分地块(Tile)动态加载
我们按照地块(Tile)生成LightProbes资源,在运行时则依据角色/摄像机当前所属地块,动态的加载 -> 替换光照探针资源。具体算法可以参考以前介绍过的9宫格系统,默认当前摄像机处于中心,需要提前异步得加载周围8块地块对应的LightProbes资源,确保当需要切换资源时,资源总是在手边可用。
具体切换操作非常简单,如下:
LightmapSettings.lightProbes = usedLightProbes[curIndex];
只需要将预加载好的对应场景LightProbes对象替换全局对象LightmapSettings的lightProbes变量即可,无需添加或切换场景。
3.2 地块衔接处的处理
细化一下切换LightProbes资源逻辑,既所谓的“延后切换”:为了避免因为角色在地块交界处来回移动,而频繁触发切换操作的弊端,我们规定只有当角色越过了原本地块边界一定距离(gap)后才会触发刷新和切换,只要触发切换时角色所在位置任然被上一个场景的LightProbes覆盖,就不会导致环境色跳变,同时由于增加了gap,角色来回运动时必须要满足 2 x gap 的距离才会再次触发切换,从而降低了频率。
3.3 TOD和探针的明暗变化
Time of Day(简称TOD)要求游戏光照能够随时间变化而变化,以适应不同时间段天光辐照度的要求,例如表现黎明时分的酱紫色或日落时刻的橙红色。为了达到目的,我们首先想到的是由天空盒控制主光源(日光)的光强和颜色,但是这只影响直接光照,间接光照来自预烘焙的光照探针,而烘焙时日光使用了什么强度/颜色,探针中就存储了对应的间接光漫反射信息。
如果不考虑代价,为达到最真实效果,我们需要在每一次日照发生变化时都烘焙一份探针信息,运行时排着队的替换使用。
从另一方面考虑,如果要求代价最小,那么我们可以只烘焙一份探针信息,运行时只简单调整探针光照信息的强度。
最后平衡一下这两种极端情况,我们可以烘焙若干个有代表性的时间点,运行到下一个时间段才触发光照探针的替换,衔接的部分则可以通过球谐系数或者光照强度的调节,尽可能让2套探针的光照信息在替换的时刻保持一致,从而减小视觉上的跳变感。
3.4 LightProbes 强度设置注意点
本节以调节探针光照强度为例,一起来看看Unity为用户暴露出了哪些接口。首先我们可以在:
LightmapSettings.lightProbes.bakedProbes
中获取到一组探针信息队列,队列中的元素是一种叫 SphericalHarmonicsL2 的类,意思是二阶球谐函数,存储了来自L0的1个,来自L1的3个以及来自L2的5个,一共1 + 3 + 5 = 9
组系数,每组系数都需要代表一种RGB色彩,所以系数总合还要再乘以3个通道,最终得到 3 * 9 = 27
个浮点数。 我们在访问类 SphericalHarmonicsL2 时,可以采用如下方法获取到这27个球谐系数:
for (int j = 0; j < 3; j++)
{
for (int k = 0; k < 9; k++)
{
Debug.Log(sh[j, k]);
}
}
特别注意一点,虽然Unity允许我们通过下标的方式直接访问甚至修改这些球谐系数,但是至少在Unity2019版本上,这种修改方式不会在运行时产生任何效果,这与网上的大部分参考示例(详见参考2、参考3)显示的结果不一致!推测原因是Unity在某个版本之后关闭了直接手K系数的通道,取而代之的是一些新的API。
说到API,因为修改光照探针数据主要就是修改SphericalHarmonicsL2,我们再来看看这个类有哪些接口:
public struct SphericalHarmonicsL2 : IEquatable<SphericalHarmonicsL2>
{
public float this[int rgb, int coefficient] { get; set; }
public void AddAmbientLight(Color color);
public void AddDirectionalLight(Vector3 direction, Color color, float intensity);
public void Clear();
public override bool Equals(object other);
public bool Equals(SphericalHarmonicsL2 other);
public void Evaluate(Vector3[] directions, Color[] results);
public override int GetHashCode();
public static SphericalHarmonicsL2 operator +(SphericalHarmonicsL2 lhs, SphericalHarmonicsL2 rhs);
public static SphericalHarmonicsL2 operator *(SphericalHarmonicsL2 lhs, float rhs);
public static SphericalHarmonicsL2 operator *(float lhs, SphericalHarmonicsL2 rhs);
public static bool operator ==(SphericalHarmonicsL2 lhs, SphericalHarmonicsL2 rhs);
public static bool operator !=(SphericalHarmonicsL2 lhs, SphericalHarmonicsL2 rhs);
}
Unity给的官方示例里使用的是 AddAmbientLight 和 AddDirectionalLight 这两个接口,它们分别提供了在运行时动态设置Ambient和DirectionLight这两项参数的能力,但是个人感觉不适合我们预想的应用场景,因为从设置参数到转化为27个系数需要比较复杂的数学运算,这是一处额外的CPU消耗,且我们也无法简单准确地给出每一个探针点的对应环境光和方向光,这些参数本身应当是预计算结果,不应在运行时现场运算,消耗更多的计算资源。
余下比较有意思的是2个算符重载,一个是“*”一个是“+”。
先说星号,经过测试,发现参与运算的浮点数起到了控制探针光强的作用,变化是线性的,乘子为0则全黑,乘子为1则维持本色。
其次是加号,可以猜想是通过某种算法,将参与相加的两组球谐函数系数混淆起来,这点比较有意思,因为我们可以利用这种简单的加法混淆,将一种SH状态渐渐的转化到另一种SH状态,从而完成烘焙数据之间的无缝转换。
为简单起见,这里先用星号算符对lightProbes进行强度控制,代码参考如下:
void Start()
{
...
LightmapSettings.lightProbes = Instantiate(originLightProbes[curIndex]);
}
void Update()
{
...
var bakedProbes = LightmapSettings.lightProbes.bakedProbes;
var origin = originLightProbes[curIndex].bakedProbes;
for (int i = 0; i < bakedProbes.Length; i++)
{
bakedProbes[i] = origin[i] * intensity;
}
LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}
这里需要强调一点,就是任何对bakedProbes的修改,都会影响到Asset资源的数值,所以务必只对实例化后的bakedProbes进行修改,参考Start方法中调用的实例化函数。另外不难发现,在Update方法中 bakedProbes[i] = origin[i] * intensity;
使用的乘法算子已经是被Unity重载过的了。
4.1 优化
上述实践完成后,我们已经有了一套初步的GI方案来适配TOD,但是通过控制探针光强的方法过于简单和缺乏灵活性。举个例子,只控制光强因子的前提下,我们会烘焙一套正午时分的环境光资源,然后设计一条类似正弦曲线,让光强因子在黎明时分从0开始增长,直到正午达到峰值1,然后缓慢落回0,此时正值夜幕降临。这种设计也许能对付下简单的昼夜交替的变化,但是当遇到诸如在夜晚发光萤石或者光源,那么周围的环境光就会穿帮(此时环境光强为0)。一种可行的解决办法是预先烘焙多组探针资源,然后设法在它们之间顺滑地切换,后续我们将基于这个假设来讨论如何优化表现效果。
优化的另一方面是性能开销,在保证效果的前提下,我们会讨论如何降低计算复杂度,降低内存开销等影响帧率的部分。
4.2 效果优化
要实现在两套资源 lightProbesA
和 lightProbesB
间顺滑过度,无论一开始基于哪一个lightProbes,只通过调节其光照强度是无论如何也做不到顺滑切换的,就像你不可能通过调整一把红色手电筒的强度,自然过度到绿色手电筒的效果(前提是切换点两者的光强都不是0)。那么什么方法可以呢?答案是按比例插值。假设我们一开始基于lightProbesA
的数据显示环境光,首先我们想办法预计算两道资源的差异:
delta = lightProbesB - lightProbesA
然后我们还需要一个系数来控制插值的百分比:
intensity = (cur_time - start_time_A) / (start_time_B - start_time_A)
那么最后过渡段某个时刻的环境光插值可以作如下表示:
usedLightProbe = lightProbesA + delta * intensity
这里面使用到的‘+’和‘*’都是Unity重载过的算符
具体到Unity工程中,简化后的计算delta
的代码可以参考如下:
delta = new SphericalHarmonicsL2[probes[0].count];
for (int i = 0; i < probes[0].count; i++)
{
SphericalHarmonicsL2 shA = probes[0].bakedProbes[i];
SphericalHarmonicsL2 shB = probes[1].bakedProbes[i];
SphericalHarmonicsL2 d = new SphericalHarmonicsL2();
for (int j = 0; j < 3; j++)
{
for (int k = 0; k < 9; k++)
{
d[j, k] = shB[j, k] - shA[j, k];
}
}
delta[i] = d;
}
而使用插值修改lightProbes可以参考如下示例:
public void ChangeProbes()
{
float intensity = (Mathf.Sin(Time.time / 2.0f) + 1f) / 2.0f; // 0 ~ 1
var bakedProbes = LightmapSettings.lightProbes.bakedProbes;
var originProbes = probes[0].bakedProbes;
for (int i = 0; i < bakedProbes.Length; i++)
{
bakedProbes[i] = originProbes[i] + delta[i] * intensity;
}
LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}
4.3 分帧更新
之前提及过,修改每个光照探针只涉及到27个浮点数的加法和乘法操作,且由于我们区分了地块,每次只工作在一个相对小规模的光照探针网络中,奈何光照探针的数目很可能任然巨大,如果每一帧都要执行数千上万次乘法操作,所消耗的CPU资源不可小视。 事实上,当采用上一节的ChangeProbes
方法负责刷新探针数据并做profiler后得到下图结果:
可见红色方框内产生了数百次memcpy和算术操作,此外箭头标记处显示有大量内存Alloc,这个后面讨论。
这个问题的解决方案很简单,分帧更新即可,我们每帧不必遍历所有激活的探针节点,而是把存放探针数组的容器定义为一个回环buffer( 或者叫 ring buffer ),每一次只遍历从起始位置开始往后的N个节点,待遍历完成后再重设一下起始位置即可。
public void ChangeProbesPartial()
{
float intensity = (Mathf.Sin(Time.time / 2.0f) + 1f) / 2.0f;
var bakedProbes = LightmapSettings.lightProbes.bakedProbes;
var originProbes = probes[0].bakedProbes;
var totalSize = probes[0].count;
int ct = MaxNumberPerFrame;
int i = startIndex;
while (ct-- > 0)
{
bakedProbes[i] = originProbes[i] + delta[i] * intensity;
i = ++i % totalSize;
}
startIndex = i;
LightmapSettings.lightProbes.bakedProbes = bakedProbes;
}
这样修改后,更新频率就变成一个可控的参数N。通过Profiler查看结果如下图:
可见红框中的调用次数下降了一个数量级,CPU资源消耗几乎归零,且显示效果丝毫没有影响。
4.4 内存优化
参考上图中箭头处的内存消耗(主要来自Alloc)不难发现Unity底层对LightProbes的get_bakedProbes()
操作会产生值拷贝,具体到Unity工程代码参考如下:
var bakedProbes = LightmapSettings.lightProbes.bakedProbes; //触发get操作
var originProbes = probes[0].bakedProbes; //也会触发get操作
为了缓解频繁(每帧)拷贝bakedProbes这种蠢事,我尝试了一种更加懒惰的更新策略,代码如下:
public void ChangeProbesPartialLazy()
{
float intensity = (Mathf.Sin(Time.time / 3.0f) + 1f) / 2.0f;
if (startIndex < MaxNumberPerFrame)
{
workOn = LightmapSettings.lightProbes.bakedProbes;
}
var totalSize = probes[0].count;
int ct = MaxNumberPerFrame;
int i = startIndex;
while (ct-- > 0)
{
workOn[i] = origin[i] + delta[i] * intensity;
i = ++i % totalSize;
}
startIndex = i;
if (startIndex < MaxNumberPerFrame)
{
LightmapSettings.lightProbes.bakedProbes = workOn;
}
}
简述下逻辑,我们只在完成一次循环后(完整遍历了lightProbes队列)才设置 + 获取一次Unity托管的bakedProbes资源。整个过程就像打快照一样,一旦得到快照,在接下来的几帧或者数十帧内都是基于当前快照内容进行修改,等到快照完成更新后再一次性提交给Unity用来刷新显示。对于ChangeProbesPartialLazy
再次Profiler后得到下图结论:
可以看到,大多数帧内来自方法内部的Alloc消失了,并且实际表现上仍然看不出区别:
5.1 测试数据
说明:表格展示了随着烘焙探针数量的倍增,其资源大小,系统耗时的成长关系。
以下所有数据取自 WIN7系统 Core I7-6700 @ 3.7G 兼容机平台 (X2 ~ 2.5 Snapdragon 845 @2018年旗舰)
项目\探针数 | 72 | 144 | 288 | 576 | 1152 | 2304 | 4608 |
---|---|---|---|---|---|---|---|
Size(KB) | 203 | 428 | 896 | 1836 | 3719 | 8053 | 17084 |
Instantiate Cost (ms) | 0.08 | 0.15 | 0.25 | 0.50 | 1.07 | 3.00 | 5.59 |
Set_Probes Cost (ms) | 0.0034 | 0.0047 | 0.0069 | 0.0148 | 0.0298 | 0.0533 | 0.1405 |