今天学习的是《全境封锁》(端游)在GDC 2016上分享的关于全局光方案的技术方案。
照例对其中的一些要点做一下总结:
- 整体的GI方案是纯动态的,支持TOD、天气等的变化,主要的策略就是relighting
- GI方案覆盖室内外,而非仅仅用在室内
- 地图尺寸为6平方公里,actor数目为200w,sector数目为4k,probe数目为1.15M,Surfel数目为56M,磁盘空间占用为1.07GB(surfel存储的是GBuffer,比较耗费空间)
- 不但实现了动态光照的支持,还实现了动态场景(雪景)的支持,具体方式是通过为surfel存储一套面向雪景的参数,在运行时对这套参数进行读取并施加影响
- PRT的数据存储采用的是HL2而非SH2,虽然需要用到8个参数,但是其结果相对于SH2更好(这里是不是应该跟SH3做比较?)
- Probe的布置是程序化实现的,主要是俯视角基于ray cast来得到的,此外,建筑等大块物件的侧面也需要添加一些probe(当然,如果是立交桥这种多层的,推测应该也是需要布设多层probe的)
- probe并不是唯一的存储光照的数据结构,这里还有一套新增的双层数据结构Surfel,Surfel用于搜集各个关键位置的光照输入,之后基于这些输入,会整合到一个brick中,得到一个brick的平均input radiance数据,对于每个probe还需要关联多个brick,之后从这些brick中拿到对应这个probe的input radiance,最终完成lighting计算
- 渲染逻辑是每帧基于预算完成若干个sector的probe的relighting(大约600~800个probe的更新),这里的relighting包括说对surfel的,对brick的,以及对probe的,probe的relighting除了需要考虑身边的brick的输入,还需要考虑天光的输入(同样用HL2的参数存储)
- 为了得到多次反射的间接光,这里在对surfel进行relighting的时候还会采样周边最近的probe的数据(类似于TAA的思路)
- 整体的性能消耗还比较可控,在GTX 760上,整体的计算消耗大约是0.47ms
- 除了放置的probe之外,这里还弄了一个虚拟的probe volume map,也就是irradiance volume map,用于覆盖视野中各个点位的光照数据(大概是用于实现动态物件的照明的)
- 针对室内场景,还做了特殊的布置,不过这里没有聊到具体的细节。
- 针对远景也做了特殊处理,主要是LOD逻辑,比如只用一张2D的大尺寸贴图,就不用volume map的数据了,每个texel只对应于一个sector的probe,且probe只取天光作为直接光来完成计算
- AO这边用了多种技术叠加来提升效果,包括probe的天光遮蔽、烘焙的AO、SSAO以及AO Decal等
- 基于PRT实现
- 支持高频动态光源:高频变化的动态光源,意味着光照效果要能支持随着光源的变化而变化,而不能仅仅只支持静态效果
- 将同一套方案用在室内室外,省去美术同学调制两套的成本
- 编辑要能做到立即生效,提高研发效率
户外效果:有没有GI差异还是很大的(就是不知道,如果只是叠加一层天光的话,效果跟这里基于PRT对比起来会咋样)
室内差异也很大,同样的问题,室内如果加个环境光,不知道效果咋样呢,脑补一下,应该会缺少层次感,立体感。
6平方公里,接近200w个actor,载具有2.2w,垃圾堆2.8w,整个城市非常复杂,因此考虑通过probe来降低制作生产时的复杂度。
环境光照就变得非常重要,一方面因为要支持TOD,美术同学对太阳光方向的控制力度就变得非常有限,另一方面还因为某些区域可能会一直处在阴影中。
不论光照怎么变化,都不需要额外重复bake
所以这里说的不用rebake,就是所有的计算都是实时的,难道probe的生成是运行时完成的?
大尺寸的室内场景,内部承载了十分丰富的物件、装饰,同样会有比较重的动态光照,同样支持TOD,这里的另一个需求是必须要避免漏光。
天气效果通过程序控制,无法提前预算,只能依赖于动态的计算。
雪由于也是程序化生成,会改变场景的效果外观,因此也再一次对实时动态提出了要求。
看起来像是1.5~2m一个probe。
PRT算法本身没有要求光源距离场景足够远,而是要求能够拿到某个点处足够准确的光源的SH表达。
如果想要实现高光反射效果,需要存储的数据量会远多于Diffuse,计算逻辑复杂度也会有所增加。
全境这边的GI方案概览:
- 暴力(?)
- 每个probe存储一份类似于GBuffer的surfels数据(不知道都包含啥数据,但看起来是可以用于Relighting)
天光遮蔽其实可以看成是远程的AO,跟SSAO结合起来之后,效果就非常好了。
不过不知道这里的天光遮蔽是咋实现的,是用了一张全图的深度贴图呢,还是存储在probe中,看描述是用了一个球面shadow term,准确来说应该是一个上半球的天光可见性。
基于之前构建的probe数据,我们就可以重构出cosine卷积的结果。上图右侧四个小图,左上角的是specular cube的结果,右上角的是ambient cube的ground truth的结果,左下角的是基于SH2重建得到的ambient cube结果,右下角则是基于HL2的ambient cube重建得到的结果,从效果对比来看,HL2的效果要更接近ground truth。
这里描述的是啥?Surfel数据?
这里介绍的是probe是如何布置的:
- 通过程序化的方式完成摆放
- 相邻probe间距4m
- 通过ray cast来寻找probe的放置点(自上而下的射线追踪吗?)
- 为了避免建筑周边光照效果过于平,这里还需要沿着建筑的墙面做垂直方向上的probe布局
这里描述了probe的streaming策略:
- 将地图以8x8一个格子来划分,每个格子大概会包含200+个probe
- 之后采用类似于World Partition的策略来实现probe数据的streaming(其实全加载应该也可以吧,毕竟内存占用不高?)
前面说的Surfel数据存储的是类似于GBuffer的周边场景的Lighting所需的输入数据(或者干脆就是lighting数据),这些数据跟probe的数据是分开的,看起来密度也不一样:
- 每个sector只存储一套surfel数据(可能不止一个,不过同一个sector的probe在需要的时候只需要使用这个sector的surfel数据,而不需要从旁边sector中获取数据),且这份数据可以被当前sector的所有probe所共用(从这里来看,surfel就相当于是输入的Radiance cube数据了)
- Surfel数据会被组织成一个双层结构,存储在一个hash grid中(2D数组,可以基于hash来索引得到)。
这是一堵L型的墙体,需要在水平方向的四个方向上摆放Surfel,看起来Surfel数据的摆放是程序化的,按照1m的间距来实现。
第一层的surfel存储:Surfel的平均位置(?),法线数据,基色数据等信息。
Surfel覆盖的范围被称之为一个cell,目前cell的尺寸为1m^3。
这里没有特别介绍surfel的布局方式,看上面的图标,
在索引的时候,会基于Surfel的位置(可能跟probe具有相同位置?)以及主要的法线方向(比如是+X还是-X,是+Y还是-Y,这几个方向都是水平的,但实际情况下可能还会有+Z跟-Z的,用于表示当前surfel是用于覆盖哪一侧的数据的?)来查找surfel的数据在buffer中的索引。
前面cell就是surfel的第一层结构。第二层结构就是将多个surfel合并成一个brick,目前设定的brick尺寸为4x4x4 m^3
接下来看看各个层级的数据的数据布局:
- 每个probe包含三个数据:probe的位置、probe处的sky visibility信息(用什么格式存储,HL2 basis吗?),Factor Range(用于控制与其相关的Surfel在Factors Buffer中的范围)
- Factor Buffer中的每个元素(Factor)包含两个数据:Basis Weights(每个Brick中的SH系数?)以及Brick Index(用于从Brick Buffer中获取到对应的Surfel数据的范围)
- Brick Buffer中的每个元素存储的是该Brick对应的Surfel数据在Surfel Buffer中的范围
- Sufel Buffer中的每个Surfel数据包含三个数据:该Surfel的位置,法线跟基色信息(贴图)
从这个图来看,每个probe需要对应:
- 一个位置
- 一个sky visibility数据
- 多套HL2 basis系数(每套对应一个brick,可以看成是身边的一个输入光源)
- 以及更多套的Surfel数据(为啥是更多套呢,是因为每个probe关联多个brick,每个brick关联多个surfel)
这里的Basis Weights应该是当前brick的input radiance对该probe的影响加权。
烘焙过程给出如下:
- 对每个sector中的所有probe做一次性处理(为啥以sector为单位?)
- 在GPU中为每个surfel位置,绘制一个cubemap
- 在CPU中对这些cubemap进行处理,拆解出Surfel数据,同时完成前面所说的双层Surfel Grid的Cluster(分类)操作
整体的数据量给出如下:
- 磁盘总共占用1.07G
- Sector数目、Probe数目以及Surfel数目给出如上图所示(看起来probe跟surfel的数目比例是1:56)
接下来看看怎么渲染的,应该就能大概知道surfel数据具体该怎么用。
渲染主要包含三个部分:
- 对Surfel进行relighting:这里的relighting就相当于一个延迟buffer的lighting逻辑,前面说了,surfel是一个双层结构,上层是brick,所以这里还需要将brick中的所有surfel的lighting结果累加起来,得到一个brick的平均数据(不知道用什么格式存储,看后面的使用,应该可以考虑SH存储)
- 对probe进行relighting:这里的relighting就是用输入光的radiance去跟transfer数据进行卷积,而输入光数据除了前面brick的数据之外,还有天光的数据
- 基于probe数据进行shading
下面看下具体的细节
surfel跟probe的relighting过程是每帧都需要做的,这样就可以实现对动态光照的支持,整个relighting过程当然是发生在GPU上。
relighting完成之后,就可以基于relighting的结果与PRT的输入,得到每个probe位置的irradiance数据。
这是一个纯动态的GI效果,而非烘焙多套,并在其中过渡的方案。
这里展示了不同时间段的照明效果。
这里给出了整体计算逻辑的伪代码:
- 首先是计算每个brick的radiance数据(带方向),数据来自于之前完成relighting的surfel,这个数据是对所有surfel光照结果的平均,因此需要除以surfel的数目
- 接着是probe的irradiance计算,为了得到更为精细的结果,这里将每个probe的irradiance分为六个分量,每个分量对应于一个cube face,每个分量需要考虑天光的输入跟brick的输入,其中天光部分等于天光遮蔽跟天光强度相乘的结果,而brick的输入则是将该probe关联的所有brick在对应方向上的irradiance累加起来,累加的时候还需要考虑brick对该probe的贡献权重(可能跟距离有关)
对brick的radiance的计算,由于radiance是一个带方向的量,假设我们将结果存储在一个cubemap上,那么cubemap的每个像素就对应于一个radiance。
而这里的一个疑问是,对于每个radiance,要如何才能找到surfel上的一个匹配的点呢?这里推测一下,对于上面说的cubemap的每个face,我们都能过滤出具有相同face direction的surfel,我们只需要从cubemap的中心向着对应像素发射射线,找到与场景的交点,之后将这个点转换为surfel上的uv坐标即可(当然,有可能结果已经超出贴图覆盖范围了)。
这里给出了只受太阳光照射时的PRT relighting后的光照结果,效果其实还是挺不错的。
说到阴影,前面surfel的relighting理论上也是需要考虑太阳光(甚至其他光源)的阴影的,如果没有被阴影贴图覆盖的话,那relighting的结果就不是太准确,因此这里也说了,对于没有正确的阴影贴图的surfel是需要做好标记的(不知道后面会做啥特殊处理)
前面说了太阳光,由于TOD的存在,太阳光需要动态变化,所以所有的数据都需要每帧计算。
对于局部光源来说,可以认为是一段时间内维持不变的光源,其处理需要特殊对待:
- 需要找到每个surfel受哪些局部光源的影响,避免无谓的计算
- 可以将局部光源的照明结果缓存下来,避免每帧的计算
场景的动态变化,也会影响到照明结果。雪景的出现就会导致效果的差异,这里的做法是在原始的地表基色作用下的surfel的radiance基础上叠加白雪的照明效果,白雪的照明相关参数就直接以surfel为单位进行存储。
对比了on/off的效果差异。
如何实现间接光的多次反射呢?最常用的方法是复用上一帧的数据,比如这里的做法就是对于每个surfel而言,都会绑定一个与之最近的probe,之后surfel在relighting的时候,就可以从probe的irradiance数据中拿到上一帧的间接光结果,并将之用于计算当前帧的光照结果,从而实现了多次反射效果。
可以看到,开闭效果差异还挺大的。
接下来看看天光部分的计算。
天光数据就来自于天空盒,只需要拿到每个方向的irradiance结果即可。
最后再来看看brick对probe的光照
这是整体的性能消耗数据:
- 控制预算,每一帧只完成两个probe sector的relight逻辑,其中大概包含600~800个probe
- 采用异步compute shader计算的方式来完成
- 总体的消耗取决于要更新的probe数量,在XBox这边大概是0.95ms(非异步Compute),而在PC GTX 760的显卡上,则只需要0.47ms(看起来非常有前景)
除了probe的irradiance之外,这里还计算了一个irradiance volume map,这是一张3D的贴图,分辨率是32x16x32,覆盖100x50x100的范围,这里面存储的数据在延迟或者前向照明的时候会用到(为啥不直接用probe的数据?是因为过于稀疏不好采样吗?)
为了提升室内计算的品质,这里还对室内外做了区分(通过一个volume标识,会影响到stencil的值,基于这个值判断走哪套逻辑),具体的细节就介绍不多了。
前面说到的irradiance volume主要用于近景的照明,而远景区域则需要另一套数据来源,这里的做法是用一张2D的贴图来存储probe的照明信息,其中每个像素都对应于一个probe,远景就不用考虑过于复杂的计算逻辑,比如直接光这里就只考虑天光的,不考虑局部光源与太阳光了。
看起来就是有个环境光效果即可,效果也还挺不错的。
场景的AO效果通过多个技术方案来实现:
- 在probe的照明计算中,考虑上天光的遮蔽效果
- 应用屏幕空间AO方案
- 将部分AO数据烘焙在贴图或者其他存储结构中
- 部分特殊场景如载具,我们可以通过一个屏幕空间的贴花来伪造
这里是屏幕空间贴花实现的细节
体积光对间接光的采样这里也做了考虑:
- 将probe的数据采样到一张3D的volume map中
- 在raymarching的时候采样这张3D贴图来获取光照数据
从效果来看,还是挺不错的。
irradiance的更新是在运行时进行的(好奇跟probe的数据是怎么做选择的),不需要bake(数据来自于哪里?难道是纯运行时计算?),这个方法有如下的一些不足之处:
- 存在明显的光照跳变(边界位置吗?)
- 不支持过于快速的光照变化(不是纯动态计算吗?为啥不支持)
Irradiance volume的更新是在运行时完成的,不需要离线的baking,不过这种方式存在如下的问题:
- 明显可见的光照跳变(边缘位置?)
- 不适合用在光照环境变化迅速的场景(为啥?)
传递函数的基函数选择的是8分量的非正交基(难道不是SH?),这里选用的是HL2的基函数,原因是更加符合项目需要。
HL2的基函数是咋样的?
在probe布点不够的时候,会导致dark spot(暗斑)问题。这里的解决方案是给美术同学更灵活的控制力度,以避免这类问题。
支持建筑上的UV映射(这个解决的是啥问题?)
Interior Volume的问题在于室内外的转变过于突兀,需要更好的策略,另外也说到了full shading打开的时候问题就没那么明显,好奇的是full shading是啥?
在实际落地的时候还遇到了probe精度的问题,这类问题可以通过增加probe与surfel的分辨率来缓解。不过这种做法是有代价的
多次反射的实现方案也会带来一些问题,比如部分区域的暗斑等
方案的优点是可以支持TOD、天气等动态参数的变化。