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

Unity中使用LineRenderer实现贝塞尔曲线虚线流动效果

Unity中使用LineRenderer实现贝塞尔曲线虚线流动效果 ★ FEATURED ARTICLE
做项目的时候经常会碰到这类需求技能释放后脚下要拉出一条能量路径从角色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 是控制点。t0 时落在P0t1 时落在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 GetComponentLineRenderer(); if (line null) line gameObject.AddComponentLineRenderer(); 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点位置额外放一个箭头图标让它沿着曲线终点的切线方向旋转。二次贝塞尔在t1处的切线方向可以直接求导得到切线方向 2 * (P2 - P1)这里P2是终点P1是控制点。所以在脚本里可以加一个公开的Transform arrow在BuildCurve里更新它的位置到B点并朝向这个切线方向if (arrow ! null) { arrow.position b; arrow.forward (b - c).normalized; }注意二次贝塞尔公式对t求导后代入t1结果是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标志可能没有被正确标记曲线就会一直停留在旧位置此时最简单的办法是调一个公开方法手动调用BuildCurveprivate void OnDrawGizmosSelected() { if (pointA ! null pointB ! null) BuildCurve(); }OnDrawGizmosSelected在编辑器里选中物体时也会执行很适合调试阶段手动刷新。注意这只在编辑器里有效运行时还是要靠Update里的hasChanged检测。4.6 曲线追随移动物体时的坐标系问题如果你的A点挂在某个会跑动的角色身上LineRenderer又挂在另一个父物体下最短路径是把LineRenderer.useWorldSpace设为true所有采样点都用世界坐标。如果场景层级复杂父物体位置一直在变无论是把LineRenderer挂进父物体还是单独挂一个空物体只要useWorldSpacetrue它都能正确显示世界坐标下的曲线。反过来如果useWorldSpacefalse那你必须把贝塞尔采样结果从世界坐标换算到LineRenderer所在物体的局部坐标否则曲线会跑到完全错误的位置。很多团队会习惯性创建一个单独的“特效Root”来统一管理这类引导线我个人也很推荐这种结构特效Root挂脚本PointA和PointB由业务逻辑每帧赋值位置控制点由美术在场景里摆好其余全部自动。这样既方便批量管理又不会跟角色绑定时产生坐标换算的麻烦。我自己在实际项目里是这么扩展这套方案的需要做多段路径时把多段二次贝塞尔曲线首尾相连前一段的终点作为后一段的起点然后分别计算各自的控制点想让引导线看起来更高级时把LineRenderer的widthCurve调成前窄后宽配合虚线流动视觉上就像能量在汇聚。这篇文章给的是最精简的落地版本你拿到之后先按原样跑通再按自己的项目把控制点暴露给美术、把流速和密度参数做成可调项这套代码基本就能长期服役了。
阅读完成 · 觉得有帮助?
咨询建站