做项目的时候经常会碰到这类需求:技能释放后脚下要拉出一条能量路径,从角色A蜿蜒到目标B;或者新手指引里画一条虚线路径引导玩家点击;再比如模拟生产线上物资从A点运送到B点的流向提示。这种需求在Unity里其实可以很干净地拆成三块——形状由贝塞尔曲线提供,渲染交给LineRenderer,方向感和虚线感则用一张透明贴纸加UV偏移来完成。这篇文章就把这三块怎么拼起来、每个参数怎么定、常见的坑在哪里,全部过一遍,直接给你一套能抄的完整方案。
1. 需求拆解与方案选型:LineRenderer实现贝塞尔曲线为什么靠谱
1.1 标题里其实藏了四个子需求
“在Unity中使用LineRenderer实现A点到B点的贝塞尔曲线,并且曲线为虚线,方向为A点流向B点。”这句话拆开是四个问题:第一,A点到B点,意味着路径必须有明确的起点和终点;第二,贝塞尔曲线,意味着中间需要平滑弯曲,而不是一段直线;第三,虚线,意味着渲染出来的线要有透明间隔;第四,方向为A点流向B点,意味着这条虚线不能是静止的,要让人一眼看出有一条“链”在朝B方向跑。
这四个需求组合在一起,实际就是很多游戏里所谓的“引导线”或“能量流线”。你只要把最后一个效果图在脑子里转一下,就会发现本质是这么一回事:一根从A点弯曲到B点的虚线,虚线段正在持续向前移动,看起来像是箭头在沿着曲线流动。这个效果用LineRenderer来做非常合适,因为LineRenderer天生就是干这个的——它专门用来渲染一条连续的线段,而且自带纹理映射能力,不需要你手写顶点缓冲。
1.2 对比三种实现路线:LineRenderer、网格生成与粒子系统
我第一次接到这种需求时也犹豫过:要不要直接生成一个Mesh?或者干脆用粒子排成一条轨迹?后来对比了一下,结论很明确:
| 方案 | 开发量 | 性能 | 动态修改难度 | 效果可控性 |
|---|---|---|---|---|
| LineRenderer | 低 | 高 | 低(直接改坐标重设) | 中 |
| 程序化生成Mesh | 高 | 高 | 高(要重建顶点/网格) | 高 |
| 粒子系统 | 中 | 中 | 低 | 中低,粒子路径不好精确控制 |
Mesh方案适合要做那种非常夸张的描边、发光、多层叠加效果的情况,但就“一条带纹理的曲线”这种基础需求,写Mesh是杀鸡用牛刀。粒子的麻烦在于路径越精确越难控制,你很难让粒子严格沿着贝塞尔曲线从A走到B,而且还得处理粒子生命周期和碰撞,增加了不少变量。
LineRenderer的核心优势在于:它把一个“数组里的坐标点集合”转化为一条渲染曲线,只需要改变坐标点就能实时改变形状。配合材质动画,虚线和流动方向也顺带解决了。性能上它是CPU侧生成顶点、GPU一次性三角形带渲染,顶点数量级在几十到几百,属于非常轻量。所以最后选择LineRenderer,是权衡了开发速度和效果覆盖面的结果。
另外,标题里说的是“效果图如下”,实际在开发中你要按这个动效去还原。我自己一般会再加一个终点箭头,或者让流动的虚线段带一点斜度,这样指向性更强,后面章节会讲具体做法。
2. 数学原理与渲染机制:贝塞尔公式、虚线贴图和UV流动
2.1 二次贝塞尔曲线公式与采样点密度怎么定
贝塞尔曲线有很多阶,二次贝塞尔已经足够覆盖大多数“从A到B需要绕一个弯”的场景。它的公式非常简单:
B(t) = (1-t)^2 * P0 + 2*(1-t)*t * P1 + t^2 * P2其中 t 从 0 到 1 变化,P0 是起点,P2 是终点,P1 是控制点。t=0 时落在P0,t=1 时落在P2,中间过程被P1“拉”出一个弧线。你可以把P1想象成一根绳子的拽点:拽得越远,曲线弯得越厉害;拽的方向决定弯向哪边。
那P1怎么定?实战里有两种做法:一种是直接在Inspector里暴露一个Transform作为控制点,让策划或美术手动拖;另一种是程序自动算,例如取A、B中点,再往某个方向偏移一段距离。自动算适合原型期,手动控制点适合正式项目,因为设计师能肉眼调整弧度和位置,直观得多。我的做法是两种都保留:默认自动算,但提供一个可选的controlPoint覆盖。
采样密度也就是segments这个值,决定了线上有多少个顶点。二次贝塞尔公式本身是一条连续光滑曲线,但因为LineRenderer只能用折线逼近,如果segments太少,曲线就会显出一截一截的折角;太多则CPU开销和内存白白增加。个人经验是:线段长度在10个单位以内、弯曲不太夸张时,64个点足够;如果路径长达几十个单位或者弯曲特别急,提到128个点也基本看不出折角。注意哪怕你把LineRenderer的positionCount设成10000,视觉上也不会有质的提升,纯属浪费。
另外提一个重要细节:LineRenderer顶点UV的是按顶点顺序推进的,也就是说它并不关心你这些顶点在空间里是不是等距。如果A、B控制点之间的位置跨度极不均匀,贴图拉伸也会不均。好在二次贝塞尔曲线的采样点本身在t均匀变化时,空间分布还算平滑,不会出现严重不均匀。如果你要做的是三次贝塞尔或更高阶曲线,且路径特别长,后续可以按弧长均匀采样,但那条鱼一般需求用不到。
2.2 LineRenderer关键参数逐个说明
LineRenderer组件的属性很多,但这次需求真正涉及的只有几个,分别说明一下:
- positionCount:曲线顶点数量。上面说的segments + 1,因为首尾都要算进去。
- SetPosition/SetPositions:把贝塞尔采样结果写进LineRenderer。SetPositions可以一次传入整个数组,性能更好。
- widthCurve与widthMultiplier:控制线宽。widthCurve默认是一条恒定值,你可以调成两头细中间粗,模拟能量汇聚或拖尾,但这篇需求保持恒定宽度就够了。
- alignment:决定线的朝向。View是始终面向摄像机,适合通用特效;Transform模式会跟物体自身坐标走。引导线一般用View,简单省心。
- useWorldSpace:如果LineRenderer挂在一个会移动的物体下面,且点坐标是世界坐标,这个必须为true。如果希望线条跟着父物体整体跑,把它设为false,点坐标改成局部坐标。很多人在这里栽过跟头,后面踩坑部分会细聊。
- textureMode:这个很关键。Stretch模式是把整张贴图沿整条线拉伸;Tile模式是按材质里的Tiling参数平铺;RepeatPerSegment是按每个顶点段平铺。做虚线流动时,Stretch模式配合mainTextureScale的Tiling设置,效果最容易控制。
还有numCapVertices和numCornerVertices,控制端点圆滑度和拐角圆滑度。对于虚线效果,默认的平头端点就够用,拐角处如果不想出现折线,可以适当给一点cap顶点,但会增加顶点数,虚线纹理的接缝也更明显,所以我一般不设。
这里最大的一个习惯性坑是:很多人把LineRenderer当成一个“看不见的组件”,直接给个默认材质就开跑。其实LineRenderer本身不提供虚线和流动功能,它只是把材质贴在线上。能不能变成虚线、能不能流动,全看材质和贴图怎么配。所以下一节才是核心。
2.3 虚线纹理的生成与UV偏移实现方向流动
实现“虚线+方向流动”,原理其实一句话:在一张透明底色的纹理上画几段白色色块,把纹理的WrapMode设成Repeat,再让纹理的横向偏移量随时间变化。这样贴在线上的纹理就会像一条移动的斑马线,从A方向持续涌向B方向。
为了让大家不用依赖美术资源,我干脆在脚本里用Texture2D程序化生成这张虚线贴图。核心逻辑是横向交替填充白色和透明色:
Texture2D CreateDashTexture(int width = 256, int height = 8) { var tex = new Texture2D(width, height, TextureFormat.RGBA32, false); tex.wrapMode = TextureWrapMode.Repeat; tex.filterMode = FilterMode.Bilinear; Color clear = new Color(0, 0, 0, 0); Color dash = Color.white; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { tex.SetPixel(x, y, (x % 64) < 32 ? dash : clear); } } tex.Apply(); return tex; }这里的(x % 64) < 32表示每个64像素的周期里,前32像素是白色实线段,后32像素是透明间隔。你可以随意改这两个数字来控制虚线密度:要虚线段长一点就写40/24,要间隔长一点就写32/40。
为什么WrapMode必须设成Repeat?因为纹理要沿线重复多次才能形成多条虚线,如果保持Clamp,那么贴图只在线上显示一次,结果就是一大块白色色块覆盖整条线,根本看不出虚线。
方向流动的实现就更简单了:材质里的mainTextureOffset会改变纹理采样时使用的UV偏移。每帧减少一点offset.x,纹理图案就会向一侧平移,看起来就像线条在被“推”着往前走。这段逻辑放在Update里:
uvOffset -= flowSpeed * Time.deltaTime; mat.mainTextureOffset = new Vector2(uvOffset, 0f);flowSpeed控制流速,正负号决定方向。如果实际跑起来发现是反的,把减号改成加号,或者直接把flowSpeed设成负数。
3. 从零搭建完整案例:可复制的Unity脚本与配置步骤
3.1 场景搭建:三个空物体加一个LineRenderer
场景结构非常简单。我习惯创建一个空物体叫BezierLineRoot,把脚本挂上去。脚本在Awake里会自己AddComponent一个LineRenderer,省得手动操作。然后创建三个子物体或独立物体,分别作为PointA、PointB和ControlPoint。为了方便观察,我会给这三者各挂一个带颜色的Cube或Sphere,但记住这些只是调试辅助,最终只影响曲线坐标。
实际操作中控制点最好放在A点和B点中线的侧上方。比如A在(0,0,0),B在(0,0,5),控制点放在(0,3,2.5),曲线就会向上弯曲,很自然地表达出“跨越”的感觉。如果控制点直接放在中点,其实是一条直线,因为贝塞尔曲线经过中点的轮廓线刚好抵消了控制点的拉力。
脚本挂好之后,运行游戏就能看到一条从A到B弯曲的虚线在流动。接下来把完整脚本给出来。
3.2 完整C#脚本与逐段说明
下面是完整的可运行脚本,包含了贴图生成、贝塞尔采样、虚线流动三个部分:
using UnityEngine; public class BezierLineRenderer : MonoBehaviour { [Header("路径点")] public Transform pointA; public Transform pointB; public Transform controlPoint; [Header("曲线采样")] [Range(8, 256)] public int segments = 64; [Header("虚线效果")] [Range(1, 30)] public int dashTiling = 10; public float flowSpeed = 1.5f; private LineRenderer line; private Material mat; private float uvOffset; void Awake() { line = GetComponent<LineRenderer>(); if (line == null) line = gameObject.AddComponent<LineRenderer>(); mat = new Material(FindTransparentShader()); mat.mainTexture = CreateDashTexture(); mat.mainTextureScale = new Vector2(dashTiling, 1f); mat.mainTextureOffset = Vector2.zero; line.material = mat; line.textureMode = LineTextureMode.Stretch; line.widthMultiplier = 0.15f; line.positionCount = segments + 1; } void Start() { BuildCurve(); } void Update() { bool needRebuild = pointA != null && pointA.hasChanged || pointB != null && pointB.hasChanged || controlPoint != null && controlPoint.hasChanged; if (needRebuild) { BuildCurve(); if (pointA != null) pointA.hasChanged = false; if (pointB != null) pointB.hasChanged = false; if (controlPoint != null) controlPoint.hasChanged = false; } uvOffset -= flowSpeed * Time.deltaTime; mat.mainTextureOffset = new Vector2(uvOffset, 0f); } void BuildCurve() { Vector3 a = pointA != null ? pointA.position : Vector3.zero; Vector3 b = pointB != null ? pointB.position : Vector3.forward * 3f; Vector3 c = controlPoint != null ? controlPoint.position : (a + b) * 0.5f + Vector3.up * 2f; for (int i = 0; i <= segments; i++) { float t = i / (float)segments; line.SetPosition(i, QuadraticBezier(a, b, c, t)); } } Vector3 QuadraticBezier(Vector3 a, Vector3 b, Vector3 c, float t) { float u = 1f - t; return u * u * a + 2f * u * t * c + t * t * b; } Shader FindTransparentShader() { Shader shader = Shader.Find("Sprites/Default"); if (shader == null) shader = Shader.Find("Universal Render Pipeline/2D/Sprites-Default"); if (shader == null) shader = Shader.Find("Legacy Shaders/Transparent/Diffuse"); return shader; } Texture2D CreateDashTexture() { const int width = 256; const int height = 8; var tex = new Texture2D(width, height, TextureFormat.RGBA32, false); tex.wrapMode = TextureWrapMode.Repeat; tex.filterMode = FilterMode.Bilinear; Color clear = new Color(0, 0, 0, 0); Color dash = Color.white; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { tex.SetPixel(x, y, (x % 64) < 32 ? dash : clear); } } tex.Apply(); return tex; } }逐段说明一下:
- Awake阶段做初始化。这里的关键是把LineRenderer的textureMode设成Stretch,材质用new Material创建而不是直接引用asset,这是为了防止多个对象共用材质后,一个对象的Offset动画把另一个对象的材质也带偏。
- Update里检测三个Transform的hasChanged。很多人没注意到这个属性,其实它非常适合做“点移动了但不用每帧重建曲线”的判断。注意读取之后要手动把hasChanged置回false,否则下次判断仍然认为有变化。
- BuildCurve里的控制点默认取中点加上向量上偏移2个单位。这个向上的偏移量是我调试时觉得最不容易让曲线穿地面的值,但你可以根据实际需求改成Vector3.up * k或者让控制点完全手动控制。
- quadraticBezier函数就是二次贝塞尔公式,三个Vector3的线性组合。这里注意公式里有个小陷阱:很多人记成 a + 2t(c-b) 那种简化形式,但那是三次贝塞尔或特殊条件下的写法,直接套原始公式最不容易出错。
- FindTransparentShader这个方法是为了兼容不同渲染管线。Built-in下Sprites/Default够用,URP项目没有这个Shader名,就回退到Universal Render Pipeline的Sprite Shader;再不行就用Legacy Shaders/Transparent/Diffuse兜底。
3.3 材质、平铺系数与速度参数调整
dashTiling这个参数就是虚线在整条线上的重复次数。如果你把dashTiling设为10,意味着整条贝塞尔曲线上一共会出现10组白色虚线块。这个值需要根据曲线长度来调:曲线很长但dashTiling很小,线之间就会拉出很宽的透明距离,观感零散;曲线很短但dashTiling很大,虚线块就会挤在一起,几乎看不出是虚线。
更精确的做法是按固定虚线长度来算tiling,比如希望每隔0.5个单位出现一段虚线,那就先测量整条贝塞尔曲线长度,再除以0.5得出tiling。但大部分项目里直接用Inspector手调就够了,不必为这点开销去算曲线长度。
flowSpeed同样是个经验参数。以1.5为例,在10个tiling的曲线上,大概每秒能看到2到3条虚线从A端流到B端,视觉上比较顺畅。如果速度太快,观感会变成抖动或闪烁;太慢看起来又像静止。用Time.deltaTime累加,而不是直接拿Time.time当偏移量,是避免帧率波动导致流速不均匀的基本要求。
材质本身需要注意颜色和透明度:Sprites/Default支持透明,所以纹理里的透明部分才能真正透明。如果换成不透明Shader,虚线会变成一长条白色带子,完全失去间隔效果。
3.4 想要的额外效果:给曲线终点加一个流动箭头
虚线流动已经能表达“从A流向B”的方向,但有时候需要更强的指向性。我的做法是在B点位置额外放一个箭头图标,让它沿着曲线终点的切线方向旋转。二次贝塞尔在t=1处的切线方向可以直接求导得到:
切线方向 = 2 * (P2 - P1)这里P2是终点,P1是控制点。所以在脚本里可以加一个公开的Transform arrow,在BuildCurve里更新它的位置到B点并朝向这个切线方向:
if (arrow != null) { arrow.position = b; arrow.forward = (b - c).normalized; }注意二次贝塞尔公式对t求导后代入t=1,结果是2*(P2-P1),方向跟b-c完全一致,所以直接用b-c算切线方向即可。把这个小箭头放在B点,配合流动的虚线,整个引导线的指向性就很明确了。
4. 踩坑记录:常见问题与排查技巧
4.1 为什么虚线变成长条色块或者一整条实线
这个问题出现频率最高。第一反应先查材质:是不是用了普通的Unlit或者Standard材质,而不是支持透明通道的Shader。只有Sprites/Default、Legacy Shaders/Transparent/Diffuse这类透明Shader才认纹理里的alpha通道,否则你的透明部分全被当成了白色或黑色。
第二个原因就是WrapMode。如果代码里忘了设Repeat,生成出的贴图就不会重复铺在线上,结果就是一张图被拉伸到整条线那么长,看起来当然不是虚线。所以CreateDashTexture里那句tex.wrapMode = TextureWrapMode.Repeat;不能省。
第三个原因是LineRenderer的textureMode设成了Tile或RepeatPerSegment,但材质Scale没配合好。最稳妥的表现是保持Stretch模式,由材质自己控制Tiling。如果你用了Tile模式,就要注意设置LineRenderer的materialTextureScale,我代码里提供的是Stretch方案,遇到问题优先排查前两条。
4.2 虚线方向不对、间隔不均匀
如果你看到虚线不是沿着曲线方向排布,而是横着一条条短竖线,问题几乎一定出在贴图的条纹方向。程序化贴图里用x坐标判断条纹,那么x对应的是纹理U方向,当贴图上线后,U默认沿曲线方向排布,所以应该没问题。但如果美术给的横条纹贴图本身是横向条纹,映射到线上就会垂直于曲线,看起来像一圈一圈的横线。
间隔不均匀的原因,除了前面说的采样点分布问题,还有一种可能是LineRenderer的widthCurve被调过。widthCurve如果两头宽、中间窄,视觉上虚线段会被挤压或拉伸,观察起来就像间隔变化。保证widthCurve是恒定线即可。
另外如果线是闭合的,也就是起点终点连起来了,最后一个采样点和第一个采样点之间会突然出现一个很奇怪的接缝。这篇需求A和B是明确的开放曲线,一般不会遇到闭合问题,但如果你扩展成循环路径,记得把textureMode换成RemarkPerSegment并处理好首尾相接的顶点段。
4.3 流动方向反了、速度不稳定
方向反了是最容易解决但也很容易绊人的一个环节。我在Update里用的是uvOffset -= flowSpeed * Time.deltaTime;。为什么用减号?因为我对偏移量的正负方向期望是“偏移减少时图案往U正方向流动”,但不同贴图方向、不同LineRenderer朝向,实际视觉方向可能完全相反。所以你在自己项目里跑通后如果发现方向不对,直接把-=换成+=,或者把flowSpeed改成负数。不要硬记公式,记住“反了就改符号”这个调试思路更实用。
速度不稳定基本都是流动偏移累加没有跟时间做乘。如果你直接写mat.mainTextureOffset = new Vector2(Time.time, 0f);,万一某帧卡顿,Time.time的增量会突变,流速就会显得抽搐。用uvOffset += speed * Time.deltaTime的方式累加则能保证每帧的偏移增量和真实经过的时间成正比,无论帧率怎么波动,速度感恒定。
4.4 URP管线下材质变洋红色
如果你把项目切到URP,或者新项目一上来就是用URP,上面几个Shader.Find可能会失灵。Built-in管线里的Sprites/Default在URP下根本不存在,Unity找不到指定Shader时就会用默认洋红色占位。我脚本里已经写了回退链,Sprites/Default找不到就找Universal Render Pipeline的Sprite Shader,再找不到就用Legacy Shaders/Transparent/Diffuse。但如果你的URP版本比较新,Shader名字可能又变了,最稳妥的办法是在编辑器里手动新建一个材质,把Shader选成“Universal Render Pipeline/Unlit”或“Universal Render Pipeline/2D/Sprites-Default”,再把贴图拖上去,然后把材质拖给LineRenderer的material属性,绕过运行时Shader.Find。
还有一个隐藏问题:URP的材质如果没有启用“Receive Shadows”之类的设置,虚线上可能有奇怪的黑影或透明排序问题。对单条线来说,把材质面板里的Surface Type设为Transparent、Blend设为Alpha即可,遇到太暗的表现优先查这一项。
4.5 点移动后曲线不跟随刷新
我在Update里判断了Transform.hasChanged,但这招有个前提:每次BuildCurve后要把三个Transform的hasChanged手动置回false。如果你漏了这一行,那么点只动过一次之后,每一帧都会重复重建曲线,虽说不至于报错,但CPU浪费非常明显。反过来,如果你把pointB或controlPoint移动时机放在非主线程或者某些插值帧里,hasChanged标志可能没有被正确标记,曲线就会一直停留在旧位置,此时最简单的办法是调一个公开方法手动调用BuildCurve:
private void OnDrawGizmosSelected() { if (pointA != null && pointB != null) BuildCurve(); }OnDrawGizmosSelected在编辑器里选中物体时也会执行,很适合调试阶段手动刷新。注意这只在编辑器里有效,运行时还是要靠Update里的hasChanged检测。
4.6 曲线追随移动物体时的坐标系问题
如果你的A点挂在某个会跑动的角色身上,LineRenderer又挂在另一个父物体下,最短路径是把LineRenderer.useWorldSpace设为true,所有采样点都用世界坐标。如果场景层级复杂,父物体位置一直在变,无论是把LineRenderer挂进父物体还是单独挂一个空物体,只要useWorldSpace=true,它都能正确显示世界坐标下的曲线。反过来,如果useWorldSpace=false,那你必须把贝塞尔采样结果从世界坐标换算到LineRenderer所在物体的局部坐标,否则曲线会跑到完全错误的位置。
很多团队会习惯性创建一个单独的“特效Root”来统一管理这类引导线,我个人也很推荐这种结构:特效Root挂脚本,PointA和PointB由业务逻辑每帧赋值位置,控制点由美术在场景里摆好,其余全部自动。这样既方便批量管理,又不会跟角色绑定时产生坐标换算的麻烦。
我自己在实际项目里是这么扩展这套方案的:需要做多段路径时,把多段二次贝塞尔曲线首尾相连,前一段的终点作为后一段的起点,然后分别计算各自的控制点;想让引导线看起来更高级时,把LineRenderer的widthCurve调成前窄后宽,配合虚线流动,视觉上就像能量在汇聚。这篇文章给的是最精简的落地版本,你拿到之后先按原样跑通,再按自己的项目把控制点暴露给美术、把流速和密度参数做成可调项,这套代码基本就能长期服役了。