好久没写博客了,最近在整毕业论文,码的字也比较多,但是博客想起来好久没碰过了。在整毕设的时候,尝试着解决了一些自己很久没有完成的心病,也做了一些效果来玩一玩,这次把这个东西记一下,以后有需求可以随时拿起来用。所以这篇博客不会深入探讨一个东西从前到后是如何推导出来的(那样写一般都要花小半个星期,之前旋转那一篇博客花了我一个月),这篇文章主要写一写自己在做项目的时候的一些思路和坑。
今天的博客记一下关于后处理的描边方法,说起描边还是比较简答的,在杂记那一篇中,记录了根据模型本身来构建描边的方法, 虽然特别特别水,而且对于模型法线来说要求贼高。在我的毕设中,使用了MagicalVoxel做的建模(因为本人太懒了,3Dmax不想用,就想着偷懒了)。这个建模软件做的东西效果还是挺好的,特别简单,我都是一边听相声一边做,超级安逸。
但是这样的后果就是,如果我需要做一些描边的时候,就超级难搞,例如我用MagicalVoxel做出的一个锅子是这样的:
这个效果在MagicalVoxel中效果还不错,但是放在Unity中就会出现很多问题,且不说MagicalVoxel导出的Vox文件的贴图文件保存的为顶点色,和一般的模型的贴图的方式不同,所以在采样的时候会就出现颜色问题。而且MagicalVoxel导出后必须自己界定模型中心,所以每个模型都要自己整一下,做个空物体来表示。最最最最蛋疼的是,如果对于这类型的模型描边的时候,直接使用模型顶点外拓效果会非常差,例如上面这个锅子,使用法线外拓后的描边然后再用模板测试相减后效果为(我这里描边是绿色):
如果改用offset对纯色模型描边的话,效果和上面差不多,而且还要差一些。
所以对于MagicalVoxel这样法线方向单一的模型来说,对于单个模型来进行描边着色是做不了的。一般都是使用后处理进行描边。后处理的描边卷积我在模糊那一篇的最后地方写过一个描边效果,对于场景中的效果根据卷积核来判断一个模型的颜色然后界定描边。这里就不重复了,对于使用MagicalVoxel建模的项目中,如果场景中存在如下的模型:
如果我们使用普通的颜色描边卷积核来做画面的描边效果,那么对于上图来说效果是这样的:
现在看起来这个还算堪用,只能说是稍微和我想要的效果沾点边。但是由于基于颜色,很多时候指不定会在屏幕上画出啥脏乱差的效果。
对于我的项目来说,我在尝试描边的时候曾经想过做出类似于MagicalVoxel的描边,MagicalVoxel中,如果开启了左下角的Grid描边是这样的:
我在用MagicalVoxel的时候,感觉这个效果对于方块模型来说还是挺炫酷的,因为每次看起来感觉层次比较分明。我摸了几天,发现这里的这种描边可以使用屏幕法线纹理和屏幕深度纹理来采样叠加来模拟这个效果。放在Unity中具体来说是这样的:
这样的效果看起来还行 ,而且特别简单,只需要一丢丢代码就能实现这个效果,只需要将深度贴图的描边结果和法线贴图描边的结果结合起来就行。
Shader 代码:
Shader "Custom/LineNormal"
{
Properties
{
_MainTex("Texture",2D)="white"{}
_EdgeColor("EdgeColor",Color)=(1,1,1,1)
_NoneEdgeColor("NoneEdgeColor",Color)=(1,1,1,1)
_SampleRange("SampleRange",float)=1.0
_NormalDiffThreshold("NormalDiffThreshold",float)=1.0
}
CGINCLUDE
#include "UnityCG.cginc"
struct VertexData
{
float4 vertex:POSITION;
float2 uv:TEXCOORD0;
};
struct VertexToFragment
{
float4 pos:SV_POSITION;
float2 uv[9]:TEXCOORD0;
};
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _CameraDepthNormalsTexture;
sampler2D _CameraDepthTexture;
float4 _NoneEdgeColor;
float4 _EdgeColor;
float _SampleRange;
float _NormalDiffThreshold;
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos=UnityObjectToClipPos(v.vertex);
VToF.uv[0]=v.uv+float2(-1,1)*_MainTex_TexelSize*_SampleRange;
VToF.uv[1]=v.uv+float2(0,1)*_MainTex_TexelSize*_SampleRange;
VToF.uv[2]=v.uv+float2(1,1)*_MainTex_TexelSize*_SampleRange;
VToF.uv[3]=v.uv+float2(-1,0)*_MainTex_TexelSize*_SampleRange;
VToF.uv[4]=v.uv;
VToF.uv[5]=v.uv+float2(1,0)*_MainTex_TexelSize*_SampleRange;
VToF.uv[6]=v.uv+float2(-1,-1)*_MainTex_TexelSize*_SampleRange;
VToF.uv[7]=v.uv+float2(0,-1)*_MainTex_TexelSize*_SampleRange;
VToF.uv[8]=v.uv+float2(1,-1)*_MainTex_TexelSize*_SampleRange;
return VToF;
}
float CheckEdge(fixed4 a,fixed4 b)
{
float2 normalDiff=abs(a.xy-b.xy);
return (normalDiff.x+normalDiff.y)<_NormalDiffThreshold;
}
fixed4 myFragmentDepth(VertexToFragment VToF):SV_TARGET
{
fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
fixed4 edge1=tex2D(_CameraDepthTexture,VToF.uv[0]);
fixed4 edge2=tex2D(_CameraDepthTexture,VToF.uv[1]);
fixed4 edge3=tex2D(_CameraDepthTexture,VToF.uv[2]);
fixed4 edge4=tex2D(_CameraDepthTexture,VToF.uv[3]);
fixed4 edge5=tex2D(_CameraDepthTexture,VToF.uv[5]);
fixed4 edge6=tex2D(_CameraDepthTexture,VToF.uv[6]);
fixed4 edge7=tex2D(_CameraDepthTexture,VToF.uv[7]);
fixed4 edge8=tex2D(_CameraDepthTexture,VToF.uv[8]);
float result=1.0;
result*=CheckEdge(edge1,edge8);
result*=CheckEdge(edge2,edge7);
result*=CheckEdge(edge3,edge6);
result*=CheckEdge(edge4,edge5);
return lerp(_EdgeColor,getColor,result);
}
fixed4 myFragment(VertexToFragment VToF):SV_TARGET
{
fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
fixed4 edge1=tex2D(_CameraDepthNormalsTexture,VToF.uv[0]);
fixed4 edge2=tex2D(_CameraDepthNormalsTexture,VToF.uv[1]);
fixed4 edge3=tex2D(_CameraDepthNormalsTexture,VToF.uv[2]);
fixed4 edge4=tex2D(_CameraDepthNormalsTexture,VToF.uv[3]);
fixed4 edge5=tex2D(_CameraDepthNormalsTexture,VToF.uv[5]);
fixed4 edge6=tex2D(_CameraDepthNormalsTexture,VToF.uv[6]);
fixed4 edge7=tex2D(_CameraDepthNormalsTexture,VToF.uv[7]);
fixed4 edge8=tex2D(_CameraDepthNormalsTexture,VToF.uv[8]);
float result=1.0;
result*=CheckEdge(edge1,edge8);
result*=CheckEdge(edge2,edge7);
result*=CheckEdge(edge3,edge6);
result*=CheckEdge(edge4,edge5);
return lerp(_EdgeColor,getColor,result);
}
ENDCG
SubShader
{
pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragment
ENDCG
}
pass
{
CGPROGRAM
#pragma vertex myVertex
#pragma fragment myFragmentDepth
ENDCG
}
}
}
在后处理脚本中,只需要将两个Pass相加即可:
Material.SetFloat("_edgeOnly", EdgeOnly);
Material.SetColor("_EdgeColor", EdgeColor);
Material.SetColor("_NoneEdgeColor", NoneEdgeColor);
Material.SetFloat("_SampleRange", SampleRange);
Material.SetFloat("_NormalDiffThreshold", NormalDiffThreshold);
RenderTexture RT1 = RenderTexture.GetTemporary(src.width, src.height, 0);
Graphics.Blit(src, RT1, Material, 0);
Graphics.Blit(RT1, dst, Material, 1);
RenderTexture.ReleaseTemporary(RT1);
最终就可以输出效果:
这个效果还是够用的,但是论文导师说这样看起来画面太脏,然后说丑。我想着没办法,只好重新想描边的方法。
在我的项目中,后处理有如下的需求:
按照这个需求,上面的那些描边效果都统统不能用,因为它们都是针对屏幕纹理来描边。在屏幕纹理上,我完全不能区分出每一个物体,然后根据物体来描边。所以我想出来的方法是,对场景中每个物体都单独渲染一张纯色贴图,然后将该贴图进行后处理然后贴在屏幕上。无非就是以下步骤:
我当时想到这里的时候有点手足无措,因为如果要将一个物体单独为它渲染一张贴图, 我第一时间想到的就是为一个物体单独设置一个相机来渲染,但是这样的后果就是,我场景里物体越多,需要的相机越多,而且如果是根据layer来区分一个相机的渲染目标的话,每个相机都需要一个独立的层级,每个物体都需要一个独立的层级。而且为了将这些相机与主相机适配,所以它们的Transform参数都应该同步。。。。这样看起来,及其麻烦而且费力不讨好,所以我最开始构想的时候想到这里就打住了,因为这样做还不如不做,最终性能就不断往上面叠,而且效果还可能很差。之后就没想过再做描边这个事情。
之后在网上看了一些命令缓冲的文章,又让我对描边起了想法。命令缓冲可以指定用一种材质渲染一个渲染器,将渲染好的图像输出一张RenderTexture,这无疑是很好的。每个命令缓冲都和一张RenderTexture存在一一对应的关系,用字典可以轻松的描述它们,在我的项目中,当某个物体生成时:
public void Add(ICon getICon)
{
CommandBuffer newBuffer = new CommandBuffer();
RenderTexture newTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0);
newBuffer.SetRenderTarget(newTexture);
newBuffer.ClearRenderTarget(true, true, Color.black);
Material newMaterial = new Material(ColorShader);
Color materialColor;
TestIConPool.ColorDictionary.TryGetValue(getICon.returnNumOfDic(), out materialColor);
newMaterial.SetColor("_OutLineColor", materialColor * 10f);
newBuffer.DrawRenderer(getICon.myRenderer, newMaterial);
BufferDic.Add(newBuffer, newTexture);
IConBufferDic.Add(getICon, newBuffer);
}
其中 TestIConPool指的是物体颜色池,物体将会根据类型索引从池子里拿出对应的颜色出来;ColorShader仅仅是非常简单的输出一张纯色模型的Shader;BufferDic就是存放缓冲与渲染纹理的字典,类型是
public static void Remove(ICon removeICon)
{
CommandBuffer removebuffer;
IConBufferDic.TryGetValue(removeICon, out removebuffer);
IConBufferDic.Remove(removeICon);
RenderTexture getRT;
BufferDic.TryGetValue(removebuffer, out getRT);
BufferDic.Remove(removebuffer);
removebuffer.Release();
removebuffer = null;
RenderTexture.ReleaseTemporary(getRT);
getRT = null;
}
在我的项目中,清除一个物品的情况很多,但是每个情况都要依赖于这个脚本的实例的话就会很麻烦,所以Remove是一个静态方法,以便于其他脚本调用。
一般描边分为两种,一种是硬描边,第二种是软描边(也有些游戏里描边是先硬然后逐渐软的那种)。这两种描边无非是采样的时候稍微修改一下就好了。软描边非常简单,只需要高斯模糊+纹理叠加Pass+纹理相减Pass就可以了。Shader很简单:
Shader "Custom/OutLineShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_OutLineSize("OutLineSize",int)=4
_OutLineTexture("OutLineTexture",2D)="white"{}
_ObjectTexture("BlurTexture",2D)="white"{}
_OutLineColor("OutLineColor",Color)=(1,1,1,1)
}
CGINCLUDE
float _OutLineSize;
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _ObjectTexture;
sampler2D _OutLineTexture;
float4 _OutLineColor;
struct VertexData
{
float4 vertex:POSITION;
float2 uv:TEXCOORD0;
};
struct VertexToFragmentBlur
{
float4 pos:SV_POSITION;
float2 uv[5]:TEXCOORD0;
};
VertexToFragmentBlur vertexBlur(VertexData v)
{
VertexToFragmentBlur VToFB;
VToFB.pos=UnityObjectToClipPos(v.vertex);
VToFB.uv[0]=v.uv;
VToFB.uv[1]=float2(1,0)*_MainTex_TexelSize.xy*_OutLineSize;
VToFB.uv[2]=float2(2,0)*_MainTex_TexelSize.xy*_OutLineSize;
VToFB.uv[3]=float2(0,1)*_MainTex_TexelSize.xy*_OutLineSize;
VToFB.uv[4]=float2(0,2)*_MainTex_TexelSize.xy*_OutLineSize;
return VToFB;
}
fixed4 fragmentBlur(VertexToFragmentBlur VToFB):SV_TARGET
{
float weight[3]={0.4026,0.2442,0.0545};
fixed4 getColor=tex2D(_MainTex,VToFB.uv[0])*weight[0];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[1])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[1])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[2])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[2])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[3])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[3])*weight[1];
getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[4])*weight[2];
getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[4])*weight[2];
return getColor*0.626;
}
struct VertexToFragment
{
float4 pos:SV_POSITION;
float2 uv:TEXCOORD0;
};
VertexToFragment myVertex(VertexData v)
{
VertexToFragment VToF;
VToF.pos=UnityObjectToClipPos(v.vertex);
VToF.uv=v.uv;
return VToF;
}
fixed4 FragmentRemove(VertexToFragment VToF):SV_TARGET
{
float3 BlurColor= tex2D(_MainTex,VToF.uv);
float3 objectColor=tex2D(_ObjectTexture,VToF.uv);
float3 finalColor=BlurColor-objectColor;
return fixed4(finalColor,1.0);
}
fixed4 FragmentAdd(VertexToFragment VToF):SV_TARGET
{
fixed4 screen = tex2D(_MainTex, VToF.uv);
fixed4 outLine=tex2D(_OutLineTexture,VToF.uv);
screen.rgb+=outLine.rgb;
//fixed4 final=screen*(1-all(outLine))+_OutLineColor*any(outLine.rgb);
return screen;
}
ENDCG
SubShader
{
Cull Off ZWrite Off ZTest Always
pass
{//0
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex vertexBlur
#pragma fragment fragmentBlur
ENDCG
}
pass
{//1
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex myVertex
#pragma fragment FragmentRemove
ENDCG
}
pass
{//2
CGPROGRAM
#include "UnityCG.cginc"
#pragma vertex myVertex
#pragma fragment FragmentAdd
ENDCG
}
}
}
这里只是多了两个片元着色器,将两个颜色相加的逻辑在这里而已。真正关键的代码在于后处理脚本中。这个时候上文中的字典的功能开始起作用了。如果对于每个纯色贴图都要同样的进行一遍高斯模糊,这样的效率是极低的。所以需要将所有物体的纯色贴图合并成一张,然后集体高斯模糊,然后再一个个剔除原来的贴图颜色。这种情况下,字典发挥了它的功用,即:
RenderTexture RTA = RenderTexture.GetTemporary(src.width, src.height, 0);
RenderTexture RTB = RenderTexture.GetTemporary(src.width, src.height, 0);
foreach (KeyValuePair pair in BufferDic)
{
Graphics.ExecuteCommandBuffer(pair.Key);
GetMaterial.SetTexture("_OutLineTexture", pair.Value);
Graphics.Blit(RTA, RTB, GetMaterial, 2);
Graphics.Blit(RTB, RTA);
}
RenderTexture bufferA = RenderTexture.GetTemporary(src.width, src.height, 0);
RenderTexture bufferB = RenderTexture.GetTemporary(src.width, src.height, 0);
GetMaterial.SetFloat("_OutLineSize", outLineSize);
Graphics.Blit(RTA, bufferA, GetMaterial, 0);
Graphics.Blit(bufferA, bufferB, GetMaterial, 0);
for (int t = 0; t < BlurSize; t++)
{
Graphics.Blit(bufferB, bufferA, GetMaterial, 0);
Graphics.Blit(bufferA, bufferB, GetMaterial, 0);
}
//Graphics.Blit(bufferB, RTA);
foreach (KeyValuePair pair in BufferDic)
{
GetMaterial.SetTexture("_ObjectTexture", pair.Value);
Graphics.Blit(bufferB, RTA, GetMaterial, 1);
Graphics.Blit(RTA, bufferB);
}
GetMaterial.SetTexture("_OutLineTexture", bufferB);
Graphics.Blit(src, dst, GetMaterial, 2);
RenderTexture.ReleaseTemporary(bufferA);
RenderTexture.ReleaseTemporary(bufferB);
RenderTexture.ReleaseTemporary(RTA);
RenderTexture.ReleaseTemporary(RTB);
RTA.Release();
RTB.Release();
其中,ExecuteCommandBuffer为渲染目标缓冲的方法,渲染后该命令缓冲对应的RT才会存在图像,由于两个值都存在同一个字典里,遍历字典的时候就可以很轻松的拿到它们,即pair.key和pair.value。然后将它们合并后进行高斯模糊的反复渲染工作。渲染好后,再次遍历字典,挨个将对应的贴图掏空,最终粘在屏幕上。即如下图所示:
屏幕原图:
其中除了主角那个垃圾车以外,其他的物品根据各自在颜色池的索引渲染出来的颜色集合在一起后(即第一次字典遍历以后)的效果是:
将这张图统一高斯模糊,然后进行镂空(即第二次字典遍历以后)后,效果是这样的:
然后就可以非常简单的粘在屏幕上:
关于这个方法的两个坑:
1. 将多张贴图合并到一起,最好使用两张临时贴图而仅是一章。因为不能骑驴找驴,需要两张图类似于左右手一样工作,即:
Graphics.Blit(RTA, RTB, GetMaterial, 2);
Graphics.Blit(RTB, RTA);
2. 由于RenderTexture本身不能手动使用New实例化,都是使用GetTemporary来获得一张临时的纹理。这样的方法很类似于从对象池里拿出但不拷贝的值。这样的方法节省了内存,但后果就是RenderTexture随时保存了一组临时纹理,当拿到它时,很可能是之前其他代码用过后不管的颜色,或者是一些垃圾颜色。常用ReleaseTemporary来进行销毁,但是这样的销毁似乎只是清除了引用,而不是销毁了里面的颜色。
我在测试该功能的时候,常常发生了这样的情况:
这种情况就是上一帧的一些RenderTexture没有正确清除颜色所造成的, ReleaseTemporary并不能将RT的硬件资源卸载。下一次拿到的RT很可能就是上一次的并未清除的RT的引用,这就导致颜色持久留在了屏幕上,形成了这样的拖尾。对于这种非托管资源来说,正确的卸载方式是:
RTA.Release();
RTB.Release();
您也看到了,单纯的软描边在一些物体上不是那么明显,感觉有点若隐若现。所以最终我采用了硬描边。硬描边看起来更难,实际上更简单一些,只需要单纯的外扩就行,连反复渲染的步骤都免了(亏得我之间做出了软描边想要改成硬描边的时候想那么久)。
首先,将采样的时候的权重都改为1,即单纯的获得外面的颜色就好:
然后让这个BlurPass的后处理代码删掉反复渲染(不关其实也没事):
RenderTexture RTA = RenderTexture.GetTemporary(src.width, src.height, 0);
RenderTexture RTB = RenderTexture.GetTemporary(src.width, src.height, 0);
foreach (KeyValuePair pair in BufferDic)
{
Graphics.ExecuteCommandBuffer(pair.Key);
GetMaterial.SetTexture("_OutLineTexture", pair.Value);
Graphics.Blit(RTA, RTB, GetMaterial, 2);
Graphics.Blit(RTB, RTA);
}
RenderTexture bufferA = RenderTexture.GetTemporary(src.width, src.height, 0);
RenderTexture bufferB = RenderTexture.GetTemporary(src.width, src.height, 0);
GetMaterial.SetFloat("_OutLineSize", outLineSize);
Graphics.Blit(RTA, bufferA, GetMaterial, 0);
Graphics.Blit(bufferA, bufferB, GetMaterial, 0);
foreach (KeyValuePair pair in BufferDic)
{
GetMaterial.SetTexture("_ObjectTexture", pair.Value);
Graphics.Blit(bufferB, RTA, GetMaterial, 1);
Graphics.Blit(RTA, bufferB);
}
GetMaterial.SetTexture("_OutLineTexture", bufferB);
Graphics.Blit(src, dst, GetMaterial, 2);
RenderTexture.ReleaseTemporary(bufferA);
RenderTexture.ReleaseTemporary(bufferB);
RenderTexture.ReleaseTemporary(RTA);
RenderTexture.ReleaseTemporary(RTB);
RTA.Release();
RTB.Release();
}
然后调整OutLineSize就可以了,最终输出的样式为:
我个人觉得还行,还是挺满意的。做这个描边大约花了两三天,前前后后反复改了好多地方,但是其实最大的感受不是XX功能很强大,XX方法很好用这种比较套路化的想法,而是感觉,一个效果,单纯的做出来和应用到项目往往中间存在很大的隔阂。优秀的游戏程序员不仅仅是做出漂亮的效果,还要让这个效果能用上,能用好。