【UE】Unreal Engine中的间接光方案

最近在整理UE的光照系统,其中有一块是间接光方案,经过查阅资料了解到UE存在多种实现对间接光模拟的技术方案,如Lightmap、Indirect Light Cache(ILC)、Volumetric Lightmap(VLM)、Light Propagation Volumes(LPV)以及各类后处理方案(SSGI、DFAO、SSAO,这部分由于效果十分有限,通常用作辅助,本文就不做讨论了)等,关于这些技术方案,很容易就想到如下的一些问题:

  1. 这些方案背后的技术原理是什么?
  • 这个问题在后面的正文中有叙述,这里就不做回答了
  1. 为什么同样一个问题,会涌现出这么多不同的方案?
  • 这是因为不同的方案适用于不同的应用场景,不同的应用场景有不同的需求。
  1. 这些方案之间的优劣对比是如何的,各自具有如何的适用场景?
  • lightmap方案通过额外的内存、包体消耗来避免运行时的计算消耗,对于静态物体而言具有较好的效果,但是对于动态物体而言,就无法得到高质量的间接光与间接阴影效果
  • ILC/VLM通过离线烘焙的方式得到一系列预生成的数据,这些数据可以用于动态物体的间接光计算,其不足在于精度不能太高,否则内存扛不住,其次则是probe的覆盖范围也不能过大,否则内存消耗依然扛不住,因此在效果上会有局限。
  • LPV方案则是完全运行时实时计算的间接光方案,通过较低的消耗实现任意位置的间接光效果,干掉了预处理的繁琐流程,但是其不足在于运行时的计算消耗依然难以做到十分通用,尤其是对目前主流的移动平台而言。

为了解答上述问题,这里单开一个篇章用做对相关技术学习后的总结与思考对比。

UE的光源对场景的照亮主要是通过两种方式,分别是直接光照明(实时动态计算)与Lightmass烘焙照明(离线烘焙,运行时使用)。

Lightmass烘焙照明包含了如下的三种应用策略,分别是:

  1. 应用于静态(Static)物体跟静态或者非移动(Static or Stationary)光源的lightmap,由于Stationary光源在运行时会有可能发生光强与光照颜色的变化,而lightmap数据是离线烘焙出来的,因此无法在运行时变化,因此这里动态变化后的光源其lightmap效果可能会跟光源数据对不上
  2. 能够cover住动态物体的ILC/VLM方案,由于lightmap是针对与静态物体的,当物件变化(位置、朝向、缩放、形变等)后,数据将不再可用,为了给动态物体(尤其是角色)加上间接光,需要额外的实现方案,这里指的就是ILC/VLM。

UE的Lightmass对各个点间接光计算使用的算法是photon mapping,这个算法的原理可以参考此前的学习总结Photon Mapping算法学习总结

UE中的间接光方案可以分成静态跟动态两类,静态间接光指的是作用于静态物件与静态光源的间接光效果,此类方案对于动态物体不生效,主要有lightmap方案;动态间接光方案指的是对动态物体也能起作用的间接光方案,有ILC、VLM跟LPV等几种方案。

1. 静态间接光方案

1.0 Lightmass间接光效果

Lightmass能够提供众多的间接光效果,下面逐一进行介绍。

1.0.1 Color Bleeding

也称为Diffuse Interreflection,即打在相邻物体上的光线反射到当前物体上会带有相邻物体的颜色的一种效果,这是间接光最常见的一种效果。

1.0.2 character lighting

指的是动态物体身上感受到的间接光效果,通过ILC或者VLM实现。但是目前有着一些不足之处:

  1. sample自动摆放的默认配置会导致一个大场景中摆放了大量的probe,从而导致插值时间消耗过高
  2. Lightmass Importance Volume之外的区域动态物件将感受不到GI效果

1.0.3 Ambient Occlusion

也叫Indirect Shadow,现在的效果有如下的一些问题:

  1. 需要较高分辨率才能出效果:因为边角位置的AO变化是高频的
  2. 因为AO需要较高密度的采样,而preview模式的采样点通常比较稀疏,因此看到的AO效果跟实际效果相去甚远

1.0.4 Masked shadows

实现Alpha Test物件的真实阴影,如下图所示:

实际的面片
烘焙后的效果

1.0.5 Stationary Lights独有的一些效果

1.0.5.1 Bent Normal sky occlusion

1.0.5.2 Distance field shadowmaps

1.0.6 Static Lights独有的一些效果

1.0.6.1 Area lights and shadows

lightmass中的所有static光源都是被看成面光源的:

  1. 点光跟聚光灯的形状是一个球,半径通过Light Source Radius调整;
  2. 方向光的形状是一个圆盘

影响阴影效果的两个参数

  1. 光源尺寸
  1. 到occluder的距离

1.0.6.2 Translucent shadows

影响参数主要有两个:

  1. 材质:不同的照明模式,计算逻辑不同:
        Lit材质
            混合模式
                BLEND_Translucent and BLEND_Additive
                    Transmission = Lerp(White, BaseColor, Opacity)
                BLEND_Modulate
                    Transmission = BaseColor
        Unlit材质
            混合模式
                BLEND_Translucent and BLEND_Additive
                    Transmission = Lerp(White, Emissive, Opacity)
                BLEND_Modulate
                    Transmission = Emissive
  1. 光源,参数为Light Source Angle,尺寸会影响阴影的锐利程度,下面给出不同的数值下的效果:
0
5

现有效果有如下几点不足:

  1. Translucent Materials由于不参与light scatter,因此不会对周边物体产生color bleeding
  2. 不支持折射(焦散)
  3. 半透材质不会对间接光计算中的第一次反弹光线造成影响

1.0.7 使用tips

tips主要涵盖降低构建时间与提升构建质量两项展开:

1.0.7.1 降低构建时间

Lighting Build Info弹窗面板可以看到各个物件的构建时长,主要有如下几点使用策略:

  1. 只为高频光影区域分配高分辨率lightmap,不可见区域、未暴露在直接光照的区域应该分配低分辨率lightmap
  2. 减少需要烘焙的物件数目,Lightmass Importance Volume将重点区域括起来
  3. 降低光照环境复杂度,减少光源数目,缩小光照影响范围
  4. 调整lightmap分辨率以使得场景中每个物件的build时间是相近的,并行构建,木桶原理?
  5. 结构比较复杂的物件(比如带有较多自遮挡)的构建时间会更长

1.0.7.2 提升构建质量

提升质量有两方面的使用策略:

  1. 突出光照效果
    1.1 对于diffuse贴图而言,通过调整basecolor,使之在光照下有较大的变化,比如下面给出了高低对比度下的构建表现,明显高对比度质量更佳:
高对比
高对比-unlit
低对比
低对比-unlit

可以看到,只有地板位置的高对比效果明显。通过unlit模式可以很好的分析diffuse是否满足条件,unlit模式看起来比较平(flat)且色彩单调的,通常比较好,因为所有的颜色变化都是由光影控制。

1.2 对于光照配置而言

  • 避免对全场景进行统一调整的环境光,因为环境光会降低对比
  • 增强直接光跟间接光区域的对比度
  • 避免过曝跟过暗
  1. 增强光照质量
  • 提升lightmap分辨率,这个会增加烘焙时间与内存消耗,可以考虑将lightmap分辨率分配给最需要的地方
  • 调整solver,跟lighting quality绑定,production level效果最好

1.1 Lightmap方案

什么是lightmap?

Lightmap技术最早是由John Carmack在Quake(雷神之锤)中提出的,目的是为了通过软光栅解决此前顶点光照 Gouraud shading方案中光照效果过于粗糙的问题,后来随着硬件进步,逐渐能够支持多贴图采样时,Lightmap则演变成了在离线的时候将光照结果缓存下来,运行时直接取用的方式来避免间接光照计算消耗过高的问题,从而可以实现实时性能下的间接光效果。

Lightmap方案的技术细节是什么样的呢?

我们知道物件的颜色通常是通过顶点色以及texture来表示,其中texture表示的颜色是通过从顶点上取得uv坐标经过插值之后传递到PS,在PS中使用插值后的UV对此贴图进行采样而得到。既然贴图可以存储颜色,而光源打在物件上某点反射出来的光照同样是可以通过RGB来存储的,那么是否可以考虑将这个结果在离线的时候计算完成,并将之存储在贴图中,运行时直接通过对应的uv对贴图进行采样实现相应的光照效果呢?实际上,这就是Lightmap的大致原理,不过在实际实现的时候可能会因为各种细节的不同,使得最终的Lightmap的解释方式也有所不同。

最简单的Lightmap中存储的就是光照打在某个物件上经过物件交互后的反射光强,在运行时只需要在PS中通过uv对Lightmap进行采样即可。这里有些细节需要做一下定性的解释:

  1. 首先,存储在Lightmap中的反射光强由于是在离线的时候计算的,因此可以采用各种高端科技进行计算,以实现光照效果的最优,这里的结果可以是直接光也可以包含间接光,通常来说,不管光照计算方式多复杂,反射次数有多高,其存储的空间尺寸与运行时采样的消耗是不变的,因此出于性价比考虑,这里通常会同时将直接光照与间接光照都考虑进去。
  2. 其次,这里存储的光照是针对静态物体的,且输入光源也理应是维持不变的,不然光照效果将会需要重新计算
  3. 第三,这里对Lightmap采样所使用的UV跟物件颜色贴图采样所使用的UV是不同的,这也就是我们所说的2UV;2UV可以通过软件自动生成(虽然效果可能不太好),也可以通过其他建模软件(比如3ds Max)实现手动配置。
  4. 最后,同一个场景中摆放在不同位置的同一个模型,因为位置不同,受到的光照表现也是不同的,因此其Lightmap数据自然也是不同的,但是同一个物件中存储的2UV是相同的,这也就意味着,如果将这两个物件实例的Lightmap数据存储在一张Lightmap上,为了保证两者采样到的数据都是正确的,就需要一个额外的机制来将两者的采样区域错开,最常见的方式就是为每个物件实例增加一个Lightmap Struct,这个Struct会包含此实例对应的Lightmap的Index,同一个Lightmap中通常会存储多个不同的物件实例的数据,因此这个Struct中还需要指定对应物件实例的Lightmap数据在对应Lightmap上的Texture Offset以及Texture Scale,如此才能为不同的物件实例映射到不同的Lightmap区域上。

UE中的Lightmap是如何编码的?

关于UE中的Lightmap编码部分,可以参考此前整理的【UE】Unreal Lightmap编码方案,这里就不赘述了。

Lightmap有如下的一些优缺点:
优点:

  1. 可以以较低的计算消耗得到场景中绝大部分物件的间接光效果

缺点:

  1. 间接光表现取决于Lightmap的分辨率,而Lightmap分辨率过高会导致内存占用上升、包体增加、带宽消耗增加等问题;Lightmap分辨率过低则会导致效果存在异常反而导致光照质量的下降。
  2. 增加了一次额外的采样消耗,Lightmap的使用也会导致内存包体带宽的消耗增加等。
  3. 只能用于静态物体+静态光源的应用场景,光源与物件其中任一发生了变化(位移、形变、变色等)都会导致烘焙缓存的光照效果失效

此外需要注意的是,Lightmap的生成范围也是受LightmassImportanceVolume影响的,只有处于这个范围内的物件才会生成对应的lightmap(这是因为Lightmass的计算逻辑是不变的,而Lightmap跟后面ILC/VLM都是同一套算法计算得到的,因此并无二致)

2. 动态间接光方案

动态间接光方案分为离线烘焙的方案与实时计算的方案,离线烘焙方案包含ILC跟VLM两种,前者是UE4.18及之前版本所使用的方案,后者则是4.18之后版本使用的方案(在4.18以后的版本中ILC也是存在的,只是默认关闭的);实时计算方案则主要是LPV方案。

离线烘焙方案中,主要是在场景中合适的位置放置若干个probe(这些probe同样也是Lightmass用于烘焙Lightmap所使用的probe,只不过将数据保存下来以实现动态物体在运行时的光照计算,理论上这些probe存储的数据静态物体也是可以使用的,不过lightmap存储的数据精度更高,计算消耗更低,因此通常静态物件不走这个光照计算逻辑),每个probe存储的是使用SH编码保存的光照信息,这些数据是在离线计算通过PRT算法采集各个probe位置处的光照信息得到的,之后在运行时通过插值得到对应物件乃至对应像素位置的光照信息(依然使用SH编码表达),通过SH系数的点乘求得对应法线位置的输出Radiance结果。

离线烘焙方案的效果给出如下:

on
off

2.1 Indirect Light Cache(ILC)方案

什么是ILC?这个术语包含两重意思,分别是indirect light跟cache,前者表明这个方案是用于计算间接光的,每个probe中存储的是对应位置向着各个方向输出的Radiance信息。Cache则意味着数据是带有缓存的,通常计算场景中各个位置的probe的Radiance数据需要较高的消耗,但是如果将这些结算得到的数据缓存下来,只有在失效(比如物件发生变化或者光照发生变化的时候)时才进行重新计算,就可以降低烘焙的时间消耗,提升开发效率,这就是Cache的意义。

2.1.1 烘焙过程

具体而言,ILC只在Static物体上方(只要朝上即可,而非要求法线与Z轴平行)surface摆放高密度Probe(之所以设定为表面上方,是因为UE这边考虑为角色可行走区域提供一些更为优秀的光照效果,当然不排除角色有攀爬可能,但是这些都是小众需求了,在表现与消耗之间做的一个不那么十全十美的平衡),其他surface地方摆放低密度Probe,这个过程是自动完成的,摆放范围可以通过LightmassImportanceVolume控制,在编辑器中可以通过Show->Visualize->Volume Lighting Samples查看场景中摆放的probe数据。

关于probe生成位置(可以参考FStaticLightingSystem::BeginCalculateVolumeSamples函数),还有一些优化逻辑[11]:

对于退化的三角形或Lightmap精度小于给定阈值的表面,则该三角形不生成ILC

对于给定的采样点,如果它周围(采样方向为球体而不只是正半球)有超过30%的采样点是物体的背面,则该采样点不生成ILC

在UE4.25之前,ILC数据生成在PersistentLevel里,这样ILC在关卡被加载之初,就一直常驻于内存中。这对于小型地图来说无伤大雅,但当游戏需要更大的世界,更大的地图的时候,它会同时带来内存的巨大增长和ILC查询插值性能的降低。所以4.25之后ILC数据被分割到各个StreamingLevel中,可以配合WorldComposition和LevelStreaming对ILC数据进行动态加载和卸载。

- 对于物体表面附近那些ILC来说,它们会被放置到该物体所在的StreamingLevel中
- 对于CharacterImportanceVolume所产生的ILC来说,Lightmass会从采样点世界空间向下打一条射线,返回第一个被接触到的物体所在的StreamingLevel中
- 稀疏的ILC放置点会存在于当前的PersistentLevel中

在一些情况下,自动摆放的probe可能无法提供需要的光照精度,比如在一个房间中靠近天花板的区域(因为默认只在房间地面上方一段距离内放置了probe),这个时候可以手动摆放一个LightmassCharacterIndirectDetailVolume来提升对应区域的probe密度,其实就是相当于在volume中添加额外的probe,如下图所示:

Lightmass Importance Volume
Lightmass Character Indirect Detail Volume

LightmassImportanceVolume除了控制着probe的摆放范围之外,还有其他作用:在photon mapping算法中,发射的光子在LightmassImportanceVolume内部会进行多次反射,在其外部则只会进行一次反射,而如果不放置LightmassImportanceVolume,会对全场景进行间接光采样,从而导致效率低下。

probe放置完成后,就需要计算每个probe上通过3阶SH系数存储的Radiance数据,这个部分可以在FStaticLightingSystem::CalculateVolumeSampleIncidentRadiance函数中找到,基本流程给出如下[11]:

计算每个probe上下半球潜在的贡献光子的方向,存储以用于真正的光照计算
计算probe可以接受到的直接光能量的SH参数
计算probe上半球间接光照入射光能量的SH参数(FinalGather,AdaptiveSample加速)
计算probe下半球间接光照入射光能量的SH参数(FinalGather,AdaptiveSample加速)
叠加2,3,4步的SH参数,生成最终的高质量和低质量ILC(高质量ILC包含的光照数据更少,低质量ILC包含的光照数据更多,因为光质量ILC没有包含的部分都是实时计算的,这是高质量一词的注解)
在计算潜在有贡献的光子时,使用的是Volumetric PhotonSegementMapping算法,这个算法中的PhotonSegment跟附着在物体表面(photon与漫反射碰撞点)的传统photon不同——它们是传统photon在碰撞后的反射路径上均匀分布的点,包含了当前点的方向和光照值所生成的能量记录,在Lightmass中被叫做PhotonSegement。想象一下光线在空间以直线传播,被PhotonSegement均分为一段一段的小线段,所图所示:

烘焙完成后,属于某个物件的probe最终会存储在一张全局的volume texture中,这个volume texture是一个atlas,每个物件分配的probe数目是可以调整的:

通过SurfaceLightSampleSpacing可以控制上表面的probe之间的间距,通过FirstSurfaceSampleLayerHeight可以调整上表面第一层的probe之间的间距,而SurfaceSampleLayerHeightSpacing可以调整每层probe之间的间距,NumSurfaceSampleLayers控制上表面probe层数,DetailVolumeSampleSpacing控制Character Indirect Detail Volume内的ILC采样点距离,而VolumeLightSampleSpacing控制既不是物体表面,又不是Character Indirect Detial Volume内的其它地方ILC采样距离,这个参数是对VLM生效的,ILC没有这类的probe。(距离单位为cm)

每个物件移动或者输入光照变化,Cache就失效了,需要对物件所对应的probes进行更新。

烘焙数据是存储在Level/StreamingLevel的MapBuildData中的,这个文件除了ILC/VLM数据之外,还包含2D的Lightmap数据、IBL数据、NavMesh数据以及Shadow Mask数据等。

probe数据在场景中是按照八叉树组织的,这样有利于实现快速的查找,单个probe的存储结构为FRPrecomputedLightVolumeData。这个结构的加载时机跟ULevel是一致的,即当Level加载时,就会同步完成对应的probe数据的加载(合理)。

2.1.2 运行时采样

运行时,每个被设置为movable的物体上的光照会在直接光照上叠加上对probe进行插值得来的SH系数求取的间接光,这个插值是针对整个(movable)物体而言的,即每个物体只进行一次插值(per-instance),插值的输入是物件周边的probe,插值的结果会传入到PS中用于计算光照。下面这个torus周边就有5x5x5个插值点:

通常来说,插值过程不是每帧都进行的,而是只有当物体移动幅度达到一定程度,使得之前的插值数据失效,才会重新进行一轮插值,这也是这个方案叫做Caching的另一重原因。ILC更新逻辑给出如下:

其中ILC Octree的遍历与插值是一个高频操作,因此UE也提供了性能更优的多线程版本。此外,SH表达常见的一个问题就是,输入光是非负数值,但是经过SH投影与重建后会得到负值,这个表现叫做Ringing异常(详情可以参考【GDC2008】Stupid Spherical Harmonics (SH) Tricks),也就是上图所说的振铃现象,这是因为高频信息缺失导致的震荡,常用的做法是在频域中使用窗口函数过滤掉高频信息来得到较好的结果,UE中没有采用这种做法,而是在SH上添加上一个额外的数值来抬高重建后信号的下限,这个数值是在OppsiteLighting的基础上添加一个小幅的(5%)额外振幅求得的。

ILC方案的问题有:物件周边才存在间接光效果,在距离物件较远区域,动态物体将失去此效果;因为不同物件上的probe密度是不同的,因此多个物件衔接处效果可能会不够平滑。

除此之外,还拷贝了jiff[11]中关于ILC的相关陈述,仅做参考:

ILC的不足之处与优化方案:

  1. 最严重的问题是漏光/漏阴影
  • 针对漏光问题,可以考虑在会产生漏光的区域(如薄壁处)增加额外的probe并使用阴影检测手段(通过判断此处是否是属于阴影中并添加一个clamp)来规避,也可以在离线生成ILC采样点的时候做文章(可参考FarCry3),对检测到光照断层的区域降低ILC probe的作用半径。
  1. 不支持场景的动态破坏,不支持TOD
  2. 对立体空间支持不佳,尤其是对于空中飞行的游戏或跳伞类游戏来说
  • 对室外远离地面的空间,可以直接使用全局的SKY SH全类似于PostProcess Volume方式计算GI,只有在近地面或光线变化敏感的区域生成较密集的ILC采样点
  1. 对于基于静态实例(ISM)和分层的静态实例(HISM,如植被)的支持不好,因为ILC是基于Component绑定,这就意味着所有的Instance会共享一份ILC SH,对于一定较大范围内合并的ISM或HISM,其结果会不尽人意
  • 对于画质要求高的游戏来说,ISM/HISM可以考虑逐Instance生成ILC而不是全部共用(或者使用后面的VLM逐像素插值SH)
  1. 对使用ILC的物体体积有较严格要求,比如一个大房子肯定是不能使用一个ILC来表达GI的,甚至一辆小卡车使用单ILC来表达也不足以表现其GI变化的频率
  • 对于画质要求高的游戏来说,中型物体需要使用ILC,也可以为一个物体生成多个ILC布点(类似COD)

其他优化方案:

  • 对内存紧张的情形来说,在稍微损失一些效果的前提下,ILC的SH参数总是可以压缩为2阶的,从32位浮点数压缩为16位半精度
  • 注意到ILC SH光照是在逐像素计算的,而对于没有Normalmap的模型来说,完全等价于在VS里计算ILC光照。

2.2 Volumetric Lightmap(VLM)方案

跟ILC不同,VLM除了在Static物体上方会摆放probe之外,在空间中也会摆放Probe,这样就避免了使用LightmassCharacterIndirectDetailVolume来处理无静态物件区域中动态物件的间接光效果异常的问题,相当于扩大了probe的范围,从而增加了动态物体被间接光覆盖的范围,此外需要补充一下,相对于一些实时方案中光照使用2阶球谐来表示(LPV),ILC跟VLM中存储的SH系数是三阶的共9个参数。

在放置了LightmassImportanceVolume的地方,会生成均匀分布的4x4x4个probe(这个数值可以通过 Volumetric Lightmap Detail Cell Size 进行调整),而在static物件周围会提高probe的密度(具体的密度提升逻辑估计跟前面ILC中设置Probe之间的间距逻辑实行同的,这里的一个问题是,静态物件为什么不能使用probe的数据,而需要lightmap呢?实际上,在UE4.25之后,已经可以通过将静态物体的Lightmap Type设为Force Volumetric来使用ILC/VLM数据,从而避免为其生成占据包体以及内存的lightmap数据):

此外,光照插值基于渲染物体的像素(即每个像素的光照SH系数是不同的)进行,跟ILC基于probe插值(即每个物件会插值出一个对应于物件所在位置的probe,之后使用这个probe的数据进行所有像素的着色)不同,这就是为什么VLM可以实现fog光照的原因,也可以用于解决当物件较大的时候,ILC得到的光照效果会周边lightmap计算得到的光照效果存在较大跳变的问题。

VLM的fog照明

由于VLM是逐像素插值的,且在静态物体周边的probe数目更为密集(确定吗?),因此其最终的效果也更为优秀:

VLM的间接光效果更为优秀

相对于ILC而言,VLM有如下的一些不足:

  1. 内存消耗增加了

ILC跟VLM的问题有:

  1. 动态角色站在墙体后面,如果墙体较薄,可能会存在漏光,这是因为采样到了墙外的probe数据导致,解决方案是,提升Probe密度或者增加墙体厚度

2.3 Light Propagation Volumes(LPV)方案

LPV是一种用于实时渲染场景中的间接光计算方案,这个方案中:

  1. 首先会将光源打在场景中的光照数据用RSM(Reflection Shadow Map)来表示
  2. 之后将RSM的每个像素看成是一个新的光源VPL(Virtual Point Light),并将每个VPL根据其法线转换成二阶的SH系数求得其Radiance数据,并将这个数据塞入到覆盖全场景的Light Propagation Volume的对应Cell中
  3. 通过相邻Cell的光照传播扩散,完成整个Light Propagation Volume Cell的数据填充,这个过程也是间接光二次反射的过程
  4. 将计算好的LPV用于光照,得到间接光效果。

具体实现细节可以参考【Siggraph2009】Light Propagation Volumes in CryEngine 3

3. Actor移动类型(Mobility)

移动类型主要作用于两种Actor,分别是StaticMesh Actor跟Light Actor,目的是用于在运行时使用不同的策略来完成光照的计算,类型主要有三种,分别是Static,Stationary以及Movable:
Static表示的是Actor是完全不变的,包括位置以及其他;
Stationary表示的是位置是不变的,其他数据可能会发生变化,对于光源而言,其表示的是光源的位置不会发生变化,但是其颜色与光强在运行时却是可能发生变化的;
Movable表示的是Actor是可能会发生变化的,包括位置以及光源的光强等参数。

3.1 StaticMesh Mobility

对于StaticMesh Actor而言,不同的移动类型的光照计算方式的区别如下:

  • Static:启动lightmass光照构建时,在满足条件的情况下(处于LightmassImportanceVolume中),会为这类Mesh构建其Lightmap数据(ILC/VLM同样也会有),且这种物件本身也会参与到Lightmass计算中,产生对其他物件的投影。此外,需要注意的是,这类物件的材质是可以在运行时发生变化的。
  • Stationary:虽然这种类型的Mesh数据不会移动,但是可能会发生其他的变化(不会导致阴影变化的参数变更等),因此lightmass不会为这类物件构建对应的Lightmap,也不会参与到其他物体的lightmap的计算中,其对场景的光影的影响是动态计算的,跟Movable物件一样,不同的是,这类物件生成的投影是可以在Shadow Cache中绘制到静态阴影贴图中的,而不需要每帧更新;
  • Movable:同上,这种位置都会发生变化的物件,在Lightmass中更加不会参与Lightmap构建,不过跟Stationary物体不同的是,这类物件的阴影不能Cache住,需要每帧更新。虽然物件是动态的,但是依然可以使用Static光源烘焙的光场数据(ILC/VLM)来计算间接光,而如果是Stationary或者Movable光源,就只能动态计算直接光照了。

3.2 LightSource Mobility

对于光源Actor而言,不同的移动类型对光照的影响给出如下:

  • Static:这类光源会参与到Lightmass的计算之中,会对Lightmap以及ILC/VLM等数据产生影响。生成的数据会对Static模型(Lightmap、ILC/VLM)、Stationary以及Movable(ILC/VLM)等产生照明效果。
  • Stationary:这类光源的位置不会动态变化,但是其光源强度与颜色却是可能会变化的,虽然如此,这类光源依然可以用于Lightmass光照烘焙,以计算Static Mesh的阴影效果,需要注意的是,烘焙的间接光效果使用的数据是烘焙时那一刻的数据,在运行时动态变化后,其间接光效果因为是预计算的,因此并不会跟着变化,此外这类光源对Movable/Stationary物件的阴影计算是动态完成的。Stationary光源的直接光照是运行时动态计算的,因此可以随着光强与颜色的变化而变化,而其直接投影则是在离线的时候通过Distance Field Shadow计算的到,会为Static物件生成Distance Field的Shadowmap,这种Shadowmap即使在较低分辨率情况下,也可以得到较为精确的阴影效果,且由于不再需要进行投影计算,运行时消耗非常低。

Stationary的Shadowmap跟Lightmap是分开存储的,如下图所示:

Stationary Lightmap
Stationary Shadowmap

可以看到Shadowmap这里是将四个通道合并到一起使用的,前面两个通道存储的是6个shadowmap(cubemap,对应点光阴影),第三个通道存储的是方向光的Shadowmap,最后一个通道空置,效果如下图所示:

Shadowmap阴影

Distance Field Shadowmap是怎么存储的呢,跟普通的Shadowmap有什么不一样,具体采样逻辑是怎么样的?经过截帧分析发现,Distance Field Shadowmap是已经计算好的Shadow Factor的map而非从光源角度出发的物件像素深度map,也就是说是一张普通的2D贴图,之所以叫Distance Field Shadowmap大概是因为通过Distance Field完成计算的,其采样逻辑给出如下:

        float2 ShadowMapCoordinate;
        uint LightmapDataIndex;
        GetShadowMapCoordinate(Interpolants, ShadowMapCoordinate, LightmapDataIndex);


        float4  DistanceField = Texture2DSample(LightmapResourceCluster_StaticShadowTexture,  LightmapResourceCluster_LightMapSampler , ShadowMapCoordinate);


        float4 InvUniformPenumbraSizes = GetLightmapData(LightmapDataIndex).InvUniformPenumbraSizes;
        float4 DistanceFieldBias = -.5f * InvUniformPenumbraSizes + .5f;


        float4  ShadowFactors = saturate(DistanceField * InvUniformPenumbraSizes + DistanceFieldBias);

Stationary Light经过Lightmass烘焙的间接光以及间接光阴影是存储在Lightmap中的,而烘焙的直接光阴影则是存储在Shadowmap中的,为啥不像Static Light烘焙结果一样,将Shadowmap直接整合到Lightmap中呢?关于这一点,目前没有太多信息,推测是因为Lightmap分辨率较低,无法保证较高的阴影效果

Static光源烘焙的数据是不包含Shadowmap的,其阴影效果是直接烘焙到Lightmap中,因此在分辨率较低的时候质量较差,如下图所示:

Lightmap阴影
Lightmap中的阴影部分

Shadowmap是怎么存储的呢,其读取方式应该跟Lightmap不同吗?这段逻辑是放在shader中的GetPrecomputedShadowMasks函数中的,从读取方式可以看到,Shadowmap的读取uv跟Lightmap是完全相同的,且其存储的就是可以直接拿来用的阴影效果,在运行时做了一些处理以得到更好的效果,这里的一个新的疑问是,这个处理为什么不直接在离线完成并编码到Shadowmap中?推测可能是为了保证存储的shadowfactor具有较高的精度,因为浮点数越靠近原点,精度越高。

Distance Field Shadowmap存储的是ShadowFactor,也就是说是跟物件表面绑定的,那么如何用到场景中无Lightmap烘焙的半透物体上呢?这是因为除了为每个物件计算的与Lightmap等价的Shadowmap之外,还有一张全局的Static Shadowmap(也就是用于静态无条件向动态物件投影的Per-Object Shadowmap,下面会有更多介绍),这张Shadowmap存储的是静态光源下静态物件的光源视角的Depth数据,从而省去在运行时绘制阴影的消耗(虽然是运行时动态创建的,但是不必每帧更新),而场景中的动态物件(比如透明物体)只需要在运行时将像素坐标转换到光源视角并与shadowmap上对应位置的深度值进行比较就能知道当前点是否处于阴影之中了,需要注意的是,这张shadowmap的分辨率较低,默认是一米一个像素,这个数值可以在配置文件中进行调整(Baselightmass.ini中的StaticShadowDepthMapTransitionSampleDistanceX, StaticShadowDepthMapTransitionSampleDistanceY参数)。

下图给出的是不透明物体使用Distance Field Shadowmap得到的直接阴影效果:

需要注意的是,由于Shadowmap最多包含四个通道,因此理论上同一时刻最多只有四盏Stationay光源可以拥有静态直接阴影(Static光源不需要直接阴影,因为直接嵌入Lightmap中了),而实际上太阳光需要占用一个通道(太阳光如果用作动态光也需要占用一个通道吗?),因此只剩下三个通道可用,当需要同一时刻需要产生投影的Stationary光源超出这个数值,超出的光源将采用动态投影方式输出阴影,这就可能会导致严重的性能问题。

Stationary光源的Shadowmap中只存储了静态物体产生的直接阴影,但是动态物体本身在光源下也会产生直接阴影,如果不做处理,就会导致穿帮,而动态物体在Stationary光源下的直接阴影通常是通过Per-Object Shadow来实现的。

实际上,Per-Object Shadowmap并不是为每个物件分配一张Shadowmap,而是分配一张大的Shadowmap Atlas,每个需要创建Per-Object Shadow的物件会分配一块空间,如下图所示,装载了三个Per-Object Shadow:

那么这些Per-Object Shadow要如何应用到场景里呢,总不能场景的每个像素都需要对每个Per-Object Shadow进行一次比对吧?实际上Per-Object Shadow的绘制Bounds是在光源视角下计算得到的物件的AABB,而这个AABB沿着光线传播的方向继续延伸一个长度,就得到一个AABB Volume,这个Volume就是对应物体在光源照射下的阴影覆盖范围,只需要计算出处于这个Volume中的屏幕空间像素即可,这样就可以极大的减少绘制的消耗。

这种方式是不是看起来很眼熟?这就是延迟渲染中用于降低Deferred Lighting计算消耗常用的方法,绘制一个Light Volume Primitive,获得其影响的屏幕空间像素,对这些像素进行Lighting。具体而言,就是通过Stencil操作进行标记,对Volume进行绘制的时候,开启双面绘制,正面跟背面的Stencil处理逻辑不同,如下图所示(注意UE使用的是Reversed Z,因此靠近近平面的位置Depth数值更大,正常剔除使用的是GreaterEqual):

pass 1
pass 1

这里的绘制分成两个pass完成,第一个pass用于标记出需要进行深度计算的像素,采用的是双面绘制,Stencil的判定函数是Always,也就是说只要通过Depth Test就进行写Stencil操作:

  1. 那么Back face如果没被遮挡就执行-1,Front face执行+1,这种情况对应的是场景中的像素处于Volume之后,不需要进行计算处理,Stencil结果为0
  2. 如果Back face被遮挡则测试失败,不写Stencil,Front face通过执行+1,这种情况对应的是场景中的像素处于Volume之中,需要进行计算处理,Stencil结果为1
  3. 如果Back face被遮挡则测试失败,不写Stencil,Front face失败不写Stencil,这种情况对应的是场景中的像素处于Volume之前,不需要进行计算处理,Stencil结果为0
  4. 如果Back face被遮挡则测试通过,Stencil -1,Front face失败不写Stencil,这种情况是不可能出现的。
pass 2
pass 2

第二个pass则是正常的渲染,执行背面剔除,在这个地方会进行Stencil判定,只有非0的像素才会通过检测,从而实现响应像素的筛选。

这里需要考虑的是相机位于Volume内部的情况,由于Front face会被近平面裁剪掉,因此会是的Stencil结果存在异常,比如Back face位于物件之后,理论上对应的是此像素处于Volume内部,但是在这种情况下,Stencil结果为0,因此也就不会计算对应的阴影。那么要怎么解决这个问题呢?

pass 1
pass 1

同样分成两个pass,第一个pass也是两面渲染,区别主要在于之前对Stencil的写操作在于Stencil Test Pass的时候完成,而现在改成Depth Test Failed(即对应的Volume像素被场景像素遮挡时)的时候进行,因为相机是在Volume中的,因此通过这种方式可以正确判定某个像素是否处于当前volume的范围之中。

pass 2
pass 2

第二个Pass的处理跟前面一致,需要注意的是,这里的Stencil等配置是逐Volume配置的,因此对于不同的volume其配置可能是不一样的,从而保证任何情况下,都能得到正确的结果。

对于一个动态物体而言,会需要动态创建两张shadowmap:

  1. 一张用于处理场景中烘焙好的静态物体向动态物体投射的阴影,这张对应的应该是普通的shadowmap,从截帧数据看,这里投影使用的是一张离线生成的Shadowmap,且形状较为奇怪,只有01两个数据,如下图所示,这是为什么呢?
静态物件包含两个cube跟两个sphere

经过测试发现,每当一个物体从Movable变成Static,就会触发这个贴图的更新,将其绘制到这张全局共享的且带Cache的Static Shadowmap中,而之所以上面图片显示的只有01两个数值,大概率是Renderdoc本身在深度贴图显示上的问题。

那么要计算这个贴图对动态物体的阴影factor要怎么做呢,只需要将动态物体上的像素反向投影到光源空间,之后根据位置使用PCF计算即可,这里为了提升效率,也可以先将动态物体进行一遍不写color的绘制,只是通过stencil标记出需要绘制阴影的像素,之后通过一个全屏后处理完成对应像素的shadow计算。

  1. 另一张用于处理动态物体向场景(包括静态跟动态)投射的阴影,这张shadowmap可以跟烘焙好的Distance Field Shadowmap最终计算得到的factor合并到一起来为静态物体提供阴影效果。这张shadowmap可以理解为前面的Per-Object Shadowmap。

对于设置为Stationary的方向光来说,还有一些其他的特殊处理:

  1. Stationay方向光除了烘焙的Distance Field Shadowmap之外,还会在运行时增加一套CSM(目的何在?应该是为了应对相机frustum近端的大量动态物件,相当于物件的直接光照阴影既需要考虑Distance Field Shadowmap的计算结果还需要考虑CSM的计算消耗,虽然看起来过程是更繁琐了,但是节省下大量静态物件在运行时参与阴影生成的消耗),这种策略在角色周围存在着大量的植被的时候会比较有用,可以省掉大量动态物件需要创建的Cascade消耗(Cascade?不是Per-Object Shadow吗?从实现逻辑上来讲,Per-Object Shadow就相当于一个Cascade),而CSM覆盖的动态阴影会随着视距的增加逐渐过渡到静态阴影上,可以通过Dynamic Shadow Distance StationaryLight参数来调节过渡距离。这个距离比较小的时候,为每个动态物件创建的Per-Object Shadow是会比较有用的(因为这个创建不会考虑当前动态物件是否处于CSM覆盖范围之内),但是如果距离设置的比较大,Per-Object Shadow的创建就比较浪费了,在这个时候可以关闭光源上的Use Inset Shadows For Movable Objects开关来干掉这部分消耗。
  2. Stationary方向光支持一种叫做Use Area Shadows for Stationary Lights特性,当开启这项特性的时候,预计算的Shadowmap存储的会是将光源看成是面光源计算得到的数据,而面光源产生的阴影具有距离投影物越远而变得越柔软的效果,如下图所示:
  • Movable:这类光源不但位置会发生变化,其光强也可能会变化,因此不会参与到烘焙计算过程中,由于所有的光影都是动态计算的,因此使用这类光源的时候要十分小心,当然如果不投射阴影,且采用的是延迟管线,那么这类光源的计算消耗就不是特别高(理论上来说,需要投影的动态光源计算消耗是不需要投影的动态光源计算消耗的20倍[13])。

参考文献

[1] Lightmap - Wiki
[2] Unwrapping UVs for Lightmaps
[3] Lightmap(光照贴图)能实现动态光照吗?
[4] 【UE4 全局光照明】虚幻引擎学习之路:渲染模块之全局光照明
[5] Indirect Lighting Cache
[6] UNREAL ENGINE 4 LIGHTING MASTERCLASS (SUMMARY)
[7] CPU Lightmass Global Illumination
[8] Light Propagation Volumes
[9] Unreal Summit 2016 Seoul Lighting the Planetary World of Project A1
[10] Volumetric Lightmaps
[11] 深入浅出UE4 ILC
[12] Actor Mobility
[13] Stationary Lights

你可能感兴趣的:(【UE】Unreal Engine中的间接光方案)