网上关于自制UIMask的博客也不少了
丢掉Mask遮罩,更好的圆形Image组件[Unity]
这里在自学了一部分之后详细记录一下
圆形遮罩的原理 以及 在自制mask里面实现精确点击的原理 以及 部分的编辑器知识
目前的UGUI有mask组件,但是存在一些缺点:
- drawCall高,在一些CPU与GPU交互速率比较低的机器上,drawcall高是造成卡顿的一个主要因素
- 边界锯齿感强 明显粗糙不好看
- 点击不准确
自制UIMask涉及到的知识属于稍微底层了
代码的看通需要读者对渲染知识了解
图像的成型需要顶点和像素颜色的辅助,顶点简单来说就是帮助形成一个形状的各个端点,像素颜色是顶点内部每个像素点需要表达的颜色
圆形可以由若干个相同的以圆心为顶点的等腰三角面片组成正多边形,近似模拟出来。三角面片分得多了,多边形的边越多,夹角越大,就越近似圆形。
在比较多的时候 要实现一些看似比较复杂比较难上手的UI任务的时候
可以查看unity的UGUI 源码 ,unity对UGUI开源
Image的主要原理
上图大约的描述了 圆形遮罩的 生成过程
图中的0代表了中心顶点
举例 顶点2 的横坐标与圆心的差距x1是通过半径乘以角a的余弦值得到
纵坐标与圆心的差距y1通过半径乘以角a的正弦值得到,其他的顶点是类似的
(x1 + 0的x坐标, y1 + 0的y坐标)描述了顶点的坐标
全部顶点的生成顺序是按图中的1 2 3 4 这样逆时针生成的
0 1 2 三个顶点 组成了一个三角面片
但是 组合三角面片的顺序是 1 0 2 这样的一个方向
在图中按照这个方向连线可以发现是顺时针方向
如果降低周围顶点数量 就可以从圆形降低到多边形
顶点生成的顺序是按照阿拉伯数字递增来生成的 即图中的逆时针
然后加入到顶点数组的顺序也是按照这个来放置的
后面生成三角面的时候 传入AddTriangle的顶点顺序在图中是按照顺时针的
即1 0 2 和 2 0 3 这样
这是根据了unity的成像原理,生成三角面传入的顶点顺序在屏幕上看需要是
顺时针才会进行渲染 否则 会被引擎认为是背面 (模型内部往外看就是背面)
一般来说 背面一般不会进行渲染
如果一个方形的image里面 放了个内切圆
则如果没有点到圆的部分的话 unity引擎里面是会依然识别到点击的
所以要自己去弄精确点击的部分
精确点击的原理
一个点向右发出射线 因为任何自定义形状都是闭合的
所以如果穿过自定义形状的边的次数是偶数(偶数包括0)
则这个点一定在外边,否则 就是在里面了
下面的脚本挂载在UI组件下 同时再挂载一个button组件即可
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Sprites;
using UnityEngine.UI;
public class CircleImage : Image
{
//图片被分成的总片数
[SerializeField]
protected int totalSegments = 100;
//显示部分占圆形的百分比.
[SerializeField]
private float cdOutPercent = 1;
private readonly Color32 GRAY_COLOR = new Color32(60, 60, 60, 255);
private List<Vector3> imgVertexPosList;
/// <summary>
/// 这个函数是image用于处理图像顶点的
/// 重写这个方法可以定义顶点的处理过程
/// 处理后的顶点要传回给VertexHelper
/// 在c#里面 是不用加ref 和out 也能在被调用函数里面改变传进来的参数
/// 使得调用函数的值不一样 具体百度
/// </summary>
/// <param name="vh"></param>
protected override void OnPopulateMesh(VertexHelper vh)
{
//这里清除意义是不用unity内定的图片渲染顶点数据
vh.Clear();
imgVertexPosList = new List<Vector3>();
GenVertex(vh, totalSegments);
AddTriangle(vh, totalSegments);
}
private void GenVertex(VertexHelper vh, int segements)
{
float width = rectTransform.rect.width;
float heigth = rectTransform.rect.height;
int cdOutSegments = (int)(segements * cdOutPercent);
//这里使用overrideSprite 因为使用Sprite的话 即使有赋值 有时会为空
//DataUtility.GetOuterUV函数是获得图片的uv信息
Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
Debug.LogError("uv.x " + uv.x + " uv.y " + uv.y + " uv.z " + uv.z + " uv.w " + uv.w);
//(uv.x,uv.y)是image所用的sprite左下角在uv坐标系的坐标
//(uv.z,uv.w)是image所用的sprite右上角在uv坐标系的坐标
//实际上在一张图片的uv坐标系中 横竖坐标值的范围都是0-1
//所以 x和y一般是0 z和w一般是1
float uvWidth = uv.z - uv.x;
float uvHeight = uv.w - uv.y;
//uvCenter表示的是image所用的sprite的中心点 在uv坐标系中的坐标 一般恒定是(0.5,0.5)
Vector2 uvCenter = new Vector2(uvWidth * 0.5f - uv.x, uvHeight * 0.5f - uv.y);
//uv尺寸与图片尺寸的转换比例
//这个比例得出的根据是 一张图片上所有的顶点都均匀对到uv上的所有位置
Vector2 uvImgConvertRatio = new Vector2(uvWidth / width, uvHeight / heigth);
//每一份片段的弧度 360度等于2Π
float perSegRadian = (2 * Mathf.PI) / segements;
float radius = width * 0.5f;
//顶点生成的根据点,也可以看成图片中心
//在unity的ui顶点坐标赋值体系中
//如果一个UI顶点坐标赋值是0向量,
//则这个顶点的位置是在这个组件的pivot位置
//下面的处理 实际上就是让顶点生成位置不受组件的pivot影响 保持在中心
//假设rectTransform.pivot是零向量,即pivot在左下角
//则vertexGenCenter位置刚好在pivot的右边距离pivot刚好组件宽度一半的距离
//以及在pivot的上边距离pivot刚好组件高度一半的距离
//即vertexGenCenter是在组件中心 其他类推
Vector2 vertexGenCenter = new Vector2((0.5f - rectTransform.pivot.x) * width, (0.5f - rectTransform.pivot.y) * heigth);
Vector2 vertPos = Vector2.zero;
Color32 colorTemp = GetCenterColor();
//中间的顶点的对应uv刚好是正中间 所以第三个参数是零向量
UIVertex centerV = GetUIVertex(colorTemp, vertexGenCenter, Vector2.zero, uvCenter, uvImgConvertRatio);
//所有要生成的顶点都要加进去 给unity使用
vh.AddVert(centerV);
int outCDVertexCount = cdOutSegments == 0 ? 0 : cdOutSegments + 1;
//当前顶点与中心水平向右的直线的弧度夹角
float curVertexRadian = 0;
Vector2 eachVertPosRelativetoGenCenter = Vector2.zero;
//这里面生成的顶点是图形外围的顶点
//生成的顶点数是分成片段数量加一
//其实最后一个周围顶点和第一个周围顶点是重合的
//为什么不最后一个顶点直接与第一个顶点直接相连呢?
//举个例子
//如果生成一个100边形
//这时要求cd区域为99%
//如果不采取重合的方式
//则第一个周围顶点和最后一个周围顶点都设置成黑色
//就是说只要cdOutPercent低于0.01 则直接显示整个黑色了
//这部分细节是不太好的
for (int i = 0; i < segements + 1; i++)
{
//想象成在直角三角形里面 根据正弦和余弦定理以及斜边求得 两个直角边的长度 即x和y
float x = Mathf.Cos(curVertexRadian) * radius;
float y = Mathf.Sin(curVertexRadian) * radius;
curVertexRadian += perSegRadian;
Debug.LogError(" CircleImage GenVertex outCDVertexCount " + outCDVertexCount);
if (i <= outCDVertexCount - 1)
{
//采用顶点在Texture根据uv采集的颜色
colorTemp = color;
}
else
{
colorTemp = GRAY_COLOR;
}
//每个顶点相对于中心的偏移位置
eachVertPosRelativetoGenCenter = new Vector2(x, y);
UIVertex vertexTemp = GetUIVertex(colorTemp, eachVertPosRelativetoGenCenter + vertexGenCenter, eachVertPosRelativetoGenCenter, uvCenter, uvImgConvertRatio);
vh.AddVert(vertexTemp);
imgVertexPosList.Add(eachVertPosRelativetoGenCenter + vertexGenCenter);
}
}
/// <summary>
/// 返回的颜色亮度与cdOutPercent成正比
/// 首先解释一下unity里面的成像原理
/// 每个顶点之间的颜色使用插值过渡的形式实现 这就是说
/// 如果要把一片区域弄成黑色 则里面的每个生成的顶点的颜色都是黑色
/// 这也是GetCenterColor里面计算要乘上cdOutPercent的原因
/// 在cdOutPercent为0的时候 返回的值就是GRAY_COLOR
/// 在cdOutPercent为1的时候 返回的值就是白色了
/// </summary>
/// <returns></returns>
private Color32 GetCenterColor()
{
Color32 colorTemp = (Color.white - GRAY_COLOR) * cdOutPercent;
return new Color32(
(byte) (GRAY_COLOR.r + colorTemp.r),
(byte) (GRAY_COLOR.g + colorTemp.g),
(byte) (GRAY_COLOR.b + colorTemp.b),
255);
}
private void AddTriangle(VertexHelper vh, int realSegements)
{
int id = 1;
for (int i = 0; i < realSegements; i++)
{
//unity的成像原理就是如果一个三角面片的三个顶点
//在渲染的时候是在屏幕上看过去的时候 如果是逆时针传入 则属于反向的渲染
//GPU在反向渲染时候是不会进行这个三角面片的渲染的
//只有前向渲染才会正常渲染
//所以AddTriangle里面的三个值 在屏幕看过去的时候 是顺时针顺序传入的
vh.AddTriangle(id, 0, id + 1);
id++;
}
}
private UIVertex GetUIVertex(Color32 col, Vector3 vertAbsolutePos, Vector2 vertDeltaPos, Vector2 uvCenter, Vector2 uvImgRatio)
{
UIVertex vertexTemp = new UIVertex();
vertexTemp.color = col;
vertexTemp.position = vertAbsolutePos;
//vertDeltaPos是顶点坐标系中 这个顶点与中心顶点的偏移值
//uvImgRatio是顶点坐标系的值转换到uv坐标系的值的参数
//vertDeltaPos.x * uvImgRatio.x 得到的是在uv坐标系上 这个顶点对应的uv与中心uv 的偏移值
vertexTemp.uv0 = new Vector2(vertDeltaPos.x * uvImgRatio.x + uvCenter.x, vertDeltaPos.y * uvImgRatio.y + uvCenter.y);
return vertexTemp;
}
/// <summary>
/// 在unity 的image 系统里面
/// 这个方法 用于判断这次点击对于image来说是否作为有效点击
/// </summary>
/// <param name="screenPoint">点击的位置</param>
/// <param name="eventCamera">事件相机</param>
/// <returns></returns>
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
Vector2 localPoint;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out localPoint);
return IsValid(localPoint);
}
/// <summary>
/// 被点击的点 向右发出的射线 不管经过什么图形
/// 如果射线碰到的边界次数为奇数
/// 则这个点一定在图形内部
/// 否则是在图形外部
/// </summary>
/// <param name="localPoint"></param>
/// <returns></returns>
private bool IsValid(Vector2 localPoint)
{
return GetCrossPointNum(localPoint, imgVertexPosList) %2 == 1;
}
private int GetCrossPointNum(Vector2 clickPointLocal, List<Vector3> imgVertexPosList)
{
int count = 0;
Vector3 vert1 = Vector3.zero;
Vector3 vert2 = Vector3.zero;
int vertCount = imgVertexPosList.Count;
for (int i = 0; i < vertCount; i++)
{
vert1 = imgVertexPosList[i];
vert2 = imgVertexPosList[(i + 1)% vertCount]; // 防止i遍历到最后的时候 越界
if (IsYInRange(clickPointLocal, vert1, vert2))
{
//将点击点的位置的y值代入 顶点vert1 和 vert2 所在的直线求出x1
//如果点击点的位置的x值小于x1 则计数加一
if (clickPointLocal.x < GetX(vert1, vert2, clickPointLocal.y))
{
count++;
}
}
}
return count;
}
/// <summary>
/// localPoint的高度位于顶点vert1和vert2之间的才纳入考虑范围
/// </summary>
/// <param name="localPoint"></param>
/// <param name="vert1"></param>
/// <param name="vert2"></param>
/// <returns></returns>
private bool IsYInRange(Vector2 localPoint, Vector3 vert1, Vector3 vert2)
{
if (vert1.y > vert2.y)
{
return localPoint.y < vert1.y && localPoint.y > vert2.y;
}
else
{
return localPoint.y < vert2.y && localPoint.y > vert1.y;
}
}
/// <summary>
/// 根据一元一次方程公式
/// y = kx + b 改进而成
/// 斜率有个公式是 k = (y2 - y1)/(x2 - x1)
/// </summary>
/// <param name="vert1"></param>
/// <param name="vert2"></param>
/// <param name="y"></param>
/// <returns></returns>
private float GetX(Vector3 vert1, Vector3 vert2, float y)
{
float k = (vert1.y - vert2.y)/(vert1.x - vert2.x);
//这里求出k 之后 改动斜率公式
//(y2 - y1) / (x2 - x1) 得出 要求的y
return vert1.x + (y - vert1.y)/k;
}
}
继承自image 而不是monobehaviour的 脚本 挂载是不能显示自定义参数的
所以还要另外写编辑器脚本
下面的脚本放在Assets/Editor下
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
//字面意思是告诉unity的editor 对一个类进行inspector展示,第二个参数表示的意思
//是否对其子类也进行同样操作 通常在某个继承Editor的类前面声明
//表示这个继承Editor类的类操作的是CircleImage类
[CustomEditor(typeof(CircleImage), true)]
//当多选挂载CircleImage的脚本的物体的时候
//inspector是否支持显示和编辑 没声明这个属性的话 多选的时候
//inspector面板会不 支持显示和编辑 对应的脚本
[CanEditMultipleObjects]
//编辑器类 直接或间接继承基类Editor
//因为CircleImage继承自image 所以这个脚本继承自IamgeEditor比较好
public class CircleImageEditor : UnityEditor.UI.ImageEditor
{
//要序列化的属性 使用SerializedProperty声明
SerializedProperty _fillPercent;
SerializedProperty _segements;
protected override void OnEnable()
{
base.OnEnable();
//serializedObject表示当前显示在inspector面板里面的对象
//参数字符串要和CircleImage里面的某个变量名字一样
_fillPercent = serializedObject.FindProperty("cdOutPercent");
_segements = serializedObject.FindProperty("totalSegments");
}
/// <summary>
/// 这个方法必须被重写
/// 在里面进行要显示在inspector里面的实际操作
/// 里面创建的UI显示在inspector里面
/// </summary>
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
//这一句和后面的ApplyModifiedProperties都必须 否在inspector面板显示的值 不能够更改
serializedObject.Update();
//创建一个滑动条
EditorGUILayout.Slider(_fillPercent, 0, 1, new GUIContent("cdOutPercent"));
//创建一个属性域
EditorGUILayout.PropertyField(_segements);
serializedObject.ApplyModifiedProperties();
if (GUI.changed)
{
//将inspector的更改应用到CircleImage的prefab target代表CircleImage
//unity的机制 如果一个场景的物体改变了 想要保存到prefab里面
//则需要下面这句话
EditorUtility.SetDirty(target);
}
}
}