URP下定制PBRShader

复刻URPLit

考虑到项目移植各个平台的顺利,URP还是目前最实用的管线了,但自带的lit还是缺少很多功能,比如对VR很重要的几何高光抗锯齿,混合SSR也不是很方便,各向异性,环境法线,高光遮蔽,视差映射等等功能都是缺少的。
为了功能的收缩与扩张方便,还是有必要手撸一个PBR的。
而且Lit的代码为了兼容各种环境,比如Dot,VR,LODCross,做了大量的判断,代码很杂乱,很难修改。整理一遍也是为了更好的理解URP的Lit着色器。
以上存粹是为了说服自己造这个轮子。。。

需求

先从简单的搞起,大致输入就先仿照URP最精简的Lit

  • 按金属粗糙的工作流
  • 不透明
  • 仅正面
  • alpha不裁切
  • 接收阴影
  • 阴影不级联
  • BaseMap(RGBA)
  • BaseColor(RGBA)
  • Metallic
  • MetallicMap(R金属 A光滑)
  • Smoothness
  • NormalMap
  • 先不考虑高度图
  • OcclusionMap
  • OcclusionStrength
  • EmissionColor
  • EmissionMap
  • 先不考虑细节贴图
  • 先不考虑清漆
  • 实时光仅平行光
  • 有环境反射
  • 先不考虑烘焙

开搞

这里就没什么好说的了,公式就翻翻URP的lit的实现,先尽量汇总到一个文件里,方便之后比对。

Properties

就参照上面的需求,大致列一下属性,不够之后再加。

Properties
{
    [MainTexture] _BaseMap("Albedo", 2D) = "white" {}
    [MainColor] _Color("Color", Color) = (1,1,1,1)
    
    _Metallic("Metallic", Range(0.0, 1.0)) = 0.0
    _MetallicGlossMap("Metallic", 2D) = "white" {}
    _Smoothness("Smoothness", Range(0.0, 1.0)) = 0.5
    
    _BumpMap("Normal Map", 2D) = "bump" {}
    
    _OcclusionStrength("Strength", Range(0.0, 1.0)) = 1.0
    _OcclusionMap("Occlusion", 2D) = "white" {}

    [HDR] _EmissionColor("Color", Color) = (0,0,0)
    _EmissionMap("Emission", 2D) = "white" {}
}

Tags

按照URP文档的要求,要配置一些通用的Tag

Tags
{
    "RenderType" = "Opaque" 
    "RenderPipeline" = "UniversalPipeline"
    "UniversalMaterialType" = "Lit"
}

Pass

URP要求一个Shader中要实现多个Pass,分别包含了

  • UniversalForward
  • ShadowCaster
  • UniversalGBuffer
  • DepthOnly
  • DepthNormals
  • Meta
  • Universal2D

顾名思义,不一一介绍了,最为最简单的仅前向的shader,这里就挑出以下五个实现

  • UniversalForward
  • ShadowCaster
  • DepthOnly
  • DepthNormals
  • Meta

关键字

参照urp的lit,分为材质的关键字、管线的关键字、unity的关键字和GPUInstancing。
材质的关键字主要用来定义一些局部的属性,如是否启用法线贴图,是否启用高度图,光滑的从哪里采样等等
这部分就先省略,之后优化时再加。
然后是管线的关键字,主要定义一些是否启用了主光源阴影,屏幕阴影,附加光阴影,软阴影等等。
这里就也先省略了,需要对应的功能再加。
然后是untiy的关键字。包含了光照贴图,阴影的shadowMask,雾气等等。

存储结构

参考lit,把输入以及一些常用的采样分到一个文件中,具体的着色器函数在另一个文件,方便引用,那这里也依葫芦画瓢。
值得一提的是URP中还把常见的一些贴图又分到了一个SurfaceInpute中,这里就没有必要了,合并到一起。

#ifndef MK_LIT_INPUT_INCLUDED
#define MK_LIT_INPUT_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;

half4 _BaseColor;
half4 _EmissionColor;

half _Smoothness;
half _Metallic;
half _OcclusionStrength;
CBUFFER_END

TEXTURE2D(_BaseMap);            SAMPLER(sampler_BaseMap);
TEXTURE2D(_BumpMap);            SAMPLER(sampler_BumpMap);
TEXTURE2D(_EmissionMap);        SAMPLER(sampler_EmissionMap);
TEXTURE2D(_OcclusionMap);       SAMPLER(sampler_OcclusionMap);
TEXTURE2D(_MetallicGlossMap);   SAMPLER(sampler_MetallicGlossMap);


#endif // MK_LIT_INPUT_INCLUDED

都是很常见的属性。

顶点着色器

Varyings LitPassVertex(Attributes input)
{
    // 初始化赋值
    Varyings output = (Varyings)0;

    // GPUInstance和VR单pass的套路
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

    // 一个方便的小函数,包含了各种位置的计算
    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);

    //  求BTN
    // normalWS and tangentWS already normalize.
    // this is required to avoid skewing the direction during interpolation
    // also required for per-vertex lighting and SH evaluation
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    // 顶点光照颜色 这里就不用顶点光照了
    half3 vertexLight = VertexLighting(vertexInput.positionWS, normalInput.normalWS);

    // 也省略雾气
    half fogFactor = 0;
    #if !defined(_FOG_FRAGMENT)
        fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
    #endif

    // UV缩放
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);

    // 输出法线
    // already normalized from normal transform to WS.
    output.normalWS = normalInput.normalWS;

    // 输出切线
#if defined(REQUIRES_WORLD_SPACE_TANGENT_INTERPOLATOR) || defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
    real sign = input.tangentOS.w * GetOddNegativeScale();
    half4 tangentWS = half4(normalInput.tangentWS.xyz, sign);
#endif
#if defined(REQUIRES_WORLD_SPACE_TANGENT_INTERPOLATOR)
    output.tangentWS = tangentWS;
#endif

    // 切线空间的视角向量
#if defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS);
    half3 viewDirTS = GetViewDirectionTangentSpace(tangentWS, output.normalWS, viewDirWS);
    output.viewDirTS = viewDirTS;
#endif

    // 输出光照贴图UV
    OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, output.staticLightmapUV);
#ifdef DYNAMICLIGHTMAP_ON
    output.dynamicLightmapUV = input.dynamicLightmapUV.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif
    // 如果没有光照贴图,就用光照探针的SH
    OUTPUT_SH(output.normalWS.xyz, output.vertexSH);

    // 雾气赋值
#ifdef _ADDITIONAL_LIGHTS_VERTEX
    output.fogFactorAndVertexLight = half4(fogFactor, vertexLight);
#else
    output.fogFactor = fogFactor;
#endif

    // 输出世界位置
#if defined(REQUIRES_WORLD_SPACE_POS_INTERPOLATOR)
    output.positionWS = vertexInput.positionWS;
#endif

    // 输出阴影贴图UV
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    output.shadowCoord = GetShadowCoord(vertexInput);
#endif

    output.positionCS = vertexInput.positionCS;

    return output;
}

那精简之后得到

Varyings LitPassVertex(Attributes input)
{
    Varyings output = (Varyings)0;

    // todo instance VR

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);

    output.normalWS = normalInput.normalWS;

    real sign = input.tangentOS.w * GetOddNegativeScale();
    half4 tangentWS = half4(normalInput.tangentWS.xyz, sign);
    output.tangentWS = tangentWS;

    OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, output.staticLightmapUV);

    output.positionWS = vertexInput.positionWS;


    output.shadowCoord = GetShadowCoord(vertexInput);

    output.positionCS = vertexInput.positionCS;

    return output;
}

片元

然后就是最复杂的片元了
URP的SurfaceData略显复杂,精简一下

struct SurfaceData
{
    half3 albedo;
    half  metallic;
    half  smoothness;
    half3 normalTS;
    half3 emission;
    half  occlusion;
};

然后是初始化SurfaceData,就是采样一堆贴图,然后根据系数得出最终的每个片元的属性。简化一下如下

inline void InitializeStandardLitSurfaceData(float2 uv, out SurfaceData outSurfaceData)
{
    half4 albedoAlpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
    outSurfaceData.albedo = albedoAlpha.rgb * _BaseColor.rgb;

    half4 metallicSmoothness = SAMPLE_TEXTURE2D(_MetallicGlossMap, sampler_MetallicGlossMap, uv);
    outSurfaceData.metallic = metallicSmoothness.r * _Metallic;
    outSurfaceData.smoothness = metallicSmoothness.a * _Smoothness;
    
    half4 n = SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, uv);
    outSurfaceData.normalTS = UnpackNormal(n);

    outSurfaceData.emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, uv).rgb * _EmissionColor.rgb;

    half occ = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv).g;
    
    outSurfaceData.occlusion = LerpWhiteTo(occ, _OcclusionStrength);
}

接着是初始化顶点的输入,主要涉及各种空间下的坐标以及GI、阴影、雾气等。

void InitializeInputData(Varyings input, half3 normalTS, out InputData inputData)
{
    inputData = (InputData)0;

    // 世界坐标
#if defined(REQUIRES_WORLD_SPACE_POS_INTERPOLATOR)
    inputData.positionWS = input.positionWS;
#endif

    // 视角方向
    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
    // 法线相关
#if defined(_NORMALMAP) || defined(_DETAIL)
    // 如果有法线贴图就要用TBN重新计算
    float sgn = input.tangentWS.w;      // should be either +1 or -1
    float3 bitangent = sgn * cross(input.normalWS.xyz, input.tangentWS.xyz);
    half3x3 tangentToWorld = half3x3(input.tangentWS.xyz, bitangent.xyz, input.normalWS.xyz);

    #if defined(_NORMALMAP)
    inputData.tangentToWorld = tangentToWorld;
    #endif
    inputData.normalWS = TransformTangentToWorld(normalTS, tangentToWorld);
#else
    inputData.normalWS = input.normalWS;
#endif

    // 归一化,还要考虑向量为0
    inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS);
    inputData.viewDirectionWS = viewDirWS;

    // 阴影
#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
    inputData.shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
    inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS);
#else
    inputData.shadowCoord = float4(0, 0, 0, 0);
#endif
    // 雾
#ifdef _ADDITIONAL_LIGHTS_VERTEX
    inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactorAndVertexLight.x);
    inputData.vertexLighting = input.fogFactorAndVertexLight.yzw;
#else
    inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactor);
#endif

    // GI
#if defined(DYNAMICLIGHTMAP_ON)
    inputData.bakedGI = SAMPLE_GI(input.staticLightmapUV, input.dynamicLightmapUV, input.vertexSH, inputData.normalWS);
#else
    inputData.bakedGI = SAMPLE_GI(input.staticLightmapUV, input.vertexSH, inputData.normalWS);
#endif

    // 屏幕空间UV
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
    // 阴影遮罩
    inputData.shadowMask = SAMPLE_SHADOWMASK(input.staticLightmapUV);

    #if defined(DEBUG_DISPLAY)
    #if defined(DYNAMICLIGHTMAP_ON)
    inputData.dynamicLightmapUV = input.dynamicLightmapUV;
    #endif
    #if defined(LIGHTMAP_ON)
    inputData.staticLightmapUV = input.staticLightmapUV;
    #else
    inputData.vertexSH = input.vertexSH;
    #endif
    #endif
}

GI

依据不同的光照贴图的编码,有不同的解码方式,这里先取巧用FULL_HDR,就可以不用解码了。
大致流程就是采样贴图,得出方向与强度,用halfLambert得出最终的GI。

阴影

这里仅算主光源阴影,Untiy还会采样烘焙的阴影贴图,去混合实时光阴影与烘焙阴影。

PBR

整个计算流程就是分为算AO,实际的URP会在这里取看有没有SSAO,我们先省略。
然后得出主光源的信息,这里就包含了阴影。
然后就是计算GI,主光源,附加光,顶点光。
最后两项先省略。
代码如下:

half4 PBR(InputData inputData, SurfaceData surfaceData)
{
    BRDFData brdfData;
    
    InitializeBRDFData(surfaceData, brdfData);
    AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
    
    Light mainLight = GetMainLight(inputData, 1, aoFactor);
    
    LightingData lightingData = CreateLightingData(inputData, surfaceData);

    lightingData.giColor = GlobalIllumination(brdfData,
                                              inputData.bakedGI, aoFactor.indirectAmbientOcclusion, inputData.positionWS,
                                              inputData.normalWS, inputData.viewDirectionWS, inputData.normalizedScreenSpaceUV);
    
    lightingData.mainLightColor = LightingPhysicallyBased(brdfData,
                                                             mainLight,
                                                             inputData.normalWS, inputData.viewDirectionWS);
    return CalculateFinalColor(lightingData, 1);
}

详细的计算GI与实时光的代码就不贴了,总之这样就算有了一个改动方便的PBR。后续再想增加一些SSR、环境法线之类的功能就方便很多了。
当然这个还很不完善,比如级联阴影,透明,等等都没有考虑。
很多分支都被删减了,先看看好不好用吧,如果可行那就以后再慢慢补充。

分文件

还是有必要按照原来逻辑把hlsl代码分开,不然后期不太好维护。
大致分为以下几个文件

  • AmbientOcclusion AO计算,包括直接光与间接光,SSAO得出的结果在前向渲染里也是在这里采样的。
  • BRDF 这里就是PBR那一套公式了
  • EntityLighting 涉及球谐函数以及光照贴图的采样(包括解码)
  • GlobalIllumination 在对EntityLighting封装的基础上,添加了诸如BoxProjected,混合,根据光照贴图编码方式选择具体的解码,采样反射探针等
  • ImageBasedLighting 包含了一些基于图像的光照工具函数
  • Lighting 主要的光照计算,除了调用上面的BRDF,其实还包含了Unlit,BlinnPhong,BakedLit等多种光照模型。
  • LitForwardPass 包含顶点与片元。
  • LitInput 包含从材质传递过来的全部输入
  • RealtimeLights 实时光的获取,并调用阴影
  • Shadows 阴影相关,阴影贴图的采样,级联,也包含了采样光照贴图里的阴影贴图并混合
  • SurfaceData 一个结构体,包含了一个要进入光照计算的片元的表面数据,如漫反射,金属,粗糙,遮蔽等。
  • InputData 一个结构体,包含了一个要进入光照计算的片元的几何数据,如位置坐标,世界法线,阴影坐标,BakedGI等。

根据这些文件的调用关系,再梳理一下就是
LitForwardPass是总入口,汇总并梳理了全部的输入,整合成两个输入即InputData与SurfaceData。
当然,出于性能的考虑,这里的阴影坐标也提前在顶点着色器中获取了。
然后选择Lighting中的一个光照模型。类似与Lighting实现了一个通用的光照模型的接口。
这里就选用PBR的模型。
然后就开始采用PBR的公式,分以下几步,

  • 首先是从InputData与SurfaceData构造BRDFData。
    • BRDFData与SurfaceData很类似,主要是增加了一些诸如roughness2MinusOne等方便计算的属性。
  • 取AO,从AmbientOcclusion取出AO,如果有SSAO,也会在这里处理。
  • 取主光照数据(数据结构Light),从RealtimeLights获取实时光的数据。
    • 这里又会调用Shadows来包含对应的阴影
    • Light包含 方向,颜色,阴影。
  • 然后是构造LightingData,包含了GI,主光源,附加光,顶点光,自发光。
    • GI是调用BRDF的工具函数由公式计算得出,主要的输入如下
      • 间接漫反射就是直接取的光照贴图,或者球谐函数。
      • 间接高光就是采用的反射贴图了
    • 主光源就单纯的用BRDF公式,也是分为直接漫反射和直接高光。
    • 出于简化的目的,其余的先不看了。
  • 最后就是将这些来自各种光源的光叠加,得出最终的输出。

这应该就是URP下的最简单的PBR实现了

阴影投射Pass

只需在顶点着色器中应用一下法线和深度偏移,其余没有什么特殊的。

球谐函数

根据LIGHTMAP_ON这个关键字,来判断GI是用贴图还是球谐函数。

级联阴影

通过_MAIN_LIGHT_SHADOWS_CASCADE来判断是否级联,然后根据距离各个级联球的中心的距离与设定距离的差值,来判断应该取哪一个级联。

总结

说不上是从零开始手撸PBR造轮子,只能算是精简了URP的原本的lit的着色器,主要目的还是理清其各个文件的逻辑关系,方便之后实现各种效果。


URP下定制PBRShader
https://www.kuanmi.top/2023/06/14/pbr/
作者
KuanMi
发布于
2023年6月15日
许可协议