用过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,敬请期待。