1. 为什么要在Unity里手搓一条贝塞尔曲线
如果你做过Unity项目,大概率遇到过这些场景:想让UI元素沿着一条平滑弧线飞入屏幕、想给相机做一个柔和的转场路径、想在策略游戏里画出部队的行军路线预览、或者想给技能编辑器加一个可拖拽的抛物线指示器。这些需求的共同点是什么?直线插值太生硬,动画曲线又不够自由,你需要一条能随时调整形状、能精确控制弯曲程度的曲线。贝塞尔曲线就是干这个的。
我在实际项目里第一次认真用贝塞尔曲线,是做一个塔防游戏的怪物路径系统。当时策划要求路径必须是平滑的,不能有折角,而且要在编辑器里可视化拖拽控制点。一开始想用Unity自带的LineRenderer加动画曲线凑合,结果发现根本没法自由控制弯曲方向。后来老老实实把贝塞尔曲线的数学原理啃了一遍,自己写了一套绘制和采样逻辑,才发现这东西一旦理解透了,用起来是真的顺手。
这篇文章面向的是有一定Unity基础、想在项目里实际用上贝塞尔曲线的开发者。不管你是做UI动效、路径规划、还是编辑器工具,只要涉及“让物体沿一条平滑曲线运动”或者“在场景里画出平滑曲线”,这里的内容都能直接拿去用。我会从数学原理讲到代码实现,再到实际项目中的踩坑经验,尽量把每个环节的“为什么”说清楚,让你不只是抄代码,而是真正理解自己在干什么。
2. 贝塞尔曲线的核心原理与方案选型
2.1 从生活场景理解贝塞尔曲线
先别急着看公式。想象你手里有一根有弹性的细钢丝,两端固定在墙上,中间用手往某个方向拉一下再松手,钢丝会自然弯曲成一条平滑的弧线。贝塞尔曲线本质上就是在用数学方法描述这条弧线。你拉的那个位置就是“控制点”,钢丝两端就是“起点”和“终点”。控制点拉得越远,曲线弯曲得越厉害;控制点位置变了,曲线的形状也跟着变。
在计算机图形学里,贝塞尔曲线是一组用伯恩斯坦多项式定义参数曲线的数学工具。最常见的是一阶、二阶、三阶,分别对应1个、2个、3个控制点。Unity项目里用得最多的是三阶贝塞尔曲线,因为它有起点、终点加两个控制点,一共四个点,既能画出足够复杂的形状,计算量又不会太大。
2.2 为什么选三阶而不是二阶或更高阶
这里有个实际项目中的选型逻辑。二阶贝塞尔曲线只有一个控制点,能画出的形状比较有限,基本就是一条简单的弧线,弯曲方向完全由那一个控制点决定。如果你要做一条S形路径,二阶就做不到了。三阶有两个控制点,可以分别控制起点和终点附近的切线方向,能画出S形、波浪形、甚至接近圆角的形状,灵活性大幅提升。
那为什么不直接用更高阶的?四阶、五阶理论上可以画出更复杂的曲线,但计算量会上去,而且控制点越多,手动调整越困难。在编辑器里拖拽四个点已经够直观了,拖六个点就开始晕了。另外,高阶贝塞尔曲线在数值稳定性上也会变差,容易出现曲线抖动或者自交的情况。所以三阶是工程上的甜点区,够用、好调、算得快。
2.3 两种实现路线的取舍
在Unity里实现贝塞尔曲线,有两条路可以走。第一条是纯数学计算,自己写公式,每帧采样若干点,然后用LineRenderer或者GL接口画出来。第二条是借助Unity的AnimationCurve或者第三方样条插件。我两条路都试过,最后在项目里选了纯数学计算。
原因很简单:AnimationCurve本质上是二维的,只能表达y随x变化的关系,没法处理三维空间里的曲线,也没法做闭合曲线。第三方插件虽然功能全,但引入依赖会增加项目复杂度,而且很多插件封装的太厚,想改一个采样精度都要翻半天源码。自己写公式虽然前期多花一两个小时,但后面想怎么改就怎么改,调试也方便。对于大多数中小型项目来说,自己实现一套轻量的贝塞尔曲线工具是性价比最高的选择。
3. 三阶贝塞尔曲线的数学拆解与代码实现
3.1 公式推导与参数含义
三阶贝塞尔曲线的参数方程长这样:
B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
其中t是参数,取值范围0到1。P₀是起点,P₃是终点,P₁和P₂是两个控制点。这个公式看起来有点吓人,但拆开看就很好理解。四个项分别对应四个点的权重,权重之和恒等于1。当t=0时,只有第一项为1,其余为0,所以B(0)=P₀;当t=1时,只有最后一项为1,所以B(1)=P₃。中间过程就是四个点按不同权重“拉扯”出来的结果。
我在第一次实现的时候,犯过一个低级错误:把t理解成了“时间”。其实t只是一个归一化的参数,它和真实时间没有直接关系。如果你想让物体沿曲线匀速运动,不能简单地让t随时间线性增加,因为贝塞尔曲线的参数化不是弧长参数化,t均匀增加时,物体在曲线上的移动速度是不均匀的。这个问题后面会专门讲怎么解决。
3.2 在C#中实现核心计算函数
下面是我在项目里实际用的核心计算代码,放在一个静态工具类里:
using UnityEngine; public static class BezierUtility { // 三阶贝塞尔曲线求值 public static Vector3 EvaluateCubic(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float oneMinusT = 1f - t; float oneMinusTSq = oneMinusT * oneMinusT; float tSq = t * t; return oneMinusTSq * oneMinusT * p0 + 3f * oneMinusTSq * t * p1 + 3f * oneMinusT * tSq * p2 + tSq * t * p3; } // 计算切线方向,用于让物体朝向曲线前进方向 public static Vector3 EvaluateTangent(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float oneMinusT = 1f - t; Vector3 tangent = 3f * oneMinusT * oneMinusT * (p1 - p0) + 6f * oneMinusT * t * (p2 - p1) + 3f * t * t * (p3 - p2); return tangent.normalized; } }这段代码有两个关键点。第一,Mathf.Clamp01(t)是必要的保护,防止外部传入越界的t导致曲线跑到控制点外面去。第二,切线函数是单独写的,因为很多场景下你不仅需要位置,还需要知道曲线在该点的朝向。比如让一个箭头沿着曲线飞,箭头必须始终指向切线方向,否则看起来会很别扭。
3.3 采样精度与性能的平衡
用LineRenderer画曲线的时候,你需要把曲线离散成若干个点。采样点数量直接决定了曲线的平滑度和性能开销。我一般会根据曲线在屏幕上的大致长度来动态决定采样数,经验公式是:
采样数 = Mathf.Clamp(曲线包围盒对角线长度 × 2, 20, 200)
这个公式的意思是,曲线越长,需要的采样点越多,但最少20个点保证基本平滑,最多200个点防止性能爆炸。在移动端项目里,我会把上限压到100,因为手机GPU对顶点数的敏感度比PC高得多。
还有一个细节:采样点不是越多越好。当采样数超过一定阈值后,视觉上已经看不出区别了,但每多一个点就多一次顶点计算和一次绘制调用。我实测过,在1080p屏幕上,一条横跨屏幕的曲线用60个点和用200个点,肉眼几乎分辨不出来,但帧率能差出3到5帧。所以别盲目堆采样数,够用就行。
4. 完整实操流程:从零搭建一个可交互的贝塞尔曲线工具
4.1 场景搭建与组件准备
先建一个空场景,创建一个空物体命名为BezierController,挂上下面这个脚本。这个脚本的核心职责是管理四个控制点的Transform引用,并在每帧更新时重新计算曲线并刷新LineRenderer。
using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class BezierController : MonoBehaviour { [Header("控制点")] public Transform point0; public Transform point1; public Transform point2; public Transform point3; [Header("曲线设置")] [Range(10, 200)] public int sampleCount = 60; public float lineWidth = 0.1f; private LineRenderer lineRenderer; void Awake() { lineRenderer = GetComponent<LineRenderer>(); lineRenderer.positionCount = sampleCount; lineRenderer.startWidth = lineWidth; lineRenderer.endWidth = lineWidth; lineRenderer.useWorldSpace = true; } void Update() { if (point0 == null || point1 == null || point2 == null || point3 == null) return; for (int i = 0; i < sampleCount; i++) { float t = i / (float)(sampleCount - 1); Vector3 pos = BezierUtility.EvaluateCubic( point0.position, point1.position, point2.position, point3.position, t ); lineRenderer.SetPosition(i, pos); } } }然后在场景里创建四个小球,分别拖到四个控制点槽位里。运行之后,你拖动任意一个小球,曲线都会实时更新。这个就是最基础的交互式贝塞尔曲线编辑器雏形。
4.2 让物体沿曲线运动并自动朝向
光画出曲线还不够,实际项目里更常见的是让某个物体沿着曲线移动。这里的关键是解决前面提到的“t均匀增加但速度不均匀”的问题。我的做法是预先计算一张弧长表,然后根据目标速度反查t值。
using UnityEngine; public class BezierMover : MonoBehaviour { public BezierController bezier; public float speed = 2f; public bool loop = true; private float[] arcLengthTable; private float totalLength; private const int TABLE_SIZE = 200; private float progress = 0f; void Start() { BuildArcLengthTable(); } void BuildArcLengthTable() { arcLengthTable = new float[TABLE_SIZE]; Vector3 prev = BezierUtility.EvaluateCubic( bezier.point0.position, bezier.point1.position, bezier.point2.position, bezier.point3.position, 0f ); arcLengthTable[0] = 0f; for (int i = 1; i < TABLE_SIZE; i++) { float t = i / (float)(TABLE_SIZE - 1); Vector3 current = BezierUtility.EvaluateCubic( bezier.point0.position, bezier.point1.position, bezier.point2.position, bezier.point3.position, t ); arcLengthTable[i] = arcLengthTable[i - 1] + Vector3.Distance(prev, current); prev = current; } totalLength = arcLengthTable[TABLE_SIZE - 1]; } void Update() { if (totalLength <= 0f) return; progress += (speed / totalLength) * Time.deltaTime; if (loop) progress = Mathf.Repeat(progress, 1f); else progress = Mathf.Clamp01(progress); float t = ArcLengthToT(progress * totalLength); Vector3 pos = BezierUtility.EvaluateCubic( bezier.point0.position, bezier.point1.position, bezier.point2.position, bezier.point3.position, t ); Vector3 tangent = BezierUtility.EvaluateTangent( bezier.point0.position, bezier.point1.position, bezier.point2.position, bezier.point3.position, t ); transform.position = pos; if (tangent.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.LookRotation(tangent); } float ArcLengthToT(float targetLength) { // 二分查找 int low = 0, high = TABLE_SIZE - 1; while (low < high - 1) { int mid = (low + high) / 2; if (arcLengthTable[mid] < targetLength) low = mid; else high = mid; } float segmentLength = arcLengthTable[high] - arcLengthTable[low]; if (segmentLength <= 0f) return low / (float)(TABLE_SIZE - 1); float ratio = (targetLength - arcLengthTable[low]) / segmentLength; return (low + ratio) / (float)(TABLE_SIZE - 1); } }这段代码里有个细节值得说:弧长表的精度直接决定了匀速运动的效果。TABLE_SIZE设成200是我在多个项目里试出来的平衡点。设成100的话,快速移动时能感觉到轻微的速度波动;设成500的话,内存占用和构建时间都会上去,但视觉提升几乎为零。200是一个比较稳妥的默认值。
4.3 在编辑器里可视化控制点连线
调试的时候,光看曲线本身不够直观,最好把控制点和控制多边形也画出来。Unity的OnDrawGizmos非常适合干这个:
void OnDrawGizmos() { if (point0 == null || point1 == null || point2 == null || point3 == null) return; // 画控制多边形 Gizmos.color = Color.gray; Gizmos.DrawLine(point0.position, point1.position); Gizmos.DrawLine(point2.position, point3.position); // 画控制点 Gizmos.color = Color.yellow; Gizmos.DrawSphere(point0.position, 0.15f); Gizmos.DrawSphere(point3.position, 0.15f); Gizmos.color = Color.cyan; Gizmos.DrawSphere(point1.position, 0.12f); Gizmos.DrawSphere(point2.position, 0.12f); }灰色线是控制多边形,它告诉你控制点是怎么“拉扯”曲线的。黄色球是起点和终点,青色球是控制点。这个可视化在调试路径的时候特别有用,你能一眼看出哪个控制点需要调整。
5. 实际项目中的常见问题与排查技巧
5.1 曲线抖动或出现折角
这是最常见的问题,通常有三个原因。第一,采样数太低。如果你只采样10个点,曲线看起来就是折线。解决办法是提高sampleCount,或者根据曲线长度动态计算。第二,控制点位置突变。如果你在代码里直接修改控制点的position,而修改频率和Update不同步,就会出现抖动。解决办法是把控制点修改放在LateUpdate里,或者用插值平滑过渡。第三,LineRenderer的alignment设置不对。如果曲线在三维空间里拐弯,而LineRenderer的alignment设成了View,线条会始终面向相机,在某些角度下看起来就像折了。改成TransformZ或者用自定义的曲线网格可以解决。
5.2 物体沿曲线运动时速度忽快忽慢
这个问题我在第一个版本里踩过坑。当时直接让t随时间线性增加,结果物体在曲线弯曲的地方明显变慢,在平直的地方又突然加速。原因就是贝塞尔曲线的参数化不是弧长参数化。解决办法就是前面说的弧长表加二分查找。这里有个经验:弧长表的构建最好放在Start或者Awake里,不要每帧重建,否则性能开销会很大。如果控制点会动态变化,那就需要每帧重建,但可以把TABLE_SIZE降到100来减少开销。
5.3 曲线跑到控制点外面去了
有些开发者会误以为贝塞尔曲线一定会经过所有控制点。实际上除了起点和终点,中间的控制点只是“吸引”曲线靠近,曲线并不会真正穿过它们。如果你需要曲线穿过某个特定点,要么调整控制点位置,要么用Catmull-Rom样条代替贝塞尔。Catmull-Rom的特点是曲线会穿过所有控制点,适合做路径编辑。贝塞尔更适合做形状设计,因为它的切线控制更直观。
5.4 性能问题排查速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 帧率下降 | 采样数过高 | 在Profiler里看LineRenderer的顶点数 | 降低sampleCount或动态调整 |
| 曲线闪烁 | 控制点每帧被重置 | 检查是否有代码在Update里修改控制点 | 把修改逻辑移到LateUpdate |
| 移动端发热 | 每帧重建弧长表 | 看CPU占用是否集中在BuildArcLengthTable | 缓存弧长表,只在必要时重建 |
| 曲线显示不全 | LineRenderer的positionCount没同步 | 检查positionCount和实际设置的点数是否一致 | 在设置点之前先设positionCount |
提示:在移动端项目里,如果曲线只是用来做UI指示器,可以考虑用Mesh代替LineRenderer,因为LineRenderer在部分安卓机型上有兼容性问题,画出来的线宽会不一致。
6. 进阶扩展:从单条曲线到曲线系统
6.1 多段贝塞尔曲线拼接
单条三阶贝塞尔曲线能表达的形状有限。如果你需要一条很长的、有多个弯的路径,就需要把多条贝塞尔曲线首尾拼接起来。拼接的关键是保证连接点处的切线连续,否则会出现明显的折角。具体做法是:前一条曲线的P₃等于后一条曲线的P₀,并且前一条曲线的P₂、连接点、后一条曲线的P₁三点共线。这样拼接出来的曲线在连接点处是光滑的。
我在做赛道编辑器的时候用过这个技巧。整条赛道由十几段贝塞尔曲线组成,每段的控制点都可以独立调整,但拼接处自动保持切线连续。实现方式是在编辑器里加一个“自动对齐”按钮,点击后自动调整相邻控制点的位置。
6.2 贝塞尔曲线在UI动效中的应用
UI动效里用贝塞尔曲线,最常见的是做缓动函数。Unity的AnimationCurve本质上就是贝塞尔曲线的一种应用。但如果你想做更复杂的UI路径动画,比如让一个图标沿着弧线飞到目标位置,就需要自己采样曲线并驱动RectTransform的位置。
这里有个坑:UI的坐标系和世界坐标系不一样,RectTransform的anchoredPosition是相对于父节点的。如果你直接把世界空间的贝塞尔曲线采样点赋给anchoredPosition,位置会完全不对。正确的做法是把控制点也转换到UI的局部坐标系里,或者用RectTransformUtility.ScreenPointToLocalPointInRectangle做转换。
6.3 用贝塞尔曲线做抛物线指示器
很多游戏里都有扔手雷或者射箭的抛物线预览。这个用贝塞尔曲线做特别合适。起点是角色手部位置,终点是鼠标点击的地面位置,中间的控制点根据力度和角度计算。具体公式是:控制点1 = 起点 + 方向 × 力度 × 0.3,控制点2 = 终点 - 方向 × 力度 × 0.3。这样调整力度的时候,抛物线的弯曲程度会自然变化。
我实测下来,这种参数化方式比直接用物理公式计算抛物线要直观得多,策划也能自己调。而且贝塞尔曲线不涉及重力加速度这些物理量,纯几何计算,性能开销几乎可以忽略。
7. 我个人在实际操作中的几点体会
第一个体会是:别怕数学。贝塞尔曲线的公式看起来复杂,但真正用到的就是那几个乘法和加法。你不需要理解伯恩斯坦多项式的证明,只需要知道每个点的权重是怎么分配的就行。我见过很多开发者一看到公式就绕道走,结果用插件用了半年还不知道怎么改采样精度。
第二个体会是:可视化调试的时间投入绝对值得。我一开始为了省时间,没写Gizmos绘制,结果调控制点的时候全靠猜,一个路径调了整整一个下午。后来花二十分钟把控制点和控制多边形画出来,同样的路径十分钟就调好了。在编辑器工具上多花的时间,会在后续调试里加倍还回来。
第三个体会是:弧长表是个好东西,但别滥用。如果你的曲线是静态的,弧长表建一次就够了。如果曲线会动态变化,那就要权衡重建频率和精度。我一般会加一个阈值,控制点移动距离小于0.01的时候不重建,大于这个值才重建,这样能省下大量不必要的计算。
第四个体会是:移动端项目里,LineRenderer的材质要用Unlit的,别用受光照影响的Shader。贝塞尔曲线通常只是辅助线或者UI元素,不需要参与光照计算。用Unlit Shader能省下不少GPU开销,而且颜色更纯粹,不会因为场景光照变化而变色。
最后分享一个小技巧:如果你需要在运行时动态增删控制点,可以考虑把贝塞尔曲线的阶数做成可配置的。虽然三阶最常用,但偶尔也会遇到需要二阶或者四阶的场景。把阶数做成参数,用循环计算权重,代码会更通用。不过要注意,阶数越高,采样数也要相应增加,否则曲线会显得不够平滑。