走进 Stencil Buffer 系列 4:Stencil 后处理局部描边

作者:阿创
2020-05-28
9 5 3

一、前言

我们之前已经介绍了一种几何过程式描边方法了。几何过程式描边可以很好的为不同模型设置不同的描边参数(描边颜色,宽度等等),不过也正是如此,要为每个模型都额外渲染一遍描边模型,性能上花费比较多。而有另外一种描边方法就是基于屏幕图像后处理描边方法,它只需要对一张屏幕图像进行边缘检测,无论模型多么复杂,计算量也是恒定的,也就节省了性能开销。

屏幕图形后处理比较常见的是在渲染的最后的阶段,拿到屏幕已经渲染的结果(一张 2D 图像),再对其进行图像处理,这也是“后处理”的这个名字来源。不过这样一来对整一张屏幕图像进行处理,有些地方我们不太希望被处理的地方也会被“误操作”了。比如在下图《英雄联盟(LOL)》游戏里,我们只想对英雄与小兵进行描边,而场景背景保持不变。那我们该怎么办呢?
只针对小兵描边

上图没有描边,下图只针对小兵描边

没错,这时又需要请出我们的 Stencil Test 啦![1]

注意因为这是 Stencil 系列的文章,对于涉及到的屏幕后处理和图像边缘检测算法,不会太过于全面地介绍的相关知识。如果大家有看不太懂的地方,可能需要去查找一些屏幕后处理相关的资料了。

二、实现思路

我们主要思路是:首先让所有需要描边的物体在渲染的时候,将 Stencil 参考值写入 Stencil Buffer 中。全部写入完成之后,我们就把 Stencil Buffer 提取出来转换成一直图像,并使得图像上只有 Stencil 值的地方有颜色。然后把这张图像传入屏幕后处理所用自定义提取 Shader 中,根据 Sobel 边缘检测算法对其边缘检测,检测出边缘后与原屏幕图像进行叠加就完成了。

我们再来分析一下其中的技术细节。

1、对于 Stencil 参考值写入用一个 Stencil 指令就 ok 了。

2、将 Stencil Buffer 提取并转换成图像。我们需要借助一张渲染纹理 RenderTexture [2],渲染纹理这个名字和“渲染到纹理”技术相关。通常渲染结果都是直接输出到屏幕窗口帧缓冲中,而渲染到纹理技术,可以把渲染结果渲染到一张纹理中(即渲染纹理)。这也是屏幕后处理的核心技术。

通常需要借助 Graphics.Blit (Texture source, RenderTexture dest,Material mat) 函数将屏幕渲染结果通过某个材质的 Shader 处理后搬运到目标渲染纹理中,其中 Blit 函数会把 source 设置为材质的 Shader 中的 _MainTex。而这个 Shader 就是我们提取 StencilBuffer 为图像的关键。我们可以对屏幕图像里每一个像素检测 Stencil 值,如果相等就渲染一个固定颜色(比如白色 RBGA(1,1,1,1)),否者就不进行任何渲染(RBGA(0,0,0,0)),由此渲染到一张渲染纹理中就完成对 StencilBuffer 提取转换图像 [3]

3、 Sobel 边缘检测算法。边缘检测的目的是标识数字图像中亮度变化明显的点,即对图像用 Soebl 卷积核进行卷积运算 [4]A 代表原始图像,GxGy 分别代表经横向及纵向边缘检测的图像,通过以上公式就可以分别计算出横向 和 纵向 的梯度值,即 GxGy,梯度值越大,边缘就越明显。

Sobel 卷积核算子

三、具体实现

首先建一个场景,放一个可爱的小兔子 bunny 还有一个立方体 cube,并使 bunny 的材质 Shader 中写入 Stencil 参考值 2,但 cube 不写入参考值。

bunny 材质 Shader 中写入 Stencil 参考值 2,cube 不写入参考值

然后创建后处理 StencilOutlinePostProcessing.cs 脚本。

在脚本里我们声明两个材质,一个用于后处理提取 Stencil 并转换为图像的材质 StencilProcessMat ,一个用于后处理边缘检测的描边材质 OutlinePostProcessByStencilMat

还有两个渲染纹理,一个用于承接屏幕渲染结果图像的 cameraRenderTexture ,一个用于承接颜色图像形式 StencilBufferstencilBufferToColor

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

public class StencilOutlinePostProcessing : MonoBehaviour
{

    //用于后处理描边的材质
    public Material OutlinePostProcessByStencilMat;
    //用于提取出纯颜色形式的 StencilBuffer 的材质
    public Material StencilProcessMat;
    //屏幕图像的渲染纹理
    private RenderTexture cameraRenderTexture;
    //纯颜色形式的 StencilBuffer
    private RenderTexture stencilBufferToColor;

    private Camera mainCamera;

}

然后,就是初始化部分,两个渲染纹理都设置为一个深度缓冲区中的位数是 24 位的渲染纹理,(可选 0,16,24;但只有 24 位具有模板缓冲区),是因为 24 位缓冲区里包括了 16 为的深度缓冲 depthBuffer,和 8 位的模板缓冲 stencilBuffer。并且对用于边缘检测的 OutlinePostProcessByStencilMat 材质传入了 stencilBufferToColor 即后面用来承载颜色图像形式的 StencilBuffer 渲染纹理。

void Start()
{
    mainCamera = GameObject.FindWithTag("MainCamera").GetComponent<Camera>();

    //创建一个深度缓冲区中的位数是 24 位的渲染纹理,(可选 0,16,24;但只有 24 位具有模板缓冲区)
    cameraRenderTexture = new RenderTexture(Screen.width,Screen.height,24);

    //因为无法直接获得 Stencil Buffer,
    //将 renderTexture 中的被 Stencil 标记的像素转换成一张纯颜色的渲染纹理
    stencilBufferToColor = new RenderTexture(Screen.width,Screen.height,24);

    OutlinePostProcessByStencilMat.SetTexture("_StencilBufferToColor",stencilBufferToColor);
}

然后脚本的后处理部分。这里要特别注意一下,通常情况后处理下都是在 void OnRenderImage(RenderTexture src, RenderTexture dest) 函数 内操作的,不过经过实验和资料查询 [5],在调用 OnRenderImage 之前,就已经把 src 中的 Stencil buffer 清除掉了。这真是一个致命伤啊...那我们该怎么办呢?

我们来看看 Unity 生命周期的 Scene rendering 渲染阶段 [6]

OnRenderImage 函数前还有 OnPostRender 函数,那我们的逻辑可以放到 OnPostRender 函数里,从而实现屏幕后处理效果。还要注意一点的是 OnPostRender 函数是没有参数的,即意味着我们要自己去获得屏幕图像。而 OnPreRender 函数在照相机开始渲染场景之前调用,我们可以在 OnPreRender 中就设置摄像机渲染的屏幕图像目标是我们设定创建的 cameraRenderTexture

好的,接下来就是我们的后处理部分代码。

void OnPreRender()
{
    //将摄像机的渲染结果传到 cameraRenderTexture 中
    mainCamera.targetTexture = cameraRenderTexture;
}

void OnPostRender()
{
    //null 意味着 camera 渲染结果直接交付给 FramBuffer
    mainCamera.targetTexture = null;

    //设置 Graphics 的渲染操作目标为 stencilBufferToColor
    //即 Graphics 的 activeColorBuffer 和 activeDepthBuffer 都是 stencilBufferToColor 里的
    Graphics.SetRenderTarget(stencilBufferToColor);

    //清除 stencilBufferToColor 里的颜色和深度缓冲区内容,并设置默认颜色为(0,0,0,0)
    GL.Clear(true,true,new Color(0,0,0,0));

    //设置 Graphics 的渲染操作目标
    //即 Graphics 的 activeColorBuffer 是 stencilBufferToColor 的 ColorBuffer
    //Graphics 的 activeDepthBuffer 是 cameraRenderTexture 的 depthBuffer
    Graphics.SetRenderTarget(stencilBufferToColor.colorBuffer,cameraRenderTexture.depthBuffer);

    //提取出纯颜色形式的 StencilBuffer:
    //将 cameraRenderTexture 通过 StencilProcessMat 材质提取出到 Graphics.activeColorBuffer
    //即提取到 stencilBufferToColor 中
    Graphics.Blit(cameraRenderTexture,StencilProcessMat);

    //将 cameraRenderTexture 通过 OutlinePostProcessMat 材质
    //并与材质中的 _StencilBufferToColor 进行边缘检测操作
    //最后输出到 FrameBuffer(null 意味着直接交付给 FramBuffer)
    Graphics.Blit(cameraRenderTexture,null as RenderTexture,OutlinePostProcessByStencilMat);
}

OnPreRender 中我们设置了摄像机的渲染目标纹理。

而后处理的重点在 OnPostRender 中,首先我们把 Graphics 的渲染激活操作目标为 stencilBufferToColor ,并清除 stencilBufferToColor 里的颜色和深度缓冲区内容,并设置默认颜色为 RGBA(0,0,0,0)。随后又设置 Graphics 的激活操作目标,写入 color 的目标是 stencilBufferToColor.colorBuffer ,测试使用的 depth buffer 的数据来源是 cameraRenderTexture.depthBuffer

接下来就是提取出纯颜色形式的 StencilBuffer 了,用 Blit 函数将 cameraRenderTexture 通过 StencilProcessMat 模板测试材质把 StencilBuffer 提取出到 stencilBufferToColor.colorBuffe r 中。

StencilProcessMat 的作用就是对 cameraRenderTexture.depthBuffer 进行模板测试 Stencil Test,如果相等才写入我们自定义的 _StencilColor 颜色 (白色),否者为 RGBA(0,0,0,0)

StencilProcessMat 的代码如下:

Shader "Unlit/StencilProcess"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _StencilColor("StencilBuffer Color",Color)=(1,1,1,1)
        _RefValue("Ref Value",Int)=2
    }
    SubShader
    {
        Stencil{
            Ref [_RefValue]
            Comp Equal
        }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            fixed4 _StencilColor;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            } 

            fixed4 frag (v2f i) : SV_Target
            {
                return _StencilColor;
            }
            ENDCG
        }
    }
}

我们在 Frame Debugger 中可以查看到这个颜色图像形式的 StencilBuffer

颜色图像形式的 StencilBuffer

随后就到边缘检测和原图像叠加了,将  cameraRenderTexture  通过  OutlinePostProcessMat  材质处理,并与材质中的  _StencilBufferToColor  进行边缘检测操作。

//将 cameraRenderTexture 通过 OutlinePostProcessMat 材质
//并与材质中的 _StencilBufferToColor 进行边缘检测操作
//最后输出到 FrameBuffer(null 意味着直接交付给 FramBuffer)
Graphics.Blit(cameraRenderTexture,null as RenderTexture,OutlinePostProcessByStencilMat);

用于边缘检测和原屏幕图像叠加的 OutlinePostProcessMat 材质 Shader 代码如下:

Shader "Unlit/OutlinePostProcessByStencil"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _EdgeColor("Edge Color",Color)= (1,1,1,1)
    }
    SubShader
    {
        ZTest Always Cull Off ZWrite Off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct v2f
            {
                float2 uv[9] : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            sampler2D _MainTex;
            sampler2D _StencilBufferToColor;
            float4 _StencilBufferToColor_TexelSize;
            float4 _EdgeColor;

            v2f vert (appdata_img v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                half2 uv = v.texcoord;

                o.uv[0] = uv + _StencilBufferToColor_TexelSize.xy * half2(-1, -1);
                o.uv[1] = uv + _StencilBufferToColor_TexelSize.xy * half2(0, -1);
                o.uv[2] = uv + _StencilBufferToColor_TexelSize.xy * half2(1, -1);
                o.uv[3] = uv + _StencilBufferToColor_TexelSize.xy * half2(-1, 0);
                o.uv[4] = uv + _StencilBufferToColor_TexelSize.xy * half2(0, 0);
                o.uv[5] = uv + _StencilBufferToColor_TexelSize.xy * half2(1, 0);
                o.uv[6] = uv + _StencilBufferToColor_TexelSize.xy * half2(-1, 1);
                o.uv[7] = uv + _StencilBufferToColor_TexelSize.xy * half2(0, 1);
                o.uv[8] = uv + _StencilBufferToColor_TexelSize.xy * half2(1, 1);

                return o;
            }

            float SobelEdge(v2f i){

                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}; 

                float edge = 0;
                float edgeY = 0;
                float edgeX = 0;    
                float luminance =0;
                for(int it=0; it<9; it++){
                    luminance = tex2D(_StencilBufferToColor,i.uv[it]).a;
                    edgeX += luminance*Gx[it];
                    edgeY += luminance*Gy[it];
                }

                edge =  1 - abs(edgeX) - abs(edgeY);
                return edge;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 sourceColor = tex2D(_MainTex, i.uv[4]);
                float edge = SobelEdge(i);
                return lerp(_EdgeColor,sourceColor,edge);
            }
            ENDCG
        }
    }
}

在 Shader 最后根据边缘检测出来的 edge ,对原图像和边缘描边颜色进行插值,我们就搞定了。

只针对 Stencil 参考值为 2 的 bunny 描边

四、其他效果展示

如果我们让 cube 的材质 Shader 也写入 Stencil 值,并且是和小兔子 bunny 的 Stencil 值不同(比如是 1),但用于 StencilBuffer 提取的材质 Shader 还是用和 bunny 相同的 2 进行模板测试的话,提取出来的颜色图像形式的 StencilBuffer 长这样:

cube 写入值 1,bunny 写入 2, StencilProcessMat 模板测试值为 2 的 Stencil Buffer

描边效果长这样:

cube 写入值 1,bunny 写入 2,StencilProcessMat 模板测试值为 2 的描边效果

为啥会这样?有知道的同学欢迎在评论区留言噢~~(看看能钓到多少活鱼儿)

五、下一章预告

Stencil 后处理原理的传送门视觉效果!!!

参考资料和引用

[1] 《英雄联盟 LoL》中后备的小兵英雄后处理 Stencil 描边方法

[2] Unity 手册渲染纹理介绍

[3] 乐园:利用 StencilBuffer 实现局部后处理描边

[4] Unity Shader - 边缘检测

[5] UWA:OnRenderImage 提问

[6] Unity 生命周期的 Scene rendering 渲染阶段

其他比较杂的,算是收集资料的时候顺带补充了知识

  1. 有讲到 depth/stencil buffer 的关系
  2. CommandBuffer.Blit() isn't stencil buffer friendly
  3. 有讲到 Graphics 的 activeXXXBuffer 和 SetRenderTarget 用法
  4. 口袋妖怪 X/Y 制作技法
  5. Unity 后处理 性能优化

结尾碎碎念

啊,这篇好长,写了两天好久。看了一下之前的文章排版也是惨不忍睹,瞎琢磨了一下下排版(感觉还行吧。。吧)。希望到时候投稿不用麻烦小编操心改排版就好了。

后续可能做做其他系列 Shader 文章,但也不一定,有可能是零碎的 Shader 效果。

临近学期末,作业也越来越多,当初定下一星期一篇真是越来越难了/(ㄒoㄒ)/~~(咕咕咕

本文为用户投稿,不代表 indienova 观点。

近期点赞的会员

 分享这篇文章

阿创 

一句话介绍测试 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. Matata 2020-05-28

    很棒的!学习啦!谢谢

    • 阿创 2020-05-28

      @Matata:谢谢支持~~

  2. Aimetu 2021-11-19

    写的非常好,赞!

您需要登录或者注册后才能发表评论

登录/注册