LOADING

加载过慢请开启缓存 浏览器默认开启

翻页Shader

通过 UI 网格细分和顶点动画实现纸张翻页,推导卷曲位置,并记录坐标转换、双面渲染以及尚未验证的真机表现。

老规矩,先看效果

1.曲面细分

其本质其实是顶点动画,所以需要网格体有足够多的顶点,不然动起来就会非常硬:

翻页Shader示意图 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.顶点动画

简单介绍下顶点动画,这样才知道我们具体操作的对象是什么,顶点动画其实就是通过数学手段计算顶点的位置,从而得到的动画,一般顶点动画的计算是在模型的模型空间。对于纸片的旋转,我们可以想象一个卷曲的纸包围一个圆柱体,那么我们的目的就是计算纸面上任意一顶点,在卷曲后到底在这个圆柱体的哪个位置。

翻页Shader示意图 2

2.纸面卷曲的要素

那么不妨想象一下,卷曲有哪些要素 方向:这个卡牌从哪边卷到哪边,就是这个圆柱体的轴线方向,用dir来表示。 半径:你卷这个卷它有多卷,圆柱的半径越小,这个卷越卷,看起来这个纸就越软,半径越大,他就越直,看起来这个纸就越硬。用_Radius来表示 进度:这个卷曲进行到什么程度,是刚刚开始卷一个边,还是已经成为了卷了这个纸面的大部分区域,其实也可以理解为这个圆柱体的位置,实际上就是在圆柱轴线的某个点。用Point来表示。

3.判断是否卷曲

在正式开始卷曲之前,我们先来判断纸面的任意一点是否卷曲,如果卷曲了,我们再去判断之后顶点的位置,没卷曲的顶点直接不动就可以。

翻页Shader示意图 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.判断平直

实际上对于卷曲而言有两种情况,第一种是完全卷曲,也就是卷曲后的任意一点都贴着这个圆柱过去,把整个纸卷成一个圆柱体,像这样:

翻页Shader示意图 4

而另一种情况则是超过圆柱上面的部分直接平直过去,像这样:

翻页Shader示意图 5

第一种情况就不需要这种判断,直接去计算顶点映射到圆柱的位置就行,而第二种则是要判断卷曲后的点有没有超过圆柱的轴线,并且计算超过后这个点到底应该在空间中的哪个位置。这里主要讲一下第二种情况。 想象这样一种情况,如果这个点想要超过圆柱的轴线,也就是说,它所走过的距离应该超过这个圆柱周长的一半,如图:

翻页Shader示意图 6

也就是dir应该>=PI* _ Radius,这样的顶点才是超过轴线的,我们可以这样判断:

float moreThanHalfCir = (dis - UNITY_PI * _Radius); 
//如果超过周长的一半的点 
if (moreThanHalfCir >= 0)  
{}

那么如何求得这个点实际的位置呢,想象一个纸平面,纸面坐标系是XY,那么如果卷起的话他的高度,也就是Z,一定是圆柱的直径

翻页Shader示意图 7

这样我们就解决了这点P的Z值float3(?,?,2*_ Radius)。 那么XY值(纸面上的位移)呢,如图

翻页Shader示意图 8

原先纸面上的一点红P经过卷曲到达了蓝P的位置,dis的长度实际上也是蓝线的长度,我们的目的是求得蓝P的位置,那么我们可以看作蓝P是从轴线位置出发再往前走一点点距离M得到,那么这一点点距离实际上就是dis-半个周长,所以这个m也就是前文代码的float moreThanHalfCir = (dis - UNITY_PI * _ Radius); 接下来只需要知道p到轴线上的点就行

翻页Shader示意图 9

还是这张图,要求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的位置了。

翻页Shader示意图 10

如图所示,要求得蓝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来判断模型是哪面,然后指定输出颜色,不过这样的渲染方式会导致翻面后的前后绘制出现异常,大概这样:

翻页Shader示意图 11

前前后后排查了透明度混合,深度测试,都找不到问题,目前是开启了深度测试,深度写入并将透明度混合设置为Blend SrcAlpha OneMinusSrcAlpha,然后下图模式下,至少Game视角是没问题的,没打包到手机上测试过,不知道真机能不能稳定运行。

翻页Shader示意图 12