资讯动态

Unity 心电图线波效果实现:PQRST 波形、滚动速度与参数化调色

发布时间:2026/10/21 6:44:24 来源:尧图企业网站定制
简介这是一份基于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 越小峰旁边衰减越快波形越尖。这套参数的常用初始值可以照抄波峰amplitudecenterwidthP 波0.250.160.03Q 波-0.080.280.012R 波1.000.320.015S 波-0.150.360.012T 波0.350.550.055U 波0.050.700.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. 搭一套可调参的心电图线波 DemoLineRenderer 最小实现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 GetComponentLineRenderer(); 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 { QueueTransparent RenderTypeTransparent } 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 存一份波形成套配置如果你要在同一个场景里挂很多条心电线并且希望每条线的形状、速度、颜色能批量替换可以把整套参数抽成一个 ScriptableObjectusing 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驱动相位这个坎就再没出现过。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑