简介:一份面向Unity开发者的心电图线波模拟效果Demo,适合游戏UI特效、模拟医疗或可视化交互等场景使用。项目基于C#脚本与Unity渲染组件实现,波线速度、颜色、上下振幅均可在参数中自由调整,并支持通过Q/W键触发效果变化,可直接运行或二次开发。压缩包为rar格式,共6688个文件、约39.19MB。内部包含大量C#脚本、Unity场景/预制体/材质资源(.unity、.prefab、.asset、.mat),以及png、psd贴图、DLL库和json配置等,目录结构完整,是一个可直接打开的Unity工程。已有331人学习下载。通过该demo可以快速掌握Unity中波形绘制的实现思路,包括Mesh与Renderer渲染、Input键盘交互、Time.deltaTime动画控制、Color颜色动态修改、Transform振幅调节等知识点,适合用来学习游戏可视化效果或作为心电图模拟功能的基础模板。
1. 为什么用 Unity 做心电图线波,而不是直接用 UI 动画
先给结论:如果你只是在做医疗大屏、健康监控看板或者模拟驾驶舱,想表现一条“心电图”一样上下跳动的波形线,用 Unity 的 LineRenderer 加脚本驱动顶点坐标,是比播放视频、缩放贴图、Animator 关键帧都更划算的方案。它不一定是你脑子里第一个闪过的做法,但一旦把波线速度、颜色、振幅这几个参数开放出去,你会发现这条线不只能当心电图,还能改造成声波可视化、震动曲线、数据流动态效果,一个脚本走天下。
这个标题里的 demo 我复现过一版,最初是被“上下振幅”这个词吸引——绝大多数新手做线波,只会让整条线上下平移,那看起来是“水波纹”而不是“心电图”。真正的 ECG 波形特征是:基线平稳,带一个陡峭的 QRS 波群,然后是缓和的 T 波。你要的是一条在时间轴上不断向左滚动的曲线,取样点的高度由一组振幅参数决定,而不是整条线上下浮动。所以这个方案的落地核心有三件事:顶点动画怎么驱动、波形形状怎么合成、参数怎么暴露给美术或策划调。
本文适合已经在 Unity 里能跑通基础脚本的人,不教 GameObject 怎么创建,也不讲 C# 基本语法。我会把原理、实现步骤、参数边界和踩过的坑按顺序讲清楚。如果你只是在找一段能直接用的代码,第 3 章可以直接复制;如果你想知道为什么参数不能乱调,第 4 章值得看完。
2. 心电图线波的核心原理:LineRenderer 顶点动画与波形合成
2.1 为什么选 LineRenderer 而不是 UIImage、Mesh 或 Shader
做一条可以自定义颜色和振幅的波形线,Unity 里有几条路:用 UGUI 的 Image 加 Mask 做滚动贴图、用 Mesh 动态生成条带、用 Shader 做 UV 滚动、用 LineRenderer。
我一开始试过贴图滚动的方案:准备一张横向渐变图,用 UV 偏移让它滚动,再叠加一张遮罩控制上下形状。这个方案在只做背景装饰的时候没问题,但一旦你要“单个 demo 里开放颜色和振幅参数”,它立刻废掉——因为遮罩的形状是死的,改振幅等于重新做一张图,运行时做不到实时响应。
LineRenderer 的核心优势在于:它天生就是一条由顶点数组构成的折线,你只需要在 Update 里改每个顶点的 Y 坐标,就能实现任意波形。颜色可以用 Gradient 控制,每个顶点可以有不同的颜色值,这正好匹配“颜色自定义”的需求。性能上,一条 512 个顶点的线在 UI 层或者世界空间里绘制,开销远小于动态 Mesh,Shader 方案虽然性能更好,但对美术人员不友好——调 Shader 参数的门槛比调 C# 脚本里的 Inspector 字段高多了。
如果你的项目里已经有 UI Particle 或者自定义 Shader 管线,那另说。但对绝大多数人,LineRenderer 是在“可维护性”和“性能”之间最平衡的选择。事实上,我后面用它做音频频谱可视化,也只是把顶点 Y 坐标换成频谱数据而已,架构完全没动。
2.2 振幅、速度、颜色在代码里分别对应什么
在动手写代码前,先建立对应关系:
| 参数 | 代码层面 | 影响 |
|---|---|---|
| 振幅 | 控制顶点 Y 坐标的 sin / 自定义波形函数的乘数 | 波形最高点到最低点的距离 |
| 波线速度 | Update 里顶点数据左移的步长,或者波形函数的相位增量 | 波形向左滚动的快慢 |
| 颜色 | LineRenderer 的 startColor / endColor 或 gradient | 线的整体颜色和渐变 |
| 波形形状 | 波形函数的次数和叠加项 | 是尖峰还是圆润 |
这里最容易搞混的概念是“速度”。很多人以为改变顶点数量或者线的长度就能让波形跑得更快,实际不对。LineRenderer 的顶点在空间上是静止的,你看到波形在动,是因为每个顶点的 Y 值实时更新成“下一帧的波形值”。而下一帧的波形值,是由一个时间相位参数 t 决定的。t 每次增加的量,就是速度。
我把这个 t 叫做相位游标,它在这类效果里是一个绕不开的中间变量。无论你最后用 sin 还是用自定义函数,都必须有一个递增的相位值,然后为每个顶点计算它自己的相对相位,再送进波形函数得到 Y 坐标。这个逻辑想通了,后面改波形、做阻尼、做随机变化都只是换函数的事。
2.3 最小可运行的波形脚本:一个参数驱动的心电图
这一节给出一个最基础的 C# 脚本。它做的事是:用 300 个顶点组成一条线,每个顶点的高度由“sin 主波 + 二次谐波”共同决定,整体往左滚动。
using UnityEngine; public class SimpleECGWave : MonoBehaviour { public LineRenderer line; // 指定的 LineRenderer 组件 public int vertexCount = 300; // 顶点数量,越多曲线越平滑 public float width = 0.1f; // 线的宽度 public float amplitude = 1.5f; // 振幅,影响上下摆动的幅度 public float speed = 1.0f; // 相位速度,值越大波形跑得越快 public float waveLength = 12.5f; // 波长,决定一个完整波形的水平跨度 private float phase; // 相位游标,随时间递增 void Start() { if (line == null) line = GetComponent<LineRenderer>(); line.positionCount = vertexCount; line.useWorldSpace = false; // 使用本地坐标,方便整体移动 } void Update() { phase += Time.deltaTime * speed; for (int i = 0; i < vertexCount; i++) { // 每个顶点的相对位置,在 [0, waveLength] 范围内 float x = (float)i / (vertexCount - 1) * waveLength; // 主波:模拟 QRS 波群的尖峰,用 sin 的高次方压缩波形 float mainWave = Mathf.Pow(Mathf.Sin(phase + x * 0.8f), 15f); // 二次谐波:叠加一个频率更高的波纹,增加“生命感” float subWave = 0.3f * Mathf.Sin(phase * 2.3f + x * 1.7f); // 基线漂移:整体带一个缓慢的起伏,更像真实心电信号 float baseline = 0.15f * Mathf.Sin(phase * 0.6f + x * 0.3f); float y = (mainWave * 0.7f + subWave + baseline) * amplitude; line.SetPosition(i, new Vector3(x, y, 0f)); } } }这段代码的逻辑说明如下:phase是整个波形的时间轴,Update里用帧间隔和速度把它累加,保证不同帧率下滚动速度一致。内层循环里,x表示顶点在横向上的位置,用的是归一化索引乘以waveLength。mainWave用了sin的 15 次方,这样函数值只在峰值附近趋近 1,其他区域几乎为 0,形成类似心电图 QRS 波群的尖锐脉冲。subWave和baseline是附加项,让波形不至于单调得像一道数学题。最后所有分量乘以amplitude,就是暴露给使用者的“振幅”参数。
参数怎么调的小建议:vertexCount太少会看到折线棱角,太多在高刷屏上会带来不必要的顶点提交开销,我的建议是 200 到 500 之间。waveLength和vertexCount一起决定了 x 方向的密度,waveLength不变时顶点数翻倍,波形形状不变但更平滑。speed是纯时间量,2.0 就是每秒相位走 2 个弧度,想快就调,0.5 适合那种“监护仪快不行了”的缓慢感——当然,那时候你可能更该关心病人。
3. 把波形拆成可自定义的心电图效果:参数字段设计与运行时改动
3.1 从“一个 sin”到“QRS-T 波群”:用几个波形成分合成你想要的形状
真实心电图不是单一正弦波,它由 P 波、QRS 波群、T 波组成。你不需要精确建模医学信号,但要做出“像心电图”的效果,至少要有两个特征:一个尖锐的主峰,和一段相对平缓的波动。用纯sin函数做不到这一点,因为它的形状太对称了。
我做这版 demo 时采用的合成策略是“高斯脉冲 + 正弦背景”。高斯脉冲用来模拟 QRS 波群的尖锐凸起,正弦背景负责让线在两次心跳之间有缓慢起伏,不至于是一条直线。
float QRSWave(float phase, float spread) { // 高斯形状:峰值 1,宽度由 spread 控制的波群 return Mathf.Exp(-phase * phase / (2f * spread * spread)); }这类函数的好处是参数语义清晰:spread越小,尖峰越窄;把相位中心平移一下,就能控制尖峰出现在哪个位置。合成时,把一次QRSWave叠加到低幅正弦上,立刻就能得到一版比纯sin更像心电图的线。实际操作中,我会把 QRS 触发的相位做成一个循环周期变量,比如每 2 秒触发一次尖峰,中间用正弦波填充——这样观众看到的是一条“有心跳节奏”的线,而不是均匀重复的山丘。
如果你不想算波形函数,还有一个土办法:把一组预定义的采样点存进AnimationCurve,按时间采样输出 Y 值。AnimationCurve 的好处是美术能直接在手柄上拖拽出形状,不用看懂代码。坏处是运行时想改形状不太方便——AnimationCurve的 key 虽然能改,但在 Inspector 里调试不如直接改公式直观。两种做法我都用过,给外部演示时用公式版更唬人,给团队内部调参美术用AnimationCurve版更省沟通成本。
3.2 内置参数面板:把速度、颜色、振幅全部暴露在 Inspector 上
LineRenderer 的颜色有几种控制方式:直接赋startColor和endColor,或者用一个Gradient。Gradient的灵活之处在于可以定义线从尾到头产生颜色渐变,这对心电图的“淡入淡出”尾迹效果特别有用。
完整版的波纹参数面板至少应该是这样的:
public class ECGLineController : MonoBehaviour { [Header("波形形状")] public int vertexCount = 300; public float amplitude = 1.5f; public float speed = 2.0f; public float waveLength = 12.5f; public float qrsWidth = 0.6f; // 尖峰宽度 [Header("颜色控制")] public Gradient lineGradient; // 从 LineRenderer.gradient 引用或新建 [Header("尾部渐变")] [Range(0f, 0.5f)] public float fadeTail = 0.15f; // 尾部透明区域占比 [Header("运行控制")] public bool autoBeat = true; // 自动触发心跳 public float beatInterval = 1.2f; // 心跳间隔秒数 }这些字段在 Inspector 里默认值已经给了,挂上脚本就有默认效果,不会因为忘赋值白屏。Gradient我建议直接在 Inspector 里新建并把它拖到lineGradient上,同时在Start里赋给 LineRenderer。有一点值得注意,如果你的项目做了资源热重载或者预设体实例化,Gradient的引用可能会断开,重新赋值一次最稳妥。
3.3 运行时调参:在不暂停游戏的情况下验证目标效果
做 demo 最烦的是调一个参数就得点 Play 停掉再改。合理的设计应该在Update里检测transform.hasChanged或直接提供运行时控制方法。我用过的方案是:在 Inspector 里加一个ExecuteAlways的辅助方法,把OnValidate和刷新逻辑绑定,这样拖动滑块时就能实时看到波形变化,不用进出 Play 模式。注意OnValidate在编辑器里也会执行,如果你的刷新逻辑里包含大量顶点分配,最好加个脏标记,否则卡掉帧。
运行时改参数更实用的方式是写一个接口方法,供 UI 面板调用,比如 UI 里的 Slider 直接绑定脚本的公开方法。这里有一个所有做 demo 的人都会踩的坑:Slider 的onValueChanged是float回调,如果你绑定的方法签名不匹配,事件会静默失败,搜索半天才发现是类型对不上。我给个建议,写一个中间方法,把 Slider 的值转成参数,再调刷新逻辑,这样签名问题一眼就能看到。
再补充一点关于性能的习惯:SetPosition每帧调用几百次不构成压力,但如果你做了一个 512 顶点的波形,又同时在场景里挂了 30 个实例,顶点提交量就变成 15360 个,这会让某些低端移动端的 CPU 曲线明显抬头。我的经验是,实例数量多时,把vertexCount降到 128,波形依旧能看,性能压力小很多。还有,不要每帧 new 一个Vector3[],复用数组或者直接用SetPosition逐点更新,能避免触发 GC 分配。
4. 振幅、颜色、速度三个参数的坑与正确调法
4.1 振幅不是越大越好看:顶点宽度补偿与视觉失真的边界
心电图这条线是由顶点连接构成的,LineRenderer 本身有宽度。当振幅调大时,线的斜边变长,同样宽度下,斜边的视觉粗细会小于水平段——这是 LineRenderer 的经典透视问题。表现出来就是:波峰处线条变细,基线处正常。这其实是数学上的必然,因为 LineRenderer 的宽度是垂直于线段方向的,坡度越陡,投影到屏幕上的实际宽度越小。
解决方式通常有三个。第一,接受这个现象,在振幅不超过 3 倍的线宽时,肉眼感知不明显,很多项目无所谓。第二,开LineRenderer.useWorldSpace = true,让顶点坐标按世界单位计算,此时宽度单位变成世界单位,斜率造成的扭曲更可控。第三,我试过比较有效的方式,用Alignment.View模式,让线始终面向相机,这样宽度在屏幕投影上更稳定,但代价是波形在视角变化时可能出现混淆。对纯 2D UI 展示来说,第三个方案不如第一个省事。
真正的振幅上限,通常是 LineRenderer 所在父节点的裁剪范围。你调振幅到 5.0,波形可能直接跑到 Canvas 外面,Mask组件却不会帮你裁剪 LineRenderer——LineRenderer 不是 UI 元素,不受 UGUI Mask 影响。这是新手最容易困惑的一点:在 UI 里放了一个 LineRenderer,以为挂上 Mask 就能限制显示区域,结果波浪从面板上长出去了。遇到这种情况,要么用 RectMask2D 配合特殊的 Screen Space Overlay 模式,要么把 LineRenderer 塞进一个用 Shader 做裁剪的材质。我的建议是直接放弃在 UGUI 体系里嵌入 LineRenderer,改用 RawImage 加 RenderTexture 的方式渲染波形,一劳永逸。
4.2 速度参数不是帧率,相位步长才是关键
phase += Time.deltaTime * speed这行代码,我是见一个项目就解释一次。很多从 Flash 时代转过来的开发者习惯写成phase += speed;,完全不乘以Time.deltaTime,结果就是 60 帧显示器上波形快如闪电,换成 99 帧的显示器速度明显变慢。这不是玄学,是帧率与固定增量之间的简单乘积关系。
速度参数的语义应该是“每秒相位变化量”。你可以用弧度表示,也可以用“每秒钟波形滚动过的距离”。我用弧度是因为 sin 函数的周期是 2π,这样语义更直接:speed = Mathf.PI就是每 2 秒滚动一个完整波形周期。另一个值得绑定的关系是速度与顶点数量:如果顶点是均匀分布在waveLength上的,速度越快,单位时间内顶点位移越大,超过相邻顶点间距时就会出现“跳点”感,也就是波形在滚动时像在闪烁。判断标准是:速度乘以帧间隔后,位移量应小于相邻顶点间距的 1/3,否则要么提高vertexCount,要么降低speed。
4.3 避坑:LineRenderer 材质与 Canvas 渲染顺序
做 UI 心电图线波时,LineRenderer 和 UI 之间的遮挡关系是个老大难。
现象:LineRenderer 在 Canvas 下面或者上面,显示顺序不可控,甚至被 UI 面板整个遮住。原因:LineRenderer 默认使用LineMaterial或你手动指定材质,它的渲染队列与 Canvas 不在一个体系里。解决:把 LineRenderer 放进独立的 Camera 渲染层,再叠加到主画面;或者使用Renderer.sortingOrder手动指定它在透明队列中的前后位置。我用的是后者,因为简单,但配合 Canvas 的Screen Space - Camera模式时必须注意,sortingOrder 的作用域只在同一个Canvas根节点下有效。
现象:波形线半透明时出现残影或重复叠加的亮斑。原因:材质启用了透明混合,但 LineRenderer 同一位置有重叠顶点,比如振幅归零时所有顶点都压在同一条线上,重叠绘制让 alpha 叠加。解决:检查顶点坐标中是否有完全相同的点,或者在Update开头加一个if (Mathf.Approximately(prevAmplitude, amplitude)) return;之类的判断,减少无意义的重复写入。
现象:波形线在 Scene 视图里正常,打进 Android 后消失。原因:LineRenderer 的材质 Shader 在移动端不支持某些特性,比如_ALPHAPREMULTIPLY_ON或者顶点颜色着色器在部分 GPU 上被裁剪。解决:在真机上切换为Universal Render Pipeline的UnlitShader 或内置的Sprites/Default,它们对移动端支持最稳。当然如果你用的是 LWRP 或 URP,LineRenderer 需要绑定 URP 专用的线渲染材质,否则它默认走内置管线,会出现“编辑器好真机废”的情况。
现象:动态调色时,startColor和endColor改了没反应。原因:LineRenderer 的colorGradient属性一旦被赋值,startColor和endColor的所有修改都被渐变覆盖。解决:要么全部用Gradient,要么全部用startColor/endColor,不要在代码里混着赋值。我踩过这个坑之后,统一把颜色逻辑收敛到Gradient一个入口,之后问题再没出现过。
5. 将心电图线波接入 UI:RenderTexture 方案与预制体设计
5.1 一个 RawImage 搞定波形显示:RenderTexture 的配置和脚本
如果你需要把 LineRenderer 做的波形显示在 Canvas 的某个区域里,最稳的做法是把它渲染到 RenderTexture,再显示到 RawImage。这样 Canvas 的裁剪、透明度、层级都正常工作,而且波形本身还可以用单独的相机做特效。下面是配置步骤。
第一步,创建 RenderTexture。右键 Create -> Render Texture,设置尺寸为 512x256,深度缓冲根据需要,一般 0 就行了,因为波形线是纯色透明背景,不需要深度。确定格式是 Default ARGB32。
第二步,创建相机。新建 Camera,命名为 EKG Camera,设置Clear Flags为Solid Color,背景色为完全透明的黑色,Culling Mask只勾选 ECG 波形所在的层(比如单独建一个 EKGWave 层)。然后把这个相机的Target Texture指向第一步创建的 RenderTexture。
第三步,创建 UI。在 Canvas 下新建 RawImage,把 RenderTexture 拖进它的Texture槽位。此时 Canvas 里看到的就是一个带透明通道的波形画面,可以被 RectMask2D 正常裁剪。
脚本这边不需要额外控制,LineRenderer 的渲染由相机自动完成。如果你要动态调整波形大小或颜色,直接改脚本里的公开字段,不需要碰 RawImage 的参数。有一点我想多说一句:RawImage 的Raycast Target记得关掉,否则调试时经常误触遮挡 UI 点击。
5.2 从 demo 到“能用的组件”:波形控件的生命周期管理
很多人把 demo 做出来就结束了,但一个“好用”的组件,至少要考虑暂停、重置、数据和增删实例三种操作。
暂停:例如应用切后台,波形还要不要动?我一般提供一个SetPaused(bool)方法,暂停时跳过phase累加,其他逻辑不动。这样波形冻结在当前位置,不会跳变。
重置:波形归零,phase = 0,所有顶点 Y 坐标清成 0。这个在模拟器重启,或者切换数据源时很关键。难点是切换瞬间的突变——如果上一刻波形在 5.0 振幅,下一秒直接归零,视觉上会闪一下。我的做法是做一层阻尼缓冲,把目标振幅存起来,当前显示振幅以Mathf.Lerp的方式渐进靠拢,形成平滑过渡。
数据:如果你的波形不是由函数生成,而是来自串口或网络数据,那要把数据写入缓冲区,然后按顶点数量做插值采样。这个逻辑和函数生成的唯一区别,是顶点的y值是查表而非计算。AnimationCurve也是这个用法,差别只是数据来源。
实例多开:在场景里放 20 个心电图组件时,要小心每个组件都持有一条 LineRenderer,材质是共享的还好,但Gradient引用和波形参数需要各自独立。注意别在组件里用static变量存相位,否则所有心电图会同跳一个频率,看着像复制粘贴,不自然。
5.3 性能排查:一条线、二十条线、一百条线的开销对比
先说结论:一条 300 顶点的 LineRenderer 渲染开销低到可以忽略;二十条同时在屏幕上,占用也不大;一百条时,CPU 端的SetPosition调用会成为主要瓶颈,因为每帧要更新 3 万个顶点的位置和颜色,这在移动端上会出现掉帧。
优化方向有三个。第一,降低顶点数,一百条线每条 64 个顶点,视觉上在细线场景完全够用。第二,把波形计算从 Update 移到协程里降低更新频率,比如每秒 30 次而不是 60 次,视觉差异极小但 CPU 消耗减半。第三,如果顶点数和时间步长允许,用 Jobs 系统并行重算所有线的顶点位置。我实际用过后两种方案后,发现协程方案改动最少收益最直接,而且它不改变架构,适合大多数 demo 项目。
做批量更新时,记得把 LineRenderer 的useWorldSpace保持一致,混用会很麻烦。还有一个小技巧:多条波形共享同一个材质球时,在材质里启用GPU Instancing,如果 Shader 支持,LineRenderer 的Renderer实例渲染会合并批次。注意 LineRenderer 在默认管线下对 Instancing 支持有限,URP 下表现更好。
6. 进阶玩法:从心电图到通用动态波形组件
这一章给三个可以立刻上手的扩展方向,它们不是凭空想象,都是我在这类组件上做过并且确实能落地的改动。把基础组件搭好之后,扩展开销很低。
第一个扩展是音频频谱可视化。把 AudioSource 的频谱数据GetSpectrumData接入,取代第 3 章的心跳函数。注意频谱数据的长度通常不是 300 的整数倍,要做线性插值再映射到顶点上。颜色方面,可以用 Gradient 让低频段偏暖色、高频段偏冷色,效果非常像音乐 App 的播放页背景。这个扩展只需要改内层循环里的y计算方式,其他全部复用。
第二个扩展是动态数据回放。比如从 JSON 或 CSV 里读一组患者心率数据,按时间逐点推进。这个场景下,波形不是实时生成的,而是读取回放数据并填充顶点。做法是维护一个环形缓冲区,新的数据点进队列,每帧取出一段区间做绘制。环形缓冲区的好处是自然形成“滚动”效果,旧数据渐隐,新数据不断进入视野。
第三个扩展是交互反馈。给波形加一个“碰撞区”,当鼠标接近某个顶点时,该顶点附近颜色变亮,用粗线高亮显示。LineRenderer 的顶点颜色支持这一点,只需要在指定顶点范围覆盖一个新的 Gradient 即可。注意别在Update里做范围判断后频繁切换 Gradient,容易造成内存分配。
验证方法方面,我在做这类效果时有个习惯:把波形输出到一行 TXT 日志,记录帧间隔和顶点更新耗时,跑满一分钟后看平均值。如果平均每帧更新耗时超过 1 毫秒,就说明这个组件在这个目标设备上已经偏重了。用 Profiler 看更准,但日志方式在真机上调试更方便。
最后讲一个我自己的教训,也算一种习惯:任何时候我做完一个可自定义参数的线波动效,都会强制自己把参数面板中的每一项用一次“超出范围”的方式测试一遍。振幅设成 0.001,速度设成 999,颜色 Alpha 设成 0,顶点数量设成 1。这些极限情况如果都不崩溃或者不产生纯粹蓝屏的垃圾输出,这个组件才算真正“亲测好用”。测试过程很枯燥,但能帮你少挨很多美术和策划的骂。
如果你要接入自己的项目,记住一个核心原则:波形生成和参数控制分离。波形生成只负责根据相位和振幅计算顶点坐标,参数控制负责接收外部输入并驱动生成逻辑。这个分层会让后续所有扩展都轻松很多。希望这篇实战笔记帮到正在做类似效果的你。
本文还有配套的精品资源,点击获取