PQRST波)
簡介一款基于Unity引擎的心電圖線波模擬效果demo支持波線速度、顏色、振幅的自定義調(diào)整并內(nèi)置Q/W鍵交互觸發(fā)效果變化適合Unity初中級開發(fā)者學(xué)習(xí)圖形渲染、動畫控制與用戶輸入處理。壓縮包總量約39.19MB共6688個文件以C#腳本、meta配置、PNG貼圖、Prefab預(yù)制體、Asset資源及Shader著色器為主另含少量場景文件與DLL庫目錄結(jié)構(gòu)完整可直接打開工程運行調(diào)試。截至目前已有331人學(xué)習(xí)下載實測運行穩(wěn)定。該demo涵蓋Mesh/Renderer繪制線波、Time.deltaTime控制速率、Material顏色動態(tài)修改、Transform縮放調(diào)節(jié)振幅等關(guān)鍵實現(xiàn)并采用組件化腳本設(shè)計便于分離功能模塊。通過閱讀源碼和場景搭建方式可快速掌握Unity中可視化波形效果的制作思路同時為UI反饋、參數(shù)面板聯(lián)動及性能優(yōu)化等擴展提供可參考的實踐起點。1. Unity線波模擬心電圖這個Demo讓速度、振幅、顏色隨時可調(diào)做醫(yī)療訓(xùn)練屏幕、設(shè)備狀態(tài)面板或者游戲里的生命體征告警時碰到心電圖波形幾乎都會卡在同一個地方波形一眼假。純正弦波放上去外行看著像心跳內(nèi)行看著像玩具。這個Demo解決的就是這個問題——用Unity線波組件模擬真實心電圖的上下振幅效果核心只有一條LineRenderer加幾十行波形采樣函數(shù)產(chǎn)出的是帶P波、QRS波群、T波結(jié)構(gòu)的心電信號而不是簡單三角函數(shù)。速度控制波形滾動快慢振幅控制波形上下高度顏色通過Gradient實時調(diào)整三個參數(shù)全部暴露在Inspector里運行時改完立刻生效。適合想快速做出“像樣”心電監(jiān)護畫面、又不想引入龐大插件的開發(fā)者。下面按波形生成、渲染參數(shù)、動態(tài)細節(jié)、排錯、進階驗證五個部分完整展開照著復(fù)制代碼就能跑。2. 波形合成是第一步P波、QRS波群、T波怎么用高斯窗拼出來2.1 為什么心電圖不能用正弦波拼正弦波的問題在于沒有時間結(jié)構(gòu)。真實心電監(jiān)護儀上一個完整心動周期是小圓頂?shù)腜波在前緊跟著一個極窄的高尖峰R波R波前后各有輕微下陷的Q波和S波最后是寬緩的T波。這套結(jié)構(gòu)不是靠幾個不同頻率的正弦波疊加能模擬的因為正弦疊加得到的仍然是規(guī)則的振蕩而心電圖是“安靜一段、突然一刺、再緩一下”的節(jié)奏。模擬場景下的目標(biāo)也不是做醫(yī)學(xué)診斷而是讓訓(xùn)練屏、Demo畫面、游戲里的監(jiān)護儀能被一眼認(rèn)出所以特征波的相對位置和形狀比精確的幅值更重要。常見做法是直接合成波形樣本用高斯窗函數(shù)做“鼓包”。高斯窗的好處是每個特征波獨立控制中心位置、寬度和幅度代碼量極小實時性好改參數(shù)時不會像頻域合成那樣牽一發(fā)動全身。下面這個信號生成函數(shù)就是這套組件的核心它把時間映射到一個0到1的歸一化心跳周期內(nèi)再疊加五個高斯窗分別對應(yīng)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 波小幅圓頂出現(xiàn)在周期前段 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 波寬緩正向波出現(xiàn)在周期后半段 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); } }這里最關(guān)鍵的參數(shù)是歸一化相位。用相位而不是絕對時間的好處是當(dāng)心率從72改成120時五個特征波在周期內(nèi)的相對位置不變自動適應(yīng)新的節(jié)拍不需要重新配參數(shù)。如果直接按秒寫死特征波位置心率一變P波和R波的間隔就錯了。高斯窗里的width是標(biāo)準(zhǔn)差語義數(shù)值越小波峰越窄R波要的就是這種“細針”效果?;€減0.03是為了讓整個周期首尾都貼近0線滾動拼接時不會在周期邊界處出現(xiàn)臺階。下表是五個特征波的默認(rèn)參數(shù)調(diào)波形形狀只需要改這張表的數(shù)字特征波中心相位寬度(σ)幅度作用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返回的是浮點數(shù)乘以振幅參數(shù)振幅小于0.1時波形幾乎貼著基線走超過10時R波會頂出屏幕具體設(shè)多少取決于相機視口高度這在后面參數(shù)說明里展開。2.2 采樣點數(shù)、時間間隔和水平間距決定波形分辨率算出來的是一串浮點數(shù)值要變成線上需要把它映射到Vector3坐標(biāo)。這里有兩個獨立的參數(shù)時間軸上每兩個采樣點的間隔和世界空間里每兩個點的水平間距。它們共同決定一個完整心跳周期在屏幕上占多寬。橫坐標(biāo)i * spacing縱坐標(biāo)就是采樣值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個完整心跳跟真實監(jiān)護儀同時顯示2到5個心搏的量級一致。分辨率的關(guān)鍵約束是R波。R波寬度σ0.014對應(yīng)時間約0.014 * 0.833 ≈ 11.7毫秒。如果時間間隔設(shè)為1/64秒即15.6毫秒一個R波尖峰只能采到兩個點左右畫出來會明顯發(fā)鈍更像正弦波。時間間隔設(shè)成1/128秒即7.8毫秒后R波能被采到四五個點尖峰才立得住。點數(shù)和時間間隔增大意味著每幀更新的頂點變多但512個點每幀重算一次開銷很小CPU時間遠低于這個組件渲染本身的消耗完全不用為性能擔(dān)心。2.3 時間軸是固定窗口不是移動整條線常見誤區(qū)是把心電圖滾動做成“把整條LineRenderer往左移”這是錯的。真機上的心電監(jiān)護儀是紙帶向右走屏幕上波形相對左移同時新的采樣點從右側(cè)持續(xù)進入。正確做法是維持LineRenderer的positionCount不變每幀只更新每個點的y值而時間坐標(biāo)由統(tǒng)一的當(dāng)前時間減去一個偏移量算出。用固定窗口思路看上面的循環(huán)就很清楚最右側(cè)點i511的時間是t最接近當(dāng)前時刻是“最新的點”最左側(cè)點i0的時間是t - 511 * timeStep是最早的點。當(dāng)前時刻推進后所有點的時間坐標(biāo)同步前進波形自然向左滑動右側(cè)持續(xù)出現(xiàn)新波形。這個設(shè)計讓后續(xù)所有參數(shù)調(diào)整都不需要碰頂點結(jié)構(gòu)只需要改采樣函數(shù)和窗口參數(shù)。3. LineRenderer渲染與參數(shù)化完整組件代碼與三組必調(diào)參數(shù)3.1 一個可直接掛載的組件心跳參數(shù)、渲染參數(shù)、更新循環(huán)把前面兩部分合到一起就是一個完整可運行的組件。新建空物體掛上下面這個腳本再隨意拖一個透明材質(zhì)到lineMaterial運行即可看到心電圖波形。這個組件把速度、振幅、顏色、采樣密度全部暴露為公開字段Inspector里改完立刻生效不需要碰代碼。using UnityEngine; public class EcgLineController : MonoBehaviour { [Header(波形參數(shù))] [Tooltip(心率每分鐘心跳次數(shù))] public float heartRate 72f; [Tooltip(振幅R波峰值高度世界單位)] public float amplitude 5f; [Tooltip(滾動速度倍率1為原始速度)] public float speed 1f; [Header(采樣參數(shù))] public float spacing 0.04f; public float timeStep 1f / 128f; public int pointCount 512; [Header(渲染參數(shù))] 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是為了讓波形刷新發(fā)生在所有邏輯更新之后。如果場景里有其他腳本在Update里移動這個掛載物體的父節(jié)點或者相機在做跟隨LateUpdate能保證波形采樣的時間軸和其他系統(tǒng)狀態(tài)在同一幀內(nèi)是對齊的避免出現(xiàn)波形位置比畫面晚一幀的抽動感。速度不是直接乘到坐標(biāo)上而是乘到時間推進上這樣speed0時波形靜止speed2時滾動速度翻倍波形形狀本身不變化。3.2 三個核心參數(shù)速度、振幅、顏色怎么設(shè)才算合理速度參數(shù)speed控制的是時間推進倍率不是波形水平方向的拉伸。speed1時屏幕上波形滾動速度由spacing和timeStep共同決定水平滾動的視覺速度 spacing / timeStep即0.04 / (1/128) ≈ 5.12世界單位每秒。想跑得快就加大speed想橫向拉伸波形、讓一個周期占更寬的位置則要調(diào)大timeStep或spacing而不是調(diào)speed。振幅參數(shù)amplitude是線性縮放波形輸出。以默認(rèn)R波幅度1.0計算amplitude5時R波峰值5世界單位。Orthographic相機高度如果是10R波正好頂?shù)狡聊灰话敫叨冗@是視覺上最舒適的范圍。超過15世界單位R波就可能裁出屏幕低于0.5波形幾乎貼在基線上。振幅不需要做歸一化因為它就是世界單位直接按場景尺寸選。顏色參數(shù)lineColor用的是Gradient不是簡單的Color字段。Gradient可以做到波形尾部透明、頭部亮綠、中段帶青色這種漸變正是心電監(jiān)護儀“熒光屏”質(zhì)感的來源。默認(rèn)值里我把左邊舊數(shù)據(jù)設(shè)成透明新波形從右側(cè)出現(xiàn)時是全亮看起來就像波形沿著屏幕流動。如果只想要單色把Gradient所有顏色鍵設(shè)為同色即可alpha鍵也要全部設(shè)為1否則會出現(xiàn)不知道哪頭淡出的情況。3.3 材質(zhì)與線寬讓波形有“監(jiān)護儀質(zhì)感”而不是一條塑料線LineRenderer對材質(zhì)非常敏感。默認(rèn)的Default-Line材質(zhì)本身支持頂點色和透明度能用但視覺效果發(fā)灰。更可控的做法是新建一個材質(zhì)Shader選擇當(dāng)前渲染管線的透明Unlit或URP下帶Surface TypeTransparent的Unlit材質(zhì)。必須確保材質(zhì)支持頂點色并且開啟透明混合否則Gradient里的alpha全部失效波形會變成一整條實心線。線寬通過AnimationCurve控制。心電圖的線一般恒定寬度所以用AnimationCurve.Constant(0,1,0.06)讓整條線等寬。numCapVertices設(shè)為4或8能讓線頭變圓避免尖端顯得生硬。線條寬度要注意按世界單位評估相機離得遠時0.06可能細到看不見離得近時可能粗得像畫筆調(diào)的時候把相機距離也考慮進去。還有一個細節(jié)LineRenderer的textureMode默認(rèn)Stretch即可如果設(shè)成Tile又沒給合適的紋理波形上會出現(xiàn)詭異的重復(fù)圖案。4. 動態(tài)細節(jié)心率平滑、基線漂移與場景坐標(biāo)適配4.1 心率突變會讓波形“瞬移”平滑才是關(guān)鍵運行時直接把heartRate從72改成120你會發(fā)現(xiàn)波形不是慢慢變快而是突然左右跳一下然后才繼續(xù)滾。原因是beatInterval變了而采樣函數(shù)的phase (t mod beatInterval) / beatInterval依賴這個周期值周期一變當(dāng)前時間點在歸一化周期里的位置就變了相當(dāng)于所有特征波瞬間平移。真實監(jiān)護儀里心率變化是漸進的所以要給heartRate做指數(shù)平滑。private float _smoothHeartRate; private void Update() { _smoothHeartRate Mathf.Lerp( _smoothHeartRate, heartRate, 1f - Mathf.Exp(-4f * Time.deltaTime) ); }把_smoothHeartRate傳給EcgSignal.Sample替代直接使用heartRate。指數(shù)平滑的系數(shù)決定過渡快慢4f這個值大約在0.5秒內(nèi)完成大部分過渡適合模擬正常的心率變化。想讓心率突變做出“搶救”效果把系數(shù)改成10f甚至更高波形會在一兩幀內(nèi)追上目標(biāo)心率。注意Update和LateUpdate的執(zhí)行順序平滑計算放在Update采樣放在LateUpdate這樣同一幀內(nèi)用的是已經(jīng)更新過的心率值不會差一幀。4.2 基線漂移呼吸帶來的上下浮動真實心電圖的基線不是一條絕對直線呼吸、肢體動作、導(dǎo)聯(lián)松動都讓波形整體上下浮動。純數(shù)學(xué)模擬時加一個低頻正弦就能打破“太干凈”的假象。把基線漂移疊加到EcgSignal的返回值上頻率設(shè)為每分鐘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對應(yīng)約每分鐘13次接近靜息呼吸頻率。幅度取振幅的6%amplitude5時漂移只有0.3世界單位肉眼能看到波形整體上下輕微浮動但不會壓過R波的主體形態(tài)?;€漂移會讓心跳周期在屏幕上的位置也緩慢變化時間軸和相位邏輯不受影響因為它是加在波形輸出端而不是加在時間參數(shù)上。想要更強的“導(dǎo)聯(lián)松動”效果可以再疊加一個低頻隨機游走但模擬場景通常不需要。4.3 坐標(biāo)空間與相機適配正交相機還是透視相機LineRenderer默認(rèn)useWorldSpacefalse點坐標(biāo)是局部空間坐標(biāo)這樣掛載物體的父節(jié)點可以整體平移、旋轉(zhuǎn)、縮放波形會跟著設(shè)備模型一起動。比如要把波形貼到某個監(jiān)護儀模型的屏幕上直接把組件掛到屏幕子物體上調(diào)好位置和旋轉(zhuǎn)即可。波形在局部空間內(nèi)始終朝向自身坐標(biāo)系的XY平面用alignmentView能讓線的寬度始終面向相機避免轉(zhuǎn)角處線變成一條細縫。相機選擇直接影響觀感。透視相機下波形離相機越遠線越細、振幅看起來越小適合波形作為3D場景里某個設(shè)備屏幕的一部分。如果是全屏心電圖背景、數(shù)據(jù)可視化面板這類用途正交相機更合適波形大小不隨距離變化參數(shù)調(diào)整更直觀。正交相機高度設(shè)成比振幅范圍略大比如振幅5、基線漂移0.3相機高度設(shè)12到14左右波形占屏幕高度的比例就比較舒服。波形所在平面的z值也要注意如果有其他UI或模型在同一平面附近可能出現(xiàn)穿插遮擋常見做法是把波形放在獨立的一個層級或者略微調(diào)整z值讓它在其他內(nèi)容前面。5. 避坑指南LineRenderer心電圖最常見的5個翻車現(xiàn)場5.1 波形不滾動而是整根線忽左忽右現(xiàn)象波形看起來在左右平移但屏幕右端沒有持續(xù)冒出新的波峰線還會斷續(xù)像被拽著走。原因把滾動做成了“移動整條LineRenderer”或“不斷改變positionCount”。移動整根線時所有頂點同步位移屏幕邊緣看不到新數(shù)據(jù)出現(xiàn)重復(fù)修改positionCount會觸發(fā)頂點緩沖重建波形就一抖一抖的。解決固定positionCount只改每個點的y值和對應(yīng)時間坐標(biāo)絕不在運行期增刪頂點。用前面組件里的方式每個點的t由統(tǒng)一的_time隨時間推進計算波形自然左移右端持續(xù)出現(xiàn)新波形。5.2 R波消失波形退化成正弦波現(xiàn)象采樣點數(shù)足夠但R波尖峰就是立不起來整個波形像被磨平了。原因時間間隔timeStep比R波特征寬度還大。R波σ0.014周期心率72時對應(yīng)約12毫秒如果用1/64秒采樣一個R波只采到兩個點線插值后尖峰被拉平。解決把timeStep縮到1/128秒或更小。采樣間隔應(yīng)小于R波特征寬度的一半一般按1毫秒到8毫秒之間選。注意縮小時如果pointCount不變屏幕總寬度會減小需要同步調(diào)整spacing或pointCount補償。5.3 設(shè)置了透明漸變線頭還是實心的現(xiàn)象Gradient里alpha在0處設(shè)為0但波形左端完全不透明甚至整條線變成一個色塊。原因材質(zhì)不支持透明或沒有開啟Blend。Unlit/Color這類不透明材質(zhì)會直接丟掉頂點色里的alpha信息Gradient配置再對也沒用。解決確認(rèn)材質(zhì)Shader是透明類型且renderQueue在Transparent區(qū)間。URP下要新建Unlit或Shader Graph材質(zhì)并把Surface Type設(shè)為Transparent。驗證方式是把材質(zhì)color臨時改成半透明紅色如果線變透明說明Blend生效再檢查頂點色的alpha通道。5.4 波形斷開、閃爍或者出現(xiàn)粉紅色方塊現(xiàn)象運行過程中線突然斷掉或模型變成洋紅色或線的某些段消失只剩碎片。原因洋紅色是材質(zhì)丟失常見于用了Shader.Find(Unlit/Transparent)但當(dāng)前渲染管線里沒有這個Shader。斷裂閃爍則多半是數(shù)組里出現(xiàn)了NaN或Infinity高斯函數(shù)參數(shù)異常、phase計算出錯都會產(chǎn)生。解決材質(zhì)不要依賴內(nèi)置舊Shader顯式指定項目可用的透明材質(zhì)。采樣側(cè)用Mathf.Repeat保證phase落在[0,1)并給振幅加個非零下限。如果波形閃斷先Debug.Log采樣值看有沒有異常浮點數(shù)再往下查。5.5 想放到UI界面LineRenderer卻不顯示現(xiàn)象把組件掛到Canvas下的子物體上波形完全看不見。原因LineRenderer是世界空間渲染組件不走UGUI的網(wǎng)格管線Canvas里看不到是正常的不是代碼問題。解決兩個可靠路線。一是用RenderTexture單獨用一個正交相機只渲染波形層輸出到RenderTexture再把Texture貼到RawImage上二是自繪UI網(wǎng)格用UIVertex生成多段線條這個方案性能更好但代碼量明顯更大。模擬訓(xùn)練屏這類場景RenderTexture雖然多一次相機渲染開銷但實現(xiàn)快波形所有參數(shù)調(diào)整邏輯都不用動。兩個方案都不需要在Canvas里掛LineRenderer。6. 進階與驗證數(shù)據(jù)源注入、參數(shù)核對和一句經(jīng)驗把EcgSignal.Sample換成AnimationCurve模板是這個組件最實用的升級。用AnimationCurve在Inspector里畫一個心動周期的波形輪廓采樣時直接用curve.Evaluate(phase)就可以不碰代碼讓美術(shù)或策劃調(diào)整波形形狀。代碼替換量很小核心只改一行float y _template.Evaluate(phase) * amplitude;這個用法適合團隊協(xié)作也適合接入真實心電數(shù)據(jù)把導(dǎo)出的心電樣本按周期歸一化后灌進AnimationCurve就能用真實數(shù)據(jù)驅(qū)動顯示只是注意數(shù)據(jù)本身的采樣率和噪聲導(dǎo)入前要做平滑處理。驗證參數(shù)是否正確的辦法很簡單把speed設(shè)為0波形靜止時數(shù)一數(shù)屏幕上完整心跳的個數(shù)對照心率、timeStep、spacing三者的換算關(guān)系。再寫一個R波相位檢測每次phase穿過0.34附近時打印間隔時間和60除以heartRate對比誤差應(yīng)在5%以內(nèi)。顏色和振幅的驗證則靠肉眼對照參考圖重點是先確認(rèn)時間軸正確再動視覺參數(shù)——我做過一次先把顏色調(diào)成熒光綠、結(jié)果波形根本沒滾動的調(diào)試屏幕上一片刺眼綠光排查半天才發(fā)現(xiàn)是時間軸沒推進。這套組件里先固定采樣參數(shù)和時間軸再調(diào)顏色和線寬能省下大部分冤枉時間。希望幫到你。本文還有配套的精品資源點擊獲取