简介:这是一份基于Unity引擎的心电图线波模拟效果项目demo,面向游戏开发者与视觉特效爱好者,主要解决在Unity中快速实现可交互的动态线波效果。项目运行流畅,支持通过键盘按键Q和W切换心电图波形状态,并可灵活调整波线速度、颜色和振幅,适合用作UI反馈、医疗模拟或创意视觉展示的参考。资源压缩包约39.19MB,共6688个文件,其中包含1066个C#脚本、1828个meta配置以及大量png、shader、prefab、asset等资源,完整覆盖场景搭建、脚本逻辑与渲染设置。目前已有331人学习下载,便于入门者直接导入Unity观察整体结构。通过该demo可学习到Mesh与Renderer绘制线波的图形编程方法、Input.GetKeyDown事件监听、Time.deltaTime驱动的动画控制、Material颜色动态修改以及Transform缩放实现振幅变化等核心技巧,同时也能体会Unity组件化脚本设计和资源目录组织的思路,是深入了解Unity动态图形与交互逻辑的实用案例。
1. 心电图线波不是正弦波:这个 demo 真正要解决的事
Unity 里做“模拟心电图上下振幅”的线波,常见误区是直接拿一条正弦波滚动,再把振幅调大。实际能落地的 demo 要把三件事一次性做对:波形是 PQRST 复合峰不是正弦、波线速度不随帧率抖动、颜色和振幅能被实时改。这套方案在空场景里挂一个脚本就能跑,顶点更新交给 LineRenderer,波形曲线放在 AnimationCurve 里编辑,速度、振幅、颜色全走公开参数。适合做 UI 动效、数据可视化背景、音游节奏反馈这类偏视觉的需求。如果你只想要一个心电图样式的花哨线条,按这个思路做不会白跑一趟。
先说结论:不要一开始就钻进画线代码里,先把你想要的“波形曲线”定义清楚。曲线决定波形长什么样,速度决定它滚得多快,振幅决定它跳多高,颜色决定它最终能不能融入场景。这四个点都盯住了,这个 demo 才有真正的复用价值。
2. 波形生成原理:PQRST 峰值合成与速度、振幅、颜色的定义
2.1 心电图的数学骨架:把 P、Q、R、S、T 波拼成一条周期曲线
模拟心电图不是画一条正弦波再乘个系数。心电图的典型特征是一个周期内出现一组先后错落的波峰:P 波是个低缓隆起,QRS 群是一个剧烈的上下冲,T 波是收尾的宽峰。用正弦去拟合会得到一条圆滚滚的线,和真实心电图的锐利感差很远。
把每个波峰看成一个“高斯峰”,用三个参数描述:中心位置 center(在周期内的 0~1 位置)、幅度 amplitude(上下振幅贡献)、宽度 width(越窄越尖锐)。波形函数就是多个高斯峰的叠加:
[System.Serializable] public struct WavePeak { public string label; // 波峰名称:P/Q/R/S/T public float amplitude; // 峰高度,R 波通常最大 public float center; // 周期内位置 0~1 public float width; // 峰宽,越小越尖 } public static float SampleWave(float t, WavePeak[] peaks) { float value = 0f; for (int i = 0; i < peaks.Length; i++) { float d = t - peaks[i].center; float sigma = peaks[i].width; value += peaks[i].amplitude * Mathf.Exp(-(d * d) / (2f * sigma * sigma)); } return value; }逻辑说明:这段代码就是在做“高斯叠加”这唯一一件事。对每个波峰,计算采样点 t 与该峰中心的距离 d,距离越远,这个峰对当前点的贡献越小;所有贡献累加,就得到 t 时刻的波形高度。width 越小,峰旁边衰减越快,波形越尖。
这套参数的常用初始值可以照抄:
| 波峰 | amplitude | center | width |
|---|---|---|---|
| P 波 | 0.25 | 0.16 | 0.03 |
| Q 波 | -0.08 | 0.28 | 0.012 |
| R 波 | 1.00 | 0.32 | 0.015 |
| S 波 | -0.15 | 0.36 | 0.012 |
| T 波 | 0.35 | 0.55 | 0.055 |
| U 波 | 0.05 | 0.70 | 0.025 |
把这组参数代进 SampleWave,横轴从 0 扫到 1,你会看到一条典型的单周期心电图:先是低缓的 P 波,接着 QRS 群像一根钉子一样猛地抽上去又抽下来,最后 T 波宽宽地收尾。首尾都是 0 左右,意味着周期首尾相接时滚动不会跳变。
2.2 速度、振幅、颜色三个参数背后在改什么
“速度”这个参数最容易被人调晕。心电图线波在屏幕上向左滚动,并不是把整条线往左平移,而是采样点在波形周期上“往前走”。速度的实际含义是:每秒滚动多少个完整周期,单位是 Hz。比如速度设 1.25,视觉上大约每秒走 1.25 个心跳周期,接近真实心率 75 次/分钟。
用时间驱动相位是关键。我一般不会用_phase += speed * Time.deltaTime,因为帧率掉一帧波形就跳一下;更靠谱的做法是直接用Mathf.Repeat(Time.time * speed, 1f)求当前相位。这样无论帧率怎么波动,同一时刻看到的相位只取决于绝对时间,滚动节奏永远是均匀的。
“上下振幅”在 Inspector 里就是一个缩放系数的字段,默认 1。它负责把曲线里 0~1 的相对高度映射成世界单位或 UI 像素。振幅 0.5 和振幅 2.0 的差别,最直接体现在 R 波尖峰顶上:调大后峰更高,同时相邻采样点的高度差也变大,这会影响你需要的采样点数量。
“颜色自定义”看起来是一句话,实际有两个层面:线本身的主色,以及整条线从首端到尾端的渐变。如果只给一个 Color 字段,做不出“前段暗、后点亮”的荧光感。所以这个 demo 里颜色应该用 Gradient 而不是 Color,让每条波线可以在自身长度上做渐变。
2.3 LineRenderer 与 Shader 两条路线怎么选
做这个效果,一大半的时间会花在“用什么方式把曲线画出来”上。两条主流路线:
LineRenderer 方案:脚本每帧把采样点写入顶点数组,CPU 做计算,GPU 只负责光栅化。好处是顶点位置实时可见、可以在 Scene 视图里点击,也能在脚本里做交互;坏处是几百个顶点每帧刷新,波形数量一多就吃力。
Shader 方案:把波形计算放进片段着色器,一条 Quad 就能画出整条波形,性能开销跟顶点数基本无关,适合多路波形、4K 大屏这类场景。坏处是曲线不好调,想换波形只能在 Shader 里改高斯参数,不能像 AnimationCurve 那样可视化编辑。
对比下来,这个 demo 用 LineRenderer 做主路线,Shader 作为进阶补充。如果你只是做 UI 背景或数据可视化,LineRenderer 完全够用;如果你验到后面发现需要同时挂 30 条波线,再切到 Shader 方案。
3. 搭一套可调参的心电图线波 Demo:LineRenderer 最小实现
3.1 把默认波形生成进 AnimationCurve
直接在代码里写死 SampleWave 调用,调试时改一个参数要回到代码里重新编译,很痛苦。常见做法是把波形曲线从代码“搬”到 AnimationCurve 里,让你在 Inspector 的曲线编辑器直接拖拽修改。
生成默认曲线的函数这样写:
public static AnimationCurve BuildDefaultCurve() { WavePeak[] peaks = new WavePeak[] { new WavePeak { label = "P", amplitude = 0.25f, center = 0.16f, width = 0.03f }, new WavePeak { label = "Q", amplitude = -0.08f, center = 0.28f, width = 0.012f }, new WavePeak { label = "R", amplitude = 1.00f, center = 0.32f, width = 0.015f }, new WavePeak { label = "S", amplitude = -0.15f, center = 0.36f, width = 0.012f }, new WavePeak { label = "T", amplitude = 0.35f, center = 0.55f, width = 0.055f }, new WavePeak { label = "U", amplitude = 0.05f, center = 0.70f, width = 0.025f }, }; AnimationCurve curve = new AnimationCurve(); const int samples = 64; for (int i = 0; i <= samples; i++) { float t = i / (float)samples; curve.AddKey(t, SampleWave(t, peaks)); } for (int i = 0; i < curve.length; i++) { curve.SmoothTangents(i, 1f); } return curve; }逻辑说明:先用 64 个均匀采样点把高斯叠加结果烘焙成 AnimationCurve 的关键帧,再逐个平滑切线。SmoothTangents 会把关键帧两侧切线改为曲线相邻点的平均斜率,避免 Inspector 里出现尖锐的折角。
参数说明:samples 取 64 已经足够,因为后续 LineRenderer 顶点采样用的还是这条曲线,曲线本身是平滑的;采样点太多反而会让曲线文件变大。width 值 0.03 表示 P 波比较宽缓,0.012 表示 Q、S 波很窄,才能还原心电图的尖刺感。
3.2 LineRenderer 最小可跑代码:挂上就出波形
新建一个空物体,挂上 LineRenderer,再挂这个脚本,直接 Play 就能看到一条从左往右滚动的心电图线:
using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class EKGLineEffect : MonoBehaviour { [Header("显示范围")] public float displayWidth = 10f; // 波形在 X 方向占多少世界单位 public float baselineY = 0f; // 基线相对物体中心的偏移 [Header("波形自定义")] public AnimationCurve waveCurve = new AnimationCurve(); public float amplitude = 1f; // 上下振幅,乘在曲线值上 [Header("滚动控制")] public float speed = 0.5f; // 每秒滚动多少个周期 [Header("采样质量")] [Range(64, 1024)] public int pointCount = 512; [Header("线样式")] public Gradient colorGradient; [Range(0.01f, 1f)] public float lineWidth = 0.08f; private LineRenderer _line; private Vector3[] _positions; void Reset() { waveCurve = BuildDefaultCurve(); colorGradient = CreateDefaultGradient(); } void OnValidate() { RebuildArray(); } void Awake() { _line = GetComponent<LineRenderer>(); RebuildArray(); } void Update() { // 用绝对时间算相位,而不是逐帧累加,保证滚动速度不受帧率影响 float phase = Mathf.Repeat(Time.time * speed, 1f); float spacing = pointCount > 1 ? displayWidth / (pointCount - 1) : 0f; float baseX = transform.position.x - displayWidth * 0.5f; for (int i = 0; i < pointCount; i++) { float x = baseX + i * spacing; float u = i * spacing / displayWidth; float t = Mathf.Repeat(u + phase, 1f); float y = transform.position.y + baselineY + waveCurve.Evaluate(t) * amplitude; _positions[i] = new Vector3(x, y, transform.position.z); } _line.positionCount = pointCount; _line.SetPositions(_positions); _line.widthMultiplier = lineWidth; _line.colorGradient = colorGradient; } void RebuildArray() { if (_positions == null || _positions.Length != pointCount) { _positions = new Vector3[pointCount]; } } static AnimationCurve BuildDefaultCurve() { // 直接复用 2.1 节的 SampleWave 与默认参数 WavePeak[] peaks = new WavePeak[] { new WavePeak { label = "P", amplitude = 0.25f, center = 0.16f, width = 0.03f }, new WavePeak { label = "Q", amplitude = -0.08f, center = 0.28f, width = 0.012f }, new WavePeak { label = "R", amplitude = 1.00f, center = 0.32f, width = 0.015f }, new WavePeak { label = "S", amplitude = -0.15f, center = 0.36f, width = 0.012f }, new WavePeak { label = "T", amplitude = 0.35f, center = 0.55f, width = 0.055f }, new WavePeak { label = "U", amplitude = 0.05f, center = 0.70f, width = 0.025f }, }; AnimationCurve curve = new AnimationCurve(); const int samples = 64; for (int i = 0; i <= samples; i++) { float t = i / (float)samples; curve.AddKey(t, SampleWave(t, peaks)); } for (int i = 0; i < curve.length; i++) { curve.SmoothTangents(i, 1f); } return curve; } static Gradient CreateDefaultGradient() { Gradient gradient = new Gradient(); gradient.SetKeys( new GradientColorKey[] { new GradientColorKey(new Color(0.1f, 1f, 0.3f), 0f), new GradientColorKey(new Color(0f, 0.8f, 1f), 1f) }, new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0.3f, 1f) }); return gradient; } }逻辑说明:核心循环里,每个顶点算两件事:它在 displayWidth 内的横向位置 u,以及它应该显示哪个时间相位。t = Mathf.Repeat(u + phase, 1f)的意思是:屏幕最左边的顶点对应当前相位,越往右越靠近“未来”的波形;phase 随时间变大,左边之前画的形状被推到左边外,新波形从右边补进来,视觉就是整条线向左滚动。position 用 transform 位置做基准,这样脚本挂到哪个物体上,波形就在那个位置出现。
参数说明:amplitude 控制上下振幅,是“曲线高度乘 amplitude”后叠加到 baselineY 上的世界单位值;speed 是每秒滚动的周期数,0.5 就是两秒一个周期;pointCount 越多,波形越平滑,但每帧 CPU 计算量越大;lineWidth 是 LineRenderer 的宽度乘数,配合材质纹理调整最终视觉粗细。
3.3 在 Inspector 里自定义速度、振幅、颜色:调参面板怎么设
脚本挂上去后,先在 Inspector 里点开 WaveCurve 字段右侧的曲线编辑器。你会看到一条带 PQRST 波形的曲线,横轴 0~1 是一个周期,纵轴是波形相对高度。把 R 波那个最高点往上拖,Run 起来之后波形跳动幅度会直接变大,这就是“上下振幅”在曲线层面的自定义。
振幅还有一个更外层的控制,就是脚本里单独的 amplitude 字段。它和曲线编辑器里的高度是相乘关系:曲线里 R 波画得再高,amplitude 设 0 也看不到;反过来 amplitude 设 3,曲线里 P 波那点小起伏也会被放大到明显跳动。我通常建议曲线编辑器管“形状”,amplitude 管“整体强度”,两个分开调才不会乱。
速度字段直接调数值就能看到滚动快慢。一个经验值:想模拟 75 次/分钟心跳,speed 设 1.25;想要从容的“呼吸感”,放慢到 0.3~0.5。颜色那块调的是 Gradient,单击色条可以加色标和控制透明度。前端偏暗、后端偏亮,或者整条线从青绿渐变到深蓝,都可以在这里完成。
3.4 用 Shader 把波形计算搬进 GPU:批量波形再切这条路线
如果验证到后面发现波形数量一多,LineRenderer 每帧更新顶点开始吃力,就可以把波形放进一条 Quad。下面是 Built-in 管线里的可运行 Shader,挂到 Quad 上就能出一整条平滑心电图线:
Shader "Unlit/EKGLineShader" { Properties { _WaveColor ("Wave Color", Color) = (0, 1, 0, 1) _Amplitude ("Amplitude", Float) = 0.15 _Speed ("Speed", Float) = 0.5 _LineWidth ("Line Width", Float) = 0.03 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; fixed4 _WaveColor; float _Amplitude; float _Speed; float _LineWidth; float SampleWave(float t) { float v = 0; float sigma = 0.03; v += 0.25 * exp(-pow(t - 0.16, 2) / (2 * sigma * sigma)); sigma = 0.012; v -= 0.08 * exp(-pow(t - 0.28, 2) / (2 * sigma * sigma)); sigma = 0.015; v += 1.00 * exp(-pow(t - 0.32, 2) / (2 * sigma * sigma)); sigma = 0.012; v -= 0.15 * exp(-pow(t - 0.36, 2) / (2 * sigma * sigma)); sigma = 0.055; v += 0.35 * exp(-pow(t - 0.55, 2) / (2 * sigma * sigma)); return v; } v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float phase = _Time.y * _Speed; float t = frac(i.uv.x + phase); float wave = _Amplitude * SampleWave(t); float dist = abs(1.0 - i.uv.y - wave); float edge = fwidth(dist); float alpha = 1.0 - smoothstep(_LineWidth - edge, _LineWidth + edge, dist); return fixed4(_WaveColor.rgb, alpha * _WaveColor.a); } ENDCG } } }逻辑说明:片段着色器对每个像素,用 uv.x 加时间相位算出它属于波形周期里的哪个位置,再调用 SampleWave 得到这一点的波形高度;把像素的 uv.y 与这个高度做差,差足够小的像素就是波线上的像素。fwidth 是屏幕空间的导数指令,用来把波线边缘做一像素级别的抗锯齿,比硬性的 threshold 看起来更平滑。
参数说明:_Amplitude 是相对 Quad 高度的振幅,0.15 表示波形最大跳起约 15% 的 Quad 高度;_Speed 是每秒滚动周期数;_LineWidth 是波线粗细,0.02~0.04 在普通 Quad 上比较合适。这套 Shader 在 Built-in 管线里直接建材质就能用,如果你的项目是 URP,需要把它改写成 Shader Graph 或 URP HLSL,不能直接挂。
4. 调参与排查:4 个踩坑记录与参数边界
这四条都是我实际把方案跑起来之后遇过的翻车现场,剩下的是血泪经验。对新手来说,代码跑通只是第一步,真正花时间的往往是这些看起来很小的边界问题。
4.1 现象:波形滚动速度忽快忽慢,像卡顿一样
原因:脚本里用_phase += Time.deltaTime * speed累加相位。帧率高时每帧加的量小、帧率低时每帧加的量反而大,肉眼看起来节奏不稳定。另一个常见原因是每帧重新new Vector3[pointCount],频繁分配数组触发 GC,导致 Update 卡顿,速度看起来一抖一抖。
解决:第 3 章的代码里已经把相位改成Mathf.Repeat(Time.time * speed, 1f),并且用预分配的_positions数组承接顶点数据。Time.time是绝对时间,不管中间卡了多少帧,下一次更新取的相位永远是平滑递增的。如果以后要支持暂停、加速减速,建议维护一个自己的累计时间变量,但只在外部事件发生时修改它,不要在每帧里做增量加法。
4.2 现象:振幅调大之后波形出现尖角断裂
原因:振幅变大,R 波顶端相邻两个采样点之间的高度差也会变大。如果 pointCount 太少,波峰处会变成一根很突兀的折线,严重时视觉上像波形“断了一截”。有的还把 AnimationCurve 的 tangent 调成过冲,波峰会冲出设置高度,看起来像尖刺。
解决:把 pointCount 提到 512 甚至 1024,尤其是 displayWidth 设得比较大的时候。点距公式是displayWidth / (pointCount - 1),理想情况下这个点距应该远小于线宽,R 峰附近才能圆滑过渡。另一个做法是在曲线生成后用 SmoothTangents 统一平滑切线,不要手动去拉折线切线。如果振幅要长期保持 3 以上,建议优先走 Shader 方案,因为 GPU 是逐像素算波形,根本没有采样点不足的问题。
4.3 现象:放在 Canvas 下波形消失或拉伸变形
原因:LineRenderer 是 3D 世界空间的渲染组件,不响应 UGUI 的 RectTransform。你把挂 LineRenderer 的物体拖进 Canvas 下,Canvas 的缩放会被套用到 LineRenderer 顶点上,结果波形要么小到看不见,要么被拉伸成大跨度斜线,而且不能在 Canvas 上正常遮挡 UI。
解决:如果一定要让波形显示在 UI 背景里,别直接塞 LineRenderer。常见做法是新建一个 RawImage,把 LineRenderer 单独渲染到 RenderTexture,再将 RenderTexture 显示在 RawImage 上;或者写一个继承自 MaskableGraphic 的自定义 UI 组件,在 OnPopulateMesh 里把波形顶点填进 UI 网格。前者 10 分钟能搭起来,适合做背景;后者适合做可交互的 UI 波形,但需要自己处理 Canvas 的缩放因子,我一般只在波形需要和 UI 元素绑定时才用。
4.4 现象:修改颜色后整个场景里的波形颜色一起变怪
原因:不少人为了改颜色会直接写lineRenderer.material.color = xxx。LineRenderer.material属性会重新实例化一份材质,改一次就 new 一份出来,材质资源会越堆越多;如果多个波形共用一份材质,改动还会互相影响,出现“改一条线,全场景变色”的情况。
解决:这个 demo 里颜色直接走lineRenderer.colorGradient,它是顶点颜色,不碰材质,永远不会互相污染。如果需要改发光的荧光色,通过MaterialPropertyBlock设置_Color,或者维护一份共享材质并在编辑器里改好再提交,不要在运行时反复动.material。检查材质内存最直接的方式是打开 Profiler 的 Memory 模块,看 Material 数量有没有随着每次调色往上涨。
5. 进阶:把波形做成配置资产,并用验证清单收口
5.1 用 ScriptableObject 存一份波形成套配置
如果你要在同一个场景里挂很多条心电线,并且希望每条线的形状、速度、颜色能批量替换,可以把整套参数抽成一个 ScriptableObject:
using UnityEngine; [CreateAssetMenu(fileName = "EKGProfile", menuName = "Demo/EKGProfile")] public class EKGProfile : ScriptableObject { public AnimationCurve waveCurve; public float amplitude = 1f; public float speed = 0.5f; public Gradient colorGradient; }逻辑说明:这个资产把波形曲线、振幅、速度、渐变颜色收成一个文件。在 Project 窗口右键创建一份 EKGProfile,把默认曲线拖进去,然后让多个 EKGLineEffect 都引用同一个 Profile,改一份资产,所有线同步更新。这样比在十几个组件里逐个改参数靠谱得多。
参数说明:ScriptableObject 天然支持 AnimationCurve 和 Gradient 序列化,不用额外写类型。唯一要注意的是 Profile 里曲线首尾值必须都为 0,否则循环滚动时每圈交接处都会跳一下;如果做了完全不循环的设计,则不需要考虑这个限制。
5.2 验证“亲测好用”的清单
花两分钟按下面这张表过一遍,能确认这套效果是真的能用,而不是只在某一帧看起来对:
| 检查项 | 做法 | 通过标准 |
|---|---|---|
| 波形循环 | 连续播放 1 分钟,暂停观察首尾交接 | 交接处无跳变、无折痕 |
| 速度稳定 | 拖动窗口制造掉帧,观察滚动 | 节奏不随帧率忽快忽慢 |
| 振幅正确 | amplitude 设 2,量峰谷距离 | 大致等于曲线最大高度乘 2 |
| 颜色自定义 | 改渐变两端的色值 | 整条线平滑过渡,无一处突然变色 |
| 曲线可调 | 拖高 R 波再恢复 | 波形立刻跟随变化,无需重启 |
最后一招是在 Profiler 里看 Update 的耗时。512 顶点波形在普通台式机上通常不到 0.5ms,在低端移动设备上如果超过 2ms,就把 pointCount 降到 256,或者用第 3.4 节的 Shader 方案。以前做模拟项目X时,我为了几条线同步,各写各的计时器,结果屏幕上全是对不齐的波;后来统一用Time.time * speed驱动相位,这个坎就再没出现过。希望帮到你。
本文还有配套的精品资源,点击获取