简介:面向Unity开发者与图形交互方向学习者的心电图线波模拟演示工程,围绕ECG波形上下振幅、波线滚动速度与线条颜色等参数的可调实现,适合从事游戏UI动效、医疗可视化演示及互动装置原型的中初级开发者参考。包内共6688个文件,以C#脚本、meta配置、PNG贴图、Markdown说明为主,另含着色器、预制体、场景、DLL与少量资源包,压缩包约39.19MB;脚本与场景资源可用于拆解渲染组件、输入响应及时间步长控制等实现思路。已有333人学习下载。读者可获得可直接运行的演示场景与脚本组织方式,观察参数改动对振幅、速度和配色的即时影响,并借鉴组件化拆分、材质颜色赋值与性能优化做法,便于将波形效果迁移到监控面板、游戏HUD或互动展示项目。
1. 波形方案要先立住:Unity 里把心电图上下振幅做成可调参数
心电图波形看着简单,真扔进 Unity 里做实时演示,问题立刻冒出来:振幅要么写死、要么靠缩放整张贴图,波速一快就抖;颜色想换一套主题,得回头改材质和 Shader;上下两条波形还要对不齐基线。这个 Demo 针对的就是这类需求——用一段可编程的线波,把心电图的上下振幅、波线速度、颜色三件事全部参数化,挂在面板上直接调。适合做医疗类交互演示、教学课件动画、健康类 App 的动态背景,也适合课程设计里需要一个「看得见的算法 + 看得到的效果」的题目。它不追求真实医学数据,追求的是波形可控、参数可调、效果稳定,这三点立住,后面所有模块才有意义。
2. 需求拆解与总体设计:三条主线撑起一个波形系统
2.1 把「调参」拆成三类可独立变化的输入
先把参数分清楚,不然后面代码会乱成一锅粥。整个应用侧的输入通常归成三组:
| 参数组 | 典型字段 | 作用范围 | 调整后立即生效 |
|---|---|---|---|
| 波形形态 | 振幅、基线偏移、周期长度 | 决定上下起伏的高度与位置 | 是 |
| 播放节奏 | 波速、是否循环、暂停 | 决定波形横向滚动的快慢 | 是 |
| 外观表现 | 线条颜色、线宽、背景色 | 决定视觉主题 | 是 |
这三组互不干扰是关键设计目标:调振幅不能顺带改变波速,换颜色不能重置波形相位。做到这一点,最省事的路径就是把形态、节奏、外观分别做成独立的运行时配置对象,谁改谁生效,谁都不去动别人的状态。
2.2 总体架构:参数层、采样层、渲染层三层分离
结构上我一般拆成三层,职责一条一条写死:
- 参数层:
WaveConfig持有振幅、基线、周期、速度、颜色等字段,是唯一的数据源,面板和外部脚本都只改它。 - 采样层:
WaveSampler根据当前时间t和配置,算出一个归一化波值v ∈ [-1, 1],只做数学,不碰渲染。 - 渲染层:
LineRenderer(或自绘 Mesh)按采样层输出的点序列更新顶点位置和颜色,只做表现,不做计算。
好处很直接:波形数学和渲染解耦以后,想换 Shader、想从 LineRenderer 改成 Mesh 都只动渲染层;想加一种新波形,只动采样层。心电图效果本质是「一条随时间滚动的折线」,用LineRenderer最省事,点数够、线宽可调、颜色支持渐变,做毕设演示完全够用。
2.3 采样点的数量与分布决定质感
LineRenderer的点是离散的,点数太少,波形会变成折线块;点数太多,每帧重建顶点又浪费。通常的做法是按屏幕宽度定个数:一个横向采样点大约对应 2~4 像素,1920 宽取 512 个点左右比较稳。点分布要均匀,横轴x等距排开,纵轴y用采样结果乘振幅再叠基线,这样波速变化时不会出现「前疏后密」的拉伸感。
3. 核心实现:波形采样、滚动与参数化渲染
3.1 波形采样函数:用分段函数拼出一条心电图
真实心电图有 P 波、QRS 波群、T 波,全部还原成本高、演示价值低。演示里常用做法是把一个心动周期切成若干段,用简单函数拼出「平线—小鼓包—尖峰—回落—大鼓包—平线」的节奏,再靠横向平移实现滚动。
using UnityEngine; // 单个采样点:输入归一化相位 phase ∈ [0,1),输出 [-1,1] 的波值 public static class EcgWave { // 一个心动周期内的关键分段,比例为周期百分比 // 0.00-0.10 平线;0.10-0.20 P 波;0.20-0.28 Q 波;0.28-0.36 R 尖峰; // 0.36-0.44 S 波;0.44-0.62 平线;0.62-0.78 T 波;0.78-1.00 平线 public static float Sample(float phase) { phase -= Mathf.Floor(phase); // 保证落在 [0,1) if (phase < 0.10f) return 0f; if (phase < 0.20f) // P 波:一个小鼓包 { float u = (phase - 0.10f) / 0.10f; return Mathf.Sin(u * Mathf.PI) * 0.22f; } if (phase < 0.28f) // Q 波:向下的小凹 { float u = (phase - 0.20f) / 0.08f; return -Mathf.Sin(u * Mathf.PI) * 0.18f; } if (phase < 0.36f) // R 波:主尖峰,决定视觉冲击力 { float u = (phase - 0.28f) / 0.08f; return Mathf.Sin(u * Mathf.PI) * 1.0f; } if (phase < 0.44f) // S 波:尖峰后的下冲 { float u = (phase - 0.36f) / 0.08f; return -Mathf.Sin(u * Mathf.PI) * 0.32f; } if (phase < 0.62f) return 0f; if (phase < 0.78f) // T 波:较宽的大鼓包 { float u = (phase - 0.62f) / 0.16f; return Mathf.Sin(u * Mathf.PI) * 0.45f; } return 0f; } }这段是整个方案的心脏。几个设计点值得说清楚:
Mathf.Floor那行取小数部分,是为了让相位可以无限增长,滚动时直接加时间就行,不用手动回绕,省掉一类越界 bug。- 每一段都用
Mathf.Sin(u * PI)做包络,好处是段首段尾都是 0,段与段拼接处天然连续,不会出现台阶跳变;如果改用线性插值,接缝处会出现明显的折角,波形看起来「一卡一卡」,这是新手最容易翻车的地方。 - 各段的幅度(0.22、0.18、1.0、0.32、0.45)就是波形的相对形态,R 波给到 1.0,其余按比例压下去,视觉上主次分明。想改风格,改这几个系数就够,不用动结构。
3.2 滚动逻辑:相位推进与横向映射分开算
波形要「流动」,本质是每个采样点的相位随时间偏移,而不是整条线平移。常见错误是把LineRenderer整体往左挪,结果左端出现空档、右端被裁掉。正确做法是每个点自己算相位:
using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class EcgLineController : MonoBehaviour { [Header("波形形态")] public float amplitude = 1.2f; // 上下振幅 public float baseline = 0f; // 基线纵向偏移 public float cycleLength = 4f; // 一个心动周期占多少世界单位宽 [Header("播放节奏")] public float waveSpeed = 1.0f; // 周期推进速度(周期/秒) [Header("外观")] public Color lineColor = Color.green; public float lineWidth = 0.08f; [Header("采样")] public int pointCount = 512; // 点数越多越平滑,也和性能挂钩 public float totalWidth = 20f; // 波形横向总宽度(世界单位) private LineRenderer _lr; private float _phase; // 全局相位,持续增长 private Vector3[] _points; void Awake() { _lr = GetComponent<LineRenderer>(); _lr.positionCount = pointCount; _points = new Vector3[pointCount]; ApplyStyle(); } void Update() { _phase += waveSpeed * Time.deltaTime; // 相位推进,与振幅无关 RebuildPoints(); _lr.SetPositions(_points); } void RebuildPoints() { for (int i = 0; i < pointCount; i++) { float t = i / (float)(pointCount - 1); // 0..1 横向归一化 float x = (t - 0.5f) * totalWidth; // 居中展开 float localPhase = t * (totalWidth / cycleLength) + _phase; float v = EcgWave.Sample(localPhase); float y = baseline + v * amplitude; _points[i] = new Vector3(x, y, 0f); } } public void ApplyStyle() { _lr.startColor = lineColor; _lr.endColor = lineColor; _lr.startWidth = lineWidth; _lr.endWidth = lineWidth; } }参数含义逐条对一下:waveSpeed只加在_phase上,所以改速度不影响振幅和基线;amplitude只乘在采样结果上,改振幅不影响滚动;cycleLength和totalWidth决定画面上能同时看到几个周期,totalWidth / cycleLength就是屏幕内的周期数,想看得「密」一点就调小cycleLength。baseline单独加在最后,这样就可以把波形整体上移下移,做上下两条对比波形时非常方便。
3.3 颜色与外观:为什么用顶点色而不是改材质
颜色可调这件事,最省事的实现是改LineRenderer的startColor/endColor,也就是上面ApplyStyle里那两行。走这条路的原因:改材质属性需要拿到材质实例并注意sharedMaterial的共享问题,稍不注意就污染同场景其他物体;而LineRenderer的颜色是每渲染器独立的,改完即时生效,也不会影响别的对象。
如果要做「随振幅变色」这类进阶效果,可以在RebuildPoints里按v的大小给SetColors传一个渐变数组,让尖峰处偏亮、平线处偏暗。代价是每帧多一次颜色数组写入,512 个点完全扛得住。颜色空间上注意一点:如果项目用的是 Linear 色彩空间,Color.green这类常量在面板上看到的和场景里渲染出来的会略有差异,调色时以 Game 视图为准,别对着面板值纠结。
4. 参数面板与自定义扩展:让波速、颜色、振幅真正「可调」
4.1 运行时面板:把配置集中在 Inspector 而不是散落各处
参数都挂成public字段后,Inspector 里就能直接拖,这是最快能让读者「看到自己调的东西生效」的方式。为了让面板不至于乱,用[Header]分好组,并配合[Range]限制危险区间:
[Header("波形形态")] [Range(0.1f, 5f)] public float amplitude = 1.2f; [Range(-3f, 3f)] public float baseline = 0f; [Range(1f, 10f)] public float cycleLength = 4f; [Header("播放节奏")] [Range(0f, 5f)] public float waveSpeed = 1f; public bool paused = false; [Header("外观")] public Color lineColor = Color.green; [Range(0.01f, 0.5f)] public float lineWidth = 0.08f;amplitude卡在 0.1~5,是防止有人把波形拉到屏幕外;lineWidth上限 0.5,是因为线宽太大时相邻采样点会互相吞掉,看起来像一条实心色带,这属于可预见的问题,提前收口比事后排查划算。暂停键paused在代码里只需要在Update里if (paused) return;,但它对演示的价值很高——现场演示时能停在某个波形上讲解,这个细节答辩时很讨巧。
4.2 多波形对比:用一个脚本实例化多份,而不是复制代码
要做上下两条心跳波形对比,不要写两个脚本。同一个EcgLineController挂两次,各自设置不同的amplitude、baseline、cycleLength,就能得到「强/弱振幅」「快/慢节奏」的对照效果。更规范的做法是抽一个WaveProfile结构体,把形态、节奏、外观打包,再让控制器从 profile 读参数:
[System.Serializable] public struct WaveProfile { public float amplitude; public float baseline; public float cycleLength; public float waveSpeed; public Color lineColor; } public class EcgLineController : MonoBehaviour { public WaveProfile profile; public void ApplyProfile() { amplitude = profile.amplitude; baseline = profile.baseline; cycleLength = profile.cycleLength; waveSpeed = profile.waveSpeed; lineColor = profile.lineColor; ApplyStyle(); } }这样一份配置可以在 Inspector 里保存成资产,多个场景复用,切主题时换一个 profile 就行。做课程设计时这一步很加分,因为它体现的是「参数化配置」而不是「硬编码」。
4.3 表现增强:线宽渐变、发光与拖尾的取舍
想让波形更像示波器,可以加两层:一层是主线LineRenderer,一层是同位置但线宽更大、颜色更低透明度的「辉光」线。两层的点数一致,位置复用_points数组,只改宽度和颜色,性能开销很小。
不推荐刚上手就上后处理 Bloom,原因很实际:Bloom 会让整屏所有亮色都发光,波形之外的 UI 和背景也会被带亮,色偏起来很难收拾。先用两层线的思路做局部辉光,效果可控、调试成本低,符合「先能跑通再谈质感」的节奏。
5. 避坑清单:波形项目里最容易翻车的五个点
5.1 波形抖动或撕裂
现象:波速调高以后,线看起来在抖,甚至段与段之间出现断层。原因通常有两个,一是采样点在Update里按Time.deltaTime推进相位,但屏幕刷新率和帧率不同步,掉帧时相位跳变大;二是采样点数太少,横向跨度一大,相邻点之间像素差过大,视觉上就成了锯齿。解决:相位推进改用累加并做上限保护,同时把pointCount提到 256 以上;如果还有抖动,把渲染层从Update挪到LateUpdate,避开同帧其他逻辑的干扰。
5.2 改颜色后整场景都变色了
现象:调了一次颜色,场景里别的 LineRenderer 也跟着变了。原因多半是动过材质的共享属性,或者用了renderer.material在运行时生成实例却没收口。解决:颜色一律走LineRenderer.startColor/endColor;如果确实要改材质,用material而不是sharedMaterial,并且明确知道它会在运行时产生材质实例。这个坑属于经典血泪经验,第一次踩的时候很难联想到是共享材质的问题。
5.3 两端出现空白或截断
现象:波形往左滚动时,左端空出一截,右端被硬生生切掉。原因是用「整体平移」的思路做滚动,而波形长度是有限的。解决:回到 3.2 的做法,横向位置由采样序号固定,变化只发生在相位上,屏幕外的点自然从另一侧接进来,不存在端点问题。如果必须用平移,就得同时做首尾循环拼接,成本比相位法高得多。
5.4 振幅调到很大时线宽看起来「变细」
现象:振幅拉大以后,线条观感变细、变虚。原因不是线宽变了,而是视觉比例问题——同样的线宽在更高更陡的波形上显得更细。解决:把线宽与振幅做弱关联,例如lineWidth = baseWidth * Mathf.Lerp(0.8f, 1.2f, amplitude / 5f),让大振幅时线略粗,观感更平衡。这是一条纯经验性的调整,但现场演示时的观感差别很明显。
5.5 暂停后恢复出现跳变
现象:暂停再继续,波形直接「跳」到新位置。原因是暂停期间Time.deltaTime仍在累计,或者恢复时相位被意外重置。解决:暂停时直接return,不推进相位;恢复时不做任何相位补偿。相位是连续累加量,只要暂停期间不动它,恢复后自然衔接。
提示:这五条里,5.3 和 5.5 是最容易被误判成「Unity 玄学」的两条,实际都是状态管理没做干净,跟引擎无关。
6. 验收与答辩:用一张参数表把方案讲清楚
验收这个波形 Demo,不用堆功能,重点是把「参数—效果」的对应关系证明清楚。我一般会准备一张对照表,答辩时直接投出来:
| 验收项 | 操作 | 预期结果 | 判定标准 |
|---|---|---|---|
| 振幅可调 | 拖动amplitude从 0.5 到 3 | 波形上下起伏等比放大,周期与速度不变 | 尖峰高度线性变化,无跳变 |
| 波速可调 | 拖动waveSpeed从 0.2 到 3 | 横向滚动变快或变慢,形态不变 | 相位连续,无断层无抖动 |
| 颜色可调 | 切换lineColor | 整条线颜色即时变化,其他物体不受影响 | 场景内其他线条颜色不变 |
| 基线偏移 | 调baseline | 整条波形上下平移,振幅不变 | 上下两条对比波形互不干扰 |
| 暂停/恢复 | 点paused两次 | 停在当前波形,恢复后从原位继续 | 恢复瞬间无位置跳变 |
演示顺序也有讲究:先调振幅,再调速度,最后换颜色。这个顺序对应「形态—节奏—外观」三层结构,讲的时候顺着说,评委很容易跟上你的设计思路。反过来先换颜色再调振幅,听起来像在罗列功能,结构感就没了。
答辩时被问到「为什么不用真实心电数据」,回答口径要稳:这个方案的目标是波形表现层的参数化与可控性,数据源可以替换,采样层EcgWave.Sample就是预留的接入点,换成真实数据的归一化序列即可,渲染层和参数层完全不用改。这句话既解释了边界,也证明了架构分层不是摆样子。
从那以后我每次做这类波形或曲线演示,都强制先把「参数分组表」写出来再动代码,因为十次翻车里有八次是参数之间偷偷耦合了,事后拆比一开始就分开贵得多。希望这套结构帮到你。
本文还有配套的精品资源,点击获取