Unity 3D : 贝塞尔道路网格生成

引言

最近的一个项目,涉及到一个较大的场景,考虑到场景仅为演出效果,为节省资源打算使用tile贴图完成地面主要材质,道路则自动生成适用于贴片材质的网格。
使用样例
Loop及分辨率调整

贝塞尔路径的展现与编辑

首先建立一个简单的类结构,用于存储每个曲线段。

// 单个曲线段的数据结构
[Serializable]
public class CurveSegment
{
    public Vector3 point;
    public Vector3 controlPoint1;//控制点1
    public Vector3 controlPoint2;//控制点2
    public bool isBezier = true; // 是否是贝塞尔曲线
}

在这里解释一下该结构的含义
结构解释图
每一个贝塞尔曲线只存储当前线段的结束点。 起始点由前一个相邻的曲线决定,这样规划的好处是不必在同步两个点上花费心思,同时也可以自动适应删除的逻辑(起始点始终是前面的线段)

不过注意,我希望能够混合存储直线/贝塞尔曲线,因此添加了名为isBezier 的标识变量,用于区分线段的类型。

为了管理这些曲线段,建立一个Mono类。

我的计划是生成简单的单向道路,不存在交叉路口,因此只需要一个List即可管理这些曲线段结构。

//我们希望在编辑器模式就可以执行Unity事件函数
[ExecuteInEditMode]
public class MultiSegmentBezierCurve : MonoBehaviour
{
    public List<CurveSegment> segments = new(); // 曲线结构数组
    
	private void Start()
    {
    	//默认存在一个结构作为根
        AddSegment();
    }
    // 添加新的曲线段
    public void AddSegment()
    {
    	//如果结构为空,则根的位置与GameObject的位置相同。
    	//否则自动衔接上一个曲线的位置
        var lastPoint = segments.Count > 0 ? segments[^1].point : transform.position;
        var newSegment = new CurveSegment
        {
            point = lastPoint + Vector3.right * 2,
            controlPoint1 = lastPoint + Vector3.right * 0.5f,   
        };
        newSegment.controlPoint2 = newSegment.point - Vector3.right * 0.5f;
        segments.Add(newSegment);
    }

    // 删除最后一段曲线
    public void RemoveSegment()
    {
        if (segments.Count > 1)
        {
            segments.RemoveAt(segments.Count - 1);
        }
        PointUpdated();
    }
}

在Editor中绘制样条线

首先为了能在Editor中画出线段内容。在Assets根目录建立Editor文件夹。

作为先行步骤,我们先完成绘制逻辑:

//表明目标类是MultiSegmentBezierCurve
[CustomEditor(typeof(MultiSegmentBezierCurve))]
public class MultiSegmentBezierCurveEditor : UnityEditor.Editor
{
    private void OnSceneGUI()
    {
        var curve = (MultiSegmentBezierCurve)target;    //获取绑定的Mono

        if (curve.segments.Count == 1)return;   //如果当前只有根,则直接退出
        //逐个循环每个区线段
        for (var index = 0; index < curve.segments.Count; index++)
        {
            var isLast = index == curve.segments.Count - 1; //标记当前是否是末尾元素
            CurveSegment lastSegment;
            CurveSegment nextSegment;
            if(index == 0)  //跳过根
                continue;
            if (isLast)     //如果是最后一段曲线,则进行如下处理
            {
                lastSegment = curve.segments[index - 1];
                nextSegment = null;
            }
            else    //一般情况
            {
                lastSegment = curve.segments[index - 1];
                nextSegment = curve.segments[index + 1];                             
            }
        
            var segment = curve.segments[index];

            //Draw Segment
            var lastPoint = lastSegment.point;  
            var thisPoint = segment.point;
            var cp1 = segment.controlPoint1;
            var cp2 = segment.controlPoint2;

            if (segment.isBezier)   //绘制贝塞尔
            {
                Handles.color = Color.blue; //当前画笔颜色改为蓝色
                Handles.DrawLine(lastPoint, cp1, 4);    //绘制控制点1的直线
                Handles.DrawLine(thisPoint, cp2, 4);    //绘制控制点2的直线

                Handles.color = Color.green;//画笔颜色改为绿色
                Handles.DrawBezier(lastPoint, thisPoint, cp1, cp2, Color.green, null, 8f);// 使用内置函数绘制贝塞尔
            }
            else    //绘制直线
            {
                Handles.color = Color.green;
                Handles.DrawLine(lastPoint, thisPoint,4f);  //绘制直线
            }

        }
    }
}

此时,可以通过Inspector编辑List容器属性,手动添加贝塞尔曲线数据,提前查看效果,确定是否正常工作
效果

你可能注意到,更新数据时画面并不会实时更新,这是由于OnSceneGUI并不是每时每刻都会被触发。

在Editor中绘制可操控的Handles

继续编辑MultiSegmentBezierCurveEditor,实现显示自定义的交互标志(或称之为Handles)并处理相应事件:

private void OnSceneGUI()
{
    var curve = (MultiSegmentBezierCurve)target;    //获取绑定的Mono
    if (curve.segments.Count == 1)return;   //如果当前只有根,则直接退出
    //逐个循环每个区线段
    for (var index = 0; index < curve.segments.Count; index++)
    {
        //Draw Segment
        ...见上文代码...

        //Draw Handles
        //首先绘制一个红色圆圈,用于显示point的位置
        Handles.color = Color.red;
        Handles.DrawWireDisc(segment.point, Vector3.up, 1f);

        var controlPoint1 = segment.controlPoint1;
        var controlPoint2 = segment.controlPoint2;

        //在绘制可交互的Handles之前,使用BeginChangeCheck
        //提醒Unity开始监听以下的Handles是否正在与用户交互
        EditorGUI.BeginChangeCheck();
        
        //在point的位置上,绘制一个位置Handles(一个小的,可操控的三维坐标)
        var point = Handles.PositionHandle(segment.point, Quaternion.identity);
        if (segment.isBezier)
        {
            //如果是贝塞尔,则绘制控制柄 颜色为蓝色
            Handles.color = Color.blue;
            //在controlPoint1的位置上,绘制一个滑块Handles,Handles.SphereHandleCap表明它的形状是一个球体
            //这个滑块所处平面是 Vector3.up, Vector3.forward所形成平面(也就是主观上的地面),它只能在此平面移动
            controlPoint1 = Handles.Slider2D(cp1, Vector3.up, Vector3.forward, Vector3.right, 0.4f, Handles.SphereHandleCap,Vector2.zero);
            controlPoint2 = Handles.Slider2D(cp2, Vector3.up, Vector3.forward, Vector3.right, 0.4f, Handles.SphereHandleCap,Vector2.zero);
        }

        //如果Handles正在被用户交互,则EndChangeCheck返回true,否则返回false。
        //在这里,如果用户没有与任何Handles交互,则跳过下面的代码
        if (!EditorGUI.EndChangeCheck()) continue;

        //使用RecordObject记录当前的操作。
        //记录操作后,用户可以在操作后使用撤销恢复本次所做的更改
        //这是官方用法,无需担心性能问题。
        Undo.RecordObject(target, "Changed Position");

        //请记住:当用户修改Handles后,相应的Handles会返回修改后的数值
        //我们进行恒等判断,区分用户到底修改了哪个Handles
        //用户每一帧只可能修改一个Handles(因为鼠标只有一个)因此使用elif分支判断即可
        if (segment.point != point)
        {
            //用户更新了点 则应同时移动两个段的控制柄
            var diff = point - segment.point;
            segment.point = point;
            segment.controlPoint2 += diff;
            if (!isLast || isLoop)
                nextSegment.controlPoint1 += diff;
        }
        else if (segment.controlPoint1 != controlPoint1)
        {
            //用户更新了控制柄1 则也应当一并更新上一个段的控制柄2
            segment.controlPoint1 = controlPoint1;
            lastSegment.controlPoint2 = lastSegment.point + (lastSegment.point - controlPoint1);
        }
        else if (segment.controlPoint2 != controlPoint2)
        {
            //用户更新了控制柄2 则应更新下一个段的控制柄1
            segment.controlPoint2 = controlPoint2;
            if(!isLast || isLoop)
                nextSegment.controlPoint1 = point + (point - controlPoint2);
        }
    }
}

该逻辑较多,提取的重点如下:

在上面代码中,请着重注意:

  1. 绘制了两种Handles:

    • 在point的位置上绘制PositionHandle(表现为可移动的小坐标轴)
    • 在控制点1/2的位置上绘制Slider2D滑块(表现为只能在固定平面移动的实心球体)
  2. 实现了撤销功能:使用 Undo.RecordObject(Unity文档) 记录操作至Unity操作栈,用户可以按需撤销。

  3. 实现了Handles事件逻辑:使用 BeginChangeCheck/EndChangeCheck(Unity文档) 包围Handles所在的代码块,并在EndChangeCheck时检查用户是否正在修改Handles实现响应逻辑

  4. 实现了控制点1/2的自动对称,跟随父级移动的逻辑

在这里插入图片描述

在Inspector中添加自定义按钮

接下来,为了能够更快捷的对曲线进行增删改的操作,可以考虑在Inspector窗口中添加交互按钮。
继续编辑MultiSegmentBezierCurveEditor ,重载OnInspectorGUI函数,添加一个工具函数:

public override void OnInspectorGUI()
{
    DrawDefaultInspector();

    var curve = (MultiSegmentBezierCurve)target;

    //检测按钮是否被按下,只需要检查其返回值即可
    if (GUILayout.Button("添加曲线段"))
        curve.AddSegment();
    if (GUILayout.Button("删除最后一段曲线"))
        curve.RemoveSegment();
    
    //显示一个Label标签
    GUILayout.Label("线段类型");
    //为每个线段添加一个按钮,用于切换线段类型
    for (var index = 1; index < curve.segments.Count; index++)
    {
        var segment = curve.segments[index];
        CurveSegment nextSegment;
        CurveSegment lastSegment;

        lastSegment = curve.segments[index - 1];
        nextSegment = index == curve.segments.Count - 1 ? null : curve.segments[index + 1];
        
        if (GUILayout.Button("切换为" + (segment.isBezier ? "直线" : "贝塞尔") + "类型"))
        {
            segment.isBezier = !segment.isBezier;
            if (segment.isBezier)   //切换回贝塞尔,只需要更新本曲线的控制柄位置即可
            {
                if (lastSegment != null)
                    segment.controlPoint1 = lastSegment.point + (lastSegment.point - lastSegment.controlPoint2);
                if (nextSegment != null)
                    segment.controlPoint2 = segment.point + (segment.point - nextSegment.controlPoint1);
            }
            else    //切换到直线 稍微复杂,将前后段的贝塞尔控制柄自动对其到本直线
            {
                var step = (segment.point - lastSegment.point) / 4;
                segment.controlPoint1 = lastSegment.point + step;
                segment.controlPoint2 = segment.point - step;
                if (nextSegment != null)
                {   // 更新下一个段的控制柄1
                    DoUpdateCurveSegment(index + 1);
                }
                {   // 更新上一个段的控制柄2
                    DoUpdateCurveSegment(index - 1);
                }
            }
            //注意:在这里通知Unity对视窗进行重绘,即触发OnSceneGUI
            //否则更改无法被实时显示出来
            SceneView.RepaintAll();
        }
    }
}

//工具函数,更新控制柄的位置使对齐前后曲线的控制柄
private void DoUpdateCurveSegment(int index)
{
    var curve = (MultiSegmentBezierCurve)target;
    var segment = curve.segments[index];
    if (!segment.isBezier)return;
    CurveSegment nextSegment;
    CurveSegment lastSegment;
    if (index == 0)
    {
        if(curve.segments.Count <= 1)return;
        lastSegment = null;
        nextSegment = curve.segments[1];
    }
    else
    {
        lastSegment = curve.segments[index - 1];
        nextSegment = index == curve.segments.Count - 1 ? null : curve.segments[index + 1];
    }

    if (lastSegment != null)
    {
        segment.controlPoint1 = lastSegment.point + (lastSegment.point - lastSegment.controlPoint2);
    }
    if (nextSegment != null)
    {
        segment.controlPoint2 = segment.point + (segment.point - nextSegment.controlPoint1);
    }
}

效果

此效果仅堪堪够用,细节未考虑周全,不过你应该注意如下要点:

  1. 如果你在代码中修改了某个会影响画面表现的数据,则必须调用SceneView.RepaintAll通知Unity更新画面内容,因为内容的更新实际位于OnSceneGUI事件中,而OnSceneGUI并不是每时刻被调用。
  2. 处理GUILayout中的控件事件只需检测其返回值

由点路径生成网格

在生成网格之前,我们需要知道该路径的描点。由于画图逻辑是自动绘制的,因此我们需要手动的逐一采样路径点。

曲线路径采样

首先要解决的是对贝塞尔采样,可以从其计算方式入手:
计算公式
之后设定一个采样率resolution作为参数即可,对于直线采样,则只需关注该直线的起始点与末尾点,整个路径的采样函数如下:

[SerializeField] private int resolution = 10;//采样率
public void PointUpdated()
{
    float length = 0;     //记录此路径的长度
    var list = new List<Vector3>(); //采样后的点List
    var lastPoint = Vector3.zero;
    //逐一遍历所有线段
    for (var i = 0; i < segments.Count; i++)
    {
        CurveSegment prevSegment;
        if (i == 0) //如果是第一个
        {
            list.Add(segments[0].point);    //添加点
            lastPoint = segments[0].point;
            continue;                       //直接进入下一段线段
        }
        else
            prevSegment = segments[i - 1];  //更新prevSegment
        
        var segment = segments[i];
        if (segment.isBezier)
        {
            //对贝塞尔采样 采样精度为resolution
            for (var step = 1; step <= resolution; step++)
            {
                var t = step / (float)resolution;
                var point = Mathf.Pow(1 - t, 3) * prevSegment.point +
                            3 * Mathf.Pow(1 - t, 2) * t * segment.controlPoint1 +
                            3 * (1 - t) * Mathf.Pow(t, 2) * segment.controlPoint2 +
                            Mathf.Pow(t, 3) * segment.point;
                list.Add(point);
                length += (point - lastPoint).magnitude;//取得此点到上一个点的距离,并累加到路径长度
                lastPoint = point;
            }
        }
        else
        {
            //对直线则直接取两点
            // list.Add(prevSegment.point);//无需添加直线开始点,因为开始点已经被加入
            var len = (segment.point - prevSegment.point).magnitude;//量直线长度,并累加到路径长度
            length += len;
            list.Add(segment.point);        //添加直线结束点
            lastPoint = segment.point;      
        }
    }
    //采样完毕,此时可以根据list开始进行生成网格
}

由路径生成网格

*为避免混淆,我将路径采样得到的点称为路径点。*
Unity有两种方式 (官方文档)生成网格:
一种是传统的API:通过直接设置Mesh的vertices/triangles/uv等属性(或SetVertices/SetTriangles/SetUVs等)
一种是“高级”API(官方称如此):通过SetVertexBufferParams/SetVertexBufferData/SetSubMesh等方法设置顶点各项属性,顶点的各个属性分量被集成到同一个数组中。
“高级”API省略了部分数据验证,因此执行效率更高,但你应确保数据的准确性。下面我将使用两种方式生成网格

网格生成逻辑阐述:

从路径点生成网格顶点

生成顶点的逻辑如下图所示。
首先寻找切线位置(前进方向),之后根据切线向左右延伸固定宽度,由此确定生成的顶点位置
过程描述
实际上图中的“切线”并不准确,我们的为了取得“切线”的方向,只需要将上一段的矢量方向与下一段的矢量方向相加后取方向即可。
在这里插入图片描述

对于端点只需取a,b之中存在的一种方向

之后取这条“切线”的垂直方向,向这个垂直方向延伸一定的正负距离即可确定顶点位置。

由网格顶点生成面

生成面的逻辑如下图所示:
在这里插入图片描述

注意组成面的顶点顺序要保持一致,Unity使用缠绕顺序(winding order,即顶点的顺序)确定面朝向。因此需始终保持顺时针的方向组成面。Unity文档 mesh-index-data
Unity面组缠绕顺序

由图中可以看出,最佳的方式是每个Path点负责生成两个面,最后一个Path点不生成面。
顶点生成的面


生成网格(传统API)

public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float width = 1.0f) //输入路径点列表
{
    //存储顶点数据:一共有path.Count * 2顶点待生成
    var verts = new Vector3[path.Count * 2];
    //存储三角形网格数据:一共有(path.Count - 1) * 2个三角形,每个三角形占用3个元素位置
    var tris = new int[(path.Count - 1) * 2 * 3];

    //记录当前已生成的进度
    var vertIndex = 0;
    var triIndex = 0;

    //前后线段的方向
    var fromDir = Vector2.zero;
    var toDir = Vector2.zero;
    for (var i = 0; i < path.Count; i++)
    {
        //路径前进方向:即“切线”
        var forward = Vector2.zero;
        //如果当前顶点并不是最后一个,即包含下一段,则加上下一段的方向
        if (i < path.Count - 1)
        {
            var a = path[i + 1] - path[i];
            //抛弃y轴数据,只取xz平面上的路径切线
            toDir = new Vector2(a.x, a.z).normalized;
            forward += toDir;
        }
        //如果当前顶点并不是第一个,即包含上一段,则加上上一段的方向
        if (i > 0)
        {
            var a = path[i] - path[i - 1];
            //抛弃y轴数据,只取xz平面上的路径切线
            fromDir = new Vector2(a.x, a.z).normalized;
            forward += fromDir;
        }
        //将“切线”归一化
        forward.Normalize();
        //取“切线”的垂直方向,长度为width
        var left = new Vector2(-forward.y, forward.x) * width;
        
        //======设置顶点位置=======
        var pathPoint = new Vector2(path[i].x, path[i].z);
        //将路径左移作为顶点
        verts[vertIndex + 0] = new Vector3(pathPoint.x + left.x,path[i].y,pathPoint.y + left.y);   
        //将路径右移作为顶点
        verts[vertIndex + 1] = new Vector3(pathPoint.x - left.x,path[i].y,pathPoint.y - left.y);   
        
		//======设置三角形数据=======
        if (i < path.Count - 1)
        {
            //三角形之一
            tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
            tris[triIndex + 1] = vertIndex + 1;
            tris[triIndex + 2] = vertIndex + 0;
            //三角形之二
            tris[triIndex + 3] = vertIndex + 1;
            tris[triIndex + 4] = vertIndex + 2;
            tris[triIndex + 5] = vertIndex + 3;
        }
        //生成了两个顶点
        vertIndex += 2;
        //生成了两个三角形,占用了2*3=6个tris元素位置
        triIndex += 2 * 3;
    }
    var mesh = new Mesh
    {
        vertices = verts,   //赋值顶点数据
        triangles = tris,   //赋值三角形
    };
    return mesh;
}

注意:

  1. 在计算“切线”forward时,抛弃了Y轴数据,我们只需要在xz平面(地平面)上获取垂直于它的方向。
  2. triangles数组的数据排列方式比较微妙,每三个为一组,将它想象成一个二维数组,triangles[triIndex]表示访问第triIndex个三角形。

生成网格(高级API)

private static readonly VertexAttributeDescriptor[] VertexAttributes = {
        //表示顶点属性包含Position位置信息,数据格式是Float32(默认为此),包含3个分量xyz(默认为此) 
        new(VertexAttribute.Position,VertexAttributeFormat.Float32,3),
        //(未来可以在此处添加更多信息,例如UV,Normal等数据)
    };
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float width = 1.0f)
{
    //表示当前有path.Count * 2个顶点
    var vertexCount = path.Count * 2;
    //每一个buffer长度为3(根据顶点属性,只包含一个Position属性,Position需要3个分量表示xyz)
    const int bufferLength = 3;
    //顶点属性buffer总长度
    var vertexAttributeBufferLength = vertexCount * bufferLength;
    //顶点属性buffer
    var vertexAttributeBuffer = new float[vertexAttributeBufferLength];
    //包含(path.Count - 1) * 2个三角形
    var tris = new int[(path.Count - 1) * 2 * 3];

    var vertIndex = 0;
    var triIndex = 0;

    //前后线段的方向
    var fromDir = Vector2.zero;
    var toDir = Vector2.zero;
    for (var i = 0; i < path.Count; i++)
    {
        //标记本顶点属性的开始位置
        var start = i * bufferLength * 2;
        //方向
        var forward = Vector2.zero;

        
        if (i < path.Count - 1)
        {
            var a = path[i + 1] - path[i];
            toDir = new Vector2(a.x, a.z).normalized;
            forward += toDir;
        }
        if (i > 0)
        {
            // fromDir = toDir;
            var a = path[i] - path[i - 1];
            fromDir = new Vector2(a.x, a.z).normalized;
            forward += fromDir;
        }

        forward.Normalize();
        var left = new Vector2(-forward.y, forward.x) * width;
		
		//======设置顶点位置=======
        var pathPoint = new Vector2(path[i].x, path[i].z);
        for (var j = 0; j < 2; j++)
        {
            var isLeftSidePoint = j == 0;
            //offset用于自动的定位左右端点
            var offset = bufferLength * j; 
            if (isLeftSidePoint)
            {
                // 顶点数据 左端点(加上left偏移)

                //[start + offset]表示当前是第start + offset个顶点的数据位置
                //[start + offset + 0]表示当前是第start + offset个顶点的首个属性
                //首个属性具体含义由代码开头VertexAttributeDescriptor的定义可知:
                //前三个数据是Position数据,因此[start + offset + 0]是x分量,[start + offset + 1]是y分量...以此类推
                vertexAttributeBuffer[start + offset + 0] = pathPoint.x + left.x ;
                vertexAttributeBuffer[start + offset + 1] = path[i].y;
                vertexAttributeBuffer[start + offset + 2] = pathPoint.y + left.y ;
            }
            else
            {
                // 顶点数据 右端点(减去left偏移)
                vertexAttributeBuffer[start + offset + 0] = pathPoint.x - (left.x );
                vertexAttributeBuffer[start + offset + 1] = path[i].y;
                vertexAttributeBuffer[start + offset + 2] = pathPoint.y - (left.y );
            }
        }
        //======设置三角形数据=======
        if (i < path.Count - 1)
        {
            //左侧三角形
            tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
            tris[triIndex + 1] = vertIndex + 1;
            tris[triIndex + 2] = vertIndex + 0;
            //右侧三角形
            tris[triIndex + 3] = vertIndex + 1;
            tris[triIndex + 4] = vertIndex + 2; //这个顶点在下一个path中生成
            tris[triIndex + 5] = vertIndex + 3; //这个顶点在下一个path中生成
        }
        
        vertIndex += 2;
        triIndex += 6;
    }
    //将顶点缓冲区写入Mesh
    var mesh = new Mesh{ name = "PathMesh" };
    //固定范式
    mesh.SetVertexBufferParams(vertexCount, VertexAttributes);
    mesh.SetVertexBufferData(vertexAttributeBuffer, 0, 0, vertexAttributeBufferLength);
    
    //将顶点索引写入索引缓冲区
    var indexCount = tris.Length;
    mesh.SetIndexBufferParams(indexCount, IndexFormat.UInt32);
    mesh.SetIndexBufferData(tris, 0, 0, indexCount);

    //每个Mesh至少包含一个SubMesh
    mesh.subMeshCount = 1;
    var subMeshDescriptor = new SubMeshDescriptor(0, indexCount);
    mesh.SetSubMesh(0, subMeshDescriptor);

    //计算Bounds信息
    mesh.RecalculateBounds();
    
    return mesh;
}

可以看到最大的差异在于设置顶点的位置信息代码变得冗长。
在高级API中,需要手动的设置顶点属性的每个分量,顶点属性可以包含多种数据,上面代码中只包含位置信息,则需要设置x,y,z的数据,在下文将添加UV信息,则需要设置x,y,z,uv.x,uv.y信息,以此类推。高级API的顶点数据更加紧凑,无需额外开辟UVs数据数组,后文会有所体现。


自动更新网格

首先在Mono中,添加一个pathWidth属性,并更新先前的采样路径点函数PointUpdated,在函数末尾调用上文的网格生成函数CreatePathMesh

//添加一个道路宽度属性
[SerializeField] private float pathWidth = 1f;
public void PointUpdated(){
	//...
	...其他代码...
	//采样完毕,此时可以开始进行生成网格
	//需要一个MeshRenderer
	//考虑为这个Mono类添加特性[RequireComponent(typeof(MeshFilter))],以便Unity自动的为你添加这个组件
	//并在Start事件中保存MeshFilter的变量,这样不必每次都GetComponent
    GetComponent<MeshFilter>().mesh = CreatePathMesh(list,pathWidth);
}

注意:如果你决定在Start中保存MeshFilter的引用,请留意在ExecuteInEditMode特性修饰下Awake和Start仅在脚本赋给物体的时候被调用,更新脚本不会触发AwakeStart

为了能够在发生改动时自动更新生成网格,在MultiSegmentBezierCurveEditorOnSceneGUIOnInspectorGUI方法中适时调用PointUpdated

//在for循环中,EndChangeCheck之后的末尾调用PointUpdated
private void OnSceneGUI(){
    var curve = (MultiSegmentBezierCurve)target;
    ...其他代码...
    for (var index = 0; index < curve.segments.Count; index++){
        ...其他代码...
        if (!EditorGUI.EndChangeCheck()) continue;
        ...其他代码...
        //在这里调用PointUpdated
        curve.PointUpdated();
    }
}

//在按钮点击事件的末尾调用PointUpdated
public override void OnInspectorGUI(){
    ...
    if (GUILayout.Button("切换为" + (segment.isBezier ? "直线" : "贝塞尔") + "类型")){
        ...
        //在这里调用PointUpdated
        curve.PointUpdated();
    }
}

如果没有问题,你应该可以看到出现紫色的网格体
生成网格
此时你会发现在Inspector中修改pathWidth不会实时更新网格,可以在Mono中实现OnValidate方法,当属性修改时调用PointUpdated

private void OnValidate()
{
    PointUpdated();
}

UV生成

为了能够让Tiles贴图在竖直方向(y轴)上延伸,且让左端点的UV.X为0,由端点的UV.X为1,UV的分布如下:
UV描述
明确一点:网格的分布并不是均匀的,因此需要在UV划分上加入对距离的考量。
之后只需添加对UV数据处理的代码即可:

传统API-对UV数据的处理

为节省篇幅,这里只列出与上文代码不同的地方

//在参数中添加一个totalLength用于标定总体长度
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f) //输入路径点列表
{
	...原始代码...
	//声明一个UV数组存储uv,长度为verts数组的长度,与verts数组一一对应
	var uvs = new Vector2[verts.Length];
	//添加一个记录当前路径长度的数组
	var currentLength = 0f;
	//在for循环中
    for (var i = 0; i < path.Count; i++)
    {
    	...原始代码...
    	//添加对Uv的数据处理
        if (i == 0)
        {
        	//端点开头设置固定值
            uvs[vertIndex + 0] = new Vector2( 0,0);
            uvs[vertIndex + 1] = new Vector2(0,1);
        }
        else
        {
        	//更新当前长度
        	currentLength += (path[i] - path[i - 1]).magnitude;
        	//根据长度判断进度
            var u = currentLength / totalLength;
            //设置uv数据
            uvs[vertIndex + 0] = new Vector2( u,0);
            uvs[vertIndex + 1] = new Vector2(u,1);
        }
    }
    var mesh = new Mesh
    {
        vertices = verts,   //赋值顶点数据
        triangles = tris,   //赋值三角形
        uvs = uvs,			//*赋值uv
    };
    return mesh;
}

高级API-对UV数据的处理

再次强调:相较于传统API,高级API可直接为顶点分配UV,位置,法向等信息。而传统API需要通过mesh.uvs/mesh.normals的方式分配。

//首先要更新VertexAttributeDescriptor,添加UV数据
private static readonly VertexAttributeDescriptor[] VertexAttributes = {
        new(VertexAttribute.Position,VertexAttributeFormat.Float32,3),
        //默认uv使用第一个纹理坐标TexCoord0,其包含2个分量(x和y)
        new(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 2),
    };
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f)
{
	//需要更改的地方:因为添加了两个分量(uv坐标的x和y),因此bufferLength需要增加2
	const int bufferLength = 3 + 2;
	
	..原始代码...
	//添加一个说明型变量 增加可读性
	var isFirstPathPoint = i == 0;
	for (var j = 0; j < 2; j++){
		...原始代码...
	    //设置uv数据
	    if (isFirstPathPoint)
	    {	
	    	//为什么是+4?因为0 1 2对应着顶点的position坐标x y z,则3 4则对应着uv的x y
	    	//这里是将uv的y设定为0
	        vertexAttributeBuffer[start + offset + 4] = 0;
	        vertexAttributeBuffer[start + offset + 4] = 0;
	    }
	    else
	    {
	        var u = currentLength / totalLen;
	        vertexAttributeBuffer[start + offset + 4] = u;
	        vertexAttributeBuffer[start + offset + 4] = u;
	    }
	    //根据左右顶点,将uv的x设置为0或者1
	    vertexAttributeBuffer[start + offset + 3] = isLeftSidePoint ? 0 : 1;
	}
	...原始代码...
}

效果检验

在开始之前需要添加MeshRenderer组件,可以使用[RequireComponent(typeof(MeshRenderer))]特性修饰Mono

你可以使用一个简单的shader检验uv分布是否符合预期

Shader "Unlit/UVDisplay"{
    Properties{ }
    SubShader{
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            v2f_img vert (const appdata_base v){
                v2f_img o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.texcoord;
                return o;
            }
            fixed4 frag (v2f_img i) : SV_Target{
                return lerp(lerp(i.uv.y,fixed4(1,0,0,1),step(0.495,abs(i.uv.y - 0.5)))
                ,fixed4(1,0,0,1),step(0.49 ,abs(i.uv.x - 0.5)));
            }
            ENDCG
        }
    }
}

UV的01边界处将由红色描边,内容由uv.y填充,预期结果应为
预期结果

网格斜角处理(Miter join)

现在有一个问题,当使用多段直线弯折时,会出现拐角处路面不平行的问题:
问题浮现
由于每个路径点左右延伸的宽度是固定的,对于发生倾斜的线段,固定的宽度会导致收缩。
在这里插入图片描述
为了解决这个问题,我们需要延长位于拐角处的宽度,根据弯折角度确定宽度延伸倍率。
要计算这个倍率,可以从几何的角度出发:
三个圆半径相同,蓝色为直径,其中黑色线段为切线,灰色线段为圆心连线,求红色线段的长度。
在这里插入图片描述
那么我们应该从如下角度思考
解题思路


解决方法
在生成网格时计算这个倍率,使用传统API和高级API的计算方式是一样的:
left变量声明之后对left进行倍率缩放:

//添加一个参数miterJoin :我们需不需要处理斜角?
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLength,float width = 1.0f,bool miterJoin = false)
{
	...此前的代码完全一致
	
	forward.Normalize();
	var left = new Vector2(-forward.y, forward.x) * width;
	//在left变量声明之后,添加额外的计算方式
    if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
    {
        var fromLeft = new Vector2(-fromDir.y, fromDir.x);
        var toLeft = new Vector2(-toDir.y, toDir.x);
        var angle = Vector2.Angle(fromLeft, toLeft) / 2;
        if (angle != 0)	//如果发生弯折,则乘上新的倍率
            left *= 1 / Mathf.Cos(angle * Mathf.Deg2Rad);
    }
    //其余代码一致...
}

效果展示

对于高锐度的斜角,不适用于Tiles贴图,因为必然会出现接缝,或严重拉伸的问题

Tiles自适应

由于UV是单纯的从0到1,所以会导致当道路延长时,贴图跟随道路伸展或压缩,这不符合预期。
先前我们计算了totalLength用于计算uv,现在可以基于totalLength计算材质的Y向缩放倍率进而实现所谓的“自适应”,
例如在计算采样点的PointUpdated方法中:

//添加一个倍率值属性
[SerializeField] [Range(0.01f,1)] private float tiling = 0.5f;
public void PointUpdated()
{
    ...其余代码...
    //在更新网格之前或之后
    _meshFilter.mesh = CreatePathMesh(list,length,isLoop,pathWidth,miterJoin);
    if(_meshRenderer.sharedMaterial)
        _meshRenderer.sharedMaterial.mainTextureScale = new Vector2(1, length * tiling);
}

效果

仅对于包含_MainTex_ST输入的Shader有效,你可以为shader添加_MainTex ("Texture", 2D)属性和float2 _MainTex_ST变量用于读取倍率

Loop路径循环

现在只差最后一步,只需要将头尾链接即可。但要注意:起始段顶点已分配了UV,不可被再次覆盖,因此我们应在起始段使用两个新的顶点完全覆盖它的位置,在这个新的顶点上使用新的UV数据。
示意图


在开始之前…
Mono脚本中,添加一个属性isLoop用于标识此路径是否是循环路径。更新PointUpdated让其对根线段进行采样。

public bool isLoop;
public void PointUpdated()
{
    float length = 0;
    var list = new List<Vector3>();
    var lastPoint = Vector3.zero;
    for (var i = 0; i < segments.Count; i++)
    {
        CurveSegment prevSegment;
        if (i == 0)
        {
            if (isLoop)//开启了循环
            {
                //前一个线段为末尾线段
                prevSegment = segments[^1]; 
                //上一个点是末尾线段的点,用于测量距离
                lastPoint = prevSegment.point;
            }
            else    //未开启循环
            {
                list.Add(segments[0].point);    //直接添加一个点,作为起始点
                lastPoint = segments[0].point;
                continue;   //跳过根线段的网格生成
            }
        }
        else
        {
            prevSegment = segments[i - 1];
        }

        //...之后的内容不变,依旧是采样路径...
        var segment = segments[i];
    }
}

有必要提醒:PointUpdatedlist.Add用于添加采样点,且仅在采样一次之后才能使用Add,换句话说list.Add只能添加末尾的点,因此开启Loop后不能直接调用Add,而未开启Loop时则需要直接调用Add相当于根线段直接采样完毕成为一个点

接下来修改MultiSegmentBezierCurveEditor
在旧的OnSceneGUI中,我们直接跳过了对根线段的绘制,现在我们希望对于Loop路径,可以显示初始点完整的controlPoint1/2,因此对其修改如下:

private void OnSceneGUI()
{
    var curve = (MultiSegmentBezierCurve)target;
    if (curve.segments.Count == 1)return;
    var isLoop = curve.isLoop;
    //先进行特化处理 如果不是循环路径
    if (!isLoop)//那么我们只绘制根的Point Handle,用于允许移动
    {
        var firstSegment = curve.segments[0];
        Handles.color = Color.red;
        EditorGUI.BeginChangeCheck();
        var firstPoint = Handles.PositionHandle(firstSegment.point,Quaternion.identity);
        if (EditorGUI.EndChangeCheck())//移动了point 则进行更新
        {
            var diff = firstPoint - firstSegment.point;
            firstSegment.point = firstPoint;
            if (curve.segments.Count > 1)
                curve.segments[1].controlPoint1 += diff;//即使controlPoint1是不可见的,依然需要更新
            curve.PointUpdated();
        }
    }
    //下面进行逐一处理
    for (var index = 0; index < curve.segments.Count; index++)
    {
        var isLast = index == curve.segments.Count - 1;
        CurveSegment lastSegment;
        CurveSegment nextSegment;
        if (isLoop)//是loop
        {
            if (index == 0)
            {   //对于loop,第一个的上一个就是最后一个
                lastSegment = curve.segments[^1];
                nextSegment = curve.segments[1];
            }
            else if(isLast)
            {   
                lastSegment = curve.segments[index - 1];
                //对于loop,最后一个的下一个就是第一个
                nextSegment = curve.segments[0];
            }
            else
            {
                lastSegment = curve.segments[index - 1];
                nextSegment = curve.segments[index + 1];                             
            }
        }
        else
        {
            if(index == 0) //不是loop,直接跳过对根的绘制,因为我们已经处理过了
                continue;
            if (isLast) 
            {
                lastSegment = curve.segments[index - 1];
                nextSegment = null;//不是loop,末尾的下一个就是null
            }
            else
            {
                lastSegment = curve.segments[index - 1];
                nextSegment = curve.segments[index + 1];                             
            }
        }
        
        //...下面的剩余代码相同...
        var segment = curve.segments[index];
        //Draw Segment
    }
}

主要是针对lastSegment,nextSegment的处理,因为loop是循环的。

如果你使用了OnInspectorGUI的方法,请注意一并处理,OnInspectorGUI的情况更复杂,因为涉及到传递index而非传递引用,因此最便捷的方式是添加变量lastSegmentIndexnextSegmentIndex,把index进行独立管理。
限于篇幅原因,且考虑到修改起来并不困难,因此在此处略过OnInspectorGUI的修改内容。

Loop网格生成

最关键的步骤在于创建一个位置与起始位置重叠,但UV数据相反的顶点。
为了便于拓展, 将使用List类型代替原先的数组。

篇幅至此已略长,我将只给出高级API的网格生成解决方案,至于使用传统API还请读者手动修正相关内容。

为了能在任意时机复制当前的顶点,我们需要一个函数DuplicateVertexes,用于复制顶点,我们称之为D逻辑
默认自动建立的顶点的逻辑将被提取为CreateVertexes函数,用于执行默认的建立顶点逻辑,我们称之为C逻辑
二者的区别是:
- D逻辑会与先前的顶点组成三角形,不会与在其之后生成的顶点组成三角形。
- C逻辑既会连接之前的顶点也会连接之后的顶点,若先前的顶点已经与D逻辑的顶点相连,则C逻辑将只会连接之后的顶点。

区别示意图
图中为了区分,特意将两者的顶点间隙扩大,实际上两种顶点是完全重叠的。
两者都是CreatePathMesh的局部函数,因此它们可以使用CreatePathMesh的局部变量。
首先是CreateVertex函数:

void CreateVertex(int i, Vector2 left, float leftUVy, float rightUVy)
{
    var pathPoint = new Vector2(path[i].x, path[i].z);
    for (var j = 0; j < 2; j++)
    {
        var isLeftSidePoint = j == 0;
        var offset = bufferLength * j;
        if (isLeftSidePoint)
        {
            // 顶点数据 左端点
            vertexAttributeBuffer[start + offset + 0] = pathPoint.x + left.x ;
            vertexAttributeBuffer[start + offset + 1] = path[i].y;
            vertexAttributeBuffer[start + offset + 2] = pathPoint.y + left.y ;
            //uv.y
            vertexAttributeBuffer[start + offset + 4] = leftUVy;
        }
        else
        {
            // 顶点数据 右端点
            vertexAttributeBuffer[start + offset + 0] = pathPoint.x - (left.x );
            vertexAttributeBuffer[start + offset + 1] = path[i].y;
            vertexAttributeBuffer[start + offset + 2] = pathPoint.y - (left.y );
            //uv.y
            vertexAttributeBuffer[start + offset + 4] = rightUVy;
            
        }
        //uv.x
        vertexAttributeBuffer[start + offset + 3] = isLeftSidePoint ? 0 : 1;
    }
}

可以看出它仅仅是将原始的逻辑提取为函数。
参数中leftUVyrightUVy分别指定了左顶点和右顶点的UV.y的值,UV.x的值将会自动生成,其值始终是往左为0,往右为1
接下来是DuplicateVertexes函数:

void DuplicateVertexes(int i,Vector2 left,float leftUVy = 0f,float rightUVy = 0f)
{
	//扩充2个顶点的容量
    vertexAttributeBuffer.AddRange(new float[bufferLength * 2]);
    CreateVertex(i, left, leftUVy, rightUVy);
    //自动递增相关的标志
    vertIndex += 2;
    vertexCount += 2;
    start += bufferLength * 2;
    vertexAttributeBufferLength += 2 * bufferLength;
}

从功能描述中可以看出其包含了CreateVertex的功能,因此从实现逻辑上它本身就是CreateVertex的一层封装。
三角形连接采用的是相对寻址,在DuplicateVertexes中修改相关的偏移量便可实现类似于“鸠占鹊巢”的效果,抢占三角形位置,达到预期目的。

再次强调:由于这些都是局部函数,因此可以共用父函数的局部变量,例如start ,vertIndex ,vertexCount

之后,重写CreatePathMesh,在末尾加入前文的两个局部函数,同时添加针对loop的逻辑。
为节省篇幅,我删去了与文章最初所给出的一致的注释

public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLen,bool isLoop = false,float width = 1.0f,bool miterJoin = false)
{
    var vertexCount = path.Count * 2;
    const int bufferLength = 3 + 2;
    var vertexAttributeBufferLength = vertexCount * bufferLength;
    var vertexAttributeBuffer = new List<float>(new float[vertexAttributeBufferLength]);
    //注意:根据是否开启了loop循环决定三角形个数
    //默认有(path.Count - 1) * 2个三角形,开启循环后,将添加两个三角形,即path.Count * 2个三角形
    var tris = new int[(path.Count - (isLoop ? 0 : 1)) * 2 * 3];
    var vertIndex = 0;
    var triIndex = 0;
    var start = 0;
    var currentLength = 0f;
    var fromDir = Vector2.zero;
    var toDir = Vector2.zero;
    for (var i = 0; i < path.Count; i++)
    {
        if (i != 0)
        {
            currentLength += (path[i] - path[i - 1]).magnitude;
        }
        var forward = Vector2.zero;
        if (i < path.Count - 1)
        {
            var a = path[i + 1] - path[i];
            toDir = new Vector2(a.x, a.z).normalized;
            forward += toDir;
        }else if (isLoop)
        {
            //如果开启了loop 则可以计算最后一段曲线的toDir
            var a = path[0] - path[i];
            toDir = new Vector2(a.x, a.z).normalized;
            forward += toDir;                
        }
        if (i > 0)
        {
            // fromDir = toDir;
            var a = path[i] - path[i - 1];
            fromDir = new Vector2(a.x, a.z).normalized;
            forward += fromDir;
        }else if (isLoop)
        {
            //如果开启了loop 则可以计算第一段曲线的fromDir
            var a = path[0] - path[^1];
            fromDir = new Vector2(a.x, a.z).normalized;
            forward += fromDir;                
        }
        forward.Normalize();
        var left = new Vector2(-forward.y, forward.x) * width;
        //斜角处理
        if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
        {
            var fromLeft = new Vector2(-fromDir.y, fromDir.x);
            var toLeft = new Vector2(-toDir.y, toDir.x);
            var angle = Vector2.Angle(fromLeft, toLeft) / 2 * Mathf.Deg2Rad;
            if (angle != 0)
            {
                left *= 1 / Mathf.Cos(angle);
            }
        }
        //若是起始点,且开启了Loop,则复制当前顶点
        //(因为是最先生成的顶点,因此顶点的编号为0,1,在之后会用到此编号)
        if (i == 0 && isLoop)   
            DuplicateVertexes(i, left,1f,1f);
        
        var isFirstPathPoint = i == 0;
        var uvy =isFirstPathPoint ? 0 : currentLength / totalLen;     
        //调用默认建立顶点的逻辑           
        CreateVertex(i, left,uvy,uvy);
        
            
        //开始创建三角形
        if (i < path.Count - 1)
        {
            //左侧三角形
            tris[triIndex + 0] = vertIndex + 2; //这个顶点在下一个path中生成
            tris[triIndex + 1] = vertIndex + 1;
            tris[triIndex + 2] = vertIndex + 0;
            //右侧三角形
            tris[triIndex + 3] = vertIndex + 1;
            tris[triIndex + 4] = vertIndex + 2; //这个顶点在下一个path中生成
            tris[triIndex + 5] = vertIndex + 3; //这个顶点在下一个path中生成
        }
        else if (isLoop)    //如果开启了Loop,则在末尾生成三角形
        {
            //与前半部分if分支内比较:
            //将原本位于下一个path中生成的顶点,
            //替换为最初使用D逻辑复制的顶点编号(0,1)
            tris[triIndex + 0] = 0;
            tris[triIndex + 1] = vertIndex + 1;
            tris[triIndex + 2] = vertIndex + 0; 
            
            tris[triIndex + 3] = vertIndex + 1;
            tris[triIndex + 4] = 0;
            tris[triIndex + 5] = 1;
        }
        
        vertIndex += 2;
        triIndex += 6;
        start +=  bufferLength * 2;
    }
    //与先前一致,此处不在赘述
    var mesh = new Mesh{name = "PathMesh"};
    mesh.SetVertexBufferParams(vertexCount, VertexAttributes);
    mesh.SetVertexBufferData(vertexAttributeBuffer, 0, 0, vertexAttributeBufferLength);
    var indexCount = tris.Length;
    mesh.SetIndexBufferParams(indexCount, IndexFormat.UInt32);
    mesh.SetIndexBufferData(tris, 0, 0, indexCount);
    mesh.subMeshCount = 1;
    var subMeshDescriptor = new SubMeshDescriptor(0, indexCount);
    mesh.SetSubMesh(0, subMeshDescriptor);
    mesh.RecalculateBounds();
    return mesh;

    ...CreateVertex的定义放在这里...
    ...DuplicateVertexes的定义放在这里...
}

注意:使用D逻辑复制的起始路径点顶点将会预留到末尾路径点生成顶点后使用。
三角形连接采用的是相对寻址(vertIndex + 偏移个数),这是D逻辑与C逻辑能够发挥作用的基础。

若无差错,则此时便可实现单向UV的Loop路径
效果展示
如果在D逻辑中为顶点添加恒定偏移,便能看到接缝位置:
接缝

高锐度斜角UV纠正

扭曲的UV
如之前提到的,对于高锐度的斜角,不适用于Tiles贴图,因为必然会出现接缝,或严重拉伸的问题,但是某种意义上接缝要比拉伸好的多,因此在这里我给出一个纠正UV的方案,以接缝来代替拉伸问题,可供参考。
考虑一种极端情况:
黑色为实际UV坐标,红色为预期正确的UV坐标
极端情况
由于UV是基于长度比例totalLength计算的,因此可以通过这点计算偏移Δ
解题思路
得出正确的坐标后,使用前文的DuplicateVertexes/CreateVertex分离交界处的UV,便能做到类似于裁切UV的效果。
代码如下:

//添加参数maxAngleThreshold 用于指定最大的角度,超过此角度则进行UV分离
public static Mesh CreatePathMesh (IReadOnlyList<Vector3> path,float totalLen,bool isLoop = false,float width = 1.0f,bool miterJoin = false,float maxAngleThreshold = 45f)
{
    ...默认代码...
    for (var i = 0; i < path.Count; i++)
    {
        //添加一个标记变量,防止CreateVertex被调用多次
        var vertCreated = false;
        ...默认代码...
        //在斜角处理过程中,添加角度判断逻辑
        if (miterJoin && fromDir != Vector2.zero && toDir != Vector2.zero)
        {
            var fromLeft = new Vector2(-fromDir.y, fromDir.x);
            var toLeft = new Vector2(-toDir.y, toDir.x);
            //注意:此处改为有符号的角度
            var angle = Vector2.SignedAngle(fromLeft, toLeft) / 2 * Mathf.Deg2Rad;
            if (angle != 0)
            {
                left *= 1 / Mathf.Cos(angle);
                //斜角UV纠正
                if (Mathf.Abs(angle) > maxAngleThreshold * Mathf.Deg2Rad)
                {
                    var u = currentLength / totalLen;
                    var offset = left.magnitude * Mathf.Sin(angle) / totalLen;
                    DuplicateVertexes(i,left,u-offset,u+offset);
                    CreateVertex(i,left,u+offset,u-offset);
                    vertCreated = true; //接管了create 无需再次create
                }
            }
            ...默认代码...
            //将CreateVertex使用!vertCreated.if包围
            if (!vertCreated)
            {
                var isFirstPathPoint = i == 0;
                var uvy =isFirstPathPoint ? 0 : currentLength / totalLen;                
                CreateVertex(i, left,uvy,uvy);
            }
            ...剩余代码一致...
        }
    }
}

虽然存在明显接缝,但基于长度比例的UV计算对于特征明显的部分可做到接近同步传送效果。
效果
效果GIF


至此本文完结,任何想法或建议,欢迎在评论区留言探讨。如果文章中有任何疏漏或不准确之处,恳请指正。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值