1. 程式人生 > >模仿NGUI實現SoftClip(一)

模仿NGUI實現SoftClip(一)

用過NGUI的童鞋都知道UIPanel可以設定一個矩形的Clip區域,它下轄的UIWidget都只能在Clip區域內顯示。今天我就模仿UIPanel實現類似的Clip功能,讓一個3D面片只能在我所指定的矩形區域內顯示。相信看完這篇文章,UIPanel的Clip原理也就不再神祕了。

為了讓難度循序漸進,我們先實現HardClip(邊緣沒有Alpha過渡,直接硬切)。

實現原理:用指令碼算出顯示區域ClipArea的大小和位置,把計算結果通過Material傳給Shader,讓Shader在顯示時做判斷,如果該畫素在ClipArea外就讓其Alpha為0,達到裁切的目的。

  準備1:開啟一個Unity專案,自己找一張圖片放到專案裡用於顯示。

  準備2:建立一個Unlit Shader取名為Clip,再建立一個Material也取名Clip並選擇“Unlit/Clip”這個Shader,然後把圖片拖到Texture屬性裡。

  準備3:在場景中新增一個叫做ClipPanel的空物體(用於配置Clip),再新增一個叫做ClipDrawer的Quad物體並把Clip.Material拖上去(用於顯示)。

準備工作做好後,先建立一個指令碼ClipPanel.cs並拖到ClipPanel上,有了該指令碼的4個變數,我們就能確定顯示區域的大小位置了。指令碼內容如下

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ClipPanel : MonoBehaviour
{
	public float clipWidth = 4f; // 顯示區域寬度
	public float clipHeight = 3f; // 顯示區域高度

	public float offsetHor = 0; // 顯示區域水平偏移
	public float offsetVer = 0; // 顯示區域垂直偏移

	// 在Scene視窗中繪製一個白框用來定位顯示區域
	private void OnDrawGizmos()
	{
		Vector3 panelPointLB = new Vector3(offsetHor - clipWidth * 0.5f, offsetVer - clipHeight * 0.5f);
		Vector3 panelPointRT = new Vector3(offsetHor + clipWidth * 0.5f, offsetVer + clipHeight * 0.5f);

		Vector3 worldPointLB = transform.TransformPoint(panelPointLB);
		Vector3 worldPointRT = transform.TransformPoint(panelPointRT);
		Vector3 worldPointLT = new Vector3(worldPointLB.x, worldPointRT.y, worldPointRT.z);
		Vector3 worldPointRB = new Vector3(worldPointRT.x, worldPointLB.y, worldPointLB.z);

		Gizmos.DrawLine(worldPointLB, worldPointLT);
		Gizmos.DrawLine(worldPointRB, worldPointRT);
		Gizmos.DrawLine(worldPointLB, worldPointRB);
		Gizmos.DrawLine(worldPointLT, worldPointRT);
	}
}

再建立一個指令碼ClipDrawer.cs並拖到ClipDrawer上,然後把ClipPanel物體拖到其panel屬性裡。這個指令碼負責收集ClipPanel的資料然後傳遞給Shader。指令碼內容如下

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ClipDrawer : MonoBehaviour
{
	public ClipPanel panel;

	private void OnWillRenderObject()
	{
		if (panel != null)
		{
			// 從panel裡取得裁切視窗資料,轉化為視窗的左下角、右上角兩個座標點(基於panel的本地座標)
			Vector3 panelPointLB = new Vector3(panel.offsetHor - panel.clipWidth * 0.5f, panel.offsetVer - panel.clipHeight * 0.5f);
			Vector3 panelPointRT = new Vector3(panel.offsetHor + panel.clipWidth * 0.5f, panel.offsetVer + panel.clipHeight * 0.5f);
			// 把這兩個點轉化為世界座標點
			Vector3 worldPointLB = panel.transform.TransformPoint(panelPointLB);
			Vector3 worldPointRT = panel.transform.TransformPoint(panelPointRT);
			// 把這兩個點轉化為ClipDrawer的本地座標點
			Vector3 localPointLB = transform.InverseTransformPoint(worldPointLB);
			Vector3 localPointRT = transform.InverseTransformPoint(worldPointRT);
			// 恢復為視窗尺寸和偏移資料
			Vector2 localSize = new Vector2(localPointRT.x - localPointLB.x, localPointRT.y - localPointLB.y);
			Vector2 localOffset = (localPointLB + localPointRT) * 0.5f;
			// 合併資料到一個Vector4中並等待發送
			Vector4 clipRange = new Vector4(localSize.x, localSize.y, localOffset.x, localOffset.y);

			Renderer r = GetComponent<Renderer>();
			Material mat = r.materials[0];
			// 把資料傳送給Shader,一共4條資料:視窗寬度、視窗高度、視窗水平偏移、視窗垂直偏移(注意這些資料都基於本地座標)
			mat.SetVector("_ClipRange", clipRange);
		}
	}
}

這個指令碼每幀都會把最新的裁切視窗資料傳送給自己的Material,供Shader使用。下面到了最關鍵的Shader部分,如果你對Shader一無所知,我推薦你買本《Unity Shader入門精要》學習一下。這裡假定你已經對Shader有一定基礎。下面開啟Clip.shader並修改為如下內容

Shader "Unlit/Clip"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_ClipRange("ClipRange", Vector) = (0, 0, 0, 0)
	}
	SubShader
	{
		// 因為需要操控alpha,所以需要設定渲染型別為透明
		Tags { "RenderType"="Transparent" "Queue"="Transparent" }

		Pass
		{
			// 因為是透明Shader,需要關閉深度寫入並開啟alpha混合
			ZWrite Off
			Blend SrcAlpha OneMinusSrcAlpha

			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
				// 該二維陣列記錄該點的xy座標與顯示區域的關係
				float2 relation : TEXCOORD1;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;
			float4 _ClipRange = float4(1.0, 1.0, 0.0, 0.0);
			
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);

				// 將頂點的本地座標值和視窗尺寸相除,如果點在視窗內,結果會落在(-1,1)區間內。這行程式碼一定要理解
				o.relation = (v.vertex.xy - _ClipRange.zw) / (_ClipRange.xy * 0.5);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 col = tex2D(_MainTex, i.uv);
				// 如果畫素點的relation值不在[-1, 1]區間內,就把輸出顏色的alpha改成0
				if (i.relation.x < -1 || i.relation.x > 1 || i.relation.y < -1 || i.relation.y > 1)
				{
					col.a = 0;
				}
				return col;
			}
			ENDCG
		}
	}
}

Shader裡的程式碼很短,理解了座標轉區間那行程式碼就可以了。

然後執行場景,調整ClipDrawer和ClipPanel的位置,就能看到圖片被裁剪的效果,效果如下圖所示

上面的Shader雖然有效但很low,在Shader裡分支語句會影響效能,所以if..else..能避免就避免。下面則提供一個高階點的不帶if的frag函式

fixed4 frag (v2f i) : SV_Target
{
	// 處理relation,處理後的結果中,大於0表示點在視窗內,否則點在視窗外
	float2 relation1 = float2(1, 1) - abs(i.relation.xy);
	// 不需要分別檢查x和y兩個座標,選擇其中較小的值做檢查即可
	float relation2 = min(relation1.x, relation1.y);
	// 向上取整,大於0的整數表示視窗內的點,小於1的整數表示點在視窗外
	float relation3 = ceil(relation2);
	// 把資料約束到[0, 1]範圍內,此時的結果只可能是0或者1
	float relation4 = clamp(relation3, 0, 1);

	// sample the texture
	fixed4 col = tex2D(_MainTex, i.uv);
	// 把輸出顏色乘以relation4,得到想要的結果
	col.a *= relation4;
	return col;
}

我們用cg語言自帶的各種數學函式來操作資料,最後達到了和if語句相同的結果。

下文我會新增一個帶alpha過渡的Soft Clip版本shader,敬請期待。