矯正腳本:PD控制器與陀螺儀實(shí)戰(zhàn))
玩《太空工程師》的都知道飛船開著開著就歪了這件事有多煩。造了一艘大船推進(jìn)器不對稱、貨艙裝了重東西、或者說你手動轉(zhuǎn)向的時(shí)候總得不停按反方向鍵去“剎車”姿態(tài)一塌糊涂。后來我試著用游戲里的可編程塊寫腳本做飛船姿態(tài)矯正實(shí)測下來比手動穩(wěn)太多了。這篇帖子就把我踩過的坑和寫腳本的思路捋清楚給同樣想搞姿態(tài)矯正的朋友做個參考。我默認(rèn)你已經(jīng)至少跑過一個基礎(chǔ)的可編程塊腳本知道游戲內(nèi)C#是什么概念但細(xì)節(jié)不用怕我會把關(guān)鍵步驟都拆開說甚至新手按這個思路也能做出能用的姿態(tài)矯正。1. 為什么需要姿態(tài)矯正1.1 飛船在空中“跑偏”的高發(fā)場景先聊聊什么情況下你會需要姿態(tài)矯正而不是靠手動按鍵去頂。游戲里的飛船一旦超過幾十個小方塊手動控制的精度就會急劇下降。最常見的情況是一邊推進(jìn)器點(diǎn)得多、一邊點(diǎn)得少。哪怕你造的是標(biāo)準(zhǔn)對稱船戰(zhàn)斗損失、貨倉裝礦、鉆頭伸出去鉆小行星都可能導(dǎo)致飛船質(zhì)心偏移。這時(shí)候你按W往一個方向飛飛著飛著船就朝一邊轉(zhuǎn)。另一個很常見的就是太陽帆太陽能板對朝向敏感你要用它充電必須把船頭對準(zhǔn)太陽但一旦你手動調(diào)整過角度船就會慢慢晃開。還有一種是自動導(dǎo)航或者降落的時(shí)候你想讓飛船自動對正某個朝向比如對接舷窗的方向但手動去按軸轉(zhuǎn)總是要來回推最后要么轉(zhuǎn)過頭要么轉(zhuǎn)不到位。所以姿態(tài)矯正腳本最直接解決的就是“不靠人手按鍵、讓飛船自動穩(wěn)定在對準(zhǔn)的朝向”。1.2 手動矯正的痛點(diǎn)你先試試純手動打“姿態(tài)”按住鼠標(biāo)中鍵拖視角或者用陀螺儀控制方向你會發(fā)現(xiàn)船是有慣性的。你松開按鍵船并不是馬上停而是還會轉(zhuǎn)一段。你需要在快到位的時(shí)候提前按反方向鍵“剎車”但每次力度很難把握輕了剎不住重了就反向又轉(zhuǎn)了。要是船特別重陀螺儀轉(zhuǎn)速慢來回修正就更煩。更麻煩的是你在手動對正的時(shí)候沒法干別的比如你想一邊讓飛船對準(zhǔn)某個方向一邊去操作鉆頭純手動就很難。而腳本不一樣它每秒幾十次甚至上百次地檢測偏差、自動調(diào)節(jié)推進(jìn)器和陀螺儀的出力飛船就像被一只無形的手穩(wěn)穩(wěn)扶住。1.3 腳本能省掉哪些操作姿態(tài)矯正腳本可以做到點(diǎn)一下把船頭原地對準(zhǔn)指定朝向。自動鎖錕讓船在飛行時(shí)保持當(dāng)前姿態(tài)相對慣性不變。配合自瞄、對接、搜敵等自動操作騰出你的手去拉警報(bào)、啟動武器或切換視角。我寫這個腳本最主要的目的就是對準(zhǔn)太陽板方位時(shí)不再手動糾正其次就是對接的時(shí)候不再反復(fù)調(diào)方向。實(shí)際用下來只要陀螺儀數(shù)量夠、推進(jìn)器出力足腳本比老玩家手動操作還平滑。2. 姿態(tài)矯正腳本的核心原理2.1 游戲里的朝向是怎么表示的太空工程師里每個網(wǎng)格Grid有它自己的空間朝向這個世界坐標(biāo)系是三維的游戲引擎底層用四元數(shù)Quaternion來表示旋轉(zhuǎn)但在腳本里我們通常把它轉(zhuǎn)成歐拉角Euler Angle方便理解和控制。你可以把飛船的朝向想象成三個軸船頭朝向、船頂朝向、船右朝向。在代碼里我們用Transform的WorldMatrix來取得這三個方向向量。矯正姿態(tài)本質(zhì)上就是讓你船頭指向目標(biāo)方向同時(shí)船頂跟船右也固定在一個合適的狀態(tài)上也就是整體對正朝向。很多新手會把“正面朝向”理解成只要船頭對就行但實(shí)際游戲中比如對接、起降、用太陽帆都要求“船頂”或者“船側(cè)”也固定。比如你的船頭對著太陽但船頂如果不正刮到旁邊的小行星還是會麻煩。所以我的腳本里做了全軸矯正不是單軸鎖頭。2.2 PD控制器為什么直接補(bǔ)角度不行自動矯正光看“現(xiàn)在朝向”和“目標(biāo)朝向”的差是不夠的。假如你直接按誤差比例加推力船快到目標(biāo)位置時(shí)速度會特別大直接沖過頭然后反向再沖就會來回震蕩跟手動操作差不多。所以這里要用PD控制器比例項(xiàng)P項(xiàng)負(fù)責(zé)“偏了多少就補(bǔ)多少”微分項(xiàng)D項(xiàng)負(fù)責(zé)“船還在轉(zhuǎn)的時(shí)候提前剎一點(diǎn)”。D項(xiàng)相當(dāng)于阻尼它的作用就是當(dāng)你接近目標(biāo)朝向時(shí)根據(jù)角速度自動減弱控制力防止沖過頭。我用的核心公式大致是這樣誤差向量 目標(biāo)朝向向量 - 當(dāng)前朝向向量 角速度向量 陀螺儀的測量值每秒轉(zhuǎn)動多少弧度 控制力 誤差 * Kp - 角速度 * KdKp和Kd是調(diào)出來的系數(shù)調(diào)Kp越大”拉回力”越強(qiáng)調(diào)Kd越大“剎車”越強(qiáng)。如果Kp太大Kd太小飛船就來回晃如果Kd太大飛船反應(yīng)遲鈍像是沒力氣。2.3 推進(jìn)器與陀螺儀誰干誰的活姿態(tài)矯正有兩種執(zhí)行機(jī)構(gòu)陀螺儀Gyroscope和推進(jìn)器Thruster。陀螺儀是最適合的旋轉(zhuǎn)執(zhí)行器它的特點(diǎn)是不耗燃料、能直接用功率產(chǎn)生力矩但它的力矩有個上限由陀螺儀數(shù)量和質(zhì)量決定。如果你的飛船特別大一個陀螺儀根本掰不動得裝幾個并聯(lián)。陀螺儀的轉(zhuǎn)速和功率都可以通過腳本控制設(shè)置GyroOverride。推進(jìn)器也能噴出橫向推力產(chǎn)生旋轉(zhuǎn)力矩但會消耗燃料推力有限而且如果用反方向的推進(jìn)器剎車會特別費(fèi)油。不過在某些情況下——比如你想快速轉(zhuǎn)頭光用陀螺儀太慢可以同時(shí)調(diào)用左右兩側(cè)的推進(jìn)器做輔助偏航。我最終的方案是默認(rèn)用陀螺儀執(zhí)行姿態(tài)穩(wěn)定如果誤差較大超過30度短暫啟動側(cè)向推進(jìn)器輔助快速轉(zhuǎn)向等誤差小于閾值再切換回全陀螺儀模式。這樣既省燃料又保持平滑。3. 從零寫一個姿態(tài)矯正腳本3.1 腳本環(huán)境和基礎(chǔ)框架在太空工程師里腳本寫在編程塊Programmable Block的C#編輯器里游戲每幀或者你設(shè)定的Update頻率都會調(diào)用Main函數(shù)。你要做的事就是在這個Main里循環(huán)處理。首先你要拿到主飛船的引用我推薦用Me.CubeGrid獲取當(dāng)前網(wǎng)格。然后拿到所有陀螺儀和推進(jìn)器ListIMyGyro gyros new ListIMyGyro(); ListIMyThrust thrusters new ListIMyThrust(); Me.GridTerminalSystem.GetBlocksOfType(gyros); Me.GridTerminalSystem.GetBlocksOfType(thrusters);注意GetBlocksOfType會包含所有子網(wǎng)格的部件如果你的船有轉(zhuǎn)子或活塞可能會拿到別的部分的陀螺儀需要過濾掉不是本網(wǎng)格的gyros gyros.Where(g g.CubeGrid Me.CubeGrid).ToList();這一步很容易忽略不然你裝了連接的小船上的陀螺儀腳本會把它們一起控了。3.2 獲取當(dāng)前朝向與目標(biāo)朝向在Main里獲取飛船當(dāng)前位置和朝向IMyCubeGrid grid Me.CubeGrid; Vector3D shipForward grid.WorldMatrix.Forward; // 船頭方向 Vector3D shipUp grid.WorldMatrix.Up; // 船頂方向 Vector3D shipRight grid.WorldMatrix.Right; // 船右方向目標(biāo)朝向怎么定義最常見的是“讓船頭指向某個位置”比如你想讓船頭對準(zhǔn)某個GPS坐標(biāo)點(diǎn)那么目標(biāo)Forward向量就是Vector3D targetForward (targetGPS - grid.GetPosition()).Normalized();如果你還要求船頂方向也固定比如“船頂朝向上方”那么目標(biāo)Up向量可以取世界坐標(biāo)系的上方向。然后就構(gòu)建一個從當(dāng)前朝向到目標(biāo)朝向的旋轉(zhuǎn)求出旋轉(zhuǎn)軸和旋轉(zhuǎn)角度。C#里有個高級方法是計(jì)算四元數(shù)差但更直觀的是用叉積Vector3D rotationAxis Vector3D.Cross(shipForward, targetForward); rotationAxis.Normalize(); double angle Math.Asin(Vector3D.Dot(Vector3D.Cross(shipForward, targetForward).Length(), Vector3D.Dot(shipForward, targetForward) 0 ? 1 : -1));注意這里有個坑叉積算出的向量的長度就是sin角直接求Asin就能得到旋轉(zhuǎn)角。但Unity/游戲坐標(biāo)全靠右手系你要小心符號。我習(xí)慣是先算旋轉(zhuǎn)軸再根據(jù)需求計(jì)算控制方向的符號。3.3 扭轉(zhuǎn)控制信號限制轉(zhuǎn)速和推力陀螺儀的控制是通過一個方向向量和轉(zhuǎn)速大小來指定的。在游戲里你給陀螺儀的GyroOverride設(shè)一個Vector3D它就會按那個軸方向轉(zhuǎn)轉(zhuǎn)速由向量長度決定。把旋轉(zhuǎn)軸和誤差結(jié)合起來就能產(chǎn)生控制力矩Vector3D rotationError rotationAxis * controlGain;但為了防止船轉(zhuǎn)太快你要對向量長度做限制比如限制最大轉(zhuǎn)速為每秒0.5弧度約30度每秒超過就歸一化。然后對每個陀螺儀設(shè)定gyro.GyroOverride true; gyro.GyroOverrideAxis rotationError; gyro.SetValueFloat(GyroOverrideX, ...);實(shí)際操作中我用的是自定義屬性寫起來更方便簡化的設(shè)置方式如下var errorVector rotationAxis * scaledSpeed; foreach (IMyGyro g in gyros) { g.SetValue(Override, true); g.SetValue(GyroOverrideAxis, errorVector); }需要注意的是游戲里的陀螺儀默認(rèn)是按照網(wǎng)格局部坐標(biāo)來設(shè)置Override軸的所以你要把世界坐標(biāo)的旋轉(zhuǎn)軸轉(zhuǎn)換到網(wǎng)格局部坐標(biāo)。用Vector3D.Transform(rotationAxis, MatrixD.Transpose(grid.WorldMatrix))轉(zhuǎn)換。3.4 完整腳本骨架我把腳本的關(guān)鍵骨架放在下面你可以在自己的編程塊里直接改改參數(shù)跑public Program() { Runtime.UpdateFrequency UpdateFrequency.Update10; } public void Main(string argument, UpdateType updateSource) { IMyGridTerminalSystem gridTerminal GridTerminalSystem; var gyros new ListIMyGyro(); var thrusters new ListIMyThrust(); gridTerminal.GetBlocksOfType(gyros); gridTerminal.GetBlocksOfType(thrusters); // 過濾子網(wǎng)格 gyros gyros.Where(g g.CubeGrid Me.CubeGrid).ToList(); thrusters thrusters.Where(t t.CubeGrid Me.CubeGrid).ToList(); Vector3D shipPos Me.CubeGrid.GetPosition(); Vector3D targetPos new Vector3D(0, 0, 0); // 這里替換為你的目標(biāo)GPS坐標(biāo) Vector3D currentForward Me.CubeGrid.WorldMatrix.Forward; Vector3D targetForward Vector3D.Normalize(targetPos - shipPos); Vector3D rotationAxis Vector3D.Cross(currentForward, targetForward); double angle Math.Asin(MathHelper.Clamp(rotationAxis.Length(), -1.0, 1.0)); rotationAxis Vector3D.Normalize(rotationAxis); // 如果角度太小就不需要矯正 if (Math.Abs(angle) 0.01) { SetGyroControl(Vector3D.Zero, 0.0f); return; } // 控制增益 float kp 3.0f; float kd 0.5f; // 這里讀取角速度需要從陀螺儀列表讀取 Vector3D shipAngularVelocity gyros.First().GetVelocity(); // 投影到旋轉(zhuǎn)軸 double angularVel Vector3D.Dot(shipAngularVelocity, rotationAxis); // 控制量 double control angle * kp - angularVel * kd; double maxSpeed 0.5; control MathHelper.Clamp(control, -maxSpeed, maxSpeed); Vector3D overrideVec rotationAxis * control; // 轉(zhuǎn)換到局部坐標(biāo) Vector3D localOverride Vector3D.Transform(overrideVec, MatrixD.Transpose(Me.CubeGrid.WorldMatrix)); foreach (IMyGyro g in gyros) { g.Orientation Me.CubeGrid.Orientation; // 確保旋轉(zhuǎn)對齊 g.SetValueFloat(Override, 1f); g.SetValue(GyroOverrideAxis, localOverride); } // 輔助推進(jìn)器 if (Math.Abs(angle) 0.3) { // 啟動推進(jìn)器輔助這里省略只演示陀螺儀 } } void SetGyroControl(Vector3D localOverride, float speed) { foreach (IMyGyro g in gyros) { g.SetValueFloat(Override, speed); g.SetValue(GyroOverrideAxis, localOverride); } }這個代碼只是演示基礎(chǔ)流程實(shí)際使用還要處理角速度讀取比如從陀螺儀接口里拿實(shí)際的角速度、目標(biāo)朝向在滾動方向上的穩(wěn)定控制。如果你只想鎖定船頭方向不關(guān)心滾動只控制兩個軸即可如果要全姿態(tài)矯正你還需要再算一個“繞Forward軸”的旋轉(zhuǎn)誤差控制船頂和船右的方向。4. 實(shí)戰(zhàn)中的坑與調(diào)試技巧4.1 陀螺儀力不夠怎么瘋狂轉(zhuǎn)不動很多人的船很大但只裝了2個陀螺儀這時(shí)候就算你的控制算法正確飛船也反應(yīng)很慢。我在一艘50萬公斤的運(yùn)輸船上裝了4個大陀螺儀才勉強(qiáng)能在5秒內(nèi)轉(zhuǎn)90度。解決辦法增加陀螺儀數(shù)量并且把Override軸設(shè)成局部坐標(biāo)注意陀螺儀本身也有方向和朝向在網(wǎng)格里裝歪了會導(dǎo)致軸混疊。陀螺儀設(shè)置里有一個“轉(zhuǎn)速上限”默認(rèn)可能很低我在腳本里直接把MaxOverrideSpeed調(diào)大一點(diǎn)。如果陀螺儀真的不夠你可以在轉(zhuǎn)向時(shí)用左右舷的推進(jìn)器做輔助轉(zhuǎn)角。我測試過對于大型船最好同時(shí)控制橫搖的推進(jìn)器用噴氣背包和推進(jìn)器的側(cè)噴否則轉(zhuǎn)向角度大時(shí)陀螺儀要轉(zhuǎn)半天。4.2 方向判斷相反左右不分寫這個腳本最常的bug就是Override軸的方向?qū)懛戳?。因?yàn)橥勇輧x的方向向量是“右手定則”而我用的是叉集很可能算出來的方向正好相反。解決辦法就是加入一個手動“反轉(zhuǎn)”變量bool invert true;如果發(fā)現(xiàn)船越調(diào)越偏就把rotationAxis乘以-1。我在調(diào)試時(shí)開著駕駛艙看著船慢慢地往反方向轉(zhuǎn)就知道是這里的正負(fù)號反了改一次就行。另外如果你腳本里控制的目標(biāo)是船頭對準(zhǔn)GPS但陀螺儀被裝反了也會造成方向混亂。所以在代碼里我用gyro.Orientation和網(wǎng)格Orientation做了一次匹配盡量讓陀螺儀的本地方向和網(wǎng)格方向一致。4.3 游戲內(nèi)Update頻率與續(xù)航游戲內(nèi)腳本不是每幀都跑而是基于UpdateFrequency。我用Update10是每10個Tick約每秒6次跑一次如果你要求非常平滑可以改成Update1每幀但這樣會消耗更多游戲性能在服務(wù)器里還可能被管理員限制。一般來說姿態(tài)矯正不用每幀都跑每秒6次和每秒60次效果差不多因?yàn)橥勇輧x自身的響應(yīng)本來就有滯回。不過你注意PD控制器的Kd要根據(jù)更新頻率來調(diào)頻率低就適當(dāng)減小Kd否則會出現(xiàn)控制量亂跳。4.4 堆棧溢出與心跳計(jì)數(shù)有一段時(shí)間我的腳本跑久了會卡死。排查發(fā)現(xiàn)是花了大量時(shí)間在GetBlocksOfType它每次都掃描整個網(wǎng)格所以我改進(jìn)成在構(gòu)造方法Program()里只查一次把列表緩存下來只用引用更新。另一個小技巧是加一個調(diào)試輸出用Echo(angle...);在編程塊的LCD上顯示角度誤差和輸出力矩很方便觀察有沒有調(diào)過頭。4.5 多軸鎖定別只用船頭如果你的船頭對準(zhǔn)了目標(biāo)但船身還在慢慢滾這是因?yàn)檩啌Q軸沒有控制。需要再檢測船頂方向與目標(biāo)Up方向的夾角施加一個繞Forward的俯仰/偏航力矩來穩(wěn)定。我后來在第2版里加入了“LockFullAttitude”開關(guān)可以用參數(shù)控制是否要求船頂也固定。對接、入塢時(shí)用全鎖定向巡航時(shí)只鎖船頭方便省燃料。5. 調(diào)試參數(shù)速查表這里給你一個我自己調(diào)參時(shí)的對照表你直接拿你的船去試Kp先從小調(diào)大Kd從大調(diào)小有一組很合適的工作點(diǎn)場景KpKd結(jié)果船太小陀螺儀響應(yīng)快125非常穩(wěn)但反應(yīng)快中型貨船4陀螺儀52平滑不震超大量級10陀螺儀輔助推力31.5大角度轉(zhuǎn)向平穩(wěn)剛開始測試20.5安全不飛掉記住原則如果船在目標(biāo)位置前后震蕩把Kp調(diào)小或Kd調(diào)大如果反應(yīng)遲緩轉(zhuǎn)不動把Kp調(diào)大或Kd調(diào)小。6. 進(jìn)階把姿態(tài)矯正接入自動駕駛當(dāng)你把姿態(tài)矯正腳本跑通后最大的瓶頸不再是“能不能轉(zhuǎn)”而是“怎么和游戲里的自動導(dǎo)航協(xié)調(diào)”。我的做法是給編程塊加一個參數(shù)接口在Main里讀取字符串參數(shù)比如target:GPS:X:Y:Z:0或者用attitude:lock來切換自動矯正模式。然后通過定時(shí)器塊連接讓駕駛員按一下面板上的某個按鍵就執(zhí)行一次轉(zhuǎn)向。這樣你可以在一個控制面板里集成多個預(yù)設(shè)朝向起飛朝向、入港朝向、太陽帆朝向。甚至可以用腳本監(jiān)聽傳感器探測到障礙物后自動把船側(cè)偏向不過這就是另一個項(xiàng)目了。我實(shí)測下來姿態(tài)矯正腳本只要跑起來、調(diào)參調(diào)穩(wěn)基本不怎么需要再管。用了它之后我手動操作率降了七八成再也沒掉進(jìn)以前那種“按W轉(zhuǎn)圈”的尷尬。最后再分享一個小技巧腳本跑多了之后也許你會感覺某個朝向總是鎖定不到位那不是算法問題很可能是你這個網(wǎng)格的重心偏偏到一側(cè)了做一個穩(wěn)定的姿態(tài)矯正不如把重心對齊一下。先用腳本把船頭指向某個方向然后關(guān)掉矯正看它往哪側(cè)漂就用這個信息去做配重。按這個順序來遠(yuǎn)比單純調(diào)Kp和Kd更有效。