【翻譯】Godot 是獨立遊戲的新寵兒嗎?Godot API 繫結系統的大討論!

發表於2023-09-23

最近,因為 Unity 的謎之操作,大量的 Unity 開發者外流尋找可替代 Unity 的遊戲引擎。Godot 因為支援 C# 開發,4.0 版本後功能相對完善起來,所以國內外 Unity 開發者對其關注度非常高,因此也展開了不少關於 Godot 能否替代 Unity 的討論。

其中流傳最廣的討論之一就是 Sam pruden 在 Reddit 論壇上對於 Godot API 呼叫過程效能的質疑。文章中詳細研究並測試了 Godot 射線檢測的效能,並對引擎核心和各語言 API 間的繫結層的設計提出了質疑。

隨後,Godot 的核心開發人員之一 —— Juan Linietsky 對其質疑進行了回覆和解釋,並講解了 Godot 對繫結層和 GDExtension 的定位和設計思路。

譯者在圍觀吃瓜的過程中受益頗多,學習了很多關於遊戲效能最佳化方面的思路,所以趕忙翻譯了兩位的文章,供大家一起交流學習。

直到譯者熬夜翻譯完的第二天,這場交流討論還在火熱地進行著,想要圍觀的小夥伴可以去 Github GistReddit 圍觀各路大佬的討論。


Sam pruden 對 Reddit 論壇中觀點的總結:

Godot 不是新的 Unity - 對 Godot API 呼叫的剖析

By Sam Pruden

原文地址:Godot is not the new Unity - The anatomy of a Godot API call

譯者 溫吞

本文章僅用作學習交流使用,如侵刪

更新:這篇文章開啟了和 Godot 開發人員的持續對話。他們很關心文中提出的問題,並想進行改進。肯定會做出重大的改變——儘管還為時尚早,且不清楚會進行怎樣的改變和何種程度的改變。我從大家的回覆中得到了鼓勵。我相信 Godot 的未來一定是十分光明的。

像很多人一樣,過去的幾天裡我一直在尋找新的 Unity。Godot 潛力不錯,特別是如果它能好好利用大量湧入的開發人才來快速推動改變的話。開源在這方面就是很棒。然而,有一個重要的問題在阻礙它的發展——在引擎程式碼和遊戲程式碼之間的繫結層使用了一種很緩慢的結構,如果不將一切推倒重來並重建整個API的話,就很難修復。

Godot 已經被用來建立了一些很成功的遊戲,所以這個問題並不總是一個阻礙。然而,Unity 在過去的五年內一直致力於用一些很瘋狂的專案來提高指令碼的執行速度,例如構建了兩個自定義編譯器、SIMD 數學庫、自定義回收和分配,當然還有龐大的(且還有很多未完成的)ECS 專案。自 2018 年之後,這些一直是他們的 CTO 關注的重點。很顯然 Unity 確信指令碼效能對他們的大部分使用者群很重要。切換到 Godot 不僅像回到了五年前的 Unity —— 甚至更糟糕。

幾天前,我在 Reddit 的 Godot 子版塊上對此進行了一場有爭議但富有成效的討論。 這篇文章是我在那篇文章中想法更詳細的延續,現在我對 Godot 的工作原理有了更多的瞭解。在此明確一點:我仍然是一個 Godot 新手,這篇文章可能會包含錯誤和誤解。

注:以下包含對 Godot 引擎設計和工程的批評。 雖然我偶爾會使用一些情緒化的語言來描述我對這些事情的感受,但 Godot 開發者為開源社群付出了很多努力,並創作了讓很多人喜愛的東西,我的目的並不是冒犯或故意對某些人表現得粗魯。

深入研究 C# 對射線檢測的執行

我們將深入探討在 Godot 中如何實現與 Unity 的 Physics2D.Raycast 相當的效果,以及當我們使用它時會發生什麼。 為了使探討更加具體,我們首先在 Unity 中實現一個簡單的函式。

Unity

// Unity 中的簡單射線檢測
bool GetRaycastDistanceAndNormal(Vector2 origin, Vector2 direction, out float distance, out Vector2 normal) {
    RaycastHit2D hit = Physics2D.Raycast(origin, direction);
    distance = hit.distance;
    normal = hit.normal;
    return (bool)hit;
}

讓我們透過跟蹤這些呼叫來快速瞭解一下這是如何實現的。

public static RaycastHit2D Raycast(Vector2 origin, Vector2 direction)
 => defaultPhysicsScene.Raycast(origin, direction, float.PositiveInfinity);

public RaycastHit2D Raycast(Vector2 origin, Vector2 direction, float distance, [DefaultValue("Physics2D.DefaultRaycastLayers")] int layerMask = -5)
{
    ContactFilter2D contactFilter = ContactFilter2D.CreateLegacyFilter(layerMask, float.NegativeInfinity, float.PositiveInfinity);
    return Raycast_Internal(this, origin, direction, distance, contactFilter);
}

[NativeMethod("Raycast_Binding")]
[StaticAccessor("PhysicsQuery2D", StaticAccessorType.DoubleColon)]
private static RaycastHit2D Raycast_Internal(PhysicsScene2D physicsScene, Vector2 origin, Vector2 direction, float distance, ContactFilter2D contactFilter)
{
    Raycast_Internal_Injected(ref physicsScene, ref origin, ref direction, distance, ref contactFilter, out var ret);
    return ret;
}

[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void Raycast_Internal_Injected(
    ref PhysicsScene2D physicsScene, ref Vector2 origin, ref Vector2 direction, float distance,
    ref ContactFilter2D contactFilter, out RaycastHit2D ret);

好的,所以它做了一些少量的操作,並透過修飾符機制將呼叫有效地分流到非託管的引擎核心。 這很合理,我相信 Godot 做的也差不多。 烏鴉嘴。

譯者注:託管和非託管都是 C# 中的重要概念,非託管程式碼必須提供自己的垃圾回收、型別檢查、安全支援等服務。

Godot

讓我們在 Godot 中也做一遍,完全按照教程的建議

// 在 Godot 中同等效果的射線檢測
bool GetRaycastDistanceAndNormal(Vector2 origin, Vector2 direction, out float distance, out Vector2 normal)
{
    World2D world = GetWorld2D();
    PhysicsDirectSpaceState2D spaceState = world.DirectSpaceState;
    PhysicsRayQueryParameters2D queryParams = PhysicsRayQueryParameters2D.Create(origin, origin + direction);
    Godot.Collections.Dictionary hitDictionary = spaceState.IntersectRay(queryParams);

    if (hitDictionary.Count != 0)
    {
        Variant hitPositionVariant = hitDictionary[(Variant)"position"];
        Vector2 hitPosition = (Vector2)hitPositionVariant;
        Variant hitNormalVariant = hitDictionary[(Variant)"normal"];
        Vector2 hitNormal = (Vector2)hitNormalVariant;
        
        distance = (hitPosition - origin).Length();
        normal = hitNormal;
        return true;
    }

    distance = default;
    normal = default;
    return false;
}

我們首先就會注意到的是程式碼更長了。這不是我批評的重點,部分原因是我對這段程式碼進行了冗長的格式化,以便我們更容易地逐行分解它。那麼我們來看看,執行時發生了什麼?

我們首先呼叫了 GetWorld2D()。在 Godot 中,物理查詢都是在世界的上下文中執行的,這個函式獲取了我們程式碼所在的正在執行的世界。儘管 World2D 是一個託管型別,這個函式並沒有做什麼瘋狂的事情比如在每次執行時給它分配記憶體。這些函式都不會為了一個簡單的射線檢測做這種瘋狂的事,對吧?又烏鴉嘴。

如果我們深入研究這些 API 呼叫,我們會發現,即使是這些表面上簡單的呼叫,也是透過一些相當複雜的機制實現的,這些機制多少會帶來一些效能開銷。讓我們深入研究 GetWorld2D 作為一個例子,解析它在 C# 中的呼叫。這大致就是所有返回託管型別的呼叫的樣子。 我新增了一些註釋來解釋發生了什麼。

// 這是我們研究的函式。
public World2D GetWorld2D()
{
    // MethodBind64 是一個指向我們在 C++ 中呼叫的函式的指標。
    // MethodBind64 儲存在靜態變數中,所以我們必須透過記憶體查詢來檢索它。
    return (World2D)NativeCalls.godot_icall_0_51(MethodBind64, GodotObject.GetPtr(this));
}

// 我們呼叫了這些調解 API 呼叫的函式
internal unsafe static GodotObject godot_icall_0_51(IntPtr method, IntPtr ptr)
{
    godot_ref godot_ref = default(godot_ref);

    // try/finally 機制不是沒有代價的。它引入了一個狀態機。
    // 它還可以阻止 JIT 最佳化
    try
    {
        // 驗證檢查,即使這裡的一切都是內部的、應該被信任的。
        if (ptr == IntPtr.Zero) throw new ArgumentNullException("ptr");

        // 這會呼叫另一個函式,這個函式用於執行函式指標指向的函式
        // 並透過指標的方式將非託管的結果放入 godot_ref
        NativeFuncs.godotsharp_method_bind_ptrcall(method, ptr, null, &godot_ref);
        
        // 這是用於對超過 C#/C++ 邊界的託管物件進行引用遷移的某些機制
        return InteropUtils.UnmanagedGetManaged(godot_ref.Reference);
    }
    finally
    {
        godot_ref.Dispose();
    }
}

// 實際呼叫函式指標的函式
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static partial void godotsharp_method_bind_ptrcall( global::System.IntPtr p_method_bind,  global::System.IntPtr p_instance,  void** p_args,  void* p_ret)
{
    // 但是等一下!
    // _unmanagedCallbacks.godotsharp_method_bind_ptrcall 實際上是
    // 對儲存另一個函式指標的靜態變數的訪問
    _unmanagedCallbacks.godotsharp_method_bind_ptrcall(p_method_bind, p_instance, p_args, p_ret);
}

// 老實說,我對這裡的研究還不夠深入,以至於不能確切地搞懂這裡發生了什麼。
// 基本思想很簡單 —— 這裡有一個指向非託管 GodotObject 的指標,
// 將其帶給 .Net,通知垃圾回收器以便可以跟蹤它,並將其轉換為 GodotObject 型別。
// 幸運的是,這似乎沒有進行任何記憶體分配。烏鴉嘴。
public static GodotObject UnmanagedGetManaged(IntPtr unmanaged)
{
    if (unmanaged == IntPtr.Zero) return null;

    IntPtr intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_script_instance_managed(unmanaged, out var r_has_cs_script_instance);
    if (intPtr != IntPtr.Zero) return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
    if (r_has_cs_script_instance.ToBool()) return null;

    intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_instance_binding_managed(unmanaged);
    object obj = ((intPtr != IntPtr.Zero) ? GCHandle.FromIntPtr(intPtr).Target : null);
    if (obj != null) return (GodotObject)obj;

    intPtr = NativeFuncs.godotsharp_internal_unmanaged_instance_binding_create_managed(unmanaged, intPtr);
    if (!(intPtr != IntPtr.Zero)) return null;

    return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
}

這實際上是一筆很大的開銷。在我們的程式碼和 C++ 之間有指標的多層間接引用。 其中每一個都是記憶體查詢,最重要的是我們做了一些驗證工作,try finally,並解釋執行返回的指標。 這些可能聽起來像是微不足道的事情,但是當對核心的每次呼叫以及 Godot 物件上的每個屬性/欄位訪問都要經歷整個過程時,它就開始累積起來。

如果我們去看對 world.DirectSpaceState 屬性進行訪問的下一行,我們會發現它做了幾乎相同的事情。 透過此機制,PhysicsDirectSpaceState2D 會再次從 C++ 中檢索。 別擔心,我不會煩人地再演示一遍細節了!

接下來的這行才是第一個真正讓我大吃一驚的事情。

PhysicsRayQueryParameters2D queryParams = PhysicsRayQueryParameters2D.Create(origin, origin + direction);

有什麼大不了的,這只是一個封裝了一些射線檢測引數的小結構,對吧?錯誤的

PhysicsRayQueryParameters2D 是一個託管類,這是一次可以觸發 Full GC 的記憶體分配。在效能敏感的熱路徑中做這件事太瘋狂了!我確信這只是一次記憶體分配,對嗎? 讓我們看看裡面。

譯者注:熱路徑 (hot path)是程式非常頻繁執行的一系列指令。

// 摘要:
//     返回了一個新的、預配置的 Godot.PhysicsRayQueryParameters2D 物件。
//     使用它可以用常用選項來快速建立請求引數。
//     var query = PhysicsRayQueryParameters2D.create(global_position, global_position
//     + Vector2(0, 100))
//     var collision = get_world_2d().direct_space_state.intersect_ray(query)
public unsafe static PhysicsRayQueryParameters2D Create(Vector2 from, Vector2 to, uint collisionMask = uint.MaxValue, Array<Rid> exclude = null)
{
    // 是的,這經歷了上面討論的所有相同機制。
    return (PhysicsRayQueryParameters2D)NativeCalls.godot_icall_4_731(
        MethodBind0,
        &from, &to, collisionMask,
        (godot_array)(exclude ?? new Array<Rid>()).NativeValue
    );
}

啊哦…你發現了嗎?

那個 Array<Rid>Godot.Collections.Array。這是另一種託管類。看看當我們傳入一個 null 值會發生什麼。

(godot_array)(exclude ?? new Array<Rid>()).NativeValue

沒錯,即使我們不傳遞一個 exclude 陣列,它也會繼續在 C# 堆上為我們分配一個完整的陣列,以便它可以立即將其轉換回表示空陣列的預設值。

為了將兩個簡單的 Vector2 值(16 位元組)傳遞給射線檢測函式,我們現在搞了兩個獨立的垃圾分別建立了堆分配,總計 632 位元組!

稍後你就會看到,我們可以透過快取 PhysicsRayQueryParameters2D 來緩解這個問題。然而,正如你從我上面提到的文件教程中看到的那樣,API 明確期望並建議為每個射線檢測建立新的例項。

讓我們進入下一行,簡直不能再瘋狂了,對吧?還是烏鴉嘴。

Godot.Collections.Dictionary hitDictionary = spaceState.IntersectRay(queryParams);

後生仔,這塊的問題好像看不太出來啊。

哈,我們的射線檢測返回的是一個非型別化的字典。是的,它在託管堆上又分配了 96 個位元組來建立垃圾。我允許你現在做出一副困惑和不安的表情:“哦,好吧,如果它沒有擊中任何東西,也許它至少會返回 null?”你可能在想。不會。如果沒有命中任何內容,它會分配並返回一個空字典。

讓我們直接跳到 C++ 實現。

Dictionary PhysicsDirectSpaceState2D::_intersect_ray(const Ref<PhysicsRayQueryParameters2D> &p_ray_query) {
    ERR_FAIL_COND_V(!p_ray_query.is_valid(), Dictionary());

    RayResult result;
    bool res = intersect_ray(p_ray_query->get_parameters(), result);

    if (!res) {
        return Dictionary();
    }

    Dictionary d;
    d["position"] = result.position;
    d["normal"] = result.normal;
    d["collider_id"] = result.collider_id;
    d["collider"] = result.collider;
    d["shape"] = result.shape;
    d["rid"] = result.rid;

    return d;
}

// 這是內部的 intersect_ray 接收的引數結構體
// 這塊沒什麼太瘋狂的地方(除了那個 exclude 可以改進下)
struct RayParameters {
    Vector2 from;
    Vector2 to;
    HashSet<RID> exclude;
    uint32_t collision_mask = UINT32_MAX;
    bool collide_with_bodies = true;
    bool collide_with_areas = false;
    bool hit_from_inside = false;
};

// 這裡是輸出。射線檢測的完全合理的返回值。
struct RayResult {
    Vector2 position;
    Vector2 normal;
    RID rid;
    ObjectID collider_id;
    Object *collider = nullptr;
    int shape = 0;
};

就像我們看到的一樣,本來完美無缺的射線檢測函式被包裝得稀爛、慢得讓人發瘋。內部的 intersect_ray 才是應該出現在 API 裡的函式!

這段 C++ 程式碼在非託管堆上分配了一個無型別字典。如果我們深入研究這個字典,我們會發現一個和預想的一樣的雜湊表。它執行了六次對雜湊表的查詢來初始化這個字典(其中一些甚至可能會進行額外的分配,但我還沒有研究得那麼透徹)。但是等等,這是一個無型別字典。這是如何運作的?哦,內部的雜湊表把 Variant 鍵對映到了 Variant 值。

唉。什麼是 Variant?emmm,實現相當複雜,但簡單來說,它是一個大的標籤聯合型別,包含字典可以容納的所有可能型別。我們可以將其視為動態無型別型別。我們關心的是它的大小,即20位元組。

好的,我們寫入字典的每個“欄位”現在都有 20 個位元組大。哦對,鍵也是如此。那些 8 位元組的 Vector2 值?現在每個 20 位元組。那個 int ?20 位元組。你明白了吧。

如果我們將 RayResult 中欄位的大小相加,我們將看到 44 個位元組(假設指標是 8 個位元組)。如果我們將字典中 Variant 的鍵和值的大小相加,那就是 2 6 20 = 240 位元組!但是等等,這是一個雜湊表。雜湊表不會緊湊地儲存資料,因此堆上該字典的真實大小至少比我們想要返回的資料大 6 倍,甚至可能更多。

好吧,我們回到 C#,看看當我們返回這個東西時會發生什麼。

// 這是我們呼叫的函式
public Dictionary IntersectRay(PhysicsRayQueryParameters2D parameters)
{
    return NativeCalls.godot_icall_1_729(MethodBind1, GodotObject.GetPtr(this), GodotObject.GetPtr(parameters));
}

internal unsafe static Dictionary godot_icall_1_729(IntPtr method, IntPtr ptr, IntPtr arg1)
{
    godot_dictionary nativeValueToOwn = default(godot_dictionary);
    if (ptr == IntPtr.Zero) throw new ArgumentNullException("ptr");

    void** intPtr = stackalloc void*[1];
    *intPtr = &arg1;
    void** p_args = intPtr;
    NativeFuncs.godotsharp_method_bind_ptrcall(method, ptr, p_args, &nativeValueToOwn);
    return Dictionary.CreateTakingOwnershipOfDisposableValue(nativeValueToOwn);
}

internal static Dictionary CreateTakingOwnershipOfDisposableValue(godot_dictionary nativeValueToOwn)
{
    return new Dictionary(nativeValueToOwn);
}

private Dictionary(godot_dictionary nativeValueToOwn)
{
    godot_dictionary value = (nativeValueToOwn.IsAllocated ? nativeValueToOwn : NativeFuncs.godotsharp_dictionary_new());
    NativeValue = (godot_dictionary.movable)value;
    _weakReferenceToSelf = DisposablesTracker.RegisterDisposable(this);
}

這裡需要注意的事情是,我們在 C# 中分配了一個新的託管(垃圾建立,巴拉巴拉的)字典,並且它作為一個指標指向了 C++ 在堆上建立的那個。嘿,至少我們沒有把字典內容複製過來!當我發現這一點的時候感覺自己像贏了一樣。

好的,然後呢?

if (hitDictionary.Count != 0)
{
    // 從字串到 Variant 的轉換可以是隱式的 - 為了清楚起見,我在這裡寫出來了
    Variant hitPositionVariant = hitDictionary[(Variant)"position"];
    Vector2 hitPosition = (Vector2)hitPositionVariant;
    Variant hitNormalVariant = hitDictionary[(Variant)"normal"];
    Vector2 hitNormal = (Vector2)hitNormalVariant;
    
    distance = (hitPosition - origin).Length();
    normal = hitNormal;
    return true;
}

希望我們都能瞭解此時此刻正在發生的事情。

如果我們的射線沒有擊中任何東西,則會返回空字典,然後我們會透過檢查計數來檢查命中情況。

如果我們命中了某些物體,對於我們想要讀取的每個欄位會:

  1. string 的鍵轉換為 C# 的 Variant 結構(這也會呼叫 C++)
  2. 為了這個要找一堆函式的指標去呼叫 C++
  3. 查雜湊表來獲取 Variant 儲存的值(當然還是要透過找函式指標)
  4. 將這 20 個位元組複製回 C# 的世界(是的,即使我們讀取的 Vector2 值只有 8 個位元組)
  5. Variant 中提取 Vector2 值(是的,它還會透過指標一路追回到 C++ 中以進行此轉換)

唉,返回個 44 位元組的結構並讀取幾個欄位需要費這麼大勁。

我們可以做得更好嗎?

快取請求引數

如果你還記得,早在 PhysicsRayQueryParameters2D,我們就有機會透過快取來避免一些分配,所以讓我們快點試下。

readonly struct CachingRayCaster
{
    private readonly PhysicsDirectSpaceState2D spaceState;
    private readonly PhysicsRayQueryParameters2D queryParams;

    public CachingRayCaster(PhysicsDirectSpaceState2D spaceState)
    {
        this.spaceState = spaceState;
        this.queryParams = PhysicsRayQueryParameters2D.Create(Vector2.Zero, Vector2.Zero);
    }

    public bool GetDistanceAndNormal(Vector2 origin, Vector2 direction, out float distance, out Vector2 normal)
    {
        this.queryParams.From = origin;
        this.queryParams.To = origin + direction;
        Godot.Collections.Dictionary hitDictionary = this.spaceState.IntersectRay(this.queryParams);

        if (hitDictionary.Count != 0)
        {
            Variant hitPositionVariant = hitDictionary[(Variant)"position"];
            Vector2 hitPosition = (Vector2)hitPositionVariant;
            Variant hitNormalVariant = hitDictionary[(Variant)"normal"];
            Vector2 hitNormal = (Vector2)hitNormalVariant;
            distance = (hitPosition - origin).Length();
            normal = hitNormal;
            return true;
        }

        distance = default;
        normal = default;
        return false;
    }
}

在第一次投射過後,這將去除按計數計算的每條射線 C#/GC 分配的 2/3,以及按位元組計算的 632/738 的 C#/GC 分配。雖然沒好多少,但已經是一個進步了。

GDExtension 怎麼樣?

你可能聽說過,Godot 還為我們提供了 C++(或 Rust,或其他原生語言)API,使我們能夠編寫高效能程式碼。那個會拯救一切,對吧?是這樣吧?

嗯…

事實是 GDExtension 公開了完全相同的 API。對。你可以編寫飛快的 C++ 程式碼,但你仍然只能獲得一個返回了臃腫的 Variant 值的非型別字典的 API。這是好了一點,因為不用擔心 GC 了,但是…對…我建議你現在可以換回悲傷的表情了。

一種完全不同的方法 —— RayCast2D 節點

等等!我們可以採取完全不同的方法。

bool GetRaycastDistanceAndNormalWithNode(RayCast2D raycastNode, Vector2 origin, Vector2 direction, out float distance, out Vector2 normal)
{
    raycastNode.Position = origin;
    raycastNode.TargetPosition = origin + direction;
    raycastNode.ForceRaycastUpdate();

    distance = (raycastNode.GetCollisionPoint() - origin).Length();
    normal = raycastNode.GetCollisionNormal();
    return raycastNode.IsColliding();
}

這兒有一個函式,它引用了場景中的 RayCast2D 節點。顧名思義,這是一個執行射線檢測的場景節點。它是用 C++ 實現的,並且不會使用相同的 API 來處理所有字典開銷。這是一種非常笨拙的射線檢測方式,因為我們需要引用場景中我們總是樂於變更的節點,並且我們必須重新定位場景中的節點才能進行查詢,但讓我們看一下內部實現。

首先我們需要注意,正如我們所想的那樣,我們正在訪問的每個屬性都在 C++ 的領地中進行了完整的指標追逐之旅。

public Vector2 Position
{
    get => GetPosition()
    set => SetPosition(value);
}

internal unsafe void SetPosition(Vector2 position)
{
    NativeCalls.godot_icall_1_31(MethodBind0, GodotObject.GetPtr(this), &position);
}

internal unsafe static void godot_icall_1_31(IntPtr method, IntPtr ptr, Vector2* arg1)
{
    if (ptr == IntPtr.Zero) throw new ArgumentNullException("ptr");

    void** intPtr = stackalloc void*[1];
    *intPtr = arg1;
    void** p_args = intPtr;
    NativeFuncs.godotsharp_method_bind_ptrcall(method, ptr, p_args, null);
}

現在讓我們看看 ForceRaycastUpdate() 實際做了什麼。我相信你現在已經能猜出 C# 了,所以讓我們直接深入瞭解 C++。

void RayCast2D::force_raycast_update() {
    _update_raycast_state();
}

void RayCast2D::_update_raycast_state() {
    Ref<World2D> w2d = get_world_2d();
    ERR_FAIL_COND(w2d.is_null());

    PhysicsDirectSpaceState2D *dss = PhysicsServer2D::get_singleton()->space_get_direct_state(w2d->get_space());
    ERR_FAIL_NULL(dss);

    Transform2D gt = get_global_transform();

    Vector2 to = target_position;
    if (to == Vector2()) {
        to = Vector2(0, 0.01);
    }

    PhysicsDirectSpaceState2D::RayResult rr;
    bool prev_collision_state = collided;

    PhysicsDirectSpaceState2D::RayParameters ray_params;
    ray_params.from = gt.get_origin();
    ray_params.to = gt.xform(to);
    ray_params.exclude = exclude;
    ray_params.collision_mask = collision_mask;
    ray_params.collide_with_bodies = collide_with_bodies;
    ray_params.collide_with_areas = collide_with_areas;
    ray_params.hit_from_inside = hit_from_inside;

    if (dss->intersect_ray(ray_params, rr)) {
        collided = true;
        against = rr.collider_id;
        against_rid = rr.rid;
        collision_point = rr.position;
        collision_normal = rr.normal;
        against_shape = rr.shape;
    } else {
        collided = false;
        against = ObjectID();
        against_rid = RID();
        against_shape = 0;
    }

    if (prev_collision_state != collided) {
        queue_redraw();
    }
}

看起來這裡做了很多事,但實際上很簡單。如果我們仔細觀察,我們會發現該結構與我們的第一個 C# 函式 GetRaycastDistanceAndNormal 幾乎相同。它獲取世界,獲取狀態,構建引數,呼叫 intersect_ray 以完成實際工作,然後將結果寫入屬性。

但是看!沒有堆分配,沒有 Dictionary,也沒有 Variant。太喜歡它了!我們可以預測到這會快很多。

效能測試

好吧,我已經多次提到所有這些開銷都存在很大的問題,我們可以輕易看出來,但還是讓我們透過基準測試來給出一些實際的數字。

正如我們在上面看到的,RayCast2D.ForceRaycastUpdate()非常接近對物理引擎的 intersect_ray 的極簡呼叫,因此我們可以使用它作為基線。請記住,即使這樣也會因指標追蹤函式呼叫而產生一些開銷。我還對我們討論過的程式碼的每個版本進行了基準測試。每個基準測試都會對被測函式執行 10,000 次迭代,並進行預熱和異常值過濾。我在測試期間禁用了 GC 回收。我喜歡在較弱的硬體上執行我的遊戲基準測試,所以如果你來重現時可能會得到更好的結果,但我們關心的是相對數字。

我們設定了一個簡單的場景,包含了一個我們的射線總是命中的圓形碰撞體。我們感興趣的是測量繫結的開銷,而不是物理引擎本身的效能。我們處理的是以納秒為單位測量的單個射線的計時,因此這些數字可能看起來非常非常小。為了更好地展現他們的意義,我還列出了“每幀呼叫次數”,用來展示如果遊戲中除了射線檢測之外什麼都不做的情況下,在 60fps 和 120fps 的單幀中可以呼叫函式的次數。

MethodTime (μs)Baseline multiplePer frame (60fps)Per frame (120fps)GC alloc (bytes)
ForceRaycastUpdate (raw engine speed, not useful)0.491.0034,00017,0000
GetRaycastDistanceAndNormalWithNode0.971.9817,2008,6000
CachingRayCaster.GetDistanceAndNormal7.7115.732,2001,10096
GetRaycastDistanceAndNormal24.2349.45688344728

這些差異太顯著了!

我們可能期望在文件中教授的射線檢測的使用方法,是為了最快的合理使用引擎/API 而公開的方法。但正如我們所看到的,如果我們這樣做,繫結/API 開銷會使該速度比原始物理引擎速度慢 50 倍。天吶!

使用相同的 API,但明智地(或者笨拙地)快取,我們可以將開銷降至 16 倍。是變好了,但仍然很糟糕。

如果我們的目標是獲得實際效能,我們必須完全避開正確/規範/宣揚的 API,選擇笨拙地操作場景物件來利用它們來為我們執行查詢。在一個合理的世界中,在場景中移動物件並要求它們為我們進行射線檢測會比呼叫原始物理 API 慢,但實際上它快 8 倍。

即使是節點方法也比引擎的原始速度慢 2 倍(我們實際上低估了)。這意味著該函式中有一半的時間花在設定兩個屬性和讀取三個屬性上。繫結開銷太大了,以至於五個屬性訪問所花費的時間與射線檢測一樣長。讓我們沉思下。我們甚至沒考慮這樣一個事實:在實際場景中,我們很可能想要設定和讀取更多屬性,例如設定圖層蒙版和讀取命中的碰撞器

在低端裝置中,這些數字太侷促了。我當前的專案每幀需要超過 344 個射線檢測,當然它所做的不僅僅是射線檢測。這個測試是一個帶有單個碰撞器的簡單場景,如果我們讓射線檢測在更復雜的場景中進行實際的工作,這些數字會更低!文件中進行射線檢測的標準方法會讓我的整個遊戲陷入卡頓。

我們也不能忘記 C# 中發生的垃圾建立分配。我通常採用每幀零垃圾政策來編寫遊戲。

只是為了好玩,我還對 Unity 進行了基準測試。它在大約 0.52μs 內完成完整有用的射線檢測,包括引數設定和結果檢索。在 Godot 的繫結開銷發生之前,核心物理引擎的速度是相當的。

我是特意挑選的嗎?

當我在 reddit 上發文的時候,很多人說這個物理 API 是很爛,但它不代表著整個引擎。我當然不是故意挑的 —— 碰巧是射線檢測是我在檢視 Godot 時最早看到的。然而可能我做的是不太公平,讓我們再檢查下。

如果我想特意挑選一個最糟糕的方法,我都不用找得太遠。緊挨著 IntersectRay 的就是 IntersectPointIntersectShape ,這倆都和我分享的 IntersectRay 有一樣的問題,甚至它們返回的還是多個結果,所以它們返回的是一堆託管分配的 Godot.Collections.Array<Dictionary>!哦順便說,那個 Array<T> 事實上是 Godot.Collections.Array 這個型別的包裝的,所以本來對每個字典的 8 位元組的引用儲存成了 20 位元組的 Variant。顯然我沒選 API 中最糟糕的方法!

如果我們翻閱整個 Godot API (透過 C# 反映的),我們會幸運地發現有很多東西都會返回 Dictionary。這個列表不拘一格地包含了 AbimationNode._GetChildNodes 方法,Bitmap.Data 屬性,Curve2D._Data 屬性(還有 3D),GLTFSkin 中的一些東西,TextServer 中的一些成員,NavigationAgent2D 中的一些片段,等等。他們中的每一個都不是使用擁有緩慢的堆分配的字典的好地方,但是在物理引擎中使用比那些地方還糟糕。

根據我的經驗,很少有引擎 API 能像物理一樣得到如此多的使用。如果檢視我遊戲程式碼中對引擎 API 的呼叫,它們可能 80% 是物理和變換。

請大家記住,Dictionary 只是問題的一部分。如果我們觀察使用更廣泛的 Godot.Collections.Array<T>(記住:堆分配,內容為Variant),我們會在物理、網格和幾何操作、導航、圖塊地圖、渲染等中發現更多。

物理可能是 API 中特別糟糕(但必不可少)的領域,但堆分配型別問題以及指標多層引用的普遍緩慢問題在整個過程中根深蒂固。

所以我們為什麼等待戈多?

Godot 主推的指令碼語言是 GDScript,一種動態型別的解釋語言,其中幾乎所有非原語都是堆分配的,即它沒有結構類似物。這句話應該會在你的腦海中引起效能警報。我給你一點時間,讓你的耳鳴停下來。

如果我們看看 Godot 的 C++ 核心如何公開其 API,我們會看到一些有趣的東西。

void PhysicsDirectSpaceState3D::_bind_methods() {
    ClassDB::bind_method(D_METHOD("intersect_point", "parameters", "max_results"), &PhysicsDirectSpaceState3D::_intersect_point, DEFVAL(32));
    ClassDB::bind_method(D_METHOD("intersect_ray", "parameters"), &PhysicsDirectSpaceState3D::_intersect_ray);
    ClassDB::bind_method(D_METHOD("intersect_shape", "parameters", "max_results"), &PhysicsDirectSpaceState3D::_intersect_shape, DEFVAL(32));
    ClassDB::bind_method(D_METHOD("cast_motion", "parameters"), &PhysicsDirectSpaceState3D::_cast_motion);
    ClassDB::bind_method(D_METHOD("collide_shape", "parameters", "max_results"), &PhysicsDirectSpaceState3D::_collide_shape, DEFVAL(32));
    ClassDB::bind_method(D_METHOD("get_rest_info", "parameters"), &PhysicsDirectSpaceState3D::_get_rest_info);
}

這一共享機制用於生成所有三個指令碼介面的繫結;GDSCript、C# 和 GDExtensions。ClassDB 收集有關每個 API 函式的函式指標和後設資料,然後透過各種程式碼生成系統進行管道傳輸,為每種語言建立繫結。

這意味著每個 API 函式的設計主要是為了滿足 GDScript 的限制。IntersectRay 返回一個無型別的動態 Dictionary 是因為 GDScript 沒有結構。我們的 C# 甚至 GDExtensions C++ 程式碼都必須為此付出災難性的代價。

這種透過函式指標處理繫結的方式也會導致顯著的開銷,正如我們從簡單的屬性訪問中看到的那樣,訪問速度很慢。請記住,每次呼叫首先進行一次記憶體查詢以找到它想要呼叫的函式指標,然後進行另一次查詢以找到實際負責呼叫該函式的輔助函式的函式指標,然後呼叫傳遞它的輔助函式指向主函式的指標。整個過程中都會有額外的驗證程式碼、分支和型別轉換。C#(顯然還有 C++)有一個透過 P/Invoke 呼叫原生程式碼的快速機制,但 Godot 根本沒用它。

Godot 做出的哲學決定使它變得緩慢。與引擎互動的唯一實際方法是透過這個繫結層,但其核心設計使其無法快速執行。無論對 Dictionary 的實現或物理引擎進行多少最佳化,都無法迴避這樣一個事實:當我們應該處理微小的結構時,我們正在傳遞大量的堆分配值。雖然 C# 和 GDScript API 保持同步,但這始終會阻礙引擎的發展。

好的,讓我們來修復它!

在不偏離現有繫結層的情況下我們能做什麼?

如果我們假設我們仍然需要保持所有 API 與 GDScript 相容,那麼我們可能可以在一些方面進行改進,儘管效果並不理想。讓我們回到我們的 IntsersectRay 例子。

  • GetWorld2D().DirectStateSpace 可以透過引入 GetWorld2DStateSpace() 將其壓縮為一個呼叫而不是兩個呼叫。
  • PhysicsRayQueryParameters2D 可以透過新增將所有欄位作為引數的過載來消除這些問題。這將使我們的CachedRayCaster效能大致保持一致(16 倍基線),而無需進行快取。
  • Dictionary可以透過允許我們傳入要寫入值的快取字典/字典池來刪除記憶體分配。與結構體相比,這是醜陋且笨拙的,但它會刪除記憶體分配。
  • 字典查詢過程仍然慢得離譜。我們也許可以透過返回具有預期屬性的類來改進這一點。這裡的分配可以透過快取/池化的方法來消除,就像 Dictionary 的最佳化一樣。

這些選項對於使用者來說並不美觀或不符合人類工程學,但如果我們打上了這些醜陋的補丁,它們可能會起作用。這將修復分配問題,但由於所有指標都會跨越邊界並管理快取值,因此我們仍然可能只達到基線的 4 倍左右。

還可以改進生成的程式碼來避免惡作劇式的指標多層傳遞問題。我還沒有詳細研究過這一點,但如果成功找到了解決辦法,那麼它們將全面應用於整個 API,那就太棒了!我們至少可以移除對釋出版本的驗證和 try finally

如果我們被允許為 C# 和 GDExtensions 新增額外的與 GDScript 不相容的 API,該怎麼辦?

現在我們討論下這個!如果我們開放了這種可能性*,那麼理論上我們可以對 ClassDB 繫結,使用直接處理結構和透過適當的 P/Invoke 機制的方式來增強。這是提升效能的可靠辦法。

不幸的是,用這種更好的方式來複制整個 API 會相當混亂。可能有辦法透過對一些部分標記上 [Deprecated] 並嘗試正確引導使用者來解決這個問題,但是諸如命名衝突之類的問題會變得很難看。

* 也許這已經是可能的,但我還沒有找到。可以的話告訴我!

如果我們把一切都拆掉並重新開始會怎樣?

這個選擇顯然會帶來很多短期痛苦。Godot 4.0 最近才出現,而現在我正在談論像 Godot 5.0 一樣打破整個 API 的後向相容。然而,老實地講,我認為這是讓引擎在三年內處於良好狀態的唯一可行途徑。如上所述,混合快速和慢速 API 會讓我們頭痛數十年——我預計引擎可能會陷入這個陷阱。

在我看來,如果 Godot 走這條路,GDScript 可能應該完全被放棄。當 C# 存在時,我真的不明白它的意義,支援它會帶來這麼多麻煩。在這一點上,我顯然與主要的 Godot 開發人員和專案理念完全不一致,所以我覺得這種情況不會發生。誰知道呢——Unity 最終放棄了 UnityScript 轉而採用完整的 C#,也許 Godot 有一天也會採取同樣的步驟。也是烏鴉嘴?

修改:我現在把上面的內容劃掉。我個人並不關心 GDScript,但其他人關心,我不想把它從他們手中奪走。我不反對 C# 和 GDScript 並排使用不同的 API,每個 API 都針對各自語言的需求進行最佳化。

這篇文章的標題是不是煽動性的標題黨?

可能有一點,但是不算多。

在 Unity 中製作遊戲的不少人,是可以在 Godot 中製作同樣的遊戲的,這些問題對他們無關緊要。Godot 或許能夠佔領 Unity 的低端市場。然而,Unity 最近對效能的關注很好地表明瞭這些需求的存在。我反正知道我很關心。Godot 的效能不僅比 Unity 差,而且是顯著且系統性地差。

在某些專案中,95% 的 CPU 負載都消耗在從未接觸引擎 API 的演算法中。在這種情況下,那些效能問題都不重要了。(GC 總是很重要,但我們可以使用 GDExtensions 來避免這種情況。)對於很多其他人來說,與物理/碰撞的良好程式設計互動以及手動修改大量物件的屬性對於專案才是最重要的。

但是對於這些人,重要的是他們應該可以在需要時做這些事情。也許你在專案中投入了兩年的時間,認為它根本不需要射線檢測,然後你在遊戲後期決定新增一些需要能夠檢測碰撞的自定義 CPU 粒子。這是一個很小的美學上的改變,但當你突然需要使用引擎 API 時,你就遇到了麻煩。這是關於確信你的引擎將來會為你提供支援的,很多重要的討論。Unity 的問題在於其糟糕的商業行為,Godot 的問題在於效能。

如果 Godot 希望能夠佔領整個 Unity 市場(我實際上不確定它是不是想這樣),那麼它需要做出一些快速且根本性的改變。本文討論的許多內容對於 Unity 開發人員來說根本無法接受。

討論

在 r/Godot Reddit 子版塊上釋出了這篇帖子,那裡有相當活躍的討論。如果你是從其他地方來到這裡的,並且想提供反饋或在網際網路上對我進行匿名爆粗,你可以去那邊看看。

致謝

  • Reddit 上的 _Mario_Boss 是第一個讓我注意到 Raycast2D 節點技巧的人。
  • John Riccitiello,終於給了我一個對其他引擎進行研究的理由。
  • Mike Bithell,讓我偷了他的關於烏鴉嘴的笑話。我實際上並沒得到許可,但是他人看起來太好了所以沒找過來打我。
  • Freya Holmér,因為在寫這篇文章時,沒有什麼比看到她抱怨虛幻引擎以釐米為單位做物理,並且等待她分享我發現 Godot 居然有 kg pixels^2 這種單位的恐懼的過程,更快樂的事了。修改:我的這個笑話終於落地了。
  • Clainkey 在 Reddit 上指出,我錯誤地用了納秒,而我本應該用微秒。

Juan Linietsky 對 Sam pruden 文章的回應:

對 Godot 繫結系統的解釋

By Juan Linietsky

原文地址:Godot binding system explained

譯者 溫吞

本文章僅用作學習交流使用,如侵刪

在過去的幾天裡,Sam Pruden 的這篇精彩文章一直在遊戲開發社群中流傳。雖然這篇文章進行了深度的分析,但是有些錯過要點進而得到了錯誤的結論。因此,在很多情況下,不熟悉 Godot 內部結構的使用者會得出下面的結論:

  • Godot 對 C# 的支援效率低下
  • Godot API 和繫結系統是圍繞著 GDScript 設計的
  • Godot 還不是一個成熟的產品

在這篇簡短的文章中,我將進一步介紹 Godot 繫結系統的工作原理以及 Godot 架構的一些細節。這可能會有助於理解其背後的許多技術決策。

內建型別

與其他遊戲引擎相比,Godot 在設計時考慮了相對較高階別的資料模型。從本質上講,它在整個引擎中使用多種資料型別。

這些資料型別是:

  • Nil:表示空值。
  • Bool、Int64 和 Float64:用於標量數學。
  • String:用於字串和 Unicode 處理。
  • Vector2、Vector2i、Rect2、Rect2i、Transform2D:用於 2D 向量數學。
  • Vector3、Vector4、Quaternion、AABB、Plane、Projection、Basis、Transform3D:用於 3D 向量數學。
  • Color:用於顏色空間數學。
  • StringName:用於快速處理唯一 ID(內部唯一指標)。
  • NodePath:用於引用場景樹中節點之間的路徑。
  • RID:用於引用伺服器內部資源的資源 ID。
  • Object:類的例項。
  • Callable:通用函式指標。
  • Signal:訊號(參見 Godot 文件)。
  • Dictionary:通用字典(可以包含任何這些資料型別作為鍵或值)。
  • Array:通用陣列(可以包含任何這些資料型別)。
  • PackedByteArray、PackedInt32Array、PackedInt64Array、PackedFloatArray、PackedDoubleArray:標量壓縮陣列。
  • PackedVector2Array、PackedVector3Array、PackedColorarray:向量壓縮陣列。
  • PackedStringArray:字串壓縮陣列。

這是否意味著你在 Godot 中所做的任何事情都必須使用這些資料型別?絕對不是。

這些資料型別在 Godot 中具有多種作用:

  • 儲存:任何這些資料型別都可以非常高效地儲存到磁碟和載入回來。
  • 傳輸:這些資料型別可以非常有效地編組和壓縮,以便透過網路傳輸。
  • 物件自省:Godot 中的物件只能將其屬性公開為這些資料型別。
  • 編輯:在 Godot 中編輯任何物件時,都可以透過這些資料型別來完成(當然,根據上下文,同一資料型別可以存在不同的編輯器)。
  • Language API:Godot 將其 API 公開給它透過這些資料型別繫結的所有語言。

當然,如果你對 Godot 完全陌生,你首先想到的問題是:

  • 如何公開更復雜的資料型別?
  • 其他資料型別(例如 int16)怎麼辦?

一般來說,你可以透過 Objects API 公開更復雜的資料型別,因此這不是什麼大問題。此外,現代處理器都至少具有 64 位匯流排,因此公開 64 位標量型別以外的任何內容都是沒有意義的。

如果你不熟悉 Godot,我完全可以理解你的懷疑。但事實上,它執行得很好,並且使開發引擎時的一切變得更加簡單。與大型主流引擎相比,這種資料模型是 Godot 成為如此微小、高效且功能豐富的引擎的主要原因之一。當你更加熟悉原始碼時,你就會明白為什麼。

語言繫結系統

現在我們有了資料模型,Godot 提出了嚴格的要求,即幾乎所有暴露給引擎 API 的函式都必須透過這些資料型別來完成。任何函式引數、返回型別或暴露的屬性都必須透過它們。

這使得繫結的工作變得更加簡單。因此,Godot 擁有我們所說的萬能繫結器。那麼這個繫結器是如何工作的呢?

Godot 像這樣將任何 C++ 函式註冊到繫結器上:

Vector3 MyClass::my_function(const Vector3& p_argname) {
   //..//
}

// 然後,在一個特殊的函式中,Godot 執行了以下操作:

// 將方法描述為具名和引數名,並傳遞方法指標
ClassDB::bind_method(D_METHOD("my_function","my_argname"), &MyClass::my_function);

在內部,my_functionmy_argument 被轉換為 StringName(如上所述),因此從現在開始,它們將被視為繫結 API 的唯一指標。事實上,在釋出進行編譯時,模板會忽略引數名稱,並且不會生成任何程式碼,因為它沒有任何作用。

那麼,ClassDB::bind_method 有什麼作用呢?如果你想瘋狂深入並嘗試瞭解極其複雜和最佳化了的 C++17 可變引數模板黑魔法,可以自行前往

但簡而言之,它建立了一個像這樣的靜態函式,Godot 稱之為 “ptrcall” 形式:

// 並不是真的這麼實現,只是一個方便給你思路的儘可能簡化的結果

static void my_function_ptrcall(void *instance, void **arguments, void *ret_value) {
    MyClass *c = (MyClass*)instance;
    Vector3 *ret = (Vector3*)ret_value;
    *ret = c->my_method( *(Vector3*)arguments[0] );
}

這個包裝器基本上是儘可能高效的。事實上,對於關鍵功能,內聯被強制放入類方法中,從而產生指向實函式程式碼的 C 函式指標。

然後,Language API 的工作方式是允許以 “ptrcall” 格式請求任何引擎函式。要呼叫此格式,該語言必須:

  • 分配一點堆疊(基本上只是調整 CPU 的堆疊指標)
  • 設定一個指向引數的指標(引數已經以該語言 1:1 的原生形式存在,無論是 GodotCPP、C#、Rust 等)。
  • 呼叫。

就是這樣。這是一個非常高效的通用粘合 API,您可以使用它來有效地將任何語言公開給 Godot。

因此,正如您可以想象的那樣,Godot 中的 C# API 基本上是透過 unsafe API,使用 C 函式指標在將指標分配給原生 C# 型別後再進行呼叫的。這是非常非常高效的。

Godot 不是新的 Unity —— 對Godot API 呼叫的剖析

我想堅持認為 Sam Pruden 寫的文章非常棒,但如果您不熟悉 Godot 的底層工作原理,那麼它可能會產生很大的誤導。我將繼續更詳細地解釋容易誤解的內容。

只是暴露了一個病態用例,API 的其餘部分都很好。

文章中展示的用例,ray_cast 函式是 Godot API 中的一個病態用例。像這樣的情況很可能不到 Godot 展示的 API 的 0.01%。看起來作者在嘗試分析射線檢測時偶然發現了這一點,但它並不代表其餘的繫結。

這個問題在於,在 C++ 級別,該函式採用結構體指標來提高效能。但在語言繫結 API 時,這很難正確暴露。這是非常古老的程式碼(可以追溯到 Godot 的開源),字典被透過 hack 的方式暫時啟用,直到找到更好的替代。當然,其他東西更重要,而且很少有遊戲需要數千次射線檢測,所以幾乎沒有人抱怨。儘管如此,最近還是有一個公開的提案來討論這些型別的函式的更有效的繫結。

此外,更不幸的是,Godot 語言繫結系統支援了這樣的結構體指標。GodotCPP 和 Rust 的繫結可以使用指向結構體的指標,沒有任何問題。問題是 Godot 中對 C# 的支援早於擴充套件系統,並且尚未轉換為擴充套件系統。最終,C# 將會被轉移到通用擴充套件系統,這將會統一預設編輯器和 .net 編輯器,雖然現在沒實現,但它在優先事項列表中名列前茅。

解決方法更加病態

這次,是 C# 的限制。如果將 C++ 繫結到 C#,你需要建立 C# 版本的 C++ 例項作為介面卡。這對於 Godot 來說並不是一個獨特的問題,任何其他引擎或應用程式在繫結都會需要這個。

為什麼說它麻煩呢?因為 C# 有垃圾回收,而 C++ 沒有。這會強制 C++ 例項保留與 C# 例項的連結,以避免其被回收。

因此,C# 繫結器在呼叫採用類例項的 Godot 函式時必須執行額外的工作。你可以在Sam的文章中看到這段程式碼:

public static GodotObject UnmanagedGetManaged(IntPtr unmanaged)
{
    if (unmanaged == IntPtr.Zero) return null;

    IntPtr intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_script_instance_managed(unmanaged, out var r_has_cs_script_instance);
    if (intPtr != IntPtr.Zero) return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
    if (r_has_cs_script_instance.ToBool()) return null;

    intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_instance_binding_managed(unmanaged);
    object obj = ((intPtr != IntPtr.Zero) ? GCHandle.FromIntPtr(intPtr).Target : null);
    if (obj != null) return (GodotObject)obj;

    intPtr = NativeFuncs.godotsharp_internal_unmanaged_instance_binding_create_managed(unmanaged, intPtr);
    if (!(intPtr != IntPtr.Zero)) return null;

    return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
}

雖然非常高效,但對於熱路徑來說它仍然不是理想的選擇,因此暴露的 Godot API 是深思熟慮的,不會以這種方式暴露任何關鍵的東西。然而,並且由於沒有使用實函式,達到目的所使用的解決方法非常複雜。

特意挑選的問題

我堅信作者並不是故意挑選這個 API 的。事實上,他自己寫道,他檢查了其他地方的 API 使用情況,也沒有發現任何這種程度的病態。

為了進一步澄清,他提到:

請大家記住,Dictionary 只是問題的一部分。如果我們觀察使用更廣泛的 Godot.Collections.Array<T>(記住:堆分配,內容為Variant),我們會在物理、網格和幾何操作、導航、圖塊地圖、渲染等中發現更多。

從我和貢獻者的角度來看,這些用法都不是熱路徑或病態的。請記住,正如我上面提到的,Godot 使用 Godot 型別主要用於序列化和 API 通訊。雖然它們確實進行堆分配,但這僅在資料被建立時發生一次。

我認為 Sam 和該領域的其他一些人可能會感到困惑(如果您不熟悉 Godot 程式碼庫,這很正常),Godot 容器不像 STL 容器那樣工作。因為它們主要用於傳遞資料,所以它們被分配一次,然後透過引用計數儲存。

這意味著,從磁碟讀取網格資料的函式是唯一執行了分配的函式,然後該指標透過引用計數穿過多個層,直到到達 Vulkan 並上傳到 GPU。這一路上沒有任何複製。

同樣,當這些容器透過 Godot 彙集暴露給 C# 時,它們也會在內部進行引用計數。如果您建立這些陣列中的某一個來傳遞 Godot API,則分配只會發生一次。然後就不會發生進一步的複製,資料會完好無損地到達消費者手中。

當然,從本質上講,Godot 使用了更加最佳化的容器,這些容器不直接暴露給繫結器 API。

誤導性結論

文章中的結論是這樣的:

Godot 做出的哲學決定使它變得緩慢。與引擎互動的唯一實際方法是透過這個繫結層,但其核心設計使其無法快速執行。無論對 Dictionary 的實現或物理引擎進行多少最佳化,都無法迴避這樣一個事實:當我們應該處理微小的結構時,我們正在傳遞大量的堆分配值。雖然 C# 和 GDScript API 保持同步,但這始終會阻礙引擎的發展。

正如你在上述幾點中所讀到的,繫結層絕對不慢。緩慢的原因可能是測試的極其有限的用例可能是病態的。對於這些情況,有專用的解決方案。這是 Godot 開發背後的通用理念,有助於保持程式碼庫小、整潔、可維護且易於理解。

換句話說,是這個原則:

當前的繫結器達到了其目的,並且在超過 99.99% 的用例中執行良好且高效。對於特殊的情況,如前所述,擴充套件 API 已經支援結構體(您可以在擴充套件 api 轉儲的摘錄中看到)

        {
            "name": "PhysicsServer2DExtensionRayResult",
            "format": "Vector2 position;Vector2 normal;RID rid;ObjectID collider_id;Object *collider;int shape"
        },
        {
            "name": "PhysicsServer2DExtensionShapeRestInfo",
            "format": "Vector2 point;Vector2 normal;RID rid;ObjectID collider_id;int shape;Vector2 linear_velocity"
        },
        {
            "name": "PhysicsServer2DExtensionShapeResult",
            "format": "RID rid;ObjectID collider_id;Object *collider;int shape"
        },
        {
            "name": "PhysicsServer3DExtensionMotionCollision",
            "format": "Vector3 position;Vector3 normal;Vector3 collider_velocity;Vector3 collider_angular_velocity;real_t depth;int local_shape;ObjectID collider_id;RID collider;int collider_shape"
        },
        {
            "name": "PhysicsServer3DExtensionMotionResult",
            "format": "Vector3 travel;Vector3 remainder;real_t collision_depth;real_t collision_safe_fraction;real_t collision_unsafe_fraction;PhysicsServer3DExtensionMotionCollision collisions[32];int collision_count"
        },

所以,最後,我認為 “Godot 被設計得很慢” 的結論有點倉促。當前缺失的是將 C# 語言遷移到 GDExtension 系統,以便能夠利用這些優勢。目前這項工作正在進行中。

總結

我希望這篇短文能夠消除 Sam 精彩文章無意中產生的一些誤解:

  • Godot C# API 效率低下:事實並非如此,但只有很少的病態案例有待解決,並且在上週之前就已經在討論了。實際上,很少有遊戲可能會遇到這些問題,希望明年不會再有這種情況。
  • Godot API 是圍繞 GDScript 設計的:這也是不正確的。事實上,直到 Godot 4.1,型別化 GDScript 都是透過 “ptrcall” 語法進行呼叫,而引數編碼曾是瓶頸。因此,我們為 GDScript 建立了一個特殊的路徑,以便更有效地呼叫。

感謝你的閱讀,請記住 Godot 不是閉門開發的商業軟體。我們所有的製作者都和你身處同一個線上社群。如果你有任何疑問,請隨時直接詢問我們。

額外說明:與普遍看法相反,Godot 資料模型不是為 GDScript 建立的。最初,該引擎使用其他語言,例如 Lua 或 Squirrel,並在內部引擎時期釋出了幾款遊戲。GDScript 是後來開發的。


譯者後記

兩人的討論內容十分硬核,譯者才疏學淺、剛剛接觸 Godot,很多內容都是邊看邊學邊找資料才能看懂的,所以翻譯過程中難免有疏漏錯誤,還請大家不吝指正。

兩位雖然解開了 Godot API 整體效率差的誤會,但還在圍繞熱路徑下很多 GC 的細節進行討論,很多大佬也在評論區參與討論,限於篇幅沒法一一翻譯,大家如果感興趣可以移步觀看

各位的支援就是對譯者最大的鼓勵,如果看得還算不錯,請不要吝嗇給我一個大大的贊,我們有緣再會~

相關文章