后期处理效果

  如第八章所述,后期处理效果是获得基于物理的阴影的一个重要部分。由于需要使用 HDR,所以必须有色调映射,这最好作为后期处理效果来实现。当你有了一个后期效果后,不妨再加入一些其他的效果,如景深或模糊。

  虽然Unity资产商店提供的新的官方后期效果系统非常强大,但你可能仍然需要实现自己的色调映射,或其他一些Unity不包括的后期效果,所以至少了解一下如何开发图像效果是有意义的。另外请记住,在Unity 2017之前的后期处理效果曾经被称为图像效果,如果你遇到这个术语。

  新的后期处理栈正在快速发展,由于我提供的任何细节都会很快过时,我将坚持向你展示如何从头开始做后期处理效果,而不依赖该栈。也就是说,新的堆栈看起来很棒,具有电影级的调色能力。

后期处理效果如何工作

  后处理效果基本上是一个图像效果着色器,它被应用于当前屏幕中的每个像素。想象一下,你已经渲染了你的场景,不是在屏幕上,而是在一个单独的缓冲区,在Unity中被称为RenderTexture。

  现在你可以把它送到屏幕上进行可视化处理,或者对它进行操作。为了处理它,你可以在后处理着色器中把它作为一个纹理来访问。这个着色器与场景中的其他着色器是分开执行的,因为它需要在场景刚刚被渲染的时候执行。为了触发它,有一个函数签名,你可以在连接到你想应用后期效果的摄像机的脚本中使用。

为什么后期处理效果很有用?

  对于基于物理的阴影来说,后期效果最重要的用途是用于色调映射。你应该始终使用HDR相机以获得更好的真实感,然后在LDR屏幕上显示之前需要将色调映射回LDR。另一个对真实性有很大作用的效果,特别是对皮肤和其他基于次表面的材料,是景深。

  对于Unity不支持线性色彩空间的平台,后期效果是一个很好的方式,可以用最少的麻烦来实现。Unity最近为移动平台增加了对线性色彩空间的支持,但在这之前,如果你想在线性空间中执行计算,你需要首先将输入到着色器(颜色选择器、纹理等)的任何颜色转换成线性空间,进行着色计算,然后将整个渲染的图像转换成伽玛,作为后期效果的最后一个动作。

  知道如何做到这一点对你来说可能还是有用的,所以我们将介绍一下。

设置后期效果

你需要三样东西来设置一个后期效果:

  • 场景中的一个摄像机来应用它
  • 作为一个组件添加到该相机的脚本
  • 组件将执行的着色器
HDR和线性设置

  首先,检查你的相机是否在使用HDR。如图10-1所示,允许HDR选项必须是打开的。还要注意我们的渲染路径是Forward。在第1章中,我们谈到了Unity中不同类型的渲染器。在Unity中可用的不同类型的渲染器,在游戏行业中也是如此。从Unity 2017.2开始,可以选择的渲染器有 渲染器的选择是前进式和延迟式,还有两个传统的选择,即VertexLit和较早的延迟式

Unity 最后一帧_Unity 最后一帧


  在未来,它们可能会被新的ScriptableRenderLoop加入,这是一个超级灵活的渲染器,目前正在开发中。

  我们的着色器与Unity Forward渲染器一起工作,所以我们要坚持使用它。在开发复杂的BRDFs时,延迟渲染器可能会受到限制,因为在开发渲染器时,决定哪些数据可以提供给着色器的决定已经定下来了。

  确保在你的Player Settings中,色彩空间被设置为线性。要检查这一点,打开Build设置,然后是播放器设置,然后是其他设置,在那里你可以找到色彩空间,如图10-2所示。

Unity 最后一帧_渲染器_02


  在这两者中,色彩空间是更重要的一个,所以如果你需要只选择一个,就选择线性。也就是说,HDR也有助于实现真实感,所以认真考虑使用HDR相机渲染你的游戏。

脚本设置

  让我们创建一个新的后期效果脚本,选择摄像机并点击检查器中的添加组件按钮。滚动到新建脚本,选择C#作为语言,并将新脚本命名为PostEffects。创建的脚本应该和清单10-1中的一样。

Listing 10-1. Default C# Script

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PostEffects : MonoBehaviour {
	// Use this for initialization
	void Start () {
	}
	// Update is called once per frame
	void Update () {
	}
}

  这是一个经典的基于MonoBehaviour的空脚本。让我们开始定制它,使其作为后期效果的应用者工作。首先,我们要添加一行,防止我们把这个脚本作为一个组件添加到不是摄像机的游戏对象上。

[RequireComponent (typeof (Camera))]
[ExecuteInEditMode]

  第二行使脚本也在编辑模式下执行;否则,在不播放的情况下预览我们的修改会很麻烦。然后我们应该给这个类添加一些成员,再加上一个getter方法。我们需要一个对脚本将要使用的图像效果着色器的引用,另外我们还需要为它动态地创建一个材质(见清单10-2)。

清单10-2. 添加到脚本中的成员和获取器

public Shader curShader;
private Material curMaterial;
Material material
{
	get
	{
		if (curMaterial == null){
			curMaterial = new Material(curShader);
			curMaterial.hideFlags = HideFlags.HideAndDontSave;
		}
		return curMaterial;
	}
}

  为了使脚本更加方便,我们可以自动填入相应的着色器(通过名称找到它),确保脚本在不支持图像效果或不支持着色器的情况下不会出错。我们可以通过在Start函数中增加几行来做到这一点,如清单10-3所示。

Listing 10-3. Complete Start Function

void Start () {
	curShader = Shader.Find("Hidden/PostEffects");
	GetComponent<Camera>().allowHDR = true;
	if(!SystemInfo.supportsImageEffects){
		enabled = false;
		Debug.Log("not supported");
		return;
 	}
 	if (!curShader && !curShader.isSupported){
		 enabled = false;
		 Debug.Log("not supported");
	}
	GetComponent<Camera>().depthTextureMode = DepthTextureMode.Depth;
}

  请注意,我们也在强迫相机计算深度纹理。深度纹理对很多效果都很有用,比如景深,但在这个案例中,我只想告诉你它是什么样子的,以及如何获得它。

  其他管理任务包括在禁用相机GameObject时销毁材质,以及在相机未启用时提前从更新函数返回(见清单10-4)。当你想在Update函数中改变你的图像效果着色器的值时,后者是必要的。

void Update () {
	if (!GetComponent<Camera>().enabled)
	return;
}
void OnDisable(){
	if(curMaterial){
		DestroyImmediate(curMaterial);
	}
}

  最后,我们到了神奇发生的地方。OnDisable、Start和Awake是由引擎在适当的时候触发的方法。

  为了应用我们的效果,我们需要另一个这样的函数,OnRenderImage。

void OnRenderImage(RenderTexture source, RenderTexture destination).

  正如你所看到的,它需要两个参数,一个源RenderTexture和一个目标参数。你应该把实际应用效果的代码放在这个方法中。还有一种方法,使用两个类似的方法–void OnPreRender() 和 void OnPostRender()。在这种情况下,你需要自己创建源渲染纹理,这取决于你的平台,可能更有效率。我们将介绍这两种方法。让我们从OnRenderImage开始。需要有一系列的步骤。

  • 在RenderTexture中获取当前渲染的场景(RenderTexture source为我们做了这个)。
  • 使用Graphics.Blit将图像效果着色器应用于源纹理。Blit 意味着将所有像素从原点表面复制到目标表面,同时 可以选择应用一些变换。
  • 这也包括一个目标RenderTexture。如果它是空的,Blit将把结果直接发送到屏幕上。

  为了将其付诸实践,让我们用通常的方法制作一个图像效果着色器(在项目窗格中右键单击,然后选择创建 ➤ 着色器 ➤ 图像效果着色器)。将此着色器称为 PostEffects。其结果显示在清单 10-5 中。

清单10-5. 默认的图像效果着色器

Shader "Hidden/PostEffects"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	SubShader
	{
		// No culling or depth
		Cull Off ZWrite Off ZTest Always
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
			};
			struct v2f
			{
				float2 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
			};
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = v.uv;
				return o;
			}
			sampler2D _MainTex;
			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 col = tex2D(_MainTex, i.uv);
				// just invert the colors
				col = 1 - col;
				return col;
			}
			ENDCG
		}
	}
}

  这看起来主要是一个Unlit shader,除了默认情况下路径在Hidden之下,而且它声明了Cull和Zwrite关闭,以及ZTest Always。我们之前没有动过这些值,因为我们不需要。之所以需要这些值,是因为我们不是在给模型着色,而是在处理一个将在屏幕上显示为两个三角形的2D图像,形成一个四边形。我们不能剔除背面,而Zwrite也没有任何作用,因为没有深度。

  主纹理实际上是我们渲染的场景。在OnRenderImage中应用这个着色器,看看结果如何。我们已经有了源纹理和目标纹理(也就是屏幕),所以我们只需要几行代码来应用这个效果(见清单10-6)。

清单10-6. OnRenderImage 第一个版本

void OnRenderImage(RenderTexture sourceTexture, RenderTexture destTexture) {
	if (curShader != null)
	{
		Graphics.Blit(sourceTexture, destTexture, material, 0);
	}
}

  Graphics.Blit接收源纹理、目标纹理、包含要应用的着色器的材质,以及要使用的通道,从0开始。 结果如图10-3所示。

Unity 最后一帧_着色器_03


  正如你所看到的,默认的后期效果颠倒了场景的颜色。让我们复制并粘贴这个通道,改变片段函数的内容,以显示摄像机的深度纹理。我们可以在通道内添加一个名称,我们需要将Cull Off行移到每个通道的顶部。为了访问摄像机的深度纹理,我们需要用正确的名字声明这个变量(这是一个惯例),然后用一个宏将其转换为灰度颜色(清单10-7)。

清单 10-7. 解码相机的深度纹理

sampler2D _CameraDepthTexture;
fixed4 frag (v2f i) : SV_Target
{
	fixed depth = UNITY_SAMPLE_DEPTH( tex2D(_CameraDepthTexture, i.uv) );
	fixed4 col = fixed4(depth,depth,depth, 1.0);
	return col;
}

完整的Pass将看起来像清单10-8。

清单10-8. 显示相机深度的图像效果通道

Pass
{
	name "DebugDepth"
	Cull Off ZWrite Off ZTest Always Lighting Off
	
	CGPROGRAM
	#pragma vertex vert
	#pragma fragment frag
	#include "UnityCG.cginc"
	struct appdata {
		float4 vertex : POSITION;
		float2 uv : TEXCOORD0;
	};
	struct v2f {
		float2 uv : TEXCOORD0;
		float4 vertex : SV_POSITION;
	};
	v2f vert (appdata v) {
		v2f o;
		o.vertex = UnityObjectToClipPos(v.vertex);
		o.uv = v.uv;
		return o;
	}
	
	sampler2D _CameraDepthTexture;
	
	fixed4 frag (v2f i) : SV_Target {
		fixed depth = UNITY_SAMPLE_DEPTH( tex2D(_CameraDepthTexture, i.uv) );
		fixed4 col = fixed4(depth,depth,depth, 1.0);
		return col;
 	}
 	ENDCG
}

  你想应用的Pass已经改变,所以你需要相应地改变 Blit 这一行。

Graphics.Blit(sourceTexture, destTexture, material, 1);

  你正在应用着色器中的第二遍。为了展示这个结果,我把相机的远平面减少到12(深度越大,越不精确),并在更远的地方添加了一些球体(见图10-4)。

Unity 最后一帧_渲染器_04


  让我们给脚本添加一些选项,这样你就可以切换正在应用的图像效果通道。我们需要添加两个布尔运算,一个用于反转效果,另一个用于深度效果。在OnRenderImage中,我们将检查哪一个是,并采取相应的行动。如果两个都打开了,反转效果将获胜。如果都没有打开,场景就不会有任何后期效果的应用(见清单10-9)。

  清单 10-9. 根据检查器中可见的bool属性,在不同的通道之间进行切换

public bool InvertEffect;
public bool DepthEffect;
void OnRenderImage(RenderTexture sourceTexture, RenderTexture destTexture) {
	if (curShader != null)
	{
		if (InvertEffect) {
			Graphics.Blit(sourceTexture, destTexture, material, 0);
		} else if (DepthEffect) {
			Graphics.Blit(sourceTexture, destTexture, material, 1);
		} else {
			Graphics.Blit(sourceTexture, destTexture);
		}
	}
}

  现在你可以在不同的效果之间进行切换,从检查器中查看

  转换为线性

  要尝试这一点,你需要在玩家设置中把颜色空间切换回伽马。为了使这一点完全正确,我们需要为鸭子也做一个着色器,将其内部的任何颜色转换为线性空间。但那会太长了;即使不这样做,我们仍然可以看到用线性空间计算的图像效果和用伽马空间计算的图像效果之间的差异

  在成员中再添加一个布尔值,并将其称为LinearInvertEffect。在链上再添加一个,并在图像效果着色器上添加一个通道。在该通道中,_MainTex应该在2.2的幂范围内进行采样。然后应用反转,然后颜色应该被提升到1/2.2的幂。(清单10-10)。

清单 10-10. 在伽玛空间项目中用线性空间计算的效果

fixed4 frag (v2f i) : SV_Target
{
	fixed4 col = pow(tex2D( _MainTex, i.uv ), 2.2);
	col = 1 - col;
	return pow(col, 1/2.2);
}

  尽管我们根本没有做什么,但当在线性空间中计算效果时,你仍然可以看到不同的结果(在伽玛空间的项目中,请记住)(见图10-5)。

Unity 最后一帧_Unity 最后一帧_05

RenderTextures简要概述

  RenderTextures是你可以写入的纹理。你可以设置一个作为摄像机的目标。你可以从用户界面上创建它们,作为资产,或者你可以通过编程来创建它们

  当你以编程方式创建它们时,重要的是要记住释放它们。你的一个方法是 可以通过获得一个临时的RenderTexture来做到这一点。

RenderTexture.GetTemporary(512,512,24,RenderTextureFormat.DefaultHDR);

  在你使用它之后,调用

RenderTexture.ReleaseTemporary(someRenderTex);

  到现在为止,我们只考虑了不连续的后期效果,因为它们不使用前一个通道的结果。一些使用它的效果是模糊和景深。在这种情况下,你需要使用临时纹理来作为参数传递给下一个着色器通道。这不在我们这本书的范围内,因为你很可能会使用Unity的后处理堆栈来做这种效果。

  正如本章开始时提到的,有一种不使用OnRenderImage的替代方法,其工作原理如清单10-11所示。

清单10-11。应用后期效果的另一种方法

RenderTexture aRenderTex;
void OnPreRender()
{
	aRenderTex = RenderTexture.GetTemporary(width,height,bitDepth, textureFormat);
	camera.targetTexture = myRenderTexture;
}
void OnPostRender()
{
	camera.targetTexture = null;
	Graphics.Blit(aRenderTex,null as RenderTexture, material, passNumber);
	RenderTexture.ReleaseTemporary(aRenderTex);
}

  这个技术可能更快,取决于你的平台。你可以看到其中对GetTemporary和ReleaseTemporary的使用。

一个简单的色调映射器

  色调映射,如第8章所述,是一种将HDR缓冲区优雅地转换为LDR缓冲区的方法。其目的是以美观的方式将HDR值映射到LDR值,而不是直接剪掉它们。

  您可以使用许多色调映射运算符,但我们将坚持使用一个相对简单的运算符,即 John Hable 为神秘海域 2 发明的一种,并将其发布在他的博客上供所有人使用。

  首先,我们需要添加一个新的布尔值,一个新的if语句,以及一个新的着色器通道。我们还需要添加另一个属性,即相机的曝光。为了在检查器中获得一个滑块,你应该使用这个代码。

[Range(1.0f, 10.0f)]
public float ToneMapperExposure = 2.0f;

  在声明了它之后,我们要在效果激活时将其值发送到着色器中。为此我们应该使用 material.setFloat。我们要把它放在if链中。

[...]
} else if (ToneMappingEffect) {
	material.SetFloat("_ToneMapperExposure", ToneMapperExposure);
	Graphics.Blit(sourceTexture, destTexture, material, 3);
} else {
[...]

  然后我们需要在着色器中声明该属性,并且只在我们要使用它的段落中声明该变量。然后,我们在片段着色器中实现Hable的操作符(见清单10-12)。

  清单 10-12. Hable Tone Mapper操作符

float _ToneMapperExposure;
float3 hableOperator(float3 col)
{
	float A = 0.15;
	float B = 0.50;
	float C = 0.10;
	float D = 0.20;
	float E = 0.02;
	float F = 0.30;
	return ((col * (col * A + B * C) + D * E) / (col * (col * A + B) + D * F)) - E / F;
}
fixed4 frag (v2f i) : SV_Target
{
	float4 col = tex2D(_MainTex, i.uv);
	float3 toneMapped = col * _ToneMapperExposure * 4;
	toneMapped = hableOperator(toneMapped) / hableOperator(11.2);
	return float4(toneMapped, 1.0);
}

  这个着色器的代码已经变得很长了,所以我不能只是把它贴在这里供你浏览。你应该通过下载书中的源代码并查看名为chapter10-posteffects.unity的场景来检查你的最终结果是否正确。

  在这一点上,如果你选中了激活色调贴图器通道的布尔值,你应该能够改变检查器中的曝光滑块,并看到它改变了结果。打开和关闭色调贴图器应该能让你看到它是如何影响最终结果的(见图10-6)。

Unity 最后一帧_Unity 最后一帧_06


  现在你对色调映射的实现有了一些了解。网上记录了各种色调映射操作,尽管你很可能想使用强大的后期处理堆栈来实现,接下来我将简单介绍一下。

Post-Processing Stack v1

  这个处理栈从2017年8月开始在资产商店提供。这是新的后期处理堆栈的第一个版本,它给你提供了一些预制的效果,你可以打开和关闭并自定义。

  要获得它,请从资产商店下载,并将它作为一个组件附加到摄像机上。你必须创建一个后期处理设置文件(见图10-7),你可以打开和关闭不同的效果。如果你使用HDR(如果性能允许,你应该这样做),那么一定要打开颜色分级效果,其中包括色调映射器。还要考虑景深问题,因为它有助于感知真实性。

Unity 最后一帧_unity_07

Post-Processing Stack v2

  这个处理栈从2017年8月开始在GitHub上提供。这是新处理栈的第二个版本,它增加了更多的灵活性。一些根据不同的触发器切换后期效果设置的方法,脚本化,能够添加你自己的效果,并与即将到来的渲染脚本化循环兼容

  为了得到它,你可能需要把GitHub项目的v2分支克隆到你的资产文件夹中。这个堆栈的设置比较复杂,很可能会改变,所以你应该阅读GitHub的维基,或者Unity手册中的说明。你也需要为这个项目创建一个配置文件,并再次考虑使用包括色调映射器和景深(见图10-8)。

Unity 最后一帧_渲染器_08

总结

  本章使用C#脚本和图像效果着色器,从头开始实现了各种后期效果。我们介绍了RenderTextures,并实现了一个简单的色调映射器。我们还介绍了Unity开发的两个后期效果栈,你应该用它来满足你的色调映射和调色的需要。

  下一章介绍了一些流行的BRDFs,你可能要考虑实施。我们将介绍一个工具来探索它们,并以多种方式可视化它们的差异。