UnityShader入门精要——第9章

时间:2021-12-17 19:03:51

Unity的渲染路径

前向渲染路径、延迟渲染路径、顶点照明渲染路径
Always
ForwardBase 环境光、最重要的平行光、逐顶点/SH光源、lightmaps
ForwardAdd 额外的逐像素光照,每个pass对应一个光源
Deffered 会渲染G缓冲
ShadowCaster 把物体的深度信息渲染到阴影映射纹理lightmap或一张深度纹理中
prepassBase 用于遗留的延迟渲染,该pass会渲染法线和高光反射的指数部分
prepassFinal 用于遗留的延迟渲染,合并纹理、光照和自发光
vertex vertexLMRGBM VeretxLM 遗留的顶点光照

1. 前向渲染路径
每进行一次完整的前向渲染,需要渲染该对象对应的渲染图元,并计算两个缓冲区的信息:颜色缓冲区、深度缓冲区。
多个逐像素光源就要渲染多次。
场景中最亮的平行光:总是逐像素
渲染模式为not important的光源:逐顶点或SH
渲染模式为important:逐像素
BasePass:一个逐像素的平行光以及所有逐顶点和SH光源 lightmap、环境光、自发光、阴影
AdditionalPass:其他影响该物体的逐像素光源,每个光源执行一次pass (不支持阴影,但#pragma multi_compile_fwdadd_fullshadows开启阴影)
一个BasePass仅会执行一次,一个Additional Pass会根据影响该物体的其他逐像素光源数目被多次调用

  1. 顶点照明渲染路径
    硬件配置要求少、运算性能最高、效果最差,不支持阴影、法线映射、高精度的高光反射等。其实是前向渲染路径的子集。通常一个pass完成对物体的渲染。
  2. 延迟渲染路径
    第一个pass计算哪些片元可见,通过深度缓冲实现,可见的片元,将其信息(diffuse、emission、specular、深度、平滑度、normal、lightmap、反射探针、深度缓冲、模板缓冲等)存储到G缓冲区中,再存储到帧缓存中。第二个pass利用G缓冲区的各个片元信息,进行真正的光照计算。仅仅使用2个pass,与光源数目无关。延迟渲染缺点:不支持真正的抗锯齿;不能处理半透明物体;对显卡有要求,要求显卡必须支持MRT等。

unity的光源类型

点光源、聚光灯、面光源

1. 光源类型有什么影响
位置、颜色、衰减。
平行光:位置固定、衰减固定为1
点光源:有位置、衰减由一个函数定义
聚光灯:位置、范围、衰减

  1. 在前向渲染中处理不同的光源类型
    `
    fixed4 frag(v2f i) : SV_Target {
    fixed3 worldNormal = normalize(i.worldNormal);
    #ifdef USING_DIRECTIONAL_LIGHT
    fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
    #else
    fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
    #endif

            fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));

    fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    fixed3 halfDir = normalize(worldLightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

    #ifdef USING_DIRECTIONAL_LIGHT
    fixed atten = 1.0;
    #else
    #if defined (POINT)
    float3 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1)).xyz;
    fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #elif defined (SPOT)
    float4 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1));
    fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #else
    fixed atten = 1.0;
    #endif
    #endif

    return fixed4((diffuse + specular) * atten, 1.0);
    }

    `
    如果场景中一个平行光,两个点光源(Render Mode)为Auto,则会有3个渲染事件(平行光一个,两个点光源各自一个);如果把这两个点光源设为(Not Important),则只有一个渲染事件(两个点光源的显示不出来,只有一个平行光的效果)。
    这是因为,如果逐像素光源数目较多的话,该物体的AdditionalPass会被调用多次,影响性能。
    同时,查看帧调试器可以看到,unity处理这些点光源的顺序是按照他们的重要度排序的(颜色、强度、距离等),最先绘制最重要的光源。

unity的光照衰减

  1. 用于光照衰减的纹理
    使用一张纹理作为查找表来在片元着色器中计算逐像素光照的衰减,避免数学公式的复杂性。但也有弊端:
    需要预处理得到采样纹理,而且纹理大小会影响衰减的精度。
    不直观、也不方便,因此一旦把数据存储到查找表,就无法用其他公式计算衰减。
    但提升性能、得到的效果大部分情况下不错。所以,unity默认是用这种方式计算衰减的。
  float3 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1)).xyz;
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
  1. 使用数学公式计算衰减
    float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
    atten = 1.0/distance; //linear attenuation
#ifdef USING_DIRECTIONAL_LIGHT
fixed atten = 1.0;
#else
#if defined (POINT)
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1)).xyz;
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#elif defined (SPOT)
float4 lightCoord = mul(_LightMatrix0, float4(i.worldPos, 1));
fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#else
fixed atten = 1.0;
#endif
#endif

unity的阴影

  1. 阴影是如何实现的
    光线遇到不透明物体,无法再继续照亮其他物体。Shadowmap技术:把摄像机的位置放在与光源重合的位置,那么光源阴影区域就是摄像机照不到的区域。
    在前向渲染路径中,如果场景中最重要的平行光开启了阴影,unity就会为该光源计算它的Shadowmap(阴影映射纹理)。这张Shadowmap本质上也是一张深度图,它记录了从该光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息)。
    那么在计算Shadowmap时,我们如何判定距离它最近的表面位置呢?一种方法是,先把摄像机放置在光源的位置,然后按照正常的渲染流程,即调用BassPass和Additional Pass来更新深度信息,得到Shadowmap。这种方法会对性能造成一定的浪费,因为我们实际上仅仅需要深度信息而已。而basePass和AdditionalPass中往往涉及很多复杂的光照模型计算。所以,unity选择使用一个额外的pass,专门的pass叫ShadowCaster来更新光源的阴影映射纹理。
    传统的Shadowmap,我们会在正常渲染的pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后,使用xy分量对Shadowmap进行采样,得到Shadowmap中该位置的深度信息。如果该深度值小于该点的深度值(通常由z分量得到),那么说明该点位于引用中。
    unity5中使用屏幕空间的阴影映射纹理:unity通过调用LightMode为ShadowCaster的pass来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。然后根据光源的Shadowmap和摄像机的深度纹理来得到屏幕空间的阴影图。如果摄像机的深度图中记录的表面深度大于转换到Shadowmap中的深度值,就说明该表面虽然是可见的,但却在光源的阴影中。这样,阴影图就包含了屏幕空间中所有有阴影的区域。如果我们想要一个物体接收来自其他物体的阴影,只需要在shader中对阴影图进行采样。由于阴影图是屏幕空间下的,所以,我们首先要把表面坐标从模型空间转换到屏幕空间,然后用这个坐标对阴影图进行采样即可。
    总结下,(1)一个物体接收其他物体的阴影:在shader对阴影映射纹理(Shadowmap,包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果。
    (2)如果我们想要一个物体向其他物体投射阴影,必须把该物体加入到光源的Shadowmap(阴影映射纹理)的计算中,从而让其他物体在对阴影映射纹理采样时,可以得到该物体的相关信息。在unity中,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,unity还会使用这个pass产生一张摄像机的深度纹理。
  2. 不透明物体的阴影
    (1)让物体投射阴影:在unity中,我们会选择是否让一个物体投射或接收阴影。这是通过设置mesh renderer组件中的cast shadows和receive shadows属性实现的。开启了cast shadows,unity就会把该物体加入到光源的Shadowmap计算中,正如前面所说,这个过程是通过为该物体执行LightMode为ShadowCaster的pass来实现的。如果不开启receive shadow,那么当我们调用unity的内置宏和变量计算阴影时,这些宏通过判断该物体没有开启接收阴影,内部就不会为我们计算阴影。
    我们把立方体的这两个选项开启,就会投射阴影。但是现在大家会有疑问:之前不是说unity要执行LightMode为ShadowCaster的pass来渲染Shadowmap和深度图吗,但立方体的shader中并没有这个pass,其实,秘密在于fallback的语义:Fallback “Specular”,即内置的specular,而specular本身也没有这个pass,但由于它的fallback调用了Vertexlit,他会继续毁掉,并最终调用到内置的vertexLit。打开内置的NormalVertexLit.shader,就可以看到里面有LightMode为ShadowCaster的pass了。
    // Pass to render object as a shadow caster
Pass {
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#pragma multi_compile_shadowcaster
#pragma multi_compile_instancing // allow instanced shadow pass for most of the shaders
#include "UnityCG.cginc"

struct v2f {
V2F_SHADOW_CASTER;
//UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert( appdata_base v )
{
v2f o;
//UNITY_SETUP_INSTANCE_ID(v);
//UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}

float4 frag( v2f i ) : SV_Target
{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG

}

它们的用处就是把深度信息写入到渲染目标中。这个pass的渲染目标可以是光源的阴影映射纹理或是摄像机的深度纹理。如果我们把shader中的fallback去掉,立方体就不能投射阴影了。当然,我们也可以不依赖fallback,自行在subshader中定义自己的这个pass。这种自定义的pass可以让我们更灵活控制阴影的产生。
另外,默认情况下,光源的Shadowmap会剔除掉 物体的背面,所以立方体,右侧的平面在光源空间下没有任何正面,因此就不会添加到Shadowmap中,我们可以将cast shadow设为Two Sided来允许对物体的所有面都计算阴影信息。
(2)让物体接收阴影

#include "Lighting.cginc"
#include "AutoLight.cginc"
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
};
v2f vert(a2v v) {
TRANSFER_SHADOW(o);
}
fixed4 frag(v2f i) : SV_Target {
fixed shadow = SHADOW_ATTENUATION(i);
return fixed4(ambient + (diffuse + specular) * atten * shadow, 1.0);
}

SHADOW_COORDS(声明阴影纹理坐标变量_ShadowCoord)、TRANSFER_SHADOW(会调用内置的ComputeScreenPos来计算_ShadowCoord,如果该平台不支持屏幕空间的阴影映射技术,就会使用传统的阴影映射技术。TRANSFER_sHADOW会把顶点坐标从模型空间变换到光源空间后存储到_ShadowCoord中)、SHADOW_ATTENUATION(负责使用_ShadowCoord对相关的纹理进行采样,得到阴影信息)是计算阴影时的“三剑客”。
注意,这些宏需要用到上下文变量来进行计算,例如TRANSFER_SHADOW会使用v.vertex或a.pos来计算坐标,因此为了能够让这些宏正确工作,我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。a2v结构体的顶点坐标变量名必须是vertex,顶点着色器的输出结构体v2f必须命名为vertex,且v2f中的顶点位置变量名为pos。
3. 使用帧调试器查看阴影绘制过程
这些渲染事件可以分为4个部分:Camera.Render下UpdateDepthTexture(DepthPass.Job),即更新摄像机的深度纹理;Drawing下,Render.OpaqueGeometry下的Shadows.RenderJob,shadows.RenderShadowmap渲染得到平行光的阴影映射纹理。然后,RenderForwardOpaque.CollectShadows即根据深度纹理和阴影映射纹理得到屏幕空间的阴影图,最后绘制渲染结果。
也就是,一:更新深度纹理;二:平行光 的阴影映射纹理;三:根据深度纹理和阴影映射纹理计算屏幕空间的阴影图;最后绘制渲染结果。
4. 统一管理光照衰减和阴影
之前讲过,在前向渲染路径的BassPass中,平行光的衰减因子总是等于1,而在AdditionalPass中,我们需要判断该pass处理的光源类型,再使用内置变量和宏计算衰减因子。实际上,光照衰减和阴影对物体最终的渲染结果的影响本质上是相同的,都是把光照衰减因子和阴影值及光照结果相乘得到最终的渲染结果。那么是否有什么办法同时计算两个信息呢?unity在shader里提供了这样的功能,主要是通过内置的UNITY_light_aTTENUATION宏来实现的。

#include "Lighting.cginc"
#include "AutoLight.cginc"
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
};

v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

o.worldNormal = UnityObjectToWorldNormal(v.normal);

o.worldPos = mul(_Object2World, v.vertex).xyz;

// Pass shadow coordinates to pixel shader
TRANSFER_SHADOW(o);

return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));

fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

// UNITY_LIGHT_ATTENUATION not only compute attenuation, but also shadow infos
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

return fixed4((diffuse + specular) * atten, 1.0);
}
由于使用了UNITY_LIGHT_ATTENUATION,我们的basePass和AdditionalPass的代码得以统一,不需要在BasePass中单独处理阴影,也不需要在AdditionalPass中判断光源类型来处理光照衰减。如果我们想要在AdditionalPass中添加阴影效果,就需要使用#pragma multi_compile_fwdadd_fullshadows编译指令来代替AdditionalPass中的#pragma multi_compile_fwdadd指令。这样一来,unity就会为额外的逐像素光源计算阴影,并传递给shader。

5. 透明度物体的阴影

- 透明物体的实现通常会使用AlphaTest或Blend,我们需要小心设置这些物体的fallback。透明度测试的处理比较简单,但如果我们直接用VertexLit、Diffuse、Specular等作为回调,往往无法得到正确的阴影。这是因为透明度测试需要在片元着色器中舍弃某些片元。我们如果像第4节一样,在AlphaTest里面加上阴影的,镂空区域阴影会不正常。这是因为,我们使用的是内置的vertexlit中提供的ShadowCaster来投射阴影,而这个pass中并没有进行任何透明度测试的计算。因此,它会把整个物体的深度信息渲染到深度图和阴影映射纹理中。因此,如果我们想要得到经过透明度测试后的阴影效果,就需要提供一个有透明度测试功能的ShadowCaster Pass。为了既达到效果写的代码又少,将Fallback设置为Transparent/Cutout/VertexLit
,在这个内置文件中,它的ShadowCaster pass也计算了透明度测试,因此,会把裁剪后的物体深度信息写入到深度图和Shadowmap中,但要注意,这个Shader中计算透明度测试时,使用了名为_Cutoff的属性来进行透明度测试,因此,这就要求我们的shader中必须提供名为_cutoff的属性。这样的话,有个问题,出现了一些不应该透过光的部分,这是因为只考虑了正面,没有考虑背面。让正方体的mesh Renderer组件的castShadows属性设置为Two Sides,强制unity在计算Shadowmap时,计算所有面的深度信息。
FallBack "Transparent/Cutout/VertexLit"
为使用透明度混合的物体添加阴影是一件比较复杂的事情。由于blend需要关闭深度写入,由此带来的问题也影响了阴影的生成。总体来说,要想为这些半透明物体产生正确的阴影,需要在每个光源空间下仍然严格按照从后往前的顺序进行渲染,这样,阴影处理会变得很复杂,而且也会影响性能。所以,在unity中,所有blend shader都是不会产生任何阴影的。当然我们可以强制为半透明物体生成阴影,通过
FallBack "Transparent/VertexLit"
实现。效果不一定正确。