简介:这份资源是面向 Unity 开发者与图形编程学习者的心电图线波模拟 Demo,针对需要快速实现动态波线视觉效果的场景,解决从零编写线波渲染与参数交互逻辑耗时的问题。项目通过 Mesh 与 Renderer 绘制波线,支持用 Q、W 键触发效果变化,波线速度、颜色与上下振幅均可自定义调整,涉及协程与 Update 中的时间控制、Color 与 Material 属性修改、Transform 缩放调整等实现方式,并把运动、交互、渲染等职责拆分到独立脚本组件中,便于按模块阅读与二次修改。压缩包共 6688 个文件,以 meta、cs 脚本、png 贴图为主,另含 prefab 预制体、unity 场景、asset 配置、asmdef 程序集定义、shader 与少量 dll、json 等,完整保留工程目录结构,整体约 39.19MB。已有 333 人学习下载,适合用来理解 Unity 中波线生成、参数化视觉调试与项目结构组织的实践思路。
1. 从一块监护面板说起:这个 Unity 线波 Demo 到底解决了什么
给健康监测类小游戏做监护界面,最容易被低估的就是那条会跳的线:策划要它有节奏地上下起伏、波形贴近真实心电图,还要求“看着别太假”。序列帧方案一放大就糊,纯 Shader 现算改个振幅要重新编译,两条路我都踩过坑。这个 Unity 线波模拟心电图 Demo 走的是第三条路:运行时生成顶点,把波形画成一条可编程的线,波线速度、颜色、上下振幅三组参数全暴露在 Inspector 上,拖滑块即时生效,编辑器里点运行就有画面。它适合做医疗健康类 HUD、数据监控大屏、科幻风仪表盘,也适合拿来练 LineRenderer 和动态 Mesh。下面按工程结构、波形算法、参数链路、避坑、二次开发的顺序拆。
2. 工程结构拆解:数据、几何、外观三条线怎么在三个脚本里分开
接过不少波形需求,最常见的失败版本是所有逻辑塞进一个Update()里:算点、拼网格、改颜色、调材质全在一坨。改一个参数要顺着两百行往下找,第二天自己都看不懂。这个 Demo 的价值不在效果多花哨,而在于它把“波形从哪来”“点怎么变成面”“外观怎么表现”切成了三层,改任意一层都不用碰另外两层。
2.1 目录结构与每个文件的职责
解压后 Assets 下的组织大致是这样,脚本数量不多,但每个文件的边界划得很清楚:
| 路径 | 类型 | 职责 |
|---|---|---|
Assets/Scenes/ECGWaveDemo.unity | 场景 | 摄像机、挂载节点、参数面板、演示用的示例物体 |
Assets/Scripts/WaveSettings.cs | ScriptableObject | 所有可调参数的唯一入口 |
Assets/Scripts/ECGWaveProvider.cs | 纯 C# 类 | 把时间映射成 -1~1 的波形值,不含任何 Unity 渲染代码 |
Assets/Scripts/WaveRingBuffer.cs | 纯 C# 类 | 环形缓冲,存最近 N 个采样值 |
Assets/Scripts/WaveMeshRenderer.cs | MonoBehaviour | 把一维采样数组转成带宽度的 Mesh 并提交 |
Assets/Scripts/WaveDemoController.cs | MonoBehaviour | 每帧推进时间、写缓冲、触发重建 |
Assets/Materials/WaveLine.mat | 材质 | 开启顶点色,配合渐变使用 |
Assets/Shaders/WaveLine.shader | Shader | 可选的发光/抗锯齿版本,替换默认材质即可 |
这个划分方式有个直接好处:ECGWaveProvider和WaveRingBuffer完全不依赖MonoBehaviour,可以脱离场景单独做单元测试。想验证某个心率下波形是否正确,直接在编辑器里跑一段Debug.Log就行,不用进 Play 模式盯着画面猜。
WaveMeshRenderer只认一个Vector3[]或者float[],不关心数据是心电、音频还是股票曲线。后续要接真实传感器数据,只需要换掉 Provider,渲染层一行不动。
2.2 参数收口:一个 ScriptableObject 顶掉散落的 public 字段
参数散在各个脚本的public字段上,是波形类项目最常见的返工源头。场景里挂三处波形,改一次振幅要改三个地方,还容易漏。Demo 里把参数收进一个资产:
// WaveSettings.cs using UnityEngine; [CreateAssetMenu(fileName = "WaveSettings", menuName = "ECG/Wave Settings")] public class WaveSettings : ScriptableObject { [Header("波形形状")] [Range(0.1f, 4f)] public float amplitude = 1f; // 上下振幅倍率,1 表示铺满可视高度 [Range(0.3f, 2f)] public float cycleSeconds = 0.8f; // 一个完整心动周期的秒数,0.8s 约等于 75 bpm [Range(0f, 0.5f)] public float baseline = 0.5f; // 基准线在视口里的归一化高度 [Header("滚动")] [Range(30, 480)] public int samplePerSecond = 180; // 每秒采样数,决定曲线平滑度和顶点量 [Range(1f, 20f)] public float windowSeconds = 4f; // 屏幕上同时可见的时间窗口长度 [Range(0.1f, 5f)] public float speed = 1f; // 波线速度倍率,只影响内容推进快慢 [Header("外观")] [Range(0.005f, 0.2f)] public float lineWidth = 0.04f; public Gradient colorOverLength; // 沿线的颜色渐变 public Material lineMaterial; }参数说明几句。samplePerSecond和windowSeconds相乘就是环形缓冲的容量,也是每帧要处理的顶点数上限,这两个数直接决定性能,别拍脑袋填大。cycleSeconds是心率,不是“动画时长”,把它和speed分开是这套参数设计里最关键的一点:一个是生理节奏,一个是视觉滚动速度,混在一起调会互相打架。
用 ScriptableObject 而不是普通字段,除了多套预设(正常心率、心动过速、室颤)能共存,还有个隐性好处:参数改动不会污染场景文件的 diff。团队协作时场景文件被频繁改是最难受的事,这个坑吃过一次就记住了。
2.3 主循环:一帧里到底发生了什么
WaveDemoController是整个工程的驱动层,它每帧做的事按顺序排列,读一遍就知道数据怎么流动:
// WaveDemoController.cs using UnityEngine; public class WaveDemoController : MonoBehaviour { public WaveSettings settings; public WaveMeshRenderer renderer; ECGWaveProvider m_Provider; WaveRingBuffer m_Ring; float m_TimeCursor; // 波形时间轴游标,单位秒 float m_SampleAccum; // 采样累加器,保证写入步长与帧率无关 void OnEnable() { m_Provider = new ECGWaveProvider(settings); int capacity = Mathf.CeilToInt(settings.samplePerSecond * settings.windowSeconds); m_Ring = new WaveRingBuffer(capacity); // 先灌满一整窗,避免首帧只有一根垂直的线 for (int i = 0; i < capacity; i++) m_Ring.Push(m_Provider.Evaluate(-settings.windowSeconds + i / (float)settings.samplePerSecond)); } void Update() { if (settings.samplePerSecond != m_Ring.Capacity / settings.windowSeconds) RebuildCapacity(); float step = 1f / settings.samplePerSecond; m_SampleAccum += Time.deltaTime * settings.speed; // 帧率高低只影响这一帧推进了几步,不影响每一步的时间长度 int steps = Mathf.FloorToInt(m_SampleAccum / step); m_SampleAccum -= steps * step; for (int i = 0; i < steps; i++) { m_TimeCursor += step; m_Ring.Push(m_Provider.Evaluate(m_TimeCursor)); } if (steps > 0) renderer.Rebuild(m_Ring, settings); } }逻辑说明:m_SampleAccum这个累加器是整套滚动动画稳定的核心。Time.deltaTime每帧都在抖,如果直接拿它去乘坐标,波形会呈现出规律的“呼吸感”抖动,肉眼很明显。改成累加后取整,写入步长永远是精确的1/samplePerSecond,剩下的零头留到下一帧,帧率 30 和 144 得到的波形间距完全一致。
参数说明:speed放在累加器外面乘deltaTime,改变的是单位时间内写入多少个采样点。speed = 2就是波形推进速度翻倍,但每个点代表的时间跨度没变,波形形状不会因此变形——这是很多实现里做错的地方,把 speed 直接乘进 x 坐标,结果波形被横向拉伸了。
RebuildCapacity()在samplePerSecond改动时重新分配缓冲并补数据,防止运行中拖滑块把数组越界。面板上拖参数是这类 Demo 的主要用法,这条路径必须处理。
3. 波形生成核心:一个心动周期怎么被拆成可复用的采样函数
线波好不好看,八成取决于波形函数,两成取决于渲染。很多 Demo 用Mathf.Sin叠几下就当心电了,出来的东西更像正弦干扰波,缺少 QRS 那一下陡峭的尖峰,怎么调参数都不像。
3.1 PQRST 形状:用几段高斯叠出心电图
真实心电图的一个周期可以粗略拆成 P、Q、R、S、T 五段,每段都是近似钟形的凸起或凹陷。用高斯函数叠加,连续可导、没有跳变,参数含义也直观——中心位置决定在哪出现,宽度决定陡峭程度。
// ECGWaveProvider.cs using UnityEngine; public class ECGWaveProvider { readonly WaveSettings m_Settings; public ECGWaveProvider(WaveSettings settings) { m_Settings = settings; } public float Evaluate(float timeSeconds) { // 先把时间折算到 [0,1) 的相位,心率变化只影响这一个折算 float phase = Mathf.Repeat(timeSeconds / m_Settings.cycleSeconds, 1f); float v = 0f; v += Gaussian(phase, 0.18f, 0.030f) * 0.15f; // P 波,小而缓 v += Gaussian(phase, 0.32f, 0.010f) * -0.10f; // Q 波,R 前的小凹陷 v += Gaussian(phase, 0.36f, 0.008f) * 1.00f; // R 波,主峰 v += Gaussian(phase, 0.40f, 0.012f) * -0.25f; // S 波,R 后的深凹陷 v += Gaussian(phase, 0.62f, 0.060f) * 0.30f; // T 波,宽而圆 return Mathf.Clamp(v, -1f, 1f); // 归一到 -1~1,渲染层不再关心原始量纲 } static float Gaussian(float x, float center, float sigma) { float d = x - center; return Mathf.Exp(-(d * d) / (2f * sigma * sigma)); } }逻辑说明:sigma是这段波的宽度,越小越尖。R 波用 0.008 是为了做出那根近乎垂直的尖峰,S 波用 0.012 让凹陷比 R 稍宽一点,视觉上才符合真实心电图的节奏。所有分量加完再Clamp到 -1~1,这样渲染层拿到的永远是归一化数据,振幅调整只需要在外面乘一个系数。
参数说明:cycleSeconds从 0.8 改到 0.4,心率翻倍,但波形形状完全不变,只是横向压缩。这一点很重要——如果波形函数里混进了绝对时间,改心率会连带改变波形比例,那就不对了。相位环绕处(phase 从 1 跳回 0)的函数值接近 0,因为最近的 P 波中心在 0.18,离边界足够远,所以不会出现断点。
想在 Demo 基础上做出室颤、房颤等异常波形,就是往这个函数里加分量、改中心位置,不涉及任何渲染代码。
3.2 采样与缓冲:为什么不能每帧按 x 现算点
一个常见的省事写法是每帧按屏幕上的 x 均匀取点,y = f(x + timeOffset)。这样写代码短,但有两个问题:一是波形推进受帧率影响,帧率波动时速度不均匀;二是无法表达“历史数据”,接真实传感器数据流时完全没有插值余地。
Demo 用的是环形缓冲:新采样点从头部写入,读取时按从旧到新的顺序取一整窗。
// WaveRingBuffer.cs public class WaveRingBuffer { readonly float[] m_Buffer; int m_Head; // 下一个写入位置 public WaveRingBuffer(int capacity) { m_Buffer = new float[Mathf.Max(2, capacity)]; m_Head = 0; } public int Capacity => m_Buffer.Length; public void Push(float value) { m_Buffer[m_Head] = value; m_Head = (m_Head + 1) % m_Buffer.Length; } // index 0 表示窗口里最旧的点,index Capacity-1 是最新的点 public float this[int i] => m_Buffer[(m_Head + i) % m_Buffer.Length]; public void Clear() => System.Array.Clear(m_Buffer, 0, m_Buffer.Length); }逻辑说明:m_Head指向下一个要写的位置,读取时把m_Head当作“最旧点”的起点,往后偏移i再取模,就完成了新旧顺序的重排。这样不需要每帧移动数组,写入是 O(1),读取是 O(1) 随机访问,长时间运行也不会因为数组搬移产生 GC。
参数说明:容量由samplePerSecond * windowSeconds决定。窗口 4 秒、采样 180,容量 720,配合每帧最多写入十几个点,完全够用。容量一次性开好,运行中不扩容,避免new带来的卡顿。
3.3 从一维数组到二维坐标:x 轴怎么排
采样值只是一串浮点数,要变成屏幕上的点还需要分配 x 坐标。这一步放在渲染前做,一行循环搞定:
// WaveMeshRenderer.cs 片段 void SampleToPoints(float[] output, WaveRingBuffer ring, WaveSettings s) { int count = ring.Capacity; float stepX = 2f / (count - 1); // 横向铺满 -1~1 for (int i = 0; i < count; i++) { // x 固定均匀,速度体现在缓冲区内容随时间推进,而不是拉伸 x float x = -1f + i * stepX; float y = ring[i] * s.amplitude + (s.baseline * 2f - 1f); output[i * 2 + 0] = x; output[i * 2 + 1] = y; } }逻辑说明:baseline存的是 0~1 的归一化值,渲染前乘 2 减 1 映射到 -1~1,方便在面板上按百分比调。amplitude直接乘在波形值上,正负都会等比缩放,所以波峰和波谷是对称变化的。
参数说明:stepX直接用容量算,不依赖世界单位,这样窗口时长变化时 x 轴自动重新铺满,不用改相机参数。如果这个线要贴在 UI 上,建议整体挂在RectTransform下,让父节点负责缩放,Mesh 内部始终用 -1~1 的归一化坐标。
3.4 Mesh 重建:带宽度的线段怎么生成
LineRenderer也能用,但它的宽度控制、渐变和每帧更新在移动端会有性能顾虑,而且默认的宽度是“像素”还是“世界单位”取决于对齐方式,容易在缩放时翻车。Demo 选择自己生成 Mesh:每个采样点生成上下两个顶点,相邻点连成两个三角形。
// WaveMeshRenderer.cs 片段 void RebuildMesh(Vector3[] points, WaveSettings s) { int n = points.Length; if (n < 2) return; m_Vertices.Clear(); m_Uvs.Clear(); m_Colors.Clear(); m_Triangles.Clear(); float halfW = s.lineWidth * 0.5f; for (int i = 0; i < n; i++) { // 用前后相邻点求切线,端点退化为单侧差分 Vector3 dir = i == 0 ? points[1] - points[0] : i == n - 1 ? points[i] - points[i - 1] : points[i + 1] - points[i - 1]; dir.z = 0f; if (dir.sqrMagnitude < 1e-8f) dir = Vector3.right; dir.Normalize(); // 二维平面里的法线就是切线逆时针旋转 90 度 Vector3 normal = new Vector3(-dir.y, dir.x, 0f) * halfW; m_Vertices.Add(points[i] + normal); // 上沿 m_Vertices.Add(points[i] - normal); // 下沿 float u = i / (float)(n - 1); m_Uvs.Add(new Vector2(u, 1f)); m_Uvs.Add(new Vector2(u, 0f)); Color c = s.colorOverLength.Evaluate(u); m_Colors.Add(c); m_Colors.Add(c); } // 每两个相邻点生成两个三角形,注意绕序朝相机 for (int i = 0; i < n - 1; i++) { int b = i * 2; m_Triangles.Add(b); m_Triangles.Add(b + 1); m_Triangles.Add(b + 2); m_Triangles.Add(b + 2); m_Triangles.Add(b + 1); m_Triangles.Add(b + 3); } Mesh mesh = m_MeshFilter.sharedMesh; mesh.Clear(); mesh.SetVertices(m_Vertices); mesh.SetUVs(0, m_Uvs); mesh.SetColors(m_Colors); mesh.SetTriangles(m_Triangles, 0); mesh.RecalculateBounds(); // 必须手动刷,否则振幅调大后被视锥剔除 }逻辑说明:每个点的上下沿由切线旋转得到,切线用前后点差分。如果某一段波形特别陡(R 波尖峰),切线接近竖直,法线接近水平,得出的带子会偏窄,这是因为差分法在尖角处天然会收缩。想要更均匀的宽度,可以用前后两段切线分别算左右法线取平均再归一化,代价是代码复杂一点。
参数说明:lineWidth是世界单位,在正交相机下直接对应画面比例;透视相机下会随距离变化,需要按摄像机距离补偿。RecalculateBounds这行不能省,Mesh 的包围盒不会随顶点自动更新,振幅调大后超出旧包围盒的部分会被剔除。
顶点数上,720 个采样点对应 1440 个顶点、1436 个三角形,桌面端毫无压力,移动端也完全扛得住。真到几万点的场景才需要上 Job System 或 Compute Shader。
4. 三个可调参数的落地:速度、振幅、颜色各自的坑
参数面板上有三组滑块,看着简单,每一组背后都对应一个容易做错的具体问题。这一节按参数拆开讲。
4.1 波线速度:固定步长累加器,别把 deltaTime 直接乘进坐标
速度是最容易做错的一个。直接拿Time.deltaTime去偏移采样相位,帧率一抖,波峰间距就会出现肉眼可见的不均匀,尤其在 R 波那根尖峰上特别明显——上一帧和下一帧的点挨得远近不同,尖峰会一会儿胖一会儿瘦。
第 2 章里那段累加器代码就是解决方案,这里补充几个细节:
// 运行时改速度不需要重建缓冲,只影响下一次累加的步数 m_SampleAccum += Time.deltaTime * settings.speed; // steps 是这一帧要写入的新采样点数 int steps = Mathf.FloorToInt(m_SampleAccum / step); m_SampleAccum -= steps * step;逻辑说明:累加器里存的是“还没被消耗掉的时间”,单位是秒。每帧加进来的是这一帧实际经过的波形时间(真实时间乘速度倍率),除以单步时长取整,得到需要写入的采样点数量。剩下的零头留着,下一帧继续累积。
参数说明:speed设成 0 时波形完全静止,这可以用来做暂停效果。设成负数理论上可以倒退,但环形缓冲是单向写入的,要支持倒退得改成双缓冲双向读,Demo 里没做。speed从 1 拖到 5,波形推进速度变快,但如果超过samplePerSecond / 60,一帧写入的点数就会超过缓冲可容纳的窗口,表现为波形被“跳过”了一段——这是上限,面板上建议把 speed 钳制在合理区间。
还有一种做法是速度不改写入步数,改采样率,效果类似但实现更绕,写起来容易把采样率和窗口时长这两个概念搞混。
4.2 振幅:归一化数据乘系数,还要守住基准线和边界
振幅调整看着就是乘一个系数,实际有两个边界问题:一是超出视口被裁掉,二是 Mesh 包围盒没更新导致整条线突然消失。
// 振幅参与的完整计算 float y = ring[i] * s.amplitude + (s.baseline * 2f - 1f); // 视口外的部分不会被相机裁掉,但会被 Mesh 包围盒剔掉, // 所以每次振幅变化后必须刷新包围盒 mesh.RecalculateBounds();逻辑说明:amplitude是倍数,1 表示波形铺满 -1~1 全高。baseline是基准线的位置,心电图的等电位线就在这个高度上,R 波向上凸起,S 波向下凹陷。两个参数独立,可以做出“基线偏上、振幅偏小”的效果,比如画在面板上半部分的小窗预览。
参数说明:amplitude超过 1 之后,波峰会超出可视范围,正交相机下会被裁掉一部分。如果不想裁,把相机 orthographicSize 跟着放大,或者把整个波形节点缩放到 0.5 再反过来乘 2。这里有个小技巧:把所有波形点统一缩放到一个固定包络里,让 Mesh 的 bounds 至少有一个视觉上合理的下限,这样即使波形全平,也不会因为 bounds 为零被当成空物体剔除。
想让波形在出现瞬间有“渐入”效果,可以对amplitude做一个缓动,从 0 插值到目标值。注意插值过程中要每帧刷 bounds,否则前几帧看不到。
4.3 颜色:Gradient 打在顶点色上,注意色彩空间
颜色这块的坑最隐蔽,因为它在编辑器里看着正常,打包到设备上就变了。原因是 Unity 的色彩空间设置:在 Linear 空间下,顶点色被当作线性值处理,而面板上填的 Gradient 是在 sRGB 空间里的。
// 沿线的颜色渐变,u 是从旧到新的归一化位置 Color c = s.colorOverLength.Evaluate(u); // 如果项目使用 Linear 色彩空间,需要手动转换, // 否则画面上看到的颜色会比面板里暗一截 if (QualitySettings.activeColorSpace == ColorSpace.Linear) c = c.linear; m_Colors.Add(c); m_Colors.Add(c);逻辑说明:Gradient.Evaluate返回的是面板上看到的 sRGB 值,直接塞进mesh.colors,在 Gamma 空间下没问题,在 Linear 空间下会被 Shader 当成线性值再转一次,结果偏暗偏灰。手动调一次Color.linear就能对齐。
参数说明:colorOverLength用的是沿线的归一化位置u,不是世界坐标,所以窗口时长变化时渐变分布不变。如果想要“最新一段更亮、旧的一段淡出”的效果,把 Gradient 的右侧做透明度渐变就行,配合一个支持顶点 Alpha 的 Shader。
材质那边还有一步:Shader 里必须真的用到顶点色。URP 默认的Unlit不一定开顶点色,Demo 自带的WaveLine.shader里明确写了IN.color并乘上_BaseColor,替换材质时注意这一点,不然改了半天 Gradient 画面没反应。
想要发光效果,可以在 Shader 里用视线方向和法线做菲涅尔,或者在材质上叠一层加色混合的副本。这类视觉增强不影响主逻辑,可以在工程跑通后再加。
4.4 运行时热调:面板改动怎么立刻生效
面板上拖滑块要立刻看到效果,如果每帧无条件重建 Mesh,720 个点乘以几万三角形,在低端机上会白白烧 CPU。Demo 的做法是脏标记:
// WaveDemoController 片段 bool m_Dirty; public void MarkDirty() => m_Dirty = true; void Update() { // 时间推进导致的刷新 if (steps > 0) m_Dirty = true; // 参数变化导致的刷新 if (Application.isPlaying && m_Dirty) { renderer.Rebuild(m_Ring, settings); m_Dirty = false; } } // 在 Inspector 里改参数时触发,编辑器下自动调用 void OnValidate() => m_Dirty = true;逻辑说明:OnValidate在 Inspector 数值变化时会被调用,标记一下脏位,下一次Update里重建。运行时steps > 0也会置脏,因为波形在滚动。
参数说明:如果参数在代码里通过settings.amplitude = 1.5f修改,OnValidate不会触发,需要手动调MarkDirty()。团队协作时这一点容易漏,建议给WaveSettings加一层属性封装,在 setter 里统一发事件。
5. 避坑与排查:这条线画歪的五个常见原因
波形类项目的问题大多长得很像,但根因完全不同。下面这几条是我在同类工程里反复遇到的,按“现象 → 原因 → 解决”写。
现象一:波形头部每隔几秒闪一下,像是突然往回跳了一段。
原因是环形缓冲的读取索引没有统一走取模,某处直接用了m_Head + i,数组越界后读到的是旧数据。检查所有访问入口,确认都通过索引器this[int i]走(m_Head + i) % Capacity,不要有第二套访问路径。另外缓冲刚创建时没预热也会造成首帧从零开始冲刺,OnEnable里先把整窗灌满。
现象二:调大振幅后整条线突然消失,调小又回来。
原因是 Mesh 的 bounds 没有跟着顶点更新,超出旧包围盒的部分被视锥剔除。在RebuildMesh末尾补上RecalculateBounds(),振幅、基准线、线宽这类会改变顶点范围的参数变化后都要调用。另一个可能是相机 near/far 裁剪,正交相机下检查orthographicSize是否覆盖了波形高度。
现象三:低帧率设备上波形很顺,高帧率设备上一顿一顿。
原因是速度推进用了Time.deltaTime直接乘坐标,或者采样步长没做累加取整。改成固定步长累加器,并且确认Time.deltaTime没有在别处又被乘一次。还有个隐蔽的可能是Update里重建 Mesh 时用了mesh.vertices = array这种会产生额外分配的写法,改成SetVertices(List<Vector3>)复用列表。
现象四:编辑器里颜色正常,打包到设备上偏暗。
原因是项目使用 Linear 色彩空间,Gradient 里的 sRGB 值被直接写进顶点色。检查PlayerSettings里的色彩空间设置,在写入顶点色之前做Color.linear转换,或者在 Shader 里统一做转换。材质本身的_BaseColor也要确认没有被重复转换。
现象五:线在父节点缩放下变粗变细,宽度对不上。
原因是lineWidth写在本地空间,父节点非等比缩放时 x 和 y 方向的宽度不一致,线会被压扁。解决办法有两个:一是把波形节点的缩放强制归一,缩放交给相机或上层 UI 的 CanvasScaler;二是在Rebuild里把宽度除以父节点的世界缩放,按世界单位反算本地宽度。前者简单可靠,推荐优先用。
6. 二次开发:把它改成你自己的监护面板
工程跑通之后,真正的工作才开始。这条线本质是一个“数据到几何”的通道,改造方向取决于你要接什么数据、用在哪里。下面这张表是我换过几次之后总结的改动点:
| 改造目标 | 改动位置 | 注意点 |
|---|---|---|
| 接真实传感器数据 | 新增IWaveSource接口,把Evaluate(time)换成TryRead(out float) | 数据来得不均匀,需要在 Controller 里做插值到固定采样率,否则波形会抖 |
| 换成音频/股票曲线 | 只换 Provider,保留 Provider 之外的代码 | 值的范围可能不是 -1~1,Provider 里做归一化,渲染层不要加特判 |
| 多导联并排显示 | 每个导联一套 RingBuffer + Renderer,共享一个 Controller | 采样累加器可以共享,省一次时间推进计算 |
| 加网格与刻度线 | 在波形节点下加独立的 LineRenderer 或 Sprite | 刻度不要跟着波形滚动,否则读数会错 |
| 移动端性能优化 | 采样率降到 90~120,Mesh 列表复用,关闭阴影和额外 Shader 变体 | 顶点数不是瓶颈,Draw Call 和材质变体才是 |
| 封成通用 UI 组件 | 把 Controller 和 Renderer 合到一个 MonoBehaviour,参数通过属性暴露 | 注意 UI 的RectTransform缩放,宽度要用世界单位补偿 |
接口化那一步值得展开说。真实数据源(串口、网络、文件回放)的节奏和固定采样率对不上,常见做法是维护一个目标采样率,在Update里按时间累加,每到一个采样点就去源里取一个值,不足时用上一次的值做保持或者线性插值。这层适配器写好了,波形逻辑和渲染逻辑都能原封不动复用。
验证方法上,我习惯做两件事:一是把speed设为 0,看静态波形是否与参考图对齐,尤其是 QRS 三个波峰的相对高度;二是把帧率人为压到 20 帧,观察波形间距是否仍然均匀,这一条能快速筛掉大部分累加器写错的实现。还可以在OnDrawGizmos里把采样点画成小球,直观看到点的分布是否均匀——很多时候问题不在渲染,在数据本身。
工程里的这几个脚本都是纯 C# 加一个Mesh,没有外部依赖,拿到之后先跑通 Demo 场景,确认波形能动,再去动 Provider。改波形函数时把windowSeconds调大到 10 秒,一个屏能看到十几个周期,任何形状上的畸变都会被放大,比在 4 秒窗口里看三五下更容易发现问题。从那以后我每次接波形类需求,都先把“采样率、窗口时长、基准线”这三个数写进配置并固定下来,剩下的才是美术和算法的事,顺序颠倒过来一定会返工。希望帮到你。
本文还有配套的精品资源,点击获取