用 PlayerLoopSystem 理解 Unity 主迴圈,並解釋 SyncTransforms 的執行時機
2019-11-17 16:30:00
1.2k words / 5-minute read
目錄 Table of contents
  1. 原由
  2. 流程
  3. 測試結果
  4. 結論
  5. 2021/08/29更新

本篇文章撰寫於2019/11/17,版本為 Unity2018.4

原由

最近卡在一個問題:
Physics2D.autoSyncTransforms = false 的情況下,Unity內部到底何時會進行 Sync?」
根據官方工程師的講法,如果關閉時應該是只會在FixedUpdate 以前 Sync 一次,其他時間點不會同步。
但經過測試,疑似在 Update 期間也會 Sync 啊?
偏偏Unity官網提供的執行順序圖根本沒有寫到何時Sync。

於是後來翻到了 Unity 有提供一個實驗性的底層 API : PlayerLoop
它號稱可以讓使用者修改 Unity 主迴圈,因此這對我們分析 Unity 執行順序非常有幫助。

流程

將整個主迴圈列出來:

ROOT NODE
	Initialization
		PlayerUpdateTime
		AsyncUploadTimeSlicedUpdate
		SynchronizeInputs
		SynchronizeState
		XREarlyUpdate
	EarlyUpdate
		PollPlayerConnection
		ProfilerStartFrame
		GpuTimestamp
		AnalyticsCoreStatsUpdate
		UnityWebRequestUpdate
		ExecuteMainThreadJobs
		ProcessMouseInWindow
		ClearIntermediateRenderers
		ClearLines
		PresentBeforeUpdate
		ResetFrameStatsAfterPresent
		UpdateAllUnityWebStreams
		UpdateAsyncReadbackManager
		UpdateStreamingManager
		UpdateTextureStreamingManager
		UpdatePreloading
		RendererNotifyInvisible
		PlayerCleanupCachedData
		UpdateMainGameViewRect
		UpdateCanvasRectTransform
		XRUpdate
		UpdateInputManager
		ProcessRemoteInput
		ScriptRunDelayedStartupFrame
		UpdateKinect
		DeliverIosPlatformEvents
		TangoUpdate
		DispatchEventQueueEvents
		DirectorSampleTime
		PhysicsResetInterpolatedTransformPosition
		SpriteAtlasManagerUpdate
		PerformanceAnalyticsUpdate
	FixedUpdate
		ClearLines
		NewInputFixedUpdate
		DirectorFixedSampleTime
		AudioFixedUpdate
		ScriptRunBehaviourFixedUpdate
		DirectorFixedUpdate
		LegacyFixedAnimationUpdate
		XRFixedUpdate
		PhysicsFixedUpdate
		Physics2DFixedUpdate
		DirectorFixedUpdatePostPhysics
		ScriptRunDelayedFixedFrameRate
	PreUpdate
		PhysicsUpdate
		Physics2DUpdate
		CheckTexFieldInput
		IMGUISendQueuedEvents
		NewInputUpdate
		SendMouseEvents
		AIUpdate
		WindUpdate
		UpdateVideo
	Update
		ScriptRunBehaviourUpdate
		ScriptRunDelayedDynamicFrameRate
		ScriptRunDelayedTasks
		DirectorUpdate
	PreLateUpdate
		AIUpdatePostScript
		DirectorUpdateAnimationBegin
		LegacyAnimationUpdate
		DirectorUpdateAnimationEnd
		DirectorDeferredEvaluate
		UNetUpdate
		EndGraphicsJobsAfterScriptUpdate
		ParticleSystemBeginUpdateAll
		ScriptRunBehaviourLateUpdate
		ConstraintManagerUpdate
	PostLateUpdate
		PlayerSendFrameStarted
		DirectorLateUpdate
		ScriptRunDelayedDynamicFrameRate
		PhysicsSkinnedClothBeginUpdate
		UpdateRectTransform
		UpdateCanvasRectTransform
		PlayerUpdateCanvases
		UpdateAudio
		VFXUpdate
		ParticleSystemEndUpdateAll
		EndGraphicsJobsAfterScriptLateUpdate
		UpdateCustomRenderTextures
		UpdateAllRenderers
		EnlightenRuntimeUpdate
		UpdateAllSkinnedMeshes
		ProcessWebSendMessages
		SortingGroupsUpdate
		UpdateVideoTextures
		UpdateVideo
		DirectorRenderImage
		PlayerEmitCanvasGeometry
		PhysicsSkinnedClothFinishUpdate
		FinishFrameRendering
		BatchModeUpdate
		PlayerSendFrameComplete
		UpdateCaptureScreenshot
		PresentAfterDraw
		ClearImmediateRenderers
		PlayerSendFramePostPresent
		UpdateResolution
		InputEndFrame
		TriggerEndOfFrameCallbacks
		GUIClearEvents
		ShaderHandleErrors
		ResetInputAxis
		ThreadedLoadingDebug
		ProfilerSynchronizeStats
		MemoryFrameMaintenance
		ExecuteGameCenterCallbacks
		ProfilerEndFrame

蠻複雜的,需要一個視覺化的UI比較好觀察。

在網路上找到一位網友 Lotte 用 PlayerLoop 寫了一個視覺化看主迴圈執行順序的腳本
這不但是一個很好參考的使用案例,更是一個功能強大的腳本。
也因此接下來就用這個腳本來修改成我要的功能。

首先,先建一個 Singleton 的測試腳本掛在有 Rigidbody2D 的物件上。
腳本寫了一個檢查是否 Sync 的方法,回傳 True 代表已經 Sync 了。

public static bool TestIfSyncTransforms() {
    // Singleton.
    if (s_Instance == null) {
        return false;
    }
    // Check if sync transform.
    if ((Vector2)s_Instance.transform.position == s_Instance.m_Rigidbody2D.position) {
        // Modify transform for next checking.
        s_Instance.transform.position += Vector3.right;
        return true;
    }
    return false;
}

在 Default PlayerLoop 內將的每個 Update 之間插入一個 CustomSyncTestUpdate。

// 修改Lotte腳本內的方法
PlayerLoopSystem GenerateCustomLoop() {
    // Note: this also resets the loop to its defalt state first.
    var playerLoop = PlayerLoop.GetDefaultPlayerLoop();
    hasCustomPlayerLoop = true;
    
    // 僅插入從FixedUpdate至Update之間的所有內部Update
    int subSystemListFrom = 2; // Fixed Update
    int subSystemListTo = 4; // Update

    for (int i = subSystemListFrom; i <= subSystemListTo; i++) {
        var updateSystem = playerLoop.subSystemList[i];
        var newList = new List<PlayerLoopSystem>(updateSystem.subSystemList);
        // Insert the start event.
        newList.Insert(0, CustomSyncTestUpdate.GetNewSystem(string.Format("{0} Start", updateSystem.type.Name)));
        for (int j = 1; j < newList.Count; j++) {
            var subUpdate = newList[j];
            newList.Insert(j + 1, CustomSyncTestUpdate.GetNewSystem(string.Format("After {0}", subUpdate.type.Name)));
            j += 1;
        }
        // convert the list back to an array and plug it into the Update system.
        updateSystem.subSystemList = newList.ToArray();
        // dont forget to put our newly edited System back into the main player loop system!!
        playerLoop.subSystemList[i] = updateSystem;
    }

    return playerLoop;
}
public struct CustomSyncTestUpdate {
    public static PlayerLoopSystem GetNewSystem(string testScope) {
        return new PlayerLoopSystem() {
            type = typeof(CustomSyncTestUpdate),
            updateDelegate = () => UpdateFunction(testScope)
        };
    }

    public static void UpdateFunction(string testScope) {
        bool sync = SyncTransformsTester.TestIfSyncTransforms();
        if (sync) {
            // 若有Sync則Log以紅字顯示
            Debug.LogFormat("<color=red>[{0}]: {1}</color>", testScope, sync);
        } else {
            Debug.LogFormat("[{0}]: {1}", testScope, sync);
        }
    }
}

插入後的主迴圈長這樣:

另外在測試物件上的腳本也添加以下 Code 來檢查是否 Sync :
(Log 中以綠色字體顯示)

private void FixedUpdate() {
    Debug.Log("<color=green>FixedUpdate!</color>");
    bool sync = TestIfSyncTransforms();
    if (sync) {
        Debug.LogFormat("<color=green>[{0}]: {1}</color>", "FixedUpdate", sync);
    }
}

private void Update() {
    Debug.Log("<color=green>Update!</color>");
    bool sync = TestIfSyncTransforms();
    if (sync) {
        Debug.LogFormat("<color=green>[{0}]: {1}</color>", "Update", sync);
    }
}

private void LateUpdate() {
    Debug.Log("<color=green>LateUpdate!</color>");
    bool sync = TestIfSyncTransforms();
    if (sync) {
        Debug.LogFormat("<color=green>[{0}]: {1}</color>", "Update", sync);
    }
}

測試結果

經 Log 發現,僅在 Physics2DFixedUpdate 時更新。

照順序來看,可以得知 Physics2DFixedUpdate 就是 InternalPhysicsUpdate。
而且 Sync 只會在 InternalPhysicsUpdate 的時候才更新。照工程師的說法,應該是一進去就先 Sync。
經測試,以 Transform.position 為優先,Rigidbody2D.position 會直接被蓋掉。

但是之前若將 Rigidbody2D.Interpolation 打開,會變以下結果:

多了個 Physics2DUpdate 的時候會 Sync。

這個 Physics2DUpdate 是每次 Update() 以前都會跑的,位於 PreUpdate 階段。

可以理解成,Interpolation 修改 transform.position 的地方就是這個 Physics2DUpdate。
所以當他發現 transform.position 是 dirty 狀態時會直接 Sync,而不會覆蓋過去。

結論

當你要修改 Rigidbody2D 物件的座標時,不管改 Transform.position 或是 Rigidbody2D.position 本身都可行,但有以下注意事項:

  • 若同時修改 Rigidbody 和 Transform,以 Transform 為主。
  • 根據官網說明,修改Transform效能較差,因為它需要多走Sync的步驟。
  • 關閉 autoSyncTransforms 的情況下:
    • 沒有開啟 Interpolation 時,每次 InternalPhysics 執行前才會 Sync。
    • 開啟 Interpolation 時,除了每次 InternalPhysics 執行前會 Sync 以外,每次 Update 以前也會 Sync。
  • 所以頻繁更新 Transform 的情況下就別開 Interpolation 了,因為有開跟沒開一樣都會被蓋掉。

2021/08/29更新

最近在測試時發現Unity2019.4已經不是上面那個邏輯了==
差別是不管有沒有開啟Interpolation,每次Update以前都會在PreUpdate.Physics2DUpdate階段Sync回Rigidbody。