Godot是否独立游戏的新宠:Reddit社区关于Godot API绑定系统的大讨论

作者:温吞
2023-09-26
19 4 4

写在前面

最近,因为 Unity 官方的迷之操作,大量开发者开始外流,寻找可替代 Unity 的游戏引擎。而 Godot 引擎支持 C# 开发,4.0 版本后功能相对完善起来,所以国内外 Unity 开发者对其关注度非常高。值此气氛微妙之际,自然也展开了不少关于 Godot 能否替代 Unity 的讨论。

其中流传最广的讨论之一,就是 Sam Pruden 在 Reddit 论坛上对于 Godot API 调用过程性能的质疑。文章详细记录了对 Godot 射线检测性能的研究和测试,并对引擎核心和各语言 API 间绑定层的设计提出了质疑。

随后,Godot 的创始成员、核心开发人员之一 Juan Linietsky 对其质疑进行了回复和解释,并讲解了 Godot 对绑定层和 GDExtension 的定位和设计思路。而就在文章编辑排版期间,Juan Linietsky 刚刚发布了一个解决提案。

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

直到译者熬夜翻译完的第二天,这场交流讨论还在火热地进行着,想要围观的小伙伴可以去 Github GistReddit 围观各路大佬的讨论。

(本文仅用作学习交流使用。)



最初的讨论文章是 Sam Pruden 对 Reddit 论坛中观点的总结:《Godot 不是新的 Unity:对 Godot API 调用的剖析》

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


更新:这篇文章开启了和 Godot 开发人员的持续对话。他们很关心文中提出的问题,并想进行改进。肯定会做出重大的改变——尽管还为时尚早,且不清楚会进行怎样的改变和何种程度的改变。我从大家的回复中得到了鼓励。我相信 Godot 的未来一定是十分光明的。

更新 2:Godot 核心开发成员 Juan Linietsky 对此发表了回应(译者注:译文合并于本文的后半部分)。

像很多人一样,过去的几天里,我一直在寻找 Unity 的替代者。Godot 潜力不错,特别是现在——如果它能好好利用大量涌入的开发人才来快速推动改进的话。开源产品在这方面就是很棒。然而,有一个重要问题在阻碍 Godot 的发展——引擎代码和游戏代码之间的绑定层使用了一种很缓慢的结构,如果不将一切推倒重来并重建整个 API,问题很难得到解决。

已经有开发者用 Godot 创建了一些很成功的游戏,所以这个问题并不总是一种阻碍。然而,Unity 在过去的五年内一直致力于用一些很疯狂的项目来提高脚本的运行速度,例如构建了两个自定义编译器、SIMD 数学库、自定义回收和分配,当然还有庞大的(且还有很多未完成的)ECS 项目。自 2018 年之后,上述这些一直是其 CTO 关注的重点,很显然,Unity 确信脚本性能对他们的大部分用户很重要。而如果切换到 Godot,使用体验不仅像回到了五年前的 Unity,甚至更糟糕。

几天前,我在 Reddit 的 Godot 子版块上进行过一场有争议但富有成效的讨论, 这篇文章则是更详细的延续,因为现在的我对 Godot 的工作原理多了一点了解。在此先明确一点:我仍然是一个 Godot 新手,这篇文章可能会包含错误和误解。

注:下文包含对 Godot 引擎的设计和工程的批评。 虽然我偶尔会使用一些情绪化的语言来描述对这些事情的感受,但 Godot 开发者为开源社区付出了很多努力,并创作了让很多人喜爱的东西,我的目的并不是要冒犯某些人或给人粗鲁的印象。

深入研究 C# 对射线检测的执行

我们将深入探讨如何在 Godot 中实现与 Unity 的 Physics2D.Raycast 相当的效果,以及使用它时会发生什么。 为了使探讨更加具体,我们首先在 Unity 中实现一个简单的函数。

1. 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# 中的重要概念,非托管代码必须提供自己的垃圾回收、类型检查、安全支持等服务。


2. 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?其实现相当复杂,但简单来说,它是一个大的标签联合类型,包含字典可以容纳的所有可能类型。可以将其视为动态无类型类型。我们关心的是它的大小,即 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 字节的结构并读取几个字段需要费这么大劲。


3.我们可以做得更好吗?


3.1 缓存请求参数

如果你还记得,其实早在 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,以及按字节计算的 C#/GC 分配的 632/738。虽然没好多少,但已经是一个进步了。


3.2 GDExtension 怎么样?

你可能听说过,Godot 还提供了 C++(或 Rust,或其他原生语言)API,使我们能够编写高性能代码。那会拯救一切,对吧?是这样吧?

嗯…

事实是 GDExtension 公开了完全相同的 API。对。你可以编写飞快的 C++ 代码,但仍然只能获得一个返回了臃肿的 Variant 值的非类型字典的 API。这是好了一点,因为不用担心 GC 了,但是…对…我建议你现在可以换回悲伤的表情了。


3.3 一种完全不同的方法 —— 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。这才像话!我们可以预测到这会快很多。


4. 性能测试

好吧,我已经多次提到所有这些开销都存在很大问题,虽然可以轻易看出来,但还是让我们通过基准测试来给出一些实际的数字。

正如上文中提到的,RayCast2D.ForceRaycastUpdate() 非常接近对物理引擎的 intersect_ray 的极简调用,因此我们可以使用它作为基线。请记住,即使这样也会因指针追踪函数调用而产生一些开销。我还对我们讨论过的代码的每个版本进行了基准测试,每个基准测试都会对被测函数运行 10,000 次迭代,并进行预热和异常值过滤,且测试期间禁用了 GC 回收。我喜欢在较弱的硬件上运行游戏基准测试,所以如果你进行重现,可能会得到更好的结果,但我们关心的是相对数字。

测试场景的设置很简单,包含一个我们的射线总是命中的圆形碰撞体。我们感兴趣的是测量绑定的开销,而不是物理引擎本身的性能。此处处理的是以纳秒为单位测量的单个射线的计时,因此这些数字可能看起来非常非常小。为了更好地展现它们的意义,我还列出了“每帧调用次数”,用来展示在游戏中除了射线检测之外什么都不做的情况下,在 60fps 和 120fps 的单帧中可以调用函数的次数。

差异非常显著!

我们可能期望,文档中教授的射线检测的使用方法,是为了最快地合理使用引擎/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?

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。

这篇文章的标题是不是煽动性的标题党?

可能有一点,但不算多。

使用 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 绑定系统的解释》

原文地址: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 C# API 在热路径中调用的内存分配问题终于有了阶段性成果。可以看到 Godot 开发人员对社区意见的重视。

下面是这次讨论过后的阶段性成果。


考虑到 Unity 转到 Godot 的 C# 开发人员 对内存分配这件事真的是太太太重视了,Godot 创始人之一 Juan Linietsky 发布了一个提案,用于改善热路径中的内存分配。

热路径(hot path)是程序非常频繁执行的一系列指令。例如这次,主要针对的就是可能在帧函数中大量调用的 API 提案中提到的内存分配(memory allocation),指被 C# 运行时托管的变量在创建时会进行内存的分配。这些变量因为是被托管的,所以由 C# 的 GC(垃圾回收)机制自动回收。对于开发者来说,这些回收的时机相对不可预知,所以可能会发生,在帧函数调用期间,出现被大量回收而导致卡顿。因此,在 Unity 使用 C# 的开发者,会极力避免在热路径中出现内存分配。

虽然原本 Godot 系统中设计的动态变量类型规避了大量内存开辟的问题,但作为引擎核心的 C++ 部分和 C# API 部分,传递变量时,不可避免还是有少量变量会被分配内存。

对此,Juan Linietsky 的意见是,除了原来的 API 外,对涉及热路径的,再生成一套保证一定不会出现内存分配的 API,这些 API 会把引擎核心中 C++ API 生成的变量通过 NoAlloc 的方式直接转移到 C# 中,就不会出现新变量和拷贝了。

届时,游戏开发者就会有两种选择:

一、不看重内存分配问题的,就使用原 API,得到的变量和官方 C# 那些一摸一样(Array、Collection、Dictionary 什么的),不用关心其它奇怪的变量类型;

二、看重内存分配问题的就使用新 API,虽然会用一些 Godot 创造的方言类型(Godot.SetArray 之类的),但性能会拉满。

提案底下,几位开发者还在讨论具体的实现方法。要看明白需要对 C# 和 C++ 都比较了解,我实在看不太懂,就没法翻译了……感兴趣的读者可以自己去围观!

https://github.com/godotengine/godot-proposals/issues/7842

开源万岁。

译者后记

二人的讨论内容十分硬核,译者才疏学浅,刚刚接触 Godot,很多内容都是边看、边学、边找资料才能看懂,所以翻译过程中难免有疏漏和错误,还请大家不吝指正。

两位虽然解开了 Godot API 整体效率差的误会,但还在围绕热路径下很多 GC 的细节持续进行讨论,许多大佬也在评论区参与。限于篇幅没法一一翻译,如果感兴趣,大家可以移步观看

各位的支持就是对译者最大的鼓励,请不要吝啬给我一个大大的赞。有缘再会~

本文为用户投稿,不代表 indienova 观点。

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. 期缪 2023-09-26

    致谢那一节的"kg pixels^2"我甚至今天还看到了……
    (评论区不能发图x 总之贴一个图床图片 https://imgse.com/i/pPHYW9S )

    最近由 期缪 修改于:2023-09-26 20:58:36
    • AI33.0 2023-09-27

      @期缪:发图,To do list +1!

    • 期缪 2023-09-27

      @AI33.0:但是, 与此同时, 审核难度也提升了 (´-`)

  2. ltmaster 2023-09-27

    好硬核的讨论!

您需要登录或者注册后才能发表评论

登录/注册