文章内容
- 深度图基础
- 访问深度图
- 利用深度图重建世界坐标
- 深度图应用
- 渲染深度图
- 相交高亮
- 能量场
- 全局雾效
- 扫描线
- 水淹
- 垂直雾效
- 边缘检测
- 运动模糊
- 景深
- 参考资料
深度图基础
深度图里存放了[0,1]范围的非线性分布的深度值,这些深度值来自NDC坐标。
在延迟渲染中,深度值默认已经渲染到G-buffer;而在前向渲染中,你需要去申请,以便Unity在背后利用Shader Replacement将RenderType为Opaque、渲染队列小于等于2500并且有ShadowCaster Pass的物体的深度值渲染到深度图中。
访问深度图
第一步:在C#中设置Camera.main.depthTextureMode = DepthTextureMode.Depth;
可以在主摄像机的Camera组件下看见提示:
这表明了主摄像机渲染了深度图
第二步:在Shader中声明_CameraDepthTexture
sampler2D _CameraDepthTexture;
第三步:访问深度图
//1.如果是后处理,可以直接用uv访问
//vertex
//当有多个RenderTarget时,需要自己处理UV翻转问题
#if UNITY_UV_STARTS_AT_TOP //DirectX之类的
if(_MainTex_TexelSize.y < 0) //开启了抗锯齿
o.uv.y = 1 - o.uv.y; //满足上面两个条件时uv会翻转,因此需要转回来
#endif
//fragment
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv));
//2.其他:利用投影纹理采样
//vertex
o.screenPos = ComputeScreenPos(o.vertex);
//fragment
float depth = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos));
float linear01Depth = Linear01Depth(depth); //转换成[0,1]内的线性变化深度值
float linearEyeDepth = LinearEyeDepth(depth); //转换到摄像机空间
重建世界坐标
利用覆盖屏幕的uv值和深度图中的深度,我们可以重建出物体在世界空间中的坐标。
主要有以下两种方法:
- 利用VP矩阵的逆矩阵对NDC坐标进行转换。
- 找到从摄像机指向该点的方向向量(需要是单位向量),将该方向向量乘上深度值就能得到摄像机指向该点的向量,将该向量加上摄像机位置就能得到该点的世界坐标。
1. 利用VP矩阵重建
首先要在C#脚本中传递当前的VP逆矩阵:
Matrix4x4 currentVP = VPMatrix;
Matrix4x4 currentInverseVP = VPMatrix.inverse;
mat.SetMatrix("_CurrentInverseVP", currentInverseVP);
Graphics.Blit(source, destination, mat);
然后在Shader中首先制造NDC坐标:
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1); //NDC坐标
利用当前的VP逆矩阵将NDC坐标转换到世界空间:
float4 D = mul(_CurrentInverseVP, H);
float4 W = D / D.w; //将齐次坐标w分量变1得到世界坐标
具体用法可以到下面的MotionBlur例子中查看。
2. 利用方向向量重建
首先需要知道,Post Process实际上是渲染一个覆盖屏幕的Quad,因此屏幕四个角对应摄像机的视椎体四个角。
首先是算出摄像机到四个角的向量:
float halfHeight = near * tan(fov/2);
float halfWidth = halfHeight * aspect;
Vector3 toTop = up * halfHeight;
Vector3 toRight = right * halfRight;
Vector3 toTopLeft = forward + toTop - toRight;
Vector3 toBottomLeft = forward - toTop - toRight;
Vector3 toTopRight = forward + toTop + toRight;
Vector3 toBottomRight = forward - toTop + toRight;
假设有个绿点在toTopLeft所在线上,利用相似三角形,可以得到:
toGreen / depth = toTopLeft / near
而depth是能够在Shader中获得的,因此我们只需要传递toTopLeft / near到Shader中就能计算出toGreen:
toTopLeft /= cam.nearClipPlane;
toBottomLeft /= cam.nearClipPlane;
toTopRight /= cam.nearClipPlane;
toBottomRight /= cam.nearClipPlane;
Matrix4x4 frustumDir = Matrix4x4.identity;
frustumDir.SetRow(0, toBottomLeft);
frustumDir.SetRow(1, toBottomRight);
frustumDir.SetRow(2, toTopLeft);
frustumDir.SetRow(3, toTopRight);
mat.SetMatrix("_FrustumDir", frustumDir);
在Vertex中判断出对应顶点所在的向量:
可以看到uv值和对应的索引值正好是二进制的关系,所以可以如下求出:
int ix = (int)o.uv.z;
int iy = (int)o.uv.w;
o.frustumDir = _FrustumDir[ix + 2 * iy];
你可能奇怪这样只能求到4个角线上的点,但vertex到fragment的过程中是有个东西叫插值的,这个插值正好能把每个像素所在的向量求出。
然后我们就能在fragment中求出世界坐标了:
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linearEyeDepth = LinearEyeDepth(depth);
float3 worldPos = _WorldSpaceCameraPos + linearEyeDepth * i.frustumDir.xyz;
具体的用法可以到下面的垂直雾效例子中找到。
渲染深度图
输出[0,1]范围的深度值即可,如下:
fixed4 frag (v2f i) : SV_Target
{
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv));
float linear01Depth = Linear01Depth(depth);
return linear01Depth;
}
PS: 如果代码没错,而看到的是全黑的,那么应该就是摄像机的Far Clip Plane设得太大。
相交高亮
思路是判断当前物体的深度值与深度图中对应的深度值是否在一定范围内,如果是则判定为相交。
首先访问当前物体的深度值:
//vertex
COMPUTE_EYEDEPTH(o.eyeZ);
然后访问深度图。由于此时不是Post Process,因此需要利用投影纹理采样来访问深度图:
//vertex
o.screenPos = ComputeScreenPos(o.vertex);
//fragment
float screenZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
最后就是进行相交判断:
float halfWidth = _IntersectionWidth / 2;
float diff = saturate(abs(i.eyeZ - screenZ) / halfWidth); //除以halfWidth来控制相交宽度为_IntersectionWidth
fixed4 finalColor = lerp(_IntersectionColor, col, diff);
return finalColor;
能量场
在相交高亮效果的基础上,加上半透明和边缘高亮,就能制造出一个简单的能量场效果:
float3 worldNormal = normalize(i.worldNormal);
float3 worldViewDir = normalize(i.worldViewDir);
float rim = 1 - saturate(dot(worldNormal, worldViewDir)) * _RimPower;
float screenZ = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
float intersect = (1 - (screenZ - i.eyeZ)) * _IntersectionPower;
float v = max (rim, intersect);
return _MainColor * v;
全局雾效
思路是让雾的浓度随着深度值的增大而增大,然后进行的原图颜色和雾颜色的插值:
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv.xy);
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linearDepth = Linear01Depth(depth);
float fogDensity = saturate(linearDepth * _FogDensity);
fixed4 finalColor = lerp(col, _FogColor, fogDensity);
return finalColor;
}
扫描线
思路与相交高亮效果类似,只是这里是Post Process。自定义一个[0,1]变化的值_CurValue,根据_CurValue与深度值的差进行颜色的插值:
fixed4 frag (v2f i) : SV_Target
{
fixed4 originColor = tex2D(_MainTex, i.uv.xy);
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linear01Depth = Linear01Depth(depth);
float halfWidth = _LineWidth / 2;
float v = saturate(abs(_CurValue - linear01Depth) / halfWidth); //线内返回(0, 1),线外返回1
return lerp(_LineColor, originColor, v);
}
水淹
利用上面提到的第二种重建世界空间坐标的方法得到世界空间坐标,判断该坐标的Y值是否在给定阈值下,如果是则混合原图颜色和水的颜色:
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv.xy);
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linearEyeDepth = LinearEyeDepth(depth);
float3 worldPos = _WorldSpaceCameraPos.xyz + i.frustumDir * linearEyeDepth;
if(worldPos.y < _WaterHeight)
return lerp(col, _WaterColor, _WaterColor.a); //半透明
return col;
}
垂直雾效
利用上面提到的第二种重建世界空间坐标的方法得到世界空间坐标,让雾的浓度随着Y值变化:
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv.xy);
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linearEyeDepth = LinearEyeDepth(depth);
float3 worldPos = _WorldSpaceCameraPos + linearEyeDepth * i.frustumDir.xyz;
float fogDensity = (worldPos.y - _StartY) / (_EndY - _StartY);
fogDensity = saturate(fogDensity * _FogDensity);
fixed3 finalColor = lerp(_FogColor, col, fogDensity).xyz;
return fixed4(finalColor, 1.0);
}
边缘检测
思路是取当前像素的附近4个角,分别计算出两个对角的深度值差异,将这两个差异值相乘就得到我们判断边缘的值。
首先是得到4个角:
//vertex
//Robers算子
o.uv[1] = uv + _MainTex_TexelSize.xy * float2(-1, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * float2(-1, 1);
o.uv[3] = uv + _MainTex_TexelSize.xy * float2(1, -1);
o.uv[4] = uv + _MainTex_TexelSize.xy * float2(1, 1);
然后是得到这4个角的深度值:
float sample1 = Linear01Depth(UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv[1])));
float sample2 = Linear01Depth(UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv[2])));
float sample3 = Linear01Depth(UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv[3])));
float sample4 = Linear01Depth(UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv[4])));
最后就是根据对角差异来得到判断边缘的值:
float edge = 1.0;
//对角线的差异相乘
edge *= abs(sample1 - sample4) < _EdgeThreshold ? 1.0 : 0.0;
edge *= abs(sample2 - sample3) < _EdgeThreshold ? 1.0 : 0.0;
return edge;
// return lerp(0, col, edge); //描边
PS:上面这种只用深度值来检测边缘的效果并不太好,最好结合法线图来判断,原理都是一样的。
运动模糊 (Motion Blur)
运动模糊主要用在竞速类游戏中用来体现出速度感。这里介绍的运动模糊只能用于周围物体不动,摄像机动的情景。
思路是利用上面提到的重建世界坐标方法得到世界坐标,由于该世界坐标在摄像机运动过程中都是不动的,因此可以将该世界空间坐标分别转到摄像机运动前和运动后的坐标系中,从而得到两个NDC坐标,利用这两个NDC坐标就能得到该像素运动的轨迹,在该轨迹上多次取样进行模糊即可。
首先是得到世界坐标(这里使用提到的第一种重建方法):
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1); //NDC坐标
float4 D = mul(_CurrentInverseVP, H);
float4 W = D / D.w; //将齐次坐标w分量变1得到世界坐标
然后是计算出运算前后的NDC坐标:
float4 currentPos = H;
float4 lastPos = mul(_LastVP, W);
lastPos /= lastPos.w;
最后就是在轨迹上多次取样进行模糊:
//采样两点所在直线上的点,进行模糊
fixed4 col = tex2D(_MainTex, i.uv.xy);
float2 velocity = (currentPos - lastPos) / 2.0;
float2 uv = i.uv;
uv += velocity;
int numSamples = 3;
for(int index = 1; index < numSamples; index++, uv += velocity)
{
col += tex2D(_MainTex, uv);
}
col /= numSamples;
景深 (Depth Of Field)
景深是一种聚焦处清晰,其他地方模糊的效果,在摄影中很常见。
思路是首先渲染一张模糊的图,然后在深度图中找到聚焦点对应的深度,该深度附近用原图,其他地方渐变至模糊图。
第一步是使用SimpleBlur Shader渲染模糊的图,这里我只是简单地采样当前像素附近的9个点然后平均,你可以选择更好的模糊方式:
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv[0] = v.uv + _MainTex_TexelSize.xy * float2(-1, -1) * _BlurLevel;
o.uv[1] = v.uv + _MainTex_TexelSize.xy * float2(-1, 0) * _BlurLevel;
o.uv[2] = v.uv + _MainTex_TexelSize.xy * float2(-1, 1) * _BlurLevel;
o.uv[3] = v.uv + _MainTex_TexelSize.xy * float2(0, -1) * _BlurLevel;
o.uv[4] = v.uv + _MainTex_TexelSize.xy * float2(0, 0) * _BlurLevel;
o.uv[5] = v.uv + _MainTex_TexelSize.xy * float2(0, 1) * _BlurLevel;
o.uv[6] = v.uv + _MainTex_TexelSize.xy * float2(1, -1) * _BlurLevel;
o.uv[7] = v.uv + _MainTex_TexelSize.xy * float2(1, 0) * _BlurLevel;
o.uv[8] = v.uv + _MainTex_TexelSize.xy * float2(1, 1) * _BlurLevel;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv[0]);
col += tex2D(_MainTex, i.uv[1]);
col += tex2D(_MainTex, i.uv[2]);
col += tex2D(_MainTex, i.uv[3]);
col += tex2D(_MainTex, i.uv[4]);
col += tex2D(_MainTex, i.uv[5]);
col += tex2D(_MainTex, i.uv[6]);
col += tex2D(_MainTex, i.uv[7]);
col += tex2D(_MainTex, i.uv[8]);
col /= 9;
return col;
}
第二步就是传递该模糊的图给DepthOfField Shader:
RenderTexture blurTex = RenderTexture.GetTemporary(source.width, source.height, 16);
Graphics.Blit(source, blurTex, blurMat);
dofMat.SetTexture("_BlurTex", blurTex);
Graphics.Blit(source, destination, dofMat);
第三步就是在DepthOfField Shader中根据焦点来混合原图颜色和模糊图颜色:
fixed4 col = tex2D(_MainTex, i.uv.xy);
fixed4 blurCol = tex2D(_BlurTex, i.uv.zw);
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.zw));
float linearDepth = Linear01Depth(depth);
float v = saturate(abs(linearDepth - _FocusDistance) * _FocusLevel);
return lerp(col, blurCol, v);
完整项目地址
https://github.com/KaimaChen/Unity-Shader-Demo/tree/master/UnityShaderProject
参考
Unity Docs - Camera’s Depth Texture
Unity Docs - Platform-specific rendering differences
神奇的深度图:复杂的效果,不复杂的原理
SPECIAL EFFECTS WITH DEPTH
GPU Gems - Chapter 27. Motion Blur as a Post-Processing Effect
《Unity Shader 入门精要》
《Unity 3D ShaderLab 开发实战详解》