在前面我们学习了如何混合多张屏幕图像来模拟运动模糊的效果。但是,另一种应用更加广泛的技术则是使用速度映射图。速度映射图存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有很多种方法,一种方法是把场景中的所有物体的速度渲染到一张纹理中。当这种方法的缺点是需要修改场景中所有物体的Shader代码,使其添加计算速度的代码并输出到一个渲染纹理中。
下面介绍一种生成速度映射图的方法。这种方法利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角投影矩阵的逆矩阵对NDC下的顶点坐标进行变换得到的。当得到世界空间中的顶点坐标后,我们使用前一帧的视角投影矩阵对其进行变换,得到该位置在前一帧的NDC坐标。然后,我们计算前一帧和当前帧的位置差,生成该像素的速度。这种方法的优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。
脚本:
(1)首先继承基类:
public class MotionBlurWithDepthTexture : PostEffectsBase {
(2)声明该效果需要的Shader,并据此创建相应的材质:
public Shader motionBlurShader;
private Material motionBlurMaterial = null;
public Material material {
get {
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}
(3)定义运动模糊时模糊图像使用的大小:
[Range(0.0f, 1.0f)]
public float blurSize = 0.5f;
(4)由于本节需要得到摄像机的视角和投影矩阵,我们需要定义一个Camera类型的变量,以获取该脚本所在的摄像机组件:
private Camera myCamera;
public Camera camera {
get {
if (myCamera == null) {
myCamera = GetComponent();
}
return myCamera;
}
}
(5)我们还需要定义一个变量来保存上一帧摄像机的视角*投影矩阵:
private Matrix4x4 previousViewProjectionMatrix;
(6)由于本例需要获取摄像机的深度纹理,我们在脚本的OnEnable函数中设置摄像机的状态:
void OnEnable() {
camera.depthTextureMode |= DepthTextureMode.Depth;
(7)最后,我们实现了OnRenderImage函数:
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_BlurSize", blurSize);
material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
previousViewProjectionMatrix = currentViewProjectionMatrix;
Graphics.Blit (src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
上面使用的OnRenderImage函数很简单,我们首先需要计算和传递运动模糊中使用的各个属性。本例需要使用两个变换矩阵——前一帧的视角投影矩阵以及当前帧的视角投影矩阵的逆矩阵。因此,我们通过调用 camera.worldToCameraMatrix和camera.projectionMatrix来分别得到当前摄像机的视角矩阵和投影矩阵。对它们相乘后取逆,得到当前帧的视角*投影矩阵的逆矩阵,并传递给材质。然后,我们把取逆前的结果存储在previousViewProjectionMatrix变量中,以便在下一帧时传递给材质的_PreviousViewProjectionMatrix属性。
shader:
(1)首先声明本例需要使用的各个属性:
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
_MainTex对应了输入的渲染纹理,_BlurSize是模糊图像时使用的参数。我们注意到,虽然在脚本里设置了材质的_PreviousViewProjectionMatrix和_CurrentViewProjectionInverseMatrix属性,但并没有在Properties块中声明它们。这是因为Unity没有提供矩阵类型的属性,但我们仍然可以在CG代码块中定义这些矩阵,并从脚本中设置它们。
(2)在本节中,我们使用CGINCLUDE来组织代码。我们在SubShader块中利用CGINCLUDE和ENDCG语义来定义一系列代码:
SunShader{
CGINCLUDE
...
ENDCG
...
}
(3)声明代码块中需要使用的各个变量:
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
float4x4 _CurrentViewProjectionInverseMatrix;
float4x4 _PreviousViewProjectionMatrix;
half _BlurSize;
在上面的代码中,除了在Properties声明的 _MainTex和 _BlurSize属性,我们还声明了其它三个变量。 _CameraDepthTexture是Unity传递给我们的深度纹理,而_CurrentViewProjectionInverseMatrix和_PreviousViewProjectionMatrix是由脚本传递而来的矩阵。除此之外,我们还声明了_MainTex_TexelSize变量,它对应了主纹理的纹素大小,我们需要使用该变量来对深度纹理的采样坐标进行平台化差异处理。
(4)顶点着色器的代码和之前使用多次的代码基本一致,只是增加了专门用于对深度纹理采样的纹理坐标变量:
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
};
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif
return o;
}
由于在本例中,我们需要同时处理多张渲染纹理,因此在DirectX这样的平台上,我们需要处理平台差异导致的图像翻转纹理。在上面的代码中,我们对深度纹理的采样坐标进行了平台差异化处理,以便在类似DirectX的平台上,在开启了抗锯齿的情况下让然可以得到正确的结果。
(5)片元着色器是算法的重点所在:
fixed4 frag(v2f i) : SV_Target {
// Get the depth buffer value at this pixel.
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
// H is the viewport position at this pixel in the range -1 to 1.
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
// Transform by the view-projection inverse.
float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
// Divide by w to get the world position.
float4 worldPos = D / D.w;
// Current viewport position
float4 currentPos = H;
// Use the world position, and transform by the previous view-projection matrix.
float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
// Convert to nonhomogeneous points [-1,1] by dividing by w.
previousPos /= previousPos.w;
// Use this frame's position and last frame's to compute the pixel velocity.
float2 velocity = (currentPos.xy - previousPos.xy)/2.0f;
float2 uv = i.uv;
float4 c = tex2D(_MainTex, uv);
uv += velocity * _BlurSize;
for (int it = 1; it < 3; it++, uv += velocity * _BlurSize) {
float4 currentColor = tex2D(_MainTex, uv);
c += currentColor;
}
c /= 3;
return fixed4(c.rgb, 1.0);
}
我们首先需要利用深度纹理和当前帧的视角投影矩阵的逆矩阵来求得该像素在世界空间下的坐标。过程开始于对深度纹理的采样,我们使用内置的SAMPLE_DEPTH_TEXTURE宏和纹理坐标对深度纹理进行采样,得到了深度值d。由前面可知d是由NDC下的坐标映射而来的。我们想要构建像素的NDC坐标H,就需要把这个深度值重新映射回NDC。这个映射很简单,只需要使用原映射的反函数即可,即d2-1。同样NDC的xy分量可以由像素的纹理坐标映射而来(NDC下的xyz分量范围均为[-1,1])。当得到NDC下的坐标H后,我们就可以使用当前帧的视角投影矩阵的逆矩阵对其进行变换,并把结果值除以它的w分量来得到世界空间下的坐标worldPos。
一旦得到世界空间下的坐标,我们就可以使用前一帧的视角投影矩阵对它进行变换,得到前一帧在NDC下的坐标previousPos。然后,我们计算前一帧和当前帧在屏幕空间下的位置差,得到该像素的速度velocity。
当得到该像素的速度后,我们就可以使用该速度值对它的邻域像素进行采样,相加后取平均值得到一个模糊的效果。采样时我们还是用了_BlurSize来控制采样距离。
(6)然后,我们定义了运动模糊所需的Pass:
Pass {
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
(7)最后,我们关闭了shader的Fallback:
FallBack Off
本节实现的运动模糊适合于场景静止、摄像机快速运动的情况,这是因为我们在计算时只考虑了摄像机的运动。因此,如果读者把本节的代码应用到一个物体快速运动而摄像机静止的场景,就会发现不会产生任何运动模糊效果。如果我们想要对快速移动的物体产生运动模糊的效果,就需要生成更加精确的速度映射图。读者可以在Unity自带的ImageEffect包中找到更多的运动模糊的实现方法。