“电波”这个词在需求文档里的出现频率,比大多数人以为的要高。雷达扫描、信号干扰、能量传导、心跳监测那种跳动的波形线、科幻界面里不断呼吸的水平流动光带——策划写下来往往只有四个字:要有电波。真正落到实现上,第一反应通常是拖一个 Particle System,或者找一张长条贴图做 UV 滚动,但做出来既有形状又有细节的方案,往往是LineRenderer配合一个自定义Shader。这套组合的分工非常清晰:线的形状由 C# 计算,位置、起伏、长度全部可控;线上的光效由 Shader 计算,脉冲、渐隐、闪烁全部在 GPU 上完成;两者之间只靠一组 UV 传话。本文拆的就是这套组合,从方案选型、参数换算、Shader 逐段实现,到实际调参时踩过的坑,都会写清楚。适合已经会用 LineRenderer 画条线、但被“那条线看起来很死板”卡住的开发者,也适合刚接触手写 Shader、想找一个足够小又足够典型的练手项目的人。全文的代码可以直接抄进工程跑起来,改几个数字就能变成完全不同的效果。
1. 先把需求拆开:电波到底在画什么
1.1 四种常见形态,做法完全不同
很多人一开始就把“电波”当成一个东西,结果做出来四不像。我的习惯是先把它拆成形态,形态决定技术路线。实际项目里遇到的绝大多数需求,都能归到这四类里:
| 形态 | 视觉特征 | 核心难点 | 推荐做法 |
|---|---|---|---|
| 连续波动线 | 正弦起伏,整体流动 | 波形频率与流速的匹配 | LineRenderer 点位 + 流动 Shader |
| 脉冲跳动线 | 有尖峰、有平段,像心电 | 尖峰锐化不能糊 | 包络函数 + pow 锐化 |
| 电弧/闪电 | 高频抖动、分叉、忽明忽暗 | 随机性要“像”但不重复 | 噪声扰动 + 顶点抖动 |
| 环形扩散波 | 从中心向外一圈圈推开 | 圆形 UV 与环宽的映射 | 圆环 LineRenderer 或 Plane |
这四类里,前两类本质上是同一条技术路线,只是包络函数换一下;第三类需要额外的噪声层;第四类换个几何体就行。所以只要把前两类的骨架搭好,后面两类都是在同一个 Shader 上加参数,不需要重写。
我当时接手的是一个科幻题材的小 Demo,界面上需要一条横贯面板的信号线,要求是“平时安静地流,出事件时猛地跳一下再恢复”。这个需求听着简单,实际上同时踩中了前两类的坑:静态流动要顺滑,事件触发时要能瞬间拉高能量,还要平滑回落。最后落地的方案就是一条 LineRenderer 加一个可参数化的 Shader,事件触发时只改三个 float,代码量很小。
1.2 为什么是 LineRenderer 加 Shader 这个组合
先说不选别的的理由。用 Particle System 做电波线,形状控制会很别扭,粒子的顺序、间距、亮度衰减都要靠曲线硬调,一旦要线随两点之间的实际距离自适应,参数会失控。用 UI Image 加贴图滚动,做静态背景没问题,但只要线需要拐弯、需要贴合三维空间里的物体表面,UI 那一套立刻失效。用 TrailRenderer 倒是能拖出流动感,可它的轨迹是“已经走过的地方”,你没法精确控制某一段的亮度,也没法让波停在原地。
LineRenderer 的优势在于它是程序化几何:你给多少点,它就连多少段,UV 沿着线的长度方向自然铺开。这意味着波形可以在 C# 里用任意数学函数生成,也可以实时响应游戏逻辑(比如信号强度下降时让振幅变小)。而 Shader 的优势在于它是逐像素计算:软边、脉冲、噪声、闪烁这些细节,如果用贴图去实现,需要一张很长的图,还要处理不同线长下的拉伸;用 Shader 算,永远清晰,永远跟得上分辨率。
两者结合的关键接口只有一个:UV。LineRenderer 生成的网格,沿长度方向的 U 从 0 到 1(或者按世界距离平铺),宽度方向的 V 从 0 到 1(一侧是 0,另一侧是 1)。把这个规则吃透,后面所有效果都是在这两个数字上做文章。
提示:不同 Unity 版本、不同渲染管线对 V 方向的端点约定可能有一点点差异,写 Shader 时不要假设“上边一定是 1”,用
abs(uv.y - 0.5)这种对称写法可以规避掉这个不确定性。
1.3 一个容易被忽略的前置判断:这条线在哪个空间
动手之前必须先确认一件事:这条电波线是画在世界空间还是屏幕空间。这个判断会影响后面一半的参数。
世界空间的线,用useWorldSpace = true,位置直接给世界坐标,好处是不受父物体缩放影响,坏处是摄像机一动,线的透视会变,宽度方向的软边在远处会挤成一条硬边。屏幕空间的线(比如 UI 面板上的波形),通常挂在 Canvas 下,用useWorldSpace = false,位置用局部坐标,好处是永远正对摄像机、不会变形,坏处是不能贴到三维物体上。
我个人的经验是:面板、HUD、监控界面这类,一律走屏幕空间,Shader 里可以放心用 V 做软边,效果最稳定;场景里的能量束、扫描波,走世界空间,但要把alignment设成View,让线始终面向摄像机,否则摄像机转个角度线就侧过去了,看着像纸片。
2. 方案选型:三条路走下来为什么留下这条
2.1 波形在 C# 算还是全在 Shader 算
这是最先要做的决策,也是决定后期好不好改的关键。两条路都走得通,但代价完全不同。
全在 Shader 算的意思是:LineRenderer 只画一条直的、两三个点的线,起伏全靠顶点着色器偏移。优点是 CPU 零开销,几万条线也不怕。缺点是 LineRenderer 的段数决定了顶点数,如果只有两个点,顶点着色器再怎么偏移也只能弯一次,做不出多周期的波形;而且线的包围盒不会跟着顶点偏移更新,稍微偏多一点就会被视锥剔除,屏幕上突然消失。
波形在 C# 算、光效在 Shader 算是另一条路:C# 按密度铺点,把正弦、噪声、包络都算进点位里,Shader 只负责沿着 UV 做流动和渐隐。优点非常明显——形状完全可控,拐点、尖峰、随机抖动都能精确到点;包围盒自动正确;调试时可以随时把点位打到日志里看。缺点是要在 CPU 上算点,不过这个开销其实很小,一条 100 个点的线,每帧重算也就几百次三角函数,跟 UI 的布局计算比起来根本不算什么。
我选的是后者,也就是 C# 算形状、Shader 算光。理由很实际:这个项目里电波的形状要跟游戏事件联动(信号弱的时候振幅变小、频率变乱),如果形状在 Shader 里,这些联动就得改成传一堆参数进去,耦合会越来越重;放在 C# 里,改一行代码就能改行为。
2.2 混合模式与渲染队列的选择逻辑
电波效果的光感,八成来自混合模式。选错了,怎么调颜色都不对。
Blend One One是加色混合,也就是颜色直接叠加到背景上。做发光效果、能量束、科幻光带,这是首选,因为它天生就有“越叠越亮”的物理直觉,多条线交叉的地方会自然形成热点,看着很像能量汇聚。代价是这种混合下颜色很容易过曝,特别是叠了 Bloom 之后,一片死白。
Blend SrcAlpha OneMinusSrcAlpha是常规透明混合,颜色会被 alpha 调制,好处是不过曝、颜色可控,坏处是没有“光”的感觉,看着像半透明的塑料片。
我的做法是:单条静态波用One One,把强度参数压到 1 到 2 之间;多条叠加的场景用SrcAlpha OneMinusSrcAlpha,或者在 Shader 里对最终颜色做一次saturate限幅再乘以强度,避免叠成白块。
渲染队列设成Transparent,ZWrite Off,Cull Off。Cull Off必须开,因为 LineRenderer 生成的是双面薄片,摄像机稍微绕到背面就看不见了,这个坑很多人第一次都会踩。ZWrite Off意味着这条线不会被不透明物体遮挡吗?不是的,ZTest默认还是LEqual,所以它会被前面的不透明物体正确遮挡,只是线跟线之间不互相遮挡,会叠加。
2.3 后处理的配合:Bloom 不是万能药
很多人以为加了 Bloom 就有了光。实际上 Bloom 对输入有要求:亮度必须超过阈值才会被提取。所以如果 Shader 输出的颜色本身很暗(比如(0.1, 0.3, 0.4)),Bloom 一点反应都没有,你会以为是后处理没生效,其实是亮度不够。
正确的做法是在 Shader 里留一个_Intensity参数,最终颜色乘上去,让核心亮度能到 1.5 到 3 之间,边缘保持低于 1。这样 Bloom 只会提取核心部分,形成一圈柔和的光晕,而不是整条线糊成一团。这个参数我在项目里改成运行时可调,策划想要“更亮一点”的时候直接拖动,不用重新出包。
3. LineRenderer 的关键参数与曲线生成
3.1 那几个必须手动设的参数
新建一个 LineRenderer,默认配置几乎没法直接用。下面这几个参数是每次都要过一遍的:
positionCount决定有多少个点。注意它是预分配的,改大改小都会重新生成网格,运行时频繁改会有 GC 和重建开销,所以要么一开始就估好上限,要么用固定点数、改点位的方式,不要每帧改 count。
useWorldSpace前面说过,场景物体设 true,UI 设 false。这个属性的坑在于:设成 true 之后,物体被父节点移动时,线的位置不会跟着动,因为坐标已经是世界坐标了。如果线要跟随某个物体,记得自己在 Update 里重算点位。
alignment有三个选项,做电波基本都用View,让线正对摄像机。用TransformZ的话,摄像机一转,线就成了一条细缝。
textureMode是Stretch或Tile。Stretch下 U 从 0 到 1 铺满整条线,不管线多长。Tile下 U 按世界距离重复,配合material.SetTextureScale能控制重复次数。做电波我一般用Stretch,因为流动的周期数在 Shader 里用参数控制更灵活,不受线长影响;Tile的好处是线变长时波长不变,适合那种“线可以无限拉长但波的感觉要保持一致”的场景。
numCornerVertices和numCapVertices分别控制拐角和端点的圆滑度。默认是 0,也就是硬拐角、方头端点。做高压电弧、能量束的时候,numCornerVertices = 4、numCapVertices = 6能让拐角处的光带过渡自然很多。代价是顶点数会增加,每个拐角多插 4 个点,每段端点再多几个。
widthCurve是宽度曲线,横轴是沿线的比例,纵轴是宽度倍数。做电波时把它设成两端收窄、中间略粗的曲线(比如AnimationCurve.EaseInOut(0, 0.3f, 1, 0.3f),中间打一个 key 到 1),出来的效果就像一条有“腰”的光带,比等宽的线高级一截。这里要注意:widthCurve改变的是几何宽度,但 V 方向仍然是 0 到 1,所以屏幕上的软边会被挤压——窄的地方软边看起来更硬,宽的地方更柔。这个细节后面在坑表里还会提。
3.2 波形点的生成与开销控制
点位的生成逻辑不复杂,核心是把整条线按长度切分成 N 段,每段一个点,然后给每个点加偏移。写的时候有几个地方值得讲究。
第一,点数要按距离算,不能写死。一条 2 米和一条 20 米的线用同样的点数,短的那条会过度细分,长的那条会出现折线感。我用的公式是count = Mathf.Clamp(Mathf.RoundToInt(distance * density), 2, 4096),density取 8 到 12,也就是每单位长度 8 到 12 个点。4096 是 LineRenderer 的点数上限,超过会报错。
第二,用SetPositions而不是循环SetPosition。SetPosition每次调用都会触发一次内部的位置设置,循环 200 次就是 200 次调用,虽然不一定每帧都重建网格,但开销明显。SetPositions一次传数组,快得多。数组要提前缓存,避免每帧new Vector3[]产生 GC。
第三,包络要乘上去,不然波形两端会突兀地断掉。直接用Mathf.Sin(t * frequency)生成的波形,在 t=0 和 t=1 处不一定归零,接在另一条线上会出现错位。乘一个Mathf.Sin(t * Mathf.PI)的包络,两端自然收到 0,视觉上就像波从中间涌出来又消失。
核心代码大致是这样:
private Vector3[] _buffer = new Vector3[4096]; void BuildWave(Vector3 a, Vector3 b, float amplitude, float cycles, float phase) { float dist = Vector3.Distance(a, b); int count = Mathf.Clamp(Mathf.RoundToInt(dist * density), 2, 4096); if (_buffer.Length < count) _buffer = new Vector3[count]; line.positionCount = count; Vector3 dir = (b - a).normalized; Vector3 side = Vector3.Cross(dir, Vector3.forward).normalized; for (int i = 0; i < count; i++) { float t = i / (float)(count - 1); float envelope = Mathf.Sin(t * Mathf.PI); float offset = Mathf.Sin(t * Mathf.PI * 2f * cycles + phase) * amplitude * envelope; _buffer[i] = Vector3.Lerp(a, b, t) + side * offset; } line.SetPositions(_buffer); }这段代码里side的计算值得说一下。用Cross(dir, Vector3.forward)得到的是与世界 Z 轴垂直的方向,如果线本身就在 XY 平面上,算出来的偏移方向正好在平面内,看起来是上下起伏。但如果线是沿着 Z 轴摆的,Cross的结果会退化。稳妥的写法是给一个外部指定的“起伏平面法线”,用它跟方向叉乘。
3.3 UV 分布规则:所有效果的地基
前面反复提到 UV,这里单独讲透,因为它决定了后面 Shader 里能做什么、不能做什么。
LineRenderer 生成网格时,每个位置点会对应两个顶点(宽度方向的两端),每个顶点的 U 值按“沿线的比例”给(Stretch模式),V 值是 0 和 1。所以:
uv.x = 0是线的起点,uv.x = 1是终点,中间线性递增。uv.y = 0是宽度的一侧,uv.y = 1是另一侧,uv.y = 0.5是中心线。
由此可以推导出两个实用结论。第一,因为 U 是按比例给的而不是按距离,同样一段 UV 区间在长线上对应的物理长度更长,所以如果想让波长在世界空间保持一致,就得用Tile模式或者自己在 Shader 里乘上线长。第二,因为 V 只有 0 和 1 两个值(实际上因为没有细分,中间没有顶点),所有横向的渐变都必须在片元着色器里用插值算出来——这反而是好事,插值免费且平滑。
注意:
positionCount在运行时改变后,UV 会重新计算,之前缓存的 UV 相关假设全部失效。如果你的 Shader 里用了“每段固定周期数”这类逻辑,改 count 之前先想清楚会不会变形。
4. Shader 核心实现:从 UV 到一条会呼吸的电波
4.1 整体分层:四层叠加出一个像样的电波
我把电波拆成四层,从下往上叠:
底层是软边,负责让线有厚度感,中心亮、边缘透明。没有这一层,线就是一条硬边的色块,非常廉价。
第二层是流动脉冲,负责“电”的感觉,沿着 U 方向滚动,形成明暗交替的波峰。这一层决定了流动速度。
第三层是噪声扰动,负责打破规律性。纯正弦的脉冲看久了会觉得机械,加一点值噪声让它忽快忽慢、忽明忽暗,立刻就活了。
第四层是高频闪烁,负责整体的生命感。幅度很小,但非常关键,能让静态的线看起来在“呼吸”。
这四层都是乘在一起的,任何一层为 0,对应位置就透明。这个结构的好处是每一层都可以单独关掉调,出问题的时候能快速定位是哪一层算错了。
4.2 软边与脉冲的具体算法
软边的算法很直接:把 V 映射到 0 到 1(用abs(uv.y - 0.5) * 2),0 是中心、1 是边缘,然后取pow(1 - v, softness)。softness越大,边缘衰减越快,光带越细越锐;越小,光带越宽越柔。实测下来 1.5 到 2.5 之间最像发光体,超过 4 就变成一条细线了。
脉冲的算法是frac(uv.x * tileCount - time * speed),得到一个 0 到 1 的锯齿波,然后用smoothstep把它切成“亮段 + 暗段”。这里的关键是理解两个参数的物理含义:
tileCount= 整条线上摆多少个脉冲周期。设成 6,就是线上有 6 个亮段。speed= 每秒流过多少个周期。设成 2,就是每秒有 2 个脉冲从起点流到终点。
这两个数一除,就得到波峰的实际移动速度(以线长的比例计):speed / tileCount。举个具体的例子,线长 10 单位,tileCount = 6,speed = 2,那么一个周期的物理长度是 10/6 ≈ 1.67 单位,波峰的移动速度是 (2/6) × 10 ≈ 3.33 单位每秒。这个换算我写在注释里了,因为每次策划说“流得太快”或者“波太密”,我都要先算一遍再改数字,凭感觉调很容易把两个参数搞混。
_PulseWidth控制亮段的占比,0.2 到 0.35 之间比较像电波。太小会变成断续的短划线,太大会连成一片实心光带。这里用smoothstep(0, w, p) * smoothstep(0, w, 1 - p)而不是简单的阈值比较,是为了让亮段的边缘有过渡,硬切会出现锯齿感。
4.3 完整 Shader 代码
下面这份是内置渲染管线下的 Unlit 版本,可以直接新建一个.shader文件贴进去用:
Shader "Custom/SignalWaveLine" { Properties { _Color ("核心颜色", Color) = (0.35, 0.9, 1.0, 1) _EdgeColor ("边缘颜色", Color) = (0.05, 0.35, 0.9, 1) _Speed ("流速(周期/秒)", Float) = 2.0 _TileCount ("周期数", Float) = 6.0 _PulseWidth ("亮段占比", Range(0.01, 0.9)) = 0.25 _EdgeSoft ("软边锐度", Range(0.2, 6.0)) = 1.8 _NoiseScale ("噪声频率", Float) = 9.0 _NoiseAmount ("噪声强度", Range(0, 1)) = 0.25 _FlickerSpd ("闪烁速度", Float) = 14.0 _FlickerAmt ("闪烁幅度", Range(0, 1)) = 0.18 _Intensity ("整体亮度", Float) = 2.2 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" "PreviewType"="Plane" } Blend One One ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; float4 _Color, _EdgeColor; float _Speed, _TileCount, _PulseWidth, _EdgeSoft; float _NoiseScale, _NoiseAmount; float _FlickerSpd, _FlickerAmt, _Intensity; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } float hash11(float p) { p = frac(p * 0.1031); p *= p + 33.33; p *= p + p; return frac(p); } float valueNoise(float x) { float i = floor(x); float f = frac(x); f = f * f * (3.0 - 2.0 * f); return lerp(hash11(i), hash11(i + 1.0), f); } fixed4 frag (v2f i) : SV_Target { float2 uv = i.uv; // 时间取模,避免长时间运行后浮点精度丢失 float t = fmod(_Time.y, 3600.0); // 第一层:横向软边 float v = abs(uv.y - 0.5) * 2.0; float core = pow(saturate(1.0 - v), _EdgeSoft); // 第二层:沿长度方向流动的脉冲 float flow = uv.x * _TileCount - t * _Speed; float p = frac(flow); float pulse = smoothstep(0.0, _PulseWidth, p) * smoothstep(0.0, _PulseWidth, 1.0 - p); // 第三层:噪声扰动,让脉冲不规则 float n = valueNoise(uv.x * _NoiseScale + t * 2.7); pulse *= lerp(1.0, n * 1.8, _NoiseAmount); // 第四层:整体呼吸闪烁 float flick = 1.0 - _FlickerAmt * abs(sin(t * _FlickerSpd)); // 合成 float mask = core * pulse * flick; float3 col = lerp(_EdgeColor.rgb, _Color.rgb, core); float alpha = mask * i.color.a; return fixed4(col * alpha * _Intensity, alpha); } ENDCG } } }这段代码里有几个点值得单独说。fmod(_Time.y, 3600.0)那行是为了防止长时间运行后浮点精度下降导致脉冲变形,_Time.y是自场景加载以来的秒数,跑上几个小时数值会很大,frac之后会出现肉眼可见的抖动。这个坑我是在一个挂机测试里发现的,跑了大概四十分钟波形开始变糊,一开始怀疑是点位计算溢出,最后定位到时间精度。
顶点颜色那部分i.color.a是给 LineRenderer 的渐变色留的通道。LineRenderer 自带colorGradient,会写进顶点色,如果在 Shader 里乘上去,就可以用 Inspector 直接控制线两端的淡入淡出,不用再写额外参数。
4.4 URP 下的改写要点
上面这份是内置管线的写法,URP 下不能直接用。需要改的地方不多,但每一处都必须改:
| 位置 | 内置管线 | URP |
|---|---|---|
| SubShader Tags | 无需RenderPipeline | 加"RenderPipeline"="UniversalPipeline" |
| 代码块 | CGPROGRAM | HLSLPROGRAM |
| 头文件 | "UnityCG.cginc" | "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" |
| 顶点变换 | UnityObjectToClipPos | TransformObjectToHClip |
| 时间变量 | _Time.y | _Time.y(这个一样,URP 也提供) |
| 颜色属性 | 直接声明float4 _Color; | 需要放进CBUFFER_START(UnityPerMaterial)里,否则 SRP Batcher 不生效 |
CBUFFER那一条是 URP 下最容易被忽略的。不包的话,材质属性每帧都要重新绑定,合批效率会下降。对一条线来说影响不大,但如果场景里有几百条线,差距就出来了。
另外 URP 下Blend One One依然有效,但要注意 Volume 里的 Bloom 参数,URP 的 Bloom 阈值默认是 0.9,如果_Intensity设得太低,完全不会有光晕。
5. 让它活起来:从静态波形到可控效果
5.1 参数控制的正确姿势
Shader 参数怎么改,这里有个容易出事的细节。直接写line.material.SetFloat("_Speed", 3f)是可以的,但Renderer.material这个属性每次访问都会实例化一份材质,如果放在Update里,每帧都会新建一个材质对象,内存一路涨,最后卡死。这个坑非常经典,我在早期项目里踩过一次,内存曲线一路上扬,找了一下午才发现是这一行。
正确的做法有三种。第一种是用sharedMaterial,直接改共享材质,简单粗暴,但场景里所有用这个材质的线会一起变。第二种是在Awake里缓存一份实例化后的材质引用,之后都改这个缓存。第三种是用MaterialPropertyBlock,不实例化材质,只覆盖属性,最干净:
private MaterialPropertyBlock _mpb; private static readonly int SpeedId = Shader.PropertyToID("_Speed"); void Awake() { _mpb = new MaterialPropertyBlock(); } void Update() { line.GetPropertyBlock(_mpb); _mpb.SetFloat(SpeedId, currentSpeed); line.SetPropertyBlock(_mpb); }要注意 URP 下MaterialPropertyBlock会破坏 SRP Batcher 的合批,不过 LineRenderer 本身就不走 SRP Batcher 的静态合批路径,影响可以忽略。Shader.PropertyToID要缓存成静态字段,不要每次调用,那个字符串哈希本身也有开销。
5.2 多段叠加:一条线不够就画三条
单条线的层次感是有限的。想要那种“信号很强、能量很足”的观感,最省事的做法是叠三条同起点的线,用不同的参数:
| 层 | 周期数 | 流速 | 亮度 | 作用 |
|---|---|---|---|---|
| 主线 | 6 | 2.0 | 2.2 | 主体结构,决定整体节奏 |
| 副线 | 13 | -1.3 | 0.9 | 反向流动,制造干涉感 |
| 细线 | 23 | 3.5 | 0.5 | 高频细纹,增加细节密度 |
三条线共用同一个 Shader,只改MaterialPropertyBlock里的参数。负的流速会让副线反向流动,两条不同速度的波叠在一起,视觉上会产生轻微的摩尔纹干涉,非常像真实的电磁信号。细线的周期数取质数(23)是为了避免跟主线的 6 形成明显的拍频,如果取 24,会周期性地出现整齐的暗带,看着很假。
这个技巧的成本是三倍的绘制调用,如果线很多,可以合并成一条线用不同的 UV 段来区分,但那会让 Shader 复杂不少,一般情况下不值得。
5.3 环形扩散波:换个几何体就行
雷达扫描、水波纹、冲击波,本质上是同一个东西:一个圆心固定、半径不断变化、亮度随半径衰减的环。
用 LineRenderer 画圆,需要loop = true,然后按角度铺点:
void BuildRing(float radius, int segments) { line.loop = true; line.positionCount = segments; for (int i = 0; i < segments; i++) { float a = i / (float)segments * Mathf.PI * 2f; _buffer[i] = new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f); } line.SetPositions(_buffer); }段数取 64 到 128 之间比较合适,少于 48 会有明显的多边形感。因为是闭合的,uv.x在起点和终点会从 1 跳回 0,流动脉冲会在接缝处断开。解决办法有两个:一是让 Shader 里的_TileCount设为整数,并且用frac配合smoothstep做首尾融合;二是在接缝处叠一个额外的亮点遮住。我一般用前者,把_TileCount设成 12 或 16,接缝几乎看不出来。
环形波的扩散动画不需要每帧重建点位,改transform.localScale就能缩放半径——前提是useWorldSpace = false。如果是世界空间,就得重建点位,或者用LineRenderer.widthMultiplier配合缩放做近似。缩放的副作用是宽度也会跟着放大,如果希望宽度不变,还是要重建点位。
6. 实际踩坑与排查速查表
6.1 现象、原因、解决对照
下面这张表是我这两年里实际遇到并记录下来的,按出现频率排序:
| 现象 | 根本原因 | 解决方式 |
|---|---|---|
| 线的背面看不见 | 双面材质没开 | 加Cull Off |
| 波形在拐角处断裂 | 拐角点太少,UV 变化剧烈 | numCornerVertices设 4 以上 |
| 线两端是方头,很生硬 | 端点没做圆角 | numCapVertices设 6 |
| 运行久了波形变糊 | _Time.y数值过大,frac精度丢失 | fmod(_Time.y, 3600) |
| 内存持续上涨 | renderer.material每帧实例化 | 改用sharedMaterial或MaterialPropertyBlock |
| 线在摄像机转动时消失 | alignment用了TransformZ | 改成View |
| 线被远处物体挡住一截 | 深度测试正常,是包围盒没更新 | 重建点位或手动设置bounds |
| 叠加后一片死白 | 加色混合 + 高强度 | 限幅或改用 Alpha 混合 |
| 波浪线看着像折线 | 密度不足 | density提到 10 以上 |
UV 在改positionCount后错乱 | 网格重建,UV 重算 | 固定点数,只改坐标 |
6.2 几个反直觉的细节
宽度曲线会改变软边的观感。前面提过,widthCurve窄的地方软边被挤压,看起来更硬。我第一次做的时候,线中间粗两端细,结果两端出现明显的硬边,调了半天 Shader 才发现问题不在 Shader。解决办法是把_EdgeSoft设得小一点,或者干脆用等宽曲线。
smoothstep的边界反了不会报错,只是效果全无。smoothstep(0, w, 1-p)这里如果写成smoothstep(w, 0, 1-p),在某些实现里会返回未定义结果,表现为整条线透明。这种错误编译器不会提示,只能靠肉眼,所以写完务必先在 Inspector 里拖一遍参数确认。
噪声的hash函数在不同平台上结果可能不同。frac(p * 0.1031)这种写法在 PC 上跑得很好,某些移动端 GPU 因为精度问题会产生完全不同的图案。如果要保证跨平台一致,把frac换成更稳的实现,或者在 C# 里生成噪声纹理传进去。我现在的习惯是:只要效果依赖噪声,一律走预生成纹理,省心。
顶点数是肉眼看不出来的性能黑洞。一条线 200 个点,看着不起眼,但numCornerVertices = 8之后,每个拐角插 8 个点,100 个拐角就是 800 个额外点,还要各自算 UV 和顶点色。移动端上这种浪费很容易变成帧率杀手。我的经验值是:移动端numCornerVertices不超过 2,numCapVertices不超过 4。
7. 移动端与性能取舍
这套方案在 PC 上随便跑,但搬到手机上要考虑的事情会多一圈。
首先是填充率。加色混合的透明物体,填充率是非常贵的,如果一条电波线在屏幕上占的面积很大(比如贯穿全屏的光带),每个像素都要走一遍完整的片元着色器,再加上混合,开销会很明显。实测下来,一条覆盖 1080p 屏幕三分之一的电波线,在中端机上大概占用 1.5 到 2 毫秒。如果同时叠三条,就有点吃紧了。对策是:主线保持高质量,副线和细线降到半分辨率——用RenderTexture渲染再叠加,或者干脆在低端机上关掉副线。
其次是片元里的计算量。上面那份 Shader 里有一个pow、两个smoothstep、一个valueNoise(内部两次hash,每次好几次乘法和frac)。这些计算单看都很便宜,但乘上像素数就不便宜了。移动端的优化手段是把噪声简化成单次sin叠加,省掉hash的乘法链,视觉差异在有 Bloom 的情况下几乎看不出来。
第三是批处理。同一材质的 LineRenderer 理论上可以合批,但因为MaterialPropertyBlock的差异会打断,所以要么接受多批次,要么用图集或纹理参数化的方式让所有线共用一个材质。如果场景里只有几条线,纠结这个没意义;如果是几十条的界面,值得花时间统一参数结构。
最后提一个我自己的取舍:低端机上我会把_TileCount的数值改成整数,并且让_Speed是它的约数。这样frac的结果在首尾能对齐,去掉接缝处的额外处理,同时脉冲分布更规律,人眼看着反而更“像信号”,比随机流动更干净。这个改动不花任何性能,纯粹是数值上的取巧。
这套方案我前后用了三个项目,每次都在细节上补一点东西。最近一次是在 Shader 里加了一个_FadeStart和_FadeEnd参数,用来控制线两端淡出的起始位置——因为widthCurve只能控制宽度,控制不了透明度,而两端半透明的收尾比单纯的几何收窄自然得多。加的时候发现smoothstep(_FadeStart, _FadeStart + 0.1, uv.x)在_FadeStart接近 1 的时候会出问题,最后改成用saturate((uv.x - _FadeStart) / 0.1)手动算,稳得多。这种小改动看起来不起眼,但真放到画面里,就是“能看”和“好看”的差别。