Unity 径向模糊 简易解决方案


本文欢迎转载,转载请标明出处!

什么是径向模糊

径向模糊,这个名字咋一看很陌生,不知道是什么东西,但是看了下面这对对比图也许你就知道是什么了。

在这里插入图片描述
在这里插入图片描述

(PID:77789607)

可见图2中出现了一个由定点扩散型的模糊,这个就是径向模糊。

径向模糊的运用非常广泛。由于这种扩散型的模糊可以带来很强的速度感和压迫感,常用于竞速游戏或者高速移动的对象上,也有用于Boss的登场画面特效等等。

一、原理分析和算法简介

既然是一个定点扩散型的模糊,那就需要有一个扩散的起点。在我们的Shader渲染过程中,这个起点可以用UV坐标属性来确定。

其次就是这个扩散的效果,先贴上核心的算法:
在这里插入图片描述
(来源:https://qianmo.blog.csdn.net/article/details/105350519)

  • 首先是我们需要计算出模糊的方向Offset,后面要用这个Offset值偏移UV持续采样。这个通过扩散定点的坐标减去当前像素的UV坐标就可以得出。得到方向后,通过一个额外的参数来控制这个方向带来的扩散效果
  • 既然是模糊,那就避免不了多次采样。径向模糊需要多次采样来保证模糊效果,并在每次采样后,采样坐标继续与扩散方向进行累加,并累加采样后的颜色。最终,输出这几次采样颜色的平均值。整体来看,径向模糊的主要逻辑相对于高斯模糊要简单不少,而且可以在一个Pass中就处理完毕。
  • 核心代码中的unroll关键字的意义在于告诉着色器可以安全展开多少次遍历。这样一来,在Shader编译成OpenGL或者其他着色器语言的时候会把这个循环展开,最终效果就等同于我们把10,11行所在的代码重复写30次。

二、具体实现

新建一个项目,保存场景,创建一个Canvas,并修改Canvas的RenderMode为ScreenSpace-Camer。笔者这里图个方便,就挂主摄像机上去了。在实际项目中,一定要严格区分场景和UI摄像机

在这里插入图片描述
创建一个RawImage,放上一张图片。

在这里插入图片描述
新建一个Shader,命名为RadiusBlur,Shader的代码如下:

Shader "SaberShad/RadiusBlur"
{
    Properties
    {
        [HideInInspector]_MainTex ("Texture", 2D) = "white" {}
        // 径向模糊数据 xy分量代表径向中心点  z分量代表偏移 
        _RadiusData("Radius Data", Vector) = (0.5, 0.5, 0.0, 1.0)
        _RadiusIteration("Radius Iteration", Range(1, 30)) = 1.0
    }
    SubShader
    {
        CGINCLUDE
            #include "UnityCG.cginc"
            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _MainTex_TexelSize;
            half3 _RadiusData;
            half _RadiusIteration;

            struct a2f_rb
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f_rb
            {
                float4 vertex : SV_POSITION;
                float2 texcoord: TEXCOORD0;
            };

            v2f_rb RadiusBlurVertex(a2f_rb v)
            {
                v2f_rb o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.texcoord = TRANSFORM_TEX(v.uv, _MainTex);

                return o;
            }

            half4 RadiusBlurFragment(v2f_rb i): SV_Target
            {
                half2 radiusRange = (_RadiusData.xy - i.texcoord.xy) * _RadiusData.z;

                half4 color = 0.0;
                [unroll(30)]
                for(int j = 0; j < _RadiusIteration; j++)
                {
                    color += tex2D(_MainTex, i.texcoord);
                    i.texcoord += radiusRange;
                }

                return color / _RadiusIteration;
            }
        ENDCG

        Cull Off ZWrite Off ZTest Always
		Pass
		{
			CGPROGRAM
			#pragma vertex RadiusBlurVertex
			#pragma fragment RadiusBlurFragment
			ENDCG
		}
    }
}

参数方面,把中心点坐标和偏移强度参数整合到了一个Vector4参数中来传递。

新建一个材质球RadiusMat,把材质球的shader改成我们刚刚处理的Shader。

在这里插入图片描述
接着是写一个摄像机可以用的后处理类RadiusBlur,这个类的作用在于控制摄像机的渲染输出前处理我们的模糊效果,代码如下:

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

[RequireComponent(typeof(Camera))]
public class RadiusBlur : MonoBehaviour
{
    [SerializeField]
    public Material radius_material;

    [SerializeField]
    public Vector3 radius_data{
        get{
            if(radius_material != null)
            {
                return radius_material.GetVector("_RadiusData");
            }
            return new Vector3(0.5f, 0.5f, 0.01f);
        }
        set{
            if(radius_material != null)
            {
                radius_material.SetVector("_RadiusData", value);
            }
        }
    }

    [SerializeField]
    public int iteration{
        get{
            if(radius_material != null)
            {
                return radius_material.GetInt("_RadiusIteration");
            }
            return 1;
        }
        set{
            if(radius_material != null)
            {
                radius_material.SetInt("_RadiusIteration", value);
            }
        }
    }

    static int RADIUS_BLUR_PASS = 0;
    private void Awake() {
        if(radius_material != null)
        {
            radius_data = radius_material.GetVector("_RadiusData");
            iteration = radius_material.GetInt("_RadiusIteration");
        }
    }

    private void OnRenderImage(RenderTexture src, RenderTexture dest) {
        if(radius_material)
        {
            Graphics.Blit(src, dest, radius_material, RADIUS_BLUR_PASS);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }
}

整体的逻辑相比之前的高斯模糊要简洁不少,因为只有一个Pass就可以得到模糊后的效果。同时,Shader兼容相关的逻辑笔者不再赘述,具体的可以参考笔者之前的高斯模糊的解决方案。

材质球的值并不需要在OnRenderImage中实时更新,只需要值变动的时候才更新,因此笔者把参数设置成属性类型,在修改的同时可以直接修改材质球的参数。

现在的效果是直接修改摄像机输出的画面,但如果是想要输出成截图的话还需要修改不少代码。具体的内容可以参考我之前关于高斯模糊的博客,相关的代码思路不再赘述。

RadiusBlur挂到目标摄像机下,在笔者这个解决方案内就是主摄像机了。

在这里插入图片描述
由于设置成了属性参数因此监视面板不显示对应的公共字段了,加之我们预定的参数比较复杂不够直观,因此我们可以写一个Editor类,用于特殊显示我们的RadiusBlur在监视器中的表现,方便调试。

新建一个Editor文件夹,并在文件夹中创建C#脚本RadiusBlurEditor,代码如下:

using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(RadiusBlur))]
public class RadiusBlurEditor : Editor
{
    RadiusBlur radiusBlur;

    public float centerX = 0.5f;
    public float centerY = 0.5f;
    [Range(0f, 0.03f)]
    public float radiusOffset = 0;
    [Range(1, 30)]
    public int iteration = 1;
    bool first_loaded = true;
    
    public override void OnInspectorGUI()
    {
        radiusBlur = target as RadiusBlur;
        ShowRadiusData();   
        first_loaded = false; 
    }   

    void ShowRadiusData()
    {
        radiusBlur.radius_material = EditorGUILayout.ObjectField("径向模糊材质", radiusBlur.radius_material, typeof(Material), true) as Material;
        GUILayout.Space(10);

        Vector3 radius_data = radiusBlur.radius_data;
        centerX = EditorGUILayout.Slider("径向模糊中心X", centerX, 0f, 1f);
        centerY = EditorGUILayout.Slider("径向模糊中心Y", centerY, 0f, 1f);
        // 跟径向模糊中心分离开
        GUILayout.Space(10);
        radiusOffset = EditorGUILayout.Slider("偏移范围(0~0.03)", radiusOffset, 0.0f, 0.03f);
        iteration = EditorGUILayout.IntSlider("迭代次数Iteration", iteration, 1, 30);
        
        if(first_loaded)
        {
            centerX = radius_data.x;
            centerY = radius_data.y;
            radiusOffset = radius_data.z;
            iteration = radiusBlur.iteration;
        }
        if(first_loaded || 
            radius_data.x != centerX ||
            radius_data.y != centerY ||
            radius_data.z != radiusOffset ||
            radiusBlur.iteration != iteration)
        {
            radiusBlur.iteration = iteration;
            radiusBlur.radius_data = new Vector3(centerX, centerY, radiusOffset);
        }
    }
}

(PS:这种跟美术相关的后处理类,笔者建议都需要为之设计一个方便调试的监视器Editor,尤其是在正式项目中,这可以大大方便美术童鞋调试出合适的效果参数。)

由于我们制作的是MonoBehaviour脚本的Editor,所以我们的RadiusBlurEditor要继承自Editor。

同时,我们的目标类是RadiusBlur,因此在声明class之前需要在前面添加[CustomEditor(typeof(RadiusBlur))] ,意味着我们自定义了这个类的面板显示。

我们需要重写OnInspectorGUI()方法,EditorGUILayout是监视器GUI布局类,通过对应的方法创建监视器可视化对象节点,并获取到当前RadiusBlur的参数后保存到RadiusBlurEditor预先声明的字段中,最后通过判断是否是首次更新或者字段属性有变动来修改RadiusBlur属性的值,进而修改材质球的参数。

偏移范围radiusOffset的值域是我自己调试得出的一个我觉得比较合理的偏移范围。如果这个范围太大的话,举个例子,离模糊中心比较远的点,当这个偏移范围去到1的时候,还没几次迭代UV就已经超过1了。因此,这个值才会这么小。

编译通过后,就会发现我们的RadiusBlur在监视器中变成了这个样子:

在这里插入图片描述
运行游戏,微调参数,就可以得到最上面图2的效果啦~

三、逻辑优化

众所周知,在Shader中要尽可能的减少除法的使用,因为GPU对于除法运算的性能比较差。而我们的Shader中对颜色取均值的地方使用了除法:

	...
	return color / _RadiusIteration;
	...

我们可以在传递参数的时候,就把1 / _RadiusIteration的结果传递过去,这样一来我们可以用乘法代替除法了。

修改Shader中的_RadiusIteration属性,将它改成一个Vector,X分量用于存储之前的迭代次数,Y分量用于存储这个迭代次数的倒数,改动如下:

...
	Properties
    {
        ...
        // y分量代表迭代次数的倒数 例如迭代30次就是1/30
        _RadiusIterationData("Radius Iteration", Vector) = (1.0, 1.0, 0.0, 0.0)
    }
    SubShader
    {
        CGINCLUDE
    		...
            // half _RadiusIteration;
            half2 _RadiusIterationData;
            half4 RadiusBlurFragment(v2f_rb i): SV_Target
            {
            	...
                // for(int j = 0; j < _RadiusIteration; j++)
                for(int j = 0; j < _RadiusIterationData.x; j++)
                ...
				// return color / _RadiusIteration;
                return color * _RadiusIterationData.y;
            }
        ENDCG
    }
...
}

返回RadiusBlur中,我们修改了Shader的属性,才赋值和取值的地方要做对应的修改。

...
[RequireComponent(typeof(Camera))]
public class RadiusBlur : MonoBehaviour
{
	...
    [SerializeField]
    public int iteration{
        get{
            if(radius_material != null)
            {
            	// return radius_material.GetInt("_RadiusIteration");
                return (int)radius_material.GetVector("_RadiusIterationData").x;
            }
            return 1;
        }
        set{
            if(radius_material != null)
            {
            	// radius_material.SetInt("_RadiusIteration", value);
                float invIteration = 1.0f / value;
                radius_material.SetVector("_RadiusIterationData", new Vector4(value, invIteration, 0f, 0f));
            }
        }
    }
    
...

    private void Awake() {
        if(radius_material != null)
        {
            radius_data = radius_material.GetVector("_RadiusData");
            // iteration = radius_material.GetInt("_RadiusIteration");
            iteration = (int)radius_material.GetVector("_RadiusIterationData").x;
        }
    }
...
}

其次,我们可以使用Shader.PropertyToID()方法获取代表Shader属性的静态int字段,代替材质球在取值赋值时使用的字符串名称。这样一来在取值和赋值的时候就可以减少堆内存消耗。

继续修改RadiusBlur,改动如下:

...
[RequireComponent(typeof(Camera))]
public class RadiusBlur : MonoBehaviour
{
    static int radius_blur_dataId = Shader.PropertyToID("_RadiusData"),
        radius_blur_iterationId = Shader.PropertyToID("_RadiusIterationData");
        
    ...
    
    [SerializeField]
    public Vector3 radius_data{
        get{
            if(radius_material != null)
            {
            	// return radius_material.GetVector("_RadiusData");
                return radius_material.GetVector(radius_blur_dataId);
            }
            return new Vector3(0.5f, 0.5f, 0.01f);
        }
        set{
            if(radius_material != null)
            {
            	// radius_material.SetVector("_RadiusData", value);
                radius_material.SetVector(radius_blur_dataId, value);
            }
        }
    }

    [SerializeField]
    public int iteration{
        get{
            if(radius_material != null)
            {
            	// return (int)radius_material.GetVector("_RadiusIterationData").x;
                return (int)radius_material.GetVector(radius_blur_iterationId).x;
            }
            return 1;
        }
        set{
            if(radius_material != null)
            {
                float invIteration = 1.0f / value;
                // radius_material.SetVector("_RadiusIterationData", new Vector4(value, invIteration, 0f, 0f));
                radius_material.SetVector(radius_blur_iterationId, new Vector4(value, invIteration, 0f, 0f));
            }
        }
    }

    ...
    
    private void Awake() {
        if(radius_material != null)
        {
        	// radius_data = radius_material.GetVector("_RadiusData");
            radius_data = radius_material.GetVector(radius_blur_dataId);
            // iteration = (int)radius_material.GetVector("_RadiusIterationData").x;
            iteration = (int)radius_material.GetVector(radius_blur_iterationId).x;
        }
    }
    ...
}

四、功能拓展

现在,我们的径向模糊的内径只是一个点,即从这个点开始就已经在模糊画面了。我们可不可以加一个内径,在这个内径内模糊效果不那么明显甚至不模糊呢?

当然这个效果是可以实现的,只是在判断是否模糊这一个需求上就避免不了if分值。不过我们可以使用shader_feature来区分分值。即:当这个内径为0时,保持现在已有的效果,当内径大于0时,使用另一个Shader变体执行额外的逻辑

修改RadiusBlur,为这个Shader添加一个使用内径的变体。同时,增加参数用于控制内径,改动如下:

	Properties
    {
	    ...
        _RadiusCenterRange("Radius Center Range", Range(0, 0.5)) = 0
    }
    SubShader
    {
        CGINCLUDE
        	...
        	#pragma shader_feature _ _USE_CIRCLE_CENTER // 定义Shader变体
            ...
            half _RadiusCenterRange;
            ...
            half4 RadiusBlurFragment(v2f_rb i): SV_Target
            {
                // half2 radiusRange = (_RadiusData.xy - i.texcoord.xy) * _RadiusData.z;
                // 由于要判断内径,因此这边的半径先不与偏移强度相乘
                half2 radiusRange = _RadiusData.xy - i.texcoord.xy;

                half4 color = 0.0;
                half range_amount = 1;
                #if defined(_USE_CIRCLE_CENTER) // 当关键字可用时才会执行以下的逻辑
                    // 点乘获得偏移的平方
                    // 不使用开方获得真实的距离,直接用平方来比较
                    half square_dis = dot(radiusRange, radiusRange);
                    half square_range = _RadiusCenterRange * _RadiusCenterRange;
                    range_amount = square_dis >= square_range ? 1 : 0;
                #endif
                [unroll(30)]
                for(int j = 0; j < _RadiusIterationData.x; j++)
                {
                    color += tex2D(_MainTex, i.texcoord);
                    // i.texcoord += radiusRange * range_amount;
                    // 补上偏移强度
                    i.texcoord += radiusRange * _RadiusData.z * range_amount;
                }
                return color * _RadiusIterationData.y;
            }
        ENDCG
        ...

回到摄像机下的RadiusBlur类。由于我们增加了字段,因此我们要多一个控制内径Shader属性的属性。同时,我们在RadiusBlur类中就要判断:当内径小于等于0时,禁用_USE_CIRCLE_CENTER关键字。改动如下:

...

[RequireComponent(typeof(Camera))]
public class RadiusBlur : MonoBehaviour
{
    static int radius_blur_dataId = Shader.PropertyToID("_RadiusData"),
        radius_blur_iterationId = Shader.PropertyToID("_RadiusIterationData"),
        radius_blur_centerRange = Shader.PropertyToID("_RadiusCenterRange"); // 新增int静态ShaderID

    ...
    public float radius_center_range{
        get{
            if(radius_material != null)
            {
                return radius_material.GetFloat(radius_blur_centerRange);
            }
            return 0f;
        }
        set{
            if(radius_material != null)
            {
                if(value <= 0f) // 判断变体关键字是否启用
                {
                    radius_material.DisableKeyword("_USE_CIRCLE_CENTER");
                }
                else
                {
                    radius_material.EnableKeyword("_USE_CIRCLE_CENTER");
                }
                radius_material.SetFloat(radius_blur_centerRange, value);
            }
        }
    }
    ...
    private void Awake() {
        if(radius_material != null)
        {
            ...
            radius_center_range = radius_material.GetFloat(radius_blur_centerRange);
        }
    }

    ...
}

最后我们修改RadiusBlurEditor,追加内径属性的判断和修改监听:

...
public class RadiusBlurEditor : Editor
{
    ...
    [Range(0f, 1f)]
    public float centerRange = 0f;
    ...

    void ShowRadiusData()
    {
        ...
        centerRange = EditorGUILayout.Slider("模糊内径", centerRange, 0f, 1f);

        if(first_loaded)
        {
            ...
            centerRange = radiusBlur.radius_center_range;
        }
        if(first_loaded || 
            radius_data.x != centerX ||
            radius_data.y != centerY ||
            radius_data.z != radiusOffset ||
            radiusBlur.iteration != iteration ||
            radiusBlur.radius_center_range != centerRange) // 追加判断内径属性
        {
            ...
            radiusBlur.radius_center_range = centerRange;
        }
    }
}

运行游戏,修改内径,可以看到下面的效果:

在这里插入图片描述
在这里插入图片描述
笔者设计的内径分别是0和0.25,虽然确实能控制内径内不模糊,但是这个边缘也过于锐利了。
在这里插入图片描述

我们修改RadiusBlur Shader,改用偏移offset减去内径内径差比值的方式来调整控制内径比率的临时字段range_amount,改动如下:

	...
    SubShader
    {
        CGINCLUDE
            ...
            half4 RadiusBlurFragment(v2f_rb i): SV_Target
            {
                ...
                #if defined(_USE_CIRCLE_CENTER) // 当关键字可用时才会执行以下的逻辑
                    // 点乘获得偏移的平方
                   	// 不使用开方获得真实的距离,直接用平方来比较
                    half square_dis = dot(radiusRange, radiusRange);
                    half square_range = _RadiusCenterRange * _RadiusCenterRange;
                    // range_amount = square_dis >= square_range ? 1 : 0;
                    // 在内径内的返回值控制为0,超过内径后再逐渐变为1
                    // 而这个“逐渐”的过程,也可以用一个另外的属性来控制,这里笔者偷个懒,就还用内径了
                    range_amount = saturate((square_dis - square_range) / square_range);
                #endif
                ...
            }
        ENDCG
        ...

重新运行游戏,在同样的0.25内径下,边缘的过度平滑了很多:

在这里插入图片描述
至于这个内径为什么结果会变成椭圆形,是因为我们判断内径的根据是UV坐标,UV上确实是圆形内径,但是实际上的画面长宽并不是1比1的,因此案例中的效果会出现横向拉伸。但从结果上来看,这个效果还是可以接受的~或者,我们的内径控制可以直接精确到UV长宽,不过这个部分的改动,还请读者尝试自行实现哦!

GitHub:https://github.com/SaberZG/RadiusBlur
参考资料

  1. 高品质后处理:十种图像模糊算法的总结与实现 https://qianmo.blog.csdn.net/article/details/105350519
  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值