本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
Unity的阴影
在本章中,我们将学习Unity是如何让一个物体向其他物体投射阴影,以及如何让一个物体接受来自其他物体的阴影。
阴影的实现
在现实中的阴影是如何产生的?由于光是直射的,因此当一道光线遇到一个不透明的物体,那么这个物体就会投射阴影,阴影部分区域是光无法到达的区域。
在实时渲染中,最常使用的是一种叫做Shadow Map的技术,这种技术理解起来很简单——就是把摄像机位置放置与光源重合的位置上,那么摄像机看不到的地方即为阴影区域了。
在前向渲染路径中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算它的阴影映射纹理(Shadow Map) 。这张阴影映射纹理本质上也是一张深度图。它记录了从该光源的位置触发、能看到的场景中距离它最近的表面位置(深度信息)。
在计算阴影映射纹理时,我们如何判定距离它最近的表面位置?一种方法是,先把摄像机放置到光源位置上,然后按照正常的渲染流程,即调用Base Pass和Additional Pass来更新深度信息,得到阴影映射纹理。但是我们实际上仅仅只需要深度信息即可,而Base Pass和Additional Pass涉及了太多复杂的光照模型计算。
因此Unity使用了一个额外的Pass来专门更新光源的阴影映射纹理,需要将LightMode 标签设置为ShadowCaster。这个Pass的渲染目标不是帧缓存,而是阴影映射纹理。Unity首先将摄像机放置到光源的位置上,然后调用该Pass,通过对顶点变换后得到光影空间下的位置,并据此输出深度信息到阴影映射纹理中。
因此,当开启了光源的阴影效果之后,底层渲染引擎首先会在当前渲染物体的UnityShader中找到LightMode为ShadowCaster的Pass,如果没有,它就会在Fallback指定的UnityShader中继续寻找,如果仍然没有找到,该物体就无法向其他物体投射阴影(但它仍然可以接收来自其他物体的阴影)。
- 在传统的阴影映射纹理实现中,我们会在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后我们使用xy分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息,如果该深度值小于该顶点的深度值(通常由z分量得到),那么说明该点位于阴影中。
- 在Unity中,我们使用了屏幕空间的阴影映射技术(Screen space Shadow Map) 。该方法原本是延迟渲染中产生阴影的方法,Unity首先会调用LightMode 标签设置为ShadowCaster 的Pass来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。这一步和上一种方法相似,区别在于传统方法是在光源空间下获取并对比阴影深度和顶点深度。而该法是比较光源的阴影纹理深度和摄像机的深度纹理,若摄像机的深度图中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然是可见的,但出于该光源的阴影中。(通过此法,我们比较摄像机的深度纹理和光源的阴影纹理从而得到一张阴影图,由于阴影图是屏幕空间下的,我们只需要把物体的表面坐标从模型空间变换到屏幕空间,再使用这个UV坐标对阴影图进行采样即可)
综上所述,两种阴影映射技术方法,第一种是通过得到阴影映射纹理(在Unity中也是使用LightMode 标签设置为ShadowCaster 实现的),并在正常的Pass中将顶点变换到光源空间后对阴影映射纹理进行采样,并比较该位置的深度值和阴影位置的深度信息判断是否位于阴影中
而第二种方法则是使用LightMode 标签设置为ShadowCaster 的Pass得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理,比较二者之后得到一张屏幕空间的阴影图,这样我们只需在Shader中将物体的表面坐标从模型空间变换到屏幕空间,再对该阴影图进行采样即可。
总结一下,一个物体接收来自其他物体的阴影,以及它向其他物体投射阴影是两个过程:
- 如果我们想要物体接收来自其他物体的阴影,就必须再Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果
- 如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息,在Unity中,产生阴影映射纹理的过程是通过为该物体执行LightMode = ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,unity还会使用这个Pass产生一张摄像机的深度纹理。
为了使得物体可投射阴影,我们进行如下操作:
首先在平行光选项中选择阴影的属性,此处选择了Soft shadows,软阴影的边缘处更加柔和,不会有锯齿状。我们可以通过下方的阴影属性面板来调整阴影。
对于物体而言,阴影计算分为向其他物体投射阴影和接收其他物体阴影这两个过程。也分别对应上图中的两个选项。
其中CaseShadows可以被进行开关设置,如果开启了,那么Unity就会把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。
同时我们要为需要被投射阴影的物体开启Receive Shadows,代表该物体可以接收其他物体的阴影投射。若未开启该项,则在ShadowCaster Pass中就不会为该物体计算阴影。
该立方体中,我们使用的shader是之前写过的ForwardRendering,当时我们并没有定义ShadowCaster Pass,但是正方体依然投下了阴影,这是因为我们定义了Fallback "Specular"
,该Shader中又包含了Fallback VertexLit
,因此最终实现了ShadowCaster Pass。其定义如下:
Pass
{
Name "ShadowCaster"
Tags{"LightMode" = "ShadowCaster"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct v2f
{
V2F_SHADOW_CASTER;
};
v2f vert(appdata_base v)
{
v2f o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
return o;
}
float4 frag(v2f i):SV_Target
{
SHADOW_CASTER_FRAGMENT(i);
}
ENDCG
}
因此Fallback也是一个灵活的用法,使用Fallback即使我们没有实现ShadowCaster,也可以从其他的Shader中调用。利用这一特性我们可以使得一些其他Shader中的Pass也能够被我们调用。
还有一个现象,如果我们对右侧的平面(Plane) 勾选CastShadows是无法投射阴影的,这是因为在默认情况下,我们在计算光源的阴影映射纹理时会剔除掉物体的背面,但是Unity中的Plane组件默认剔除了背面,只剩下一个面,虽然我不知道为什么,但是这使得它在光源空间下没有正面了(正面成了背面?),因此就不会添加到阴影映射纹理中,我们可以将CaseShadows勾选为Two Sided来允许对物体的所有面都计算阴影信息。
通过设置Two Sided来使得plane可以计算正面的阴影映射纹理,但是由于正方体使用的Shader并没有实现接收阴影的Pass,因此正方体上并没有产生阴影效果,阴影直接透过正方体落在了最下方的平面上。
让物体接收阴影
我们新建一个Shader,并在前向渲染的基础上加上ShadowCaster和接收Shadow相关的Pass:
下方是加入了接收阴影的BasePass
Pass
{
// Base Tag
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
// 声明一个用于对阴影纹理采样的坐标 的内置宏
// 参数为下一个可用的插值寄存器的索引值,此处即为TEXCOORD2,也就是2
SHADOW_COORDS(2)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
// TRANSFER_SHADOW会根据平台信息决定如何变换阴影坐标
// 如果支持屏幕空间的阴影映射技术,则将坐标转化到屏幕空间;否则使用传统阴影映射将坐标转换到光源空间
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormalDir,worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfLambert = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormalDir,halfLambert)),_Gloss);
fixed atten = 1.0;
// 用于在片元着色器中计算阴影值的内置宏
fixed shadow = SHADOW_ATTENUATION(i);
// 将shadow值与漫反射值、高光反射值相乘,并没有与环境光相乘
return fixed4(ambient + (diffuse + specular) * atten * shadow,1.0);
}
ENDCG
}
SHADOW_COORDS
、TRANSFER_SHADOW
、SHADOW_ATTENUATION
可谓计算阴影的三剑客。SHADOW_COORDS
实际上就是用一个寄存器声明了一个名为_ShadowCoord
的阴影纹理坐标变量。而TRANSFER_SHADOW
实现的坐标转换则会根据平台的不同自动设置,并将顶点坐标变换到对应空间坐标系后存储到_ShadowCoord
中,SHADOW_ATTENUATION
则负责使用_ShadowCoord
对相关的纹理进行采样以得到阴影信息,ATTENUATION是衰减的意思,我们会根据UV坐标采样阴影值并作为衰减系数相乘。
如果该物体关闭了接收阴影的话,则SHADOW_COORDS
、TRANSFER_SHADOW
不会有任何作用,SHADOW_ATTENUATION
的值为1。
由于这些宏会使用上下文变量进行相关计算,例如TRANSFER_SHADOW
会使用v.vertex和o.pos来进行坐标计算,因此为了让宏能够正确工作,我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。因此a2v中顶点的变量名必须为vertex,顶点着色器的输入结构体v2f必须为v,v2f中顶点位置变量必须为pos。
上述代码中,我们只对Base Pass进行了修改,而想要更复杂的光照效果则需要在Additional Pass中进行阴影处理。二者大体上是差不多的。
使用帧调试器查看阴影绘制过程
观察帧渲染Debugger,我们发现渲染事件可大致分为四部分:UpdateDepthTexture更新深度纹理,RenderShadowmap渲染平行光的阴影映射纹理,CollectShadows根据深度纹理和阴影映射纹理得到屏幕空间的阴影图,最后绘制渲染结果
首先是获取摄像机的深度纹理,就是用ShadowCaster Pass来获取深度纹理(包括平行光的阴影映射纹理和摄像机的深度纹理)(屏幕空间阴影映射)。
在CollectShadow步骤处,可以看到最终形成的阴影映射纹理
可以看到模型表面直接根据屏幕坐标对阴影纹理进行了采样相乘。
统一管理光照衰减和阴影
我们已经讲过如何在BasePass中计算光照衰减,其中平行光的衰减因子总是等于1。而在AdditionalPass中,我们则需要判断Pass处理的光源类型,再使用内置变量和宏计算衰减因子。实际上,光照衰减和阴影对物体最终的渲染结果的影响本质上是相同的,请看VCR—— 光照 = a m b i e n t + ( d i f f u s e + s p e c u l a r ) ∗ a t t e n ∗ s h a d o w 光照=ambient +(diffuse+specular)*atten*shadow 光照=ambient+(diffuse+specular)∗atten∗shadow,光照衰减和阴影都是对光照结果相乘获得最终的渲染结果,那么我们可不可以同时计算好 ( a t t e n ∗ s h a d o w ) (atten*shadow) (atten∗shadow)呢?
答案是可以,Unity在Shader里可以使用UNITY_LIGHT_ATTENUATION
宏来实现:
//Base Pass
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
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 = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfLambert = normalize(viewDir + worldLightDir);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormalDir,worldLightDir));
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormalDir,halfLambert)),_Gloss);
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(ambient + (diffuse + specular) * atten,1.0);
}
ENDCG
}
使用该宏,我们就无需分别处理atten和shadow两个变量了,更妙的是,之前在AdditionBase中处理时我们根据多种不同的情况计算了不同类型光源下的Atten值,但是现在所有计算Atten的代码全部可以用UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
宏来替代了。
此外,我们希望可以在Additional Pass中添加阴影效果,就需要使用#pragma multi_compile_fwdadd_fullshadows
编译指令来替代#pragma multi_compile_fwdadd
指令。这样Unity也会为其他逐像素光源计算阴影并传递给Shader。
透明度物体的阴影
透明度测试的阴影
要想Unity中的物体能够向其他物体投射阴影,我们需要ShadowCaster Pass,并将深度结果输出到一张深度图或阴影映射纹理中
但是对于透明物体,我们要小心的处理它的阴影,透明物体的实现通常使用透明度测试或透明度混合,我们需要小心设置这些物体的Fallback。
我们在透明度测试时,若直接使用VertexLit,Diffuse,Specular等FallBack,往往无法得到正确的阴影,因为在透明度测试时一些片元被舍弃了,而ShadowCaster时却没有舍弃这些片元。我们需要自己编写ShadowCaster,在舍弃片元的基础上重新计算阴影:
Shader "Custom/AlphaWithShadowCaster_Copy"
{
Properties
{
_Color("Color Tint" ,Color) = (1,1,1,1)
_MainTex("Main Tex",2D) = "white" {}
_Cutoff("CutOff",Range(0,1)) = 0.2
}
SubShader
{
Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}
//Base Pass
Pass
{
Tags{"LightMode" = "ForwardBase"}
Cull Off
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
SHADOW_COORDS(3)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex,i.uv);
clip(texColor.a - _Cutoff);
fixed3 albedo = texColor.xyz * _Color.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo.xyz;
fixed3 diffuse = _LightColor0.xyz * albedo.xyz * saturate(dot(worldNormalDir,worldLightDir));
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(ambient + diffuse * atten,1.0);
}
ENDCG
}
}
FallBack "Transparent/Cutout/VertexLit"
}
当然,过程只是在透明度测试的基础上加上了阴影映射。
在编写上述代码时遇到了一个很坑爹的点:由于_Cutoff变量被我取名为_CutOff导致阴影映射投影时无法对阴影正常Clip
原因是因为Fallback中使用VertexLit这个Shader时需要保证一些变量的名称固定从而使得shader能够正确调用(例如_Cutoff),这一点书中也提到了,刚好被我踩到了。。。
但是这样的渲染还是有问题的,观察影子发现其实最终渲染的阴影是剔除了背面之后渲染的阴影。因此我们还需要实现背面的阴影渲染。这个问题只需要我们Cast Shadows中勾选Two Sided即可。
勾选双面投影之后获得的正确的阴影结果。
透明度混合下的阴影
与透明度测试相比,透明度混合则更加复杂。因为所有内置透明度混合的Shader都没有包含ShadowCaster的Pass——也就是说半透明物体是不参与深度图和阴影映射纹理的计算的,它们不会接收来自其他物体的阴影,也不会投射阴影。
这样当然是不对的,这是由于透明度混合需要关闭深度写入,因此影响了阴影的生成(因为阴影映射其实就是深度图)。想要为这些半透明物体生成正确的阴影,需要在每个光源空间下严格按照从后往前的顺序进行渲染,这会使得阴影处理变得非常复杂,也会影响性能。
当然我们可以使用一些技巧来强制为半透明物体生成阴影,例如把它们的Fallback设置为VertexLit、Diffuse这些不透明物体使用的UnityShader,然后在MeshRenderer面板上控制阴影投射。(其本质是将半透明物体当作不透明物体生成阴影)
这样当然是不正确的,来自其他物体的阴影应当透过半透明物体投射到下方的地面,此外物体本身的阴影由于半透明性,不该完全投射。总之这是一种取巧的方法,并且我们也可以发现半透明物体的背面部分也被剔除了。
虽然书中并没有提及,但此处我试图实现一下透明度混合的阴影计算
最重要的部分在于半透明的阴影,我本以为对阴影映射进行透明度相乘,但是实际上阴影映射只和深度值相关,颜色值是默认的。因此没用。
此处参考了Github上的Issue,使用了Dithering(颜色抖动)技术,本质上就是对阴影进行透明度剔除,剔除成一堆小圆点,通过小圆点的混合欺骗视觉,看起来像不同透明度颜色的阴影。
这也导致了使用Fallback VertexLit时,投射到表面的阴影变成了小圆点,这是由于摄像机生成的深度阴影映射实际上是用Dithering剔除了模型表面的片元,使得投射的阴影能够实现半透明,但是一旦在表面接受阴影就会暴露出这个问题。
一些本来被照亮的区域也被莫名其妙渲染上了阴影,而模型表面的阴影区域成了点状网格
不过我到是有个好想法,如果我们用两个物体,一个只渲染阴影,一个不渲染阴影不就解决了吗(虽然计算量可能变大了,而且修改效果肯定也会更复杂)
具体代码放在GitHub中了。Scene_9_4_5b_Copy中,Shader为AlphaBlendBothSideShadowBuild