曲线间平滑计算方法和一个 Spline 的实现

本文是对 Unity 曲线编辑器和 Bezier 曲线插值的延伸,专门讨论曲线间平滑过渡的实现问题。
曲线间的平滑过渡效果,根本上取决于两条二阶曲线在粘合处的平滑程度。

曲线平滑方法概览

01
5 点线性平滑
利用历史点信息,通过线性预测推算插值点。若不加校正容易产生蝴蝶效应——预测依赖的点会越来越偏离真实点。
02
三阶 Bezier 三点共线
第一条曲线第二控制点、终点、第二条曲线第一控制点三点共线,保证连接处平滑。但曲率突变仍可能引起抖动。
03
论文方法
学术论文提出的方法,通过数学推导保证 C2 连续性,理论最严格。
04
角度与位置分别插值
不平滑的根因在于交合处角度和位置存在偏差,针对两者分别插值可同时解决方向和位置的跳变。
05
RungeKutta 算法
用于曲线弧长计算,四阶近似解决步长精度问题,工程中最常用的高精度数值方法。
06
欧拉渐进法(Euler)
一阶 RungeKutta,计算开销最小但精度最低,适合对性能要求高而精度容忍度大的场景。
5 点线性平滑示意 — A、B 由历史点 p0、p1、p2 推算

Bezier 三点共线特性

P2(第一条曲线第二控制点)、P3(连接点)、P4(第二条曲线第一控制点)在同一直线上,保证连接处切线方向一致。编辑器中可做自动微调让三点始终共线。

P2、P3、P4 三点共线 — 保证连接处切线方向一致
注意:连接处平滑 ≠ 过渡自然。曲率变化偏差较大时仍会引发视觉抖动,编辑时需兼顾曲率连续性控制。

M-Spline 方法实现

M-Spline vs Bezier

对比维度M-SplineBezier
经过控制点是 — 所有控制点仅首尾端点
控制点影响全局(每点影响整条曲线)局部(仅相邻段)
参数范围0~1 全局值,受 float 精度限制分段参数,可任意长
适用场景短曲线、所见即所得编辑长曲线、局部精确控制

算法整体架构

初始化流程 — Make(points)
Make(points)
计算差分 dtbl[]
三对角前向消元
回代求解 ftbl[]
CalcSectionLength(×N)
就绪
运行时查询 — GetPoint(rate)
输入 rate (0~1)
GetAllRateToSectionRate
section + sectionRate
GetPoint(sec, rate)
Vector3 坐标

Make() — 三对角方程组求解

构造核心。使用追赶法(Thomas Algorithm)求解自然三次样条的二阶导数向量,边界条件 f''(0) = f''(n-1) = 0。

Make() 步骤分解
输入 Vector3[] points — 控制点序列
dtbl[i+1] = points[i+1] - points[i](一阶差分)
ftbl[1] = dtbl[2] - dtbl[1](右端项初始化)
前向消元:对角系数 4,消去下对角 -1/w[i]
回代:ftbl[i] = (ftbl[i] - ftbl[i+1]) / w[i]
对每段执行 CalcSectionLength(i) 存入 sectionLength[]
输出:ftbl[](二阶导), dtbl[](差分), sectionLength[](弧长)
三对角矩阵系数为 4(主对角),-1(上下对角)。这是三次样条 C2 连续性的数学要求——相邻段在连接处的二阶导数相等。

GetPoint() — 三次多项式求值(Horner 形式)

给定段编号和局部参数 t∈[0,1],用嵌套乘法计算三次 Hermite 插值多项式:

GetPoint(section, rate) — 逐步展开
v = (ftbl[i+1] - ftbl[i]) × t
v = (v + 3×ftbl[i]) × t
v = (v + dtbl[i+1] - 2×ftbl[i] - ftbl[i+1]) × t
result = v + posTbl[i]
等价于 P(t) = at³ + bt² + ct + d,Horner 嵌套减少乘法次数(6次 → 3次),且数值更稳定。

GetAllRateToSectionRate() — 全局/局部参数映射

弧长参数映射
targetDist = rate × totalLength
遍历各段,累加 sectionLength[i]
找到 accum + sectionLength[i] > targetDist 的第 i 段
section = i, sectionRate = (targetDist - accum) / sectionLength[i]

CalcSectionLength() — 弧长数值积分

对每段在 [0,1] 上积分 |GetSlope(section, t)| dt,步长 h = 1/lengthDiv。三种方法并存,通过 useMethod 枚举切换:

Euler 法
for t = 0 → 1, step h
slope = GetSlope(sec, t)
len = |slope|
sum += h × len
L ≈ ∑ h⋅|f'(ti)|
精度 O(h) · 1 次采样/步
改进 Euler 法
for t = 0 → 1-h, step h
len1 = |GetSlope(sec, t)|
len2 = |GetSlope(sec, t+h)|
sum += h×(len1+len2)/2
L ≈ ∑ h⋅(|f'i|+|f'i+1|)/2
精度 O(h²) · 2 次采样/步
RungeKutta 四阶
k1 = h × |slope(t)|
k2 = h × |slope(t+0.5k1)|
k3 = h × |slope(t+0.5k2)|
k4 = h × |slope(t+k3)|
L += (k1+2k2+2k3+k4)/6
精度 O(h&sup4) · 4 次采样/步

GetInvR() — 曲率估算

用前后两点的切线角度差估算局部曲率(逆半径),用于运行时转弯判断:

曲率计算流程
取前后两点: rate ± (2.5 / Length)
分别求切线方向 → 转为 angle(世界坐标系)
Δangle = angle2 - angle1(限制在 ±180°)
invR = atan(Δangle × 0.2) / (π/2) / 30

API 总览

Make()
构造样条:求解三对角方程组,预计算各段弧长
Vector3[] → void
GetPoint()
获取曲线上任意位置的坐标(支持全局/局部参数)
float → Vector3
GetSlope()
获取指定位置的切线向量(一阶导数)
float → Vector3
GetInvR()
估算曲率(逆半径),用于转弯检测和路径跟随
float → float
IteratePathPosisionRatio()
等距步进(Newton 迭代),解决弧长参数化匀速移动
(rate,off,len) → float
GetWidth()
获取路径宽度(当前固定 0.2,预留动态宽度接口)
(sec,rate,side) → float
Unity 编辑器中 M-Spline 效果
控制点拖动实时更新曲线

完整实现

MSplineScript.cs C# / Unity
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using System.Collections;

public class MSplineScript : MonoBehaviour
{
    public static MSplineScript ins = null;

    protected Vector3[] dtbl;
    protected Vector3[] ftbl;
    public bool isMaked;
    private float length_;
    protected int lengthDiv = 8;
    public Vector3[] posTbl;
    protected float[] sectionLength;
    protected Method useMethod = Method.ImprovedEuler;
    void Awake()
    {
        ins = this;
    }
    ArrayList mpoints = new ArrayList();
    void Start()
    {
        ins = this;
        var ps = this.GetComponentsInChildren<Transform>();
        var vs = new Vector3[ps.Length];
        int i = 0;
        foreach (var p in ps)
        {
            vs[i] = p.position;
            ++i;
        }
        this.Make(vs);

        mpoints.Clear();
        var pp = this.GetComponentsInChildren<MPont>();
        foreach (var p in pp)
        {
            for (float iter = 0f; iter < 1f; iter += 0.001f)
            {
                if (Vector3.Distance(this.GetPoint(iter), p.transform.position) < 1f)
                // 注意这个1f的距离取决于 曲线的精度,如果曲线很长,那么这个1可能不够
                {
                    p.rate = iter;
                    break;
                }
            }
            mpoints.Add(p);
        }
    }
    public BezierDirection GetDirection(float rate)
    {
        for (int i = mpoints.Count - 2; i >= 0; i--)
        {
            var p = mpoints[i] as MPont;
            if (rate >= p.rate)
            {
                return p.direction;
            }
        }
        return BezierDirection.None;
    }
    protected float CalcSectionLength(int section)
    {
        Vector3 slope;
        float num2;
        float num3;
        int num = section;
        float num6 = 1f / ((float)this.lengthDiv);
        float num7 = 0f;
        switch (this.useMethod)
        {
            case Method.Euler:
                for (int i = 0; i <= this.lengthDiv; i++)
                {
                    float rate = i * num6;
                    slope = this.GetSlope(num, rate);
                    slope.Scale(slope);
                    num2 = Mathf.Sqrt((slope.x + slope.y) + slope.z);
                    num7 += num6 * num2;
                }
                return num7;

            case Method.ImprovedEuler:
                for (int j = 0; j <= (this.lengthDiv - 1); j++)
                {
                    float num9 = j * num6;
                    slope = this.GetSlope(num, num9);
                    slope.Scale(slope);
                    num2 = Mathf.Sqrt((slope.x + slope.y) + slope.z);
                    slope = this.GetSlope(num, (j + 1) * num6);
                    slope.Scale(slope);
                    num3 = Mathf.Sqrt((slope.x + slope.y) + slope.z);
                    num7 += (num6 * (num2 + num3)) * 0.5f;
                }
                return num7;

            case Method.RungeKutta:
                for (int k = 0; k <= this.lengthDiv; k++)
                {
                    float num11 = k * num6;
                    slope = this.GetSlope(num, num11);
                    slope.Scale(slope);
                    num2 = Mathf.Sqrt((slope.x + slope.y) + slope.z) * num6;
                    slope = this.GetSlope(num, num11 + (0.5f * num2));
                    slope.Scale(slope);
                    num3 = Mathf.Sqrt((slope.x + slope.y) + slope.z) * num6;
                    slope = this.GetSlope(num, num11 + (0.5f * num3));
                    slope.Scale(slope);
                    float num4 = Mathf.Sqrt((slope.x + slope.y) + slope.z) * num6;
                    slope = this.GetSlope(num, num11 + num4);
                    slope.Scale(slope);
                    float num5 = Mathf.Sqrt((slope.x + slope.y) + slope.z) * num6;
                    num7 += ((num2 + (2f * (num3 + num4))) + num5) / 6f;
                }
                return num7;
        }
        return num7;
    }

    public void GetAllRateToSectionRate(float rate, out int section, out float sectionRate)
    {
        rate = Mathf.Clamp(rate, 0f, 1f);
        section = 0;
        sectionRate = 0f;
        if (rate <= 0f)
        {
            section = 0;
            sectionRate = 0f;
        }
        else if (rate >= 1f)
        {
            section = this.posTbl.Length - 1;
            sectionRate = 0f;
        }
        else
        {
            float length = this.Length;
            float num2 = rate * length;
            float num3 = 0f;
            if (this.posTbl != null)
            {
                for (int i = 0; i < this.posTbl.Length; i++)
                {
                    float sectionLength = this.GetSectionLength(i);
                    if ((num3 + sectionLength) > num2)
                    {
                        section = i;
                        float num6 = num2 - num3;
                        sectionRate = num6 / sectionLength;
                        break;
                    }
                    num3 += sectionLength;
                }
            }
        }
    }

    public Vector3 GetPoint(float rate)
    {
        int num;
        float num2;
        this.GetAllRateToSectionRate(rate, out num, out num2);
        return this.GetPoint(num, num2);
    }

    public Vector3 GetPoint(int section, float rate)
    {
        if (!this.isMaked)
        {
            return Vector3.zero;
        }
        if (section >= this.posTbl.Length)
        {
            throw new IndexOutOfRangeException();
        }
        if (this.ftbl == null)
        {
            return Vector3.zero;
        }
        if (this.ftbl.Length == 0)
        {
            return Vector3.zero;
        }
        int index = section;
        Vector3 scale = new Vector3(rate, rate, rate);
        Vector3 vector = this.ftbl[index + 1] - this.ftbl[index];
        vector.Scale(scale);
        Vector3 vector2 = Vector3.Scale(this.ftbl[index], new Vector3(3f, 3f, 3f));
        vector += vector2;
        vector.Scale(scale);
        vector += this.dtbl[index + 1];
        vector2 = Vector3.Scale(this.ftbl[index], new Vector3(2f, 2f, 2f));
        vector -= vector2;
        vector -= this.ftbl[index + 1];
        vector.Scale(scale);
        return (vector + this.posTbl[index]);
    }

    public int GetSection(float rate)
    {
        int num;
        float num2;
        this.GetAllRateToSectionRate(rate, out num, out num2);
        return num;
    }

    public float GetSectionLength(int section)
    {
        if ((this.sectionLength != null) && ((section < this.sectionLength.Length) && (this.sectionLength != null)))
        {
            return this.sectionLength[section];
        }
        return 0f;
    }

    public float GetSectionRate(int section)
    {
        if ((section >= 0) && (section < (this.posTbl.Length - 1)))
        {
            return (this.GetSectionLength(section) / this.Length);
        }
        return 0f;
    }

    public float GetSectionRateToAllRate(int section, float rate)
    {
        if (section < 0)
        {
            return 0f;
        }
        if (section > (this.posTbl.Length - 1))
        {
            return 1f;
        }
        float num = 0f;
        for (int i = 0; i < this.posTbl.Length; i++)
        {
            if (i < section)
            {
                num += this.GetSectionRate(i);
            }
            else
            {
                return (num + (this.GetSectionRate(i) * rate));
            }
        }
        return num;
    }

    public Vector3 GetSlope(float rate)
    {
        int num;
        float num2;
        this.GetAllRateToSectionRate(rate, out num, out num2);
        return this.GetSlope(num, num2);
    }

    public Vector3 GetSlope(int section, float rate)
    {
        if (section >= this.posTbl.Length)
        {
            throw new IndexOutOfRangeException();
        }
        if (this.ftbl == null)
        {
            return Vector3.zero;
        }
        if (this.ftbl.Length == 0)
        {
            return Vector3.zero;
        }
        int index = section;
        Vector3 a = new Vector3(rate, rate, rate);
        Vector3 vector = this.ftbl[index + 1] - this.ftbl[index];
        vector.Scale(Vector3.Scale(a, new Vector3(3f, 3f, 3f)));
        Vector3 vector2 = Vector3.Scale(this.ftbl[index], new Vector3(6f, 6f, 6f));
        vector += vector2;
        vector.Scale(a);
        vector += this.dtbl[index + 1];
        vector2 = Vector3.Scale(this.ftbl[index], new Vector3(2f, 2f, 2f));
        vector -= vector2;
        return (vector - this.ftbl[index + 1]);
    }

    public void Make(Vector3[] points)
    {
        int num2;
        this.posTbl = new Vector3[points.Length];
        points.CopyTo(this.posTbl, 0);
        Vector3[] vectorArray = new Vector3[points.Length];
        this.dtbl = new Vector3[2 * points.Length];
        this.ftbl = new Vector3[2 * points.Length];
        int length = points.Length;
        Vector3[] vectorArray2 = vectorArray;
        Vector3[] dtbl = this.dtbl;
        Vector3[] ftbl = this.ftbl;
        ftbl[0].x = ftbl[length - 1].x = ftbl[0].y = ftbl[length - 1].y = ftbl[0].z = ftbl[length - 1].z = 0f;
        for (num2 = 0; num2 < (length - 1); num2++)
        {
            dtbl[num2 + 1] = this.posTbl[num2 + 1] - this.posTbl[num2];
        }
        ftbl[1] = dtbl[2] - dtbl[1];
        vectorArray2[1].x = vectorArray2[1].y = vectorArray2[1].z = 4f;
        for (num2 = 1; num2 < (length - 2); num2++)
        {
            Vector3 scale = new Vector3(-1f / vectorArray2[num2].x, -1f / vectorArray2[num2].y, -1f / vectorArray2[num2].z);
            Vector3 vector = ftbl[num2];
            vector.Scale(scale);
            ftbl[num2 + 1] = dtbl[num2 + 2] - dtbl[num2 + 1];
            ftbl[num2 + 1] += vector;
            vectorArray2[num2 + 1] = new Vector3(4f, 4f, 4f);
            vectorArray2[num2 + 1] += scale;
        }
        ftbl[length - 2].x -= ftbl[length - 1].x;
        ftbl[length - 2].y -= ftbl[length - 1].y;
        ftbl[length - 2].z -= ftbl[length - 1].z;
        for (num2 = length - 2; num2 > 0; num2--)
        {
            ftbl[num2].x = (ftbl[num2].x - ftbl[num2 + 1].x) / vectorArray2[num2].x;
            ftbl[num2].y = (ftbl[num2].y - ftbl[num2 + 1].y) / vectorArray2[num2].y;
            ftbl[num2].z = (ftbl[num2].z - ftbl[num2 + 1].z) / vectorArray2[num2].z;
        }
        this.sectionLength = new float[this.posTbl.Length];
        for (num2 = 0; num2 < this.posTbl.Length; num2++)
        {
            this.sectionLength[num2] = this.CalcSectionLength(num2);
        }
        this.isMaked = true;
        this.length_ = 0f;
    }

    protected void GetAxisAndAngleOfPath(float rate, out Vector3 axis, out float angle)
    {
        Vector3 slope = GetSlope(rate);
        if (slope.magnitude < 0.001f)
        {
            axis = Vector3.up;
            angle = 0f;
        }
        else
        {
            Quaternion.LookRotation(slope).ToAngleAxis(out angle, out axis);
            if (axis.y < 0f)
            {
                axis.Scale(new Vector3(-1f, -1f, -1f));
                angle = 360f - angle;
            }
        }
    }


    void OnDrawGizmos()
    {
        return;
        Start();
        for (float t = 0f; t < 1f; t += 0.01f)
        {
            Debug.DrawLine(this.GetPoint(t), this.GetPoint(t + 0.01f));
        }
    }
    public float Length
    {
        get
        {
            if (!Application.isPlaying || (this.length_ == 0f))
            {
                float num = 0f;
                if (this.posTbl != null)
                {
                    for (int i = 0; i < (this.posTbl.Length - 1); i++)
                    {
                        num += this.GetSectionLength(i);
                    }
                }
                this.length_ = num;
            }
            return this.length_;
        }
    }


    //-----------------------------------------------------------------------------

    public float GetInvR(float nowRate)
    {
        Vector3 vector;
        Vector3 vector2;
        float num2;
        float num3;
        float num4 = 5f;
        float rate = Mathf.Clamp01(nowRate - ((num4 * 0.5f) / Length));
        float num6 = Mathf.Clamp01(nowRate + ((num4 * 0.5f) / Length));
        this.GetAxisAndAngleOfPath(rate, out vector, out num2);// 直接坐标切线
        this.GetAxisAndAngleOfPath(num6, out vector2, out num3);
        float num7 = num3 - num2;// 当前2个点的世界坐标原点 切线的角度,  角度是基于世界坐标原点而来
        if (num7 > 180f)
        {
            num7 -= 360f;
        }
        if (num7 < -180f)
        {
            num7 += 360f;
        }
        float num8 = 30f;
        float num9 = 0.2f;
        float num = Mathf.Atan(num7 * num9) / 1.570796f;// 角度变化量*0.2
        float invR = (num * (1f / num8));
        return invR;
    }

    [HideInInspector]
    public bool CalcRate = false;

    public float rate;
    public void Update()
    {
        ins = this;
        if (CalcRate == false) return;

        var ps = this.GetComponentsInChildren<Transform>();
        var vs = new Vector3[ps.Length];
        int i = 0;
        foreach (var p in ps)
        {
            vs[i] = p.position;
            ++i;
        }
        this.Make(vs);

        GameObject.Find("CalcRate").transform.position = GetPoint(rate);
    }

    float nowRate = 0f;

    public float IteratePathPosisionRatio(float nowRate, float sideOffset, float length)
    {
        float num = length / this.Length;
        Vector3 point = this.GetPoint(nowRate, sideOffset);
        for (int i = 0; i < 10; i++)
        {
            Vector3 vector3 = this.GetPoint(nowRate + num, sideOffset) - point;
            if (vector3.magnitude == 0f)
            {
                break;
            }
            float num3 = length / vector3.magnitude;
            float num4 = 0.05f;
            if (Mathf.Abs((float)(num3 - 1f)) <= num4)
            {
                break;
            }
            num *= ((num3 - 1f) * 0.75f) + 1f;
        }
        float b = nowRate + num;
        return Mathf.Min(1f, b);
    }
    public Vector3 GetPoint(float rate, float sideOffsetRate)
    {
        int num;
        float num2;
        this.GetAllRateToSectionRate(rate, out num, out num2);
        return this.GetPoint(num, num2, sideOffsetRate);
    }

    public Vector3 GetPoint(int section, float sectionRate, float sideOffsetRate)
    {
        Vector3 point;
        if (sideOffsetRate == 0f)
        {
            point = this.GetPoint(section, sectionRate);
        }
        else
        {
            Vector3 normalized = this.GetSlope(section, sectionRate).normalized;
            Vector3 vector3 = Vector3.Cross(Vector3.up, normalized);
            float num = this.GetWidth(section, sectionRate, sideOffsetRate) * sideOffsetRate;
            point = ((Vector3)(vector3 * num)) + this.GetPoint(section, sectionRate);
        }
        point.y = 0.1f;
        return point;
    }

    public float GetWidth(float rate, float side)
    {
        int num;
        float num2;
        this.GetAllRateToSectionRate(rate, out num, out num2);
        return this.GetWidth(num, num2, side);
    }

    public float GetWidth(int section, float rate, float side)
    {
        return 0.2f;

        /*
           float from = this.pointList[section].getWidth(side);
           if (section < (this.posTbl.Length - 1))
           {
               from = Mathf.Lerp(from, this.pointList[section + 1].getWidth(side), rate);
           }
           return from;*/
    }





    protected enum Method
    {
        Euler,
        ImprovedEuler,
        RungeKutta
    }
}