通过 UI 网格细分和顶点动画实现纸张翻页,推导卷曲位置,并记录坐标转换、双面渲染以及尚未验证的真机表现。
老规矩,先看效果
1.曲面细分
其本质其实是顶点动画,所以需要网格体有足够多的顶点,不然动起来就会非常硬:
对于3D世界的物体来说这样网格比较好获取,随便找个DCC软件,曲面细分,导出就行。如果是UI的话,我们就得去修改UI的顶点数据,写个脚本,继承自BaseMeshEffect类,重写void ModifyMesh(VertexHelper vh)方法就行,具体曲面细分的算法暂时就不过多讲解,不是本文重点,直接贴一下:
using System.Collections.Generic;
namespace UnityEngine.UI.Extensions
{
public class UITessellator : BaseMeshEffect
{
[SerializeField, Range(0, 5)]
int Level = 2;
public override void ModifyMesh(VertexHelper vh)
{ var list = new List<UIVertex>();
var newlist = new List<UIVertex>();
vh.GetUIVertexStream(list);
for (int i = 0; i < list.Count; i += 3)
{ newlist.AddRange(TessellateTriangleStream(list[i], list[i + 1], list[i + 2], Level));
}
vh.Clear();
vh.AddUIVertexTriangleStream(newlist);
}
List<UIVertex> TessellateTriangleStream(UIVertex a, UIVertex b, UIVertex c, int depth)
{ if (depth <= 0)
return new List<UIVertex> { a, b, c };
var list = TessellateTriangle(a, b, c);
var newlist = new List<UIVertex>();
for (int i = 0; i < list.Count; i += 3)
{ newlist.AddRange(TessellateTriangleStream(list[i], list[i + 1], list[i + 2], depth - 1));
} return newlist;
}
List<UIVertex> TessellateTriangle(UIVertex a, UIVertex b, UIVertex c)
{ var ab = Lerp(a, b, 0.5f);
var bc = Lerp(b, c, 0.5f);
var ca = Lerp(c, a, 0.5f);
return new List<UIVertex> {
a, ab,ca, ab,bc,ca, ab,b,bc, ca,bc,c }; }
UIVertex Lerp(UIVertex a, UIVertex b, float t)
{ var ret = new UIVertex();
ret.color = Color.Lerp(a.color, b.color, t);
ret.normal = Vector3.Lerp(a.normal, b.normal, t);
ret.position = Vector3.Lerp(a.position, b.position, t);
ret.tangent = Vector3.Lerp(a.tangent, b.tangent, t);
ret.uv0 = Vector2.Lerp(a.uv0, b.uv0, t);
ret.uv1 = Vector2.Lerp(a.uv1, b.uv1, t);
ret.uv2 = Vector2.Lerp(a.uv2, b.uv2, t);
ret.uv3 = Vector2.Lerp(a.uv3, b.uv3, t);
return ret;
} }}
有了细分后的网格就可以开始后面的主要内容了。
2.卷曲
1.顶点动画
简单介绍下顶点动画,这样才知道我们具体操作的对象是什么,顶点动画其实就是通过数学手段计算顶点的位置,从而得到的动画,一般顶点动画的计算是在模型的模型空间。对于纸片的旋转,我们可以想象一个卷曲的纸包围一个圆柱体,那么我们的目的就是计算纸面上任意一顶点,在卷曲后到底在这个圆柱体的哪个位置。
2.纸面卷曲的要素
那么不妨想象一下,卷曲有哪些要素 方向:这个卡牌从哪边卷到哪边,就是这个圆柱体的轴线方向,用dir来表示。 半径:你卷这个卷它有多卷,圆柱的半径越小,这个卷越卷,看起来这个纸就越软,半径越大,他就越直,看起来这个纸就越硬。用_Radius来表示 进度:这个卷曲进行到什么程度,是刚刚开始卷一个边,还是已经成为了卷了这个纸面的大部分区域,其实也可以理解为这个圆柱体的位置,实际上就是在圆柱轴线的某个点。用Point来表示。
3.判断是否卷曲
在正式开始卷曲之前,我们先来判断纸面的任意一点是否卷曲,如果卷曲了,我们再去判断之后顶点的位置,没卷曲的顶点直接不动就可以。
如上图所示:虚线为圆柱体所在的轴线,Point就是上面的一点,两个不同颜色的P则代指一个纸面上的任意两个点,Dir是圆柱的滚动向量,为单位向量,所以黑P是应该被卷起的点,而红P则是不动的点。 我们可以计算点P指向Point的向量,并与Dir点乘得到有向距离dis,很明显,由于Dir与黑色成锐角,所以cos值大于0,而Dir与红色成钝角,所以cos值小于0,我们就可以依据这一点来判断任意一点是否被卷曲。又由于Dir是单位向量,所以Dir点乘(Point-P)=dis,得到的值就是点P到轴线的距离,而正负值就可以判断是否卷曲了。 相关代码如下:
//源顶点
float3 P = v.vertex.xyz;
//翻页方向
float rad = radians(_Angle);
float2 dir = normalize(float2(cos(rad), sin(rad)));
//任意点P到轴线的有向距离dis
float dis = dot((_Point - P.xy), dir.xy);
//判断某点是否被卷曲
if (dis > 0)
{
}
4.判断平直
实际上对于卷曲而言有两种情况,第一种是完全卷曲,也就是卷曲后的任意一点都贴着这个圆柱过去,把整个纸卷成一个圆柱体,像这样:
而另一种情况则是超过圆柱上面的部分直接平直过去,像这样:
第一种情况就不需要这种判断,直接去计算顶点映射到圆柱的位置就行,而第二种则是要判断卷曲后的点有没有超过圆柱的轴线,并且计算超过后这个点到底应该在空间中的哪个位置。这里主要讲一下第二种情况。 想象这样一种情况,如果这个点想要超过圆柱的轴线,也就是说,它所走过的距离应该超过这个圆柱周长的一半,如图:
也就是dir应该>=PI* _ Radius,这样的顶点才是超过轴线的,我们可以这样判断:
float moreThanHalfCir = (dis - UNITY_PI * _Radius);
//如果超过周长的一半的点
if (moreThanHalfCir >= 0)
{}
那么如何求得这个点实际的位置呢,想象一个纸平面,纸面坐标系是XY,那么如果卷起的话他的高度,也就是Z,一定是圆柱的直径
这样我们就解决了这点P的Z值float3(?,?,2*_ Radius)。 那么XY值(纸面上的位移)呢,如图
原先纸面上的一点红P经过卷曲到达了蓝P的位置,dis的长度实际上也是蓝线的长度,我们的目的是求得蓝P的位置,那么我们可以看作蓝P是从轴线位置出发再往前走一点点距离M得到,那么这一点点距离实际上就是dis-半个周长,所以这个m也就是前文代码的float moreThanHalfCir = (dis - UNITY_PI * _ Radius); 接下来只需要知道p到轴线上的点就行
还是这张图,要求p到轴线上的交点,我们只需要将P沿着滚动方向移动dis距离即可。 最后,将P在轴线的交点向着滚动方向再移动M距离即可: 相关代码如下:
//P在轴线上的交点
float2 bottom = P.xy + dis * dir.xy;
//纸面上P点
P.xy = bottom+moreThanHalfCir*dir.xy
//立体P点
P = float3(P.xy,2 * _Radius)
5.在圆柱上的点
剩下的就是求得在圆柱上的点P的位置了。
如图所示,要求得蓝P的位置,只需要高中程度(或者初中?)的几何知识即可,我们先看Z值。 从图中可以直观的看到,Z=t+R,而t = cos(θ)R,而角度θ则是半圆减去圆心角,圆心角可以由弧长/半径得到,而弧长就是之前说的dis,所以可以得到以下代码:
//角度
float angle = UNITY_PI - dis / _Radius;
P.z = _Radius + cos(angle) * _Radius;
而蓝P点的纸面坐标则可以理解为点P沿着dir方向移动一段距离即可,这个距离就是dis-g,我们就需要求得g的长度,而g= Sin(θ)R,同样可以简单的求出来:
float g = dis - sin(angle) * _Radius;
P.xy = P.xy + g *dir.xy;
至此我们完整的算出了这个卷曲的全过程。
3.模型空间的UI坐标
如果用的是普通模型的其实到上一步就已经完全结束了,但UI的话还有一个小坑,UI的模型空间并不是以模型中心为坐标原点的,而是以模型所在的Canvas设置的一个点为坐标原点,这会导致顶点动画一定程度上出现奇怪的效果,具体处理方法嘛,就是传递一个Canvas的变换矩阵,我们在顶点动画之前,先把它变换为模型自身的模型空间,做完模型顶点动画之后再变回去就好。 这里给一下Canvas的矩阵:
private Matrix4x4 CalcCanvas2LocalMatrix()
{
var canvaslist = GetComponentsInParent<Canvas>();
int parentIndex = canvaslist.Length - 1;
return transform.worldToLocalMatrix * canvaslist[parentIndex].transform.localToWorldMatrix;
}
4.UI的双面渲染
普通模型的话就不用说了,可以双Pass对两个面依次渲染,不过我这里使用的是Unity6,是URP管线,UI貌似就不能使用双Pass了,就只能直接开启双面渲染,可以通过bool isFrontFace : SV_IsFrontFace来判断模型是哪面,然后指定输出颜色,不过这样的渲染方式会导致翻面后的前后绘制出现异常,大概这样:
前前后后排查了透明度混合,深度测试,都找不到问题,目前是开启了深度测试,深度写入并将透明度混合设置为Blend SrcAlpha OneMinusSrcAlpha,然后下图模式下,至少Game视角是没问题的,没打包到手机上测试过,不知道真机能不能稳定运行。