APSchmidt 发表于 2021-11-20 20:23

Unity PBR Standard Shader 实现详解(二)Shader框架和 ...

此篇文章为系列文章,如果需要观看前篇请点击
这一篇文章将依据Unity的StandardShader进行逐行的分析,并在需要引入原理讲解的时候适当解释原理。在阅读之前可能需要shader的相关经验,以及一些Untiy引擎使用的基础知识。假如你会写一点简单的shader,但觉得standarShader的学习曲线过于陡峭,那么朋友你找到你的登山车了。
撸完本文,你将获得一个自己的PBRshader,同时了解到使用Unity内置PBS或BRDF方法前,需要做的数据准备。

话不多说,点赞上车
1.shader基本框架的准备(属性及结构体部分):

我们先确认一下shader内需要的功能:因为只是搭建一个PBR实现的测试Shader,所以只添加如下的功能:阴影,雾效等一些简单的效果。不考虑烘焙和合批等内容。
以下代码的侧重详细解释的在PBR阶段,一些基础的内容可以参考冯乐乐小姐姐的书,需要注释的我尽量注释一下
我们先需要搭建一个顶点片段着色器的框架
Shader "yuxuan/PBR_test"
{
    Properties
    {

    }
    SubShader
    {
      pass
      {
            CGPROGRAM



            ENDCG
      }
    }
}然后我们在Properties里面准备一下输入的贴图们
Properties
    {
      _Color ("Color", Color) = (1, 1, 1, 1)
      _MainTex ("Albedo (RGB)", 2D) = "white" {}
      _MetallicTex("Metallic(R),Smoothness(A)",2D) = "white"{}
      //金属贴图最终只获取r分量和a分量
      _Metallic ("Metallic", Range(0, 1)) = 1.0
      _Glossiness("Smoothness",Range(0,1)) = 1.0
      _Normal("NormalMap",2D) = "bump"{}
      _OcclussionTex("Occlusion",2D) = "white"{}
      _AO("AO",Range(0,1)) = 1.0
      _Emission("Emission",Color) = (0,0,0,1)
    }注意这里面我的金属度贴图混合了Metallic和Smoothness两个单通道图。这也是Unity默认的做法,假如自己的贴图效果不对的话,可以在ps或者painter里调换一下通道。
接下来是pass里面的标签和编译指令
Pass
{
    //因为需要灯光设定和Unity配合,所以要加上前向渲染标签。
    Tags { "LightMode" = "ForwardBase" }
   
   
    CGPROGRAM
   
    //顶点片段着色器
    #pragma vertex vert
    #pragma fragment frag
    //指定平台,也可以省略
    #pragma target 3.0
    //雾效和灯光的关键字
    #pragma multi_compile_fog
    #pragma multi_compile_fwdbase
    //一些会用到的cginc文件
    #include "UnityCG.cginc"
    #include "Lighting.cginc"
    #include "UnityPBSLighting.cginc"
    #include "AutoLight.cginc"
    //...然后声明我们在材质球里输入的变量
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _MetallicTex;
fixed _Metallic;
fixed _Glossiness;
fixed _AO;
half3 _Emission;
sampler2D _Normal;
//...输入定点着色器的appdata结构体部分,我们会直接用Unity内置的appdata_Full,来看看里面有啥
下面这段是看看用的,不需要写入shader
struct appdata_full {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 texcoord1 : TEXCOORD1;
    float4 texcoord2 : TEXCOORD2;
    float4 texcoord3 : TEXCOORD3;
    fixed4 color : COLOR;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};基本上我们需要的东西都有了
接下来自己写一个v2f,顶点着色器传入片段着色器的数据
struct v2f
{
    float4 pos:SV_POSITION;//裁剪空间位置输出
    float2 uv: TEXCOORD0; // 贴图UV
    float3 worldPos: TEXCOORD1;//世界坐标
    float3 tSpace0:TEXCOORD2;//TNB矩阵0
    float3 tSpace1:TEXCOORD3;//TNB矩阵1
    float3 tSpace2:TEXCOORD4;//TNB矩阵2
    //TNB矩阵同时也传递了世界空间法线及世界空间切线
   
    UNITY_FOG_COORDS(5)//雾效坐标 fogCoord
    UNITY_SHADOW_COORDS(6)//阴影坐标 _ShadowCoord
   
    //如果需要计算了顶点光照和球谐函数,则输入sh参数。
    #if UNITY_SHOULD_SAMPLE_SH
      half3 sh: TEXCOORD7; // SH
    #endif   
};顶点着色器传递到片段着色器的数据有:最终需要输出到屏幕的顶点位置pos,贴图uv坐标uv,世界坐标worldPos,用于切线空间法线计算的TNB矩阵,雾效坐标,阴影shadowmap采样坐标,和顶点光照球谐光照系数sh。
2.顶点着色器部分

先输出一波code,需要解释的部分我尽量写在了注释里
// vertex shader
//这里没有写appdata结构体,直接采用内置的appdata_Full
v2f vert(appdata_full v)
{
    v2f o;//定义返回v2f 结构体o
    UNITY_INITIALIZE_OUTPUT(v2f, o);//将o初始化。
    o.pos = UnityObjectToClipPos(v.vertex);//计算齐次裁剪空间下的坐标位置
    //这里的uv只定义了两个分量。TranformTex方法加入了贴图的TillingOffset值。
    o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;//世界空间坐标计算。
    float3 worldNormal = UnityObjectToWorldNormal(v.normal);//世界空间法线计算
    half3 worldTangent = UnityObjectToWorldDir(v.tangent);//世界空间切线计算
    //利用切线和法线的叉积来获得副切线,tangent.w分量确定副切线方向正负,
    //unity_WorldTransformParams.w判定模型是否有变形翻转。
    half3 worldBinormal = cross(worldNormal,worldTangent)*v.tangent.w *unity_WorldTransformParams.w;

    //组合TBN矩阵,用于后续的切线空间法线计算。
    o.tSpace0 = float3(worldTangent.x,worldBinormal.x,worldNormal.x);
    o.tSpace1 = float3(worldTangent.y,worldBinormal.y,worldNormal.y);
    o.tSpace2 = float3(worldTangent.z,worldBinormal.z,worldNormal.z);

    // SH/ambient和顶点光照写入o.sh里
    #ifndef LIGHTMAP_ON
      #if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
            o.sh = 0;
            // Approximated illumination from non-important point lights
            //如果有顶点光照的情况(超出系统限定的灯光数或者被设置为non-important灯光)
            #ifdef VERTEXLIGHT_ON
                o.sh += Shade4PointLights(
                unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
                unity_LightColor.rgb, unity_LightColor.rgb,
                unity_LightColor.rgb, unity_LightColor.rgb,
                unity_4LightAtten0, o.worldPos, worldNormal);
            #endif
            //球谐光照计算(光照探针,超过顶点光照数量的球谐灯光)
            o.sh = ShadeSHPerVertex(worldNormal, o.sh);
      #endif
    #endif // !LIGHTMAP_ON

    UNITY_TRANSFER_LIGHTING(o, v.texcoord1.xy);
    // pass shadow and, possibly, light cookie coordinates to pixel shader
    //在appdata_full结构体里。v.texcoord1就是第二套UV,也就是光照贴图的UV。
    //计算并传递阴影坐标

    UNITY_TRANSFER_FOG(o, o.pos); // pass fog coordinates to pixel shader。计算传递雾效的坐标。

    return o;
}顶点着色器主要处理了输出到片段着色器的一些数据。再者就是处理了顶点光照效果和球谐光照效果,这是前向灯光渲染需要做的事情。
顶点着色器里没有进行PBR相关计算,主要还是准备数据。
3.片段着色器部分

在进入片段着色器之前,我们先看看片段着色器最终输出用的两个函数
//基于PBS的全局光照(gi变量)的计算函数。计算结果是gi的参数(Light参数和Indirect参数)。
//注意这一步还没有做真的光照计算。
LightingStandard_GI(o, giInput, gi);
fixed4 c = 0;
// realtime lighting: call lighting function
//PBS计算
c += LightingStandard(o, worldViewDir, gi);
return c;这里的LightingStandard_GI是为了准备gi变量的参数,并不是最终的计算。
LightingStandard进行了最终的PBR计算。
所以我们在片段着色器里,最主要做的事情,就是准备giInput,gi这两个变量。
那么我们可以开始看代码了,首先是处理一下一些常用的向量:法线,灯光方向,视线方向
// fragment shader
fixed4 frag(v2f i): SV_Target
{
   
    half3 normalTex = UnpackNormal(tex2D(_Normal,i.uv));//使用法线的采样方式对法线贴图进行采样。
    //切线空间法线(带贴图)转向世界空间法线,这里是常用的法线转换方法。
    half3 worldNormal = half3(dot(i.tSpace0,normalTex),dot(i.tSpace1,normalTex),
                              dot(i.tSpace2,normalTex));
    worldNormal = normalize(worldNormal);//所有传入的“向量”最好归一化一下
    //计算灯光方向:注意这个方法已经包含了对灯光的判定。
    //其实在forwardbase pass中,可以直接用灯光坐标代替这个方法,因为只会计算Directional Light。
    fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    float3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));//片段指向摄像机方向viewDir
//...
}准备好之后,需要准备SurfaceOutput o,再具体的操作之前,先看看SurfaceOutput这个结构体里存了些什么:
struct SurfaceOutputStandard
{
    fixed3 Albedo;      // base (diffuse or specular) color
    float3 Normal;      // tangent space normal, if written//这里传入的是worldNormal,是官方写错了?
    half3 Emission;
    half Metallic;      // 0=non-metal, 1=metal
    // Smoothness is the user facing name, it should be perceptual smoothness
    //but user should not have to deal with it.
    // Everywhere in the code you meet smoothness it is perceptual smoothness
    //smooth这里untiy官方费劲吧啦解释很多,其实就是smooth以及其转换的roughness并不是最终的roughness
    //之后会做一些转换,在这里不用管太多。
    half Smoothness;    // 0=rough, 1=smooth
    half Occlusion;   // occlusion (default 1)
    fixed Alpha;      // alpha for transparencies
};可以看到 SurfaceOutputStandard其实就是一个表面信息的总集合,我们只需要根据名称一个个赋予就可以。之后一些内置的函数方法会用到这个结构体。
那么继续我们的代码
SurfaceOutputStandard o;//声明变量
UNITY_INITIALIZE_OUTPUT(SurfaceOutputStandard,o);//初始化里面的信息。避免有的时候报错干扰
fixed4 AlbedoColorSampler = tex2D(_MainTex, i.uv) * _Color;//采样颜色贴图,同时乘以控制的TintColor
o.Albedo = AlbedoColorSampler.rgb;//颜色分量,a分量在后面
o.Emission = _Emission;//自发光
fixed4 MetallicSmoothnessSampler = tex2D(_MetallicTex,i.uv);//采样Metallic-Smoothness贴图
o.Metallic = MetallicSmoothnessSampler.r*_Metallic;//r通道乘以控制色并赋予金属度
o.Smoothness = MetallicSmoothnessSampler.a*_Glossiness;//a通道乘以控制色并赋予光滑度
o.Alpha = AlbedoColorSampler.a;//单独赋予透明度
o.Occlusion = tex2D(_OcclussionTex,i.uv)*_AO; //采样AO贴图,乘以控制色,赋予AO
o.Normal = worldNormal;//赋予法线接下来在进入光照计算前先算一个衰减系数atten
// compute lighting & shadowing factor
//计算光照衰减和阴影
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos)
//注意这个atten会在方法里进行声明,在外面就不必再声明过了其中关于UNITY_LIGHT_ATTENUATION是光照衰减方法,在AutoLight.cginc里面会根据不同的光照类型进行计算。其实再forwardbase中逐像素光只会计算平行光,平行光是没有衰减的,其实这里可以省略,但为了和forwardadd一致,所以这里保留了做个念想。
接下来再计算一个新的变量gi,我们先看看UnityGI结构体包含着什么
struct UnityGI
{
    UnityLight light;
    UnityIndirect indirect;
};其实里面就两个内容,一个直射光light,一个间接光indirect。GI的本意就是全局光照,所以GI存储的是光照信息。那这两个部分我们分别看下源码
struct UnityLight
{
    half3 color;//光照颜色和强度
    half3 dir;//光照方向
    //ndotl已经被弃用,在这里只是为了旧版本兼容保证稳定。
    halfndotl; // Deprecated: Ndotl is now calculated on the fly and is no longer stored. Do not used it.
};

struct UnityIndirect
{
    half3 diffuse;//漫反射部分
    half3 specular;//高光直接反射部分
};light里面存了灯光的颜色(包括了强度)和方向。Indirect里面存储了漫反射和反射两个部分的间接光亮度。
了解完UnityGI gi后,我们写入代码
//初始化全局光照,输入直射光参数。间接光参数置零待更新。
// Setup lighting environment
UnityGI gi;//声明变量
UNITY_INITIALIZE_OUTPUT(UnityGI, gi);//初始化归零
gi.indirect.diffuse = 0;//indirect部分先给0参数,后面需要计算出来。这里只是示意
gi.indirect.specular = 0;
gi.light.color = _LightColor0.rgb;//unity内置的灯光颜色变量
gi.light.dir = lightDir;//赋予之前计算的灯光方向。其中的indirect部分我们在后面会用内置方法进行inout运算,这边先归零放着。
接下来下一个要解决的结构体是UnityGIInput,我们还是先看一下cginc内的定义
struct UnityGIInput
{
    UnityLight light; // pixel light, sent from the engine

    float3 worldPos;
    half3 worldViewDir;
    half atten;
    half3 ambient;

    // interpolated lightmap UVs are passed as full float precision data to fragment shaders
    // so lightmapUV (which is used as a tmp inside of lightmap fragment shaders) should
    // also be full float precision to avoid data loss before sampling a texture.
    float4 lightmapUV; // .xy = static lightmap UV, .zw = dynamic lightmap UV

    #if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION) || defined(UNITY_ENABLE_REFLECTION_BUFFERS)
    float4 boxMin;
    #endif
    #ifdef UNITY_SPECCUBE_BOX_PROJECTION
    float4 boxMax;
    float4 probePosition;
    #endif
    // HDR cubemap properties, use to decompress HDR texture
    float4 probeHDR;
};看上去有点唬人,实际上就是灯光计算需要的一些参数,包括之前UnityGI gi里面已经设定好的灯光参数,视线方向,衰减,顶点着色器里面计算好的顶点球谐光照信息,灯光贴图坐标等。最后有很多分支的是反射探针的一些采样信息分支。我们在下面实际赋予的时候讲。
那么我们实际来在shader中赋予这些值
//初始化giInput并赋予已有的值。此参数为gi计算所需要的输入参数。
// Call GI (lightmaps/SH/reflections) lighting function
UnityGIInput giInput;
UNITY_INITIALIZE_OUTPUT(UnityGIInput, giInput);//初始化归零
giInput.light = gi.light;//之前这个light已经给过,这里补到这个结构体即可。
giInput.worldPos = i.worldPos;//世界坐标
giInput.worldViewDir = worldViewDir;//摄像机方向
giInput.atten = atten;//在之前的光照衰减里面已经被计算。其中包含阴影的计算了。

//球谐光照和环境光照输入(已在顶点着色器里的计算,这里只是输入)
#if UNITY_SHOULD_SAMPLE_SH && !UNITY_SAMPLE_FULL_SH_PER_PIXEL
    giInput.ambient = i.sh;
#else//假如没有做球谐计算,这里就归零
    giInput.ambient.rgb = 0.0;
#endif

//反射探针相关
giInput.probeHDR = unity_SpecCube0_HDR;
giInput.probeHDR = unity_SpecCube1_HDR;
#if defined(UNITY_SPECCUBE_BLENDING) || defined(UNITY_SPECCUBE_BOX_PROJECTION)
    giInput.boxMin = unity_SpecCube0_BoxMin; // .w holds lerp value for blending
#endif
#ifdef UNITY_SPECCUBE_BOX_PROJECTION
    giInput.boxMax = unity_SpecCube0_BoxMax;
    giInput.probePosition = unity_SpecCube0_ProbePosition;
    giInput.boxMax = unity_SpecCube1_BoxMax;
    giInput.boxMin = unity_SpecCube1_BoxMin;
    giInput.probePosition = unity_SpecCube1_ProbePosition;
#endif这里详细的讲一下反射探针这边的分支:
unity_SpecCube0_HDR是默认的反射探针的数据,unity会根据你场景内是否有自定义反射探针及物体所在的探针位置进行定义。而假如你的物体在两个反射探针的融合处,则会再给你第二个探针的数据,也就是unity_SpecCube1_HDR。
SPECCUBE_BLENDING是指反射探针融合的开启与否。假如没有开启BLENDING的话,当多个反射探针有过度时,会产生渲染效果的突然变化:(示例图片来自普洛透斯)



反射效果突变



反射效果混合

SPECCUBE_BLENDING需要在Graphic Settings>Tier settings里设置,且在物体的MeshRenderer里面也有有设置(默认是Blend开启)。关于反射探针的具体使用和变化,可以参考Untiy的官方文档:
最后一项UNITY_SPECCUBE_BOX_PROJECTION是指是否开启这个开关


假如默认不开启,则反射探针的采样是把反射探针环境当做一个无限大的天空盒采样,物体的位置和采样坐标没有关系。而开启后,会根据采样的片段在反射探针的实际位置来计算采样坐标,从而在反射探针内的不同位置,会有不同的采样值。就好像一个金属球,在室内的不同位置,采样的环境是会不同的。而在室外的广阔空间下,移动一定的距离采样值几乎没有变化。
具体在UnityGI gi里,我们只是根据反射探针的不同设置,为传入之后的函数输入不同的参数而已。根据示例代码进行设定即可。
那么到了这里,我们所有的SurfaceOutputStandard,UnityGI和UnityGIInput的数据就已经准备完毕,最后很淡定的祭出Unity内置的方法
//基于PBS的全局光照(gi变量)的计算函数。计算结果是gi的参数(Light参数和Indirect参数)。注意这一步还没有做真的光照计算。
LightingStandard_GI(o, giInput, gi);
fixed4 c = 0;
// realtime lighting: call lighting function
//PBS计算
c += LightingStandard(o, worldViewDir, gi);这里我们使用了两个内置的方法,其中
LightingStandard_GI,主要计算gi的indirect项;
LightingStandard用于真正的计算PBS。
这篇小文已愈万字,所以留在本系列文章的后面详细解释这两个函数及方法。
最后我们的shader补上雾效的计算,就大工搞成啦!
//....
//叠加雾效。
UNITY_EXTRACT_FOG(i);//此方法定义了一个片段着色器里的雾效坐标变量,并赋予传入的雾效坐标。
UNITY_APPLY_FOG(_unity_fogCoord, c); // apply fog
return c;
}
ENDCG
}
    FallBack "Diffuse"
}最后FallBack一个shader主要是为了获得shadowcaster,让物体可以产生阴影。
丢一个小球球到场景里试试,结果和Standard材质的结果很类似。


最后把我的源码献给点赞的朋友

http://zhstatic.zhihu.com/assets/zhihu-components/file-icon/zhimg_answer_editor_file_other.svgyuxuanPBR.shader
10.9K
· 百度网盘


4.总结

本文为系列文章,共四篇,完整链接在此
此第二篇文章大部分地方都在讲代码,为了让更广泛的读者可以阅读,我说的比较啰嗦。只是准备一下数据,也没什么图好po的。之后讲两个最重要的方法时,才是最精彩的部分。
在写作这篇文章时,我只是一个刚接触shader内容的模型师,文内可能有所疏漏,希望能有大佬斧正。我参考了如下资料,献出我的膝盖以示感谢:
taecg老师的系列教学《渲染管线与UnityShader编程》冯乐乐女神的书《UnityShader入门精要》毛星云(浅墨)的系列文章《基于物理的渲染(PBR)白皮书》
peace:)

APSchmidt 发表于 2021-11-20 20:30

太感谢了,点赞支持

stonstad 发表于 2021-11-20 20:35

[开心]

kirin77 发表于 2021-11-20 20:35

好久没看到这样实作的文章了

DomDomm 发表于 2021-11-20 20:42

你好,我把你写的Shader放Unity2017里用, 报错UNITY_LIGHT_ATTENUATION没有定义,而在2019里却没有这个报错,请问是啥原因,在2017里该怎么修改?

super1 发表于 2021-11-20 20:43

SurfaceOutputStandard中的Normal是tangent space的,这是unity对surface shader的封装,最后计算光照会统一转到同一空间下,你重写vert和frag只是用他的数据结构,当然不受影响。

APSchmidt 发表于 2021-11-20 20:48

可以到相应版本的内置cginc里搜一下内部的函数调用。

RhinoFreak 发表于 2021-11-20 20:51

大佬,你的R和A是R通道和Alpha通道吗?
页: [1]
查看完整版本: Unity PBR Standard Shader 实现详解(二)Shader框架和 ...