☰
Unity Shader+LineRenderer电波能量线实现与调优
2026/11/6 4:37:21 网站建设 项目流程

做电波效果这件事我前后换过三种做法。最早用粒子系统拼,一圈粒子沿着轨迹匀速跑,远看像一队小光点排队行军,看不出连续的能量感;后来改成平面加动画贴图,形状是死的,镜头换个角度就露馅。真正让我定下来的是Shader 配合 LineRenderer这套组合:LineRenderer 负责"形",Shader 负责"光",两层各自独立调,出问题的范围一下就缩小了。这次要聊的就是这套组合怎么落地——从线段的采样密度、宽度曲线,到 UV 怎么流动、能量包怎么跑、混合模式怎么设,再到我实际调的时候踩过的几个坑。适合已经会用 Unity 基础组件、想做科幻风格预警线、能量传输、雷达扫描、技能范围指示这类内容的人看,不需要你会写完整的光照模型,能看懂 HLSL 的加减乘除就够。

1. 为什么最后我把电波效果交给了 LineRenderer

1.1 粒子、网格、LineRenderer 三条路线的横评

要把一条会抖动的光带画出来,Unity 里至少有三条路。第一条是粒子系统配 Trails 模块,优点是现成的、参数多,缺点是粒子的尺寸是固定的,想要连续光带就得靠大量粒子重叠,overdraw 直接起飞,而且粒子的切线方向要靠外力控制,波形完全不由你说了算。第二条是自己生成带状网格,顶点和 UV 全手写,可控性最强,但动态改段数的时候要重建 Mesh,索引缓冲一改就有开销,代码量也翻倍。第三条就是 LineRenderer,它把"沿折线生成带状四边形"这件事内置了,位置数组一改,网格就跟着变,UV 也自动铺好,剩下的只需要你自己写材质。

我实际对比下来,LineRenderer 的甜点区非常明确:单条连续折线、需要每帧变形、宽度沿路径渐变。这三条它全占。它的短板也很清楚——一个 LineRenderer 只能画一条连续路径,做不了分叉;截面的形状不能自定义,只能靠宽度曲线控制粗细;它生成的是面向相机的四边形带,没法做真正的管状体积。所以如果你的电波是"能量沿着一条管道跑"这种需要体积感的,还是得回到自建网格。但对于绝大多数平面或近似平面的表现形式,LineRenderer 的投入产出比高得离谱。

下面这张表是我自己在选型时列的,供参考:

维度粒子系统 + Trails自建带状 MeshLineRenderer + 自定义 Shader
形状可控性低,波形靠模拟极高,顶点随你写高,位置数组直接改
连续能量感差,需要大量重叠好好
段数动态调整无所谓需重建 Mesh,有开销改 positionCount 即可
UV 自动生成无需手写内置,四种模式
移动端表现overdraw 高可控可控,顶点数决定
代码量少多少

1.2 把效果拆成"几何"和"光感"两层

这一条是我调了很久才想明白的:电波效果不好看,八成不是 Shader 的问题,是几何的问题。所以我在调参时会把两层彻底分开看。几何层管的是位置数组、宽度曲线、段数、拐角圆滑度,它决定轮廓长什么样;光感层管的是 UV 流动、颜色渐变、混合模式、噪声,它决定这条线看起来是不是"有能量"。

分开之后调参路径就清楚了。如果轮廓不对,别动 Shader,先把材质换成纯白无光照的默认材质跑一遍,看白模的波形、宽度、末端收口是不是你要的样子。白模顺眼了再上 Shader,这时候如果再觉得丑,问题一定在光感层,范围就缩小了。反过来,如果白模就已经打结、锯齿、忽粗忽细,那你在 Shader 里加多少噪声都是白搭。

还有一个特别容易被忽略的点:LineRenderer 的顶点色会写进 COLOR 语义。它的 colorGradient 是沿着线的长度方向采样的,这意味着你可以直接在 Inspector 里拖一个从亮到透明的渐变,Shader 里读i.color.a就能拿到长度方向的衰减,完全不用在 Shader 里算归一化距离。这个特性我一开始没用,非要在 Shader 里用 UV.x 做衰减,结果纹理一改密度、衰减位置就跟着变,白折腾了两天。

2. LineRenderer 里那几个真正决定观感的参数

2.1 Texture Mode 的四种模式与宽度曲线的联动

LineRenderer 的textureMode有四个值:Stretch、Tile、DistributePerSegment、RepeatPerSegment。这四项直接决定 UV.x 的分布,而 UV.x 又是你 Shader 里所有"沿长度方向"效果的基础,所以必须先搞清楚。

Stretch 是把整条线映射到 0 到 1,不管线多长,UV.x 永远从头 0 到尾 1。好处是"沿长度归一化"的效果天然成立,比如从头到尾的渐隐、从头到尾的颜色过渡。坏处是线一被拉长,纹理就被拉伸,你贴一条流动条纹上去,条纹会被越拉越宽。Tile 是按世界长度重复,UV.x 等于"世界距离乘以材质的 mainTextureScale.x",纹理密度和物理长度挂钩,线拉长纹理不会被拉长,但代价是 UV.x 会超过 1,而且线长变化时密度会跟着变。

DistributePerSegment 是按段均分,每一段的 UV 覆盖固定区间,密度稳定,但曲线在段与段之间的疏密变化剧烈时,纹理会被挤压。RepeatPerSegment 是每段都重复一遍完整纹理,适合那种"每一小段都要有一个节点标记"的效果,做电波基本用不上。

我的做法是:主能量包用 Tile 模式,因为能量包的间距应该跟物理距离挂钩,看起来才像是在真实空间里传播;尾部的整体渐隐交给 colorGradient,因为那是归一化的,改线长不影响。这两者配合,才能既有物理感又有整体的收口。

宽度曲线这边有个细节:widthMultiplier乘以曲线值得到的是总宽度,也就是说曲线的值等于你要的宽度,不是半宽。我之前一直以为曲线值 1 就是左右各 1 米,结果线比预期细了一半,排查了半天以为是 Shader 的 alpha 写错了。另外电波的末端我建议不要收到 0,留个 0.1 到 0.2 的残值,收到 0 会留一个针尖一样的头,看起来像是断裂而不是消散。

2.2 Alignment 在三维场景里的宽度错觉

alignment决定线段朝向哪。LineAlignment.View是默认值,每条线段都转向相机,好处是无论相机怎么转,线都不会"消失",坏处是相机一动,整条线像是在跟着转,贴着镜头的贴纸感很强。LineAlignment.TransformZ是让线段平躺在物体的本地 XY 平面上,朝向本地 Z 轴,这个在纯 2D 或者正交相机下是最稳的,但在透视相机下斜着看会看到侧面变薄。

做电波我有一个经验判断:如果这条线是"铺在地面上的预警圈",用 TransformZ,因为地面是平的,方向固定;如果这条线是"漂浮在空中的能量弧",用 View,因为弧形在三维空间里扭曲,用固定朝向会露出背面。另外用 View 的时候要注意透视相机的近大远小——宽度是按世界单位算的,远处的光带在屏幕上会明显变细,如果希望屏幕上宽度一致,得自己在 Shader 里补偿,或者干脆换成正交相机做。

Corner Vertices 和 Cap Vertices 这两个也要提一句。线段在拐角处是两段分开的四边形拼起来的,拐得急就会看到缺口或者亮度断层。Corner Vertices 设 2 到 4 就能把拐角补圆,但每个拐角都会多出顶点。电波这种柔和的形态,我一般 Corner 设 3,Cap 设 2,末端封口不留空心。

2.3 Position Count 的预算和每帧更新方式

段数这件事,先给一个采样判据:每个波长至少 8 个采样点,低于这个数正弦就会被采样成折线,肉眼能直接看出锯齿。所以如果线长 10 米、波长 2 米,一共 5 个完整波形,至少要 40 段,实际我会给到 64 到 128。想再密一点就往上加,但边际收益很快就没有了。

性能上,N 个点大概生成 2N 个顶点(圆角封口会更多),一屏幕 20 条线 × 128 点也就是 5000 个出头的顶点,这在移动端完全不是瓶颈。真正的瓶颈是每帧修改 positionCount,因为那会重建内部缓冲。所以我的原则是只有在段数真的变了才设置 positionCount,其余时候只调SetPositions。

更新方式有三种:SetPosition(index, pos)逐点设置,调用开销按点算;SetPositions(Vector3[])一次性传数组,数组可以复用;positions属性每帧读会分配新数组,绝对不能放在 Update 里。我用的都是第二种,声明一个成员数组,长度跟段数绑定,只在段数变化时重建。这个细节在 PC 上看不出来,在手机上跑久了 GC 一抖就是一次卡顿。

using UnityEngine; using UnityEngine.Rendering; [RequireComponent(typeof(LineRenderer))] public class WaveLineBuilder : MonoBehaviour { [Header("采样")] [Range(16, 512)] public int segments = 128; public float length = 10f; [Header("波形")] public float amplitude = 0.6f; public float wavelength = 2f; public float speed = 4f; [Range(0f, 4f)] public float decay = 0.6f; private LineRenderer lr; private Vector3[] buffer; void Awake() { lr = GetComponent<LineRenderer>(); lr.useWorldSpace = false; lr.textureMode = LineTextureMode.Tile; lr.alignment = LineAlignment.TransformZ; lr.shadowCastingMode = ShadowCastingMode.Off; lr.receiveShadows = false; EnsureBuffer(); } void EnsureBuffer() { if (buffer != null && buffer.Length == segments + 1) return; buffer = new Vector3[segments + 1]; lr.positionCount = segments + 1; } void Update() { EnsureBuffer(); // 用绝对时间做相位,避免 deltaTime 累积误差 float basePhase = Time.time * speed; for (int i = 0; i <= segments; i++) { float t = (float)i / segments; float z = t * length; float env = Mathf.Pow(1f - t, decay); float phase = t * length / wavelength * Mathf.PI * 2f + basePhase; buffer[i] = new Vector3(Mathf.Sin(phase) * amplitude * env, 0f, z); } lr.SetPositions(buffer); } }

3. 波形得靠代码长出来,贴图骗不了人

3.1 正弦波形的参数化:每个数字对应什么观感

很多人做电波的第一反应是"找一张波纹贴图贴上去",这样做的结果是形状固定、只能靠 UV 滚动来假装在动,一旦镜头绕着它转或者把它拉长,就完全穿帮。真正的电波,轮廓本身必须是每帧重新算出来的。而算轮廓最省事的模型就是带包络的正弦。

我的参数化是这样组织的:沿本地 Z 轴均匀取点,横向偏移由sin(t × 线长 / 波长 × 2π + 相位)决定,再乘一个包络函数。相位里加上Time.time × 速度,整条波就沿 Z 轴推着走了。四个参数的分工很明确:

参数作用调过头的表现
amplitude 振幅横向摆动的幅度太大会自交打结,太小像一条直线
wavelength 波长一个完整波形的长度太短需要极高段数,否则锯齿
speed 速度相位推进的速率太快会变成高频抖动,失去传播感
decay 衰减指数包络从起点到尾部的收缩速度太大整条线只剩前一截有波动

振幅和波长的比值是关键。我的经验是振幅除以波长不要超过 0.15,超过之后曲率半径会小于线宽的一半,LineRenderer 生成的四边形在弯得急的地方会自交,视觉上表现为局部亮得发白或者出现一个硬折角。这个比例也直接决定了波看起来是"轻微颤抖"还是"疯狂摆动",跟参数的含义是统一的。

包络我习惯用pow(1 - t, decay),t 是从 0 到 1 的归一化参数,起点处包络是 1,终点处是 0。decay设 0.5 的时候衰减很缓,整条线都在动;设 2 的时候只有前半段有明显波动,后半段几乎贴着中线,看起来像是"波刚刚从起点发射出去,还没传到后面"。这两种观感对应完全不同的场景:持续扫描用前者,一次性发射用后者。

3.2 环形、螺旋和沿路径传播

直线正弦只能解决一条线的问题。实际项目里更常见的是圆形预警圈和沿管线传播的能量。环形电波用极坐标改一下就行:θ从 0 到 2π,半径r = R + A × sin(θ × n + φ),再用cos θ和sin θ转回 XZ 平面。这里的n是圈上总的波数,必须是整数,否则首尾接不上,会看到一个明显的错位接缝——这个坑我在第一次做环形的时候踩得很实在,盯着接缝看了半小时才想起来要取整。

螺旋就是把 Y 也加上一个线性项,y = t × height,剩下的跟环形一样。这玩意儿在场景里当"上升能量柱"特别好用,配上从下往上的颜色渐变和流动条纹,能做出很唬人的效果,实际代码就是多了三行。

沿路径传播稍微复杂一点:先给一条基准路径,比如一串控制点连成的折线或者贝塞尔曲线,沿路径等距采样得到基准点和切线,再求法线方向,最后在法线方向上加正弦偏移。这里的坑在于法线的求法——用相邻两点差分得到切线之后,跟一个固定的上方向做叉乘得到副法线,当切线恰好跟上方向平行时叉乘会退化成一个零向量,波形会突然塌掉。标准的处理是判断切线跟上方向的点积,接近 ±1 的时候换一个备用上方向。这个细节不处理,在某些角度上就会看到波形瞬间消失一帧,非常难受。

3.3 段数分配的策略

段数不是越多越好,但也不能一刀切。我习惯按"每个波长 10 段"来算,再给一个下限和一个上限。比如线长 20 米、波长 1 米、20 个完整波形,那理论需要 200 段;如果线拉到 100 米,按比例就要 1000 段,这显然不现实。所以实际项目里我会给段数设一个上限,比如 256,超过上限就自动放大波长,让波形变稀疏而不是让采样变粗糙。

还有一个变通的做法:把采样密度按 t 做非线性分布。靠近起点的地方密集,靠近尾部的地方稀疏,因为远处的波本来就因为衰减而不明显,稀疏采样看不出来。这需要用 t 的映射函数先算出一个"非均匀参数"再算相位,实现上就是在循环里加一行t = t * t或者用一个自定义曲线。这个优化在单条线上意义不大,但如果你一屏幕要开几十条线,能省下不少。

4. 一个能干活的电波 Shader 怎么写

4.1 用顶点色拿长度衰减,别在 Shader 里算

前面提过 LineRenderer 会把 colorGradient 写进顶点色,这是一个被严重低估的能力。渐变是在 Inspector 里可视化编辑的,你可以随手拖一个前段全亮、后段渐隐的渐变,或者做一个三段式的明暗明,完全不用改 Shader。Shader 里只需要energy *= i.color.a一行,长度方向的任何分布都交给美术调。

要注意的是别把这个衰减乘两次。有些内置 Shader 的模板里已经有顶点色乘法了,你自己再乘一遍就会变成平方,亮度曲线会比你预期的陡很多。我一般是新建一个空的 Shader 从头写,避免继承到不认识的逻辑。

另外 colorGradient 的采样是按线段的索引插值的,不是按世界距离。所以当段数不均匀的时候,渐变的分布也会跟着不均匀。这在电波这种视觉为王的场景里其实无所谓,但如果你要做"每隔固定米数灭一段"这种精确效果,就得自己在 Shader 里用 UV.x 算。

4.2 横向能量分布和纵向流动的乘法关系

电波的光感本质上是两个方向的一维函数相乘。横向(UV.y,也就是线的宽度方向)是一个中间亮两边暗的钟形分布,纵向(UV.x,也就是线的长度方向)是一串流动的亮包。这两个相乘,就得到了"一条光带上一串能量包在跑"的效果。

横向的钟形很简单:edge = 1 - abs(v - 0.5) * 2,v 等于 0.5 时是 1,两端是 0。然后用pow(edge, 边缘锐度)来控制光带的胖瘦——指数越大,光越集中在中间那一丝上,看起来像激光;指数越小,整条线均匀发光,像霓虹灯管。我一般用 1.5 到 2.5 之间。

纵向的亮包用pow(abs(sin(u × 亮包数量 - 时间 × 流速)), 锐度)。取绝对值是因为我们要的是"明暗明暗"的交替而不是正负交替;取幂是为了让亮包尖锐、暗区宽广。这个式子里本可以做得更细:用smoothstep加两个阈值可以做出更硬的边界,但幂函数在移动端上更便宜,而且观感上已经足够。

再叠一层高频扫描线,0.5 + 0.5 × sin(v × 密度 + 时间 × 速度),这是沿着宽度方向的高频条纹,模拟全息投影的扫描质感。注意它的频率是 v 方向的,跟长度方向的亮包是垂直的,两者叠加会出现十字形的细节纹理,很讨喜。扫描线会轻微吃掉整体的亮度,所以记得在扫描项的 lerp 范围里留一个下限,比如lerp(0.88, 1.0, scan)。

Shader "Wave/EnergyLine" { Properties { _ColorCore ("Core Color", Color) = (0.55, 0.95, 1.0, 1) _ColorEdge ("Edge Color", Color) = (0.05, 0.25, 0.9, 1) _Intensity ("Intensity", Float) = 2.0 _EdgePower ("Edge Power", Range(0.5, 6)) = 1.8 _PulseCount ("Pulse Count", Float) = 3.0 _ScrollSpeed ("Scroll Speed", Float) = 2.0 _PulseSharp ("Pulse Sharpness", Range(1, 16)) = 5.0 _ScanDensity ("Scan Density", Float) = 30.0 _ScanSpeed ("Scan Speed", Float) = 6.0 _NoiseStrength ("Noise Strength", Range(0, 0.5)) = 0.12 } 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" fixed4 _ColorCore; fixed4 _ColorEdge; float _Intensity; float _EdgePower; float _PulseCount; float _ScrollSpeed; float _PulseSharp; float _ScanDensity; float _ScanSpeed; float _NoiseStrength; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } float Hash21(float2 p) { p = frac(p * float2(123.34, 456.21)); p += dot(p, p + 45.32); return frac(p.x * p.y); } fixed4 frag (v2f i) : SV_Target { float u = i.uv.x; float v = i.uv.y; // 横向能量分布:中心亮、两侧暗 float edge = saturate(1.0 - abs(v - 0.5) * 2.0); float core = pow(edge, _EdgePower); // 沿长度方向流动的能量包 float flow = u * _PulseCount - _Time.y * _ScrollSpeed; float band = pow(abs(sin(flow * 3.14159265)), _PulseSharp); // 高频扫描线,模拟全息质感 float scan = 0.5 + 0.5 * sin(v * _ScanDensity + _Time.y * _ScanSpeed); // 噪声打散规则感 float n = Hash21(float2(floor(u * 80.0), floor(_Time.y * 24.0))); float noiseTerm = lerp(1.0 - _NoiseStrength, 1.0 + _NoiseStrength, n); float energy = core * lerp(0.45, 1.0, band); energy *= lerp(0.88, 1.0, scan); energy *= noiseTerm; energy *= i.color.a; fixed3 col = lerp(_ColorEdge.rgb, _ColorCore.rgb, core); col *= _Intensity; return fixed4(col * energy, energy); } ENDCG } } }

4.3 混合模式和渲染队列的选择逻辑

电波几乎都是加色混合。Blend One One是纯粹的加色,颜色直接叠加;Blend SrcAlpha One会让 alpha 参与,输出更柔和但多乘了一道 alpha。两者的观感差异在亮的地方特别明显:纯加色在多层重叠时会快速饱和到白色,而带 alpha 的版本会慢一些。想要"越亮越白"的激光感就用前者,想要"柔和晕染"就用后者。我自己调的时候是先用 One One 做基础,觉得层次不够再换。

ZWrite Off是必须的,否则同一条线的多层四边形会互相遮挡,出现硬边和黑缝。Cull Off是为了防止某些角度看过去线消失,因为 LineRenderer 生成的四边形法线方向不一定稳定,尤其 View 模式下相机一转,朝向就可能反过来。

队列用 Transparent 默认的 3000 就够。如果电波需要出现在某些透明特效前面,把队列调到 3001 到 3010 之间,反之调到 2990。这个数值不要乱设得太大,否则会跟粒子系统的默认队列产生难以预料的前后关系。还有一点:加色混合的物体彼此之间没有顺序问题(加法交换律),所以只有在电波和半透明物体混合的时候才需要操心队列。

4.4 双层结构和噪声扰动的取舍

单条 LineRenderer 配一个 Shader,做出来的光带是"一个亮度分布"。想要真正的辉光感,最省事的做法是叠两条 LineRenderer:一条宽而暗,颜色偏冷,负责光晕;一条窄而亮,颜色偏白,负责核心。两条共用同一份位置数据,只是 widthMultiplier 和材质不同。这比在 Shader 里堆多级幂函数更可控,也更接近真实光学的多层散射。

代价是两份顶点数据和两次 Draw Call。我的判断标准是:如果场景里电波数量在 5 条以内,双层随便用;超过 10 条就要开始算账了,因为 LineRenderer 本身很难批处理,每条都是独立的 Draw Call。这时候要么回到单层然后用亮度倍增挡一下,要么把光晕那层换成贴片。

噪声这块我建议保守一点。Hash21这种基于 sin 和 frac 的哈希在移动端上不贵,但每个像素都算一次仍然有代价。而且噪声强度只要超过 0.2,就会出现明显的"脏",看起来像是贴图分辨率不够而不是有意为之。我用的强度是 0.1 左右,只是用来打散扫描线的规则感,肉眼几乎看不出噪点,但能消除那种"完美数学公式"的假感。

5. 实测踩坑:从光带压扁到参数串台

5.1 弯道处光带打结的完整排查链路

这个现象第一次出现的时候我以为是 Shader 的问题:波形摆到最大的地方,光带会出现一块特别白的区域,形状像个小小的菱形,其他地方都正常。排查过程我记一下,因为思路比结论有用。

第一步,把材质换成纯白无光照材质。白块还在,说明不是 Shader 的亮度计算问题,是几何问题。第二步,把振幅调到 0,白块消失,确认跟波形幅度相关。第三步,把线宽减半,白块变小但还在,说明是几何自交而不是单纯的亮度过高。第四步,把段数从 64 加到 512,白块还在,只是边缘稍微平滑了点——这一步很关键,它证明了增加段数不能解决曲率问题,因为曲率是几何属性,离散化只是近似它。

结论就是前面说的:曲率半径小于线宽一半的时候,LineRenderer 生成的内圈四边形会翻转,重叠区域因为加色混合就变成了亮块。解决办法只有三个,按优先级排:减小振幅、增大波长、减小线宽。我最后的取值是振幅 0.6、波长 4、线宽 0.35,振幅波长的比值 0.15,曲率半径大约是线宽的三倍,安全。

顺带说一个相关的现象:如果波形在某个区间变化特别剧烈(比如你给包络加了一个突变的曲线),即使不自交,采样点之间也会出现明显的折角。这种时候要么提高那一带的采样密度,要么让参数变化本身平滑一点,别用阶梯函数。

5.2 多条电波参数串台的两种成因

场景里放了两条电波,A 的速度改了,B 也跟着变。这个问题有两个完全不同的成因,得先分清。

第一种是材质共享。LineRenderer.material会返回一个实例化的材质,但如果你是通过sharedMaterial拿到的,或者你在编辑器里给两个 LineRenderer 拖了同一个材质资源,那 Shader 里的属性就是共享的。用material.SetFloat改一个,另一个也会变,因为改的其实是同一个材质实例。

第二种是 Shader 里用了全局量。_Time是全局的,这一点没问题,但如果你的 Shader 里有自己定义的全局参数(比如通过Shader.SetGlobalFloat设置的),那所有用到这个 Shader 的物体都会受影响。

正确的解法是MaterialPropertyBlock。它为每个 Renderer 单独存一份属性覆盖,不产生材质实例,也不会污染共享材质:

private MaterialPropertyBlock mpb; private static readonly int ScrollSpeedId = Shader.PropertyToID("_ScrollSpeed"); private static readonly int IntensityId = Shader.PropertyToID("_Intensity"); void SetWaveParams(float speed, float intensity) { if (mpb == null) mpb = new MaterialPropertyBlock(); var r = GetComponent<Renderer>(); r.GetPropertyBlock(mpb); mpb.SetFloat(ScrollSpeedId, speed); mpb.SetFloat(IntensityId, intensity); r.SetPropertyBlock(mpb); }

注意Shader.PropertyToID要缓存成静态字段,别每次调用都拼字符串。另外这个方案在用了 SRP Batcher 的渲染管线下会打断批处理,但 LineRenderer 本来就不太能批,所以影响不大。如果你的项目对批处理极其敏感,那就老老实实给每条线克隆材质,然后在OnDestroy里Destroy(material),别让它泄漏。

5.3 低端机上的相位抖动和精度衰减

还有一个坑更隐蔽:运行久了之后,波形的流动开始一卡一卡的,重启之后又好了。这是浮点精度的问题。Time.time是 float,有效位数有限,运行到几十分钟之后,小数部分的精度就不够了,Time.time × speed做正弦的时候,输入值的小数部分被吃掉,波形就会跳。

我在 C# 侧的处理是把相位做模运算,超过一个周期就归零:

float raw = Time.time * speed; float phase = raw % (Mathf.PI * 2f);

Shader 侧同理。_Time.y也是 float,长时间运行之后,_Time.y × _ScrollSpeed的有效精度下降,流动条纹会开始抖动。解决方式是在 C# 里算一个循环的时间,通过 MaterialPropertyBlock 传给 Shader,超过一定值就减掉一个整数倍的周期,视觉上看不出任何变化,但精度保住了。

另外低端机上还有一个Mathf.Sin的调用次数问题。128 段 × 每帧一次 sin,加上包络的 pow,在手机上其实还好,但如果你一屏幕开了 30 条线,就是接近 4000 次三角函数,这时候可以考虑用查表或者用预计算的正弦近似。我的做法是给段数设一个按平台的降级表:PC 给 256,中端手机 96,低端 48,通过Application.platform或者画质等级来判断,这个降级几乎不影响观感,因为 48 段在屏幕上看起来只是稍微"硬"了一点点。

6. 把电波做成可复用的组件结构

6.1 一个控制器管全套参数

做到第二个项目的时候我就把之前散落的代码收成了三层。第一层是波形构建器,只负责算位置,把Vector3[]填好,别的什么都不管。第二层是参数控制器,负责速度、亮度、颜色的整体节奏,比如"电波从启动到最强用 0.3 秒,然后匀速",这一层是在做动画曲线。第三层是 Shader 参数绑定,负责把控制器的输出通过 MaterialPropertyBlock 送进 Shader。

这样分层的好处是,当策划说"能不能让电波发射的时候先顿一下再冲出去",我只需要改第二层的时间曲线,波形和 Shader 一行都不用动。反过来,如果要做"波形换成方的",只需要改第一层的采样函数,另外两层完全不受影响。

实际组织的时候我会把波形函数做成一个委托或者虚方法,让子类去实现直线、环形、螺旋等不同形态,共用的采样逻辑、缓冲复用、段数管理都放在基类里。这样新增一种形态的成本就是写十行数学公式。

6.2 性能分级和可见性剔除

最后说一个容易被忘掉的优化:不可见的时候别更新。LineRenderer 本身有 Renderer,可以用OnBecameVisible和OnBecameInvisible来开关Update里的计算。这个在场景里电波很多、镜头视野有限的时候效果明显——我做过一个测试,20 条电波里同屏最多 6 条,加上剔除之后 CPU 侧的计算开销直接降了一半多。

分级这块我的做法是给组件加一个 `quality

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询