首页 / 资讯中心 / 文章详情

Unity网格绘制贝塞尔曲线:从数学原理到动态轨迹完整实现

Unity网格绘制贝塞尔曲线:从数学原理到动态轨迹完整实现 ★ FEATURED ARTICLE
写这篇的起因其实很简单一个项目里需要根据玩家操作实时画出一条技能轨迹线要求线条能跟着角色移动变形粗细可控还不能穿模。当时我下意识先想到的是LineRenderer结果一算顶点数量、一测贴图拉伸、一看需要动态变换的控制点LineRenderer很快就成了麻烦。后来干脆回到最底层用Mesh网格自己画贝塞尔曲线所有问题都迎刃而解。这篇文章就把这套从数学到网格、从静态到动态的完整做法梳理一遍希望给同样被Unity里画曲线困扰的朋友一个可以直接照抄的方案。1. 为什么放弃LineRenderer转而用网格画贝塞尔曲线1.1 三种常见画线方式的真实体验Unity里画线最无脑的方案是LineRenderer拖进来设置一下Positions就能跑。但它有几个挺尴尬的地方想让它跟随贝塞尔曲线形状你得自己把曲线t值等分采样生成一串Position数组更新频率稍一高性能就开始吃紧。线条宽度是屏幕空间或世界空间近似算出来的换成透视相机后很难保证看起来一样粗。纹理拉伸、UV的自动分布行为也比较难控制想让线头线尾有渐变得额外处理。动态改变控制点时整条线的顶点要重算LineRenderer本身的API设计对频繁修改并不友好。Debug.DrawLine更适合调试连发布版都不会绘制小型可视化可以用但交付给玩家的效果基本没戏。GL.Begin/End的方式近似立即模式每帧都要提交公开版性能扛不住而且在URP/HDRP下适配也比较麻烦。第三条路径也就是本文要讲的方案直接用Mesh构造一条带状几何体。这能完全绕开上述种种限制。你会拿到每个顶点的完整控制权法线、UV、粗细、形状全部自己说了算弹性空间和专业度都比前面那几种高一个量级。1.2 网格画线的底层逻辑网格的核心就三个东西顶点数组、索引数组、UV数组。画一条曲线其实就是把曲线上密密麻麻的采样点变成沿线展开的一串小四边形。每两个相邻采样点构成一个四边形四边形两侧再各生成一个顶点于是这条线看起来就是由一串细长矩形首尾相接拼出来的。这种做法类比起来就像铺地砖曲线是你的设计图纸地砖就是那些小四边形地砖铺得越密图纸的轮廓就越平滑。每个四边形本质上只有四个顶点加上前后重叠边上的顶点复用整个条带需要的顶点数量大概是采样段数 1再乘2。这样处理后线条有真实的顶点法线信息光照、描边、描影都能做UV能精确控制想做个从起点到终点渐变色的线都是基础操作甚至可以让线从某个位置撕裂开做粒子沿着曲线飞走的特效。2. 贝塞尔曲线的数学从二次到三次以及代码怎么写2.1 二次贝塞尔太单薄三次贝塞尔刚刚好贝塞尔曲线的核心思想是把若干控制点拉出一条光滑曲线。二次贝塞尔有三个控制点P0、P1、P2公式很简单曲线永远落在由这三点构成的三角形内三次贝塞尔则多一个控制点P3能表达更复杂的S形和拐弯自由度明显更高。三次贝塞尔的公式长这样B(t) (1-t)^3 * P0 3 * (1-t)^2 * t * P1 3 * (1-t) * t^2 * P2 t^3 * P3t的范围是0到1当t从0逐渐增加到1时你得到的就是一条从P0到P3的光滑轨迹。这个公式展开后就是一个多项式插值每个控制点对曲线的贡献随t的变化而增减所以曲线看起来很顺没有急停和抖动。实际项目中我几乎不会用二次贝塞尔二次只能表达抛物线类弧线形状太听话三次贝塞尔两个控制点可以分别控制曲线的进入方向和离开方向这才是制作流畅运动轨迹的最低配置。2.2 采样密度和细分决定这条线是圆滑还是锯齿曲线本身是数学上连续的网格只能是离散的所以要把曲线抽成很多个点。采样密度越高视觉上越平滑但顶点越多性能消耗越大。关键参数是每单位曲线长度需要的采样段数。我见过很多教程直接写取100个点这在小屏时可能没问题但遇到很长的曲线100个点就明显不够遇到很短的曲线100个点纯属浪费。正确做法是用预估的曲线长度除以想要的单段长度来动态决定段数segmentCount Mathf.CeilToInt(estimatedLength / segmentWidth)estimatedLength粗略估算时可以累加相邻控制点之间的距离总和或者取P0到P3直线距离乘一个1.2到1.5的经验系数。segmentWidth如果是0.1表示每隔0.1个世界单位布一个顶点效果十几米长的曲线需要一百多段中端设备上仍然很轻。有个经典错误是只看segmentCount不看曲线实际长度结果曲线缩放一下就直接糊成折线。动态采样密度是最值得提前写进工具里的一行代码。2.3 用De Casteljau算法代替公式避免魔法数字三次贝塞尔公式虽然能直接用但写代码时我更喜欢De Casteljau递推算法通过反复线性插值把控制点降维。它对任意阶贝塞尔曲线都通用代码好读不会在大项目里被误改。简单来说第一次线性插值在P0和P1、P1和P2、P2和P3之间按t各取一个点第二次线性插值在刚得到的三点之间再按t各取一个点第三次线性插值在刚得到的两点之间按t取一个点就是曲线上的点这个算法等同于公式但不容易写错而且想升阶到四阶五阶控制点时也不用改原理。项目的可维护性和稳定性在这种细节上体现得很明显。3. 网格绘制贝塞尔曲线的核心实现从0到1构建条带3.1 先搭一个最小可运行的Mesh Builder我已经把完整的核心写法整理出来数据结构和计算步骤尽量做到每一步都能看得懂。这里的思路是定义基础数据控制点数组、曲线宽度、分段数。遍历t值采样曲线中心点坐标。计算每个采样点位置的切线方向并求法线。根据法线向两侧偏移生成左右两个顶点。按照条带顺序每两个相邻采样点生成两个三角形。using System.Collections.Generic; using UnityEngine; public class BezierMeshBuilder : MonoBehaviour { public Transform p0; public Transform p1; public Transform p2; public Transform p3; public float lineWidth 0.2f; public int segmentsPerMeter 10; private MeshFilter meshFilter; void Start() { meshFilter GetComponentMeshFilter(); if (meshFilter null) meshFilter gameObject.AddComponentMeshFilter(); gameObject.AddComponentMeshRenderer(); RebuildMesh(); } void Update() { // 动态模式下每帧重建正式项目建议用脏标记控制刷新 RebuildMesh(); } public void RebuildMesh() { ListVector3 bezierPoints new ListVector3(); float estimatedLength EstimateLength(); int segmentCount Mathf.Clamp(Mathf.CeilToInt(estimatedLength * segmentsPerMeter), 8, 1024); Vector3 prevPoint GetBezierPoint(0f); bezierPoints.Add(prevPoint); for (int i 1; i segmentCount; i) { float t (float)i / segmentCount; Vector3 point GetBezierPoint(t); bezierPoints.Add(point); prevPoint point; } BuildMeshFromCenterLine(bezierPoints, lineWidth); } Vector3 GetBezierPoint(float t) { // 这里使用De Casteljau递推 Vector3 a Vector3.Lerp(p0.position, p1.position, t); Vector3 b Vector3.Lerp(p1.position, p2.position, t); Vector3 c Vector3.Lerp(p2.position, p3.position, t); Vector3 d Vector3.Lerp(a, b, t); Vector3 e Vector3.Lerp(b, c, t); return Vector3.Lerp(d, e, t); } float EstimateLength() { float length 0f; Vector3 prev p0.position; Vector3[] pts new Vector3[] { p0.position, p1.position, p2.position, p3.position }; for (int i 1; i pts.Length; i) { length Vector3.Distance(pts[i - 1], pts[i]); } return Mathf.Max(length, 0.01f); } void BuildMeshFromCenterLine(ListVector3 centerLine, float width) { if (centerLine null || centerLine.Count 2) return; int vertexCount centerLine.Count * 2; Vector3[] vertices new Vector3[vertexCount]; Vector2[] uv new Vector2[vertexCount]; int[] triangles new int[(centerLine.Count - 1) * 6]; float halfWidth width * 0.5f; for (int i 0; i centerLine.Count; i) { Vector3 position centerLine[i]; Vector3 tangent; if (i 0) { tangent centerLine[1] - centerLine[0]; } else if (i centerLine.Count - 1) { tangent centerLine[i] - centerLine[i - 1]; } else { tangent centerLine[i 1] - centerLine[i - 1]; } Vector3 normal Vector3.Cross(Vector3.up, tangent).normalized; Vector3 left position - normal * halfWidth; Vector3 right position normal * halfWidth; int vertexIndex i * 2; vertices[vertexIndex] left; vertices[vertexIndex 1] right; float v (float)i / (centerLine.Count - 1); uv[vertexIndex] new Vector2(v, 0f); uv[vertexIndex 1] new Vector2(v, 1f); } for (int i 0; i centerLine.Count - 1; i) { int baseIndex i * 2; int triIndex i * 6; triangles[triIndex] baseIndex; triangles[triIndex 1] baseIndex 1; triangles[triIndex 2] baseIndex 3; triangles[triIndex 3] baseIndex; triangles[triIndex 4] baseIndex 3; triangles[triIndex 5] baseIndex 2; } Mesh mesh new Mesh(); mesh.name BezierCurveMesh; mesh.vertices vertices; mesh.uv uv; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); if (meshFilter ! null) { meshFilter.sharedMesh mesh; } } }这段代码一次就能跑出一个横躺在地面上的曲线网格。MeshFilter、MeshRenderer以及生成逻辑全写在一个组件里事件流程简单直接。想自己验证的话把四个Transform对象在场景里摆个任意形状运行就能看到结果。3.2 三角形索引为什么是6个索引而不是4个每个四边形由两个三角形组成每个三角形3个顶点索引。四边形总共有四个顶点但两个三角形会共用中间那条对角线上的两个顶点所以需要6个索引而不是8个。上面代码里第一个三角形用 baseIndex、baseIndex1、baseIndex3第二个三角形用 baseIndex、baseIndex3、baseIndex2这里面的顺序不是随便写的Unity的坐标系是左手系三角形顶点按顺时针排列时正面朝向观察者。如果顺序反了材质就显示不出内容或者看起来黑乎乎一片。初次写代码时最容易踩的就是这地方网格生成后所有三角形法线朝下其实是索引顺序错了不是材质的问题。3.3 法线方向为什么固定用Vector3.up做叉乘求曲线上的法线正路是拿到切线后垂直方向做一个叉乘。上面代码直接用了Vector3.Cross(Vector3.up, tangent)这有个前提曲线整体铺在水平地面上且法线方向近似沿着世界Y轴向外展开。如果你的场景是竖直墙面上的弹道轨迹或任意朝向的空间曲线就不能这样硬编码。空间曲线要可靠计算左右顶点通常是在曲线上取当前采样点前后两个点组成局部平面以切线向上与某个参考方向做正交化。这样法线跟随曲线朝向自然旋转整条带子会有类似扭转的动态感。想完全稳妥可以给每个顶点传一份独立的法线数据材质用双面渲染或带迎风计算的自定义着色器。美术表现越细这种自由度越值钱。4. 常见翻车案例弯折、扭曲、UV拉伸的三个坑4.1 急弯处的粗细不均采样的顶点都在曲线中心线上法线只在当前点计算。曲线拐弯很急时相邻两个法线方向会发生大幅转动如果单段采样长度过大小四边形会严重扭转看起来像绳子被拧了一下。视觉上就是线在某一段突然变细又变粗。解决手法有两个提高采样密度让每段中心线的变化足够小。对法线方向做平滑相邻顶点的法线用加权平均替代而不是每个点独立求。第二种方法类似骨架插值里的平滑法线在复杂曲线表现中几乎必用。平滑会让条带更接近管状外观扭转感会弱很多。4.2 法线方向导致的双面不可见网格条带是双面的常见情况但很多材质默认只渲染正面。相机围着曲线转半圈就会有一半角度下曲线凭空消失。材质设置为双面渲染是最快解法但在HDRP/URP中不同渲染管线的设置面板不太一样一不小心漏掉就白调试几小时。网格生成阶段能让正面朝向动态调整更好。面对镜头方向的曲线可以考虑每帧根据相机位置翻转法线符号让条带永远朝人。这个技巧在轨迹特效和指示标线里很实用开场默认效果就比双面材质更硬核。4.3 UV拉伸UV里我把横向v固定放在了垂直方向0到1纵向u均匀沿曲线分布。如果采样密度不均匀或曲线速度变化很大UV会出现拉伸导致贴上渐变纹理后看得见明显的拉丝。想让UV不受曲线速度影响不能直接把t映射到uv.x应该使用累加弧长的方式遍历所有采样点计算每个点到前一个点的世界距离累加出弧长。然后按弧长比例映射UV。这样无论控制点怎么移动渐变都很均匀。想让某一段特别密或特别疏形成特殊效果反向按弧长调整采样密度也是后面进阶玩法的基础。5. 动态贝塞尔曲线让线条跟随控制点实时变形5.1 什么时候必须动态重建做技能指示器、弹道预览、路径规划这类功能时控制点每帧都可能变化。曲线本身不是预先烘焙好的必须每帧根据新控制点重新采样网格。但每帧创建新Mesh是不太行的GC压力太大旧Mesh泄漏风险也高。正确方式是复用Mesh对象每次更新只替换顶点、UV、三角形索引数组而不是new一个新Mesh。这一步在项目里的性能差异极其明显60帧实时更新下不正确的写法很容易出现明显掉帧。一个经典更新函数长这样void UpdateDynamicBezier(Vector3[] controlPoints, float width, float segmentWidth) { // 采样中心线 ListVector3 centerLine SampleBezierPoints(controlPoints, segmentWidth); // 清空旧数据并填充 Vector3[] vertices RebuildVerticesFromCenterLine(centerLine, width); Vector2[] uv RebuildUVFromArcLength(centerLine); int[] triangles RebuildTrianglesFromCenterLine(centerLine); // 直接修改既有Mesh数据而不是创建新Mesh mesh.Clear(); mesh.vertices vertices; mesh.uv uv; mesh.triangles triangles; mesh.RecalculateBounds(); }这里可以加一个脏标记只有控制点位置变化超过阈值或是曲线长度变化超过阈值才真正更新Mesh。比如阈值设为0.01个单位肉眼完全感受不到延迟却能省下大量无效更新。5.2 轨迹追踪类玩法沿路径拖尾另一个高频需求是角色移动时画出一条轨迹。这种玩法控制点其实不是固定四个而是沿路径不断增长的序列。一种做法是动态维护一个最近N个历史位置点的环形缓冲区每次移动都加入新点、移除旧点然后把整段中心线丢给Mesh Builder。这本质上是把贝塞尔曲线当作整段线条的平滑器而不是单纯一条固定曲线收集最近一小段时间的角色位置点。用这些点作为控制点生成一条平滑曲线。每条帧把曲线顶点延展到带网格。这时候De Casteljau算法再次发挥作用只要控制点数量是任意的递推法就能轻松扩展。你不需要为3个点、5个点、7个点各写一套公式一套递推逻辑通吃。6. 性能与视觉进阶从能用到好用6.1 采样与重建的常见指标对照场景推荐段数策略顶点量级说明静态装饰线按长度固定采样几百顶点可烘焙一次完全不用更新实时技能指示器每帧按长度重算一千顶点以内顶点尽量少动态刷新长距离弹道预览按弧长分段数千顶点用动态LOD策略控制可见段拖尾轨迹按移动速度采样数百到数千需要环形缓冲区顶点量级建议控制在四到八千以内超过这个范围移动端很容易产生压力。而且很多情况下现实中曲线是很平滑的完全不需要那么多顶点只是采样逻辑写得太粗暴才白白浪费。6.2 材质和渲染管线注意点网格线本身只是几何材质决定观感。材质的颜色、金属度、自发光、流动贴图效果完全取决于Shader怎么写。想节省性能就用自己的Unlit Shader想融入场景光照就上URP自带的Lit但记得把Receive Shadows关掉否则一根细线会在阳光下打出奇怪投影。还有一档实用技巧是给材质添加一个流苏效果利用UV.x从0到1的特性让贴图的横向平铺频率很高再按时间平移曲线看起来就像有能量在流动。配合顶点色渐变线头线尾透明度就能做得自然。6.3 曲线绘制结果的验证思路每次改成动态刷新后请务必用以下方式验证结果把四根控制柱在场景里随意拖动观察整条线是否实时跟着变化且不能出现跳跃或撕裂。把曲线旋转到任意角度确认法线扭转不产生明显的扭曲割裂。分别用最远和最近视角观察线条粗细一致性确认线宽表现正常。我踩过最大的坑就是只盯着俯视图看效果。结果一转到侧视角线条因为法线计算是硬编码Vector3.up整个条带由水平展开变成了垂直扭曲画面直接崩。这个是空间曲线绘制最容易忽视又最伤人的问题。有一点个人体会想分享Bezier的网格绘制方法虽然一开始写代码比用LineRenderer啰嗦但换来的是对线的完全掌控。当需要把曲线细化成可交互部分、动态轨迹、自定义风格特效时这套底层能力几乎是绕不开的。遇到类似需求先别急着找现成品自己画一次条带感觉完全不一样。如果你以后想往更复杂的方向走可以把贝塞尔曲线扩展到BezierPath的多段拼接或者换到Catmull-Rom曲线它们能天然穿过控制点在表示路径时比Bezier更符合直觉。网格构建的部分则完全通用吃透了这套条带生成逻辑后面那些都是换汤不换药的事。
阅读完成 · 觉得有帮助?
咨询建站