曲线间平滑计算方法和一个 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-Spline | Bezier |
|---|---|---|
| 经过控制点 | 是 — 所有控制点 | 仅首尾端点 |
| 控制点影响 | 全局(每点影响整条曲线) | 局部(仅相邻段) |
| 参数范围 | 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
}
}