图源:https://www.pixiv.net/artworks/80200450 学习用途

学习了冯乐乐《Unity Shader入门精要》第十二章第三节至第五节的内容,这里将使用Shader实现边缘检测、高斯模糊和Bloom效果的步骤与重点进行简单记录,同时会额外补充一些我认为需要了解的知识点。

首先是边缘检测,这个名词曾在学习数字图像处理和计算机视觉的时候有详细了解过,在Unity里头,它可以用来实现描边效果,其原理就是利用边缘检测的算子对图像进行卷积操作,本质是改变了图像中的像素值。

先了解什么是卷积:在图像处理中,卷积操作就是利用一个卷积核(也可称为卷积模板、滤波器、算子)对一张图像中的每个像素进行操作,而卷积核通常是由正方形组成的区域(区域大小可以是2x2、3x3,也可以是5x5、7x7),在这个正方形内的每一个格子都会有一个权重值。如下图所示,Sobel算子是一种常用的边缘检测算子。

Sobel算子,它的大小为3x3,分别是水平方向和竖直方向上的卷积核

然后就是卷积的过程:先将卷积核的中心放置在起始像素上,翻转卷积核后依次计算核中每个元素和对应被核覆盖的像素值的乘积后,再求和,最终结果就是这个位置得到一个新的像素值,之后滑动卷积核,继续重复上述步骤,下面这张动图可以很清晰地表示卷积的过程。

卷积过程,其中左侧是输入图像,中间是定义的卷积核,右侧是卷积的结果

举一个例子来看看结果是怎么计算出来的,这里以G[2,0]为例(就是结果第三行第一列的40):按照上面的计算方法,通俗地说就是将卷积核与目前对应的位置的像素值一一进行相乘后再求和,比如核内第一个权重值为1,对应到图像(紫色区域内)就是第一行第一列的10,1x10;然后第二个权重值为2,对应第一行第二列的10,2x10;以此类推,最后的结果就是求和得到40。

卷积结果的计算

以这个为例子继续探讨的话,可发现一些特点。先从输入图像看,第一列从上往下看,可以看出像素值没有变化的时候,得到的卷积结果为0;当像素值发生较大变化时,会得到不一样的结果,即输入图像(10→10)对应结果(0→0),输入图像(10→0)对应结果(0→40)。

这里就需要引入梯度这个概念来进一步解释了,可以想象一下,将一张图片放大很多倍,就可以看见每个像素具体的颜色,如果将边缘放大呢?是不是就能看见边缘之间颜色变化会非常剧烈?相邻像素之间的差值可以用梯度来表示,因为是边缘的话,这个梯度就会比较大,所以可以使用人为设定好的边缘检测算子将边缘给检测出来。

所以,在进行边缘检测的时候,需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值Gx和Gy,而整体的梯度值G可以通过两者分别平方后相加再开根号得到,而通过用两者绝对值后再相加得到的G可以节省一部分性能。

在Unity中,通过使用Sobel算子进行边缘检测,并且实现描边效果,需要进行以下的准备工作:

1、新建场景,将天空盒去除;
2、往项目中导入一张需要进行边缘检测的图片,这里我选用一张壁纸;
3、新建脚本,将脚本绑定在摄像机上;
4、新建Shader。

先将导入的图片类型设置为Sprite,然后把图片拖入到场景里头,调整大小和位置。

在新建的EdgeDetection脚本中,继承PostEffectsBase基类,这个基类的代码在第十二章开头有说是如何编写的,这里就直接贴代码了。

using UnityEngine;
using System.Collections;

// 本c#用于屏幕后处理效果的基类,在实现各种屏幕特效时,只需要继承自该基类
// 再实现派生类中不同的操作即可

// 编辑器下可执行该脚本来查看后处理效果 
[ExecuteInEditMode]
// 所有的屏幕后处理效果都需要绑定在某个摄像机上
[RequireComponent (typeof(Camera))]

public class PostEffectsBase : MonoBehaviour {

	// 提前检查各种资源和条件是否满足,在Start函数中会调用此函数
	protected void CheckResources() {
		bool isSupported = CheckSupport();
		if (isSupported == false) {
			NotSupported();
		}
	}

    // CheckResources函数会调用此函数来检查目前平台是否支持后处理效果
    // 如果支持返回true,不支持返回false
    protected bool CheckSupport() {
		if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false) {
			Debug.LogWarning("This platform does not support image effects or render textures.");
			return false;
		}
		
		return true;
	}

    // 如果目前平台不支持后处理效果的话,CheckResources函数会调用此函数
    protected void NotSupported() {
		enabled = false;
	}

    // 一些屏幕特效可能需要更多的设置,如一些默认值等
    // 可以重载Start、CheckResources、CheckSupport等函数
    protected void Start() {
		CheckResources();
	}

    // 每个屏幕后处理效果都需要指定一个Shader来创建一个用于处理渲染纹理的材质
    // 此函数接受两个参数,第一个参数制定了该特效需要使用的Shader
    // 第二个参数是用于后期处理的材质
    // 首先检查Shader是否可用,可用后会返回使用该Shader的材质,否则返回null
	protected Material CheckShaderAndCreateMaterial(Shader shader, Material material) {
		if (shader == null) {
			return null;
		}
		
		if (shader.isSupported && material && material.shader == shader)
			return material;
		
		if (!shader.isSupported) {
			return null;
		}
		else {
			material = new Material(shader);
			material.hideFlags = HideFlags.DontSave;
			if (material)
				return material;
			else 
				return null;
		}
	}
}

在EdgeDetection脚本中,先声明描边效果需要的Shader,及对应的材质;接着再定义边缘线强度、描边颜色、背景颜色等变量;最后使用OnRenderImage实现屏幕后处理,代码与注释如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EdgeDetection : PostEffectsBase
{
    // 声明描边效果需要的Shader,并创建相应的材质
    public Shader edgeDetectShader;
    private Material edgeDetectMaterial = null;
    public Material material{
        get{
            // 调用PostEffectsBase基类中检查Shader和创建材质的函数
            edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
            return edgeDetectMaterial;
        }
    }

    // 定义边缘线强度、描边颜色、背景颜色的变量
    // 当edgesOnly为0时,边缘叠加在图像上
    // 当edgesOnly值为1时,只显示边缘,不显示图像
    [Range(0.0f, 1.0f)]
    public float edgesOnly = 0.0f;
    public Color edgeColor = Color.black;
    public Color backgroundColor = Color.white;

    // 调用OnRenderImage函数实现屏幕后处理
    void OnRenderImage(RenderTexture src, RenderTexture dest){
        // 先检查材质是否可用,如果可用,则将参数传递给材质后
        // 再调用Graphics.Blit进行处理,否则不作处理
        if (material != null){
            material.SetFloat("_EdgeOnly", edgesOnly);
            material.SetColor("_EdgeColor", edgeColor);
            material.SetColor("_BackgroundColor", backgroundColor);

            Graphics.Blit(src, dest, material);
        }else{
            Graphics.Blit(src, dest);
        }
    }
}

在新建的Shader中,编写以下代码实现边缘检测。其中主要的思路是:先在Properties语义块下定义需要用到的各个变量名,其中_MainTex为渲染纹理(变量名固定)。

Properties
{
	// _MainTex为渲染纹理,变量名固定不能改变
	// 其余变量分别为边缘强度、描边颜色、背景色
    _MainTex ("Base(RGB)", 2D) = "white" {}
	_EdgeOnly ("Edge Only", Float) = 1.0
	_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
	_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
}

然后在Pass语义块下开启深度测试,关闭剔除和深度写入。

// 开启深度测试,关闭剔除和深度写入
ZTest Always
Cull Off
ZWrite Off

之后在Cg代码片段中声明与属性对应的变量,其中_MainTex_TexelSize为渲染纹理的纹素大小,它用来计算各个相邻区域的纹理坐标。

// 定义与属性对应的变量,其中_MainTex_TexelSize为渲染纹理的纹素大小
// 因为卷积需要对相邻区域内的纹理进行采样,所以要利用纹素大小来计算各个相邻区域的纹理坐标
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;

接着定义顶点着色器的输出结构体(输入结构体为Unity内置的appdata_img,此处无需再次定义),在顶点着色器(vert函数)中,计算纹理坐标。

// 定义顶点着色器的输出结构体
struct v2f
{
    float4 pos : SV_POSITION;
	// 定义了维数为9的纹理数组,对应使用Sobel算子采样时需要的9个邻域纹理坐标
    half2 uv[9] : TEXCOORD0;
};

// 在顶点着色器中计算边缘检测需要的纹理坐标
v2f vert (appdata_img v)
{
	// 将顶点从模型空间变换到裁剪空间下
    v2f o;
	o.pos = UnityObjectToClipPos(v.vertex);

	half2 uv = v.texcoord;
	// 在顶点着色器中计算纹理坐标可以减少运算提高性能
	// 而且由于顶点到片元的插值是线性的,因此不会影响纹理坐标的计算结果
	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;
}

在通过片元着色器处理边缘检测效果之前,需要先定义一个用于计算亮度值的函数luminance,和一个用来实现边缘检测的函数Sobel。

// 通过明亮度公式计算得到像素的亮度值
fixed luminance(fixed4 color){
	return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
			
// 使用Sobel函数进行边缘检测
half Sobel (v2f i){
	// 先定义水平方向和竖直方向上的算子(卷积核)
	const half Gx[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
	const half Gy[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};

	// 定义纹理颜色、水平方向上和竖直方向上的梯度值
	half texColor;
	half edgeX = 0;
	half edgeY = 0;
	// 通过循环依次对9个像素进行采样,调用luminance函数计算它们的亮度值
	// 再与算子中对应的权重相乘后叠加在各自的梯度值上
    for (int it = 0; it < 9; it++){
		texColor = luminance(tex2D(_MainTex, i.uv[it]));
		edgeX += texColor * Gx[it];
		edgeY += texColor * Gy[it];
	}
	// 最后用1减去绝对值后两个方向上的梯度值
	// edge越小,则说明这个位置很有可能是一个边缘点
	half edge = 1 - abs(edgeX) - abs(edgeY);
	// 返回给fragSobel作进一步处理
	return edge;
}

最后,在片元着色器中利用梯度值和lerp函数分别计算带原图的颜色值和背景纯色下的颜色值。

// 在片元着色器中得到边缘检测效果
fixed4 fragSobel (v2f i) : SV_Target
{
	// 调用Sobel函数计算像素的梯度值edge
	half edge = Sobel(i);
	// 再利用梯度值通过lerp插值计算得到带描边颜色的原图
	fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
	// 同样方法计算得到背景纯色下的颜色值
	fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
	// 最后利用_EdgeOnly在两者之间插值得到最终的像素值
	return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

实现边缘检测及描边效果的Shader完整代码如下:

Shader "Unlit/Chapter12/EdgeDetection"
{
    Properties
    {
		// _MainTex为渲染纹理,变量名固定不能改变
		// 其余变量分别为边缘强度、描边颜色、背景色
        _MainTex ("Base(RGB)", 2D) = "white" {}
		_EdgeOnly ("Edge Only", Float) = 1.0
		_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
		_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
    }
    SubShader
    {
        Pass
        {
			// 开启深度测试,关闭剔除和深度写入
			ZTest Always
			Cull Off
			ZWrite Off

            CGPROGRAM
			#include "UnityCG.cginc"

            #pragma vertex vert
            #pragma fragment fragSobel

			// 定义与属性对应的变量,其中_MainTex_TexelSize为渲染纹理的纹素大小
			// 因为卷积需要对相邻区域内的纹理进行采样,所以要利用纹素大小来计算各个相邻区域的纹理坐标
			sampler2D _MainTex;
			half4 _MainTex_TexelSize;
			fixed _EdgeOnly;
			fixed4 _EdgeColor;
			fixed4 _BackgroundColor;

			// 定义顶点着色器的输出结构体
            struct v2f
            {
                float4 pos : SV_POSITION;
				// 定义了维数为9的纹理数组,对应使用Sobel算子采样时需要的9个邻域纹理坐标
                half2 uv[9] : TEXCOORD0;
            };

			// 在顶点着色器中计算边缘检测需要的纹理坐标
            v2f vert (appdata_img v)
            {
				// 将顶点从模型空间变换到裁剪空间下
                v2f o;
				o.pos = UnityObjectToClipPos(v.vertex);

				half2 uv = v.texcoord;
				// 在顶点着色器中计算纹理坐标可以减少运算提高性能
				// 而且由于顶点到片元的插值是线性的,因此不会影响纹理坐标的计算结果
				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;
            }

			// 通过明亮度公式计算得到像素的亮度值
			fixed luminance(fixed4 color){
				return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
			}
			
			// 使用Sobel函数进行边缘检测
			half Sobel (v2f i){
				// 先定义水平方向和竖直方向上的算子(卷积核)
				const half Gx[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
				const half Gy[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};

				// 定义纹理颜色、水平方向上和竖直方向上的梯度值
				half texColor;
				half edgeX = 0;
				half edgeY = 0;
				// 通过循环依次对9个像素进行采样,调用luminance函数计算它们的亮度值
				// 再与算子中对应的权重相乘后叠加在各自的梯度值上
				for (int it = 0; it < 9; it++){
					texColor = luminance(tex2D(_MainTex, i.uv[it]));
					edgeX += texColor * Gx[it];
					edgeY += texColor * Gy[it];
				}
				// 最后用1减去绝对值后两个方向上的梯度值
				// edge越小,则说明这个位置很有可能是一个边缘点
				half edge = 1 - abs(edgeX) - abs(edgeY);
				// 返回给fragSobel作进一步处理
				return edge;
			}

			// 在片元着色器中得到边缘检测效果
            fixed4 fragSobel (v2f i) : SV_Target
            {
				// 调用Sobel函数计算像素的梯度值edge
				half edge = Sobel(i);
				// 再利用梯度值通过lerp插值计算得到带描边颜色的原图
				fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);
				// 同样方法计算得到背景纯色下的颜色值
				fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
				// 最后利用_EdgeOnly在两者之间插值得到最终的像素值
				return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
            }
            ENDCG
        }
    }
	FallBack Off
}

最后,保存这个Shader,回到Unity,将这个Shader拖动到脚本对应的位置,可以发现,已经有描边的效果了,此时可以调整描边颜色(Edge Color)和描边强度(Edge Only),查看不同的效果。

[s201_bai id="13"]

书中总结了,这次的边缘检测仅使用了屏幕的颜色信息,在实际应用中,物体的纹理、阴影都会影响边缘检测的效果,而在数字图像处理中,噪声会对边缘检测结果造成很大的影响。为了得到更加准确的边缘,可以通过屏幕的深度纹理和法线纹理上进行边缘检测,挖个坑,以后填(第十三章会学)。

下一节就记录一下使用Shader实现高斯模糊效果。

2020年6月27日 By Neptune

届ける言葉を今は育ててる
最后更新于 2020-06-28