最近项目里接到一个挺有代表性的需求从角色A到目标B之间要画一条虚线轨迹而且这条线还得有方向感——看起来像能量从A端流向B端。一开始我图省事直接用美术给的序列帧粒子去做结果发现改起落点、调弯曲程度都特别痛苦后来干脆换成LineRenderer自己算贝塞尔曲线性能和可控性都上来了。这个方案说白了就是三件事的组合用贝塞尔曲线算出A到B之间的弯曲路径用LineRenderer把这串坐标点渲染成一条线再配合纹理平铺和UV偏移让虚线自己动起来。整个链路不复杂但每个环节都有不少细节坑。这篇文章就按我实际做的顺序把方案选型、数学原理、虚线材质、流动动画、完整代码和踩坑记录一次讲清楚。1. 为什么选LineRenderer做弹道连线一次方案选型复盘1.1 这个需求到底在说什么先拆解一下需求本身。表面上是一句“A到B的贝塞尔虚线”实际上拆开是四个子需求曲线路径两个端点之间不是直线连接需要有弧线、抛物线或者S形过渡。虚线显示线本身不是实心的而是像“---- ----”这样的分段短线。方向感动态效果上要有流动感让玩家一眼看出能量是从A点出发流向B点的。实时可控A点和B点的位置可能是移动的曲线的弯曲程度需要随时能调。这四个需求加起来用序列帧动画或者预制体的方式做几乎没法做到通用。而我手上这个项目里同类型的线还不止一条——技能弹道预测、传送门连线、敌人之间的仇恨线、队友BUFF传导线全都长一个逻辑。所以我必须找一个能在代码层生成、参数化调整、渲染开销还小的方案。1.2 三种可选方案的对比实际对比过三条路LineRenderer、粒子系统、自建Mesh。粒子系统的好处是美术表现力最强能做出流光、拖尾、光晕各种花哨效果。但坏处也很明显做弹道预测这种实时变化的线你得把粒子发射器贴着曲线路径摆每帧都要更新发射位置和方向稍微复杂一点曲线就顾不过来。而且粒子涨涨落落很难保证“虚线每一段的长宽一致、间距均匀”。自建Mesh是最灵活的方案可以用代码生成四边形面片沿着贝塞尔曲线铺一条带状网格再在Shader里做虚线纹理和流动。想做到什么效果都能做出来但成本也最高要处理顶点的生成、法线方向、UV展平、排序管理为了一个连线特效写这么多代码性价比不大。LineRenderer是Unity自带组件专门用来渲染一串坐标点构成的多段线。它内部本身就帮你处理了顶点生成、宽度渐变、纹理UV映射这些事配合材质Tiling模式虚线效果属于原生支持。虽然美术上限不如粒子但胜在轻量、稳定、代码量小覆盖这四类需求绰绰有余。1.3 我最终的选择逻辑最后定的是LineRenderer核心思路——贝塞尔曲线只负责“算位置”LineRenderer负责“画出来”Shader或者纹理滚动负责“动起来”三者各管一摊互不越界。这样做最实在的好处是你不需要对每种曲线形状写不同逻辑只要把控制点点位换一换曲线形状就变了A点B点做成可挂在角色身上的空物体移动时每帧重算一次曲线点就行流动方向想反转把UV偏移速度改成负值就完事。对我来说能把“表现”和“逻辑”拆开后面所有改动都省心。2. 贝塞尔曲线的数学底子一阶到三阶一口气讲透2.1 先从最朴素的线性插值说起贝塞尔曲线听起来吓人本质就是“带权重的插值”。一阶贝塞尔就是两点之间的线性插值P(t) (1-t)·P0 t·P1当t从0走到1结果点就从P0平滑移动到P1。这个公式本身不稀奇LineRenderer两点之间本来就能画直线。问题在于直线太单调实战里我想要的是弧线。想让直线变成弧线做法是在中间加一个“牵引点”。这个点不直接出现在曲线上但它的位置会把曲线往自己那边拉——这就是控制点。2.2 二阶贝塞尔最常用的弧线方案二阶贝塞尔有三个点起点P0、终点P2、控制点P1。公式如下P(t) (1-t)²·P0 2·(1-t)·t·P1 t²·P2怎么理解你可以在心里模拟这个过程t在0到1之间变化时P0到P1的线段上有一个点沿着走P1到P2的线段上也有一个点沿着走这两个点再连线段在线段上再做一次比例分割。三次连续的插值得到的就是那条平滑曲线。控制点P1抬得越高曲线的弧度就越大。控制点偏向左边曲线的“山峰”就靠左。这是一个非常自然的调节方式我在项目里把控制点默认放在P0和P2中点的垂直上方通过一个offset参数控制高度效果上就像一条完整的抛物线弹道。二阶贝塞尔适合表现单峰弧线比如弓箭弹道、跳跃路径。但有些场景比如需要S形曲线中间需要拐一个弯再拐回来二阶就忙不过来了得用到三阶。2.3 三阶贝塞尔S形曲线的入场券三阶贝塞尔有四个点P0、P1、P2、P3。公式P(t) (1-t)³·P0 3·(1-t)²·t·P1 3·(1-t)·t²·P2 t³·P3P0是起点P3是终点P1控制起点附近的切线方向P2控制终点附近的切线方向。想画S形就让P1和P2在线的两侧——P1往上拉P2往下拉曲线就会先往上一拐再往下一拐自然形成S。实战里我更常用的做法是把P1放在A点正前方一段距离P2放在B点正后方一段距离。这样就能保证曲线的首尾方向与A、B两物体的朝向对齐看起来像能量从A嘴里喷出去、绕个弧线钻到B身上而不会出现“线从A出来时方向是歪的”这种穿帮观感。2.4 代码封装三个函数走天下这段代码是后面所有内容的地基建议直接放进项目里当工具类用using UnityEngine; public static class BezierUtility { public static Vector3 GetPoint(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; } public static Vector3 GetPoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t Mathf.Clamp01(t); float u 1f - t; float uu u * u; float tt t * t; return uu * u * p0 3f * uu * t * p1 3f * u * tt * p2 tt * t * p3; } }调用方式也很直白把起点、终点、控制点传进去再传一个0到1的t返回的就是曲线上对应位置的坐标。3. 把一条线变成虚线纹理平铺、材质设置与Tiling逻辑3.1 用纹理密度替代逐段画线贝塞尔曲线算出来是一堆顶点坐标但这些顶点连起来默认是实线。想要虚线最省事的办法不是代码里判断哪段显示哪段隐藏而是用一张“黑白分段”的纹理做主贴图让LineRenderer在渲染时把纹理均匀地铺在整条线上。做法准备一张宽度适中、横向由透明段和白色段交替组成的贴图比如一段白色占40%、透明占60%。当LineRenderer把这张图沿线的方向拉伸开线就成了虚线。白色段是可见的透明段是空隙。这也是最通用的做法因为美术可以随便换纹理来改变虚线的长短、密度和透明度。3.2 两种纹理模式Stretch和Tile的一次抉择LineRenderer的TextureMode设置里有两个关键选项决定了贴图怎么贴Stretch整张贴图被拉伸到覆盖整条线不管线有多长虚线段的间距都会跟着被拉伸。线越长每个虚线段越长。Tile贴图保持原始单位尺寸沿线的路径重复平铺。线的长度变化不会改变每段虚线的尺寸只会增加重复次数。我的经验是做弹道虚线、连线特效用Tile。因为玩家手里的角色A到B距离可能是十米也可能是三十米如果用Stretch虚线的“段长”随距离变化远处看起来天上一段地上一段非常难看。Tile模式不管线多长每一小段的长宽保持一致视觉上稳定得多。3.3 材质贴图参数的关键设置材质设置这块有个容易踩的坑LineRenderer用的材质主贴图Wrap Mode必须设成Repeat否则Tiling超过1之后超出部分会变成拉伸的颜色虚线看起来就是糊的。在Unity里的操作路径是选中贴图文件在Inspector面板把Wrap Mode从Clamp改成Repeat然后应用。这一步不做后面代码里把mainTextureScale调成10、20都没意义。接着把材质挂到LineRenderer上脚本里设置tilinglineRenderer.material.mainTextureScale new Vector2(density, 1f);density就是虚线密度。比如说你希望整条线大约出现20个虚线段density设20就对了。为什么这里只改x轴因为纹理沿线的方向对应UV的U轴横向拉伸就是控制虚线重复多少组Y轴保持1就行。3.4 一张纹理的参数速查表注意使用Tile模式时需要注意材质的渲染队列和透明度设置尤其是叠加在UI或场景特效上时需要配合Blend模式使用。下面这个表是我反复调试后觉得比较顺手的基准参数可以直接参考参数推荐值说明Texture Wrap ModeRepeat不设置的话Tiling超过1会显示异常Texture Filter ModeBilinear虚线边缘略显平滑不追求像素风默认用这个TextureModeTile保证虚线密度稳定不看距离脸色AlignmentView线和相机视角对齐适合做连线类特效主贴图Alpha白段Alpha 255 / 透明段0虚线段边缘如果想要羽化效果Alpha渐变过渡即可4. 让方向“流”起来UV偏移与Shader流动的两种实现4.1 视觉欺骗让纹理往一个方向滚动虚线本身是静态的怎么让它看起来像从A流向B原理和电影里的移动字幕一样——让纹理坐标随时间均匀增加贴图就会沿着一个方向“滚动”。眼睛看到的是虚线段在往B方向运动从而产生能量流动的错觉。实现方法有两个层级一个是纯脚本不写Shader每帧改材质的mainTextureOffset另一个是写一个自带流动功能的小Shader把偏移逻辑放在GPU上。两种我都用过先说简单的脚本方案再说Shader方案。4.2 脚本方案每帧更新UV偏移在Update里面加一行偏移Vector2 offset lineRenderer.material.mainTextureOffset; offset.x - flowSpeed * Time.deltaTime; lineRenderer.material.mainTextureOffset offset;为什么是x轴负方向取决于你的纹理方向定义和A、B的实际位置关系。我的习惯是把A点放在路径起点UV的U轴默认从0到1沿着线走想让它从A流向B就让纹理沿正向滚动对应在Unreal里看更像是随着t增大纹理往前走。如果发现方向反了直接把flowSpeed取负就行。这个方案的优点是真的简单缺点是要小心材质实例化的问题后面踩坑部分会细说。4.3 Shader方案把流动交给GPU脚本改偏移值最终也要传到GPU去采样纹理。干脆直接写一个Shader把流动速度作为参数暴露材质上调一个数值就完事了。这方案实测下来更干净尤其当同一项目里有多条线、每条线速度不同时Shader参数比脚本循环省心。Shader Custom/LineFlowDashed { Properties { _MainTex (虚线纹理, 2D) white {} _FlowSpeed (流动速度, Float) 1.0 _TintColor (主色调, Color) (1,1,1,1) _FlowDirection (流动方向, Vector) (1,0,0,0) } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; float _FlowSpeed; fixed4 _TintColor; float4 _FlowDirection; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { float2 uv i.uv; uv _FlowDirection.xy * _Time.y * _FlowSpeed; fixed4 col tex2D(_MainTex, uv) * _TintColor; return col; } ENDCG } } }编译完把材质拖给LineRenderer流动速度这东西就变成一个可视化参数了。想反过来流把FlowDirection的x设成-1想斜着流就同时设x和y。这个Shader很短丢进项目里也不占多少性能。4.4 流动速度的心得取值流动速度这个参数我个人经验是跟“线的长度”联动更自然。固定速度最大的问题是短线上流动很快长线上流动很慢观感飘忽。更好的做法是把速度定义为“多少秒流动一整条线”。如果你的线长度为L那么flowSpeed可以设成 L / flowTimeflowTime就是你希望整条线流动完一轮的时间。这段逻辑可以放在脚本初始化里做float lineLength 估算曲线长度(); // 见第5节 float flowTime 2f; // 期望2秒流完 shaderFlowSpeed lineLength / flowTime;曲线上每个点的间距——注意这里不是直线距离实际是曲线上所有相邻点距离的累加。用顶点采样点算即可。5. 从A到B的完整实现整合贝塞尔、虚线与流向的一个组件5.1 组件设计思路前面几节分别讲了算法、纹理、流动这一节把它们装进同一个组件里。工具命名为BezierLineController挂在拥有LineRenderer的空物体上即可。组件的公开参数包括起点A、终点BTransform类型让策划直接把空物体拖上去。控制点可以让美术手动拖也可以提供一个控制点偏移量自动算出来。曲线阶数二阶/三阶切换。虚线段数即tiling密度。流动速度和流向开关绑定。5.2 代码实现using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class BezierLineController : MonoBehaviour { [Header(端点)] public Transform pointA; public Transform pointB; [Header(控制点设置)] public bool autoControlPoint true; public Transform customControlPoint; [Range(0.2f, 3f)] public float arcHeight 1f; [Header(曲线参数)] [Range(2, 3)] public int bezierOrder 2; [Range(10, 100)] public int segmentCount 40; [Header(虚线表现)] public int dashDensity 20; [Header(流动动画)] public bool enableFlow true; public float flowTime 2f; private LineRenderer lineRenderer; private Material lineMaterial; void Awake() { lineRenderer GetComponentLineRenderer(); lineRenderer.textureMode LineTextureMode.Tile; lineRenderer.alignment LineAlignment.View; lineRenderer.positionCount segmentCount 1; } void Start() { ApplyDensity(); ApplyFlowByLineLength(); } void Update() { UpdateCurvePoints(); } void UpdateCurvePoints() { Vector3 p0 pointA.position; Vector3 p3 pointB.position; Vector3 p1, p2; if (bezierOrder 3) { Vector3 dir (p3 - p0).normalized; float dist Vector3.Distance(p0, p3); p1 p0 Vector3.Cross(Vector3.up, dir).normalized * arcHeight * dist * 0.5f; p2 p3 Vector3.Cross(Vector3.up, dir).normalized * arcHeight * dist * 0.5f; } else { Vector3 mid (p0 p3) * 0.5f; Vector3 dir (p3 - p0).normalized; Vector3 normal Vector3.Cross(dir, Vector3.up).normalized; p1 mid normal * arcHeight * Vector3.Distance(p0, p3) * 0.5f; } for (int i 0; i segmentCount; i) { float t i / (float)segmentCount; if (bezierOrder 3) { lineRenderer.SetPosition(i, BezierUtility.GetPoint(p0, p1, p2, p3, t)); } else { lineRenderer.SetPosition(i, BezierUtility.GetPoint(p0, p1, p3, t)); } } } void ApplyDensity() { lineRenderer.material.mainTextureScale new Vector2(dashDensity, 1f); } void ApplyFlowByLineLength() { if (!enableFlow) return; lineRenderer.material.SetFloat(_FlowSpeed, 1f / flowTime); } }5.3 使用步骤把代码挂上之后按这个顺序设置创建一个空物体Add Component搜索BezierLineController并挂上。创建一个材质Shader选Custom/LineFlowDashed拖给LineRenderer的Material属性。在场景里创建A和B两个空物体调整好位置拖到组件对应的字段里。如果勾选了autoControlPoint只需要调整arcHeight控制弯曲幅度如果想手动控制弯度取消勾选把自定义控制点拖进去。运行场景调整dashDensity控制虚线密度调整flowTime控制流动速度。整套流程跑完一个动态虚线连线的特效已经能用了。这个组件我在模拟项目里一次挂了八条线完全没压力。5.4 一个容易忽略的调用时机问题Start函数里如果有设置位置点的操作注意LineRenderer的positionCount必须在赋值之前先就位。我在Awake里做positionCount初始化就是为了确保其他脚本在Update里直接SetPosition时不会报越界错误。这个顺序看着小实战里真能绊人一跤。6. 实测踩坑顶点数、纹理变形、材质共享与性能优化6.1 顶点数不是越多越好刚开始调参数时我习惯把segmentCount拉到100甚至200觉得曲线越“顺滑”越好。结果在性能开销较小的移动设备上八条线同时跑就隐约感到卡顿。后来把顶点数统一压到每条约25到40视觉上几乎看不出区别开销却下来一大截。根本原因是LineRenderer每帧要更新所有顶点数据和图元信息顶点数翻倍CPU侧的提交工作量也翻倍。尤其是动态改点位置的用法每帧都要重写全部position所以顶点数的控制非常重要。判断标准很简单放大看曲线只要相邻点连线形成的折角在正常距离看不见就够用了。6.2 TextureMode与纹理拉伸导致的虚线失真用Stretch模式时曲线两端点距离拉长整个贴图被均匀拉伸虚线段也跟着变宽。如果我A到B的距离从10米变成30米虚线块就会胖成豆腐块完全没有矢量虚线那种轻盈感。切成Tile模式之后虚线块大小不看距离重复密度稳定问题立刻消失。但Tile模式也有一个隐藏问题曲线方向变化剧烈时比如弧度很大的三阶S曲线LineRenderer会自动生成UV畸变矫正个别情况下虚线段的间距会不均匀。实测下来只要segmentCount不低于30这种畸变基本肉眼不可见不太需要额外处理。6.3 共享材质引发的“串线”事故项目里多条线如果共用一个材质而脚本里做的是lineRenderer.material.mainTextureOffset xxx会触发材质实例化。但如果你图省事直接在Start里拿到sharedMaterial并缓存下来所有线的偏移会互相覆盖——你这条流动快那条流动慢结果两个都按最后一条的设置来动画效果直接废了。我的解决方案是每条线在Start里通过lineRenderer.material拿到属于自己的实例之后所有偏移操作都基于这个实例。别忘了在OnDestroy里调用Destroy恢复资源否则会有材质泄漏的小隐患。6.4 相机裁剪和性能优化LineRenderer默认会被相机裁剪掉屏幕外的部分逻辑上没问题。但如果你做的连线特效需要用后处理模糊得注意把材质队列放到Transparent并用ZWrite Off避免遮挡深度问题。性能优化方面如果线上的点不需要每帧更新——比如静态建筑的传送阵连线——可以在设置完所有位置后调用lineRenderer.enabled true就再也不碰它完全零开销。动态物体之间的线才需要每帧刷新这也是为什么我在组件里建议把动态更新和静态初始化分开。按需刷新比每帧盲目刷要省得多。6.5 一个调试切入点先画实线再画虚线这是我反复用的一招排查顺序做任何流动或虚线效果第一步先把材质换成纯色、关闭tiling确认贝塞尔曲线本身形状没问题。第二步开纹理tiling确认虚线密度正常。第三步开Shader流动确认方向正确。三步走完哪一步出问题就从哪一步查起不互相干扰。这个方法听着笨但每次都能定位出问题。上周同事说“流动方向不对”我上去第一件事就先关了流动看虚线再关了虚线看曲线最后发现是A、B节点从世界坐标换成了UI坐标曲线的翻转把方向搞反了跟代码本身一点关系没有不到三分钟就解决了。最后再分享一个小技巧前面整套方案跑下来如果还想再进一步优化观感可以在LineRenderer的Width Curve上微调——把宽度设成“头宽尾窄”A端粗一点、B端细一点流动感立刻强烈一倍。如果你嫌做特效还要调曲线麻烦对这个组件的最简改法是把curve宽度两端分别设成0.2和0.8这个细节能极大增强方向感。至于更进阶的玩法比如一条线中途分叉到多个B点、把虚线换成箭头贴图、加粒子点缀都是从这套底座上长出来的骨架不变改改贴图、改改端点数量就能成。这个“贝塞尔算位置 LineRenderer渲染 纹理滚动做动画”的思路两三行就能改出无数种特效回头再看前期为了方案选型翻来覆去对比的那会儿只能说真庆幸当时选对了工具。