1 前言
描邊的難點在于如何檢測和識別邊緣,當前實現(xiàn)描邊特效的方法主要有以下幾種:
1)基于頂點膨脹的描邊方法
在 SubShader 中開 2 個 Pass 渲染通道,第一個 Pass 通道渲染膨脹的頂點,即將頂點坐標沿著法線方向向外擴展,并使用純色給擴展后的頂點著色,第二個 Pass 通道渲染原頂點,并覆蓋第一個 Pass 通道渲染的內(nèi)部。
該方案實現(xiàn)簡單,算法效率高,但是對于拐角較大的兩個面交界處(法線突變較大處),會出現(xiàn)描邊斷裂,并且描邊的寬度會受到透視投影影響。
基于模板測試和頂點膨脹的描邊方法 解決了描邊斷裂和描邊寬度受透視影響問題。
2)基于法線和視線的描邊方法
對于物體的任意一頂點,判斷視線向量(該點指向相機的向量)與該點法線向量的夾角(記為 θ)是否近似 90 度,如果近似 90 度,就將該點識別為邊緣,并進行邊緣著色。實際應用中,通常采用模糊描邊著色法,而不是閾值法,即將 sin(θ) 作為該點渲染描邊色的強度。
該方案屬于內(nèi)描邊,實現(xiàn)簡單,算法效率高,但是物體中間 θ 值近似 90 度的地方(凹陷處)也會被描邊。
3)基于屏幕紋理的描邊方法
對渲染后的屏幕紋理進行二次渲染,根據(jù)像素點周圍顏色的差異判斷是否是物體邊緣像素,如果是邊緣,需要重新進行邊緣著色。判斷邊緣的具體做法是:對該像素點周圍的像素點的亮度進行卷積運算,得到該點的梯度(反映該點附近亮度突變的強度),根據(jù)梯度閾值判斷該點是否是邊緣。
該方案屬于內(nèi)描邊,實現(xiàn)有一定難度,算法效率一般,算法依賴梯度閾值,并且受顏色、光照、陰影等影響較大如:地面的影子可能會被描邊。
案例見→邊緣檢測特效。
4)基于深度和法線紋理的描邊方法
對渲染后的屏幕紋理進行二次渲染,根據(jù)像素點周圍深度和法線的差異判斷是否是物體邊緣像素,如果是邊緣,需要重新進行邊緣著色。判斷邊緣的具體做法是:對該像素點周圍的像素點的深度和法線進行卷積運算,得到該點的梯度(反映該點附近深度和法線突變的強度),根據(jù)梯度閾值判斷該點是否是邊緣。
該方案屬于內(nèi)描邊,效果較好,實現(xiàn)較難,算法依賴梯度閾值。
案例見→基于深度和法線紋理的邊緣檢測方法。
5)基于模板紋理模糊膨脹的描邊方法
首先使用純色對選中的物體進行渲染,得到模板紋理,接著對模板紋理進行模糊處理,使模板顏色往外擴,得到模糊紋理,再根據(jù)模板紋理和模糊紋理對所有物體重新渲染,渲染規(guī)則:如果該像素點在模板紋理內(nèi)部,就渲染原色,如果在模板紋理外部,就根據(jù)模糊紋理的透明度判斷渲染原色還是模糊紋理色。
該方案屬于外描邊,效果較好,實現(xiàn)較難,但算法不依賴閾值。
本文代碼資源見→Unity3D選中物體描邊特效。
2 基本原理
本文采用基于模板紋理模糊膨脹的描邊方法,本節(jié)將通過圖文詳細介紹該算法的原理。
1)原圖

2)模板紋理

說明:清屏顏色為 (0, 0, 0, 0),后面會用到 。通過 Graphics.ExecuteCommandBuffer(commandBuffer) 對選中的物體進行渲染,得到模板紋理。
3)模糊紋理

說明:通過對模板紋理進行模糊處理, 使模板顏色向外擴展,得到模糊紋理,外擴的部分就是需要描邊的部分。
4)合成紋理

說明:根據(jù)模板紋理和模糊紋理對所有物體重新渲染,渲染規(guī)則:如果該像素點在模板紋理內(nèi)部,就渲染原色,如果在模板紋理外部,就根據(jù)模糊紋理的透明度判斷渲染原色還是模糊紋理色,如下:
// 由于模糊紋理的外部清屏顏色是(0, 0, 0, 0), blur.a=0, 因此模糊紋理的外部也會被渲染為原色
color.rgb = lerp(source.rgb, blur.rgb, blur.a); // lerp(a,b,x)=(1-x)*a+x*b
color.a = source.a;
5)描邊顏色和寬度漸變
描邊顏色由模板顏色決定,通過設置模板顏色隨時間變化,實現(xiàn)描邊顏色漸變,通過設置模板透明度隨時間變化,實現(xiàn)描邊在出現(xiàn)和消失,視覺上感覺描邊在擴大和縮小。
fixed4 frag(v2f i) : SV_Target // 片段著色器
{
float t1 = sin(_Time.z); // _Time = float4(t/20, t, t*2, t*3)
float t2 = cos(_Time.z);
// 描邊顏色隨時間變化, 描邊透明度隨時間變化, 視覺上感覺描邊在膨脹和收縮
return float4(t1 + 1, t2 + 1, 1 - t1, 1 - t2);
}
漸變模板圖如下:

漸變描邊效果如下:

6)缺陷
如果描邊的物體存在重疊,由于所有物體共一個模板紋理,將存在描邊消融現(xiàn)象。
模板紋理如下:

描邊消融如下:

可以看到,正方體、球體、膠囊體、圓柱體下方及人體都沒有描邊特效,因為它們在模板紋理的內(nèi)部,被消融掉了。
3 代碼實現(xiàn)
OutlineEffect.cs
using System;
using UnityEngine;
using UnityEngine.Rendering;
public class OutlineEffect : MonoBehaviour {
public static Action<CommandBuffer> renderEvent; // 渲染事件
public float offsetScale = 2; // 模糊處理像素偏移
public int iterate = 3; // 模糊處理迭代次數(shù)
public float outlineStrength = 3; // 描邊強度
private Material blurMaterial; // 模糊材質(zhì)
private Material compositeMaterial; // 合成材質(zhì)
private CommandBuffer commandBuffer; // 用于渲染模板紋理
private RenderTexture stencilTex; // 模板紋理
private RenderTexture blurTex; // 模糊紋理
private void Awake() {
blurMaterial = new Material(Shader.Find("Custom/Outline/Blur"));
compositeMaterial = new Material(Shader.Find("Custom/Outline/Composite"));
commandBuffer = new CommandBuffer();
}
private void OnRenderImage(RenderTexture source, RenderTexture destination) {
if (renderEvent != null) {
RenderStencil(); // 渲染模板紋理
RenderBlur(source.width, source.height); // 渲染模糊紋理
RenderComposite(source, destination); // 渲染合成紋理
} else {
Graphics.Blit(source, destination); // 保持原圖
}
}
private void RenderStencil() { // 渲染模板紋理
stencilTex = RenderTexture.GetTemporary(Screen.width, Screen.height, 0);
commandBuffer.SetRenderTarget(stencilTex);
commandBuffer.ClearRenderTarget(true, true, Color.clear); // 設置模板清屏顏色為(0,0,0,0)
renderEvent.Invoke(commandBuffer);
Graphics.ExecuteCommandBuffer(commandBuffer);
}
private void RenderBlur(int width, int height) { // 對模板紋理進行模糊化
blurTex = RenderTexture.GetTemporary(width, height, 0);
RenderTexture temp = RenderTexture.GetTemporary(width, height, 0);
blurMaterial.SetFloat("_OffsetScale", offsetScale);
Graphics.Blit(stencilTex, blurTex, blurMaterial);
for (int i = 0; i < iterate; i ++) {
Graphics.Blit(blurTex, temp, blurMaterial);
Graphics.Blit(temp, blurTex, blurMaterial);
}
RenderTexture.ReleaseTemporary(temp);
}
private void RenderComposite(RenderTexture source, RenderTexture destination) { // 渲染合成紋理
compositeMaterial.SetTexture("_MainTex", source);
compositeMaterial.SetTexture("_StencilTex", stencilTex);
compositeMaterial.SetTexture("_BlurTex", blurTex);
compositeMaterial.SetFloat("_OutlineStrength", outlineStrength);
Graphics.Blit(source, destination, compositeMaterial);
RenderTexture.ReleaseTemporary(stencilTex);
RenderTexture.ReleaseTemporary(blurTex);
stencilTex = null;
blurTex = null;
}
}
說明: OnRenderImage 方法是MonoBehaviour的生命周期方法,在所有的渲染完成后由 MonoBehavior 自動調(diào)用,該方法依賴相機組件,由于 OnRenderImage 在渲染后調(diào)用,因此被稱為后處理操作,它是 Unity3D 特效的重要理論分支;Graphics.Blit(source, dest, material) 用于將 source 紋理按照 material 材質(zhì)重新渲染到 dest;CommandBuffer 攜帶一系列的渲染命令,依賴相機,用來拓展渲染管線的渲染效果;OutlineEffect 腳本組件必須掛在相機上。
OutlineObject.cs
using UnityEngine;
using UnityEngine.Rendering;
public class OutlineObject : MonoBehaviour {
private Material stencilMaterial; // 模板材質(zhì)
private void Awake() {
stencilMaterial = new Material(Shader.Find("Custom/Outline/Stencil"));
}
private void OnEnable() {
OutlineEffect.renderEvent += OnRenderEvent;
// _StartTime用于控制每個選中的對象顏色漸變不同步
stencilMaterial.SetFloat("_StartTime", Time.timeSinceLevelLoad * 2);
}
private void OnDisable() {
OutlineEffect.renderEvent -= OnRenderEvent;
}
private void OnRenderEvent(CommandBuffer commandBuffer) {
Renderer[] renderers = gameObject.GetComponentsInChildren<Renderer>();
foreach (Renderer r in renderers) {
commandBuffer.DrawRenderer(r, stencilMaterial); // 將renderer和material提交到主camera的commandbuffer列表進行渲染
}
}
}
說明:被選中的物體將會添加 OutlineObject 腳本組件,用于渲染選中對象的模板紋理,每個選中對象獨立持有 stencilMaterial,互不干擾,描邊的漸變相位(由_StartTime控制)可以由選中對象獨立控制,這樣每個模板的顏色就可以獨立控制,從而實現(xiàn)每個選中對象描邊各異的效果。
SelectController.cs
using System.Collections.Generic;
using UnityEngine;
public class SelectController : MonoBehaviour {
private List<GameObject> targets; // 選中的游戲?qū)ο? private List<GameObject> loseFocus; // 失焦的游戲?qū)ο? private RaycastHit hit; // 碰撞信息
private void Start() {
Camera.main.gameObject.AddComponent<OutlineEffect>();
targets = new List<GameObject>();
loseFocus = new List<GameObject>();
hit = new RaycastHit();
}
private void Update() {
if (Input.GetMouseButtonUp(0)) {
GameObject hitObj = GetHitObj();
if (hitObj == null) { // 未選中任何物體, 已描邊的全部取消描邊
targets.ForEach(obj => loseFocus.Add(obj));
targets.Clear();
}
else if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) {
if (targets.Contains(hitObj)) { // Ctrl重復選中, 取消描邊
loseFocus.Add(hitObj);
targets.Remove(hitObj);
} else { // Ctrl追加描邊
targets.Add(hitObj);
}
} else { // 單選描邊
targets.ForEach(obj => loseFocus.Add(obj));
targets.Clear();
targets.Add(hitObj);
loseFocus.Remove(hitObj);
}
DrawOutline();
}
}
private void DrawOutline() { // 描邊
targets.ForEach(obj => {
if (obj.GetComponent<OutlineObject>() == null) {
obj.AddComponent<OutlineObject>();
} else {
obj.GetComponent<OutlineObject>().enabled = true;
}
});
loseFocus.ForEach(obj => {
if (obj.GetComponent<OutlineObject>() != null) {
obj.GetComponent<OutlineObject>().enabled = false;
}
});
loseFocus.Clear();
}
private GameObject GetHitObj() { // 獲取屏幕射線碰撞的物體
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out hit)) {
return hit.collider.gameObject;
}
return null;
}
}
說明:通過單擊物體,給選中的物體添加 OutlineObject 腳本組件,再由 OutlineEffect 控制描邊。按住 Ctrl 鍵再單擊物體會追加選中,如果重復選中,會取消描邊。
StencilShader.shader
Shader "Custom/Outline/Stencil"
{
Properties
{
_StartTime ("startTime", Float) = 0 // _StartTime用于控制每個選中的對象顏色漸變不同步
}
SubShader
{
Pass
{
CGPROGRAM // CG語言的開始
// 編譯指令 著色器名稱 函數(shù)名稱
#pragma vertex vert // 頂點著色器, 每個頂點執(zhí)行一次
#pragma fragment frag // 片段著色器, 每個像素執(zhí)行一次
#pragma fragmentoption ARB_precision_hint_fastest // fragment使用最低精度, fp16, 提高性能和速度
// 導入頭文件
#include "UnityCG.cginc"
float _StartTime;
struct a2v // 頂點函數(shù)輸入結(jié)構(gòu)體
{
float4 vertex: POSITION; // 頂點坐標
};
struct v2f // 頂點函數(shù)輸出結(jié)構(gòu)體
{
float4 pos : SV_POSITION;
};
v2f vert(a2v v) // 頂點著色器
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target // 片段著色器
{
float t1 = sin(_Time.z - _StartTime); // _Time = float4(t/20, t, t*2, t*3)
float t2 = cos(_Time.z - _StartTime);
// 描邊顏色隨時間變化, 描邊透明度隨時間變化, 視覺上感覺描邊在膨脹和收縮
return float4(t1 + 1, t2 + 1, 1 - t1, 1 - t2);
}
ENDCG // CG語言的結(jié)束
}
}
FallBack off
}
說明: StencilShader 用于渲染模板紋理,并且其顏色和透明度隨時間變化,實現(xiàn)描邊顏色漸變、寬度在膨脹和收縮效果。_StartTime 用于控制時間偏移,由物體被選中的時間決定,每個物體被選中的時間不一樣,因此選中物體模板顏色各異,描邊顏色也各異。
BlurShader.shader
Shader "Custom/Outline/Blur"
{
Properties
{
_MainTex ("stencil", 2D) = "" {}
_OffsetScale ("offsetScale", Range (0.1, 3)) = 2 // 模糊采樣偏移
}
SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off
Lighting Off
Fog { Mode Off }
CGPROGRAM // CG語言的開始
#pragma vertex vert // 頂點著色器, 每個頂點執(zhí)行一次
#pragma fragment frag // 片段著色器, 每個像素執(zhí)行一次
#pragma fragmentoption ARB_precision_hint_fastest // fragment使用最低精度, fp16, 提高性能和速度
#include "UnityCG.cginc"
sampler2D _MainTex;
half _OffsetScale;
half4 _MainTex_TexelSize; //_MainTex的像素尺寸大小, float4(1/width, 1/height, width, height)
struct a2v // 頂點函數(shù)輸入結(jié)構(gòu)體
{
float4 vertex: POSITION;
half2 texcoord: TEXCOORD0;
};
struct v2f // 頂點函數(shù)輸出結(jié)構(gòu)體
{
float4 pos : POSITION;
half2 uv[4] : TEXCOORD0;
};
v2f vert(a2v v) // 頂點著色器
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 offs = _MainTex_TexelSize.xy * _OffsetScale;
// uv坐標向四周擴散
o.uv[0].x = v.texcoord.x - offs.x;
o.uv[0].y = v.texcoord.y - offs.y;
o.uv[1].x = v.texcoord.x + offs.x;
o.uv[1].y = v.texcoord.y - offs.y;
o.uv[2].x = v.texcoord.x + offs.x;
o.uv[2].y = v.texcoord.y + offs.y;
o.uv[3].x = v.texcoord.x - offs.x;
o.uv[3].y = v.texcoord.y + offs.y;
return o;
}
fixed4 frag(v2f i) : COLOR // 片段著色器
{
fixed4 color1 = tex2D(_MainTex, i.uv[0]);
fixed4 color2 = tex2D(_MainTex, i.uv[1]);
fixed4 color3 = tex2D(_MainTex, i.uv[2]);
fixed4 color4 = tex2D(_MainTex, i.uv[3]);
fixed4 color;
// max: 2個向量中每個分量都取較大者, 這里通過max函數(shù)將模板的邊緣向外擴, rgb=stencil.rgb
color.rgb = max(color1.rgb, color2.rgb);
color.rgb = max(color.rgb, color3.rgb);
color.rgb = max(color.rgb, color4.rgb);
color.a = (color1.a + color2.a + color3.a + color4.a) / 4; // 透明度向外逐漸減小
return color;
}
ENDCG // CG語言的結(jié)束
}
}
Fallback off
}
說明: BlurShader 用于渲染模糊紋理,通過對模板紋理模糊化處理,實現(xiàn)模板顏色外擴,外擴的部分就是需要描邊的部分。
CompositeShader.shader
Shader "Custom/Outline/Composite"
{
Properties
{
_MainTex ("source", 2D) = "" {}
_StencilTex ("stencil", 2D) = "" {}
_BlurTex ("blur", 2D) = "" {}
_OutlineStrength ("OutlineStrength", Range(1, 5)) = 3
}
SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off
Lighting Off
Fog { Mode off }
CGPROGRAM // CG語言的開始
#pragma vertex vert // 頂點著色器, 每個頂點執(zhí)行一次
#pragma fragment frag // 片段著色器, 每個像素執(zhí)行一次
#pragma fragmentoption ARB_precision_hint_fastest // fragment使用最低精度, fp16, 提高性能和速度
#include "UnityCG.cginc"
sampler2D _MainTex;
sampler2D _StencilTex;
sampler2D _BlurTex;
float _OutlineStrength;
float4 _MainTex_TexelSize; //_MainTex的像素尺寸大小, float4(1/width, 1/height, width, height)
struct a2v // 頂點函數(shù)輸入結(jié)構(gòu)體
{
float4 vertex: POSITION;
half2 texcoord: TEXCOORD0;
};
struct v2f // 頂點函數(shù)輸出結(jié)構(gòu)體
{
float4 pos : POSITION;
half2 uv : TEXCOORD0;
};
v2f vert(a2v v) // 頂點著色器
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
if (_MainTex_TexelSize.y < 0)
o.uv.y = 1 - o.uv.y; // 在Direct3D平臺下, 如果我們開啟了抗鋸齒, 則_MainTex_TexelSize.y 會變成負值
return o;
}
fixed4 frag(v2f i) : COLOR // 片段著色器
{
fixed4 source = tex2D(_MainTex, i.uv);
fixed4 stencil = tex2D(_StencilTex, i.uv);
if (any(stencil.rgb))
{ // 繪制選中物體
return source;
}
else
{ // 繪制選中物體以外的圖像
fixed4 blur = tex2D(_BlurTex, i.uv);
fixed4 color;
color.rgb = lerp(source.rgb, blur.rgb * _OutlineStrength, saturate(blur.a - stencil.a));
color.a = source.a;
return color;
}
}
ENDCG // CG語言的結(jié)束
}
}
Fallback Off
}
說明: CompositeShader 用于渲染合成紋理,根據(jù)模板紋理和模糊紋理對所有物體重新渲染,渲染規(guī)則:如果該像素點在模板紋理內(nèi)部,就渲染原色,如果在模板紋理外部,就根據(jù)模糊紋理的透明度判斷渲染原色還是模糊紋理色。
4 運行效果
單擊物體選中描邊,按住 Ctrl 鍵單擊物體追加選中描邊,單擊地面或空白地方所有已描邊物體取消描邊(刪除了地面的碰撞體組件)。

5 拓展
HighlightingSystem 插件也實現(xiàn)了基于模板紋理模糊膨脹的描邊方法,插件資源在Unity3D選中物體描邊特效的【Assets\Plugins\HighlightingSystem】目錄下。
該插件與本文的區(qū)別在于模板紋理的渲染方式不同,本文通過 CommandBuffer 渲染模板紋理,HighlightingSystem 通過第二個相機渲染模板紋理(camera.Render() 方法),具體操作如下:在 HighlightingEffect 腳本組件的 OnPreRender 方法中,通過 Copy 主相機新生成一個相機,并將其 cullingMask 設置為 highlightingLayer(值為7),用于渲染圖層為 7 的物體的模板紋理,被添加 HighlightableObject 腳本組件的物體在渲染模板紋理時其圖層將會被臨時更改為 7,渲染結(jié)束后又恢復原圖層。
該插件存在一個缺陷,7 號圖層需要預留出來,否則 7 號圖層的物體周圍將存在一個模糊的灰色邊緣。
HighlightingSystem 插件的使用方法如下:將 HighlightingEffect 腳本組件掛在相機下,給需要描邊的物體添加 SpectrumController 腳本組件。運行時,已添加 SpectrumController 腳本組件的物體將會被自動添加 HighlightableObject 腳本組件。
6 推薦閱讀
聲明:本文轉(zhuǎn)自[【Unity3D】選中物體描邊特效