关于游戏开发:翻译Godot-是独立游戏的新宠儿吗Godot-API-绑定系统的大讨论

6次阅读

共计 27330 个字符,预计需要花费 69 分钟才能阅读完成。

最近,因为 Unity 的谜之操作,大量的 Unity 开发者外流寻找可代替 Unity 的游戏引擎。Godot 因为反对 C# 开发,4.0 版本后性能绝对欠缺起来,所以国内外 Unity 开发者对其关注度十分高,因而也开展了不少对于 Godot 是否代替 Unity 的探讨。

其中流传最广的探讨之一就是 Sam pruden 在 Reddit 论坛上对于 Godot API 调用 过程性能的质疑。文章中具体钻研并测试了 Godot 射线检测 的性能,并对引擎外围和各语言 API 间的绑定层的设计提出了质疑。

随后,Godot 的外围开发人员之一 —— Juan Linietsky 对其质疑进行了回复和解释,并解说了 Godot 对绑定层和 GDExtension 的定位和设计思路。

译者在围观吃瓜的过程中受害颇多,学习了很多对于 游戏性能优化 方面的思路,所以赶紧翻译了两位的文章,供大家一起交流学习。

直到译者熬夜翻译完的第二天,这场交换探讨还在炽热地进行着,想要围观的小伙伴能够去 Github Gist 和 Reddit 围观各路大佬的探讨。


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 的单帧中能够调用函数的次数。

Method Time (μs) Baseline multiple Per frame (60fps) Per frame (120fps) GC alloc (bytes)
ForceRaycastUpdate (raw engine speed, not useful) 0.49 1.00 34,000 17,000 0
GetRaycastDistanceAndNormalWithNode 0.97 1.98 17,200 8,600 0
CachingRayCaster.GetDistanceAndNormal 7.71 15.73 2,200 1,100 96
GetRaycastDistanceAndNormal 24.23 49.45 688 344 728

这些差别太显著了!

咱们可能冀望在文档中传授的射线检测的应用办法,是为了最快的正当应用引擎 /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 的细节进行探讨,很多大佬也在评论区参加探讨,限于篇幅没法一一翻译,大家如果感兴趣能够移步观看。

各位的反对就是对译者最大的激励,如果看得还算不错,请不要悭吝给我一个大大的赞,咱们有缘再会~

正文完
 0