1. 为什么要在Unity里手搓一条贝塞尔曲线
第一次接触贝塞尔曲线,是在做一个弹道预览功能的时候。当时策划要求“子弹飞出去之前,先画一条弧线给玩家看落点”,我第一反应是找现成的插件,翻了一圈发现要么太重,要么曲线控制不够灵活,最后还是决定自己写一套。结果这一写,发现贝塞尔曲线这东西在Unity里出现的频率远比想象中高——相机运镜路径、UI引导手指的滑动轨迹、角色技能的范围指示、道路编辑器的中心线、甚至动画曲线的插值,背后都是它在撑着。
所以这篇东西不是讲数学课,而是把我这几年在Unity里反复实现、反复踩坑之后沉淀下来的那套做法完整摊开。核心关键词就一个:Unity贝塞尔曲线绘制。它解决的是“如何用最少的代码,在场景里或屏幕上画出一条可控、平滑、性能可接受的曲线”这个问题。适合两类人看:一类是刚学Unity、想搞明白曲线到底怎么落到代码里的新手;另一类是做过一些项目、但每次画曲线都靠复制粘贴、没真正理解参数含义的开发者。我会从最基础的公式推导讲起,一直讲到Gizmos调试、LineRenderer渲染、以及性能优化,中间穿插大量可以直接抄的代码和参数计算过程。
先说清楚一个前提:贝塞尔曲线本身是纯数学概念,跟引擎无关。Unity只是提供了把数学结果“画出来”的工具。理解了这一点,你就不会纠结“Unity的贝塞尔曲线API在哪”——它没有专门的API,你得自己算点,然后用Gizmos、LineRenderer或者Mesh去呈现。这也是为什么很多人搜了半天找不到“Unity贝塞尔曲线组件”的原因。
2. 贝塞尔曲线的数学底子与Unity落地思路
2.1 从一次贝塞尔到三次贝塞尔:公式到底在算什么
贝塞尔曲线的本质是用若干个控制点,通过插值算出一系列中间点,再把这些点连成平滑曲线。控制点越多,曲线能表达的形态越复杂,但计算量也越大。实际项目里用得最多的是二次和三次,四次以上基本没人手写,因为调整起来反人类。
一次贝塞尔就是两点之间的线性插值,公式是B(t) = (1-t)P0 + tP1,t从0到1。这其实就是Vector3.Lerp,没什么好说的。
二次贝塞尔有三个控制点P0、P1、P2,公式是:
B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2三次贝塞尔有四个控制点P0、P1、P2、P3,公式是:
B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3这里有个关键点很多人一开始会懵:P0和P3是曲线真正经过的端点,P1和P2是“牵引点”,曲线一般不会经过它们。你可以把P1、P2想象成磁铁,它们把曲线往自己方向拽,但曲线本身绕开它们走。这就是为什么调整控制点时曲线的手感那么“软”。
我在实际项目里做过一个对比:同样四个点,用二次贝塞尔和三次贝塞尔画出来的弧线,三次的弯曲更自然,因为多了一个牵引点可以分别控制起点和终点附近的曲率。所以除非是特别简单的抛物线,我一般直接用三次。
2.2 为什么Unity里要自己算点而不是找现成组件
Unity的AnimationCurve、LineRenderer、TrailRenderer都不直接支持贝塞尔。AnimationCurve本质是分段三次样条,跟贝塞尔不是一回事;LineRenderer只负责把一串点连起来,点从哪来它不管。所以标准做法是:自己写一个函数,输入控制点和采样数量,输出一串Vector3,然后交给渲染工具。
这个思路的好处是解耦。算点的逻辑跟渲染方式完全分开,今天用Gizmos调试,明天换成LineRenderer出效果,后天要生成Mesh做碰撞,都不用改核心算法。我见过有人把贝塞尔计算和LineRenderer绑死在一个脚本里,后来要改成UI上画曲线,整个重写,非常痛苦。
采样数量是个需要权衡的参数。太少,曲线看起来是一段段折线;太多,顶点数爆炸影响性能。我的经验是:屏幕空间绘制用30到50段,世界空间长距离路径用50到100段,短距离(5米以内)20段就够。后面会讲怎么根据曲线长度动态调整。
2.3 控制点的组织方式:数组还是独立字段
写代码时第一个要决定的是控制点怎么存。有两种常见写法:一种是public Vector3 p0, p1, p2, p3;四个独立字段,另一种是public Vector3[] points;数组。前者在Inspector里直观,后者方便扩展成任意阶数。
我个人的选择是:如果确定只用三次,就用四个独立字段,配合[Header]和[Tooltip]标注清楚每个点的作用。因为数组在Inspector里展开后,你分不清哪个是起点哪个是终点,调起来容易乱。但如果要做通用工具,比如支持二次到五次的曲线编辑器,那就必须用数组,配合一个int degree字段控制阶数。
这里有个小技巧:给控制点加[Space]或者在Inspector里用不同颜色画Gizmos,能大幅提升调试效率。我习惯把P0和P3画成绿色(端点),P1和P2画成黄色(牵引点),中间连线用灰色虚线。这样一眼就能看出曲线的走向是否符合预期。
3. 核心算法实现与参数计算细节
3.1 三次贝塞尔函数的完整实现
先上代码,这是整个功能的心脏:
public static Vector3 CubicBezier(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float u = 1f - t; float uu = u * u; float uuu = uu * u; float tt = t * t; float ttt = tt * t; Vector3 point = uuu * p0; point += 3f * uu * t * p1; point += 3f * u * tt * p2; point += ttt * p3; return point; }这段代码有几个细节值得说。第一,t做了Clamp01,防止外部传入越界值导致曲线跑到控制点外面。第二,把u、uu、uuu、tt、ttt都提前算好,避免在乘法里重复计算,这是性能优化的基本操作。第三,用point +=累加而不是一行写完,可读性更好,编译器优化后性能几乎一样。
如果你要二次贝塞尔,把公式换成三个点就行:
public static Vector3 QuadraticBezier(Vector3 p0, Vector3 p1, Vector3 p2, float t) { t = Mathf.Clamp01(t); float u = 1f - t; return u * u * p0 + 2f * u * t * p1 + t * t * p2; }3.2 采样点数量怎么定:一个可落地的计算公式
前面说采样数量要权衡,但具体怎么算?我总结了一个经验公式,基于曲线的包围盒对角线长度:
float boundingSize = (p0 - p3).magnitude + (p1 - p2).magnitude; int sampleCount = Mathf.Clamp(Mathf.RoundToInt(boundingSize * 2f), 20, 100);这个公式的逻辑是:曲线跨度越大,需要的采样点越多,否则折线感明显;但上限卡在100,防止极端情况顶点爆炸。boundingSize用起点终点距离加上两个牵引点距离,能粗略反映曲线的“展开程度”。
实测下来,一个跨度10米的曲线,boundingSize大概12到15,乘以2就是24到30个点,画出来已经很平滑了。如果是UI上那种几十像素的曲线,boundingSize可能只有1到2,算出来20个点(被下限兜住),也完全够用。
注意:这个公式是经验值,不是数学推导。如果你的曲线特别扭曲(比如P1和P2跑到端点连线的另一侧),可能需要手动加采样点。判断标准很简单:把采样点连成的折线和理论曲线对比,肉眼看出棱角就加。
3.3 用Gizmos在编辑器里实时预览
开发阶段最实用的调试手段是OnDrawGizmos。它不需要运行游戏就能看到曲线,改控制点位置立刻刷新,效率极高。
private void OnDrawGizmos() { if (controlPoints == null || controlPoints.Length < 4) return; Gizmos.color = Color.green; Gizmos.DrawSphere(controlPoints[0], 0.1f); Gizmos.DrawSphere(controlPoints[3], 0.1f); Gizmos.color = Color.yellow; Gizmos.DrawSphere(controlPoints[1], 0.08f); Gizmos.DrawSphere(controlPoints[2], 0.08f); Gizmos.color = Color.gray; Gizmos.DrawLine(controlPoints[0], controlPoints[1]); Gizmos.DrawLine(controlPoints[2], controlPoints[3]); Gizmos.color = Color.cyan; Vector3 prev = controlPoints[0]; int steps = 50; for (int i = 1; i <= steps; i++) { float t = i / (float)steps; Vector3 current = CubicBezier(controlPoints[0], controlPoints[1], controlPoints[2], controlPoints[3], t); Gizmos.DrawLine(prev, current); prev = current; } }这里我把端点画大一点(0.1),牵引点画小一点(0.08),颜色区分开。灰色连线表示控制多边形,能帮你理解牵引点是怎么“拉”曲线的。青色是实际曲线。
有个坑要提醒:OnDrawGizmos每帧都会调用,如果steps设成几百,编辑器会卡。50是个甜点值,既平滑又不卡。另外,如果控制点数组在运行时被修改,Gizmos会自动更新,但如果你在OnDrawGizmos里做了复杂计算(比如动态算采样数),要注意性能。
3.4 用LineRenderer渲染可见曲线
Gizmos只在编辑器里可见,要出实际效果得用LineRenderer。核心思路是:算出一串点,赋给LineRenderer.positionCount和SetPositions。
private LineRenderer lineRenderer; private void Awake() { lineRenderer = GetComponent<LineRenderer>(); lineRenderer.useWorldSpace = true; lineRenderer.startWidth = 0.1f; lineRenderer.endWidth = 0.1f; lineRenderer.material = new Material(Shader.Find("Sprites/Default")); lineRenderer.startColor = Color.white; lineRenderer.endColor = Color.white; } public void DrawCurve() { int steps = CalculateSampleCount(); lineRenderer.positionCount = steps + 1; for (int i = 0; i <= steps; i++) { float t = i / (float)steps; Vector3 point = CubicBezier(p0, p1, p2, p3, t); lineRenderer.SetPosition(i, point); } }几个关键参数说明。useWorldSpace设成true表示控制点是世界坐标,如果你想让曲线跟着某个物体走,设成false并用局部坐标。startWidth和endWidth可以不一样,做锥形拖尾效果时有用。材质用Sprites/Default是最省事的,不需要额外导入,颜色通过startColor和endColor控制。
提示:
LineRenderer的顶点数上限是65535,但实际项目中超过几百个点就该考虑用Mesh了。另外,SetPositions比循环SetPosition略快,如果点数多,先把点存进数组再一次性赋值。
4. 进阶玩法:让曲线真正好用起来
4.1 曲线长度估算与匀速运动
贝塞尔曲线有个反直觉的特性:t从0到1均匀变化时,曲线上的点移动速度并不均匀。靠近牵引点的地方走得快,靠近端点的地方走得慢。如果你想让物体沿曲线匀速运动,直接拿t当进度条会出问题。
解决办法是预计算弧长表。思路是:先采样足够多的点,算出每段长度,累加得到总长度,然后建立“弧长比例到t”的映射。
private float[] arcLengthTable; private float totalLength; private void BuildArcLengthTable(int samples = 200) { arcLengthTable = new float[samples + 1]; Vector3 prev = CubicBezier(p0, p1, p2, p3, 0f); arcLengthTable[0] = 0f; totalLength = 0f; for (int i = 1; i <= samples; i++) { float t = i / (float)samples; Vector3 current = CubicBezier(p0, p1, p2, p3, t); totalLength += Vector3.Distance(prev, current); arcLengthTable[i] = totalLength; prev = current; } } public float GetTByDistance(float distance) { if (totalLength <= 0f) return 0f; distance = Mathf.Clamp(distance, 0f, totalLength); for (int i = 1; i < arcLengthTable.Length; i++) { if (arcLengthTable[i] >= distance) { float prevLen = arcLengthTable[i - 1]; float segLen = arcLengthTable[i] - prevLen; float segT = (distance - prevLen) / segLen; return (i - 1 + segT) / (arcLengthTable.Length - 1); } } return 1f; }这段代码里,samples取200是精度和性能的平衡。200个点的弧长表,构建一次大概零点几毫秒,之后查询就是线性查找,对于单个物体完全够用。如果同时有几百个物体沿曲线运动,可以把查找改成二分,或者把表精度降到100。
实测下来,用弧长表做匀速运动,肉眼几乎看不出速度波动。不用的话,物体在曲线中段会明显“窜”一下,体验很差。
4.2 切线方向与朝向对齐
物体沿曲线运动时,朝向应该跟切线方向一致。贝塞尔曲线的切线就是它的一阶导数,公式是:
B'(t) = 3(1-t)²(P1-P0) + 6(1-t)t(P2-P1) + 3t²(P3-P2)代码实现:
public static Vector3 CubicBezierTangent(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float u = 1f - t; Vector3 tangent = 3f * u * u * (p1 - p0); tangent += 6f * u * t * (p2 - p1); tangent += 3f * t * t * (p3 - p2); return tangent.normalized; }拿到切线后,用Quaternion.LookRotation(tangent)就能让物体朝向曲线前进方向。如果曲线在二维平面上,可以只取x和y分量,忽略z。
注意:当t接近0或1时,如果P0和P1重合,切线会变成零向量,
LookRotation会报错。实际项目里要么保证控制点不重合,要么在切线长度小于阈值时用上一帧的朝向兜底。
4.3 把曲线应用到相机运镜
相机运镜是贝塞尔曲线最实用的场景之一。比如做一个“从高处俯冲到角色背后”的镜头,用四个控制点:起点是相机当前位置,终点是角色背后,中间两个牵引点分别控制俯冲的弧度和最终接近的角度。
我的做法是:把相机路径做成一个ScriptableObject,里面存四组控制点,运行时用GetTByDistance驱动进度。这样策划可以在编辑器里调路径,不用改代码。
有个细节:相机运镜通常需要配合LookAt目标。如果直接让相机朝向切线方向,画面会跟着曲线扭,很晕。正确做法是相机位置沿曲线走,但朝向始终看向一个独立的目标点(比如角色头部)。这样既有空间上的弧线运动,画面又稳定。
4.4 性能优化:什么时候该用Mesh替代LineRenderer
LineRenderer在点数少的时候很方便,但点数一多(比如超过500),它的开销就上来了,因为每个线段都要单独处理。这时候应该改用Mesh,把所有点连成三角带。
核心思路是:对每个采样点,沿法线方向偏移半个宽度,生成左右两个顶点,然后按顺序连成三角形。代码量比LineRenderer大,但性能好一个数量级。
我做过测试:1000个点的曲线,LineRenderer每帧更新大概0.5毫秒,Mesh方案只要0.1毫秒。如果曲线是静态的(不需要每帧重算),Mesh方案还能配合MeshRenderer做批处理,几乎零开销。
不过大多数项目里,曲线点数不会超过200,LineRenderer完全够用。我的建议是:先用LineRenderer,遇到性能瓶颈再换Mesh,不要过早优化。
5. 常见问题与排查技巧实录
5.1 曲线看起来是折线怎么办
这是最常见的问题,原因就一个:采样点太少。解决办法前面说了,用包围盒公式动态算采样数。但还有一种情况:采样点够多,但LineRenderer的numCornerVertices和numCapVertices没设,导致拐角处看起来有棱角。把这两个值设成2到4,曲线会圆润很多。
另外,如果曲线在屏幕上看起来还是不够平滑,可能是抗锯齿没开。在URP里,给材质加上Anti-aliasing,或者把LineRenderer的alignment设成View,让线段始终面向相机。
5.2 控制点拖动时曲线跳动
这个问题通常出现在运行时动态修改控制点的时候。原因是LineRenderer的positionCount变了但没重新赋值,或者SetPositions的数组长度跟positionCount不匹配。
我的做法是:每次修改控制点后,先调CalculateSampleCount重新算点数,再调DrawCurve完整重绘。不要试图只更新部分点,容易出bug。
还有一种可能是控制点本身在Update里被修改,但DrawCurve在LateUpdate里调用,导致一帧延迟。统一放在LateUpdate里处理就行。
5.3 曲线跑到相机后面看不见
LineRenderer默认是3D的,如果曲线在相机背面,会被裁掉。如果你做的是UI上的曲线,应该用Canvas加RectTransform,或者把LineRenderer的useWorldSpace设成false,挂到Canvas下面。
还有一种情况:曲线的Z值和相机不匹配。比如相机在Z=-10,曲线在Z=0,但LineRenderer的材质是Sprites/Default,它不写深度,所以会被其他物体遮挡。解决办法是调整渲染队列,或者用Overlay相机专门画曲线。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 曲线是折线 | 采样点太少 | 用包围盒公式动态计算,或手动加到50以上 |
| 曲线不显示 | LineRenderer未赋值或材质丢失 | 检查组件引用,用Sprites/Default兜底 |
| 曲线位置偏移 | useWorldSpace设置错误 | 世界坐标用true,局部坐标用false |
| 物体运动速度不均 | 未使用弧长表 | 预计算弧长表,用GetTByDistance驱动 |
| 切线朝向错误 | 控制点重合导致零向量 | 加阈值判断,用上一帧朝向兜底 |
| 编辑器卡顿 | OnDrawGizmos采样过多 | 把steps降到50,或只在选中时绘制 |
5.5 几个我踩过的坑
第一个坑:在OnDrawGizmos里用了LineRenderer的材质。Gizmos和LineRenderer是两套渲染系统,Gizmos只能用Gizmos.color和Gizmos.DrawLine,不能共用材质。我一开始想把LineRenderer的渐变效果搬到Gizmos里,折腾半天才发现根本不行。
第二个坑:控制点用Transform引用但没判空。如果场景里删了某个控制点物体,脚本会报NullReference。后来我改成用Vector3存坐标,需要跟物体绑定时再手动同步,稳定多了。
第三个坑:曲线长度计算用了Vector3.Distance但没考虑曲线弯曲。直线距离和弧长差别很大,尤其是牵引点拉得很开的时候。必须用采样累加的方式算弧长,不能偷懒。
第四个坑:在移动端上每帧重算200个采样点。一开始没注意,低端机上帧率掉到30。后来改成只在控制点变化时重算,运行时只更新位置,性能立刻回来了。
6. 一个完整的可运行示例
把上面的东西串起来,给一个可以直接挂到GameObject上的脚本框架:
using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class BezierCurveDrawer : MonoBehaviour { [Header("控制点")] public Vector3 p0 = new Vector3(0, 0, 0); public Vector3 p1 = new Vector3(2, 3, 0); public Vector3 p2 = new Vector3(5, 3, 0); public Vector3 p3 = new Vector3(7, 0, 0); [Header("渲染设置")] public int minSamples = 20; public int maxSamples = 100; public float width = 0.1f; private LineRenderer lr; private Vector3[] cachedPoints; private bool dirty = true; private void Awake() { lr = GetComponent<LineRenderer>(); lr.useWorldSpace = true; lr.startWidth = width; lr.endWidth = width; lr.material = new Material(Shader.Find("Sprites/Default")); lr.startColor = Color.white; lr.endColor = Color.white; } private void LateUpdate() { if (dirty) { Rebuild(); dirty = false; } } private void Rebuild() { int steps = CalculateSampleCount(); if (cachedPoints == null || cachedPoints.Length != steps + 1) cachedPoints = new Vector3[steps + 1]; for (int i = 0; i <= steps; i++) { float t = i / (float)steps; cachedPoints[i] = CubicBezier(p0, p1, p2, p3, t); } lr.positionCount = cachedPoints.Length; lr.SetPositions(cachedPoints); } private int CalculateSampleCount() { float size = (p0 - p3).magnitude + (p1 - p2).magnitude; return Mathf.Clamp(Mathf.RoundToInt(size * 2f), minSamples, maxSamples); } public static Vector3 CubicBezier(Vector3 a, Vector3 b, Vector3 c, Vector3 d, float t) { t = Mathf.Clamp01(t); float u = 1f - t; return u * u * u * a + 3f * u * u * t * b + 3f * u * t * t * c + t * t * t * d; } private void OnDrawGizmos() { Gizmos.color = Color.green; Gizmos.DrawSphere(p0, 0.1f); Gizmos.DrawSphere(p3, 0.1f); Gizmos.color = Color.yellow; Gizmos.DrawSphere(p1, 0.08f); Gizmos.DrawSphere(p2, 0.08f); Gizmos.color = Color.gray; Gizmos.DrawLine(p0, p1); Gizmos.DrawLine(p2, p3); Gizmos.color = Color.cyan; Vector3 prev = p0; for (int i = 1; i <= 50; i++) { float t = i / 50f; Vector3 cur = CubicBezier(p0, p1, p2, p3, t); Gizmos.DrawLine(prev, cur); prev = cur; } } private void OnValidate() { dirty = true; } }这个脚本挂上去就能用。OnValidate保证在Inspector里改控制点时自动重绘,LateUpdate里只在dirty时重建,避免每帧浪费。OnDrawGizmos提供编辑器预览,LineRenderer负责运行时渲染。
如果你想让它动起来,加一个float progress字段,在Update里递增,然后用GetTByDistance把进度映射到t,再更新一个沿曲线运动的小球位置。这部分代码前面已经给过,直接拼进去就行。
最后分享一个我个人的习惯:每次实现贝塞尔曲线,我都会先画控制多边形,再画曲线。控制多边形就是P0-P1、P1-P2、P2-P3这三条灰线。它能帮你快速判断曲线的大致走向,调参效率至少翻倍。很多人一上来就盯着曲线看,调半天调不对,其实就是没看控制多边形。这个技巧在三维空间里尤其有用,因为透视会让曲线看起来变形,但控制多边形的关系是稳定的。