趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边



趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
一、前言
我们之前已经介绍了一种几何过程式描边方法了 。 几何过程式描边可以很好的为不同模型设置不同的描边参数(描边颜色 , 宽度等等) , 不过也正是如此 , 要为每个模型都额外渲染一遍描边模型 , 性能上花费比较多 。 而有另外一种描边方法就是基于屏幕图像后处理描边方法 , 它只需要对一张屏幕图像进行边缘检测 , 无论模型多么复杂 , 计算量也是恒定的 , 也就节省了性能开销 。
屏幕图形后处理比较常见的是在渲染的最后的阶段 , 拿到屏幕已经渲染的结果(一张 2D 图像) , 再对其进行图像处理 , 这也是“后处理”的这个名字来源 。 不过这样一来对整一张屏幕图像进行处理 , 有些地方我们不太希望被处理的地方也会被“误操作”了 。 比如在下图《英雄联盟(LOL)》游戏里 , 我们只想对英雄与小兵进行描边 , 而场景背景保持不变 。 那我们该怎么办呢?

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
上图没有描边 , 下图只针对小兵描边
没错 , 这时又需要请出我们的 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代表原始图像 , Gx和Gy分别代表经横向及纵向边缘检测的图像,通过以上公式就可以分别计算出横向 和 纵向 的梯度值 , 即Gx和Gy , 梯度值越大 , 边缘就越明显 。

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
Sobel 卷积核算子
三、具体实现
首先建一个场景 , 放一个可爱的小兔子 bunny 还有一个立方体 cube , 并使 bunny 的材质 Shader 中写入 Stencil参考值2 , 但 cube 不写入参考值 。分页标题

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
bunny 材质 Shader 中写入 Stencil 参考值 2 , cube 不写入参考值
然后创建后处理StencilOutlinePostProcessing.cs脚本 。
在脚本里我们声明两个材质 , 一个用于后处理提取 Stencil并转换为图像的材质StencilProcessMat , 一个用于后处理边缘检测的描边材质OutlinePostProcessByStencilMat;
还有两个渲染纹理 , 一个用于承接屏幕渲染结果图像的cameraRenderTexture , 一个用于承接颜色图像形式StencilBuffer的stencilBufferToColor 。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class StencilOutlinePostProcessing : MonoBehaviour
{
//用于后处理描边的材质public Material OutlinePostProcessByStencilMat;//用于提取出纯颜色形式的 StencilBuffer 的材质public Material StencilProcessMat;//屏幕图像的渲染纹理private RenderTexture cameraRenderTexture;//纯颜色形式的 StencilBufferprivate 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;//创建一个深度缓冲区中的位数是 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]

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
在OnRenderImage函数前还有OnPostRender函数 , 那我们的逻辑可以放到OnPostRender函数里 , 从而实现屏幕后处理效果 。 还要注意一点的是OnPostRender函数是没有参数的 , 即意味着我们要自己去获得屏幕图像 。 而OnPreRender函数在照相机开始渲染场景之前调用,我们可以在OnPreRender中就设置摄像机渲染的屏幕图像目标是我们设定创建的cameraRenderTexture 。
好的 , 接下来就是我们的后处理部分代码 。
void OnPreRender
{
//将摄像机的渲染结果传到 cameraRenderTexture 中mainCamera.targetTexture = cameraRenderTexture;
}
void OnPostRender
{
// 意味着 camera 渲染结果直接交付给 FramBuffermainCamera.targetTexture = ;//设置 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 的 depthBufferGraphics.SetRenderTarget(stencilBufferToColor.colorBuffer,cameraRenderTexture.depthBuffer);//提取出纯颜色形式的 StencilBuffer://将 cameraRenderTexture 通过 StencilProcessMat 材质提取出到 Graphics.activeColorBuffer//即提取到 stencilBufferToColor 中Graphics.Blit(cameraRenderTexture,StencilProcessMat);//将 cameraRenderTexture 通过 OutlinePostProcessMat 材质//并与材质中的 _StencilBufferToColor 进行边缘检测操作//最后输出到 FrameBuffer( 意味着直接交付给 FramBuffer)Graphics.Blit(cameraRenderTexture, 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.colorBuffer 中 。
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:

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
颜色图像形式的 StencilBuffer
随后就到边缘检测和原图像叠加了 , 将cameraRenderTexture通过OutlinePostProcessMat材质处理 , 并与材质中的_StencilBufferToColor进行边缘检测操作 。
//将 cameraRenderTexture 通过 OutlinePostProcessMat 材质//并与材质中的 _StencilBufferToColor 进行边缘检测操作//最后输出到 FrameBuffer( 意味着直接交付给 FramBuffer)Graphics.Blit(cameraRenderTexture, 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 OffPass{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 Buffer 系列 4:Stencil 后处理局部描边
本文插图
只针对 Stencil 参考值为 2 的 bunny 描边
四、其他效果展示
如果我们让 cube 的材质 Shader 也写入 Stencil值 , 并且是和小兔子 bunny 的Stencil值不同(比如是1) , 但用于StencilBuffer提取的材质 Shader 还是用和 bunny 相同的2进行模板测试的话 , 提取出来的颜色图像形式的StencilBuffer长这样:

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
cube 写入值 1 , bunny 写入 2 ,StencilProcessMat 模板测试值为 2 的 Stencil Buffer
描边效果长这样:

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
cube 写入值 1 , bunny 写入 2 , StencilProcessMat 模板测试值为 2 的描边效果
为啥会这样?有知道的同学欢迎在评论区留言噢~~(看看能钓到多少活鱼儿)
五、下一章预告
Stencil 后处理原理的传送门视觉效果!!!

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图
参考资料和引用
[1] 《英雄联盟 LoL》中后备的小兵英雄后处理 Stencil 描边方法https://technology.riotgames.com/news/trip-down-lol-graphics-pipeline
[2] Unity 手册渲染纹理介绍https://docs.unity3d.com/Manual/class-RenderTexture.html
[3] 乐园:利用 StencilBuffer 实现局部后处理描边https://zhuanlan.zhihu.com/p/95747680
[4] Unity Shader - 边缘检测https://zhuanlan.zhihu.com/p/138561005
[5] UWA:OnRenderImage 提问https://answer.uwa4d.com/question/5e153e7afd2e373ffa7eaae5
[6] Unity 生命周期的 Scene rendering 渲染阶段https://docs.unity3d.com/Manual/ExecutionOrder.html
其他比较杂的 , 算是收集资料的时候顺带补充了知识

  1. 有讲到 depth/stencil buffer 的关系
    https://blog.csdn.net/weixin_34112900/article/details/86272901
  2. CommandBuffer.Blit isn't stencil buffer friendly
    https://forum.unity.com/threads/commandbuffer-blit-isnt-stencil-buffer-friendly.432776/
  3. 有讲到 Graphics 的 activeXXXBuffer 和 SetRenderTarget 用法
    https://www.jianshu.com/p/4e8162ed0c8d
  4. 口袋妖怪 X/Y 制作技法
    https://www.cnblogs.com/TracePlus/p/4299428.html
  5. Unity 后处理 性能优化
    https://zhuanlan.zhihu.com/p/39850106
结尾碎碎念
啊 , 这篇好长 , 写了两天好久 。 看了一下之前的文章排版也是惨不忍睹 , 瞎琢磨了一下下排版(感觉还行吧 。。 吧) 。 希望到时候投稿不用麻烦小编操心改排版就好了 。
后续可能做做其他系列 Shader 文章 , 但也不一定 , 有可能是零碎的 Shader 效果 。
【趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边】临近学期末 , 作业也越来越多 , 当初定下一星期一篇真是越来越难了/(ㄒoㄒ)/~~(咕咕咕

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图

趣投稿|走进 Stencil Buffer 系列 4:Stencil 后处理局部描边
本文插图