可编程渲染管线11 后处理

3 篇文章 0 订阅

这是Unity可编程渲染管线教程的第十一章。这一章我们将学习创建一个后处理栈。

本教程基于Unity 2018.4.4f1。

修改:

我移除了在先前教程中使用的VPOS语义。如果你也使用了VPOS,建议把它换掉。

1 后处理栈

场景的渲染结束之后,我们还可以进一步修改图像。用来实现一些全屏特效,比如环境光遮蔽,bloom,color grading,景深等。通常来说多个后处理会按照特定的顺序执行,这个顺序可以通过使用多个资源或者脚本来配置,从而集成为一个后处理栈。Unity也有提供类似的后处理栈的实现。

在本教程中我们将简单的创建一个属于我们自己的后处理栈,并用该后处理栈来实现两个屏幕特效。你可以基于此来拓展更多的特效,也可以修改一下用于自己的项目。

1.1 Asset

我们创建一个MyPostProcessingStack 资源类型用于统一管理所有的后处理特效。写一个公有的Render方法,传入需要填充后处理操作的一个CommandBuffer 。总体思路是通过调用栈实例中的Render方法,将栈中每个后处理的相关操作填入指定的command buffer,由pipeline执行这个buffer来最终实现后处理效果,最后清理buffer。我们暂时先让方法只简单的打印一下信息。

using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "Rendering/My Post-Processing Stack")]
public class MyPostProcessingStack : ScriptableObject {

	public void Render (CommandBuffer cb) {
		Debug.Log("Rendering Post-Processing Stack");
	}
}

创建该类型的资源作为我们的后处理栈。目前这个栈还没有添加任何的配置选项。

1.2 默认的后处理栈

MyPipeline 需要持有一个后处理栈的引用,添加字段,在构造函数中传入后处理栈对象。

	MyPostProcessingStack defaultStack;

	public MyPipeline (
		bool dynamicBatching, bool instancing, MyPostProcessingStack defaultStack,
		Texture2D ditherTexture, float ditherAnimationSpeed,
		int shadowMapSize, float shadowDistance, float shadowFadeRange,
		int shadowCascades, Vector3 shadowCascasdeSplit
	) {
		…
		if (instancing) {
			drawFlags |= DrawRendererFlags.EnableInstancing;
		}

		this.defaultStack = defaultStack;

		…
	}

同样在MyPipelineAsset 中添加后处理栈的配置参数,将对象传给pipeline实例。

	[SerializeField]
	MyPostProcessingStack defaultStack;

	…
	
	protected override IRenderPipeline InternalCreatePipeline () {
		Vector3 shadowCascadeSplit = shadowCascades == ShadowCascades.Four ?
			fourCascadesSplit : new Vector3(twoCascadesSplit, 0f);
		return new MyPipeline(
			dynamicBatching, instancing, defaultStack,
			ditherTexture, ditherAnimationSpeed,
			(int)shadowMapSize, shadowDistance, shadowFadeRange,
			(int)shadowCascades, shadowCascadeSplit
		);
	}

将我们之前创建的后处理栈拖到默认项中。

1.3 后处理栈的渲染

后处理栈的渲染应该独立于常规渲染,所以我们在MyPipeline添加一个专门的command buffer用于处理后处理特效。如果默认的栈存在,我们就使用该buffer填充并执行后处理操作,最后清除等待下一帧。后处理通常发生在常规渲染结束之后,所以在Render中我们将这段代码放在DrawDefaultPipeline 之后。

	CommandBuffer postProcessingBuffer = new CommandBuffer {
		name = "Post-Processing"
	};
	
	…
	
	void Render (ScriptableRenderContext context, Camera camera) {
		…
		
		DrawDefaultPipeline(context, camera);

		if (defaultStack) {
			defaultStack.Render(postProcessingBuffer);
			context.ExecuteCommandBuffer(postProcessingBuffer);
			postProcessingBuffer.Clear();
		}

		cameraBuffer.EndSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();

		context.Submit();

		…
	}

现在后处理栈应该会在渲染的每一帧中打印信息了。

2 Render Target

修改渲染图像的前提是我们要能够读取到它。最简单最实用的方法就是通过我们的pipeline把图像渲染进一张纹理中。到目前为止我们都只是将渲染图像传至当前摄像机所设置的Render Target中。最终目标可能是帧缓冲也可能是render texture(比如渲染反射探针的贴图)。Unity本身也会将结果渲染到一张纹理中去,用于Scene窗口和选择摄像头时在右下角出现的预览窗口。

2.1 渲染至纹理

在清理Render Target前,如果存在后处理栈就需要申请一张临时的render texture。我们使用camera buffer的 CommandBuffer.GetTemporaryRT方法来申请这样一张texture。传入着色器属性ID以及与相机像素尺寸相匹配的纹理宽高。对应着色器属性的名称为_CameraColorTexture 。

	static int cameraColorTextureId = Shader.PropertyToID("_CameraColorTexture");
	
	…
	
	void Render (ScriptableRenderContext context, Camera camera) {
		…
		context.SetupCameraProperties(camera);

		if (defaultStack) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight
			);
		}

		CameraClearFlags clearFlags = camera.clearFlags;
		
		…
	}

获取的render texture将会和我们提供的着色器属性ID绑定。接下来,我们调用camera buffer的SetRenderTarget 来设置render target。目标ID的参数类型实际是RenderTargetIdentifier但是这个类型和int类型之间可以进行隐式转换,所以我们直接使用着色器属性ID即可。此外我们还需要设置加载和存储的行为。因为我们目前只用到了一个相机,且texture在每帧都会被清理,所以我们不需要关心它的初始状态。

		if (defaultStack) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight
			);
			cameraBuffer.SetRenderTarget(
				cameraColorTextureId,
				RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
			);
		}

后处理结束之后要及时地释放render texture。我们调用camera buffer的ReleaseTemporaryRT 方法,释放对应ID的texture。虽说相机渲染结束后,缓冲区的纹理会自动释放,但是应该养成显式调用方法释放rt的好习惯。

我们可以保存RenderTargetIdentifier 反复使用吗?

当然可以,这样只需要执行一次类型转换,效率会更高,但是在本教程中不做过多的赘述。

2.2 Blitting

返回Unity我们已经看不到渲染的场景画面了,因为我们将结果渲染进了纹理而不是相机默认的RendeTarget。为了修复这一点,我们在MyPostProcessingStack.Rende将纹理的内容再拷贝至最终的相机目标。为此我们需要用到buffer的Blit 方法。他需要source和destination两个参数,修改Render方法传入cameraColrorID参数,作为source,BuiltinRenderTextureType.CameraTarget作为destination(这个类型同样可以隐式转换为RenderTargetIdentifier)。

	public void Render (CommandBuffer cb, int cameraColorId) {
		//Debug.Log("Rendering Post-Processing Stack");
		cb.Blit(cameraColorId, BuiltinRenderTextureType.CameraTarget);
	}

MyPipeline.Render对应方法中传入color texture ID参数。

		if (defaultStack) {
			defaultStack.Render(postProcessingBuffer, cameraColorTextureId);
			context.ExecuteCommandBuffer(postProcessingBuffer);
			postProcessingBuffer.Clear();
			cameraBuffer.ReleaseTemporaryRT(cameraColorTextureId);
		}

我们又可以看到画面了,但是先于天空盒渲染的物体都被天空盒覆盖了,我们只能看到位于透明队列的物体,这是因为我们失去了深度缓冲。想要重新激活深度缓冲,那么在调用GetTemporaryRT 时就需要添加一个参数指明用于深度缓冲的存储位数。默认是0,也就是禁用深度缓冲的状态。我们把它设为24来重新激活深度缓冲。

			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight, 24
			);

为什么是24位

你也可以渲染16位,但是使用24位我们可以得到精度更高的深度值,实际上你还可以设为32位,但是多出来的8位是用于模板缓冲的,就深度缓冲而言,仍是24位的精度。

现在我们的场景又像之前一样正常渲染了,在查看frame debugger时可以发现新的步骤。后处理缓冲区的执行操作被自动记录了下来,我们的blit操作在其中被列为了 Draw Dynamic

2.3 分离深度贴图

一些后处理特效需要依赖深度信息,也就是说他们需要能够读取深度缓存。为此我们需要将渲染的深度信息也存在贴图中,我们就将其命名为_CameraDepthTexture。获取贴图的方法和之前的获取颜色贴图的方法相同,只不过要调整一下参数,我们再一次调用GetTemporaryRT ,并新增两个额外参数,第一个滤波模式我们设为FilterMode.Point,,第二个参数设为RenderTextureFormat.Depth。另外我们把color texture的深度位数重新设回0。

	static int cameraColorTextureId = Shader.PropertyToID("_CameraColorTexture");
	static int cameraDepthTextureId = Shader.PropertyToID("_CameraDepthTexture");
	
	…
	
	void Render (ScriptableRenderContext context, Camera camera) {
		…
		
		if (defaultStack) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight, 0
			);
			cameraBuffer.GetTemporaryRT(
				cameraDepthTextureId, camera.pixelWidth, camera.pixelHeight, 24,
				FilterMode.Point, RenderTextureFormat.Depth
			);
			…
		}

		…
	}

接下来,再一次重新调用SetRenderTarget ,它的一个重载方法允许我们指定独立的深度缓冲,并单独设置它的加载和存储行为。

			cameraBuffer.SetRenderTarget(
				cameraColorTextureId,
				RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store,
				cameraDepthTextureId,
				RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
			);

在调用后处理栈的方法时传入ID,并在完成后释放深度贴图。

		if (defaultStack) {
			defaultStack.Render(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId
			);
			context.ExecuteCommandBuffer(postProcessingBuffer);
			postProcessingBuffer.Clear();
			cameraBuffer.ReleaseTemporaryRT(cameraColorTextureId);
			cameraBuffer.ReleaseTemporaryRT(cameraDepthTextureId);
		}

MyPostProcessingStack.Render中新增参数。之后的渲染如旧。

	public void Render (CommandBuffer cb, int cameraColorId, int cameraDepthId) {
		cb.Blit(cameraColorId, BuiltinRenderTextureType.CameraTarget);
	}

我们可以将深度贴图作为blit的source来观察原始的深度信息。最终显示结果取决于具体的图形API。

3 全屏三角形

blit一张纹理的方式基本上和渲染正常的几何图形一样。只不过blit使用的是一个全屏的矩形,然后使用一个shader基于屏幕空间位置对纹理进行采样。你可以在frame debugger中查看Dynamic Draw的信息来确认这一点。color texture被分配到了_MainTex中,绘制地网格共有四个顶点和索引。

所以Blit实际上渲染的是由两个三角形所构成的矩形。但是我们其实可以使用覆盖整个屏幕的单个三角形来实现更高效的结果。使用单个三角形最明显的好处就是顶点和索引缩减为了3个。但是更重要的是它避免了使用两个三角形时,两者之间存在的接缝。因为GPU渲染片元的时候是一小块一小块的并行渲染。三角形斜边上的片元可能会被渲染两次,造成性能上的浪费。除此之外,渲染单个三角形可以有更好的缓存一致性(cache coherency)。

矩形和三角形两者的性能差别或许很小。但是现在普遍的标准做法是使用全屏三角形,所以我们也这么做。Unity没有提供一个这样一个标准的blit方法,所以需要我们自己实现。

3.1 Mesh

第一步是生成三角形网格,我们在MyPostProcessingStack 中先写好一个Mesh类型的静态字段。然后通过InitializeStatic 静态方法来创建网格,最后我们将在Render中调用这个方法。

	static Mesh fullScreenTriangle;

	static void InitializeStatic () {
		if (fullScreenTriangle) {
			return;
		}
	}
	
	public void Render (CommandBuffer cb, int cameraColorId, int cameraDepthId) {
		InitializeStatic();
		cb.Blit(cameraColorId, BuiltinRenderTextureType.CameraTarget);
	}

该网格应该由三个顶点组成一个三角面。因为我们将直接在裁减空间绘制,所以不需要考虑矩阵运算和z轴。也就是说屏幕的中心是坐标原点,屏幕内xy的范围是[-1,1]。y轴具体的方向取决于平台,这里我们不需要考虑。所以要创建一个全屏三角形,它的顶点应该设为(-1,-1),(-1,3),(3,-1)。

	static void InitializeStatic () {
		if (fullScreenTriangle) {
			return;
		}
		fullScreenTriangle = new Mesh {
			name = "My Post-Processing Stack Full-Screen Triangle",
			vertices = new Vector3[] {
				new Vector3(-1f, -1f, 0f),
				new Vector3(-1f,  3f, 0f),
				new Vector3( 3f, -1f, 0f)
			},
			triangles = new int[] { 0, 1, 2 },
		};
		fullScreenTriangle.UploadMeshData(true);
	}

3.2 Shader

第二步,编写一个用于拷贝贴图的shader。创建Hidden/My Pipeline/PostEffectStackshader,这个shader只有一个pass,不执行剔除且忽略深度。使用CopyPassVertex 和CopyPassFragment 这两个方法作为顶点片元函数。这两个方法单独定义在一个PostEffectStack.hlsl文件中。

Shader "Hidden/My Pipeline/PostEffectStack" {
	SubShader {
		Pass {
			Cull Off
			ZTest Always
			ZWrite Off
			
			HLSLPROGRAM
			#pragma target 3.5
			#pragma vertex CopyPassVertex
			#pragma fragment CopyPassFragment
			#include "../ShaderLibrary/PostEffectStack.hlsl"
			ENDHLSL
		}
	}
}

shader的代码很简短。我们直接传递顶点位置,不需要做任何的空间转换。除此之外我们将xy坐标*0.5+0.5,作为UV坐标输出。在片元中使用UV坐标采样纹理。我们先从采样_CameraColorTexture开始。

#ifndef MYRP_POST_EFFECT_STACK_INCLUDED
#define MYRP_POST_EFFECT_STACK_INCLUDED

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"

TEXTURE2D(_CameraColorTexture);
SAMPLER(sampler_CameraColorTexture);

struct VertexInput {
	float4 pos : POSITION;
};

struct VertexOutput {
	float4 clipPos : SV_POSITION;
	float2 uv : TEXCOORD0;
};

VertexOutput CopyPassVertex (VertexInput input) {
	VertexOutput output;
	output.clipPos = float4(input.pos.xy, 0.0, 1.0);
	output.uv = input.pos.xy * 0.5 + 0.5;
	return output;
}

float4 CopyPassFragment (VertexOutput input) : SV_TARGET {
	return SAMPLE_TEXTURE2D(
		_CameraColorTexture, sampler_CameraColorTexture, input.uv
	);
}

#endif // MYRP_POST_EFFECT_STACK_INCLUDED

MyPostProcessingStack 中创建一个使用该shader的静态材质。可以使用 Shader.Find来寻找shader。

	static Material material;

	static void InitializeStatic () {
		…

		material =
			new material(Shader.Find("Hidden/My Pipeline/PostEffectStack")) {
				name = "My Post-Processing Stack material",
				hideFlags = HideFlags.HideAndDontSave
			};
	}

在编辑器状态下里我们可以正常获取,但是在发布时可能会因为没有包含该shader而导致失败。所以我们要在 Graphics 中的Always Included Shaders中添加来确保该shader会在发布时包含。你也可以使用别的方法,但这种方法不需要写什么代码,比较方便。

3.3 绘制

现在我们使用 CommandBuffer.DrawMesh来代替Blit拷贝 color texture。该方法需要指定mesh,转换矩阵和使用的材质,因为我们不需要进行坐标转换,所以传入一个单位矩阵。

	public void Render (CommandBuffer cb, int cameraColorId, int cameraDepthId) {
		InitializeStatic();
		//cb.Blit(cameraColorId, BuiltinRenderTextureType.CameraTarget);
		cb.DrawMesh(fullScreenTriangle, Matrix4x4.identity, material);
	}

但Blit要做的不只这些,他还会设置渲染目标。

		cb.SetRenderTarget(
			BuiltinRenderTextureType.CameraTarget,
			RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
		);
		cb.DrawMesh(fullScreenTriangle, Matrix4x4.identity, material);

通过frame debugger ,我们可以检视最终三角面渲染的结果。这个draw call标记为 Draw Mesh。只用到了三个顶点,且没有任何的矩阵。结果一切正常,但你有可能会观察到上下颠倒的图像。这是因为Uniy在某些平台下为了保持统一的结果可能会垂直翻转画面。如果使用的api不是OpenGL,场景窗口和相机预览窗口都可能是翻转的。

我们可以通过检查_ProjectionParamsvector的x分量来检查是否发生了翻转。只要我们的pipeline调用了SetupCameraProperties,这个全局变量就会被设置。如果值为负就说明需要翻转V坐标。

float4 _ProjectionParams;

…

VertexOutput CopyPassVertex (VertexInput input) {
	…
	if (_ProjectionParams.x < 0.0) {
		output.uv.y = 1.0 - output.uv.y;
	}
	return output;
}

3.4 Variable Source Texture

CommandBuffer.Blit可以使用任意的texture作为source,它会将texture绑定至_MainTex ,这样在shader中可以统一使用_MainTex而不需要考虑特定texture的命名。MyPostProcessingStack.Render中我们可以在绘制三角面前使用CommandBuffer.SetGlobalTexture来实现这个过程。

	static int mainTexId = Shader.PropertyToID("_MainTex");

	…
	
	public void Render (
		CommandBuffer cb, int cameraColorId, int cameraDepthId
	) {
		cb.SetGlobalTexture(mainTexId, cameraColorId);
		…
	}

在shader中将_CameraColorTexture改为_MainTex。这样我们的后处理栈就不需要考虑pipeline使用的着色器属性了。

TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

…

float4 CopyPassFragment (VertexOutput input) : SV_TARGET {
	return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
}
 

4 模糊

接下来我们要创建一个简单的模糊特效,来检验我们的后处理栈是否可以正常使用。

4.1 Shader

我们把所有后处理的代码都写在同一个shader中,一个pass对应一个效果。这样可以重用代码而且只需要专注于处理一个材质。首先我们要把HLSL文件中的CopyPassVertex 重命名为DefaultPassVertex 。因为绝大多数后处理特效用的都是这样一个简单的顶点程序。接着创建片元程序BlurPassFragment

VertexOutput DefaultPassVertex (VertexInput input) {
	…
}

float4 CopyPassFragment (VertexOutput input) : SV_TARGET {
	return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
}

float4 BlurPassFragment (VertexOutput input) : SV_TARGET {
	return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
}

接着调整shader以匹配前面的修改,并添加第二个pass用于模糊。将剔除和深度测试设置移到subshader层,这样我们就不需要再每个pass中重复写代码了。include指令也可以用HLSLINCLUDE 指令块来放到subshader层中。

Shader "Hidden/My Pipeline/PostEffectStack" {
	SubShader {
		Cull Off
		ZTest Always
		ZWrite Off
		
		HLSLINCLUDE
		#include "../ShaderLibrary/PostEffectStack.hlsl"
		ENDHLSL
		
		Pass { // 0 Copy
			//Cull Off
			//ZTest Always
			//ZWrite Off
			
			HLSLPROGRAM
			#pragma target 3.5
			#pragma vertex DefaultPassVertex
			#pragma fragment CopyPassFragment
			ENDHLSL
		}
		
		Pass { // 1 Blur
			HLSLPROGRAM
			#pragma target 3.5
			#pragma vertex DefaultPassVertex
			#pragma fragment BlurPassFragment
			ENDHLSL
		}
	}
}

MyPostProcessingStack.Render中通过将第四个参数设为1来选到我们的模糊pass。第三个参数是submesh的序列,我们设为0即可。为了让pass的选择看起来比较直观,我们定义一个枚举类型来表示copy和blur这两个pass。

	enum Pass { Copy, Blur };

	…
	
	public void Render (CommandBuffer cb, int cameraColorId, int cameraDepthId) {
		…
		cb.DrawMesh(
			fullScreenTriangle, Matrix4x4.identity, material, 0, (int)Pass.Blur
		);
	}

4.2滤波(Filtering)

我们使用滤波来模糊图像,即采样和混合纹理中每个片元周围的像素。为了使用方便,我们在HLSL文件中写一个函数,它接受三个参数,原始UV,以及u向和v向的偏移值。我们可以使用u v坐标对应的屏幕空间导数来讲偏移值转换至uv空间。最开始我们对纹理执行一次没有偏移的采样。因为特效是在像素维度的变化,所以想要更好的观察,建议放大game窗口的scale factor值。

float4 BlurSample (float2 uv, float uOffset = 0.0, float vOffset = 0.0) {
	uv += float2(uOffset * ddx(uv.x), vOffset * ddy(uv.y));
	return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
}

float4 BlurPassFragment (VertexOutput input) : SV_TARGET {
	return BlurSample(input.uv);
}

 

 

最简单的模糊操作是使用2x2的盒式滤波,取四个像素的平均值。我们可以老老实实采样四次,也可以利用纹理自带的滤波设置,将uv坐标偏移半个像素,只采样一次。纹理的双线性滤波会自动帮我们计算平均值。

	return BlurSample(input.uv, 0.5, 0.5);

但是默认的滤波模式是点滤波,这样我们只会采样到距离最近的像素,结果就只是稍微移动了一下画面。为此我们要在 MyPipeline.Render中把我们的color texture改为双线性滤波。只有当我们的采样点不在像素的中心时,这个设置才会发挥作用。

			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight, 0,
				FilterMode.Bilinear
			);

模糊后的图像会产生一点偏移。我们可以通过从四个对角线方向采样四次再平均来抵消这点误差。最终的图像不需要用到alpha通道,我们直接设为1,避免对alpha通道使用多余的平均值计算。

float4 BlurPassFragment (VertexOutput input) : SV_TARGET {
	float4 color =
		BlurSample(input.uv, 0.5, 0.5) +
		BlurSample(input.uv, -0.5, 0.5) +
		BlurSample(input.uv, 0.5, -0.5) +
		BlurSample(input.uv, -0.5, -0.5);
	return float4(color.rgb * 0.25, 1);
}

下图为3x3的采样区域,它有一部分与2x2采样重叠。也就是说越靠近原始坐标的像素对于最终的颜色有着更高的贡献度。我们将其称之为3x3 tent filter

4.3 二次模糊

画面放大时我们可以看到很明显的模糊效果,但是但画面缩小或者分辨率很高时,模糊的效果就会被弱化。我们可以通过扩大滤波的采样范围来加强模糊的效果,但是这会让我们的pass变得更加复杂。另一种思路是我们使用原来的滤波不变,但是我们选择应用两次以上地模糊。比如,我们执行第二次模糊的pass就可以将滤波大小增至5x5。

在实现之前,我们先把有关blit的操作单独移到一个独立的Blit方法内,以便我们复用。

	public void Render (CommandBuffer cb, int cameraColorId, int cameraDepthId) {
		InitializeStatic();
		Blit(
			cb, cameraColorId, BuiltinRenderTextureType.CameraTarget, Pass.Blur
		);
	}
	
	void Blit (
		CommandBuffer cb,
		RenderTargetIdentifier sourceId, RenderTargetIdentifier destinationId,
		Pass pass = Pass.Copy
	) {
		cb.SetGlobalTexture(mainTexId, sourceId);
		cb.SetRenderTarget(
			destinationId,
			RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
		);
		cb.DrawMesh(
			fullScreenTriangle, Matrix4x4.identity, material, 0, (int)pass
		);
	}

接着我们在Render中执行两次blit。同时读写同一张color texture,可能产生未知的结果或者造成平台差异。所以我们申请一张临时地render texture 用作过渡。申请纹理需要我们知道color texture的宽高

	static int tempTexId = Shader.PropertyToID("_MyPostProcessingStackTempTex");

	…

	public void Render (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		cb.GetTemporaryRT(tempTexId, width, height, 0, FilterMode.Bilinear);
		Blit(cb, cameraColorId, tempTexId, Pass.Blur);
		Blit(cb, tempTexId, BuiltinRenderTextureType.CameraTarget, Pass.Blur);
		cb.ReleaseTemporaryRT(tempTexId);
	}

所以在MyPipeline.Render.提供对应的纹理宽高。

			defaultStack.Render(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);

4.4 可调整的模糊效果

通过二次模糊我们得到了更加柔和的结果,但是在高分辨的情况下,效果仍然不理想。为了克服这一点,我们需要再添加一些pass。所以再MyPostProcessingStack中,我们添加一个表示模糊强度的滑动条来用于调整模糊程度。

	[SerializeField, Range(0, 10)]
	int blurStrength;

将相关代码移到一个单独的Blur方法中。然后只有再blurstrength为正的情况下才会调用该方法,否则就执行默认的拷贝操作。

	public void Render (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		if (blurStrength > 0) {
			Blur(cb, cameraColorId, width, height);
		}
		else {
			Blit(cb, cameraColorId, BuiltinRenderTextureType.CameraTarget);
		}
	}

在strength大于1时先暂时统一使用二次模糊,然后等于1时使用单次模糊即可。

	void Blur (CommandBuffer cb, int cameraColorId, int width, int height) {
		cb.GetTemporaryRT(tempTexId, width, height, 0, FilterMode.Bilinear);
		if (blurStrength > 1) {
			Blit(cb, cameraColorId, tempTexId, Pass.Blur);
			Blit(cb, tempTexId, BuiltinRenderTextureType.CameraTarget, Pass.Blur);
		}
		else {
			Blit(
				cb, cameraColorId, BuiltinRenderTextureType.CameraTarget, Pass.Blur
			);
		}
		cb.ReleaseTemporaryRT(tempTexId);
	}

为了实现任意强度次数的模糊效果,我们使用循环去执行二次模糊直到还剩下最多两次。在循环中我们可以使用临时纹理和最初的color texture作为渲染目标。

		cb.GetTemporaryRT(tempTexId, width, height, 0, FilterMode.Bilinear);
		int passesLeft;
		for (passesLeft = blurStrength; passesLeft > 2; passesLeft -= 2) {
			Blit(cb, cameraColorId, tempTexId, Pass.Blur);
			Blit(cb, tempTexId, cameraColorId, Pass.Blur);
		}
		if (passesLeft > 1) {
			Blit(cb, cameraColorId, tempTexId, Pass.Blur);
			Blit(cb, tempTexId, BuiltinRenderTextureType.CameraTarget, Pass.Blur);
		}

在只进行单次模糊的情况下,我们不需要申请临时贴图。

		if (blurStrength == 1) {
			Blit(
				cb, cameraColorId, BuiltinRenderTextureType.CameraTarget,Pass.Blur
			);
			return;
		}
		cb.GetTemporaryRT(tempTexId, width, height, 0, FilterMode.Bilinear);

inspector

 

我们将涉及模糊的所有draw call 分为一组。在frame debugger里集中显示在Blur条目下。

	void Blur (CommandBuffer cb, int cameraColorId, int width, int height) {
		cb.BeginSample("Blur");
		if (blurStrength == 1) {
			Blit(
				cb, cameraColorId, BuiltinRenderTextureType.CameraTarget,Pass.Blur
			);
			cb.EndSample("Blur");
			return;
		}
		…
		cb.EndSample("Blur");
	}

5 使用深度缓冲

先前有提到,一些后处理效果的实现需要依赖深度贴图。所以这里我们同样踢动一个例子,该后处理会绘制出象征深度的黑色条纹。

5.1 深度条纹

在hlsl文件中添加用于绘制深度条纹的片元函数。首先我们要从_MainTex中采样到深度信息。我们可以使用SAMPLE_DEPTH_TEXTURE 宏来解决多平台的问题。

float4 DepthStripesPassFragment (VertexOutput input) : SV_TARGET {
	return SAMPLE_DEPTH_TEXTURE(_MainTex, sampler_MainTex, input.uv);
}

我们需要用到的是世界空间深度(即和近平面的距离,而不是和相机位置的距离),我们可以通过LinearEyeDepth 函数来进行转换,转换除了需要原始深度之外,还需要用到_ZBufferParams。这个向量同样是通过在渲染管线中调用SetupCameraProperties来让Unity进行填充。

float4 _ZBufferParams;

…

float4 DepthStripesPassFragment (VertexOutput input) : SV_TARGET {
	float rawDepth = SAMPLE_DEPTH_TEXTURE(_MainTex, sampler_MainTex, input.uv);
	return LinearEyeDepth(rawDepth, _ZBufferParams);
}

基于深度绘制条纹最简单的方法就是使用sin^2 (2πd)。最终效果可能不是很完美,但是用于展现深度信息显然是足够了

float4 DepthStripesPassFragment (VertexOutput input) : SV_TARGET {
	float rawDepth = SAMPLE_DEPTH_TEXTURE(_MainTex, sampler_MainTex, input.uv);
	float depth = LinearEyeDepth(rawDepth, _ZBufferParams);
	return pow(sin(3.14 * depth), 2.0);
}

添加对应的pass

		Pass { // 2 DepthStripes
			HLSLPROGRAM
			#pragma target 3.5
			#pragma vertex DefaultPassVertex
			#pragma fragment DepthStripesPassFragment
			ENDHLSL
		}

在中添加对应的pass枚举。然后在Render中Blit。该效果先于模糊特效执行,为了便于观察,我们将模糊强度设为0。

	enum Pass { Copy, Blur, DepthStripes };

	…

	public void Render (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		Blit(cb, cameraDepthId, cameraColorId, Pass.DepthStripes);

		…
	}

5.2 混合深度和颜色

我们想要的效果不应该是完全的替代原图像,而是将条纹图像和原图像进行混合。这样我们就需要同时用到两张source texture。虽然我们可以直接使用来获取深度贴图,但是和_MainTex一样,我们希望我们的后处理栈不需要考虑渲染管线中深度贴图的实际命名。所以我们将深度贴图额外绑定到_DepthTex 。为了模糊特效还能生效,我们还是需要渲染color texture。所以我们还是要申请一张临时纹理,作为额外的拷贝。将上述的实现代码都放在DepthStripes 中,并在framedebugger中将其归类为Depth Stripes

	static int depthTexId = Shader.PropertyToID("_DepthTex");
	
	…
	
	public void Render (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		//Blit(cb, depthTextureId, colorTextureId, Pass.DepthStripes);
		DepthStripes(cb, cameraColorId, cameraDepthId, width, height);

		…
	}

	…

	void DepthStripes (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		cb.BeginSample("Depth Stripes");
		cb.GetTemporaryRT(tempTexId, width, height);
		cb.SetGlobalTexture(depthTexId, cameraDepthId);
		Blit(cb, cameraColorId, tempTexId, Pass.DepthStripes);
		Blit(cb, tempTexId, cameraColorId);
		cb.ReleaseTemporaryRT(tempTexId);
		cb.EndSample("Depth Stripes");
	}

接着调整,采样color texture和depth texture,最后将颜色值和条纹值相乘。

TEXTURE2D(_DepthTex);
SAMPLER(sampler_DepthTex);

…

float4 DepthStripesPassFragment (VertexOutput input) : SV_TARGET {
	float rawDepth = SAMPLE_DEPTH_TEXTURE(_DepthTex, sampler_DepthTex, input.uv);
	float depth = LinearEyeDepth(rawDepth, _ZBufferParams);
	float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
	return color * pow(sin(3.14 * depth), 2.0);
}

5.3 忽略天空盒

条纹效果会应用到整个画面,包括天空盒。而问题在于天空盒并不会渲染进深度缓冲。结果就是天空盒的部分写入的是最大的深度值。一旦天空盒占据了画面的绝大部分,效果就会变得非常糟糕,在相机移动是更是会产生刺眼的闪烁。所以做好的办法就是不要将效果应用到天空盒上。对于天空盒区域,默认的深度值为0或1,具体哪个值取决于深度缓冲是否进行了转置(在非OpenGL平台下)如果发生了转置,UNITY_REVERSED_Z 就会被定义,我们可以通过它来检查片元是否有有效的深度值。如果无效直接返回原始颜色。

	#if UNITY_REVERSED_Z
		bool hasDepth = rawDepth != 0;
	#else
		bool hasDepth = rawDepth != 1;
	#endif
	if (hasDepth) {
		color *= pow(sin(3.14 * depth), 2.0);
	}
	return color;

5.4 仅限Opaque的后处理

除了天空盒,半透明物体也不会写入深度缓冲。所以条纹的应用在半透明物体上的效果实际取决于该物体后方的深度。有类似问题的还有模拟景深的后处理效果。对于这些特效而言,我们最好不要将其应用到半透明物体上。我们可以通过在渲染半透明几何物体之前执行这些后处理来,让其位于opaque和transparent渲染队列之间。

为此我们将MyPostProcessingStack.Render分为: RenderAfterOpaque 和 RenderAfterTransparent.两个方法,前者完成初始化并应用条纹后处理,后者应用模糊后处理。

	public void RenderAfterOpaque (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		DepthStripes(cb, cameraColorId, cameraDepthId, width, height);
	}

	public void RenderAfterTransparent (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		//InitializeStatic();
		//DepthStripes(cb, cameraColorId, cameraDepthId, width, height);
		if (blurStrength > 0) {
			Blur(cb, cameraColorId, width, height);
		}
		else {
			Blit(cb, cameraColorId, BuiltinRenderTextureType.CameraTarget);
		}
	}

MyPipeline.Render等到绘制天空盒结束后再应用后处理处理栈,并选择其中合适的方法。

		context.DrawSkybox(camera);

		if (defaultStack) {
			defaultStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);
			context.ExecuteCommandBuffer(postProcessingBuffer);
			postProcessingBuffer.Clear();
		}

		drawSettings.sorting.flags = SortFlags.CommonTransparent;
		filterSettings.renderQueueRange = RenderQueueRange.transparent;
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);

		DrawDefaultPipeline(context, camera);

		if (defaultStack) {
			defaultStack.RenderAfterTransparent(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);
			…
		}

在opaque后处理效果执行后我们需要确保渲染目标被正确的设置。我们要再一次设置color和depth目标,要注意这次我们需要关心纹理先前的载入内容,所以设为load。

		if (activeStack) {
			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);
			context.ExecuteCommandBuffer(postProcessingBuffer);
			postProcessingBuffer.Clear();
			cameraBuffer.SetRenderTarget(
				cameraColorTextureId,
				RenderBufferLoadAction.Load, RenderBufferStoreAction.Store,
				cameraDepthTextureId,
				RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
			);
			context.ExecuteCommandBuffer(cameraBuffer);
			cameraBuffer.Clear();
		}


 

5.5 可选择的条纹

因为深度条纹更多的是用于测试,所以我们在MyPostProcessingStack中添加一个toggle用于打开和关闭特效。

	[SerializeField]
	bool depthStripes;

	…

	public void RenderAfterOpaque (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height
	) {
		InitializeStatic();
		if (depthStripes) {
			DepthStripes(cb, cameraColorId, cameraDepthId, width, height);
		}
	}

6 逐相机的后处理

目前启用后处理的唯一途径就是调整默认的后处理栈,而其中的效果则会被应用至所有的摄像机。除了主相机和场景相机,还包括用于反射探针渲染的探针以及其他任何你可能会用到的相机。所以默认后处理栈只适合那些需要应用至所有相机的后处理特效。通常大多数的后处理特效只需要应用在主相机上。当然,也有些特效可能需要应用至多个相机。所以让我们实现为每个相机配置一个独立的后处理栈。

6.1 相机配置

我们不能在已有的Camera脚本上添加配置选项。所以我们创建一个新的脚本来包含额外的选项。将其命名为MyPipelineCamera,该脚本要求附着的物体存在Camera脚本。在其中添加后处理栈的字段和只读属性。

using UnityEngine;

[RequireComponent(typeof(Camera))]
public class MyPipelineCamera : MonoBehaviour {

	[SerializeField]
	MyPostProcessingStack postProcessingStack = null;
	
	public MyPostProcessingStack PostProcessingStack {
		get {
			return postProcessingStack;
		}
	}
}

将后处理栈asset指派在这里。渲染管线中的默认后处理栈则设为空。

MyPipeline.Render中我们需要从相机中获取 MyPipelineCamera脚本用于渲染。如果当前渲染的相机上存在这样一个脚本,就用它代替默认的后处理栈用于渲染。

		var myPipelineCamera = camera.GetComponent<MyPipelineCamera>();
		MyPostProcessingStack activeStack = myPipelineCamera ?
			myPipelineCamera.PostProcessingStack : defaultStack;

		if (activeStack) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, camera.pixelWidth, camera.pixelHeight, 0,
				FilterMode.Bilinear
			);
			…
		}

		…
		
		if (activeStack) {
			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);
			…
		}

		…

		if (activeStack) {
			activeStack.RenderAfterTransparent(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				camera.pixelWidth, camera.pixelHeight
			);
			…
		}

6.2 场景相机

现在我们可以为场景中的每个相机选择到独立的后处理栈。但是我们无法直接得到用于渲染scene窗口的相机。为此我们需要为MyPipelineCamera使用一个ImageEffectAllowedInSceneView 特性。

[ImageEffectAllowedInSceneView, RequireComponent(typeof(Camera))]
public class MyPipelineCamera : MonoBehaviour { … }

Unity会从激活的主相机中寻找所有添加了该特性的脚本,并将其同样添加至场景相机。我们只需让目标相机使用MainCamera的tag。

 

 

 

 

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值