Unity SRP下做PBR基于物理的渲染和踩坑(一)
上下文关于pbr的文章已经有很多了,我也看了不少,但渲染这东西还是自己摸过才靠谱。正好试着在srp下写taa,为了更好的创造出各种高光/着色锯齿让我们的taa有锯齿可抗,干脆就先把pbr写了。
不过要真说实现了pbr也有点言过其实,目前为止我也只是把最简单通用的brdf模型实现了下,配合了个aces tonemapping出个效果。其他复杂点的像布料,clearcoat这些东西我也都还没弄。物理灯光,自动曝光啥的也全全没有(暂时给skybox叠了个可调亮度简单模拟下曝光),至于打通整条物理管线也为时尚早,做一步是一步吧。
本文纯属踩坑记录,不会过多阐释pbr的理解和推倒部分,会混杂大量srp相关内容。
推荐资料
先推荐一波pbr和srp的入门学习资料
PBR相关
首先是著名的Moving frostbite to physically based rendering
然后是谷歌的Filament pbr白皮书,对寒霜的pbr做了一些详细的阐述以及改进(我是看 @SaeruHikari 大佬的文章推荐的)
接下来是bull大佬 @CGBull 的unity和MPipeline下pbr的实现(代码部分笔误,评论区有大佬指出了)
以及@SaeruHikari 大佬的pbr实现(解释的比较清楚,代码部分和bull佬的差不多。
当然还有learn opengl的ibl相关内容,甚至有中文翻译
PBR知乎上还有很多学习资料,我就不一一列举了。Unity的core render pipeline的shader library下也有不少pbr的help function可以拿来参考学习,具体可以看BSDF.hlsl, CommonMaterial.hlsl, ImageBasedLighting.hlsl, Sampling.hlsl几个文件。
SRP相关
然后就是unity的可编程渲染管线入门。Catlike coding的Custom SRP Tutorials可以很快的帮助你了解SRP的基本框架。不过它本身教你搭的是一个很简单的渲染管线,如果只是想学怎么用,看到shadow那就行了,基础api大概也就了解了,后面挑着看。
然后就是Unity的SRP文档,不懂就查,但是要注意的是文档本身更新不一定及时,可能会存在api已经变了但文档没变的问题,另外不要以为参照文档给的简单example code snippet写,他那玩意儿经常忽略很多小坑。
具体遇到迷惑的问题了,直接读unity自己的urp和hdrp源码就可以了,整体思路还是比较清楚的,虽然不见得是最优解。
最后还有麦佬 @MaxwellGeng 的MPipeline可以配套作为参考。
PBR踩坑记录
Roughness还是Perceptual Roughness?
最开始迷惑的是几个概念:Smoothness, Roughness, Linear Roughness, Perceptual Roughness 等等。我一开始看寒霜那个pbr的时候就发现代码或者公式里一会是roughness,一会是perceptual roughness,让人头大。
实际上这几个概念也不难分清。Linear Roughness或者说Perceptual Roughness是美工做贴图的时候人为赋予的粗糙度值。为了符合人的直觉,这个值往往是按照线性的逻辑去画的,所以是linear。与此同时,也是为了符合人“越光滑越亮(白)”的直觉,寒霜让美工不画粗糙度,而是画光滑度Smoothness,这样贴图出来光滑的地方是白的,比较顺眼。所以 Linear Roughness 或者 Perceptual Roughness 就等于 1 - Smoothness。
而实际在做PBR的时候你会发现specular的D项和G项里都有个alpha^2的参数。这个alpha就是specular里给的粗糙度相关的系数,也就是我们说的Roughness。最简单的情况下我们可以直接把alpha = linear roughness,那alpha^2 = (linear roughness) ^ 2。这样相当于和美工指定的光滑/粗糙度直接对应。但是我既然说这是最简单的了,那说明可以不这么做。
事实上当一个物体已经很粗糙的时候,它稍微再粗糙/光滑一点其实改变不会很明显。也就是说粗糙度值较高的时候响应很快,但粗糙度低的时候响应没那么快。针对这种现象,寒霜做了一个粗糙度remapping。比起直接alpha = roughness = linear roughness,寒霜改成了alpha = roughness = (linear roughness) ^ 2,所以alpha^2 =roughness ^ 2 = (linear roughness) ^ 4。当然,remapping可以有很多种方法,比如crytek用了alpha^2 = (1 - 0.7 Smoothness) ^ 6,效果和寒霜的四次方效果接近。实际可以参考寒霜给的图(它这里是remapping到alpha^2,也就是roughness^2的,而不是alpha)。
这块概念不难搞懂,坑的是有时候你写着写着就搞混了:我这里到底该是用linear roughness还是roughness?所以为了保证不混,就用死办法,把所有方法设计roughness的参数都明确写出linear还是非linear,比如这样
实际上我在看bull佬和SaeruHikari的实现的时候就发现他们似乎有几个地方笔误写错了,比如求alpha^2的时候对roughness而不是linear roughness做了pow4。总之就是不难理解,但是写起来很容易错。
PBR组装
这玩意儿和拼乐高积木一样,做起来挑挑拣拣还挺有意思的。一刀切4块:Direct Diffuse, Direct Specular, Indirect Diffuse, Indirect Specular。
Direct Diffuse
我参考寒霜用了Renormalized Disney diffuse BRDF。原版的Disney diffuse BRDF在当linear roughness和view angle都高的时候,存在能量不守恒的问题(energy高于1),如下图。
我也试了下动视在COD: WWII 推的Multiscattering Diffuse BRDF。
简单来说这个就是考虑了一下粗糙表面的漫反射也是可能会多次bounce的模拟。只看Direct Diffuse的话,改进效果确实不错。下面三张图分别是DisneyRenormalized的Fd, DisneyMultiScattering的Fdm,以及两者之间的差(Fd_m - Fd)。光源是一个平行光,向中(偏左)下方投射。从左到右粗糙度变大。
可以看出带multiscattering的在边缘会亮一些,补充一般diffuse brdf在粗糙度高的时候损失的能亮,确实细腻了那么一些。但实际上加上Direct Specular和Indirect Diffuse后,区别其实不大。
这个上面是renormalized的,下面是multiscattering的,真有啥明显区别吗?我觉得不细看完全看不出来。。。
Direct Specular
这个我就用了流行的做法Fresnel用了Schlick,法线分布用了GGX,几何遮蔽用了Smith_GGX。这个没啥好说的,就是要注意下roughness的转换问题。
然后就是类似diffuse,specular也有个multiscatter的版本。我们用的Cook-Torrance model只考虑了single bounce,所以高粗糙度下以及视掠角的地方会因为缺乏散射而显得暗。(下图为direct lighting结果)
这里上面是没做Specular Multiscatter处理的,下面是做了的。我之前其实看到过这个问题,但本身没有太多直观感受,直到把direct lighting做完之后感觉才非常明显,注意第二排,金属在粗糙度越来越高的情况下越来越暗,而下面就很好的把能量补充了回去。具体做法我也是参考SaeruHikari参考Filament(套娃参考)把Energy Compensation扔在做imaged based lighting时的那张brdf lut里。
Visualize一下energy compensation(我选择输出energy compensation - 1.0f,因为这是个乘系数)。
Indirect Diffuse + Specular
Indirect Diffuse现在比较流行用球谐,因为占的空间小而且好插值,但我就是单纯用的Irradiance cubemap,也就是cosine weight卷积一下。我是偷懒了,没有自己去prefilter environment map,具体原因在后面srp部分细说,这里直接用unity那个TextureImporter上的irradiance卷积,不过不知道为什么效果特别blocky(而且因为cpu算的,速度相当慢。。。)只能说现阶段顶用下,后面要做realtime gi的话还是得自己prefilter。
Indirect Specular我现在就是Image based lighting,还没加屏幕空间反射。prefilter也是交给unity。这里有个我很迷惑的点。一般来说prefilter是mipmap和roughness对应。unity默认128分辨率,也就是max mip是6。但如果我塞给2048分辨率的cubemap进去,按理说应该max mip是11,但实际上虽然unity会帮你prefilter到11层,但实际上仍然是按内部标准max mip = 6算的。也就是说mip 7-11全是垃圾数据。。。
所以如果做roughness到mipmap level换算的时候,max mip用的是11就会发现indirect specular糊的特别快,应该仍然用6。
这里还有个问题就是roughness到mipmap level到底应该怎么换算。最简单就是mip = linearRoughness * maxMip,但这个估计的其实不够准确。Unity提供了一个快速的估计,就是mip = linearRoughness * (1.7f - 0.7f * linearRoughness) * maxMip。这个效果已经够了,如果你要更准确的也有,参考core rt里ImageBasedLighting.hlsl提供的(我觉得没必要,就没用):
最后就是要为IBL提前算一个BRDF Lut图用来查询DFG,这个各个地方讲的都很全。我这里不一样的是要额外算一下Specular Multiscattering。这一块我是折腾的最久的,因为和bull佬的还有SaeruHikari对比了半天我这个长得都不太一样,后来发现好像是他们有几个地方笔误了(也可能是我错了,等大佬指出)。
总之我的lut最后得到的是这样的,希望给大家能做为参考(不知道知乎会不会压,不会的话可以直接用):
我这个lut u是NdotV ,v是roughness (注:不是linear roughness)。lut结果rg是specular的,b是diffuse的,a是无效项,永远是1。我是用Compute Shader离线算的,实际上1024x1024分辨率下这个算的也很快,要是懒的做回读,完全可以游戏/引擎启动的时候实时算一下,玩家感知不出来的。另外就是对比lut的时候注意一下srgb转换的问题,lut应该是linear的。具体代码我不贴了,想查的话可以直接看我repo
杂项
这个时候pbr效果已经能出来了,不过还有几个点我没提到。
第一个是linear roughness需要clamp一下,这是减轻specular aliasing(并不是唯一方法),以及防止贴图的精度不够导致roughness精度不够的问题。这个值我取的是0.045。移动平台根据rt精度要相应调高一点到0.089(参考Filament)。
第二是indirect lighting的occlusion处理。我暂时还没做screen space系ao,所以这个先忽略。Indirect diffuse我一开始是直接乘材质的ao贴图。Indirect Specular的话,我不清楚我手上模型贴图有没有参考寒霜把specular occlusion塞在Base Color里,所以就没用那套,而是改用Horizon Specular Occlusion。这个东西我一开始是看任天堂大乱斗提到的,后来看了下效果意外的不错。具体可参考最早提出者:
或者也有个中文解释:
简单来说 要解决的问题是,法线贴图导致表面可能会向模型背面采样间接高光。而我们用这个hso来简单模拟下物体本身对法线贴图的自遮蔽效果。大乱斗给了个很好的示意图:
本来hso我只给了indirect specular用,但群友说indirect diffuse其实也用得到。我一想也对,于是把indirectDiffuse的遮蔽改成了min(ao, hso)。ao就是aoMap拿到的值。
然后pbr部分就差不多了,把Direct Lighting,Indirect Lighting和Emissive和起来,最后做个ACES Tonemapping,搞定收工:
天空盒我是在Poly Haven找的,这个站点也提供部分高质量pbr模型,不过都是gltf的,得用blender转一下贴图和模型格式:
头盔模型是gltf 2.0 开源项目:
那个商代青铜酒杯也是个开源3d项目:
SRP踩坑记录
然后就是SRP相关的坑了。。。
首先是我发现unity下没办法把cubemap设置成compute shader的RW对象,所以没法用cs去prefilter,只能用ps。
然后是RTSystem和BufferedRTSystem这两个东西。我写第一个srp管线的时候,因为不存在历史缓存的原因,都是直接GetTemporaryRT。但现在因为要做taa,所以部分buffer要缓存,同时可能要考虑兼容动态分辨率,所以就按unity的推荐用了基于RTHandle的RTSystem和拓展的用来做历史记录的BufferedRTSystem。具体内容可以参考core rt的文档。实际上发现点问题。
比如官方给的实例是让你用BufferedRTSystem.SwapAndSetReferenceSize()来resize rt大小,ResetReferenceSize仅仅用于节省内存用。但实际上编辑器下我用前者碰到了个问题:当我把scene view窗口缩小了后,会出现SetRenderTarget时rt尺寸不匹配的报错。
这个原因是SwapAndSetReferenceSize并不保证allocate的rt大小是一致的。对于有history buffer需求和没history buffer需求的rt,BufferedRTSystem swap的机制不一样。所以当我在做mrt的时候,就会出现depth buffer有history buffer,而mrt里的normal这些gbuffer没有,所以大小不匹配。
解决方法也很简单,都强制ResetReferenceSize(),这样一定会全部重新allocate所有rt。
稍微提一嘴管线
我没用全延迟渲染,还是压GBuffer太累,偷点懒就forward+ /w thin GBuffer了。
先是depth prepass顺便输出动态物体的velocity(本来想放forward loop里的,但考虑后面light loop复杂了,vgpr压力可能会很大,所以就放depth里了)。
forward阶段lighting buffer是RGBA16输出direct lighting + indirect diffuse + emissive,alpha通道塞specular occlusion。GBuffer 1是R11G11B10直接塞NormalWS。本来我是参考doom R16G16塞的OctQuadEncodedNormal,但是我看虚幻就直接塞R10G10B10A2,那我也就这么干了。后面如果有需要也可以改成虚幻这种,这样normal的a通道可以放material shadow(比如pom的self shadow)
GBuffer 2就是RGBA8,rgb是f0,也就是specular color,a是linear roughness。
这样在做完Indirect Specular Occlusion后lighting buffer的a通道又能空出来给transparency用。最后做完taa还能用来记录flicker历史,一alpha三吃,计划通。
当然这也有个问题,就是ssao会不太正确。要不然得在depth prepass后就做了,但这样normal就只能靠depth reconstruct(我倒是考虑过depth prepass输出normal的问题,但是如果要支持pom的话,这个开销就不低了)。目前就是凑活吧。。。
后处理优化
这里有个经典后处理优化,就是全屏Bli的时候不用两个三角形的Quad,而是用一个大的三角形把屏幕盖住。具体实现catlike coding Post Processing 有讲,但这里埋了个小坑。先放个示意图:
然后就是配合另一个经典后处理优化:用相机的frustum corners坐标插值配合深度计算世界坐标,而不是乘VP的逆矩阵。
但这两个结合起来的时候情况有点变化。原来用Quad做后处理的时候可以用4角顶点插值,现在后处理三角形的顶点并不是对应frustum corner了,所以计算要调整。这个很简单,适当延长即可:
因为rightDir和upDir这里都是frustum一半的长度,所以从中心点出发到达原来的2倍位置就是乘3。
但我这样改完了发现效果还是不对,而这个原因是catlikecoding那个示意图有点迷惑性。DirectX和Metal下Project Matrix是上下flipped的,所以他那个示意图也应该是上下flip一下才行。所以原来的bottomLeft变成了topLeft,topLeft变成了bottomLeft,bottomRight变成了topRight。改完插值就正确了。
小结
反正pbr总算亲自上手摸了一遍,感想就是diffuse xjb选,direct specular别整太拉,具体出效果还得是image based lighting出来的indirect specular,最后ACES tonemapping再压下,质感就出来了。但金属会有偏暗的问题,我感觉是缺少自动曝光。所以还得做基于物理的灯光和曝光,不过我准备都略过,先开始写Taa吧。。。
贴下本人残废管线的地址
repo起名是utaa,实际上连taa都没开始写,实锤挂羊头卖狗肉 金属偏暗应该跟自动曝光无关,单个光源在低ev低lux和高ev高lux下的表现效果一致,即光强的提高可以通过曝光抵消,单一光源的情况下可以不考虑物理光照单位。物理灯光和自动曝光的作用是展示场景内多个光源的巨大亮度差异,以及多光源情况下方便参照现实参考值打光。 个人浅见,欢迎大佬指正[捂脸] 还没具体上手做过,只能瞎猜[飙泪笑]
其实大部分时候金属也还好,就是和sketchfab那个preview比起来感觉金属没那么亮,所以我怀疑它手调了曝光。
我是直接怼了个exposure强度做完ibl的强度系数,调到2左右效果就有了 hh手动调亮环境反射
机智 [赞同] ibl如果用上所有的mipmap,diffuse就退化到ambient cube,在环境光方向性很强的时候(比如图里有个太阳)会很难看,能看到明显的坐标轴分界线。而且粗糙度较高时候cube map的格子会比较明显。不光用了更多的内存,效果还更差。不如给mip数量设个上限。 对的,我做的时候也发现了。
一个是本身ibl主光信息应该去一下,不然会有很强的光斑,我另外有一张cubemap图就有这问题。我就是为了临时解决这个问题所以把diffuse ibl直接采样最后一级mip,但是代价是会有条的分界线。如果是引擎内部捕捉的话,ibl的主光问题会好处理一点
有空看下改成球谐能不能好一点。
页:
[1]