☰
Unity实现心电图线波效果:LineRenderer动态波形参数化方案
2026/9/26 16:47:30 网站建设 项目流程

简介:这是一个Unity心电图(ECG)线波模拟效果项目Demo,面向Unity开发者、游戏特效制作人员及相关专业学习者,用于掌握实时波形渲染、动态动画控制和参数化交互设计。项目已实现心电图上下振幅模拟效果,波线速度、颜色和振幅参数均可在界面中自定义调整,代码结构完整,可直接基于源码修改和二次开发。

压缩包为RAR格式,共6688个文件,大小约39.19MB,属于完整Unity项目工程。核心内容包括1066个C#脚本、575张PNG贴图、237个Markdown文档、5个Prefab预制体、31个Asset配置文件等,覆盖脚本逻辑、图形资源、场景配置与阅读文档,目录组织清晰,便于按模块学习。已有330人学习/下载。

借助该Demo可重点研究心电图波形的绘制逻辑,学习Material颜色动态修改、Transform局部缩放控制振幅、Time.deltaTime驱动波线速度等Unity常用技术;同时可参考Input.GetKeyDown键盘交互、Update/协程动画控制及脚本组件化封装等实践思路,为游戏UI特效、医疗模拟类互动内容的开发提供直接参考。

1. Unity模拟心电图线波demo:它到底解决什么问题

做医疗训练模拟、数字孪生大屏、驾驶舱状态可视化的时候,最常被要求加的一个效果就是“看起来像是监护仪在跳”的动态波形:一条线按固定节奏左右扫过,上下起伏模拟心跳,颜色和速度还得能随时改。Unity里做这种模拟心电图线波效果,常见做法不是去引入第三方插件或者用Shader硬算,而是直接用LineRenderer配合一个可调参数脚本,把波形采样点算出来再赋值给顶点数组。这套方案的好处是轻量、不改渲染管线、不依赖特定Shader,在Built-in管线、URP下都能跑,也方便后续把真实数据接到同一个波形组件上。

这个demo适合这么用:UI上做状态提示、后台设备“活着”的呼吸感、数字孪生场景里把抽象数据变成视觉心跳。如果你手里也有类似“要一条会动的线波但不想为此引一个大插件”的需求,下面从选型到参数到踩坑,按可以照抄的顺序写清楚。

2. 用LineRenderer还是自定义Shader:先把线波渲染方案定下来

2.1 LineRenderer为什么是默认选择

拉一条会动的曲线,在Unity里有好几条路:UGUI的Image加自定义绘制、Mesh动态更新、Shader程序化生成、LineRenderer。实际做下来,LineRenderer是最省事的选择。原因有三点:第一,它是引擎内建组件,不需要额外引包,也不依赖UGUI的Layout和Canvas刷新机制;第二,它自带宽度曲线和梯度颜色,模拟监护仪的荧光色渐变不用自己写着色器;第三,它的position数组支持直接写入,每帧更新几百个顶点在移动端也完全没有压力。

缺点是它的几何体在CPU侧生成,顶点数量很大时才需要考虑优化。但模拟心电图这类波形,采样点给到200到500个已经是肉眼平滑的极限,离性能瓶颈还很远。另一个容易被忽略的点是LineRenderer在Built-in管线和URP下的材质兼容性:如果只改了颜色和宽度,直接用默认的Sprites/Default材质和线渲染管线就行;如果开了URP但没升级材质,波形会显示成洋红色,出现这个情况去材质面板重新选URP/Lit或Sprites/Default即可。

2.2 波形不是纯正弦:模拟心电图的合成公式

标题里说“模拟心电图上下振幅”,很多第一次做的人会直接写一个Mathf.Sin(time)就交差。出来的效果虽然也在跳,但和监护仪上的波形比明显“太假”。真实心电图的单周期有P波、QRS波群、T波,特征是不对称的:有个高尖刺、一个圆润的主峰、一个平缓的小鼓包。做demo不需要还原医学波形,但至少要让视觉上“像”。

常见做法是用多段正弦加一小组基函数合成波形。我在这个方案里用的是三层叠加:

// 心电图合成波形:主波动 + 高频细节 + 特征尖峰 float wave = ampMain * Mathf.Sin(2f * Mathf.PI * freq * phase); wave += ampDetail * Mathf.Sin(2f * Mathf.PI * freq * 3f * phase + 0.8f); wave += ampSpike * Mathf.Exp(-Mathf.Pow((phase % 1f - 0.2f) * 8f, 2f));

主波动负责打底,高频细节让线在运动时有一点“锯齿感”,尖峰项用高斯函数模拟QRS波群的陡升陡降。phase取值0到1之间循环,这样每一轮心跳是完整周期。选择这种合成方式而不是直接采样一段真实心电数据,是因为合成波的每个参数都能映射到自定义项:振幅、速度、颜色在Inspector上改完立刻能看到反馈,而真实采样数据做不了这种灵活动态调整。

2.3 为什么不推荐用Shader写全部效果

Shader方案理论上效率更高,波形计算放到GPU上,还能省掉每帧CPU更新顶点数组的开销。但实际维护成本高:要处理Uniform更新、不同管线的兼容性、UI遮罩和本地坐标变换,而且后续如果想对接串口或网络来的真实心电数据,Shader里不好做数据缓冲。关键问题是,Shader做出的波形适合“一路滑过”的展示型效果,不适合“波形可以暂停、变速、回放”的交互型效果。demo的价值在于给后续项目留修改空间,所以CPU生成顶点、组件属性暴露给Inspector这个方向更合适。

3. 从空场景跑通心电图线波:搭建步骤与核心脚本

3.1 场景搭建:空物体挂LineRenderer,关键参数一次设对

新建场景后创建一个空物体,命名为ECG_Line。给这个物体添加LineRenderer组件,然后先做四件事:把Loop勾选去掉,Use World Space关掉,Position设置成0,Material选择Sprites/Default。关Use World Space的原因是把生成的采样点当作局部坐标处理,后续移动整个容器不会被坐标空间弄晕;开Loop会画出一圈闭合线,和“从底边出发扫过的直线波形”不符。

// 初始化LineRenderer基础参数 LineRenderer lr = GetComponent<LineRenderer>(); lr.loop = false; lr.useWorldSpace = false; lr.positionCount = segments; lr.widthCurve = AnimationCurve.Linear(0f, lineWidth, 1f, lineWidth); lr.widthMultiplier = 1f; lr.material = new Material(Shader.Find("Sprites/Default"));

尺寸方面,建议让LineRenderer的原点位于屏幕下半部,波形整体向上凸。摄像机的Projection选择Orthographic,Size设为5时,LineRenderer的Y向跨度在正负2.5之间最合适,既不会顶到屏幕边缘,也不会因为振幅太小缺乏“心跳感”。

3.2 核心脚本:振幅、波速、颜色全部暴露到Inspector

这一步直接把核心脚本贴出来,这是整个demo的基础。脚本做的事情是每帧把phase累加一个偏移量,然后把采样点的x坐标均匀分布在水平范围里,y坐标用合成波形函数计算,最后把颜色梯度写进LineRenderer。

using UnityEngine; public class ECGLineWave : MonoBehaviour { [Header("波形参数")] public float scrollSpeed = 1.2f; // 水平滚动速度,1.0时每秒完成一个完整周期 public float mainAmplitude = 1.8f; // 主波振幅 public float detailAmplitude = 0.6f; // 高频细节振幅 public float spikeAmplitude = 1.2f; // 尖峰振幅 public float frequency = 1.0f; // 波形主频率 public int segments = 512; // 采样点数量 [Header("颜色设置")] public Gradient lineGradient; // 线渐变颜色 public float lineWidth = 0.08f; // 线宽,World单位 [Header("水平范围")] public float length = 8f; // 波形占据的水平长度 private LineRenderer lr; private float phase = 0f; void Start() { lr = GetComponent<LineRenderer>(); if (lineGradient == null || lineGradient.colorKeys.Length == 0) { lineGradient = new Gradient(); lineGradient.SetKeys( new GradientColorKey[] { new GradientColorKey(new Color(0.2f, 1f, 0.4f), 0f), new GradientColorKey(new Color(0.1f, 0.8f, 1f), 0.5f), new GradientColorKey(new Color(0.2f, 1f, 0.4f), 1f) }, new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) } ); } lr.colorGradient = lineGradient; lr.startWidth = lineWidth; lr.endWidth = lineWidth; } void Update() { phase += scrollSpeed * Time.deltaTime; if (phase >= 1f) phase -= 1f; Vector3[] positions = new Vector3[segments]; for (int i = 0; i < segments; i++) { float t = (float)i / (segments - 1); float x = t * length - length * 0.5f; float y = CalculateY(phase + t * 0.6f); positions[i] = new Vector3(x, y, 0f); } lr.positionCount = segments; lr.SetPositions(positions); } float CalculateY(float ph) { float phWrapped = ph - Mathf.Floor(ph); float wave = mainAmplitude * Mathf.Sin(2f * Mathf.PI * frequency * phWrapped); wave += detailAmplitude * Mathf.Sin(2f * Mathf.PI * frequency * 3f * phWrapped + 0.8f); float spike = Mathf.Exp(-Mathf.Pow((phWrapped - 0.2f) * 8f, 2f)); wave += spikeAmplitude * spike; return wave; } }

代码逻辑说明:Update里累加phase作为时间滚动量,CalculateY接收一个0到1之间的相位值,先取小数部分保证每周期连续,然后叠加主波、三次谐波和高斯尖峰。segment从0到segments-1映射x坐标,t乘以0.6让整条线的相位沿水平方向错开,形成“波从左向右传播”的视觉。position数组每帧重建,这是一个内存分配点,后面避坑章节会专门说怎么优化。

参数说明:scrollSpeed的单位是“每秒经过的周期数”,不是像素速度,这样在不同分辨率下节奏一致。mainAmplitude控制在1.5到2.5之间,视觉最像心率曲线;detailAmplitude不宜超过0.8,否则波形变成毛刺;spikeAmplitude给到1.0到1.5能模拟QRS波群的尖锐感,太小会被主波吞掉。segments在真机上建议不要低于200,低了曲线出现折线感,高于800后肉眼无明显提升但要占用更大顶点缓冲。

4. 振幅、波速与颜色自定义:参数怎么设才像真的监护仪

4.1 振幅和速度的控制边界

标题里说“上下振幅和波线速度都可以自定义调整”,这个“可调”如果只是把参数暴露出来,实际用的时候很容易翻车。振幅要给上下限限制,速度要和业务节奏匹配而不是随手拖。

振幅的上限取决于你放波形的容器高度。我一般按“波形最高点不超过屏幕可用高度的35%”来约束。如果容器是屏幕下方的一个状态条,高度只有4个Unity单位,那mainAmplitude给到3.0必然穿出区域。速度方面,模拟心电图的视觉节奏约在“1秒到1.2秒完成一个周期”,对应scrollSpeed在0.8到1.2之间。低于0.5感觉像是在慢放,高于2.0则失去了心跳感,像电源波纹。一个可用参数表:

参数推荐范围作用
scrollSpeed0.8 ~ 1.2每秒完成的波形周期数
mainAmplitude0.8 ~ 2.5基础波形高度
detailAmplitude0.2 ~ 0.8波形毛刺和细节颗粒感
spikeAmplitude0.8 ~ 1.5QRS尖峰的高度
frequency0.8 ~ 1.2生成波形的基础频率
segments256 ~ 512单条线的顶点数量

需要注意的是,修改mainAmplitude会影响整条线的显示范围,如果同时改了摄像机Orthographic大小,这两处是关联的。建议在代码里把振幅最大值与原点位置写一个联动约束:当振幅超过边界时在Inspector里给出黄色警告,避免调出一个波形飞出屏幕却找不到原因的尴尬。具体做法是在OnValidate回调里动态检查振幅并重新赋值。

4.2 颜色自定义:LineRenderer的Gradient设置

颜色自定义有三个层面:整条线单色、按位置渐变色、按时间闪烁。监护仪最常见的是荧光绿到浅蓝渐变,把第3章脚本里的lineGradient换成下面这组参数即可得到这个效果。

// 荧光绿渐变:适合黑色或深色背景 GradientColorKey[] colorKeys = new GradientColorKey[] { new GradientColorKey(new Color(0.1f, 1f, 0.3f), 0f), new GradientColorKey(new Color(0.2f, 0.9f, 0.7f), 0.5f), new GradientColorKey(new Color(0.1f, 1f, 0.3f), 1f) };

LineRenderer的Gradient是按顶点位置插值还是按整条线的长度插值,取决于Gradient的模式。默认是Fixed,较短的线渐变过渡看起来很快;改成Blend模式后过渡更平滑。屏幕上如果嫌颜色发灰,多半是材质没有关闭雾效或者颜色空间不对,把项目从Gamma切换成Linear后荧光色会更亮,代价是画面整体对比度提升,需要重新调整背景色。

颜色还要注意和背景的关系。深色底配荧光绿是监护仪的经典组合,但如果你把背景换成白色面板,荧光绿的对比度会下降得非常明显,这时候建议主颜色换成深蓝色或者墨绿色,线宽也可以从0.08加到0.12让波形更有存在感。

4.3 把自定义参数做成运行时接口

在demo阶段参数放Inspector手调没问题,但真要接到业务系统里,就得把参数暴露成公开方法或者做成ScriptableObject配置。常见做法是单独建一个配置类,把颜色、速度、振幅、线宽存成一整个Profile,切换不同场景状态时直接换配置:

[System.Serializable] public class ECGStyleProfile { public string profileName; public float speed = 1f; public float amplitude = 1.8f; public Gradient gradient; public float width = 0.08f; }
public void ApplyProfile(ECGStyleProfile profile) { scrollSpeed = profile.speed; mainAmplitude = profile.amplitude; lineGradient = profile.gradient; lineWidth = profile.width; }

这样外部系统调用ApplyProfile就能切换不同风格,而不需要深入到波形生成逻辑里。如果你做的是数字孪生类项目,这一步几乎必然会用到——不同设备、不同告警级别对应不同心跳频率和颜色,可能性非常多。

5. 心电图线波效果最容易翻车的5个坑:现象、原因与处理办法

5.1 波形左右移动但上下完全不动

现象:线是滚动了,但y方向始终平得像一根直线,只有尖峰偶尔出现。原因是phase的累积方向和水平偏移的方向叠加出了问题——phase滚动时,t * 0.6的水平相位偏移把滚动量抵消或者错开了。解决办法是别把水平偏移写在CalculateY内部,而是直接给x坐标加滚动偏移,波形参数里单独保留一个speed。

5.2 线宽在屏幕上忽粗忽细

现象:波形左右移动过程中,线宽看起来在变。原因是LineRenderer的widthCurve默认不是平的,或者相机离LineRenderer太近、屏幕比例不同导致投影缩放不一致。解决方法是先把widthCurve拉成线性0到1,再检查widthMultiplier是否为1。如果用的是透视相机,距离越远线越细是正常透视效果,要么把相机调成正交,要么把LineRenderer的对齐模式改成View。

5.3 波形在UGUI上面显示不出来

现象:在Canvas里创建一个RawImage或者用Graphic组件包住LineRenderer,什么都看不到。原因是LineRenderer本身是3D空间物体,UI渲染在Canvas的覆盖层,二者不在同一渲染顺序里。正确做法是不要在UICanvas里放LineRenderer,应该把波形做成相机拍摄的画面投射到RawImage上,或者用RenderTexture把线波渲染到一张贴图上再显示给UI,后者是项目中更实用的方案。

5.4 波形一顿一顿,帧率骤降

现象:编辑器里很流畅,打包到手机上波形的移动不连续,Profile显示CPU占用高。原因是每帧new出来的positions数组触发GC,以及segments设置过大。第3章的代码每帧都创建一个512长度的Vector3数组,在手机上就是无谓的GC压力。解决办法是预先分配好数组,每帧只修改值不重新创建:

private Vector3[] positions; void Start() { positions = new Vector3[segments]; } void Update() { // 复用数组,不new新对象 for (int i = 0; i < segments; i++) { // 直接修改positions[i] } lr.SetPositions(positions); }

5.5 试用版Unity水印录屏时不希望被看到

现象:项目还没正式授权,用试用版Unity跑demo,Game窗口和录屏里左下角一直浮着Unity水印。原因是Unity对未激活或者试用版会在运行时叠加水印。处理办法是如果只是做效果演示,把Game画面裁掉水印区域,或者等授权就绪后再录屏。如果是在一体机上开发,build出来的版本在启动时水印会显示,给甲方展示之前确认授权状态,这是最容易忽略的细节。

5.6 OnValidate修改参数不生效

现象:改Inspector里的线条参数,Game窗口没有实时重现。原因是LineRenderer的宽度和颜色只在Start里赋值了一次,运行时修改字段但没调用LineRenderer的更新接口。解决方法是OverrideOnValidate,在Inspector数值变化的瞬间重新赋值:

void OnValidate() { if (lr == null) lr = GetComponent<LineRenderer>(); lr.startWidth = lineWidth; lr.endWidth = lineWidth; lr.colorGradient = lineGradient; }

这样改完参数马上能看到效果,不用每次进Play Mode重新验证,调试效率会提升一大截。

6. 把心电图线波做到能交付:验证、优化与最后一个实用技巧

6.1 用AnimationCurve直接手调单周期波形

如果你不想和公式参数打交道,还有一个更直观的做法:把波形从代码合成改成AnimationCurve手绘。创建一个单周期波形曲线,让x轴是0到1的相位,y轴是振幅,然后代码里直接Evaluate曲线得到采样值。这样做的好处是波形形状完全受控,可以亲手画出想要的PQRST形态,而且美术同事也能上手调整。

public AnimationCurve waveShape = AnimationCurve.Linear(0f, 0f, 1f, 0f); float CalculateYFromCurve(float ph) { return waveShape.Evaluate(Mathf.Repeat(ph, 1f)) * mainAmplitude; }

用AnimationCurve的代价是振幅和速度分离得更明确,曲线本身的y轴范围不建议写超出正负1的数值,真正的振幅控制放到mainAmplitude统一管理。

6.2 验证和优化:让demo可以交付而不是只能跑在编辑器里

交付前做两件事:第一,用Profiler跑一遍真机,确认没有任何脚本在Update里分配堆内存,波形动画的CPU占用低于1ms;第二,做参数回归验证——把scrollSpeed调到2.0持续跑5分钟,再把mainAmplitude调到2.5跑5分钟,确认没有波形穿模、颜色突变、线宽异常。我自己的习惯是所有波形相关参数固定到一个配置类里,不同场景直接加载不同配置项,同时保证波形组件即使在没有赋值的情况下也有默认参数,不会在空引用上报错。

最后说一个经验:心电图线波这种效果,越简单越可靠。不要为了追求炫酷往里面加粒子、加辉光、加动态模糊——加了这些以后在URP管线里就要引入后处理栈,对移动端的开销和兼容性都是额外负担。把一条线的顶点、宽度、颜色、速度做到极致可信,比叠加特效更能让对方信服。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询