Unity心电图波形效果实战:LineRenderer动态绘制与参数化控制
2026/9/9 14:48:28 网站建设 项目流程

简介:这款Unity线波心电图模拟项目demo,面向游戏开发者、互动体验设计者及Unity学习人群,旨在快速实现可自定义的上下振幅心电图效果。项目基于C#脚本控制,支持波线速度、颜色与振幅的灵活调整,并通过Q/W键实时切换显示效果,经过实测稳定可用。压缩包共6688个文件,约39.19MB,内含1066个cs脚本、575个png图片、5个prefab预制体、4个unity场景及多个asset、mat等资源,包含完整Unity工程配置,可直接打开参考。目前已有330人学习下载。反复调试该项目,不仅能掌握Mesh动态绘图、Input事件监听、Time.deltaTime时间控制等核心技巧,还能理解Color颜色赋值、Transform缩放实现振幅变化、组件化脚本分离逻辑等设计思路,适合作为Unity图形交互小项目的实战案例。 做东西的时候突然想清楚了一个道理:波形效果这类东西,真正值钱的不是“知道怎么画一条线”,而是“知道这条线怎么动、怎么调才好看”。我把Unity里做心电图波形这个demo折腾了一遍,波线速度、颜色、振幅都能调,效果亲测可用,本篇就把完整的实现思路、代码和踩过的坑都放出来,给需要做医疗模拟、生命体征看板或者纯粹想要一个动态线波背景的朋友做个参考。

这个demo本身不复杂,核心就三件事:用LineRenderer画一条可动态刷新的折线、用数学函数生成心电波形数据、再把这些数据通过Inspector参数暴露出来实时调整。但实际做的时候,会有不少细节藏在暗处:坐标转换、材质光照、UI适配、波形函数的连续性问题,任何一个没处理好,效果都会露馅。

1. 整体设计与选型:为什么是LineRenderer而不是UI组件

1.1 用LineRenderer的理由和边界

Unity里画线的方式其实不少,UGUI的MaskableGraphic、Tilemap、Sprite都有人用来做波形效果,但真要实时更新、参数化控制、跨分辨率表现稳定,LineRenderer依然是性价比最高的选择。它不需要生成纹理,不需要管理图集,直接给一串世界坐标点就能画,天然支持宽度渐变、颜色渐变、材质替换,而且C#侧只需修改一个Vector3数组就能驱动它逐帧刷新。

但这个选择有个前提:LineRenderer属于3D渲染管线,它在Screen Space - Overlay模式下是渲染不出来的。也就是说,如果你想把波形直接塞进UGUI的Canvas里当UI元素用,默认情况下你会看到什么也没有。这一点我在第4节会细说。本项目demo默认放在真实空间坐标下,配合一个朝向合适的相机即可表现良好,这也是Unity世界坐标方案最省心的原因。

1.2 波形数据从哪来:心电图形状的数学近似

模拟心电图,第一反应可能是用正弦波。正弦波拿来模拟呼吸或脑电波还行,但心电图的特征PQRST波群(P波、QRS波群、T波)是有明显节奏的:一个小圆顶,一个尖锐高耸的R峰,前后各带一个凹陷的小负波,再来一个宽大的T波。想用单一正弦函数模拟这个,基本不可能。

我用高斯函数做叠加,效果非常接近。高斯函数的形式是:

f(t) = A * exp(-((t - t0)^2) / (2 * sigma^2))

A控制峰值高度,t0控制出现在哪个位置,sigma控制波峰宽度。把几个不同参数的高斯函数按心电图的时序拼起来,就能得到类似PQRST的复合波形。这个方法有两个好处:一是数学简单,性能开销可忽略;二是每个波峰的高度、宽度、位置都能独立调参,方便后面做“振幅自定义”的扩展。

1.3 参数化思路:速度、颜色、振幅怎么设计

标题里的三个核心要求——波线速度、颜色、振幅可自定义,本质上是把波形数据和渲染参数分离。速度不能直接改“线的移动速度”,因为我们画的是静态位置的点,动态效果来自相位偏移;颜色则通过LineRenderer的Gradient或StartColor/EndColor控制;振幅直接乘在波形函数输出上。把它们都暴露成public字段后,Inspector面板随时可调,运行时改完立即生效,这样才是“亲测好用”的基本标准。

2. 波形生成核心代码:从原理到落地

2.1 波形函数:心电图PQRST的近似实现

先放核心的波形函数。输入是一个归一化时间t(0到1之间),代表一个完整心跳周期的采样位置,输出是对应位置的波形高度。这个函数直接决定波形长什么样,后面的振幅调整都得经过它。

using UnityEngine; public static class ECGMath { // 高斯波峰:area控制峰值高度,center控制位置,sigma控制宽度 private static float Gaussian(float t, float center, float sigma, float area) { float d = t - center; return area * Mathf.Exp(-(d * d) / (2f * sigma * sigma)); } // 归一化的心电图近似波形,输入t为0~1 public static float Eval(float t) { // P波:较小的缓和隆起,出现在前段 float p = Gaussian(t, 0.18f, 0.022f, 0.16f); // Q波:R峰前的一小段下陷 float q = Gaussian(t, 0.29f, 0.0045f, -0.12f); // R波:尖锐主峰 float r = Gaussian(t, 0.32f, 0.006f, 1.0f); // S波:R峰后的下陷 float s = Gaussian(t, 0.35f, 0.005f, -0.20f); // T波:较平缓的中等隆起 float tWave = Gaussian(t, 0.56f, 0.035f, 0.32f); return p + q + r + s + tWave; } }

这里每个参数的取值都是看波形图微调过的,R峰位置在0.32处,保证视觉上有一个明显的高耸尖峰。因为高斯函数在t离center远时迅速衰减到接近0,整个波形周期首尾都能自然回到基线,不会出现跳变。

2.2 动态采样与相位偏移:波形动起来的核心逻辑

波形数据计算出来之后,真正的绘制由LineRenderer完成。主控制器脚本的Update里做这些事:

  1. 让phase变量按速度递增,形成波动;
  2. 在线的起点和终点之间均匀取点,每个点的x在本地坐标从左到右分布;
  3. 对于每个x,计算它对应的归一化时间值,再叠加上phase偏移取小数部分,传入波形函数得到y;
  4. 将这些点写入LineRenderer的position数组。

这里面的关键点是取模运算。心电图是周期函数,所以用(time + phase) % 1f把采样时间限制在0到1的循环区间内,这样波形就能不断向左滚动。速度参数speed直接控制phase的增量速率,速度越大,图形滚动越快。

2.3 完整脚本:一份可直接拖到物体上用的实现

结合上述设计,我整理了一个可直接挂在任何GameObject上的脚本,核心字段全部public化,方便Inspector实时调参。

using UnityEngine; [RequireComponent(typeof(LineRenderer))] public class ECGLineRenderer : MonoBehaviour { [Header("波形参数")] [Range(32, 1024)] public int pointCount = 360; [Range(0.1f, 30f)] public float lineLength = 16f; [Range(0.05f, 8f)] public float amplitude = 2f; [Range(0f, 10f)] public float speed = 1f; [Header("外观参数")] public Color lineColor = Color.green; [Range(0.01f, 3f)] public float lineWidth = 0.15f; public Material lineMaterial; [Header("运行控制")] public bool autoPlay = true; private LineRenderer lr; private float phase; void Start() { lr = GetComponent<LineRenderer>(); lr.useWorldSpace = false; lr.positionCount = pointCount; lr.startWidth = lineWidth; lr.endWidth = lineWidth; lr.startColor = lineColor; lr.endColor = lineColor; lr.loop = false; if (lineMaterial != null) lr.sharedMaterial = lineMaterial; else if (lr.sharedMaterial == null || lr.sharedMaterial.name.Contains("Default")) lr.sharedMaterial = new Material(Shader.Find("Sprites/Default")); } void Update() { if (autoPlay) phase += Time.deltaTime * speed; Vector3[] points = new Vector3[pointCount]; float startX = -lineLength * 0.5f; float endX = lineLength * 0.5f; float invCount = 1f / (pointCount - 1); for (int i = 0; i < pointCount; i++) { float x = Mathf.Lerp(startX, endX, i * invCount); // 归一化位置:0~1 映射到当前线段的空间位置 float normalizedT = (float)i / (pointCount - 1); // 用相位偏移控制波形移动,取小数部分实现周期循环 float timeInCycle = (normalizedT + phase) % 1f; float y = ECGMath.Eval(timeInCycle) * amplitude; points[i] = new Vector3(x, y, 0f); } lr.positionCount = pointCount; lr.SetPositions(points); } // 重新赋值颜色或线宽时调用 public void ApplyAppearance() { lr.startWidth = lineWidth; lr.endWidth = lineWidth; lr.startColor = lineColor; lr.endColor = lineColor; } }

要注意的是lr.useWorldSpace = false,这是为了保证点坐标基于物体本地空间,物体本身可以整体移动或旋转而不影响波形内部结构。如果不这样做,SetPosition时直接填世界坐标,后续移动物体会非常难控制。

3. 场景搭建与参数调试:照着做就能跑起来

3.1 场景搭建步骤

步骤很简单。创建空物体,挂上ECGLineRenderer脚本,系统会自动添加LineRenderer组件。然后把相机摆到合适的位置,比如物体在原点附近,相机放在(0, 0, -10)用正交或透视模式都可以。如果选择透视,推荐把相机尺寸或距离调到能完整看到线长的范围内。

材质这一环要注意。Unity新建LineRenderer时默认材质在外观上会比较奇怪,尤其是场景有平行光时,线条可能呈现为暗色或者受光照影响。我习惯直接给lineMaterial字段指定一个Shader为Sprites/Default的材质,或者把场景里的平行光方向调一下。最省事的方法就是代码里自动赋值新材料,上面Start里的逻辑就是在做这件事。

3.2 五个最值得调的参数

  • amplitude:振幅,控制波形上下摆动的幅度。建议先设成2左右,看整体高度是否适合你的布局再增减。
  • speed:速度,决定波形横向流动的快慢。注意这里不是帧率相关,而是基于真实时间,所以运行速度不受帧率波动影响。
  • lineColor:颜色,直接赋值到LineRenderer的start/endColor上,所以改完颜色立刻生效。
  • lineWidth:线宽,心电图线条不宜太粗,否则R峰看起来像一坨块的边缘而不是一条锐利的尖峰。
  • pointCount:采样点数,太低会出现折线感,一般288到512之间平滑度足够,再高几乎没有视觉差异还白耗性能。

当你调整 amplitude 时,波形高度会随之改变,但整条线的中心点在自然情况下会随着波形函数的基线数据而略有些偏移。由于我们的波形函数近似中心是y=0,所以曲线整体会围绕局部坐标原点上下对称。想让波形在画面中上下平移,可以给物体加一个Offset字段,把所有点统一加一个y偏移,这个字段没有被我写进最终的简单版本里,但使用时的场景布局需求往往需要这一项,建议自行添加。

3.3 运行时调试经验

Inspector面板上直接改数字就能看到效果,本质原因是LineRenderer组件每帧被重写,positionCount和SetPositions都在Update里执行,所以任何字段值的修改都会在下一次Update时生效。这个机制让调整变得极其顺手,不需要任何编辑器扩展代码。

遇到过一个小问题:如果要做一个“心电图暂停”、“继续播放”的效果,其实不像想象中要停掉整个组件。最简单的方法是维护一个isRunning开关,当它置为false时,Update里直接return,不更新phase也不重设点,波形就冻结在当前状态。我在代码里用的autoPlay字段就是这个逻辑的简化版,关闭后phase停住,再用脚本开起来波形会接着从之前的相位继续滚动。

4. 常见问题与避坑排查实录

4.1 LineRenderer在UGUI的Canvas下不显示

这个坑我猜90%的人会踩。如果你把挂脚本的物体直接放进Screen Space - Overlay的Canvas下,运行起来什么也看不到。原因很简单,LineRenderer是3D渲染管线组件,跟Canvas的渲染流程不是一套体系,Overlay模式的Canvas根本不参与3D世界坐标绘制。

三种解法,按需选择:

  1. 把Canvas的Render Mode改为World Space,把波形物体作为Canvas的子物体并放置在相机视野内。
  2. 单独用一个Camera渲染波形层,通过Layer和Culling Mask控制只渲染LineRenderer,再通过Canvas的Screen Space Camera模式叠加上去。
  3. 换一套纯UGUI方案,用自定义Graphic重写OnPopulateMesh生成网格,但代码量翻倍,非特殊情况不推荐。

我自己的习惯是:如果效果偏“屏幕UI”,直接World Space Canvas,再让它固定在相机前方;如果是3D场景里的生命体征监控大屏,那自然放在3D空间里,相机跟随即可,效果更自然。

4.2 波形发灰、颜色不对、明暗不均

LineRenderer的默认材质是Legacy Shaders/Particles/Alpha Blended或者Default-Material,这类材质在场景里受到光照影响,看起来颜色发灰甚至失真。最稳妥的是新建材质,Shader选择Sprites/Default。这个Shader无视光照,纯粹按顶点色输出,做UI感十足的电波效果特别合适。

另外线条自发光效果可以通过调整材质里的Tint或搭配Particles/Additive来增强,不过纯色心电图还是Sprites/Default最稳妥。

4.3 R峰不够锐利,波形看起来软绵绵

如果你看到的心电图R峰比较“圆”,往往是采样点数量太少了。360个点以上,R峰处才能用足够多的点去卡住那个陡峭的上升和下降沿。如果pointCount低于128,R峰会明显变成圆弧甚至梯形。这也是我最终默认值设360的原因,性能和效果平衡点刚好在这附近。

同时检查lineWidth别太宽,宽度超过0.3以后尖锐感会大幅下降。宽度0.1~0.2看起来最像心电监护仪上的细线。

4.4 波形跳动不平滑,卡顿感明显

这个基本不是性能问题,而是速度参数的相位步进太大。如果speed设置的过高,每一帧phase前移的距离大于单个采样区间的宽度,波峰会出现明显的跳变。把速度控制在10以下就问题不大;真要更快的滚动,建议提高pointCount而不是继续加大speed,否则整个波形像在“闪”而不是“滚”。

5. 实际应用扩展与调参心得

5.1 从心电图到通用波形线:这个demo还能怎么用

虽然我做的是心电图,但这个结构稍加改造就能变成别的效果,因为核心只是“数学函数 + LineRenderer”。把ECGMath.Eval换成正弦波,就是一个标准呼吸波形;换成PerlinNoise采样,就是随机噪声曲线,可以做异常信号提示;甚至换成AudioSource.GetSpectrumData输出,就能搞出一条实时音频频谱线。速度、颜色、振幅三个参数的抽象是通用的,只要波形函数不同,视觉效果直接翻倍。

还有一个思路:把振幅和心跳周期绑定到游戏状态上。我在做医疗主题Demo时,就把心跳的频率和玩家的血量做联动,血量低时振幅变大、速度变快,画面上的压迫感立刻就出来了。类似这种玩法,强烈建议你试一下,比单纯做一个静态展示有意思得多。

5.2 最后的经验之谈

这套效果我做下来最大的感受是:LineRenderer这种渲染组件自身的能力边界被很多人低估了。它没有UGUI那种繁琐的布局概念,没有Mask,不需要图集,只要你自己维护顶点数据,它就能做很多视觉效果。关键是要想清楚数据流:波形函数 -> 采样点 -> LineRenderer位置数组 -> 材质颜色,这四个环节分层清晰后,不管是调参数还是扩展新波形,都变得非常顺手。

最后给一个实用小技巧:如果你想让线条自带“发光”感觉,不需要加后处理,直接把波形复制两层,下面那层用大一点的lineWidth加半透明颜色,就能做出便宜的辉光效果。配合dark背景卡片,几乎就是医疗监控大屏的质感。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询