渲染阴影的过程
一 渲染屏幕空间的深度贴图 (DepthTexture)
在正向渲染里,unity会先用ShadowCaster这个Pass渲染一遍场景,得到一张深度贴图
(PS:延迟渲染,深度贴图和albedo specular之类在Deferred Pass里统一计算一并放在Gbuffer里,不会专门单独渲染)
二 渲染光源方向的深度贴图(ShadowMap)
然后unity会从光源的照射方向,以一定的距离再渲染另一份深度贴图,每个有阴影的光源都会渲染一份 这里是直射光源的shadowmap,其余光源的阴影(因为不是平行光)计算略有出入。
上图是两个直射光延各自光线方向渲染的深度图,这里每一份ShadowMap都包含了4张相同角度,不同距离的四张深度图,这是因为我们在QualitySeting里将Shadow Cascades设为了Four Cascades,你还可以设置成Two Cascades或者No Cascades 。这里每张都要单独渲染一次,所以Four Cascades就得渲染四次。
我们可以看到,这里的深度贴图是正交视图的(因为直射光是平行光),但是我们渲染场景用的一般是透视视图,近大远小,同样大小的一块地方,近处占屏幕的比例更多,所以近处阴影所需的分辨率应该要比远处的要高。但是如果只用单张的shadowmap,远近物体的阴影享有相同的分辨率,就很浪费,效果也不好。(
左: No Cascades 右:Four Cascades)
直射光是没有位置的,所以渲染深度图相机的具体位置(离物体的远近距离)Unity会根据上面的Cascade spilt以及其他一些数据来确定。
通过将Scene视图的着色凡是改为Shadow Cascades,就可以看到Cascade spilt具体的分布情况
左:Stable Fit 右:CloseFIt
Stable Fit是按离相机距离来划分,CloseFIt则是直接按屏幕控件的深度贴图划分,最后生成的shdowmap也不一样,CloseFIt可以有效的利用阴影贴图,同等条件下阴影质量更高,但是会出现 shadow edge swimming的问题,就是你移动视角时,阴影的锯齿也会移动,阴影分辨率越低越明显
三 计算屏幕空间的阴影 CollectShadow=f(depthtexture,shadowmap)
采样屏幕空间每个像素点的深度贴图,就可以通过矩阵变换得到每个点在世界的坐标,然后再转化到渲染shadowmap的对应的裁减空间,然后再NDC之后,此时该点的z值就和用该点xy采样shadowmap的值是同一个距离概念的表示单位了,然后比较两个值,如果shdowmap里的值比z值小的话,就说明光线在到达该点前就被其他物体遮挡了,所以该点对于该光源是阴影的区域(这里指深度贴图近处0远处1的情况下,因为很多资料里都是近黑远白的深度贴图,但是我用framedebug看到的深度贴图都是近白远黑,也就是近处1远处0,这样的话运算时可能要z=1-z才行)
好在这一步Unity自己会帮我们处理
两个直射光的屏幕空间阴影贴图
----------------------
上图可以看到这张贴图会被渲染到 Screenspace ShadowMap,而完成这一系列计算的的Pass则来自一个Unity内置的shader
下面是这个Subshader用到的一些属性
Unity官网可以下载内建shader 所以我找了一下
Hidden/Internal-ScreenSpaceShadows里有很多很多的SubShader,每个subshader一个pass,这里只截取部分,第一个subshader是处理硬阴影的 ,所以主要看frag_hard这个片元函数,如果是软阴影会多次采样叠加。
fixed4 frag_hard (v2f i) : SV_Target{ UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); // required for sampling the correct slice of the shadow map render texture array
float3 vpos = computeCameraSpacePosFromDepth(i);//得到裁减空间坐标
float4 wpos = mul (unity_CameraToWorld, float4(vpos,1));//转为世界空间坐标
fixed4 cascadeWeights = GET_CASCADE_WEIGHTS (wpos, vpos.z);//如果有cascades的话得到权重,决定采样哪一张
float4 shadowCoord = GET_SHADOW_COORDINATES(wpos, cascadeWeights);//得到该点对应在shadowmap中的用于采样的坐标
//1 tap hard shadow
//UNITY_SAMPLE_SHADOW我找半天在HLSLSupport.cginc里找到了一点定义,但有各种各样的分支,所以我懵逼了
//总之就是采样ShadowMap吧,应该是里面顺便-z了吧- -|||,毕竟他要了xyzw,后面点光源阴影也有用到,这里的shadowmap是指光源方向的深度图,不是最后的阴影图
fixed shadow = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, shadowCoord);
//note阴影值最后是乘的,所以0才是阴影
shadow = lerp(_LightShadowData.r, 1.0, shadow);
fixed4 res = shadow;
return res;
}
GET_SHADOW_COORDINATES宏如下
#if defined (SHADOWS_SINGLE_CASCADE) #define GET_SHADOW_COORDINATES(wpos,cascadeWeights) getShadowCoord_SingleCascade(wpos)#else
#define GET_SHADOW_COORDINATES(wpos,cascadeWeights) getShadowCoord(wpos,cascadeWeights)
#endif
....
inline float4 getShadowCoord_SingleCascade( float4 wpos )
{
return float4( mul (unity_WorldToShadow[0], wpos).xyz, 0);//这里就是无cascade的情况,直接将该点从世界控件转到这个Shadow空间(?应该就是我前面说的光源的裁减空间吧)
}
inline float4 getShadowCoord( float4 wpos, fixed4 cascadeWeights )
{//这就是多cascade的情况了
float3 sc0 = mul (unity_WorldToShadow[0], wpos).xyz;
float3 sc1 = mul (unity_WorldToShadow[1], wpos).xyz;
float3 sc2 = mul (unity_WorldToShadow[2], wpos).xyz;
float3 sc3 = mul (unity_WorldToShadow[3], wpos).xyz;
float4 shadowMapCoordinate = float4(sc0 * cascadeWeights[0] + sc1 * cascadeWeights[1] + sc2 * cascadeWeights[2] + sc3 * cascadeWeights[3], 1);
#if defined(UNITY_REVERSED_Z)
float noCascadeWeights = 1 - dot(cascadeWeights, float4(1, 1, 1, 1));
shadowMapCoordinate.z += noCascadeWeights;
#endif
return shadowMapCoordinate;
}
_LightShadowData.x - 1 - shadow strength
_LightShadowData.y - Appears to be unused
_LightShadowData.z - 1.0 / shadow far distance
_LightShadowData.w - shadow near distance
至此,阴影贴图渲染完成,物体可以从中得到相应光源阴影信息啦!
投射阴影
前面无论是屏幕空间的深度贴图还是shadowmap,都是通过渲染物体的ShdowCaster 这个Pass得到的,所以我们要实现这个pass,才能让物体投射阴影(shader里没写这个pass也能投射阴影,可能是因为fallback的shader有实现shdowcaster的pass)
Pass{
Tags{"LightMode"="ShadowCaster"
CGPROGRAM
#include "UnityCG.cginc"
//...
#pragma vertex shadowVert
#pragma fragment shadowFrag
ENDCG
}
片元函数不用输出什么值,因为我们只需要深度值,GPU会为我们记录
struct a2vShadow{
float4 vertex:POSITION;
};
float4 shadowVert(appdata_base v):SV_POSITION{
return UnityObjectToClipPos(v.vertex);
}
fixed4 shadowFrag(float4 pos:SV_POSITION):SV_Target{
return 0;
}
打开FrameDebuger就可以看到Unity调用了自己写的Pass,而且也阴影也可以正常的显示
我们只写了一个subshader,所以#0就是我们的这个shader,如果显示#1或#2,就说明它可能调用了fallback的shader中的subshader了
我们可以继续完善一下这个阴影,在每个投射阴影的光源面板可以看到这两个值,拖动一下滑条就可以发现,Bias值越大,阴影就会向光源方向(也就是shadowmap的z轴方向)偏移,Normal Bias则是会沿法线内缩。
这两个值都是为了解决不正常的自阴影 Shadow Acne。因为我们我们阴影采样贴图得到了,贴图是一个个像素,所有如果阴影分辨率很低,阴影就会由很大的像素块组成,这就容易出现这个问题,本来应该在物体背后的阴影,跑到了物体前面去,非常的诡异
(这图其实还好,阴影没投到自己身上,总之了解怎么个情况就行了- -||)
所以我们至少要把阴影缩到物体底下 而不是前面,这就是两个bias的作用了,通过缩小或者便宜阴影,避免阴影跑到了物体的迎光一侧
Unity已经为我们准备好对应的两个方法了。
struct a2vShadow{
float4 vertex:POSITION;
float4 normal:NORMAL;
};
float4 shadowVert(appdata_base v):SV_POSITION{
//normal bias
float4 position = UnityClipSpaceShadowCasterPos(v.vertex.xyz, v.normal);
//bias
return UnityApplyLinearShadowBias(position);
}
两个函数的具体内容
https://www.zhihu.com/question/270585735/answer/355147675 这里有一点讲解
float4 UnityApplyLinearShadowBias (float4 clipPos) { //让物体顶点增加z轴偏移 /(雾)应该是靠近摄像头,所以后面限制才乘的nearclip ?但+不应该远离吗?/ clipPos.z += saturate(unity_LightShadowBias.x / clipPos.w); float clamped = max(clipPos.z, clipPos.w * UNITY_NEAR_CLIP_VALUE); //unity_LightShadowBias.y 在点灯和聚光灯下是0,平行光是1,也就是平行光时会加限制,避免超过边界(视锥体?)
clipPos.z = lerp(clipPos.z, clamped, unity_LightShadowBias.y);
return clipPos;
}float4 UnityClipSpaceShadowCasterPos (float3 vertex, float3 normal) { float4 clipPos; // Important to match MVP transform precision exactly while rendering // into the depth texture, so branch on normal bias being zero.
if (unity_LightShadowBias.z != 0.0) {
//转世界 位置、法线、光照
float3 wPos = mul(unity_ObjectToWorld, float4(vertex,1)).xyz;
float3 wNormal = UnityObjectToWorldNormal(normal);
float3 wLight = normalize(UnityWorldSpaceLightDir(wPos));
// apply normal offset bias (inset position along the normal)
// bias needs to be scaled by sine between normal and light direction
// (http://the-witness.net/news/2013/09/shadow-mapping-summary-part-1/)
//
// unity_LightShadowBias.z contains user-specified normal offset amount
// scaled by world space texel size.
//单位向量,光线和法线的夹角cos->sin值
float shadowCos = dot(wNormal, wLight);
float shadowSine = sqrt(1 - shadowCos * shadowCos);
//sin值和用户定义的normalbias值结合成一个缩放值
float normalBias = unity_LightShadowBias.z * shadowSine;
//延法线方向收缩
wPos -= wNormal * normalBias;
//转回裁减空间
clipPos = mul(UNITY_MATRIX_VP, float4(wPos, 1));
}
else {
clipPos = UnityObjectToClipPos(vertex);
}
return clipPos;
}
投射阴影完成后就是让物体接受阴影,也就是读取我们的那张屏幕空间阴影贴图。
接收阴影
当主方向光投射阴影的时候,Unity将寻找启用SHADOWS_SCREEN关键字的着色器的变体。因此,我们必须为我们的base pass创建两个变体,一个启用了SHADOWS_SCREEN关键字,一个没有启用SHADOWS_SCREEN关键字。
#pragma multi_compile _ SHADOWS_SCREEN
首先添加一个插值器存储屏幕空间坐标用于读取阴影贴图 可以用宏代替
struct v2f{
//...
#ifdef SHADOWS_SCREEN
float4 shadowCoord:TEXCOORD6;
#endif
//OR
SHADOW_COORDS(6)
};
然后再顶点函数为 shadowCoord赋值屏幕空间坐标
//...
#ifdef SHADOWS_SCREEN
//1、剪裁空间转屏幕空间,用于采样阴影纹理,未进行齐次除法(/w),因为插值会破坏齐次除法,所以必须在片元函数里进行
//2、_ProjectionParams.x 处理api或平台差异,比如dx的屏幕空间坐标原点在左上而别的在左下,所以要倒转y轴
o.shadowCoord.xy=(float2(o.pos.x,o.pos.y*_ProjectionParams.x)+o.pos.ww)*0.5f;//o.pos.w;
o.shadowCoord.zw=o.pos.zw;
//OR
o.shadowCoord=ComputeScreenPos(o.pos);
#endif
//OR 3、用宏就不用再手动判断SHADOWS_SCREEN了
TRANSFER_SHADOW(o);
//...
最后在片元函数里读取阴影贴图
#ifdef SHADOWS_SCREEN
float attenuation=tex2D(_ShadowMapTexture,i.shadowCoord.xy/i.shadowCoord.w);
//OR
//1、如果是用宏实现阴影,那么最好SHADOW_COORDS TRANSFER_SHADOW SHADOW_ATTENUATION一起用,因为他们之间有一些命名约定,像阴影坐标在SHADOW_COORDS里命名为_ShadowCoord,另外两个都假设他叫这个名字,此外输入参数i的剪裁空间坐标默认叫Pos,你如果设为别的名字就报错了
float attenuation=SHADOW_ATTENUATION(i);
#else
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
--------------------------------------------------
//2、事实上,UNITY_LIGHT_ATTENUATION已经包含了SHADOW_ATTENUATION,在第二个参数导入信息即可
UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);
多重阴影
前面的计算了BasePass中直射光的阴影,如果想要addpass则需要修改一个多重编译指令,代替之前的#pragma multi_compile_fwdadd
#pragma multi_compile_fwdadd_fullshadows
会添加四个关键字 SHADOW_CUBE、 SHADOW_DEPTH 、SHADOW_SCREEN 、SHADOW_SOFT
前面几个阴影的宏包括了这些情况的阴影的处理
其中,直射光计算方式一样,但是聚光灯和点光源就有点不一样了,
首先是阴影贴图的不同,他们都没有collectShadow这一步骤来计算屏幕空间阴影贴图,毕竟他们都是范围光,用这个太浪费了
对于聚光灯而言,它的shadowmap是一张光源角度的深度图
而点光源则需要渲染六张深度图,构成立方体贴图,因为点光源是向四面八方所有方向发射的
我也不知道为什么我不做下面的修改,Unity仍然支持点光源的立体深度贴图,可能2017版本Unity(我看的文章作者用的是Unity 5.4.0f3.)已经支持深度立方体贴图了吧,不管怎样还是放着标记一下吧。
点光源深度贴图是立方体贴图,所以shadowcaster这个Pass要有一个针对立方体阴影贴图的变种
#pragma multi_compile shadowcaster
它提供了两个关键字
SHADOWS_DEPTH:用于平行光好和聚光灯的阴影
SHADOW_CUBE:用于点光源的阴影
#if defined(SHADOWS_CUBE)
struct v2fShadow {
float4 position : SV_POSITION;
float3 lightVec : TEXCOORD0;
};
v2fShadow shadowVert (a2vShadow v) {
v2fShadow o;
o.position = UnityObjectToClipPos(v.vertex);
o.lightVec =
mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
return o;
}
float4 shadowFrag (v2fShadow i) : SV_TARGET {
float depth = length(i.lightVec) + unity_LightShadowBias.x;
depth *= _LightPositionRange.w;
return 1; //UnityEncodeCubeShadowDepth(depth);
}
//...
原文:https://catlikecoding.com/unity/tutorials/rendering/part-7/