在上一篇中,我们基本上说明了遮挡描边实现的一种基本原理。这一篇中我们将了解一下基于这种原理的具体实现代码。本篇中的内容和前几篇教程相比,相对比较难一些,建议先有一些基本的Unity的C#脚本编程经验和基本的Unity Shader基础(可参考前几篇教程)。
下面我们就开始讲解具体的实现代码(由于代码较多,所以这里只对需要讲解的地方进行讲解):
C#脚本部分
与之前不同,这一次需要写一个C#脚本来辅助我们实现这个功能。它所做的主要工作就是创建一个临时摄像机用来获取我们想进行描边的物体的深度图,以及通过OnRenderImage函数对已经渲染完毕的主摄像机图像利用我们编写的Shader进行特殊处理,将处理完的结果再渲染到屏幕上。
1 /************* 2 ** author:esfog 3 /************/ 4 using UnityEngine; 5 using System.Collections; 6 7 public class PlayerOutLine : MonoBehaviour 8 { 9 //遮挡描边颜色 10 public Color OutLineColor = Color.green; 11 12 private GameObject _cameraGameObject; 13 //摄像机(专门用来处理遮挡描边的) 14 private Camera _camera; 15 private Camera _mainCamera; 16 //所需的RenderTexture 17 private RenderTexture _renderTextureDepth; 18 private RenderTexture _renderTextureOcclusion; 19 private RenderTexture _renderTextureStretch; 20 21 //临时材质 22 private Material _materialOcclusion; 23 private Material _materialStretch; 24 private Material _materialMix; 25 //用来处理玩家深度的Shader 26 private Shader _depthShader; 27 28 // 相关初始化 29 void Start () 30 { 31 _mainCamera = Camera.main; 32 _mainCamera.depthTextureMode = DepthTextureMode.Depth; 33 _depthShader = Shader.Find("Esfog/OutLine/Depth"); 34 35 _cameraGameObject = new GameObject (); 36 _cameraGameObject.transform.parent = _mainCamera.transform; 37 _cameraGameObject.transform.localPosition = Vector3.zero; 38 _cameraGameObject.transform.localScale = Vector3.one; 39 _cameraGameObject.transform.localRotation = Quaternion.identity; 40 41 _camera = _cameraGameObject.AddComponent<Camera> (); 42 _camera.aspect = _mainCamera.aspect; 43 _camera.fieldOfView = _mainCamera.fieldOfView; 44 _camera.orthographic = false; 45 _camera.nearClipPlane = _mainCamera.nearClipPlane; 46 _camera.farClipPlane = _mainCamera.farClipPlane; 47 _camera.rect = _mainCamera.rect; 48 _camera.depthTextureMode = DepthTextureMode.None; 49 _camera.cullingMask = 1 << (int)LayerMask.NameToLayer ("Main Player"); 50 _camera.enabled = false; 51 _materialOcclusion = new Material(Shader.Find("Esfog/OutLine/Occlusion")); 52 _materialStretch = new Material (Shader.Find ("Esfog/OutLine/Stretch")); 53 _materialMix = new Material (Shader.Find("Esfog/OutLine/Mix")); 54 Shader.SetGlobalColor ("_OutLineColor",OutLineColor); 55 if (!_depthShader.isSupported || !_materialMix.shader.isSupported || !_materialMix.shader.isSupported || !_materialOcclusion.shader.isSupported) 56 { 57 return; 58 } 59 } 60 61 void OnRenderImage(RenderTexture source,RenderTexture destination) 62 { 63 _renderTextureDepth = RenderTexture.GetTemporary(Screen.width,Screen.height,24,RenderTextureFormat.Depth); 64 _renderTextureOcclusion = RenderTexture.GetTemporary (Screen.width,Screen.height,0); 65 _renderTextureStretch = RenderTexture.GetTemporary (Screen.width,Screen.height,0); 66 _camera.targetTexture = _renderTextureDepth; 67 68 _camera.fieldOfView = _mainCamera.fieldOfView; 69 _camera.aspect = _mainCamera.aspect; 70 _camera.RenderWithShader (_depthShader,string.Empty); 71 72 //对比我们为角色生成的RenderTexture和主摄像机自身的深度缓冲区,计算出角色的哪些区域被挡住了 73 Graphics.Blit(_renderTextureDepth,_renderTextureOcclusion,_materialOcclusion); 74 var screenSize = new Vector4(1.0f/Screen.width,1.0f/Screen.height,0.0f,0.0f); 75 76 _materialStretch.SetVector ("_ScreenSize",screenSize); 77 Graphics.Blit (_renderTextureOcclusion,_renderTextureStretch,_materialStretch,0); 78 Graphics.Blit (_renderTextureStretch,_renderTextureStretch,_materialStretch,1); 79 80 _materialMix.SetTexture ("_OcclusionTex",_renderTextureOcclusion); 81 _materialMix.SetTexture ("_StretchTex",_renderTextureStretch); 82 Graphics.Blit (source,destination,_materialMix); 83 84 RenderTexture.ReleaseTemporary (_renderTextureDepth); 85 RenderTexture.ReleaseTemporary (_renderTextureOcclusion); 86 RenderTexture.ReleaseTemporary (_renderTextureStretch); 87 88 } 89 }
10~26行,这部分主要是声明一些我们在后面将要使用到的变量,包括我们指定的描边颜色,对主摄像机的引用,以及对我们在后面要创建的临时摄像机的引用,3个RenderTexture,1个Shader,以及三个Material.对RenderTexture做一下解释:一般来说场景中的Camera渲染完毕后图像是直接显示在游戏屏幕上的,但是我们也可以创建一个RenderTexture,然后把相机的输出目标制定为这个RenderTexture上面,那么我们将不会在屏幕上看到这个相机的任何渲染结果,因为结果已经被保存到我们指定的RenderTexture了。你也可以把屏幕窗口理解为一个默认的RenderTexture.后面具体应用还会说明。
31~33行,首先获得对场景主摄像机的引用,再通过_mainCamera.depthTextureMode = DepthTextureMode.Depth;将主摄像机的深度图模式设置为Depth.这样我们的主摄像机就会为我们提供深度图了,Shader中我们就可以直接使用_CameraDepthTexture来使用这张深度图了.然后我们通过Shader.Find()来初始化前面我们声明的一个Shader变量,后面我们会用到。
35~50行,我们开始创建我们的专门用来生成被描边物体的深度图的摄像机了.我们把生成的摄像机作为主摄像机的子物体,主要是为了比较容易调整位置,没什么特殊意义.由于我们要保证这个临时摄像机和主摄像机拍摄角度和范围一模一样,我们把子物体的位置等信息都置零,然后把临时相机的参数全部设置成和主摄像机一样,这里的临时摄像机我们通过_camera.depthTextureMode = DepthTextureMode.None;让它不生成深度图,因为前面我们已经让主摄像机的深度模式为Depth了,而Shader中的_CameraDepthTexture只能用于一个主摄像机的深度图,所以所以这里面我们把临时相机的DepthTextureMode设置为None.然后我们后面通过我们自己编写的Shader来获得深度信息。最后这句_camera.cullingMask = 1 << (int)LayerMask.NameToLayer ("Main Player");是为了让我们的相机只渲染我们想要描边的物体,cullingMask属性是一个通过不同位上的01值来判断是否渲染该层的物体,这里我们创建一共新层叫"Main Player",并在场景中把我们的要渲染的物体的Layer设置为Main Player.这样就能通过这行代码就实现了整个目的。
51~58行,前三行,我们初始化了前面声明的三个Material变量.可以看到Material的构造函数需要我们提供一个Shader,这里面我们通过Shader.Find()把我们编写的3个Shader用到三个Material上,Shader内容和用途在后面说明.Shader.SetGlobalColor ("_OutLineColor",OutLineColor);这句话就是一种通过C#脚本来给Shader中变量进行赋值的一种方法,但是这种方法是全局设置的,所以可能所有Shader的同名变量都会受到印象,所以使用前要确保只初始化了想要初始化的Shader变量。这句话就是把我们Shader中的描边颜色设置成我们OutLineColor表示的颜色.最后面的if语句,是用来判断一下是否我们编写的4个Shader是否能被支持.
61行,OnRenderImage(RenderTexture source,RenderTexture destination),这个方法MonoBehaviour提供的,如果你在脚本中写上这个函数的话,那么如果你把这个脚本挂在含有Camera组件的物体时候,当这个摄像机渲染完的时候会调用这个OnRenderImage,把当前渲染结果当做第一个参数传入,也就是这里的source,然后通过我们对source的处理,最后把处理结果赋给destination,这个destination就是摄像机最终的渲染结果了。所以我们主要的处理步骤都是在OnRenderImage里面进行的.
63~65行,我们前面声明了三个RenderTexture,这里就是初始化他的地方,其中_renderTextureDepth用来保存临时摄像机获取到的深度图信息,_renderTextureOcclusion用来保存我们进行Occlusion处理后的图像信息,这个处理就是通过Shader来找出需要描边物体的被遮挡部分._renderTextureStretch用来保存被遮挡区域拉伸后的信息,这几个操作都是按次序来个,前一个的输出作为后一个的输入.RenderTexture.GetTemporary()函数是Unity提供给我们初始化RenderTexture使用的,前两个参数是指定Texture的宽高,第三个参数是指定这个RenderTexture处理时候所涉及到的深度缓冲的具体精度(24是最大精度),第四个参数是指定图像保存的格式.这里面我们只有_renderTextureDepth比较特殊,由于他在处理过程中需要涉及到深度信息,所以第三个参数设置为24,最后一个参数设置为RenderTextureFormat.Depth是为了于Unity默认处理深度图的格式保持一致,以便我们后面处理。而其他两张RenderTexture都只是对二维图像进行处理,不需要深度信息,所以第三个参数可以设置为0.
66~70行,_camera.targetTexture = _renderTextureDepth;=这一句把临时摄像机的渲染目标指定为为我们刚刚初始化的RenderTexture,前面已经解释过为什么这么做了.中间两句让临时摄像机的FOV和Aspect和主摄像机保持一致,因为有时候游戏中是可以拉近或者拉远视角的,而我们只有在Start方法里初始化了一次临时摄像机的参数。所以如果不修改的话可能导致两个摄像机参数不一致的问题。_camera.RenderWithShader这一句是为了让临时摄像机利用这个我们提供的Shader来进行渲染, 也就是把这个摄像机处理的所有顶点信息传进这个Shader,然后把Shader的返回结果作为输出保存到摄像机的targetTexture上.其中第一个参数是指定我们用到的Shader,第二个参数用不到,这里不解释了.
73行,Graphics.Blit()是后期处理中的常用方法,他把第一个参数作为输入,利用第三个参数提供的材质所包含的Shader进行处理,处理后把结果输出到第二个参数上去.所以这一行中我们把上一步中得到的临时相机深度图作为输入,利用我们初始化的负责Occlusion处理的Material来获取被遮挡区域,再把结果保存到_renderTextureOcclusion上.
74~78行,由于我们下面要对被遮挡区域进行分别进行一次横纵拉伸一像素的操作,而在Shader中我们只能对UV来处理,所以必须知道屏幕的一像素代表多少UV,第一行代码就是为了进行这个计算.第76行把计算好的值传到Shader中,这个方法和前面的全局给Shader赋值不同,这里只对相应材质对应的Shader进行了赋值.最后两行通过Blit分别进行了横纵的拉伸处理,最后面参数的0和1,是指调用Shader中的第几个Pass,其中0代表第一个.后面看具体Shader代码就明白了.
80~82行,将前面两步处理的RenderTexture当做参数传递给最后一个Shader,然后我们通过Blit把主摄像机的原始图像当做输入,利用包含这个Shader的Material来处理,处理完成后把结果保存到destination上显示到游戏屏幕.
84~86行,处理完毕要释放掉我们前面想系统申请的RenderTexture.
下面分别来讲解我们用到的4个Shader:
获取深度信息的Shader
第一个是用来获取临时相机深度图的.
1 Shader "Esfog/OutLine/Depth" 2 { 3 SubShader 4 { 5 Tags { "RenderType"="Opaque" } 6 Pass 7 { 8 CGPROGRAM 9 10 #pragma vertex vert 11 #pragma fragment frag 12 #include "UnityCG.cginc" 13 14 struct v2f 15 { 16 float4 pos : SV_POSITION; 17 float2 depth : TEXCOORD0; 18 }; 19 20 v2f vert (appdata_base v) 21 { 22 v2f o; 23 o.pos = mul (UNITY_MATRIX_MVP, v.vertex); 24 o.depth = o.pos.zw; 25 return o; 26 } 27 28 half4 frag(v2f i) : COLOR 29 { 30 half x= i.depth.x/i.depth.y; 31 return half4(x, x, x, x); 32 } 33 ENDCG 34 } 35 } 36 }
就不逐行讲解了,主要注意一下下面几处:
24行,这里我们用一个float2的depth变量来保存进行了MVP变换后的顶点的的z和w信息,这里要说明一下,透视投影要分为矩阵变换和,透视除法两个部分.UnityShader在顶点着色器处理完后只完成了矩阵变换,还没有进行透视除法,而o.pos.zw中利用z/w就能才能把顶点在投影空间的距离转换到0~1的空间.而后面的第31行正是进行了这样的处理。为什么返回half4(x,x,x,x).这可能是由于深度图的格式要求吧,这段代码在Unity官方文档上有.另外可以用UNITY_TRANSFER_DEPTH(o.depth);替换24行.用UNITY_OUTPUT_DEPTH(i.depth);来替换30~31行.这些在UnityCG.cginc上有定义,有兴趣可以看看.
检测遮挡区域的Shader
第二个Shader是用来利用前面得到的深度图并结合出摄像机深度图来找出被遮挡区域的.
1 Shader "Esfog/OutLine/Occlusion" 2 { 3 Properties 4 { 5 _MainTex ("Base (RGB)", 2D) = "white" {} 6 } 7 SubShader 8 { 9 Tags { "RenderType"="Opaque" } 10 11 Pass 12 { 13 CGPROGRAM 14 #pragma vertex vert_img 15 #pragma fragment frag 16 #include "UnityCG.cginc" 17 18 uniform sampler2D _MainTex; 19 uniform sampler2D _CameraDepthTexture; 20 uniform float4 _OutLineColor; 21 22 float4 frag(v2f_img i):COLOR 23 { 24 float playerDepth = tex2D(_MainTex,i.uv); 25 float bufferDepth = tex2D(_CameraDepthTexture,i.uv); 26 float4 resColor = float4(0,0,0,0); 27 if((playerDepth < 1.0) && (playerDepth- bufferDepth)>0.0002) 28 { 29 resColor = float4(_OutLineColor.rgb,1); 30 } 31 return resColor; 32 } 33 ENDCG 34 } 35 } 36 FallBack "Diffuse" 37 }
注意以下几处:
14行,大家可能会奇怪为什么没有定义vertex函数,我们通过#pragma vertex vert_img使用了Unity为我们定义好的一些vertex函数,避免了自己手动编写,可以去UnityCG.cginc.看一下很简单。
18~20行,注意这里的_MainTex,因为我们在外面是通过Graphics.Blit函数来使用这个Shader的,所以Blit函数的第一个RenderTexture参数会自动被赋给_MainTex,后面的Shader也都是如此.第二行的_CameraDepthTexture在前面我们已经说过了,是Unity为什么定义好的变量,这里面代表主摄像机的深度图.第三行的表面颜色,前面也已经在C#脚本里面赋值过了.
24~30行,主摄像机的深度图和临时摄像机的深度图都是相对我们整个游戏窗口的两个二维图片,而游戏屏幕的当成一个大的模型,这个模型相当于一个大的面片只有4个顶点构成,uv就是0~1范围就对应于整个屏幕的宽高范围。所以前两行我们把两个深度图上相应uv上的像素颜色值取到,另外由于深度图的最终深度值都是存储在R通道上的,所以这里我们只用了两个float变量来接收tex2D的返回值.由于深度图中深度的信息范围是(0~1)其中0代表近平面,1代表远平面,值越大表示位置越靠后.if判断中playerDepth < 1.0是因为默认摄像机填充深度图的值就是1.0表示没有拍摄到物体,而palyerDepth<1.0就表示这个像素是在我们要被描边物体的身上的.而(playerDepth- bufferDepth)>0.0002是说明该像素所代表的位置在主摄像机上的深度比临时摄像机的深度要小,也就是这个像素实际上是被挡住的,这里的0.0002是因为避免相机深度的精度误差所引入的,不是必须的.如果if判断为true说明这个像素被遮挡,可以把颜色设置为描边颜色,否则使用本来的渲染颜色.
拉伸遮挡区域的Shader
第三个Shader是用来把上一步中得到的遮挡部分进行拉伸的.
1 Shader "Esfog/OutLine/Stretch" 2 { 3 Properties 4 { 5 _MainTex ("Base (RGB)", 2D) = "white" {} 6 } 7 SubShader 8 { 9 Tags { "RenderType"="Opaque" } 10 Pass 11 { 12 CGPROGRAM 13 #pragma vertex vert_img 14 #pragma fragment frag 15 #include "UnityCG.cginc" 16 uniform sampler2D _MainTex; 17 uniform float4 _OutLineColor; 18 uniform float4 _ScreenSize; 19 20 float4 frag(v2f_img i):COLOR 21 { 22 float4 c = tex2D(_MainTex,i.uv); 23 float4 c1 = tex2D(_MainTex,float2(i.uv.x-_ScreenSize.x,i.uv.y)); //左边一个像素 24 float4 c2 = tex2D(_MainTex,float2(i.uv.x+_ScreenSize.x,i.uv.y)); //右边一个像素 25 float3 totalCol = c.rgb + c1.rgb + c2.rgb; 26 float avg = totalCol.r + totalCol.g + totalCol.b; 27 if(avg > 0.01) 28 { 29 return _OutLineColor; 30 } 31 else 32 { 33 return float4(0,0,0,0); 34 } 35 } 36 37 ENDCG 38 } 39 40 Pass 41 { 42 Blend One One 43 CGPROGRAM 44 #pragma vertex vert_img 45 #pragma fragment frag 46 #include "UnityCG.cginc" 47 uniform sampler2D _MainTex; 48 uniform float4 _OutLineColor; 49 uniform float4 _ScreenSize; 50 51 float4 frag(v2f_img i):COLOR 52 { 53 float4 c = tex2D(_MainTex,i.uv); 54 float4 c1 = tex2D(_MainTex,float2(i.uv.x,i.uv.y-_ScreenSize.y)); //下边一个像素 55 float4 c2 = tex2D(_MainTex,float2(i.uv.x,i.uv.y+_ScreenSize.y)); //上边一个像素 56 float3 totalCol = c.rgb + c1.rgb + c2.rgb; 57 float avg = totalCol.r + totalCol.g + totalCol.b; 58 if(avg > 0.01) 59 { 60 return _OutLineColor; 61 } 62 else 63 { 64 return float4(0,0,0,0); 65 } 66 } 67 68 ENDCG 69 } 70 } 71 FallBack "Diffuse" 72 }
这个Shader比较长是因为写了两个Pass,这两个Pass的内容很相近,第一个Pass用来横向拉伸一个像素,第二个Pass进行纵向拉伸一个像素.前面说到我们通过Blit函数的第四个参数来决定使用哪个Pass.注意以下几行:
22~34行,在C#脚本中我们计算了游戏窗口的一个像素所代表的uv长度,所以我们这里以第一个Pass为例分别像当前像素的左边一个像素和右边一个像素取颜色,然后把他们的RGB值加在一起,如果大于0.01也就说明当前像素的左右区域是有颜色的,也就是属于描边区域,那么该像素就需要被设置为描边区域,设置成表面颜色,否则设置为黑.纵向拉伸也是同样的道理.
获取最终描边轮廓Shader
最后一个Shader是利用我们上面处理取得的RenderTexture对主摄像机渲染的原始图像进行最终处理.
1 Shader "Esfog/OutLine/Mix" 2 { 3 Properties 4 { 5 _MainTex ("Base (RGB)", 2D) = "white" {} 6 } 7 SubShader 8 { 9 Tags { "RenderType"="Opaque" } 10 Pass 11 { 12 CGPROGRAM 13 #pragma vertex vert_img 14 #pragma fragment frag 15 #include "UnityCG.cginc" 16 17 uniform sampler2D _MainTex; 18 uniform sampler2D _OcclusionTex; 19 uniform sampler2D _StretchTex; 20 21 float4 frag(v2f_img i):COLOR 22 { 23 float4 srcCol = tex2D(_MainTex,i.uv); 24 float4 occlusionCol = tex2D(_OcclusionTex,i.uv); 25 float4 stretchCol = tex2D(_StretchTex,i.uv); 26 float occlusionTotal = occlusionCol.r + occlusionCol.g + occlusionCol.b; 27 float stretchTotal = stretchCol.r + stretchCol.g + stretchCol.b; 28 29 if(occlusionTotal <0.01f&&stretchTotal>0.01f) 30 { 31 return float4(stretchCol.rgb,srcCol.a); 32 } 33 else 34 { 35 return srcCol; 36 } 37 } 38 39 ENDCG 40 } 41 } 42 FallBack "Diffuse" 43 }
注意一下几行:
17~19行,这三个变量均有C#脚本中进行了赋值,其中_MainTex代表主摄像机原始渲染结果,_OcclusionTex是表示包含了被遮挡部分信息的RenterTexture,_StretchTex表示被遮挡区域横纵拉伸一像素后的图像信息.
23~27行,和上一个Shader类似,取出这三个Texture在当前uv所对应的颜色信息.并将后两者的rgb值各自进行累加.
29~36行,在if判断中,如果当前像素的uv值在被遮挡区域_OcclusionTex中没有颜色(即occlusionTotal <0.01f),而在拉伸区域_StretchTex中有颜色值(即stretchTotal>0.01f)其实也就说明这个像素是在拉伸操作中被额外拉伸出来的那一像素.那么就把这个像素颜色设置为描边颜色,否则设置为图像正常渲染颜色。
总算是写完了足足4个多小时,这篇下来确实比较长而且并没有逐行的解释说明,一方面我认为大家现在的水平已经不需要逐行解释了,而来也是觉得没必要事无巨细面面俱到,这样子反而写的很罗嗦,虽然现在这样子也挺啰嗦的.如果大家一次没有看明白,希望多看几遍,主要是理解C#脚本和Shader如何协作,以及多Shader的应用和后期处理的基本流程.这篇教程里面有几处地方我本人也并不是特别的清楚,所以只能是按照我自己的理解来进行了说明,希望大家在看的时候能够自己认真思考思考,我说的并不一定对,如果你发现了笔者哪里有错误,请在留言中指出。如果你希望通过下篇教程讲解哪方面的知识,也可以留言告诉我。希望大家有所收获。就写到这吧,我要下楼去转转了,坐的太久了.
尊重他人智慧成果,欢迎转载,请注明作者esfog,原文地址http://www.cnblogs.com/Esfog/p/CoverOutline_Shader_Code.html