伏雨朝寒悉不胜,那能还傍杏花行。去年高摘斗轻盈。漫惹炉烟双袖紫,空将酒晕一衫青。人间何处问多情。 ———— 纳兰容若
Unity
上篇文章中我们了解了pbr的公式,在经历疯狂的debug后,然后我们再改改lightpass的片元着色器,记住这个地方的法线最好在gbuffer中就处理成世界空间的,因为在绘制的pass中不好拿到我们需要的转换矩阵。
fixed4 frag (v2f i,out float depthOut : SV_Depth) : SV_Target
{
float2 uv = i.uv;
float4 GT2 = tex2D(_GT2, uv);
float4 GT3 = tex2D(_GT3, uv);
//getgbuffer
float3 albedo = tex2D(_GT0, uv).rgb;
float3 normal = tex2D(_GT1, uv).rgb * 2 - 1;
float2 motionVec = GT2.rg;
float roughness = GT2.b;
float metallic = GT2.a;
float3 emission = GT3.rgb;
float occlusion = GT3.a;
float d = UNITY_SAMPLE_DEPTH(tex2D(_gdepth, uv));
float d_lin = Linear01Depth(d);
depthOut = d;
以及在计算在坐标和输入的地方,那几个变换的矩阵不可以直接通过Unity的内置函数调用,(我一开始反正整出了一堆粉红色)突然想起来乐乐书p137里也提到了,那光照的api只有前向渲染可以获取.于是只能自己手动找矩阵了,大佬的做法是在pipelineinstance里面把这个变换矩阵的逆矩阵求出来并作为全局变量公开,但是总觉得可以在哪优化一下,不过我一时间也不知道有啥更好的办法,先这样再说,至于这里的depthout我们是把lightpass的ztest打开了因为我们要画出天空,之前我们是先画lightpass然后再绘制天空盒,会覆盖,现在这样就可以用gbuffer的深度去和天空深度比较,最后画天空盒和giz得到天空盒和giz被遮挡的结果。
//rebuild point
float4 ndcPos = float4(uv*2-1, d, 1);
float4 worldPos = mul(_vpMatrixInv, ndcPos);
worldPos /= worldPos.w;
float3 N = normalize(normal);
float3 L = normalize(_WorldSpaceLightPos0.xyz);
float3 V = normalize(_WorldSpaceCameraPos.xyz - worldPos.xyz);
float3 radiance = _LightColor0.rgb;//unitylight fixed param
float3 color = PBR(N, V, L, albedo, radiance, roughness, metallic);
color += emission;
return float4(color,1);
让我们回到编辑器!头盔的两侧已经出现了蛮不错的金属高光,垃圾桶的高光也还不错,说明我们的管线写得至少能跑。
IBL环境光Image based lighting
现在开始加入环境光和间接光照。之前我们解决环境光都用简单的采样,但是都physicalbased了当然要用物理的办法,所以还是这个函数,同样可以拆解成diffuse和specular。
漫反射辐照度(irRadience)
漫反射是个常数,那么光线完全取决于法线和wi和视角无关,对于一个cubemap来说每一个 点就对应了一个wi的积分。之前玩shadertoy的时候跟着shadertoy教程写过一个环境光的公式,
Vec3 cubemapReflectionColor = texture (iChannel0, reflect(rd,normal)).rgb;
然后伪造环境光的办法就是把cubemap模糊了(降低采样精度)再用reflect(rd,normal)去采样(也就是眼睛看到在法线表面反弹的逆光路),shadertoy里面包含了一个texturelod的函数可以实现这个采样(这里感谢二女士在博客erinz.xyz提醒)。当然这个环境光乘上各种各样的控制强弱的参数就会实现蛮不错的效果(比如这个模糊的)——猪头软皮糖
那么同样的,我们其实可以把对于每一个法线他们所有的入射光(wi)穷举预积分成一张图,然后可以直接用n来查这张图(其实结果上来看,和直接模糊是差不多的,不过我们要基于物理)。漫反射和视角无关,第一项是个常数,所以直接用法线采样输出就完了。这一项其实不用pbr技术也能得到蛮不错的结果
镜面反射IBL
镜面反射specular的话我们在shadertoy里面的办法如果在pbr里面用就太耗了,因为有两个变量。所以采用近似的办法把积分拆开。这里引用毛神的文章讲一遍关于环境高光的办法
此图来源毛神的pbr白皮书,注意这里的 l 就是光线入射方向,也就是之前的p,w,而v是视角方向,根据learnopengl里面提到的,这个大概是这么转化过去的
第一项 预过滤环境贴图(Pre-filtered environment map)
这一项可以理解为对所有出射光的亮度求平均值,也就是刚刚的漫反射部分。 采用的方案基本都是利用预过滤环境贴图,利用多级模糊存到mipmap里面,相当于根据粗糙度选择最接近的Miplevel进行模拟采样实现环境高光的模拟。
第二项 环境BRDF (Environment BRDF)
镜面积分部分,假首先把菲涅尔项进行Schlick近似替换并约掉分母的nwi,然后就拆成了 理论上来说,这一部分要靠V,NdotL和粗糙度决定,但是因为是高光部分,所以近似采用N = V = R也就是视线方向等于采样方向的办法(永远都是均匀的镜面反射而没有那种拉长的效果(各向异性))来实现 也就是只受到Roughness和NdotL影响 那就可以用给定的粗糙度和一个法线n和光源方向ωiωi之间的角度或n⋅ωi来预计算BRDF的值形成一张2d贴图,然后这个部分存到LUT里面就是一张2D的贴图,虚幻就贴出了这张预积分BRDF图, 最终的积分结果就是(F * envBRDF.x + envBRDF.y) F就是菲涅尔的那个F0常数
具体实现
预计算IBL贴图,(之前干过在ue里面材质编辑器用魔幻的方法贴到plane上然后导出的办法)目前为了迅速出结果,就不造轮子了。知乎的大佬给出了这样的一个策略
对于漫反射预积分贴图可以通过 cmftStudio 这款优秀的开源工具进行预计算。对于镜面反射预积分贴图 Unity 自带了过滤器帮我们计算了各级 Mipmap
那么我们就在studio里很迅速的获取到一张从hdri转成的cubemap以及一张irradience的图,工具里面好像还有一个specular的那个采样图,不过它比较麻烦,unity自己就可以帮我们计算各级Mipmap,只需要勾选convolution type为specular。lightpass里面的IBL代码如下,这里用到的cubemap都是在asset里作为参数输入,并设定为asset和instance的成员变量,然后instance里面设置为全局texture然后在这个shader里面调用的。
float3 IBL(
float3 N, float3 V,
float3 albedo, float roughness, float metallic,
samplerCUBE _diffuseIBL, samplerCUBE _specularIBL, sampler2D _brdfLut)
{
roughness = min(roughness, 0.99);
float3 H = normalize(N); // 用法向作为半角向量
float NdotV = max(dot(N, V), 0);
float HdotV = abs(dot(H, V));
float3 R = normalize(reflect(-V, N)); // 反射向量
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);
// float3 F = SchlickFresnel(HdotV, F0);
float3 F = FresnelSchlickRoughness(HdotV, F0, roughness);
float3 k_s = F;
float3 k_d = (1.0 - k_s) * (1.0 - metallic);
// 漫反射
float3 IBLd = texCUBE(_diffuseIBL, N).rgb;
float3 diffuse = k_d * albedo * IBLd;
// 镜面反射
float rgh = roughness * (1.7 - 0.7 * roughness);
float lod = 6.0 * rgh; // Unity 默认 6 级 mipmap
float3 IBLs = texCUBElod(_specularIBL, float4(R, lod)).rgb;
float2 brdf = tex2D(_brdfLut, float2(NdotV, roughness)).rg;
float3 specular = IBLs * (F0 * brdf.x + brdf.y);
float3 ambient = diffuse + specular;
return ambient;
}
Unity里面还有一个自己计算近似菲涅尔常数的办法可以用到IBL里,最终的效果还是蛮不错的,
float3 FresnelSchlickRoughness(float NdotV, float3 f0, float roughness)
{
float r1 = 1.0f - roughness;
return f0 + (max(float3(r1, r1, r1), f0) - f0) * pow(1 - NdotV, 5.0f);
}
当然displacement,多光源,都没有处理,不过原理是基本一致的,多光源只是在gbuffer里面加多一层光源的数据,那么玩具pbr的编写先到这吧!最终的一些效果如下:final direct ambient
毛神的文章看完还是收益匪浅,里面很多种pbr模型的优化策略和近似策略,都还没时间去试试,等稍微有时间一定搞搞看。
参考文献
- 浅墨的白皮书
- Unity SRP 实战(一)延迟渲染与 PBR - 知乎 (zhihu.com)
- Unity官方文档
- Unity PBR Standard Shader 实现详解
- PBR Texture Conversion | Marmoset
- 理论 - LearnOpenGL CN (learnopengl-cn.github.io)
- 基于物理的渲染—更精确的微表面分布函数GGX - 知乎 (zhihu.com)
- 由浅入深学习PBR的原理和实现 - 0向往0 - 博客园 (cnblogs.com)
- DX12渲染管线(1) - 基于物理的渲染(PBR) - 知乎 (zhihu.com)
- shadertoy教程
- 当然还有乐乐女神的Unityshader入门精要