
简介面向需要在Unity项目中快速实现心电图线波动效的开发者这是一份可直接运行的Demo工程集成了波形生成、上下振幅变化与交互控制逻辑。工程以C#脚本驱动Mesh渲染波线速度、颜色和振幅均可在Inspector中实时调整并支持通过按键切换效果适合游戏UI特效、医疗风格模拟或学习Graphic渲染与组件化编程时参考。压缩包共6688个文件包含1066个C#脚本、575张PNG贴图、31个PSD源文件、5个Prefab预制体及多个Unity场景、材质与Shader资源整体约39.19MB目录结构完整打开即可查看核心实现。目前已有331人学习下载。对想研究线波动画、输入响应和材质颜色动态控制的Unity学习者来说这份Demo能省去从零搭建的步骤直接对照源码理解参数调整与效果联动方式。1. 会跳动的线波这个Unity心电图demo解决的是“怎么让曲线拥有生命感”拿到一个Unity的心电图线波demo第一反应可能是“不就是一条上下跳的线吗”。但我拆完这个资源后发现它的价值不在“画一条正弦波”而在把波线速度、颜色、振幅全部暴露成可调参数让一条静止的曲线变成可以配合游戏节奏、UI动效、数据可视化场景“呼吸”的动态线波。它适合两类人一类是需要在Unity里快速呈现生命体征效果的游戏或医疗模拟开发者另一类是想搞清楚LineRenderer到底能做到什么程度、怎么控制顶点采样节奏的入门者。接下来的内容我会按“原理—参数—复现—避坑—进阶”的顺序把这个demo彻底拆开。2. 原理先行LineRenderer加参数模型为什么这是心电图模拟最稳的组合2.1 为什么是LineRenderer而不是Shader或UI Image做心电图线波有几种常见技术路线用Shader画曲线、用UI.Image逐帧修改、用LineRenderer逐帧更新顶点。多数人一开始会想到Shader但实际落地时Shader方案对包体和平台兼容性的要求高而且“动态调整振幅”这种需求意味着每个顶点位置都要重新计算Shader里写Uniform数组并不比在CPU侧更新LineRenderer顶点更省事。这个demo选择LineRenderer核心原因是它在“运行时可修改性”和“调试成本”之间取得了最好的平衡。LineRenderer是Unity内置组件支持世界空间坐标、支持渐变色、支持线宽随距离变化而且每一帧更新positions数组的代价在顶点数不超过几百个时完全可以忽略。UI.Image方案我也试过问题在于它自带RectTransform的Canvas坐标系统想模拟心电图的上下大幅跳动要么把Canvas的缩放系数算得很别扭要么就要处理屏幕坐标和世界坐标的换算。LineRenderer直接在世界空间工作相机一摆曲线按世界坐标的真实尺寸跳动比例直觉非常清晰。从代码维护角度LineRenderer的positions数组本质上就是一个采样之后的坐标集合这意味着“心电图波形”可以完全和渲染解耦我先算好每个时间点的Y值再塞给LineRenderer逻辑和显示各管各。如果你后续想把波形输出成数据文件或者换成别的渲染组件改动的边界都很清晰。2.2 参数模型拆解速度、颜色、振幅到底控制了哪一段计算demo标题里提到三个可调项波线速度、颜色、振幅。这三个参数在代码中对应的是完全不同层面的东西理解它们的差异才能不乱调。波线速度通俗讲就是“整条曲线向左滚动的速率”。心电图不是一整条线同时上下跳动而是新的波形从右边生成旧的波形向左退去。实现上通常有两种做法一是移动采样时间相位让每个顶点的Y值随时间变化二是保持顶点X坐标不变但让数据数组整体偏移。这个demo用的是第一种因为第一种能精确控制波形滚动速度而不破坏LineRenderer的顶点生命周期。振幅指的是“波形上下摆动的幅度范围”。常规正弦波的振幅是固定的但心电图的QRS波群幅度明显高于P波和T波。所以要实现真实的心电图效果振幅不能是一个全局常量而应该是一个“随时间变化的包络”。代码里如果简单地把振幅做成一个float那出来的就是数学课本上的正弦波不是心电图。颜色参数则既包含单条线波的渐变色也包含整条线波在滚动过程中的颜色变化。LineRenderer本身支持Gradient属性可以定义从波形头部到尾部、从波峰到波谷的色带。这个demo把颜色暴露成可调属性方便你把它从默认的绿色医疗风改成游戏里的霓虹风或者UI里的主题色。速度、颜色、振幅这三个参数虽然都叫“可自定义”但它们作用的代码位置完全不同速度控制采样计算的时间增量振幅控制Y坐标的映射范围颜色控制材质和Gradient。调参时要清楚自己是在改数学波形还是在改渲染表现。3. 动手复现心电曲线核心脚本与参数对照这里可以直接抄作业3.1 核心脚本从采样点到渲染一条自适应的线波我先把这套方案里最核心的控制器脚本拆出来。这个脚本的作用是管理LineRenderer的顶点生命周期并按“实时采样”的方式更新波形。using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class ECGLineWaveController : MonoBehaviour { [Header(波形参数)] public float amplitude 2.0f; // 整体振幅单位世界单位 public float waveSpeed 1.0f; // 波形左移速度 public float cycleLength 10.0f; // 一屏可见多少个波峰 public int vertexCount 200; // 顶点数量数值越高线越平滑 [Header(渲染参数)] public Gradient lineGradient; // 线波渐变颜色 public float lineWidth 0.08f; // 线宽世界单位 [Header(心电图特征)] public AnimationCurve beatEnvelope; // 控制QRS波群等特征幅度的包络 public float beatScale 1.5f; // 包络缩放系数 private LineRenderer lineRenderer; private float timeOffset; void Start() { lineRenderer GetComponentLineRenderer(); } void Update() { timeOffset Time.deltaTime * waveSpeed; Vector3[] positions new Vector3[vertexCount]; float startX -cycleLength * 0.5f; float endX cycleLength * 0.5f; for (int i 0; i vertexCount; i) { float t (float)i / (vertexCount - 1); float x Mathf.Lerp(startX, endX, t); float phase x timeOffset; // 基础正弦波 float baseWave Mathf.Sin(phase * Mathf.PI * 2f); // 包络调制模拟QRS波群的尖锐高幅度 float envelope beatEnvelope.Evaluate((phase % 1f 1f) % 1f); float y baseWave * amplitude * (0.4f envelope * beatScale); positions[i] new Vector3(x, y, 0f); } lineRenderer.positionCount vertexCount; lineRenderer.SetPositions(positions); lineRenderer.colorGradient lineGradient; lineRenderer.startWidth lineWidth; lineRenderer.endWidth lineWidth; } }这段代码的逻辑分三层。第一层是时间推进timeOffset每帧增加Time.deltaTime * waveSpeedwaveSpeed越大波形滚动越快。第二层是采样计算遍历vertexCount个顶点以x timeOffset作为相位输入先算一个标准正弦波再用beatEnvelope这个AnimationCurve去调制局部振幅模拟心电图的QRS波群特征。第三层是数据提交把计算好的顶点数组一次性塞给LineRenderer。参数说明上amplitude是全局振幅上限会决定波形最高点到最低点的距离cycleLength决定多少个波峰显示在屏幕宽度内值越小波形越密vertexCount直接控制平滑度数值太低会出现折线感太高则增加每帧计算量。beatEnvelope是关键它把一个周期的形态信息以曲线形式存下来评估时只要取相位的小数部分就能让每次心跳的形态保持一致。这里用了(phase % 1f 1f) % 1f是为了防止负数取模时出现问题保证包络always落在0到1之间。3.2 集成测试另一处到参数调参与效果对照表抄完脚本还有一处需要配置挂脚本的物体必须是LineRenderer并配好材质。材质很关键Sprites/Default是常用的选择因为它不受光照影响颜色直出适合UI化和医疗模拟。如果用标准Shader很容易出现波形颜色被场景灯光搞脏的情况。在实际调参时我习惯按下面的对照表来找感觉目标效果调整参数推荐初始值调参方向波形滚动变快waveSpeed1.0增大到3.0曲线会明显急促波形上下幅度变大amplitude2.0增大到4.0注意是否超出屏幕波形更密集cycleLength10.0减小到5.0屏幕内出现更多波峰波形更平滑vertexCount200提升到512注意移动端性能突出QRS波群beatScale1.5增大到2.5波峰变得尖锐高耸变成冷色调lineGradient绿色渐变改为青色到蓝色的渐变这里最容易出错的地方是你改了amplitude但感觉波形高度没变化。原因多半是beatEnvelope的曲线幅度本身很小或者beatScale默认值过低。要确认振幅真实生效先把beatEnvelope临时设成横平竖直的常量曲线排除包络干扰再看amplitude的直接影响。我一般会先在Inspector里把beatEnvelope的曲线拉成中间值1.0的直线等整体振幅调到理想范围后再恢复成心跳包络。3.3 别把正弦波当心电图谐波叠加与包络设计的两个关键细节直接跑上面这段脚本出来的是一条平滑的正弦波并不像心电图。要让曲线产生“心跳感”要靠beatEnvelope做形态整形而不是依赖基础正弦波本身。beatEnvelope可以设计为在一个周期内的开始阶段有短暂而陡峭的高峰模拟QRS波群之后跟随一个平缓的起伏模拟T波中间有一段接近直线的静默期。在AnimationCurve里创建这个包络时我推荐至少打五个关键帧0时刻从低点快速抬升0.1时刻到达峰值0.15时刻迅速回落0.4时刻缓慢隆起形成T波0.6时刻回到平线并持续到周期结束。注意曲线不要用线性插值要把关键帧的切线调成急升缓降否则看起来还是温和的波浪而不是心电图的利落脉冲。另外还有一个细节基础波形不要只用一个正弦波。真实心电信号中含有明显的谐波成分QRS波群本质上是一次快速去极化频率远比P波和T波高。常见做法是叠加一个三倍频或五倍频的小幅高频项用来增加波形上的“毛刺感”。在代码里就是加上Mathf.Sin(phase * Mathf.PI * 6f) * 0.08f这样的项幅度控制在0.05到0.1之间太小没效果太大则波形变得粗糙杂乱。4. 场景搭建与运行时调试从空场景到完整心电图效果的步骤拆解4.1 导入资源、创建物体、挂载脚本的标准流程拿到demo资源包后先不要急着打开场景文件我习惯自己搭一个干净场景来验证核心脚本是否完好。新建空场景后在Hierarchy里创建空物体命名为“ECGLine”。给这个物体挂上LineRenderer和刚才的ECGLineWaveController脚本。挂好之后要手动设置LineRenderer的材质。在LineRenderer组件的Material区域点击右侧圆圈选择Sprites/Default。接着在ECGLineWaveController的Inspector面板里把一个新建的AnimationCurve资产拖到beatEnvelope槽位。如果demo包里有现成的曲线直接用现成的没有就按上一节描述的五个关键帧创建。在cycleLength、amplitude、vertexCount上先填入推荐默认值cycleLength设10amplitude设2vertexCount设200waveSpeed设1。此时切换到Game视图运行你应该能看到一条上下波动的绿色曲线。看不到的话检查相机是否对准原点附近的波形以及LineRenderer的材质是否被场景光照影响。4.2 世界坐标、相机视角与屏幕比例的换算关系LineRenderer工作在世界空间所以波形显示多大、是否超出屏幕完全取决于相机参数和物体位置而不是某个“UI缩放滑块”。最常见的翻车现场是波形振幅设了2但相机正交尺寸只有3结果波峰直接飞出屏幕顶。方法其实很简单——先用正交相机。把相机设为Orthographic将Size设为amplitude * 3左右。举个例子amplitude为2包络峰值还会往上冲一点所以Size至少给6到7这样波形上下跳动都有余量。如果你的场景已经用了透视相机那就要保证波形所在的Z轴平面与相机距离合适并把amplitude调小到0.5级别否则透视畸变会让曲线看起来深浅不一。另一个容易被忽略的地方是物体位置。LineRenderer默认在原点创建直角坐标体系下Y方向才是上下。确保物体Rotation是零不然波形整体倾斜。还有如果你的相机带旋转角度波形会在屏幕上是斜着滚动的这种效果在医疗模拟里是不允许的在游戏UI里则可以当作风格化手段。4.3 运行时实时调整参数的两种路径这个demo的价值在于“可自定义”所以运行时调参是必须顺畅的。第一种路径最简单在Inspector中直接拖动参数。Unity在运行状态下修改Inspector里的值会实时生效因为Update里每帧都在重新读取参数。你甚至不用停掉Play就能看到波形速度、振幅、颜色同步变化。第二种路径是写一个简单的调试面板通过键盘或屏幕按钮调整参数。常见做法是把参数改成ScriptableObject或者在Update里监听输入。下面这段代码可以加在Update里实现按键调参void Update() { // 方向键调整振幅每帧步进0.2 if (Input.GetKey(KeyCode.UpArrow)) amplitude 0.2f; if (Input.GetKey(KeyCode.DownArrow)) amplitude Mathf.Max(0.2f, amplitude - 0.2f); // 左右方向键调整波形速度 if (Input.GetKey(KeyCode.RightArrow)) waveSpeed 0.1f; if (Input.GetKey(KeyCode.LeftArrow)) waveSpeed Mathf.Max(0.1f, waveSpeed - 0.1f); if (lineRenderer ! null) { lineRenderer.colorGradient lineGradient; lineRenderer.startWidth lineWidth; lineRenderer.endWidth lineWidth; } }加这段代码的意义是你不需要在Play模式下反复切换到Inspector而是用键盘就能快速试出一组适合自己项目的参数然后停掉运行把最终值填回Inspector。Mathf.Max用于限制振幅和速度不能小于下限否则波形会静止或反转这是调参时容易忽略的边界保护。5. 避坑手册心电图线波最常见的五个翻车点5.1 波形发虚、边缘马赛克感严重现象波形确实在跳动但边缘有明显锯齿线条发虚整体不够锐利。原因LineRenderer的顶点数量过低是最直接的来源vertexCount只有50时曲线就是折线段。另一个原因是相机的Post-processing或MSAA没有开启抗锯齿或者使用的Shader不支持抗锯齿。解决先把vertexCount提升到256以上观察是否改善。如果波形还是虚在Quality设置里把Anti Aliasing调到4x或8x并确认用的材质是Sprites/Default。这个Shader对透明边缘的支持比较干净换成别的Shader反而容易糊。5.2 波形动不动就飞出屏幕怎么调都压不住现象振幅稍微设大一点波峰就跑出屏幕整个效果没法看。原因orthographic相机size没有与波形振幅联动或者包络峰值比基础振幅高导致瞬时Y值超出可见范围。解决先临时把beatEnvelope的所有关键帧Y值都设为1看基础振幅对应的显示范围。然后再把相机size设为amplitude * 3.5给包络的峰值留出余量。如果是在Canvas里作为UI元素使用建议把LineRenderer放到Canvas下并把RectTransform的宽度和高度设置为屏幕安全区域的90%让波形在世界坐标中始终在一个可控的框内滚动。5.3 颜色怎么调都是半透明或发白现象设置了一个鲜艳的红色渐变结果显示出来的颜色像蒙了一层灰透明度明显不对。原因LineRenderer的材质通道和颜色空间设置出了问题。Sprites/Default默认支持顶点色但在某些渲染管线或颜色空间Gamma与Linear混用下渐变颜色的亮度会被压缩。解决检查Project Settings里的Color Space如果用的是Linear需要把渐变关键帧的颜色亮度提高20%左右视觉上才能跟Gamma空间一致。更省事的方案是换成UI/Default材质并把LineRenderer放到带Canvas的物体下这样可以绕开大部分颜色空间问题。5.4 手机上运行时明显卡顿掉帧严重现象编辑器里波线流畅打包到手机上测试时掉帧尤其是波形滚动持续几秒之后。原因每帧重新分配Vector3[]是最大的元凶。代码里每次Update都new一个数组车载内存分配会增加GC压力手机上GC触发时就会卡一下。解决把顶点数组的分配移到Start或构造函数里作为成员变量复用。Update里只更新数组内容不重新分配长度。再配合vertexCount压缩到128或160移动端的性能压力会小很多。如果还想进一步优化可以把波形的滚动改成每两帧更新一次肉眼几乎看不出差别但CPU时间直接减半。5.5 暂停或断点后波形位置突变现象在编辑器里暂停再继续运行波形瞬间跳到另一个相位看起来像“瞬移”。原因timeOffset基于Time.deltaTime累积Unity暂停时deltaTime会归零但恢复运行后第一帧的deltaTime可能很大导致相位跳变。用Time.time作为相位基准就不会出现这个问题。解决把timeOffset的计算从 Time.deltaTime * waveSpeed改成直接读取全局时间线。最简单的方式是设置一个起始时间记录然后用(Time.time - startTime) * waveSpeed作为相位偏移。这样暂停和恢复后相位依然连续。用Time.unscaledDeltaTime也能解决但如果你在游戏里做了时间缩放还是建议用Time.time的方式。6. 进阶把单条线波变成多通道监护仪波形并接入真实心拍数据6.1 多通道布局与独立参数管理单条心电曲线可以用的地方很多但把ECGLineWaveController直接复制三份会出现所有波形共用全局timeOffset的问题。想要做出多通道监护仪的效果需要给每个实例维护独立的参数但又共享同一套渲染逻辑。我一般会再包一层管理脚本持有三个子物体每个子物体挂独立的控制器控制器之间振幅、速度、颜色都解耦。多通道布局要关注的是通道间的垂直间距如果三路的振幅都为2间距设为5波形就不会重叠。你还想让几个通道有不同的相位差实现“导联错位”的效果只需要给每个控制器的初始timeOffset加上一个偏移量即可。这个偏移量在Start里赋值一次不参与Update累积。6.2 用AnimationCurve存储真实心拍形态替代数学近似数学波形无论怎么叠加谐波都很难完全复现真实心电图的S波和ST段形态。进阶做法是找一组真实的心拍采样点把它烘焙进AnimationCurve。做法是取一个完整心跳周期的几十个R-R间隔数据每个采样点对应一个关键帧然后用beatEnvelope.Evaluate(phase)直接驱动波形。实测下来真实采样点生成的包络曲线比手拉关键帧更像心电监护仪因为真实的Q波会有一个小凹坑T波也不是对称的圆顶。如果你接手的数据不是等间隔的需要先做线性插值重采样再导入AnimationCurve。做完这一步之后把waveSpeed和振幅调成你需要的节奏效果立刻提升一个档次。从那以后我每次做心电图动效都会强制把数学波形切换到数据驱动验证一遍形态差异非常明显希望帮到你。本文还有配套的精品资源点击获取