本篇主要写一下关于屏幕后处理的一些实现方式和原理。本篇中的实现原理是基于 OnRenderImage 函数的实现,这个函数只有在 Built-in 渲染管线下才能生效,在 URP 中需要做其它的操作以适配,但原理都是相同的。所以我们先通过 Built-in 引入,在最后的部分我们再考虑升级管线的问题。
全屏 Shader
在 Unity Built-In 渲染管线下,创建一个 Image Effect Shader。这个 Shader 通常用来做全屏特效,并且会处于 Hidden 的分支下,所以在材质球上并不能直接选择。
对于后期处理而言,很多时候我们并不需要特别地处理顶点。因此,对于顶点着色器,我们可以直接使用内置的 vert_img。在片元着色器中,我们也对应地使用 v2f_img 这个内置结构,而不是传统的 v2f。
Shader "Hidden/PPSomeEffect" {
Properties { } // Some Properties omitted
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert_img
#pragma fragment frag
#include "UnityCG.cginc"
// Properties
fixed4 frag (v2f_img i) : SV_Target { return col; }
}
}
在片元着色器中,因为后期处理无论如何都是针对屏幕中已经输出的结果进行后期修改,所以第一步总是针对 _MainTex 的采样。
fixed4 frag (v2f_img i) : SV_Target {
fixed4 col = tex2D(_MainTex, i.uv);
// Some manipulation on col
return col;
}
亮度 / 饱和度 / 对比度
首先是最简单的三个特效。
亮度
亮度(Brightness)。根据亮度的定义,我们简单地将最终输出的颜色的 RGB 值去乘上一个亮度系数即可。
fixed3 finalColor = col.rgb * _Brightness;
饱和度
饱和度(Saturation)。饱和度在最低的时候,颜色要接近直接的灰度颜色。在高的时候,则要更鲜艳。
首先,如果要将一个带有颜色的图去色(Desaturate)或者说变成灰度图,我们可以用下面的公式获得这个灰色。
fixed lum = 0.2125 * col.r + 0.7154 * col.g + 0.0721 * col.b;
fixed lumColor = fixed3 (lum, lum, lum);
其中的 0.2125R, 0.7154G, 0.0721B 是一个经验公式,研究表明当RGB以这样的比例被结算成灰色的时候人眼觉得这个灰色最合理。
然后,我们再根据这个 lumColor 进行线性插值即可。
fixed3 finalColor = lerp(lumColor, col, _Saturation);
注:
回忆一下线性插值的计算方式。
//require 0 <= delta <= 1
lerp(A,B, delta) = (1-delta) * A + delta * B
所以,当 _Saturation 在 0 与 1 之间时,越接近 0,颜色就越接近 lumColor,也就是我们的灰度图;越接近 1 ,就越接近 col,也就是我们原本采样出来的颜色,不过,在 Unity 中,delta 大于 1的时候 lerp 也会有对应的数值,具体的计算这边不展示,在 Saturation 的情况中,我们可以大概理解为 _Saturation 越高,颜色就越鲜艳。越接近0,颜色就越灰。
对比度
对比度(Contrast)。当对比度低的时候,所有颜色都无法区分,变成统一的灰色(0.5, 0.5, 0.5)。因此,对比度也是很简单的线性插值就可以解决的问题。
fixed avgColor = fixed3 (0.5, 0.5, 0.5);
fixed3 finalColor = lerp (avgColor, col, _Contrast);
边缘检测
对于边缘检测,我们需要先了解卷积和卷积核的概念。
所谓的卷积(Convolution)本质上就是一种加权求和。以一个采样点为中心,覆盖到周围的一定区域内,根据卷积核(Convolution Kernal)定义的加权方式求得该点的数值。对图像进行一次卷积求值的过程也叫做滤波(Filter)。
例如在下图中,一个 3×3 的方阵中,对于中心点 P5,我们使用卷积核 Gy,对该点求值。
P5 = -1 * P1 + (-2) * P2 + (-1) * P3 + 0 * P4 + 0 * P5 + 0 * P6 + 1 * P7 + 1 * P8 + 1 * P9
= (P7 - P1) + 2 * (P8 - P2) + (P9 - P3)
我们使用 Sobel 定义的 Sobel 卷积核对图像进行卷积处理,就会发现在边缘附近得到的值会偏高。边缘附近的颜色会剧烈地变化,通过这种方式也就可以找到边缘的位置。
Sobel 卷积核分为 Gx 和 Gy 两个部分,对 x 方向和 y 方向分别滤波一次,就能求得两个方向上的数值了。
那么,对于每一个像素,影响这个像素最终颜色的都会由其周围(包括自己)的九个像素影响。但是,在这里我们稍微严谨一点。由于我们是在做全屏 Shader,我们获得的 _MainTex 实际上是在做后处理之前的一张纹理贴图,因此,当我们在计算一个像素的最终颜色的时候,我们要在 _MainTex 的纹理的一个单元,也就是纹素(Texel)上去进行采样。因此,获得周围纹素的过程依然发生在顶点着色器中。
为了方便我们把这 9 个纹素的纹理坐标全部记录下来,我们在 v2f 的数据结构中,不再仅仅记录一个 uv,而是记录 9 个 uv 值,分别是这个 9 个纹素的所有纹理坐标。
struct v2f {
half2 uv[9] : TEXCOORD0;
float4 pos : SV_POSITION;
}
然后,我们用内置的 _MainTex_TexelSize 来提取纹素的实际位置。对于这个内置变量,_MainTex_TexelSize.x 是每个纹素的水平尺寸,而 _MainTex_TexelSize.y 是每个纹素的垂直尺寸。那么这样我们就能很轻松地获取到周围的所有纹素的 uv 值了。
v2f vert (appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// Obtain the uv of current vertex
half2 uv = v.texcoord;
// Fetch the uv of the surrounding 9 Texels
o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * half2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * half2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * half2(1, 1);
return o;
}
这种技巧可以很有效地帮助我们在 v2f 中记录更多的信息。在这里,只有 o.uv[4] 代表了这个顶点在纹理上对应的本来的位置。然后,我们对这个纹素周围的 9 个点进行采样并记录。
边缘检测实际上就是针对灰度图再进行一次 Sobel 卷积。灰度图在上面的 Saturation 部分已经提过了。
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
然后我们来进行 Sobel 卷积。
half Sobel (v2f i) {
// Define Sobel Kernal
const half Gx[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
const half Gy[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
half texColor;
half edgeX = 0;
half edgeY = 0;
// Convolution sum
for (int j = 0; j < 9; j++) {
texColor = luminance(tex2D(_MainTex, i.uv[j]));
edgeX += texColor * Gx[j];
edgeY += texColor * Gy[j];
}
half edge = 1 - abs(edgeX) - abs(edgeY);
return edge;
}
Comments