资讯动态

用Unity网格绘制贝塞尔曲线:告别LineRenderer的束缚

发布时间:2026/10/16 13:49:04 来源:尧图企业网站定制
如果你在Unity里画曲线第一反应多半是把LineRenderer拖进场景给几个位置点赋个材质完事。我之前也这么干直到某次在项目模拟X里要做一条固定在世界空间的贝塞尔曲线宽度要随数值变化表面要跑UV流动特效还得能挂碰撞体做交互。LineRenderer瞬间就露怯了——它始终面向相机宽度控制也只在一条整体曲线里打转根本接不住这种需求。后来我改成用网格直接绘制贝塞尔曲线把顶点、法线、UV、三角形索引全部攥在自己手里想怎么折腾都行。这篇文章就是记录我这套做法的完整过程从贝塞尔曲线的数学原理到怎么把一串采样点变成Mesh条带再到实际项目里踩过的坑和解法。适合那些已经会用基础Mesh、但想摆脱LineRenderer限制的Unity开发者也适合刚学程序化网格的人直接抄作业。1. 方案选型为什么贝塞尔曲线要用网格画1.1 LineRenderer在曲线绘制上卡在哪LineRenderer不是不能用它在拖尾、临时连线、调试路径这些场景里非常高效几行代码就能出效果。但如果你需要一条“长得像实体物体”的曲线它的问题会一个接一个冒出来。永远面向相机。LineRenderer本质是一连串Billboard四边形无论你怎么转场景它都正对着当前相机。做激光、拖尾没问题做地面上的赛道轨迹、墙壁上的装饰线、轨道线路它就完全不对味。宽度控制不自由。虽然可以通过widthCurve调整体粗细变化但每个顶点的宽度无法根据业务逻辑精确计算做不到“某个位置突然变细、下个位置变粗”的精细控制。UV几乎不可控。LineRenderer自动生成UV你没法让贴图沿着曲线方向精确流动也没法把UV映射到自定义的渐变蒙版上。没法做几何交互。它连MeshCollider都没有拾取、碰撞、切割都无从谈起。批次和性能也容易被忽略。线条一多每个LineRenderer都有自己的材质参数更新和顶点缓冲实际的开销并没有想象中那么低。我在某跨平台系统里需要把一条贝塞尔路径做成地面上的能量引导线用户脚踩上去要有碰撞反馈。LineRenderer连第一关“固定在世界空间”都过不了所以只能走上程序化网格这条路。1.2 网格方案带给你什么自由网格绘制的本质是你用代码把一串“有意义的三维点”变成Mesh的顶点、三角面和UV。它的自由度是上不封顶的顶点位置完全由你定义曲线可以落在任意平面上也可以跟着物体Transform一起移动旋转。宽度不再是一条整体曲线而是每个采样点都能单独指定甚至可以用AnimationCurve驱动。法线自己填意味着光照可以精准控制不会出现LineRenderer那种“怎么打光都不对”的别扭感。UV自己排贴图方向、滚动速度、渐变Mask都好办。网格还能挂MeshCollider、还能被后处理特效识别、还能烘焙成静态资源。代价也很明确所有几何细节都要自己算。贝塞尔的采样点怎么来、切线和垂直方向怎么求、三角形索引怎么连、法线往哪指这些就是这篇文章要拆开讲的。1.3 三次贝塞尔公式与切线20行代码搞定贝塞尔曲线的数学并不吓人。三次贝塞尔曲线由4个控制点定义P0、P1、P2、P3参数t从0到1公式长这样B(t) (1-t)³·P0 3(1-t)²·t·P1 3(1-t)·t²·P2 t³·P3可以把它理解成四个控制点之间的“加权混血”t越接近0P0和P1的影响越大t越接近1P2和P3开始主导。整条曲线不会通过中间的P1和P2只分别在曲线端点和P0、P3相切。画网格光有点还不够还得知道每个点朝哪个方向延伸这就需要切线。对上述公式求导得到B(t) 3(1-t)²·(P1-P0) 6(1-t)·t·(P2-P1) 3t²·(P3-P2)切线方向就是曲线在这个点的走向。有了位置和切线你才能决定“曲线条带”的宽度方向开在哪一侧。用Unity的Vector3来实现这两行代码就是整个网格绘制贝塞尔曲线的地基。后面所有顶点、三角形、法线都是从这个地基上长出来的。2. 从点集到Mesh网格绘制贝塞尔曲线的核心步骤2.1 参数采样和切线计算段数不是越多越好贝塞尔曲线定义在连续的[0,1]区间上但网格是离散的所以第一步是把区间切成N段。N通常叫segments每段对应的参数步长是dt 1 / segments。采样点就是所有t i · dt 处的B(t)共segments 1个点。这些点就是曲线的“脊椎骨”相邻两个点之间用两个三角形拼成一小段平面整体连起来就是一条会拐弯的条带。段数的选择有个很实在的权衡段数太少拐弯处会露出明显的折线感甚至宽度方向出现扭曲。段数太多顶点和三角形索引线性增长虽然60段只有122个顶点、360个索引但如果每帧重建依然会造成压力和GC。我的经验是在相机不过分靠近的情况下segments取曲线总长的10到20倍。比如一条10个单位长的曲线放100到200段就足够顺滑。如果场景里有放大镜式的镜头推进再往上加但不要让它成为常态。切线方向建议直接用上一节那个导函数算别用相邻点做差分。原因是导函数精确且与采样密度无关相邻点差分在段数少的时候会明显偏离真实方向导致宽度方向跟着歪。差分在你不掌握控制点、只有现成点数组的时候才是备选方案。2.2 左右顶点、UV、三角形索引怎么安排一个采样中心点只能算一个顶点但条带要有宽度所以每个中心点要生成两个顶点左侧和右侧。方向怎么定用切线叉乘一个固定参考轴得到垂直方向binormalVector3 binormal Vector3.Cross(tangent, Vector3.forward).normalized; Vector3 left center - binormal * (width * 0.5f); Vector3 right center binormal * (width * 0.5f);这段代码默认你的曲线主要画在XY平面上Z0。此时切线永远在XY平面里和Vector3.forward叉乘的结果也必然在XY平面内垂直方向完全稳定。如果你的曲线是三维空间里的自由曲线就不能偷懒用固定参考轴了这个坑我在第4部分还会讲。顶点顺序我习惯每个采样点先放左顶点再放右顶点也就是索引为i2和i21。相邻两个中心点构成一个Quad两个三角形索引安排如下int a i * 2; int b i * 2 1; int c i * 2 2; int d i * 2 3; triangles[i60] a; triangles[i61] c; triangles[i62] b; triangles[i63] b; triangles[i64] c; triangles[i65] d;这个绕序配合前面的binormal计算能保证条带的正面朝向Z轴正方向。如果你换成了其他绕序画面也不会崩但光照、法线、后处理会出各种莫名其妙的问题。UV方面我建议水平方向U直接用采样参数t从0到1垂直方向V左侧给0、右侧给1。这样贴图可以完整地铺在整条曲线上。如果想要贴图按真实距离而不是参数比例铺展就需要把UV的U改成累计长度除以总长。前面那种方案在曲线长短不一但贴图密度一致时更实用看需求取舍。2.3 法线和边界避免曲线变黑和丢面网格画完之后最容易被忽略的是法线。如果你不设置法线部分着色器会显示全黑或者半边黑。如果调用RecalculateNormalsUnity会对每个顶点做相邻面法线的平均。带状网格结构简单大多数情况下RecalculateNormals能给出可用的结果但一旦曲线拐弯比较急顶点法线会被平均得歪歪扭扭光照会跟着出现奇怪的亮斑。对于XY平面内的曲线条带最稳的做法是直接手动指定法线全部指向Vector3.forward。因为整个条带就是平铺在XY平面上的一个平面几何表面法线的确处处都是Z轴方向。设置完顶点、UV、三角形、法线以后还要记得调一次mesh.RecalculateBounds()。这个调用会重新计算包围盒否则相机裁剪可能把一部分曲线裁掉。到这里一个网格版贝塞尔曲线的最小核心就齐了采样点、切线、左右顶点、三角形索引、UV、法线、Bounds。3. 可复用组件Unity网格绘制贝塞尔曲线实操3.1 完整脚本代码可直接挂载运行我直接把项目里经过反复测试的版本简化后贴在下面。这个组件挂在空物体上指定四个控制点调用BuildMesh就能生成曲线条带。using UnityEngine; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class BezierMesh : MonoBehaviour { public Transform p0; public Transform p1; public Transform p2; public Transform p3; [Range(4, 512)] public int segments 64; public float width 0.2f; private MeshFilter meshFilter; private Mesh mesh; void Awake() { meshFilter GetComponentMeshFilter(); } void Start() { BuildMesh(); } public void BuildMesh() { if (meshFilter null) meshFilter GetComponentMeshFilter(); if (mesh null) mesh new Mesh(); mesh.name BezierCurveMesh; mesh.Clear(); int vertexCount (segments 1) * 2; Vector3[] vertices new Vector3[vertexCount]; Vector2[] uvs new Vector2[vertexCount]; int[] triangles new int[segments * 6]; for (int i 0; i segments; i) { float t i / (float)segments; Vector3 center GetCurvePoint(t); Vector3 tangent GetCurveTangent(t).normalized; Vector3 binormal Vector3.Cross(tangent, Vector3.forward).normalized; if (binormal.sqrMagnitude 0.0001f) binormal Vector3.Cross(tangent, Vector3.right).normalized; vertices[i * 2] center - binormal * (width * 0.5f); vertices[i * 2 1] center binormal * (width * 0.5f); uvs[i * 2] new Vector2(t, 0f); uvs[i * 2 1] new Vector2(t, 1f); } for (int i 0; i segments; i) { int a i * 2; int b i * 2 1; int c i * 2 2; int d i * 2 3; triangles[i * 6 0] a; triangles[i * 6 1] c; triangles[i * 6 2] b; triangles[i * 6 3] b; triangles[i * 6 4] c; triangles[i * 6 5] d; } Vector3[] normals new Vector3[vertexCount]; for (int i 0; i vertexCount; i) normals[i] Vector3.forward; mesh.vertices vertices; mesh.uv uvs; mesh.triangles triangles; mesh.normals normals; mesh.RecalculateBounds(); meshFilter.sharedMesh mesh; } private Vector3 GetCurvePoint(float t) { Vector3 p0v GetLocalPoint(p0); Vector3 p1v GetLocalPoint(p1); Vector3 p2v GetLocalPoint(p2); Vector3 p3v GetLocalPoint(p3); float u 1f - t; return u * u * u * p0v 3f * u * u * t * p1v 3f * u * t * t * p2v t * t * t * p3v; } private Vector3 GetCurveTangent(float t) { Vector3 p0v GetLocalPoint(p0); Vector3 p1v GetLocalPoint(p1); Vector3 p2v GetLocalPoint(p2); Vector3 p3v GetLocalPoint(p3); float u 1f - t; return 3f * u * u * (p1v - p0v) 6f * u * t * (p2v - p1v) 3f * t * t * (p3v - p2v); } private Vector3 GetLocalPoint(Transform target) { if (target null) return Vector3.zero; return transform.InverseTransformPoint(target.position); } }这里有一点需要说明GetCurvePoint和GetCurveTangent内部把控制点从世界坐标转成了物体的局部坐标。这样曲线条带作为子物体跟随父物体移动旋转时网格本身不会出问题。如果你把控制点放在BezierMesh物体的子物体下拖起来也方便。3.2 场景搭建和参数收敛经验实操步很简单我整理成固定套路在场景里创建一个空物体命名CurveRoot挂上BezierMesh脚本。在这个空物体下创建四个空物体或Sphere分别拖到p0、p1、p2、p3槽里按曲线形状把位置拉好。给CurveRoot挂一个MeshFilter和MeshRendererMeshRenderer上给一个Unlit材质或者你没有需求就用Standard。点击Play或者直接调BuildMesh曲线就出来了。调试阶段我强烈建议把四个控制点做成彩色小方块或者带编号的光球否则贝塞尔曲线的P1和P2控制点经常会被误认为是“经过的点”拖半天都拖不出想要的形状。参数收敛上最优先调的是segments不是width。宽度不对一眼就能看出来段数不足产生的折痕却容易被误判成“曲线就是这样”。从64段起步如果拐角处还有明显多边形感再加到128段。正常情况下128段已经非常平滑再多就纯粹是浪费。3.3 进阶玩法渐变宽度、管状截面和贴图流动条带网格这个框架搭好之后扩展非常快。宽度渐变只需把width从常量改成每个t对应的值。可以在Inspector里放一个AnimationCurve字段代码里用curve.Evaluate(t)替代width顶点计算变成float w width * curve.Evaluate(t); vertices[i * 2] center - binormal * (w * 0.5f); vertices[i * 2 1] center binormal * (w * 0.5f);管状截面则是另一个常用变体。把“每个中心点两个顶点”改成“每个中心点一圈顶点”比如8个。每个截面顶点可以这样算Quaternion rotation Quaternion.LookRotation(tangent); for (int j 0; j sectionCount; j) { float angle j * (360f / sectionCount) * Mathf.Deg2Rad; Vector3 offset rotation * new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0f) * radius; vertices[i * sectionCount j] center offset; }三角形索引从“两个点连一个Quad”变成“相邻两圈顶点交错连接”。注意闭合处的顶点顺序要绕对否则管面会穿帮。贴图流动就更容易了。只要UV已经生成材质里放一张带方向的贴图运行时改material.mainTextureOffset的x值就能看到能量沿着曲线方向跑起来。配合UV的U方向按累计距离赋值速度会均匀很多。4. 曲线绘制常见问题与避坑笔记4.1 曲面裂缝和拐角穿插我最初做的时候遇到过曲线在拐弯处出现细缝或者两段平面在转角处互相穿插。前者多数是因为相邻采样点的顶点没严格共享——只要顶点数组里存在两套位置相同但索引不同的点三角形之间就会产生微小裂缝。检查你的循环确认左右顶点在相邻段是复用的。转角穿插则更隐蔽。它发生在宽幅条带和极小曲率半径同时出现时曲线外侧被拉得很长内侧被压缩得很短三角形仍然存在但几何上已经折叠看起来像破面。这种情况调高segments只会缓解真正彻底的办法是限制最大宽度或者把条带按弯曲程度动态细分在弯急的地方多加采样点。4.2 法线方向错乱导致光照发黑这个问题我踩过两次。第一次是忘了手动设置法线让Mesh的normals数组为空材质挂的是带光照的Standard结果曲线半边黑半边亮。第二次是用了RecalculateNormals结果一条扭得比较厉害的曲线在急弯处出现了诡异的高光条。解决方案不是去调光源而是自己掌握法线。2D平面曲线全部填Vector3.forward立刻恢复正常。3D曲线则需要用截面旋转计算真实法线法线方向大致等于“圆心到截面顶点”的方向这在管状网格里是必须的。带状网格如果想在3D空间里稳定得维护一个平滑变化的参考方向不能每段都从固定轴叉乘。4.3 动态重建的性能与GC优化控制点位置发生变化时最简单的做法是每帧调用BuildMesh。但反复new数组和赋值mesh会给GC带来不小压力。低频率更新没问题高频更新我建议把顶点、UV、三角形作为类成员数组预先分配好长度按最大segments计算。每帧只更新顶点和UV三角形索引和法线只要不变化就不用重写。用mesh.SetVertices而不是mesh.vertices前者对C#数组直接拷贝性能更好。不要在BuildMesh里反复new Mesh把它缓存起来只在Awake里创建一次。在我的动态轨迹场景里50段曲线每帧重建做了缓存之后Profiler里几乎没有可见的GC开销。4.4 交互与碰撞体不要直接无脑加MeshCollider曲线网格做出来以后很多人第一反应是挂MeshCollider。挂在静态曲线网格上没问题但如果曲线是动态重建的MeshCollider每帧同步也是不小的开销而且非凸MeshCollider跟刚体配合时会有警告和性能问题。我建议交互对象单独做。比如用2D物理就生成EdgeCollider2D把采样点投到碰撞体上要用3D物理就在曲线路径上隔一段放一个小BoxCollider或SphereCollider。这样既保留了网格的美观又避免了复杂碰撞体的坑。我在某跨平台系统里就是在路径上游标放置了若干小球碰撞体交互帧率反而比压一个巨大的MeshCollider还稳定。做这套贝塞尔网格组件的这段时间里我最深的体会是程序化网格没有想象中那么“重”。核心数学就是两个公式把采样点、三角索引、法线这三件事理清楚就能稳定输出。现在我的项目里60段曲线重建一次耗时在0.1毫秒以下控制点就摆在父物体下面拖动空物体、曲线即时跟着刷新调试手感跟专业编辑器里的样条线差不多。如果你也只是需要在场景里画一条曲线LineRenderer够用如果它有哪怕一条需求满足不了我建议你直接把这份代码拷进项目从世界空间的固定条带开始很快就能长出各种形状。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑