unity 后期特效处理初探 转
http://www.manew.com/thread-45482-1-1.html?_dsign=ec7c9be1
以前我研究过unity能否代替FX Composer用作特效制作,用的是免费版。结论是,很大程度上Unity可用于着色器原型设计。但它隐藏了可以让我实现更复杂图形技术的底层接口,所以我这几年转而研究SharpDX。
写代码是好事,但有时候你只需要拖放一些资源,附加一些着色器就可以实现同样的东西。现在Unity免费提供齐全功能,是时候重新去研究下了。这篇文章记录了我的研究发现。
因为最近我的工作大多围绕后期处理效果,我觉得应该以此开始并试着实现多步后期处理效果作为测试。我快速在场景里放置了地形和一些树。
事实证明使用Unity附带的标准后期处理特效很简单,只要拖放一个脚本到相机的Inspector面板就可以了。例如,导入特效包并且从中选择Bloom.cs添加到你的主摄像机。瞧,我们的场景多绚丽。
标准资源里有很多种值得尝试的后期效果。特别是对新手来说,实现我们自己的后期效果要多费点劲,并且文档也没有说的太详细。
我们从一些简单例子开始了解自定义后期效果,其中一个就是用指定颜色给屏幕染色。首先免责声明下,这是我第一次用Unity这么底层的东西并且搜集的信息都来源自网络和已有代码。很可能还有更高效的方式来实现同样的效果。
一个基础的后期效果脚本看起来如下(我选择了C#语言):
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
using System;using UnityEngine;namespace UnityStandardAssets.ImageEffects{ [ExecuteInEditMode] [RequireComponent (typeof(Camera))] class SimplePostEffect : PostEffectsBase { public Shader TintShader = null; public Color TintColour; private Material TintMaterial = null; public override bool CheckResources () { CheckSupport (true); TintMaterial = CheckShaderAndCreateMaterial (TintShader); if (!isSupported) ReportAutoDisable (); return isSupported; } void OnRenderImage (RenderTexture source, RenderTexture destination) { if (CheckResources()==false) { Graphics.Blit (source, destination); return; } TintMaterial.SetColor("TintColour", TintColour); //Do a full screen pass using TintMaterial Graphics.Blit (source, destination, TintMaterial); } }} |
我们自定义的SimplePostEffect 类继承自PostEffectsBase 且有两个公有成员,一个着色器变量(TintShader)用于特效,一个着色器会用到的颜色参数(TintColour)。该类的主要方法就是OnRenderImage(),这个方法将在主场景渲染完成后的每帧被调用。方法接受两个RenderTexture类型的参数,source包含主场景渲染,destination将接受我们后期效果的结果。
使用我们在类中声明的着色器对象(TintShaderShader)来创建了一个用于屏幕空间渲染的材质对象。这是在CheckResources()中完成的,同时也执行了一些检查来确保着色器被所选的平台支持。
Graphics对象的Blit()方法做了实际渲染,将我们的材质应用到了source上。顺便说下,如果调用Blit() 函数没有指定材质的话,将仅把RenderTexture输入直接拷贝到输出。
我们基本都准备好了,只缺一个着色器就能实现后期效果了。同样的,一个简单的后期效果着色器如下:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
Shader "Custom/Tint"{ Properties { _MainTex ("", any) = "" {} } CGINCLUDE #include "UnityCG.cginc" struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 TintColour; v2f vert( appdata_img v ) { v2f o = (v2f)0; o.pos = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = v.texcoord; return o; } float4 frag(v2f input) : SV_Target { float4 result = tex2D(_MainTex, input.uv) * TintColour; return result; } ENDCG SubShader { Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } } Fallback off} |
它包含一个顶点和一个片段着色器,都很简单。值得一提的是_MainTex纹理对Unity来说有特别意义,Graphics.Blit()函数在执行着色器时会找到它然后绑定到source RenderTexture传给上面的OnRenderImage()函数。除了着色器是自解释以外,顶点着色器仅用引擎提供的模型视图投影矩阵变换了顶点,片段着色器采样了_MainTex且与着色器常量TintColour相乘。
应用这个后期效果,只需将上面创建的C#脚本拖到相机并把着色器赋给“Tint Shader”字段即可。
然后设置你选择的颜色,效果很棒吧!
有趣的是你可以轻易通过添加额外脚本到相机上来合并后期效果。
这是我们自定义的对场景着色的效果再添加blooms脚本:
这么做显然很容易,但是后期效果通常不能只靠主要的渲染目标和一个着色器常量就可以实现。我们需要更多的数据,诸如景深、法线以及任意可能的渲染通道输出。
所以我决定接着实现体积光作为一种后期效果。为了实现这个,我们至少需要深度缓存和阴影映射,并且能够连接渲染通道去实现光纤模糊并应用到场景。
Unity位于延迟着色模式下时,提供了通过全局纹理采样器来访问渲染目标的g-buffer。
_CameraGBufferTexture0: ARGB32格式,漫反射颜色(RGB),没有使用(A)。
_CameraGBufferTexture1: ARGB32格式,镜面反射(RGB),光滑程度(A)。
_CameraGBufferTexture2: ARGB2101010格式,世界空间法线(RGB),没有使用(A)。
_CameraGBufferTexture3: ARGB32 (non-HDR)或者 ARGBHalf (HDR)格式,放射+光照+光照贴图+反射探测的缓存。
我们也可通过CameraDepthTexture 纹理采样来访问深度缓存。
我们实现的体积光技术是基于Toth et al的简化版,这在当今游戏里也是很好的标准。它是基于屏幕空间的光路在每个例子里计算光的消失、内散射和吸收。这完全是一种高消耗的技术,所以图形编程通常避免在全屏分辨率下进行计算,通常选择四分之一分辨率。
所以我们后期处理管线第一步是缩小深度缓存的采样。这是一个直接的过程,执行四分之一分辨率的着色器如下:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
Shader "Custom/DownscaleDepth" {CGINCLUDE#include "UnityCG.cginc"struct v2f{ float4 pos : SV_POSITION; float2 uv : TEXCOORD0;};sampler2D _CameraDepthTexture;float4 _CameraDepthTexture_TexelSize; // (1.0/width, 1.0/height, width, height)v2f vert( appdata_img v ){ v2f o = (v2f)0; o.pos = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = v.texcoord; return o;}float frag(v2f input) : SV_Target{ float2 texelSize = 0.5 * _CameraDepthTexture_TexelSize.xy; float2 taps[4] = { float2(input.uv + float2(-1,-1)*texelSize), float2(input.uv + float2(-1,1)*texelSize), float2(input.uv + float2(1,-1)*texelSize), float2(input.uv + float2(1,1)*texelSize) }; float depth1 = tex2D(_CameraDepthTexture, taps[0]); float depth2 = tex2D(_CameraDepthTexture, taps[1]); float depth3 = tex2D(_CameraDepthTexture, taps[2]); float depth4 = tex2D(_CameraDepthTexture, taps[3]); float result = min(depth1, min(depth2, min(depth3, depth4))); return result;}ENDCGSubShader{Pass{ ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG}}Fallback off} |
上面的着色器有几个地方需要注意一下,它用到了Unity提供的深度渲染对象_CameraDepthTexture。如果为纹理名字添加后缀_TexelSize,就能自动获取纹理尺寸,这是一非常有用的特性。另外要注意,不能通过对相邻的采样点求平均值来降低深度缓存的采样,我们必须使用min或者max运算。
在C#代码这里,我们需要创建四分之一分辨率的渲染对象,用一个32位单精度浮点型变量来存储深度:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
void OnRenderImage (RenderTexture source, RenderTexture destination){ if (CheckResources()==false) { Graphics.Blit (source, destination); return; } RenderTextureFormat formatRF32 = RenderTextureFormat.RFloat; int lowresDepthWidth= source.width/2; int lowresDepthHeight= source.height/2; RenderTexture lowresDepthRT = RenderTexture.GetTemporary (lowresDepthWidth, lowresDepthHeight, 0, formatRF32); //downscale depth buffer to quarter resolution Graphics.Blit (source, lowresDepthRT, DownscaleDepthMaterial); ...... ...... ...... RenderTexture.ReleaseTemporary(lowresDepthRT);} |
这里就不解释如何设置着色器和创建降低采样的材质了,因为前面已经说过了。一旦使用了一个渲染对象,为了避免内存泄露应该用ReleaseTemporary函数将它返回到池中。下次再请求一个渲染对象的时候,Unity将优先在池里查询并找到一个和参数相匹配的空闲渲染对象,而不是每次都去创建一个新的。
有了降低采样的深度渲染对象以后就可以计算体积雾了。就像我说过的,这就是一个去掉几个组件的简化版的Toth方法。
基本的体积雾算法包含从表面到相机的光路,以及计算由于每次穿过雾所造成的消光和内散射的采样,还有可见性,即采样是否能看到光。
为了在每个光路步长中计算光的可见性,我们需要能对引擎为光源(这里是主平行光)产生的阴影贴图进行采样。Unity使用级联阴影贴图,简而言之就是它把视锥分割成一些分区并且为每个区分配一个四分之一阴影贴图。这大大改善了阴影贴图的利用和透视混叠。
访问特定光晕的阴影贴图还要多做点工作,可以通过创建一个自定义名字的全局命令缓冲区变量来获取光照贴图。我们创建了下面这个简短的脚本并且把它赋给了场景中带有阴影的平行光源。
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
|
public class ShadowCopySetup : MonoBehaviour{ CommandBuffer m_afterShadowPass = null; // Use this for initialization void Start () { m_afterShadowPass = new CommandBuffer(); m_afterShadowPass.name = "Shadowmap Copy"; //The name of the shadowmap for this light will be "MyShadowMap" m_afterShadowPass.SetGlobalTexture ("MyShadowMap", new RenderTargetIdentifier(BuiltinRenderTextureType.CurrentActive)); Light light = GetComponent<Light> (); if (light) { //add command buffer right after the shadowmap has been renderered light.AddCommandBuffer (UnityEngine.Rendering.LightEvent.AfterShadowMap, m_afterShadowPass); } }} |
这个脚本所做的就是创建一个commandbuffer,并用一条指令把阴影贴图作为名为“MyShadowMap”的全局纹理暴露出来。我们把这个command buffer附在光源上,并在阴影贴图被创建后调用。更多关于命令缓冲区的基本知识可以点此学习。
有了阴影贴图后就可以继续计算体积雾的着色器了。
这个着色器有点长,所以我针对功能进行了分解说明。我们需要在每一步里对阴影贴图进行采样。棘手的是使用简单的视图和世界空间的光照变换在级联上是不够的:
|
1
2
3
4
|
//calculate weights for cascade split selection为所选的级联计算权重float4 viewZ = -viewPos.z;float4 zNear = float4( viewZ >= _LightSplitsNear );float4 zFar = float4( viewZ < _LightSplitsFar );float4 weights = zNear * zFar; |
计算每个级联的权重然后在我们的主循环中(假设currentPos 是当前采样的世界坐标):
|
01
02
03
04
05
06
07
08
09
10
11
12
|
for(int i = 0 ; i < NUM_SAMPLES ; i++ ){ //calculate shadow at this sample position float3 shadowCoord0 = mul(unity_World2Shadow[0], float4(currentPos,1)).xyz; float3 shadowCoord1 = mul(unity_World2Shadow[1], float4(currentPos,1)).xyz; float3 shadowCoord2 = mul(unity_World2Shadow[2], float4(currentPos,1)).xyz; float3 shadowCoord3 = mul(unity_World2Shadow[3], float4(currentPos,1)).xyz; float4 shadowCoord = float4(shadowCoord0 * weights[0] + shadowCoord1 * weights[1] + shadowCoord2 * weights[2] + shadowCoord3 * weights[3],1); //do shadow test and store the result float shadowTerm = UNITY_SAMPLE_SHADOW(MyShadowMap, shadowCoord);} |
unity_World2Shadow[]是Unity为每个级联提供的世界到光空间的变换,就像UNITY_SAMPLE_SHADOW通过我们向外显示出来的带有command buffer的MyShadowMap来对阴影贴图进行采样。当采样在阴影中时ShadowTerm 为0,反之为1.
下一步我们需要在光路方向上计算传播和内散射。我已经把公式简化了一下。
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
float transmittance = 1;for(int i = 0 ; i < NUM_SAMPLES ; i++ ){ float2 noiseUV = currentPos.xz / TerrainSize.xz; float noiseValue = saturate( 2 * tex2Dlod(NoiseTexture, float4(10*noiseUV + 0.5*_Time.xx, 0, 0))); //modulate fog density by a noise value to make it more interesting float fogDensity = noiseValue * FogDensity; float scattering = ScatteringCoeff * fogDensity; float extinction = ExtinctionCoeff * fogDensity; //calculate shadow at this sample position float3 shadowCoord0 = mul(unity_World2Shadow[0], float4(currentPos,1)).xyz; float3 shadowCoord1 = mul(unity_World2Shadow[1], float4(currentPos,1)).xyz; float3 shadowCoord2 = mul(unity_World2Shadow[2], float4(currentPos,1)).xyz; float3 shadowCoord3 = mul(unity_World2Shadow[3], float4(currentPos,1)).xyz; float4 shadowCoord = float4(shadowCoord0 * weights[0] + shadowCoord1 * weights[1] + shadowCoord2 * weights[2] + shadowCoord3 * weights[3],1); //do shadow test and store the result float shadowTerm = UNITY_SAMPLE_SHADOW(MyShadowMap, shadowCoord); //calculate transmittance transmittance *= exp( -extinction * stepSize); //use shadow term to lerp between shadowed and lit fog colour, so as to allow fog in shadowed areas float3 fColour = shadowTerm > 0 ? litFogColour : ShadowedFogColour; //accumulate light result += (scattering * transmittance * stepSize) * fColour; //raymarch towards the camera currentPos += rayDir * stepSize;}return float4(result, transmittance); |
在每一步中我计算了transmittance(表示管理通过雾的光量),以及scattering(描述达到采样并散射向观察者的光量)。每个点用shadowTerm 去计算雾的颜色,区间在阴影雾颜色和光照雾颜色之间。输出累加的雾颜色以及透光率,之后会将雾应用到渲染对象上使用。还用了一个2D噪声纹理调整雾的密度以加入一些变化。
我们需要创建四分之一分辨率的渲染目标去渲染雾:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
RenderTextureFormat format = RenderTextureFormat.ARGBHalf;int fogRTWidth= source.width/2;int fogRTHeight= source.height/2;RenderTexture fogRT1 = RenderTexture.GetTemporary (fogRTWidth, fogRTHeight, 0, format);RenderTexture fogRT2 = RenderTexture.GetTemporary (fogRTWidth, fogRTHeight, 0, format);fogRT1.filterMode = FilterMode.Bilinear;fogRT2.filterMode = FilterMode.Bilinear;</pre>and provide some data to the shader as such :Light light = GameObject.Find("Directional Light").GetComponent<Light>();Camera camera = GetComponent<Camera>();Matrix4x4 worldViewProjection = camera.worldToCameraMatrix * camera.projectionMatrix;Matrix4x4 invWorldViewProjection = worldViewProjection.inverse;NoiseTexture.wrapMode = TextureWrapMode.Repeat;NoiseTexture.filterMode = FilterMode.Bilinear;CalculateFogMaterial.SetTexture ("LowResDepth", lowresDepthRT);CalculateFogMaterial.SetTexture ("NoiseTexture", NoiseTexture);CalculateFogMaterial.SetMatrix( "InverseViewMatrix", camera.cameraToWorldMatrix);CalculateFogMaterial.SetMatrix( "InverseProjectionMatrix", camera.projectionMatrix.inverse);CalculateFogMaterial.SetFloat ("FogDensity", FogDensity);CalculateFogMaterial.SetFloat ("ScatteringCoeff", ScatteringCoeff);CalculateFogMaterial.SetFloat ("ExtinctionCoeff", ExtinctionCoeff);CalculateFogMaterial.SetFloat ("MaxRayDistance", MaxRayDistance);CalculateFogMaterial.SetVector ("LightColour", light.color.linear);CalculateFogMaterial.SetVector ("LightPos", light.transform.position);CalculateFogMaterial.SetVector ("LightDir", light.transform.forward);CalculateFogMaterial.SetFloat ("LightIntensity", light.intensity);CalculateFogMaterial.SetColor ("ShadowedFogColour", ShadowedFogColour);CalculateFogMaterial.SetVector ("TerrainSize", new Vector3(100,50,100)); |
然后我们就能调用Graphics.Blit()函数来计算雾了:
|
1
|
//render fog, quarter resolutionGraphics.Blit (source, fogRT1, CalculateFogMaterial); |
这是当前使用每像素32采样的结果:
我们可以用一点模糊来改进其中一些混叠。但在此之前要使用交叉采样来进一步改善混叠。因为雾的像素值变化缓慢,我们能“传播”单个像素雾的颜色估值到一些像素上。
|
1
2
3
4
|
// Calculate the offsets on the ray according to the interleaved sampling pattern 通过交叉采样模式来计算光线偏移 float2 interleavedPos = fmod( float2(i.pos.x, LowResDepth_TexelSize.w - i.pos.y), GRID_SIZE );float rayStartOffset = ( interleavedPos.y * GRID_SIZE + interleavedPos.x ) * ( stepSize * GRID_SIZE_SQR_RCP ) ;currentPos += rayStartOffset * rayDir.xyz; |
下面的图片同样由使用8*8像素网格的32采样率来生成。每个光线由(1/64) * stepSize偏移开始,相当于每个光线里64*32=2048“虚拟的”采样。
我们甚至能通过模糊化进一步改善雾。我将使用可分离的7点高斯滤波器(表示它可以被应用在1个1*7管道和一个7*1的管道,而不是更高耗的单独的7*7)。4个通道过后,雾看起来就改善了很多:
唯一的问题就是,单纯的高斯滤波器将通过几何边缘模糊雾,场景就会变的很模糊。这可以通过考虑了深度的双边滤波来修复。文章太长了,所以我提供细节代码,但只提供关于它怎么工作的想法:
|
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
float4 frag(v2f input) : SV_Target{ const float offset[4] = { 0, 1, 2, 3 }; const float weight[4] = { 0.266, 0.213, 0.1, 0.036 }; //linearise depth [0-1] float centralDepth = Linear01Depth(tex2D(LowresDepthSampler, input.uv)); float4 result = tex2D(_MainTex, input.uv) * weight[0]; float totalWeight = weight[0]; [unroll] for (int i = 1; i < 4; i++) { float depth = Linear01Depth(tex2D(LowresDepthSampler, (input.uv + BlurDir * offset[i] * _MainTex_TexelSize.xy ))); [/i] float w = abs(depth-centralDepth)* BlurDepthFalloff; w = exp(-w*w); result += tex2D(_MainTex, ( input.uv + BlurDir * offset * _MainTex_TexelSize.xy )) * w * weight; totalWeight += w * weight; depth = Linear01Depth(tex2D(LowresDepthSampler, (input.uv - BlurDir * offset * _MainTex_TexelSize.xy ))); w = abs(depth-centralDepth)* BlurDepthFalloff; w = exp(-w*w); result += tex2D(_MainTex, ( input.uv - BlurDir * offset * _MainTex_TexelSize.xy )) * w* weight; totalWeight += w * weight; }return result / totalWeight; |
我们为每个采样都从低分辨率的深度缓存中采样深度,并将它和中间像素进行深度比较。我们基于指数函数来计算该差别的权重,并将它乘以正常的高斯权重。累加总的权重以便最后归一化结果。深度的差别越大意味着我们正在模糊一个边缘,权重越快接近0,就越有效的避免了纹理对最后结果的影响。
使用改进的滤波器,场景不再那么模糊,边缘也被保留了。
我们后期处理管线的最后一步是对雾渲染对象进行升采样,并把它应用到主渲染对象上。为了实现升采样我们将使用Nearest Depth方法,像这里描述的一样。简单来说,这个方法将高分辨率像素和其周边的低分辨率深度进行比较。如果所有的低分辨率像素和高分辨率的像素有相似的深度,它就用一个标准的双线性插值滤波器来提高采样。否则它就选择最接近的深度值。请查看这个方法的实现代码。
一旦有了升采样雾的值,就可以把它用到主渲染对象的像素上了。
|
1
2
3
4
5
6
7
8
9
|
float4 frag(v2f input) : SV_Target{ float4 fogSample = GetNearestDepthSample(input.uv); float4 colourSample = tex2D(_MainTex, input.uv); float4 result = colourSample * fogSample.a + fogSample; return result;} |
我们通过带有透光率(表示到达观察者的外反射光量)的雾值的透明通道来缩放原始像素值。雾越厚,透光率越低,看到的外光更少。最后的合成结果如下:
用非黑色阴影雾的优势是我们可以很容易把雾加到阴影区域,是模拟多次散射的简便办法。
最后,我真的应该打住了,因为我们已经照亮场景,可以轻易获取本地雾的变量,通过变量我们可以创造更有趣的视觉。例如如果想重新改变之前的噪声纹理作为高度图,并且当采样位置是低于高度图高度的时候增加雾密度。
|
1
2
3
4
5
6
7
8
9
|
float2 noiseUV = currentPos.xz / TerrainSize.xz;//calculate a fog height value for this world positionfloat fogHeight = 2.5 * saturate( tex2Dlod(NoiseTexture, float4(5*noiseUV + 0.05*_Time.xx, 0, 0)));float fogDensity = FogDensity;//increase fog density if current sample is lower that the fogHeight at this world posif( currentPos.y < fogHeight ) fogDensity *= 25; |
除了光线外,还可以轻易添加薄雾到场景里。
最后,unity5.1.1的工程

浙公网安备 33010602011771号