可编程渲染管线12 图像质量

这是Unity可编程渲染管线的第十二章,在本章中,我们将通过调整渲染比例、应用MSAA 、HDR缓冲配合tone mapping等方式来提升画面质量。

该教程基于Unity2018.4.6f1。

1 渲染比例(Render Scale )

相机的设置决定了渲染图像的高和宽,这一过程不在我们渲染管线的管理范围内。但是在渲染到相机的渲染目标前,我们可以对图像做任意的操作。也就是说我们可以创建一个任意大小的中介纹理,并在该纹理上渲染画面。按着这个思路,我们可以先在一个小一点的纹理上渲染画面,接着再将图像blit到最终的相机目标。这样做会降低图像质量,但是能够加速渲染流程,因为需要处理的片元变少了。在 Lightweight/Universal pipeline中Unity提供了Render Scale选项来支持渲染比例的调整。我们在自己的渲染管线中也来实现这样一个功能。

1.1 Scaling Down

MyPipelineAsset中添加一个表示渲染比例的滑杆,范围限制在[0.25,1]。也就是最多可以将分辨率降低至四分之一(像素数量降低至十六分之一),当然除非原始像素非常高,不然这种比例的缩放肯定是不可取的。

	[SerializeField, Range(0.25f, 1f)]
	float renderScale = 1f;

将该数值传递至渲染管线的实例对象。

	protected override IRenderPipeline InternalCreatePipeline () {
		…
		return new MyPipeline(
			…
			(int)shadowCascades, shadowCascadeSplit,
			renderScale
		);
	}

MyPipeline 中保存好该数值。

	float renderScale;

	public MyPipeline (
		…
		int shadowCascades, Vector3 shadowCascasdeSplit,
		float renderScale
	) {
		…
		this.renderScale = renderScale;
	}

在Render中渲染一个相机的画面时,我们在创建render texture之前就要确定是否使用scaled rendering,避免和可能存在的后处理栈产生冲突。只要当renderScale这个值降低时,我们才会使用scaled rendering,而且只需要在game camera上使用,scene、preview这类相机应该不受影响。用一个本地的bool变量来记录并表示接下来的渲染我们是否要使用scaled rendering。

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

		bool scaledRendering =
			renderScale < 1f && camera.cameraType == CameraType.Game;
		
		if (activeStack) {
			…
		}

还需要记录的是渲染的实际宽度和高度,默认是相机的设置,但是如果开启了scaled rendering就要进行调整。

		bool scaledRendering =
			renderScale < 1f && camera.cameraType == CameraType.Game;
		
		int renderWidth = camera.pixelWidth;
		int renderHeight = camera.pixelHeight;
		if (scaledRendering) {
			renderWidth = (int)(renderWidth * renderScale);
			renderHeight = (int)(renderHeight * renderScale);
		}

1.2 渲染到至缩放纹理

在开启后处理或者使用scaled rendering时我们需要申请中介纹理。以一个bool变量来判断,并在申请纹理时使用调整后的宽高。

		bool renderToTexture = scaledRendering || activeStack;

		if (renderToTexture) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, renderWidth, renderHeight, 0,
				FilterMode.Bilinear
			);
			cameraBuffer.GetTemporaryRT(
				cameraDepthTextureId, renderWidth, renderHeight, 24,
				FilterMode.Point, RenderTextureFormat.Depth
			);
			…
		}

在调用RenderAfterOpaque 时,我们也要改为传入调整后的宽高。

		context.DrawSkybox(camera);

		if (activeStack) {
			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				renderWidth, renderHeight
			);
			…
		}

RenderAfterTransparent也一样。只有存在激活的后处理栈时,我们才能够调用RenderAfterTransparent ,但是只要renderToTexture为true,不管有没有后处理,我们都应该在最后释放纹理。

		DrawDefaultPipeline(context, camera);

		if (renderToTexture) {
			if (activeStack) {
				activeStack.RenderAfterTransparent(
					postProcessingBuffer, cameraColorTextureId,
					cameraDepthTextureId, renderWidth, renderHeight
				);
				context.ExecuteCommandBuffer(postProcessingBuffer);
				postProcessingBuffer.Clear();
			}
			else {
				cameraBuffer.Blit(
					cameraColorTextureId, BuiltinRenderTextureType.CameraTarget
				);
			}
			cameraBuffer.ReleaseTemporaryRT(cameraColorTextureId);
			cameraBuffer.ReleaseTemporaryRT(cameraDepthTextureId);
		}

1.0 0.75 0.5 025

分别对应渲染比例  1/0.75/0.5/0.25

调整渲染比例会影响渲染管线中几乎所有的东西,除了阴影,因为阴影采样的是独立的纹理。略微的降低渲染比例可能会用一种抗锯齿的效果,但你继续降低比例值就会发现这不过是在blit到最终的渲染目标时,因精度丢失而由双线性插值造成的模糊而已。

渲染比例如何和双线性插值共同作用

1.3 Scaling Up 

通过渲染比例的调整,我们可以牺牲画面质量来提升性能,自然也可以牺牲性能来提升画面质量。为了实现这一点,我们先将renderScale允许的最大值修改至2。

	[SerializeField, Range(0.25f, 2f)]
	float renderScale = 1f;

MyPipeline.Render同样要考虑到renderScale大于1的情况。

		bool scaledRendering =
			(renderScale < 1f || renderScale > 1f) &&
			camera.cameraType == CameraType.Game;

1.25 1.5

1.75 2.0

分别对应1.25/1.5 /1.75/2.0

图像质量确实有上升,但是效果甚微。只有把数值跳到2才会有比较明显的效果,在这种情况下,最终的每一个像素都市有2x2的像素块去平均值得到,也就是说计算的像素是实际像素的4倍。这和超级采样抗锯齿,SSAA2x使用的是类似的方法。

继续提升渲染比例,画面并不会有进一步的提升,提升至3的结果和1的结果一样,4的结果和2的结果一样,因为单个双线性的blit,只会计算四个像素的平均值。想要使用更高范围的渲染比例,就得要求我们的pass为每个片元执行不只一次的纹理采样了。这显然是不明智的,计算的工作量基于渲染比例呈平方增长。像SSAA4x要求我们渲染的像素是最终像素的16倍。

2 MSAA

SSAA的一个替代选择是MSAA:多重采样抗锯齿(multi-sample anti-aliasing)。它们思路相同但做法不同。MSAA会为每个像素进行多次采样,但并不是在一个固定的网格中。MSAA相比前者最大的不同点就是每个图元的每个片元都只会执行一次片元程序,也就是使用原始的分辨率。最后的结果会写入给所有被光栅化三角形所覆盖的子采样点。这样能够有效的降低工作量,但是代价是MSAA的效果只能体现在三角形的边上。高频的表面图案和alpha剪裁的边并不受其影响。

MSAA跟SSAA不同的地方在于,SSAA对于所有子采样点着色,而MSAA只对当前像素覆盖掩码不为0的进行着色,顶点属性在像素的中心进行插值用于在片断程序中着色。这是MSAA相对于SSAA来说最大的好处。

https://www.cnblogs.com/ghl_carmack/p/8245032.html

2.1 配置

MyPipelineAsset中添加切换MSAA模式的选项。默认是关闭的,可以选择开启2x,4x,8x的MSAA。我们用一个枚举来表示。枚举对应的值是每个像素需要采样的次数,比如默认关闭是1,只采样一次。

	public enum MSAAMode {
		Off = 1,
		_2x = 2,
		_4x = 4,
		_8x = 8
	}

	…

	[SerializeField]
	MSAAMode MSAA = MSAAMode.Off;

我们把每个像素需要采样的次数传给渲染管线的实例对象。

		return new MyPipeline(
			…
			renderScale, (int)MSAA
		);

MyPipeline中保存这个值。

	int msaaSamples;

	public MyPipeline (
		…
		float renderScale, int msaaSamples
	) {
		…
		this.msaaSamples = msaaSamples;
	}

并不是所有的平台都支持MSAA,并且各个平台所能支持的最大采样次数也不尽相同。如果超过了平台限制的最大值会导致程序崩溃,所以我们需要确保采样在支持的最大次数以内。我们可以将值先传给 QualitySettings.antiAliasing。虽然我们不会用到qualit setting,但是QualitySettings.antiAliasing会在平台限制的最大值和输入的值中取小的一方,接着我们再把这个值传回来,就实现了我们的需求,需要注意的是,对于不支持MSAA的平台,QualitySettings.antiAliasing会将值设为0,我们需要把它修正为1。

		QualitySettings.antiAliasing = msaaSamples;
		this.msaaSamples = Mathf.Max(QualitySettings.antiAliasing, 1);

2.2  Multisampled Render Textures

每个相机都可以单独的设置MSAA,所以设置一个变量用于表示每个相机渲染时实际使用的采样次数,如果相机没有开启MSAA,就把它强制设为1.如果最终的采样次数大于1,我们就应该申请一个多重采样纹理( multi-sampled textures 简称MS texture)作为中介。

		int renderSamples = camera.allowMSAA ? msaaSamples : 1;
		bool renderToTexture =
			scaledRendering || renderSamples > 1 || activeStack;

我们还需要在GetTemporaryRT中新增两个参数来配置正确的render texture。第一个是读写模式,color buffer设为default,depth buffer设为linear,后一个参数则是采样次数。

		if (renderToTexture) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, renderWidth, renderHeight, 0,
				FilterMode.Bilinear, RenderTextureFormat.Default,
				RenderTextureReadWrite.Default, renderSamples
			);
			cameraBuffer.GetTemporaryRT(
				cameraDepthTextureId, renderWidth, renderHeight, 24,
				FilterMode.Point, RenderTextureFormat.Depth,
				RenderTextureReadWrite.Linear, renderSamples
			);
			…
		}

在关闭所有的后处理后可以尝试观察渲染的图像。

 2x 4x

 

2.0 2.0

分别为2x、4x、8x、以及关闭MSAA的2倍渲染比例

MSAA可以作用于直射光阴影吗?

在我们的渲染管线中是可以的,但是Unity的渲染管线会遇到一点问题,因为它使用的是屏幕空间的级联阴影。但是在本章教程的后部分我们也会遇到类似的问题。

相比2倍的render scale,MSAA 4x的效果更好一点,要注意的是,render scale的效果会作用于整个画面,而MSAA只会作用于几何体的边缘。你也可以选择将两者结合使用。比如MSAA4x和render scale=2配合,可以达到和MSAA 8x相近的效果,即使后者采样了16次。

 

4x 8x
render scale=2时 MSAA 4x 和8x的比较

2.3 解析MS texutre

我们可以直接将内容渲染进MS texture,但是却不能以常规的方式直接从texture中读取像素。在采样一个像素时,我们必须首先进行解析(resolved),也就是对所有的子采样点取平均值作为最终值。解析(resolved)通常是对于整个texture执行一次称作Resolve Color的特殊pass,Unity会在任意一个pass采样之前自动插入这一步。

最终blit之前的Resolve Color

Resloving MS texutre会产生临时的texture,除非有新的内容渲染进MS texture,这张贴图可以一直使用。但是如果我们从MS中采样,再把结果渲染回MS texture,如此反复,最终结果就是对同一张texutre执行了多次多余的resolve pass。如果你激活一个后处理栈,启用模糊特效,你就能观察到这一现象,在模糊强度为5的情况下,我们最终会得到3个resolve pass。

额外的resolve pass没有任何作用,因为我们知道MSAA对全屏特效是没有用的。为了避免这种不必要的结果,我们可以选择将先blit到一张作为中介的纹理中,然后用这张纹理来代替camera target使用。要想实现这一点,我们就得在MyPostProcessingStackRenderAfterTransparent 和RenderAfterTransparent 方法中把MSAA的采样次数传进去。如果应用模糊的同时又开启了MSAA,就拷贝一张resolved texture 并传给Blur

	static int resolvedTexId =
		Shader.PropertyToID("_MyPostProcessingStackResolvedTex");
	
	…
	
	public void RenderAfterOpaque (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, int samples
	) { … }
	
	public void RenderAfterTransparent (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, int samples
	) {
		if (blurStrength > 0) {
			if (samples > 1) {
				cb.GetTemporaryRT(
					resolvedTexId, width, height, 0, FilterMode.Bilinear
				);
				Blit(cb, cameraColorId, resolvedTexId);
				Blur(cb, resolvedTexId, width, height);
				cb.ReleaseTemporaryRT(resolvedTexId);
			}
			else {
				Blur(cb, cameraColorId, width, height);
			}
		}
		else {
			Blit(cb, cameraColorId, BuiltinRenderTextureType.CameraTarget);
		}
	}

MyPipeline.Render中把renderSamples作为参数传入。

			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				renderWidth, renderHeight, renderSamples
			);
		
		…
		
						activeStack.RenderAfterTransparent(
					postProcessingBuffer, cameraColorTextureId,
					cameraDepthTextureId, renderWidth, renderHeight,
					renderSamples
				);

现在三个resolve pass减少为一个了,另外还增加了一个简单的blit.

2.4 No Depth Resolve

颜色的的采样可以通过取平均值来实现resolved,但该方法不适用与深度缓冲。对深度取四周的平均值没有意义,也没有可以使用的通用方法。所以对多重采样的深度的无法进行resolved。这就导致了在MSAA开启的情况下,我们的深度条纹特效无法正常工作。

想让我们的特效能够再次正常显示,首先想到的方法应该就是干脆不要让MSAA应用到深度贴图上。所以我们在MyPostProcessingStack 中添加一个只读属性用于表明我们是否将从深度贴图中读取信息,而目前只有深度条纹特效需要读取深度。

	public bool NeedsDepth {
		get {
			return depthStripes;
		}
	}

现在在MyPipeline.Render里,我们就可以知道后处理栈是否会访问深度贴图了。只有当我们需要获取深度信息时才需要单独的一张深度贴图,否则我们会把深度和颜色并在同一张贴图中。同时如果我们确实需要用到单独的深度贴图,我们就应该讲samples值重置为1,来关闭MSAA。4

		bool needsDepth = activeStack && activeStack.NeedsDepth;

		if (renderToTexture) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, renderWidth, renderHeight,
				needsDepth ? 0 : 24,
				FilterMode.Bilinear, RenderTextureFormat.Default,
				RenderTextureReadWrite.Default, renderSamples
			);
			if (needsDepth) {
				cameraBuffer.GetTemporaryRT(
					cameraDepthTextureId, renderWidth, renderHeight, 24,
					FilterMode.Point, RenderTextureFormat.Depth,
					RenderTextureReadWrite.Linear, 1
				);
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store,
					cameraDepthTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
				);
			}
			else {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
				);
			}
		}

在绘制完opaque类型的特效后设置render target时同样要做修改。

		context.DrawSkybox(camera);

		if (activeStack) {
			…

			if (needsDepth) {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store,
					cameraDepthTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
				);
			}
			else {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
				);
			}
			context.ExecuteCommandBuffer(cameraBuffer);
			cameraBuffer.Clear();
		}

最后记得释放纹理。

		DrawDefaultPipeline(context, camera);

		if (renderToTexture) {
			…
			cameraBuffer.ReleaseTemporaryRT(cameraColorTextureId);
			if (needsDepth) {
				cameraBuffer.ReleaseTemporaryRT(cameraDepthTextureId);
			}
		}

现在深度条纹特效可以在开启MSAA的情况下正常显示了,但是抗锯齿的效果被破坏了。因为MSAA无法影响深度信息了。我们需要想别的办法。

2.5 Depth-Only Pass

我们希望对于常规渲染使用MS depth texture,对于深度条纹效果使用non-MS depth texture,我们可以通过为深度贴图使用一个自定义的resolve pass来实现。可惜Unity对此的支持很有限。还有一个方法就是渲染两次深度,使用一个depth-only pass来渲染一张常规的深度贴图。听起来很耗性能但是是可行的。在开启MSAA又使用深度缓冲的情况下,Unity使用的就是这种方法,比如用于直射光级联阴影的screen-space shadow pass。

首先我们要把是否需要直接使用深度贴图 和 是否开启了MSAA而又需要使用depth pass 这两种情况区分开来。我们现在获取texture和设置render target的代码逻辑正好和关闭MSAA直接使用深度贴图的情况相匹配。

		bool needsDepth = activeStack && activeStack.NeedsDepth;
		bool needsDirectDepth = needsDepth && renderSamples == 1;
		bool needsDepthOnlyPass = needsDepth && renderSamples > 1;

		if (renderToTexture) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, renderWidth, renderHeight,
				needsDirectDepth ? 0 : 24,
				FilterMode.Bilinear, RenderTextureFormat.Default,
				RenderTextureReadWrite.Default, renderSamples
			);
			if (needsDepth) {
				cameraBuffer.GetTemporaryRT(
					cameraDepthTextureId, renderWidth, renderHeight, 24,
					FilterMode.Point, RenderTextureFormat.Depth,
					RenderTextureReadWrite.Linear, 1
				);
			}
			if (needsDirectDepth) {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store,
					cameraDepthTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
				);
			}
			else {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
				);
			}
		}
		
		…
		context.DrawSkybox(camera);

		if (activeStack) {
			…

			if (needsDirectDepth) {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store,
					cameraDepthTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
				);
			}
			else {
				cameraBuffer.SetRenderTarget(
					cameraColorTextureId,
					RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
				);
			}
			context.ExecuteCommandBuffer(cameraBuffer);
			cameraBuffer.Clear();
		}

如果确实需要,我们就在RenderAfterOpaque之前调用depth-only pass。做法类似opaque pass,只不过这是我们使用的pass名字是DepthOnly 。我们不需要多余renderer configuration,将深度贴图作为render target进行设置的和清除。

		context.DrawSkybox(camera);

		if (activeStack) {
			if (needsDepthOnlyPass) {
				var depthOnlyDrawSettings = new DrawRendererSettings(
					camera, new ShaderPassName("DepthOnly")
				) {
					flags = drawFlags
				};
				depthOnlyDrawSettings.sorting.flags = SortFlags.CommonOpaque;
				cameraBuffer.SetRenderTarget(
					cameraDepthTextureId,
					RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
				);
				cameraBuffer.ClearRenderTarget(true, false, Color.clear);
				context.ExecuteCommandBuffer(cameraBuffer);
				cameraBuffer.Clear();
				context.DrawRenderers(
					cull.visibleRenderers, ref depthOnlyDrawSettings, filterSettings
				);
			}

			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				renderWidth, renderHeight
			);
			…
		}

在Lit shader中添加对应的pass。我们可以直接复制默认pass中的代码,去除其中的instancing,cliping,LOD fading 即可。我们不需要写入颜色信息,所以使用color mask设置为0。ZWrite On 一直开启深度写入,而pass的顶点和片元函数,我们直接使用中DepthOnly HLSL文件。

		Pass {
			Tags {
				"LightMode" = "DepthOnly"
			}
			
			ColorMask 0
			Cull [_Cull]
			ZWrite On

			HLSLPROGRAM
			
			#pragma target 3.5
			
			#pragma multi_compile_instancing
			//#pragma instancing_options assumeuniformscaling
			
			#pragma shader_feature _CLIPPING_ON
			#pragma multi_compile _ LOD_FADE_CROSSFADE
			
			#pragma vertex DepthOnlyPassVertex
			#pragma fragment DepthOnlyPassFragment
			
			#include "../ShaderLibrary/DepthOnly.hlsl"
			
			ENDHLSL
		}

DepthOnly.hlsl可以从Lit.hlsl 中复制过来,去除其中不会影响深度的部分就是了。

#ifndef MYRP_DEPTH_ONLY_INCLUDED
#define MYRP_DEPTH_ONLY_INCLUDED

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

CBUFFER_START(UnityPerFrame)
	…
CBUFFER_END

CBUFFER_START(UnityPerCamera)
	…
CBUFFER_END

CBUFFER_START(UnityPerDraw)
	…
CBUFFER_END

CBUFFER_START(UnityPerMaterial)
	…
CBUFFER_END

TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);

TEXTURE2D(_DitherTexture);
SAMPLER(sampler_DitherTexture);

#define UNITY_MATRIX_M unity_ObjectToWorld

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

UNITY_INSTANCING_BUFFER_START(PerInstance)
	UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(PerInstance)

我们只需要位置信息和UV坐标,因此片元函数只需要应用LOD并执行clip即可。最终结果我们直接返回0。

struct VertexInput {
	float4 pos : POSITION;
	float2 uv : TEXCOORD0;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct VertexOutput {
	float4 clipPos : SV_POSITION;
	float2 uv : TEXCOORD3;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

VertexOutput DepthOnlyPassVertex (VertexInput input) {
	VertexOutput output;
	UNITY_SETUP_INSTANCE_ID(input);
	UNITY_TRANSFER_INSTANCE_ID(input, output);
	float4 worldPos = mul(UNITY_MATRIX_M, float4(input.pos.xyz, 1.0));
	output.clipPos = mul(unity_MatrixVP, worldPos);
	output.uv = TRANSFORM_TEX(input.uv, _MainTex);
	return output;
}

void LODCrossFadeClip (float4 clipPos) {
	…
}

float4 DepthOnlyPassFragment (VertexOutput input) : SV_TARGET {
	UNITY_SETUP_INSTANCE_ID(input);
	
	#if defined(LOD_FADE_CROSSFADE)
		LODCrossFadeClip(input.clipPos);
	#endif

	float4 albedoAlpha = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
	albedoAlpha *= UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Color);
	
	#if defined(_CLIPPING_ON)
		clip(albedoAlpha.a - _Cutoff);
	#endif
	
	return 0;
}

#endif // MYRP_DEPTH_ONLY_INCLUDED

现在我们在绘制深度条纹之前会有一个depth-only pass 了,MSAA终于能够再次生效了。可惜的是,MSAA无法使深度条纹受益,从而产生的锯齿也影响到的整体的画面。Unity的级联直射光阴影也以同样的原因受限于MSAA。


但MSAA仍可以作用于在这之后渲染的透明几何体。

3 HDR

如标题所言,这节我们将涉及高动态范围(high-dynamic-range)渲染。在这一节之前,我们问题的每个颜色通道的值都被限制在0-1之间,也就是能表现的亮度层次最多到1。但在实际生活中,我们知道这点值是远远不够的。以太阳为例,作为无法直视的存在,它的亮度是非常大的。除此之外,很多常规光源也很容易就超过1的阈值,特别是当你近距离观察的时候。为了说明这种情况,我将场景中的一个点光源的亮度设为100,同时把发光白球的发射强度(emission )也相应增强。

 

结果就是我们得到了一大块的纯白像素,只有在边缘能勉强看到一点亮度的过渡。HDR渲染的目的就是防止图像的非常明亮的部分退化为纯白色。这就要求我们先存储亮度信息看,然后再转化成可见的颜色。

3.1 提供HDR选项

我们在MyPipelineAsset中添加HDR选项,并把结果传给pipeline实例。

	[SerializeField]
	bool allowHDR;

	…

	protected override IRenderPipeline InternalCreatePipeline () {
		Vector3 shadowCascadeSplit = shadowCascades == ShadowCascades.Four ?
			fourCascadesSplit : new Vector3(twoCascadesSplit, 0f);
		return new MyPipeline(
			…
			renderScale, (int)MSAA, allowHDR
		);
	}

在MyPipeline中保存。

	bool allowHDR;

	public MyPipeline (
		…
		float renderScale, int msaaSamples, bool allowHDR
	) {
		…
		this.allowHDR = allowHDR;
	}

3.2 纹理格式

要想存储大于1的颜色值,我们就需要修改render texture 的纹理格式。只有当我们的pipeline和相机都开启的了HDR时我们才会进行HDR渲染,否则就只是常规的LDR。HDR纹理的不同之处在于它的颜色通道中存储的值是浮点数,而不是8位的数。这也就意味着他会占用更多的内存,所以应该只有在有确切需求的情况下开启HDR。

我们不需要让renderToTexture考虑HDR的开关情况,因为只有使用了后处理,HDR才有意义,因为最终的camera target 使用的仍是8位的LDR通道。

		RenderTextureFormat format = allowHDR && camera.allowHDR ?
			RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;

		if (renderToTexture) {
			cameraBuffer.GetTemporaryRT(
				cameraColorTextureId, renderWidth, renderHeight,
				needsDirectDepth ? 0 : 24,
				FilterMode.Bilinear, format,
				RenderTextureReadWrite.Default, renderSamples
			);
			…
		}

如果我们使用的是HDR显示器呢?

Unity现在并不支持HDR显示屏。blalbla...

如果不是计算不是在rendertexture上,请勿使用HDR。举个例子,我们开启MSAA,并关闭其余的后处理。可以发现结果有些轻微的不同,抗锯齿的质量似乎降低了。这是因为取颜色平均值的方法只适用于LDR的值,LDR中高亮区域的采样会影响最终的结果。所以HDR的颜色值会让MSAA的效果下降。

HDR 关/开 msaa x4

We also have to make sure that we don't unintentially blit to an intermediate LDR texture during post-processing, as that would eliminate the HDR data. So pass the format to both invocations of the active stack.

我们需要确保我们不会在后处理过程中意外地将结果blit进LDR格式的中介纹理中,这样会抹掉HDR的数据。所以在调用后处理栈时将纹理格式也传进去

			activeStack.RenderAfterOpaque(
				postProcessingBuffer, cameraColorTextureId, cameraDepthTextureId,
				renderWidth, renderHeight, renderSamples, format
			);
		
		…
		
				activeStack.RenderAfterTransparent(
					postProcessingBuffer, cameraColorTextureId,
					cameraDepthTextureId, renderWidth, renderHeight,
					renderSamples, format
				);

MyPostProcessingStack中增加对应的参数。我们只需要在DepthStripes中用它申请一张临时的纹理,而模糊效果反而在LDR下效果更好。

	public void RenderAfterOpaque (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, RenderTextureFormat format
	) {
		InitializeStatic();
		if (depthStripes) {
			DepthStripes(cb, cameraColorId, cameraDepthId, width, height, format);
		}
	}
	
	public void RenderAfterTransparent (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, int samples, RenderTextureFormat format
	) { … }
	
	…
	
	void DepthStripes (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, RenderTextureFormat format
	) {
		cb.BeginSample("Depth Stripes");
		cb.GetTemporaryRT(tempTexId, width, height, 0, FilterMode.Point, format);
		…
	}

3.3 色调映射(Tone Mapping)

从HDR到LDR的转换称之为色调映射,这个名词是由摄影和胶卷领域发展过来。传统的照片和胶卷表示的亮度范围有限,因此开发了许多技术来执行转换。不同的方法最终可以产生不同的氛围,像老式的胶片风格。然而,调整颜色属于color grading的范围,它在色调映射之后,我们只需要考虑调低图片的亮度,让最终结果可以落在LDR的范围内。

我们把色调映射作为一种可选择的后处理效果,因此在MyPostProcessingStack中添加这个选项。

	[SerializeField]
	bool toneMapping;

我们使用自己的pass来完成色调映射,所以添加对应的枚举和方法,最开始只是进行单纯的blit。使用RenderTargetIdentifier 格式的source和dest ID作为参数,以便我们可以灵活的选择实际传入的内容。

	enum Pass { Copy, Blur, DepthStripes, ToneMapping };
	
	…
	
	void ToneMapping (
		CommandBuffer cb,
		RenderTargetIdentifier sourceId, RenderTargetIdentifier destinationId
	) {
		cb.BeginSample("Tone Mapping");
		Blit(cb, sourceId, destinationId, Pass.ToneMapping);
		cb.EndSample("Tone Mapping");
	}

RenderAfterTransparent 中blit给camera target时,如果模糊效果是关闭的,就用色调映射代替常规的blit。否则,当色调映射或者MSAA被启用时,我们就额外创建一张纹理,并使用合适的pass。这样我们就可以处理MSAA或色调映射其一开启和两者都开启的情况。

	public void RenderAfterTransparent (
		CommandBuffer cb, int cameraColorId, int cameraDepthId,
		int width, int height, int samples
	) {
		if (blurStrength > 0) {
			if (toneMapping || samples > 1) {
				cb.GetTemporaryRT(
					resolvedTexId, width, height, 0, FilterMode.Bilinear
				);
				if (toneMapping) {
					ToneMapping(cb, cameraColorId, resolvedTexId);
				}
				else {
					Blit(cb, cameraColorId, resolvedTexId);
				}
				Blur(cb, resolvedTexId, width, height);
				cb.ReleaseTemporaryRT(resolvedTexId);
			}
			else {
				Blur(cb, cameraColorId, width, height);
			}
		}
		else if (toneMapping) {
			ToneMapping(cb, cameraColorId, BuiltinRenderTextureType.CameraTarget);
		}
		else {
			Blit(cb, cameraColorId, BuiltinRenderTextureType.CameraTarget);
		}
	}

PostEffectStack 中添加新的pass。

		Pass { // 3 ToneMapping
			HLSLPROGRAM
			#pragma target 3.5
			#pragma vertex DefaultPassVertex
			#pragma fragment ToneMappingPassFragment
			ENDHLSL
		}

在HLSL文件中添加对应的方法。这里我们先简单的处理一下值,他可以告诉我们那些像素是过亮(大于1)的。

And add the required function to the HLSL file. Initially return the color minus 1, saturated. That gives us an indication of which pixels contain over-bright colors. 

float4 ToneMappingPassFragment (VertexOutput input) : SV_TARGET {
	float3 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb;
	color -= 1;
	return float4(saturate(color), 1);
}

3.4 Reinhard

色调映射的目的就是降低画面的亮度,让原本的纯白区域可以展现更丰富的颜色,重新恢复因为精度限制而丢失的细节。就像人突然从暗区来到亮区,人眼会调整瞳孔以适应环境。但是我们不能直接线性降低整张图像的亮度,这样问题就从曝光过度变成曝光不足了。我们应该对图像的亮度进行非线性的缩放,在保留低亮度值的情况下,将高亮度值映射在01范围内。如这个函数:c/(1+c),将(0,+无穷)的值映射值(0,1)。这个函数叫做  Reinhard tone mapping operation,因为最初由Mark Reinhard提出,只不过他是将公式用于亮度值,而我们则是作用于个颜色通道。将公式用于我们的pass。

	color /= 1 + color;

左右分别为未使用和使用Reinhard色调映射的图像

最终的图像没有出现过亮的像素了,但是图像整体看起来时低饱和度的,灰蒙蒙的。通过引用色调映射的同时禁用HDR,我们可以发现,最亮值(1)降低为0.5.

3.5 修改Reinhard

实现色调映射的方法有很多中,后面的color grading还可以进一步的调整映射后的颜色,但是Reinhard时最简单的实现方法。当然我们还可以再次基础上做一个小小的调整。通过一个值来确定多大范围内的HDR值会被压缩至LDR。任何超出该范围的HDR值仍会被映射为1。这样就可以降低色调映射对低亮度场景的影响,或者增加亮暗的对比。

调整后的Reinhard公式为:\frac{c(1+\frac{c}{w^2})}{1+c}  w就是限制的white point ,或者说色调映射的最大范围。如果w是无限大的值,那么结果就和之前的原始公式一直。如果值为1就相当于不做色调映射。

w从1-5的曲线变化

添加对应的配置条目,最小值为1,相当于整个画面仍是过曝的。最大值可以设为100,这已经足够接近原始公式了。m=1/w^2 计算好后再传给GPU,节约计算。

	[SerializeField, Range(1f, 100f)]
	float toneMappingRange = 100f;

	…

	void ToneMapping (
		CommandBuffer cb,
		RenderTargetIdentifier sourceId, RenderTargetIdentifier destinationId
	) {
		cb.BeginSample("Tone Mapping");
		cb.SetGlobalFloat(
			"_ReinhardModifier", 1f / (toneMappingRange * toneMappingRange)
		);
		Blit(cb, sourceId, destinationId, Pass.ToneMapping);
		cb.EndSample("Tone Mapping");
	}

shader中的计算则变为\frac{c(1+cm)}{1+c}

float _ReinhardModifier;

…

float4 ToneMappingPassFragment (VertexOutput input) : SV_TARGET {
	float3 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb;
	color *= (1 + color * _ReinhardModifier) / (1 + color);
	return float4(saturate(color), 1);
}

Uniy2019好像改了很多东西,所以这个翻译大概废了,不过有始有终 总算翻译完了

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值