URP从原理到应用——进阶篇
1.1 前言
在SRP中C++提供了最底层的渲染接口,URP和HDRP根据底层渲染接口构建出各自的渲染管线。如下图所示,整个帧渲染的每个Pass都是在C#中完成,只需要打开URP的源码就可以轻松进行调试,这在Built-in管线中是不可能做到的。管线开源还有个好处就是我们可以进一步优化性能,URP为了兼容性默认会经过4次RT拷贝,但其实完全可以节约掉这部分性能,只需要改改源码就可以实现。
Unity目前摄像机动态分辨率支持的不佳,只有在iOS和Android的Vulkan环境下才支持,由于目前大部分设备还只是OpenGL ES,所以无法做到3D降分辨率,UI不降分辨率。有了URP我们就可以通过修改源码的方式实现这种需求。如下图所示, 我们将3D摄像机的分辨率改成了0.5,这样渲染的压力大大减小。
如下图所示,后面UI是高清分辨率直接写到FrameBuffer中,从而实现性能与效果的双重兼容性。
本篇文章会提供一些经典案例,比如URP优化RT拷贝次数、UI一部分背景+3D模糊效果、干掉FinalBlit优化性能,3D部分与UI部分不同分辨率提高性能与效果。这些功能都离不开源码的定制以及修改,所以对URP源码我们一定要了然于胸。
1.2 渲染管线与渲染技术
渲染管线和渲染技术可以说是两个完全不同的概念,渲染技术和平台引擎是无关的,学术界的大佬将渲染公式研究出来,才慢慢应用在工业界。
比如Phong光照模型,Bui Tuong Phong(裴祥风)1975年出版的博士论文 《Illumination for computer generated pictures(计算机生成图片光照)》中的计算光的反射向量有一定开销,Jim Blinn(吉姆·布林)就基于Phong光照模型的基础上于1977年提出使用光的向量+视向量算出中向量(计算中向量的效率高于计算反射向量),中向量与法向量做点乘计算高光强度,这就是我们现在手绘贴图最常用的Blinn–Phong光照模型。
现在手游中,PBR光照模型几乎已经成为标配,早在上世纪80年代由康奈尔大学就开始研究物理正确的渲染理论基础。迪士尼(Disney) 在总结前人经验的基础上,于2012年首先提出实时渲染的PBR方案。论文中提到,原本他们可以实现多个物理光照模型,让艺术家们选择和组合它们,但是无法避免参数过多的情况。所以他们将实时基于物理的渲染整合成一个模型。该光照模型由Epic Games首先应用于自家Unreal Engine的实时渲染中,上一章我们提到Unity也根据这个模型提出了一套精简版拟合方案。
如果大家对PBR的理论基础感兴趣,推荐阅读出版于2016年的这本旷世巨作《Physically Based Rendering(基于物理的渲染)》,作者是三位巨佬:包括Matt Pharr(NVIDIA的杰出研究科学家)、Wenzel Jakob(EPFL计算机与通信科学学院的助理教授)和Greg Humphreys(FanDuel的工程总监,之前曾在Google的Chrome图形团队和NVIDIA的OptiX GPU光线追踪引擎工作)。
这本书提供了在线免费阅读,推荐大家一定要看《Physically Based Rendering(基于物理的渲染)》。
通过Phong光照模型和PBR光照模型我们可以看出,渲染技术与渲染引擎是无关的,时至今日渲染技术还有很多,实时图形渲染背后的都是学术界的大佬,并不受限于Unity引擎或者Unreal Engine引擎。再回到工业界游戏引擎会根据学术界的公式适当做一些拓展或者优化,并且引入一些通用渲染效果加入引擎中。目前比较火的岗位包括图形程序员和技术美术。我认为顶级的图形程序员并不仅仅是渲染公式的搬运工,一定要搞懂背后的原理,只有完全搞懂公式的原理才能有目的性地修改渲染公式,实现项目风格化的图形渲染。(在这条路上我愿与君共勉。)
1.2.1 普通光照计算
内置管线中将光照都封装在Surface Shaders中,不利于学习与修改,现在URP将Blinn-Phong和PBR的光照都封装在Lighting.hlsl中,直接引用即可。如果某些地方想修改也可以自己写一个Shader文件,引用一个自定义的Lighting.hlsl:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
计算光照必须要得到每盏光的光方向、光颜色、光衰减、视线方向和着色点法线方向。
1. Blinn-Phong
在URP中直接使用SimpleLit.shader就可以直接实现Blinn-Phong光照,在顶点着色器中先得到视线方向:
Varyings LitPassVertexSimple(Attributes input) { Varyings output = (Varyings)0; //...略 output.viewDir = GetWorldSpaceViewDir(vertexInput.positionWS); //...略 return output; }
在片元着色器中计算光照颜色:
half4 LitPassFragmentSimple(Varyings input) : SV_Target { //...略 InputData inputData; InitializeInputData(input, normalTS, inputData); //...略 half4 color = UniversalFragmentBlinnPhong(inputData, diffuse, specular, smoothness, emission, alpha); return color; }
最终结果:
half4 UniversalFragmentBlinnPhong(InputData inputData, half3 diffuse, half4 specularGloss, half smoothness, half3 emission, half alpha) { //主光 Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, shadowMask); half3 attenuatedLightColor = mainLight.color * (mainLight.distanceAttenuation * mainLight.shadowAttenuation); half3 diffuseColor = inputData.bakedGI + LightingLambert(attenuatedLightColor, mainLight.direction, inputData.normalWS); half3 specularColor = LightingSpecular(attenuatedLightColor, mainLight.direction, inputData.normalWS, inputData.viewDirectionWS, specularGloss, smoothness); //点光 #ifdef _ADDITIONAL_LIGHTS uint pixelLightCount = GetAdditionalLightsCount(); for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex) { Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask); #if defined(_SCREEN_SPACE_OCCLUSION) light.color *= aoFactor.directAmbientOcclusion; #endif half3 attenuatedLightColor = light.color * (light.distanceAttenuation * light.shadowAttenuation); diffuseColor += LightingLambert(attenuatedLightColor, light.direction, inputData.normalWS); specularColor += LightingSpecular(attenuatedLightColor, light.direction, inputData.normalWS, inputData.viewDirectionWS, specularGloss, smoothness); } #endif //最终结果 环境光 + 漫反射 + 高光 half3 finalColor = diffuseColor * diffuse + emission + specularColor; return half4(finalColor, alpha); }
1.2.2 PBR光照计算
接着就是处理主光阴影的CBUFFER_START(MainLightShadows),URP的C#代码将阴影级联与阴影偏移等参数传递到GPU中,Shader根据这些参数就可以采样正确的阴影信息了。
Shadows.hlsl #ifndef SHADER_API_GLES3 CBUFFER_START(MainLightShadows) #endif // Last cascade is initialized with a no-op matrix. It always transforms // shadow coord to half3(0, 0, NEAR_PLANE). We use this trick to avoid // branching since ComputeCascadeIndex can return cascade index = MAX_SHADOW_CASCADES float4x4 _MainLightWorldToShadow[MAX_SHADOW_CASCADES + 1]; float4 _CascadeShadowSplitSpheres0; float4 _CascadeShadowSplitSpheres1; float4 _CascadeShadowSplitSpheres2; float4 _CascadeShadowSplitSpheres3; float4 _CascadeShadowSplitSphereRadii; half4 _MainLightShadowOffset0; half4 _MainLightShadowOffset1; half4 _MainLightShadowOffset2; half4 _MainLightShadowOffset3; half4 _MainLightShadowParams; // (x: shadowStrength, y: 1.0 if soft shadows, 0.0 otherwise, z: oneOverFadeDist, w: minusStartFade) float4 _MainLightShadowmapSize; // (xy: 1/width and 1/height, zw: width and height) #ifndef SHADER_API_GLES3 CBUFFER_END #endif
URP的C#部分代码是如何传递以上信息的,请大家仔细阅读MainLightShadowCasterPass.cs。
非主光阴影参数CBUFFER_START(AdditionalLightShadows)同样是由C#传递进来的。
Shadows.hlsl #ifndef SHADER_API_GLES3 CBUFFER_START(AdditionalLightShadows) #endif float4x4 _AdditionalLightsWorldToShadow[MAX_VISIBLE_LIGHTS]; half4 _AdditionalShadowParams[MAX_VISIBLE_LIGHTS]; half4 _AdditionalShadowOffset0; half4 _AdditionalShadowOffset1; half4 _AdditionalShadowOffset2; half4 _AdditionalShadowOffset3; float4 _AdditionalShadowmapSize; // (xy: 1/width and 1/height, zw: width and height) #ifndef SHADER_API_GLES3 CBUFFER_END #endif #endif
URP的C#部分代码是如何传递以上信息的,请大家仔细阅读AdditionalLightsShadowCasterPass.cs。
有了阴影的参数还不够,还需要将1盏主光和8盏非主光的颜色、矩阵、LightProbe和灯光的衰减从URP的C#代码传入GPU中,这样Shader就能着色灯光和阴影了。如以下代码所示,每个物体都需要引用Input.hlsl并且C#中传递主光的信息。
Input.hlsl float4 _MainLightPosition; half4 _MainLightColor; half4 _MainLightOcclusionProbes;
接着再传递点光的信息,它们保存在CBUFFER_START(AdditionalLights)中:
Input.hlsl #ifndef SHADER_API_GLES3 CBUFFER_START(AdditionalLights) #endif float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsColor[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsAttenuation[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsSpotDir[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsOcclusionProbes[MAX_VISIBLE_LIGHTS]; #ifndef SHADER_API_GLES3 CBUFFER_END #endif #endif
如下图所示,C#代码中请大家仔细阅读URP中ForwardLights.cs类文件,可以找到实际传递的地方。
光的数据已经全部传递到Shader中了,接着就可以计算光照了。请大家仔细阅读Lighting.hlsl文件,这里包含了物体表面主光和点光源的颜色计算。
Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, shadowMask); //..部分略,mainLight计算物体表面的主光颜色 color += LightingPhysicallyBased(brdfData, brdfDataClearCoat, mainLight, inputData.normalWS, inputData.viewDirectionWS, surfaceData.clearCoatMask, specularHighlightsOff); #ifdef _ADDITIONAL_LIGHTS uint pixelLightCount = GetAdditionalLightsCount(); for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex) { Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask); #if defined(_SCREEN_SPACE_OCCLUSION) light.color *= aoFactor.directAmbientOcclusion; #endif //light计算物体表明点光的颜色,最多8盏颜色进行相加 color += LightingPhysicallyBased(brdfData, brdfDataClearCoat, light, inputData.normalWS, inputData.viewDirectionWS, surfaceData.clearCoatMask, specularHighlightsOff); } #endif
最终物体表面颜色由BRDF乘以辐射率得出:
half3 LightingPhysicallyBased(BRDFData brdfData, BRDFData brdfDataClearCoat, half3 lightColor, half3 lightDirectionWS, half lightAttenuation, half3 normalWS, half3 viewDirectionWS, half clearCoatMask, bool specularHighlightsOff) { //..部分代略 half NdotL = saturate(dot(normalWS, lightDirectionWS)); //请注意这里,光源颜色*光源衰减*(法线方向点乘光的方向) half3 radiance = lightColor * (lightAttenuation * NdotL); half3 brdf = brdfData.diffuse; brdf += brdfData.specular * DirectBRDFSpecular(brdfData, normalWS, lightDirectionWS, viewDirectionWS); //最终将计算的辐射率*BRDF return brdf * radiance; }
1.2.3 BRDF优化
Unity的工程师Renaldas Zioma在2015年发表了一篇在移动端优化PBR的论文《See "Optimizing PBR for Mobile" from Siggraph 2015 moving mobile graphics course》(强烈建议大家看看),对BRDF高光感兴趣的同学可以继续看这段代码。
half DirectBRDFSpecular(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS) { float3 halfDir = SafeNormalize(float3(lightDirectionWS) + float3(viewDirectionWS)); float NoH = saturate(dot(normalWS, halfDir)); half LoH = saturate(dot(lightDirectionWS, halfDir)); // GGX Distribution multiplied by combined approximation of Visibility and Fresnel // BRDFspec = (D * V * F) / 4.0 // D = roughness^2 / ( NoH^2 * (roughness^2 - 1) + 1 )^2 // V * F = 1.0 / ( LoH^2 * (roughness + 0.5) ) // See "Optimizing PBR for Mobile" from Siggraph 2015 moving mobile graphics course // https://community.arm.com/events/1155 // Final BRDFspec = roughness^2 / ( NoH^2 * (roughness^2 - 1) + 1 )^2 * (LoH^2 * (roughness + 0.5) * 4.0) // We further optimize a few light invariant terms // brdfData.normalizationTerm = (roughness + 0.5) * 4.0 rewritten as roughness * 4.0 + 2.0 to a fit a MAD. float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f; half LoH2 = LoH * LoH; half specularTerm = brdfData.roughness2 / ((d * d) * max(0.1h, LoH2) * brdfData.normalizationTerm); // On platforms where half actually means something, the denominator has a risk of overflow // clamp below was added specifically to "fix" that, but dx compiler (we convert bytecode to metal/gles) // sees that specularTerm have only non-negative terms, so it skips max(0,..) in clamp (leaving only min(100,...)) #if defined (SHADER_API_MOBILE) || defined (SHADER_API_SWITCH) specularTerm = specularTerm - HALF_MIN; specularTerm = clamp(specularTerm, 0.0, 100.0); // Prevent FP16 overflow on mobiles #endif return specularTerm; }
代码中的注释可以清晰地看到Unity是如何进行优化的,首先看看完整的BRDF公式。
D:微表面分部函数(注意这里的D就是GGX);
G:遮挡可见性函数(注意这里的G并不是GGX);
F:菲涅尔函数;
首先Unity先将G项进行拟合称为V项遮挡可见性函数,如下图所示,拟合结果 V=G/(4(N⋅V)(N⋅L)):
接着对V*F进行拟合:
最终BRDF = V * F * D (前面说到D就是GGX):
如下图所示,按照上面的公式大家可以自己乘一下看看最终是不是正确。
大家可以按照这个公式再对比一下前面函数DirectBRDFSpecular计算的是否正确。