Unity Shader-深度相关知识总结与效果实现(LinearDepth,Reverse Z,世界坐标重建,软粒子,高度雾,运动模糊,扫描线效果)
根据深度重建世界坐标
证明世界坐标重建正确的方法
首先,得先找到一种证明反推回世界空间位置正确的方法。这里,我在相机前摆放几个物体,尽量使之在世界坐标下的位置小于1,方便判定颜色如下图:
然后将几个物体的shader换成如下的一个打印世界空间位置的shader:
//puppet_master
//https://blog.csdn.net/puppet_master
//2018.6.10
//打印对象在世界空间位置
Shader "DepthTexture/WorldPosPrint"
{
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float3 worldPos : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return fixed4(i.worldPos, 1.0);
}
ENDCG
}
}
//fallback使之有shadow caster的pass
FallBack "Legacy Shaders/Diffuse"
}
然后挂上上面的重建世界坐标位置的脚本,在开启和关闭脚本前后,屏幕输出完全无变化,说明通过后处理重建世界坐标位置与直接用shader输出世界坐标位置效果一致:
逆矩阵方式重建
深度重建有几种方式,先来看一个最简单粗暴,但是看起来最容易理解的方法:
我们得到的屏幕空间深度图的坐标,xyz都是在(0,1)区间的,需要经过一步变换,变换到NDC空间,OpenGL风格的话就都是(-1,1)区间,所以需要首先对xy以及xy对应的深度z进行*2 - 1映射。然后再将结果进行VP的逆变换,就得到了世界坐标。
shader代码如下:
//puppet_master
//https://blog.csdn.net/puppet_master
//2018.6.10
//通过逆矩阵的方式从深度图构建世界坐标
Shader "DepthTexture/ReconstructPositionInvMatrix"
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _CameraDepthTexture;
float4x4 _InverseVPMatrix;
fixed4 frag_depth(v2f_img i) : SV_Target
{
float depthTextureValue = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
//自己操作深度的时候,需要注意Reverse_Z的情况
#if defined(UNITY_REVERSED_Z)
depthTextureValue = 1 - depthTextureValue;
#endif
float4 ndc = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depthTextureValue * 2 - 1, 1);
float4 worldPos = mul(_InverseVPMatrix, ndc);
worldPos /= worldPos.w;
return worldPos;
}
ENDCG
SubShader
{
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag_depth
ENDCG
}
}
}
C#部分:
/********************************************************************
FileName: ReconstructPositionInvMatrix.cs
Description:从深度图构建世界坐标,逆矩阵方式
Created: 2018/06/10
history: 10:6:2018 13:09 by puppet_master
https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class ReconstructPositionInvMatrix : MonoBehaviour {
private Material postEffectMat = null;
private Camera currentCamera = null;
void Awake()
{
currentCamera = GetComponent();
}
void OnEnable()
{
if (postEffectMat == null)
postEffectMat = new Material(Shader.Find("DepthTexture/ReconstructPositionInvMatrix"));
currentCamera.depthTextureMode |= DepthTextureMode.Depth;
}
void OnDisable()
{
currentCamera.depthTextureMode &= ~DepthTextureMode.Depth;
}
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (postEffectMat == null)
{
Graphics.Blit(source, destination);
}
else
{
var vpMatrix = currentCamera.projectionMatrix * currentCamera.worldToCameraMatrix;
postEffectMat.SetMatrix("_InverseVPMatrix", vpMatrix.inverse);
Graphics.Blit(source, destination, postEffectMat);
}
}
}
效果如下,重建ok:
看起来比较简单,但是其中有一个/w的操作,如果按照正常思维来算,应该是先乘以w,然后进行逆变换,最后再把world中的w抛弃,即是最终的世界坐标,不过实际上投影变换是一个损失维度的变换,我们并不知道应该乘以哪个w,所以实际上上面的计算,并非按照理想的情况进行的计算,而是根据计算推导而来(更加详细推导请参考这篇文章,不过我感觉这个推导有点绕)。
已知条件(M为VP矩阵,M^-1即为其逆矩阵,Clip为裁剪空间,ndc为标准设备空间,world为世界空间):
ndc = Clip.xyzw / Clip.w = Clip / Clip.w
world = M^-1 * Clip
二者结合得:
world = M ^-1 * ndc * Clip.w
我们已知M和ndc,然而还是不知道Clip.w,但是有一个特殊情况,是world的w坐标,经过变换后应该是1,即
1 = world.w = (M^-1 * ndc).w * Clip.w
进而得到Clip.w = 1 / (M^ -1 * ndc).w
带入上面等式得到:
world = (M ^ -1 * ndc) / (M ^ -1 * ndc).w
所以,世界坐标就等于ndc进行VP逆变换之后再除以自身的w。
不过这种方式重建世界坐标,性能比较差,一般来说,我们都是逐顶点地进行矩阵运算,毕竟定点数一般还是比较少的,但是全屏幕逐像素进行矩阵运算,这个计算量就不是一般的大了,性能肯定是吃不消的。
补充:如果是DepthNormalTexture中的depth通过逆矩阵方式重建,计算方式略有不同:
float depth;
float3 normal;
float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
DecodeDepthNormal(cdn, depth, normal);
//float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
//逆矩阵的方式使用的是1/z非线性深度,而_CameraDepthNormalsTexture中的是线性的,进行一步Linear01Depth的逆运算
depth = (1.0/depth - _ZBufferParams.y) /_ZBufferParams.x ;
//自己操作深度的时候,需要注意Reverse_Z的情况
#if defined(UNITY_REVERSED_Z)
depth = 1 - depth;
#endif
float4 ndc = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1);
float4 worldPos = mul(_InverseVPMatrix, ndc);
worldPos /= worldPos.w;
屏幕射线插值方式重建
这种方式的重建,可以参考Secrets of CryENGINE 3 Graphics Technology这个CryTech 2011年的PPT。借用一张图:
然后偶再画个平面的图:
上图中,A为相机位置,G为空间中我们要重建的一点,那么该点的世界坐标为A(worldPos) + 向量AG,我们要做的就是求得向量AG即可。根据三角形相似的原理,三角形AGH相似于三角形AFC,则得到AH / AC = AG / AF。由于三角形相似就是比例关系,所以我们可以把AH / AC看做01区间的比值,那么AC就相当于远裁剪面距离,即为1,AH就是我们深度图采样后变换到01区间的深度值,即Linear01Depth的结果d。那么,AG = AF * d。所以下一步就是求AF,即求出相机到屏幕空间每个像素点对应的射线方向。看到上面的立体图,其实我们可以根据相机的各种参数,求得视锥体对应四个边界射线的值,这个操作在vertex阶段进行,由于我们的后处理实际上就是渲染了一个Quad,上下左右四个顶点,把这个射线传递给pixel阶段时,就会自动进行插值计算,也就是说在顶点阶段的方向值到pixel阶段就变成了逐像素的射线方向。
那么我们要求的其实就相当于AB这条向量的值,以上下平面为例,三维向量只比二维多一个维度,我们已知远裁剪面距离F,相机的三个方向(相机transform.forward,.right,.up),AB = AC + CB,|BC| = tan(0.5fov) * |AC|,|AC| = Far,AC = transorm.forward * Far,CB = transform.up * tan(0.5fov) * Far。
我直接使用了远裁剪面对应的位置计算了三个方向向量,进而组合得到最终四个角的向量。用远裁剪面的计算代码比较简单(恩,我懒),不过《ShaderLab入门精要》中使用的是近裁剪面+比例计算,不确定是否有什么考虑(比如精度,没有测出来,如果有大佬知道,还望不吝赐教)。
shader代码如下:
//puppet_master
//https://blog.csdn.net/puppet_master
//2018.6.16
//通过深度图重建世界坐标,视口射线插值方式
Shader "DepthTexture/ReconstructPositionViewPortRay"
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _CameraDepthTexture;
float4x4 _ViewPortRay;
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 rayDir : TEXCOORD1;
};
v2f vertex_depth(appdata_base v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord.xy;
//用texcoord区分四个角,就四个点,if无所谓吧
int index = 0;
if (v.texcoord.x < 0.5 && v.texcoord.y > 0.5)
index = 0;
else if (v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
index = 1;
else if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
index = 2;
else
index = 3;
o.rayDir = _ViewPortRay[index];
return o;
}
fixed4 frag_depth(v2f i) : SV_Target
{
float depthTextureValue = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float linear01Depth = Linear01Depth(depthTextureValue);
//worldpos = campos + 射线方向 * depth
float3 worldPos = _WorldSpaceCameraPos + linear01Depth * i.rayDir.xyz;
return fixed4(worldPos, 1.0);
}
ENDCG
SubShader
{
Pass
{
ZTest Off
Cull Off
ZWrite Off
Fog{ Mode Off }
CGPROGRAM
#pragma vertex vertex_depth
#pragma fragment frag_depth
ENDCG
}
}
}
C#代码如下:
/********************************************************************
FileName: ReconstructPositionViewPortRay.cs
Description:通过深度图重建世界坐标,视口射线插值方式
Created: 2018/06/16
history: 16:6:2018 16:17 by puppet_master
https://blog.csdn.net/puppet_master
*********************************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class ReconstructPositionViewPortRay : MonoBehaviour {
private Material postEffectMat = null;
private Camera currentCamera = null;
void Awake()
{
currentCamera = GetComponent();
}
void OnEnable()
{
if (postEffectMat == null)
postEffectMat = new Material(Shader.Find("DepthTexture/ReconstructPositionViewPortRay"));
currentCamera.depthTextureMode |= DepthTextureMode.Depth;
}
void OnDisable()
{
currentCamera.depthTextureMode &= ~DepthTextureMode.Depth;
}
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (postEffectMat == null)
{
Graphics.Blit(source, destination);
}
else
{
var aspect = currentCamera.aspect;
var far = currentCamera.farClipPlane;
var right = transform.right;
var up = transform.up;
var forward = transform.forward;
var halfFovTan = Mathf.Tan(currentCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
//计算相机在远裁剪面处的xyz三方向向量
var rightVec = right * far * halfFovTan * aspect;
var upVec = up * far * halfFovTan;
var forwardVec = forward * far;
//构建四个角的方向向量
var topLeft = (forwardVec - rightVec + upVec);
var topRight = (forwardVec + rightVec + upVec);
var bottomLeft = (forwardVec - rightVec - upVec);
var bottomRight = (forwardVec + rightVec - upVec);
var viewPortRay = Matrix4x4.identity;
viewPortRay.SetRow(0, topLeft);
viewPortRay.SetRow(1, topRight);
viewPortRay.SetRow(2, bottomLeft);
viewPortRay.SetRow(3, bottomRight);
postEffectMat.SetMatrix("_ViewPortRay", viewPortRay);
Graphics.Blit(source, destination, postEffectMat);
}
}
}
开关后处理前后效果仍然不变:
这里我用了默认非线性的深度图进行的深度计算,需要先进行Linear01Depth计算,如果用了线性深度,比如DepthNormalTexture,那么就进行一步简单的线性映射即可。整体的射线计算,我用了Linear01Depth * 外围计算好的距离。也可以用LinearEyeDepth * 外围计算好的方向。总之,方案还是蛮多的,变种也很多,还有自己重写Graphic.Blit自己设置Quad的值把index设置在顶点的z值中。
屏幕射线插值方式重建视空间坐标
补充一条屏幕空间深度重建坐标的Tips。如果我们要求视空间的位置的话,有一种更简便并且性能更好的方式。这种方式与上面的屏幕射线插值的方式重建世界坐标的原理一致。只需要输入一个投影矩阵的逆矩阵,即在vertex阶段,从NDC坐标系的四个远裁剪面边界(+-1,+-1,1,1)乘以逆投影矩阵,得到视空间的四个远裁剪面坐标位置,然后除以齐次坐标转化到普通坐标下。这样的四个点的位置也就是视空间下从相机到该点的射线方向,经过插值到fragment阶段直接乘以01区间深度就得到了该像素点的视空间位置了。
那么就只有一个问题没有解决,在于应该如何获得NDC坐标系下的边界点。上面推导中提到过,在后处理阶段,实际上就是绘制了一个Quad,对应整个屏幕。这个Quad的四个边界点刚好对应屏幕的四个边界点,uv是(0,1)区间的,刚好对应屏幕空间,我们通过*2 - 1将其转化到(-1,1)区间就可以得到四个边界对应NDC坐标系下的xy坐标了。
v2f vert (appdata v)
{
float4 clipPos = float4(v.uv * 2 - 1.0, 1.0, 1.0);
float4 viewRay = mul(_InverseProjectionMatrix, clipPos);
o.viewRay = viewRay.xyz / viewRay.w;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float depthTextureValue = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float linear01Depth = Linear01Depth(depthTextureValue);
float3 viewPos = _WorldSpaceCameraPos.xyz + linear01Depth * i.viewRay;
}
此处的InverseProjectionMatrix与上文中一样,也需要自己传入,因为在后处理阶段,内置矩阵已经被替换了。