
简介一款基于Unity引擎的心电图线波模拟效果demo支持波线速度、颜色、振幅的自定义调整并内置Q/W键交互触发效果变化适合Unity初中级开发者学习图形渲染、动画控制与用户输入处理。压缩包总量约39.19MB共6688个文件以C#脚本、meta配置、PNG贴图、Prefab预制体、Asset资源及Shader着色器为主另含少量场景文件与DLL库目录结构完整可直接打开工程运行调试。截至目前已有331人学习下载实测运行稳定。该demo涵盖Mesh/Renderer绘制线波、Time.deltaTime控制速率、Material颜色动态修改、Transform缩放调节振幅等关键实现并采用组件化脚本设计便于分离功能模块。通过阅读源码和场景搭建方式可快速掌握Unity中可视化波形效果的制作思路同时为UI反馈、参数面板联动及性能优化等扩展提供可参考的实践起点。1. Unity线波模拟心电图这个Demo让速度、振幅、颜色随时可调做医疗训练屏幕、设备状态面板或者游戏里的生命体征告警时碰到心电图波形几乎都会卡在同一个地方波形一眼假。纯正弦波放上去外行看着像心跳内行看着像玩具。这个Demo解决的就是这个问题——用Unity线波组件模拟真实心电图的上下振幅效果核心只有一条LineRenderer加几十行波形采样函数产出的是带P波、QRS波群、T波结构的心电信号而不是简单三角函数。速度控制波形滚动快慢振幅控制波形上下高度颜色通过Gradient实时调整三个参数全部暴露在Inspector里运行时改完立刻生效。适合想快速做出“像样”心电监护画面、又不想引入庞大插件的开发者。下面按波形生成、渲染参数、动态细节、排错、进阶验证五个部分完整展开照着复制代码就能跑。2. 波形合成是第一步P波、QRS波群、T波怎么用高斯窗拼出来2.1 为什么心电图不能用正弦波拼正弦波的问题在于没有时间结构。真实心电监护仪上一个完整心动周期是小圆顶的P波在前紧跟着一个极窄的高尖峰R波R波前后各有轻微下陷的Q波和S波最后是宽缓的T波。这套结构不是靠几个不同频率的正弦波叠加能模拟的因为正弦叠加得到的仍然是规则的振荡而心电图是“安静一段、突然一刺、再缓一下”的节奏。模拟场景下的目标也不是做医学诊断而是让训练屏、Demo画面、游戏里的监护仪能被一眼认出所以特征波的相对位置和形状比精确的幅值更重要。常见做法是直接合成波形样本用高斯窗函数做“鼓包”。高斯窗的好处是每个特征波独立控制中心位置、宽度和幅度代码量极小实时性好改参数时不会像频域合成那样牵一发动全身。下面这个信号生成函数就是这套组件的核心它把时间映射到一个0到1的归一化心跳周期内再叠加五个高斯窗分别对应P波、Q波、R波、S波、T波。using UnityEngine; public static class EcgSignal { public static float Sample(float time, float heartRate, float amplitude) { float beatInterval 60f / heartRate; // 单次心跳周期时长单位秒 float phase Mathf.Repeat(time, beatInterval) / beatInterval; // 归一化到 [0,1) float value 0f; // P 波小幅圆顶出现在周期前段 value Gaussian(phase, 0.12f, 0.045f) * 0.12f; // QRS 波群Q 波小凹陷、R 波主尖峰、S 波回落 value - Gaussian(phase, 0.30f, 0.012f) * 0.10f; value Gaussian(phase, 0.34f, 0.014f) * 1.00f; value - Gaussian(phase, 0.40f, 0.018f) * 0.22f; // T 波宽缓正向波出现在周期后半段 value Gaussian(phase, 0.64f, 0.060f) * 0.26f; // 减去常量基线让信号围绕 0 线振荡 value - 0.03f; return value * amplitude; } private static float Gaussian(float phase, float center, float width) { float d (phase - center) / width; return Mathf.Exp(-0.5f * d * d); } }这里最关键的参数是归一化相位。用相位而不是绝对时间的好处是当心率从72改成120时五个特征波在周期内的相对位置不变自动适应新的节拍不需要重新配参数。如果直接按秒写死特征波位置心率一变P波和R波的间隔就错了。高斯窗里的width是标准差语义数值越小波峰越窄R波要的就是这种“细针”效果。基线减0.03是为了让整个周期首尾都贴近0线滚动拼接时不会在周期边界处出现台阶。下表是五个特征波的默认参数调波形形状只需要改这张表的数字特征波中心相位宽度(σ)幅度作用P波0.120.0450.12周期开始的小圆顶Q波0.300.012-0.10尖峰前的下陷R波0.340.0141.00主尖峰最显眼的成分S波0.400.018-0.22尖峰后的下坠回落T波0.640.0600.26后半段的宽缓正波宽度缩小会同时影响这个波在屏幕上占的宽度和“尖锐感”。想做更“凶”的心跳把R波宽度从0.014降到0.010即可想做钝一点增大到0.020。幅度不是按0到1限制的EcgSignal.Sample返回的是浮点数乘以振幅参数振幅小于0.1时波形几乎贴着基线走超过10时R波会顶出屏幕具体设多少取决于相机视口高度这在后面参数说明里展开。2.2 采样点数、时间间隔和水平间距决定波形分辨率算出来的是一串浮点数值要变成线上需要把它映射到Vector3坐标。这里有两个独立的参数时间轴上每两个采样点的间隔和世界空间里每两个点的水平间距。它们共同决定一个完整心跳周期在屏幕上占多宽。横坐标i * spacing纵坐标就是采样值z固定为0。一个周期占屏宽度可以直接算周期时长除以时间间隔再乘以水平间距。int pointCount 512; float spacing 0.04f; // 相邻采样点水平距离世界单位 float timeStep 1f / 128f; // 相邻采样点时间间隔秒 Vector3[] points new Vector3[pointCount]; for (int i 0; i pointCount; i) { float t Time.time - (pointCount - 1 - i) * timeStep; points[i] new Vector3(i * spacing, EcgSignal.Sample(t, 72f, 5f), 0f); }以心率72为例单次心跳周期约0.833秒周期占屏宽度 0.833 / (1/128) * 0.04 ≈ 4.27世界单位。512个点总宽度是512 * 0.04 20.48世界单位屏幕上能同时看到约4.8个完整心跳跟真实监护仪同时显示2到5个心搏的量级一致。分辨率的关键约束是R波。R波宽度σ0.014对应时间约0.014 * 0.833 ≈ 11.7毫秒。如果时间间隔设为1/64秒即15.6毫秒一个R波尖峰只能采到两个点左右画出来会明显发钝更像正弦波。时间间隔设成1/128秒即7.8毫秒后R波能被采到四五个点尖峰才立得住。点数和时间间隔增大意味着每帧更新的顶点变多但512个点每帧重算一次开销很小CPU时间远低于这个组件渲染本身的消耗完全不用为性能担心。2.3 时间轴是固定窗口不是移动整条线常见误区是把心电图滚动做成“把整条LineRenderer往左移”这是错的。真机上的心电监护仪是纸带向右走屏幕上波形相对左移同时新的采样点从右侧持续进入。正确做法是维持LineRenderer的positionCount不变每帧只更新每个点的y值而时间坐标由统一的当前时间减去一个偏移量算出。用固定窗口思路看上面的循环就很清楚最右侧点i511的时间是t最接近当前时刻是“最新的点”最左侧点i0的时间是t - 511 * timeStep是最早的点。当前时刻推进后所有点的时间坐标同步前进波形自然向左滑动右侧持续出现新波形。这个设计让后续所有参数调整都不需要碰顶点结构只需要改采样函数和窗口参数。3. LineRenderer渲染与参数化完整组件代码与三组必调参数3.1 一个可直接挂载的组件心跳参数、渲染参数、更新循环把前面两部分合到一起就是一个完整可运行的组件。新建空物体挂上下面这个脚本再随意拖一个透明材质到lineMaterial运行即可看到心电图波形。这个组件把速度、振幅、颜色、采样密度全部暴露为公开字段Inspector里改完立刻生效不需要碰代码。using UnityEngine; public class EcgLineController : MonoBehaviour { [Header(波形参数)] [Tooltip(心率每分钟心跳次数)] public float heartRate 72f; [Tooltip(振幅R波峰值高度世界单位)] public float amplitude 5f; [Tooltip(滚动速度倍率1为原始速度)] public float speed 1f; [Header(采样参数)] public float spacing 0.04f; public float timeStep 1f / 128f; public int pointCount 512; [Header(渲染参数)] public Gradient lineColor; public AnimationCurve lineWidth AnimationCurve.Constant(0f, 1f, 0.06f); public Material lineMaterial; private LineRenderer _line; private Vector3[] _points; private float _time; private void Awake() { _line GetComponentLineRenderer(); if (_line null) { _line gameObject.AddComponentLineRenderer(); } if (lineMaterial ! null) { _line.material lineMaterial; } if (lineColor null || lineColor.colorKeys.Length 0) { lineColor BuildDefaultGradient(); } _line.useWorldSpace false; _line.positionCount pointCount; _line.colorGradient lineColor; _line.widthCurve lineWidth; _line.alignment LineAlignment.View; _line.numCapVertices 4; _line.shadowCastingMode UnityEngine.Rendering.ShadowCastingMode.Off; _line.receiveShadows false; _points new Vector3[pointCount]; } private void LateUpdate() { _time Time.deltaTime * speed; for (int i 0; i pointCount; i) { float t _time - (pointCount - 1 - i) * timeStep; float y EcgSignal.Sample(t, heartRate, amplitude); _points[i] new Vector3(i * spacing, y, 0f); } _line.SetPositions(_points); } private static Gradient BuildDefaultGradient() { Gradient g new Gradient(); g.SetKeys( new[] { new GradientColorKey(Color.cyan, 0f), new GradientColorKey(new Color(0.2f, 1f, 0.6f), 1f) }, new[] { new GradientAlphaKey(0f, 0f), new GradientAlphaKey(1f, 0.08f), new GradientAlphaKey(1f, 0.92f), new GradientAlphaKey(1f, 1f) } ); return g; } }这里用LateUpdate而不是Update是为了让波形刷新发生在所有逻辑更新之后。如果场景里有其他脚本在Update里移动这个挂载物体的父节点或者相机在做跟随LateUpdate能保证波形采样的时间轴和其他系统状态在同一帧内是对齐的避免出现波形位置比画面晚一帧的抽动感。速度不是直接乘到坐标上而是乘到时间推进上这样speed0时波形静止speed2时滚动速度翻倍波形形状本身不变化。3.2 三个核心参数速度、振幅、颜色怎么设才算合理速度参数speed控制的是时间推进倍率不是波形水平方向的拉伸。speed1时屏幕上波形滚动速度由spacing和timeStep共同决定水平滚动的视觉速度 spacing / timeStep即0.04 / (1/128) ≈ 5.12世界单位每秒。想跑得快就加大speed想横向拉伸波形、让一个周期占更宽的位置则要调大timeStep或spacing而不是调speed。振幅参数amplitude是线性缩放波形输出。以默认R波幅度1.0计算amplitude5时R波峰值5世界单位。Orthographic相机高度如果是10R波正好顶到屏幕一半高度这是视觉上最舒适的范围。超过15世界单位R波就可能裁出屏幕低于0.5波形几乎贴在基线上。振幅不需要做归一化因为它就是世界单位直接按场景尺寸选。颜色参数lineColor用的是Gradient不是简单的Color字段。Gradient可以做到波形尾部透明、头部亮绿、中段带青色这种渐变正是心电监护仪“荧光屏”质感的来源。默认值里我把左边旧数据设成透明新波形从右侧出现时是全亮看起来就像波形沿着屏幕流动。如果只想要单色把Gradient所有颜色键设为同色即可alpha键也要全部设为1否则会出现不知道哪头淡出的情况。3.3 材质与线宽让波形有“监护仪质感”而不是一条塑料线LineRenderer对材质非常敏感。默认的Default-Line材质本身支持顶点色和透明度能用但视觉效果发灰。更可控的做法是新建一个材质Shader选择当前渲染管线的透明Unlit或URP下带Surface TypeTransparent的Unlit材质。必须确保材质支持顶点色并且开启透明混合否则Gradient里的alpha全部失效波形会变成一整条实心线。线宽通过AnimationCurve控制。心电图的线一般恒定宽度所以用AnimationCurve.Constant(0,1,0.06)让整条线等宽。numCapVertices设为4或8能让线头变圆避免尖端显得生硬。线条宽度要注意按世界单位评估相机离得远时0.06可能细到看不见离得近时可能粗得像画笔调的时候把相机距离也考虑进去。还有一个细节LineRenderer的textureMode默认Stretch即可如果设成Tile又没给合适的纹理波形上会出现诡异的重复图案。4. 动态细节心率平滑、基线漂移与场景坐标适配4.1 心率突变会让波形“瞬移”平滑才是关键运行时直接把heartRate从72改成120你会发现波形不是慢慢变快而是突然左右跳一下然后才继续滚。原因是beatInterval变了而采样函数的phase (t mod beatInterval) / beatInterval依赖这个周期值周期一变当前时间点在归一化周期里的位置就变了相当于所有特征波瞬间平移。真实监护仪里心率变化是渐进的所以要给heartRate做指数平滑。private float _smoothHeartRate; private void Update() { _smoothHeartRate Mathf.Lerp( _smoothHeartRate, heartRate, 1f - Mathf.Exp(-4f * Time.deltaTime) ); }把_smoothHeartRate传给EcgSignal.Sample替代直接使用heartRate。指数平滑的系数决定过渡快慢4f这个值大约在0.5秒内完成大部分过渡适合模拟正常的心率变化。想让心率突变做出“抢救”效果把系数改成10f甚至更高波形会在一两帧内追上目标心率。注意Update和LateUpdate的执行顺序平滑计算放在Update采样放在LateUpdate这样同一帧内用的是已经更新过的心率值不会差一帧。4.2 基线漂移呼吸带来的上下浮动真实心电图的基线不是一条绝对直线呼吸、肢体动作、导联松动都让波形整体上下浮动。纯数学模拟时加一个低频正弦就能打破“太干净”的假象。把基线漂移叠加到EcgSignal的返回值上频率设为每分钟12到18次左右幅度不超过振幅的6%到10%。float baseline Mathf.Sin(2f * Mathf.PI * 0.22f * time) * amplitude * 0.06f; return value * amplitude baseline;这段代码放在EcgSignal.Sample里value * amplitude之后叠加基线。漂移频率0.22Hz对应约每分钟13次接近静息呼吸频率。幅度取振幅的6%amplitude5时漂移只有0.3世界单位肉眼能看到波形整体上下轻微浮动但不会压过R波的主体形态。基线漂移会让心跳周期在屏幕上的位置也缓慢变化时间轴和相位逻辑不受影响因为它是加在波形输出端而不是加在时间参数上。想要更强的“导联松动”效果可以再叠加一个低频随机游走但模拟场景通常不需要。4.3 坐标空间与相机适配正交相机还是透视相机LineRenderer默认useWorldSpacefalse点坐标是局部空间坐标这样挂载物体的父节点可以整体平移、旋转、缩放波形会跟着设备模型一起动。比如要把波形贴到某个监护仪模型的屏幕上直接把组件挂到屏幕子物体上调好位置和旋转即可。波形在局部空间内始终朝向自身坐标系的XY平面用alignmentView能让线的宽度始终面向相机避免转角处线变成一条细缝。相机选择直接影响观感。透视相机下波形离相机越远线越细、振幅看起来越小适合波形作为3D场景里某个设备屏幕的一部分。如果是全屏心电图背景、数据可视化面板这类用途正交相机更合适波形大小不随距离变化参数调整更直观。正交相机高度设成比振幅范围略大比如振幅5、基线漂移0.3相机高度设12到14左右波形占屏幕高度的比例就比较舒服。波形所在平面的z值也要注意如果有其他UI或模型在同一平面附近可能出现穿插遮挡常见做法是把波形放在独立的一个层级或者略微调整z值让它在其他内容前面。5. 避坑指南LineRenderer心电图最常见的5个翻车现场5.1 波形不滚动而是整根线忽左忽右现象波形看起来在左右平移但屏幕右端没有持续冒出新的波峰线还会断续像被拽着走。原因把滚动做成了“移动整条LineRenderer”或“不断改变positionCount”。移动整根线时所有顶点同步位移屏幕边缘看不到新数据出现重复修改positionCount会触发顶点缓冲重建波形就一抖一抖的。解决固定positionCount只改每个点的y值和对应时间坐标绝不在运行期增删顶点。用前面组件里的方式每个点的t由统一的_time随时间推进计算波形自然左移右端持续出现新波形。5.2 R波消失波形退化成正弦波现象采样点数足够但R波尖峰就是立不起来整个波形像被磨平了。原因时间间隔timeStep比R波特征宽度还大。R波σ0.014周期心率72时对应约12毫秒如果用1/64秒采样一个R波只采到两个点线插值后尖峰被拉平。解决把timeStep缩到1/128秒或更小。采样间隔应小于R波特征宽度的一半一般按1毫秒到8毫秒之间选。注意缩小时如果pointCount不变屏幕总宽度会减小需要同步调整spacing或pointCount补偿。5.3 设置了透明渐变线头还是实心的现象Gradient里alpha在0处设为0但波形左端完全不透明甚至整条线变成一个色块。原因材质不支持透明或没有开启Blend。Unlit/Color这类不透明材质会直接丢掉顶点色里的alpha信息Gradient配置再对也没用。解决确认材质Shader是透明类型且renderQueue在Transparent区间。URP下要新建Unlit或Shader Graph材质并把Surface Type设为Transparent。验证方式是把材质color临时改成半透明红色如果线变透明说明Blend生效再检查顶点色的alpha通道。5.4 波形断开、闪烁或者出现粉红色方块现象运行过程中线突然断掉或模型变成洋红色或线的某些段消失只剩碎片。原因洋红色是材质丢失常见于用了Shader.Find(Unlit/Transparent)但当前渲染管线里没有这个Shader。断裂闪烁则多半是数组里出现了NaN或Infinity高斯函数参数异常、phase计算出错都会产生。解决材质不要依赖内置旧Shader显式指定项目可用的透明材质。采样侧用Mathf.Repeat保证phase落在[0,1)并给振幅加个非零下限。如果波形闪断先Debug.Log采样值看有没有异常浮点数再往下查。5.5 想放到UI界面LineRenderer却不显示现象把组件挂到Canvas下的子物体上波形完全看不见。原因LineRenderer是世界空间渲染组件不走UGUI的网格管线Canvas里看不到是正常的不是代码问题。解决两个可靠路线。一是用RenderTexture单独用一个正交相机只渲染波形层输出到RenderTexture再把Texture贴到RawImage上二是自绘UI网格用UIVertex生成多段线条这个方案性能更好但代码量明显更大。模拟训练屏这类场景RenderTexture虽然多一次相机渲染开销但实现快波形所有参数调整逻辑都不用动。两个方案都不需要在Canvas里挂LineRenderer。6. 进阶与验证数据源注入、参数核对和一句经验把EcgSignal.Sample换成AnimationCurve模板是这个组件最实用的升级。用AnimationCurve在Inspector里画一个心动周期的波形轮廓采样时直接用curve.Evaluate(phase)就可以不碰代码让美术或策划调整波形形状。代码替换量很小核心只改一行float y _template.Evaluate(phase) * amplitude;这个用法适合团队协作也适合接入真实心电数据把导出的心电样本按周期归一化后灌进AnimationCurve就能用真实数据驱动显示只是注意数据本身的采样率和噪声导入前要做平滑处理。验证参数是否正确的办法很简单把speed设为0波形静止时数一数屏幕上完整心跳的个数对照心率、timeStep、spacing三者的换算关系。再写一个R波相位检测每次phase穿过0.34附近时打印间隔时间和60除以heartRate对比误差应在5%以内。颜色和振幅的验证则靠肉眼对照参考图重点是先确认时间轴正确再动视觉参数——我做过一次先把颜色调成荧光绿、结果波形根本没滚动的调试屏幕上一片刺眼绿光排查半天才发现是时间轴没推进。这套组件里先固定采样参数和时间轴再调颜色和线宽能省下大部分冤枉时间。希望帮到你。本文还有配套的精品资源点击获取