一、前言
我们之前已经介绍了一种几何过程式描边方法了。几何过程式描边可以很好的为不同模型设置不同的描边参数(描边颜色,宽度等等),不过也正是如此,要为每个模型都额外渲染一遍描边模型,性能上花费比较多。而有另外一种描边方法就是基于屏幕图像后处理描边方法,它只需要对一张屏幕图像进行边缘检测,无论模型多么复杂,计算量也是恒定的,也就节省了性能开销。
屏幕图形后处理比较常见的是在渲染的最后的阶段,拿到屏幕已经渲染的结果(一张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 代表原始图像,Gx和 Gy分别代表经横向及纵向边缘检测的图像,通过以上公式就可以分别计算出横向 和 纵向 的梯度值,即Gx和 Gy,梯度值越大,边缘就越明显。
Sobel卷积核算子
三、具体实现
首先建一个场景,放一个可爱的小兔子bunny还有一个立方体cube,并使bunny的材质Shader中写入Stencil参考值2,但cube不写入参考值。
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; //纯颜色形式的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.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:
颜色图像形式的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描边方法
[3] 乐园:利用StencilBuffer实现局部后处理描边
[6] Unity生命周期的Scene rendering渲染阶段
其他比较杂的,算是收集资料的时候顺带补充了知识
2.CommandBuffer.Blit() isn't stencil buffer friendly
3. 有讲到Graphics的activeXXXBuffer和SetRenderTarget用法
结尾碎碎念:
啊,这篇好长,写了两天好久。看了一下之前的文章排版也是惨不忍睹,瞎琢磨了一下下排版(感觉还行吧。。吧)。希望到时候投稿不用麻烦小编操心改排版就好了。
后续可能做做其他系列Shader文章,但也不一定,有可能是零碎的Shader效果。
临近学期末,作业也越来越多,当初定下一星期一篇真是越来越难了/(ㄒoㄒ)/~~(咕咕咕
编辑的时候点击插入视频按钮,直接把 B 站地址粘贴进去就行了
@∞™ ≠ 52Cº:......我的错,居然现在才看到有那个视频图标/(ㄒoㄒ)/~~,谢谢大佬
有几个问题,1这种独立描边的目标是可变的,所以处理方式应该是给有可能进行描边处理的物体shader都安排上stencil 配置,当其需要进行stencil处理时,代码更改其stencil 值?
2 博主有没有试过用commandbuffer进行处理,之前有类似思路,但无法确定commandbuffer中的framebuffer,无法实现,不知为何。
最后很感谢博主的分享。很棒。
@Thuris:emm 我的初步想法是这样的:
最近由 阿创 修改于:2020-05-21 10:54:481、通过代码来修改Stencil值是可以的。我之前不知道在哪看过说游戏里的鼠标悬停在物体上,物体高亮描边好像就是这个原理(例子待考证)
2、command buffer我没怎么用过,但是看了一下其他资料,个人感觉command buffer和处理方式后处理很像。而且好像不太需要拿到frame buffer呀....感觉先将相机渲染的画面直接存到一张RT上面,再把RT传递给shader做计算,然后把结果通过command buffer绘制到屏幕上就好了。
有可能的问题就是我文章中后面链接里提到的,command buffer可能会丢失stencil信息,你指的是这个问题吗?
我也没有试验过,到时找个时间试试康,学习学习~,或者大佬实验后也告诉告诉我呗~~
@阿创:2.CommandBuffer.Blit() isn't stencil buffer friendly 论坛链接之前看过,个人英文不好的痛处显现了出来。看里面提问者蛮多吐槽unity这方面文档不足。我也好想吐槽unity文档信息不足的问题。源码也不给。导致冷门内容探索起来太麻烦。
最近由 Thuris 修改于:2020-05-21 16:53:14个人实验下来commandbuffer在2019.3版本 stencil几乎不能用,只有在commandbuffer.blit(texA,texB,material)
中texA 与TexB 有一个是BuiltinRenderTextureType.CameraTarget 的前提下,相关shader中的stencil处理能奏效。 否则shader 中stencil处理都无法奏效,只会得到张黑图(即空图RGBA(0,0,0,0)).
另外博主这种描边适配的是物体影子轮廓描边, 想精细描边效果的话对 Shader "Unlit/StencilProcess" 最后继续返回原 fixed4 color = tex2D(_MainTex,i.uv); 颜色。以及 Shader "Unlit/OutlinePostProcessByStencil" 描边检测修改成可以检测任何边缘颜色的方法, 这样可实现精细描边- 我用这种方式给技能(粒子系统)进行了描边。
@Thuris:啊原来是这样,又学到了~~ 还有最近UE5消息出来后,Unity被吐槽的就更多了哈哈哈哈哈
最近由 阿创 修改于:2020-05-22 09:58:31我也到时找个时间试试康~
大佬,我按照你的做法做了并没有效果,能否发份源码