Godot – это не новая Unity. Анатомия вызова API в Godot

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Эта статья выросла из бесед с Godot-разработчиками. Они заботятся о том, чтобы поднимаемые проблемы решались, и стремятся улучшать ситуацию. Определённо, в Godot грядут серьёзные изменения, но сама платформа пока находится на ранней стадии развития. Поэтому сложно говорить с уверенностью, что именно изменится и в какой степени. На самом деле, я полагаю, что Godot ждёт самое светлое будущее.  

Апдейт: ведущий разработчик Godot Хуан Линьетски опубликовал ответ на этот пост.

Я, как и многие другие, в последнее время активно ищу «новую Unity». У Godot есть потенциал, особенно, если на платформу удастся привлечь талантливых разработчиков, которые обеспечили бы её стремительное развитие. Это одна из самых крутых черт свободного ПО. Но здесь есть серьёзная проблема, сдерживающая развитие Godot: связующий уровень, проложенный между кодом движка и кодом геймплея, структурно рассчитан именно на медленную работу. Такое устройство кода очень сложно исправить, не снося всю конструкцию до основания и не перестраивая API целиком с нуля.

На Godot уже были разработаны некоторые успешные игры, поэтому, конечно же, вышеперечисленные факторы не являются непреодолимыми. Но в Unity в течение последних пяти лет ведётся работа по ускорению работы сценариев, и ради этого было запущено несколько проектов один другого страннее: создано два собственных компилятора, написаны математические ОКМД-библиотеки, разработаны собственные коллекции и аллокаторы. Разумеется, нужно упомянуть и о гигантском (и в основном незаконченном) проекте ECS. Техническое руководство Unity стратегически придерживается этой линии развития с 2018 года. Определённо, команда Unity считает, что для значительной части пользовательской аудитории быстродействие сценариев – ключевой фактор. Поэтому, переключаясь на Godot, я не просто перечёркиваю то, что было сделано в Unity за последние 5 лет – нет, всё гораздо хуже.

Именно об этом я недавно начал противоречивую, но продуктивную дискуссию в подреддите, посвящённом Godot. В этой статье я более детально изложу мысли, сформулированные в рамках этой дискуссии, тем более, что теперь я немного лучше понимаю, как работает Godot. Давайте это чётко отметим: в Godot я по-прежнему новичок, и в этой статье обязательно найдутся ошибки и заблуждения.

Замечание: далее высказываются критические замечания относительно того, как спроектирован и сработан движок Godot. Порой я эмоционально высказываюсь о том, что думаю и чувствую, но признаю, что разработчики Godot вытянули массу сложнейшей работы и создали повсеместно любимый продукт. Поэтому я совершенно не намеревался никого оскорбить или грубо переходить на личности.

Подробно разберём, как реализуется бросание лучей на C#

Далее подробно разберём, как в Godot реализуется функция, эквивалентная Physics2D.Raycast из Unity, и что происходит под капотом при использовании этой функции. Чтобы немного конкретизировать изложение, давайте для начала реализуем тривиальную функцию в 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 будет сделано что-то подобное. Считайте, что это предвидение.

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++, которые мы создаём при преследовании указателя (pointer chasing). На каждом из этих этапов выполняется поиск в памяти, а сверх того приходится заниматься валидацией, try finally и интепретацией возвращённого указателя. Может показаться, что всё это – всего лишь мелкие несогласованности, но, если каждому направляемому в ядро вызову и каждой операции доступа к свойству/полю в объекте Godot приходится проделывать весь этот путь, то издержки начинают накапливаться.

Присмотревшись к следующей строке, где выполняется доступ к свойству world.DirectSpaceState, окажется, что многие из этих операций мы уже проделывали. При помощи всё той же машинерии объект PhysicsDirectSpaceState2D опять вытягивается с территории C++. Не волнуйтесь, деталями вас утомлять не стану!

А вот следующая строка – первая в этом коде, которая реально меня озадачила.

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

Что тут может быть интересного, что же просто небольшая структура, в которую упакованы параметры бросания лучей, верно? НетPhysicsRayQueryParameters2D – это управляемый класс, а с точки зрения сборщика мусора – это как раз источник мусора, под который постоянно выделяется память. Настоящее безумие – оставлять такую штуку на оживлённом пути, на котором особенно критично выдавать максимальную производительность. Но ведь можно быть уверенным, что память здесь выделяется всего один раз, верно? Давайте-ка посмотрим, что внутри.

// Резюме:
//     Возвращает новый, заранее сконфигурированный объект 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 байт. Ключи тоже такие. Помните те значения Vector2 по 8 байт? Теперь они по 20 байт. А те int? Тоже по 20 байт. Идею вы уловили.

Просуммировав размеры всех полей в RayResult, мы получим 44 байта (если предположить, что размер каждого указателя равен 8 байт). Если просуммировать размеры всех ключей Variant и значений, содержащихся в словаре, то получится 2 * 6 * 20 = 240 байт! Но, подождите-ка, ведь это хеш-таблица. В хеш-таблицах данные хранятся не компактно, поэтому реальный размер, занимаемый этим словарём в куче, будет, как минимум, вшестеро превышать размер тех данных, которые мы хотим вернуть – а, возможно, и гораздо сильнее.

Ладно, давайте вернёмся к 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. Извлекаем значение Vector2 из Variant(да, здесь также приходится преследовать указатели до самого it 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;
    }
}

Считаем: после первого луча удаляется 2/3 выделений памяти C#/GC на луч и 632/738, если перевести это в байты. Ситуация всё равно не так хороша, но, тем не менее, это прогресс.

Что насчёт GDExtension?

Как вы, возможно, слышали, Godot также предоставляет API для C++ (или Rust, или другого нативного языка), позволяющий нам писать высокопроизводительный код. Нам это здесь как раз пригодится, правда? Правда?

Ну…

Оказывается, GDExtension предоставляет точно такой же the API. Ага. Можно писать быстрый код на C++, но всё равно вы получаете API, возвращающий нетипизированный словарь с раздутыми значениями Variant. Ситуация немного лучше, так как здесь можно не беспокоиться о сборке мусора, но… сейчас опять будет повод взгрустнуть, готовьтесь.

Совершенно иной подход – с узлом 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();
    }
}

Кажется, что тут много чего творится, но это только на первый взгляд. Если внимательно рассмотреть этот код, то видно, что структурно он практически аналогичен нашей первой функции GetRaycastDistanceAndNormal на C#. Она получает игровой мир, получает состояние, собирает параметры, вызывает intersect_ray для выполнения фактической работы, а затем записывает результат в наши свойства.

Но взгляните! Никаких выделений кучи, нет Dictionary и нет Variant. Вот так уже лучше. Можно предположить, что этот код будет работать гораздо быстрее.

Померяем время

Окей, мы неоднократно намекнули, что все эти издержки доставляют массу проблем, и не составляет труда увидеть, что без них будет лучше, но давайте перейдём к конкретным числам – позанимаемся бенчмаркингом.

Как было показано выше, функция RayCast2D.ForceRaycastUpdate() очень близка к самому минималистичному вызову intersect_ray из движка, обслуживающего игровую физику, так что давайте возьмём эту функцию в качестве отправной точки. Не забывайте, что и в этом вызове есть издержки, связанные с преследованием указателей. На каждой контрольной точке мы прогоняем 10 000 итераций тестируемой функции, с предварительным прогревом и фильтрацией выбросов. Сборку мусора я на время тестирования отключил. Такой бенчмаркинг игр мне нравится проводить на сравнительно слабом железе, поэтому, если попытаетесь воспроизвести мои тесты, то ваши результаты получиться даже лучше. Но нас в данном случае интересуют относительные числа.

В качестве модели возьмём простую сцену, в которой для столкновений предусмотрен круг, и наш луч в этот круг всегда попадает. Мы хотим измерить издержки на связывание, а не производительность игрового движка как такового. Мы имеем дело с задержками отдельных лучей, они измеряются в наносекундах, и поэтому числа могут получаться нелепо маленькими. Чтобы лучше проиллюстрировать, насколько они важны, также указываю кадровую частоту и указываю, сколько раз функция может быть вызвана в пределах одного кадра при кадровой частоте 60 кадр/сек и 120 кадр/сек, если в программе не делается ничего сверх тривиального бросания лучей.

Метод

Время (μs)

Базовый множитель

Кадровая частота (60 кадр/сек)

Кадровая частота (120 кадр/сек)

Выделение GC  (байт)

ForceRaycastUpdate (скорость движка не важна)

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, но разумно (пусть и иногда неизящно) подходить к кэшированию, можно сократить вышеупомянутые издержки до шестнадцатикратных. Уже лучше, но всё равно страшно.

Если вы ставите перед собой цель поднять производительность так, чтобы это было видно на практике, то нужно полностью отойти от традиционного/канонического/разрекламированного API, а вместо этого напрямую манипулировать объектами сцены и заставить их, чтобы они делали нужные нам запросы за нас.  Казалось бы, что в разумно устроенном мире перемещать объекты по сцене вручную и требовать, чтобы они бросали лучи за нас, было бы медленнее, чем использовать API игровой физики без примочек, но на практике получается в восемь раз быстрее. 

Даже при подходе с применением узлов код работает вдвое медленнее, чем с чистым движком (на самом деле, это даже недооценка). Таким образом, половину рабочего времени эта функция тратит на установку двух свойств и считывание трёх свойств. Издержки на связывание достаточно велики, так что на пять обращений к свойствам уходит столько же времени, сколько на бросание лучей. Давайте уложим это в голове. Даже не будем задумываться о том, что в реальном приложении нам вполне может потребоваться задать и прочитать ещё больше свойств, например, чтобы наложить маску слоя и прочитать значение счётчика попаданий.

На самом деле, в нижней части диапазона эти числа очень скудные. В моих нынешних проектах требуется более 344 актов бросания лучей на кадр. Разумеется, одним бросанием лучей работа в кадре не ограничивается. Этот тест – тривиальная сцена с единственной фигурой для столкновений. Но, если речь заходит о бросании лучей для выполнения реальной работы в более сложной сцене, то числа могли бы быть ещё ниже! Если бросать лучи стандартным способом, так, как это описано в документации, то вся игра намертво застопорится.

Также нельзя забывать и о том мусоре, который образуется в результате актов выделения памяти, происходящих в C#. Когда я пишу игры, я обычно придерживаюсь политики «ноль мусора на каждый кадр».

Чисто для интереса, я также проделал бенчмаркинг Unity. Там делается полноценное рабочее бросание лучей с установкой параметров и извлечением результатов, всё – примерно за 0,52 μs. До учёта присутствующих в Godot издержек на связывание, оказывается, что скорость работы у ядер Unity и Godot оказывается сопоставимой.

Может быть, я тенденциозен

Когда я разместил тот тред на reddit, нашлось немало людей, которые говорили, что API игровой физики донельзя плох, поэтому по нему нельзя судить обо всём движке целиком. Честно, я совершенно не пытался выбрать API похуже – просто так получается, что именно бросание лучей я первым делом попробовал выполнить, взявшись разбираться с Godot. Правда, может быть, я немного лукавлю, поэтому давайте это проверим.

Если бы я хотел специально выбрать метод похуже, то долго искать бы мне не пришлось. Прямо рядом с IntersectRay находятся IntersectPoint и IntersectShape, для которых свойственны всё те же проблемы, что и для IntersectRay, а также ещё одна безуминка: дело в том, что, имея множественные результаты, они возвращают выделенный в куче управляемый Godot.Collections.Array<Dictionary>! O, кстати, этот Array<T> - на самом деле, типизированная оболочка, в которую обёрнут Godot.Collections.Array. Поэтому каждая 8-байтная ссылка на словарь на самом деле хранится в виде 20-байтного Variant. Конечно же, я выбрал не самый плохой метод в API!

Если просканировать весь API Godot (при помощи рефлексии C#), то, оказывается, что здесь не так много сущностей, которые возвращали бы Dictionary. Получается эклектичный список, где есть, в частности, метод AnimationNode._GetChildNodes , свойство Bitmap.Data, свойство Curve2D._Data (и 3D), некоторые вещи в GLTFSkin, кое-какой материал из TextServer, некоторые элементы NavigationAgent2D, т.д. Ни в одном из этих мест не годится иметь медленные словари, выделяемые в куче, но? даже на фоне всех вышеперечисленных методов, API игровой физики особенно плох.

Правда, мой опыт подсказывает, что во всём движке мало найдётся таких API, которые использовались бы столь же активно, как и физический. Если посмотреть вызовы API движка в моём коде геймплея, то оказывается, что примерно 80% из них приходятся на физику и преобразования.

Также не будем забывать, что Dictionary – всего лишь часть проблемы. Если чуть шире посмотреть, какие сущности возвращают Godot.Collections.Array<T> (напомню: они выделяются в куче, по содержимому как Variant), то найдётся масса деталей из физики, работы с игровыми сетками, геометрией, навигацией, картами замощений, рендерингом и многим другим.

Возможно, физика – особенно неудачная (но принципиально важная) зона ответственности данного API, но в ней глубоко укоренились проблемы, связанные с типами, выделяемыми в куче, а также с преследованием указателей вообще.

Так почему же мы до сих пор в ожидании Godot?

Основной язык сценариев, на котором написан Godot, называется GDScript. Это интерпретируемый язык с динамической типизацией, где почти все непримитивные типы выделяются в куче – то есть, в этом языке нет аналога структур. Это утверждение должно было разразиться симфонией сирен в той части вашей головы, где вы задумываетесь о безопасности. Сделаю паузу, пока этот звон немного утихнет.

Если рассмотреть, как написанное на C++ ядро Godot предоставляет свой 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# и даже коде на C++ для расширений GDExtensions за это приходится платить катастрофически высокую цену.

Такой способ обработки связок через указатели функций также сопряжён с существенными издержками – как мы уже видели, даже простые обращения к свойствам идут медленно. Напомню, что каждый вызов начинается с поиска в памяти (находится указатель на ту функцию, которую требуется вызвать). Затем выполняется ещё одна операция поиска, чтобы найти указатель на вторичную функцию (которая, собственно, и отвечает за вызов первой функции). На всём этом пути выполняется дополнительный валидационный код, ветвление и преобразования типов. В C# (и, очевидно, в C++) есть быстрый механизм для отправки вызовов в нативный код. Он называется P/Invoke, но в Godot этот механизм просто не используется.

Итак, в философии Godot заложена его медленная работа. Единственная практическая возможность взаимодействия с движком – через его слой связывания, но ядро движка спроектировано так, что просто не может работать быстро. Сколько ни оптимизируй реализацию Dictionary, ни ускоряй физический движок – не уйдёшь от того факта, что мы передаём туда-сюда целый ворох значений, выделенных в куче, тогда как здесь следовало бы работать с крошечными структурами. Поскольку API C# и GDScript остаются синхронизированными, это неизменно тормозит развитие движка.

Окей, так давайте же это исправим!

Что можно сделать, не отступая от работы с имеющимся уровнем связывания?

Если предположить, что по-прежнему необходимо поддерживать совместимость GDScript со всеми нашими API, то всё равно остаётся несколько областей, в которых, пожалуй, можно что-то подправить, даже если получится и не очень красиво. Вернёмся к нашему примеру с IntsersectRay.

  • GetWorld2D().DirectStateSpace можно ужать с двух вызовов до одного, введя в код GetWorld2DStateSpace().

  • Проблемы с PhysicsRayQueryParameters2D можно устранить, добавив такую перегрузку, при которой все поля принимаются как параметры. Всё это позволило бы нам примерно сравняться по производительности с CachedRayCaster (в 16 раз медленнее базовой), не прибегая к кэшированию.

  • От выделения Dictionary можно избавиться, разрешив передавать для записи такой словарь, который находится в кэше/пуле. По сравнению со структурами такой подход уродливый и неуклюжий, зато без выделений.

  • Процесс поиска в словаре до сих пор смехотворно медленный. Его можно было бы улучшить, возвращая класс с ожидаемыми свойствами. От операции выделения здесь можно было бы избавиться, применив кэш/пул так, как было описано в случае с Dictionary.

С точки зрения пользователя все эти варианты не слишком красивы и эргономичны, но, если наша цель – расставить дешёвые и сердитые патчи, просто, чтобы работало, то как-то работать будет. Так можно было бы исправить проблему с выделениями, но скорость выполнения будет, пожалуй, всего вчетверо больше базовой, так как сохраняется всё межъязыковое преследование указателей и приходится управлять кэшированными значениями.

Также можно было бы улучшить код, сгенерированный для всех операций с преследованием указателей. Я пока детально не изучал этот вопрос, но, если там найдутся потенциальные выигрыши, то они будут применимы и в рамках всего API, и это будет круто! Как минимум, можно было бы убрать из релизных сборок валидацию и блоки try finally.

Что, если бы было разрешено добавлять дополнительные API для C# и GDExtensions, такие, которые несовместимы с GDScript?

Отлично, давайте поговорим об этом! Если мы считаем, что это возможно (может быть, это уже реализовано, но я точно не знаю), то, теоретически, можно было бы добавить к имеющимся связкам ClassDB другие, более качественные, которые взаимодействовали бы напрямую со структурами через полноценные механизмы P/Invoke. Таков путь к приемлемой производительности.

К сожалению, если продублировать весь API такими улучшенными версиями, то код превратится в огромную мешанину. Это можно было бы преодолеть, например, размечая сущности [не рекомендуется] и подталкивая пользователя в верном направлении, но из-за таких проблем как конфликты имён всё станет совсем уродливо.

А что, если снести всё до основания и сделать заново?

Безусловно, в краткосрочной перспективе такой вариант очень болезненный. Godot 4.0 вышел совсем недавно, а тут я говорю об обратной совместимости, ломающей весь redux API, практически о Godot 5.0. Правда, если быть честным с самим собой, то это единственный жизнеспособный вариант, который позволил бы года за три привести движок в порядок. Если бы мы смешивали медленные и быстрые API, так, как это описано выше, это стало бы для нас головной болью на многие десятилетия. Подозреваю, движок угодит как раз в эту ловушку.

А не кликбейтный ли заголовок у этой статьи? Может, я кого-то на слезу пробить хочу?

Может быть, немного. Но не слишком.

Найдутся люди, которые пишут игры на Unity и могли бы делать точно такие игры на Godot, не будь перечисленные проблемы настолько острыми. Возможно, Godot отгрызёт у Unity эконом-сегмент её рынка. Но тот факт, что недавно в Unity стали тщательно улучшать производительность – хороший индикатор. Он означает, что на это есть спрос. Я знаю, что для меня это действительно. Но у Godot производительность не просто хуже, чем у Unity, она драматически и систематически хуже.

В некоторых проектах 95% нагрузки на ЦП даёт алгоритм, который даже не касается API движка. В таком случае, всё это не имеет значения (сборщик мусора важен всегда, но с ним проблемы можно решать при помощи GDExtensions.) Во многих других случаях важно обеспечить качественную реализацию физики/столкновений в программе и вручную модифицировать свойства огромного количества объектов, что играют в проекте ключевые роли.

Многим просто важно знать, что такие вещи можно сделать, если потребуется. Может быть, вы два года занимались проектом, полагая, что в нём не потребуется ничего кроме бросания лучей, но потом, на позднем этапе разработки игры было решено реализовать какие-то элементы работы с ЦП, которые позволяли бы проверять столкновения. Это совсем небольшая красивость, но вдруг вам требуется обращаться к API движка – и у вас проблемы. Много слов сказано о том, как важно доверять движку и знать, что в будущем он сможет послужить вам опорой. В Unity есть проблема с мутными бизнес-практиками, а в Godot – с производительностью.

Если Godot стремится повоевать с Unity на её основном рынке (кстати, я не знаю, стремится ли в самом деле), то в Godot требуются быстрые и фундаментальные изменения. Многие из вещей, рассмотренных в этой статье, для Unity-разработчиков просто неприемлемы.

Обсуждение

Я опубликовал эту статью в подреддите r/Godot, и там развернулась весьма активная дискуссия. Если вы пришли в этот пост с какого-то другого сайта, то не стесняйтесь высказываться и комментировать.

Благодарю

  • _Марио Босса с reddit за то, что он первым обратил моё внимание на фокус с узлами при работе с Raycast2D.

  • Джона Риччительо за то, что наконец-то мотивировал меня подробнее исследовать другие движки

  • Майка Бизелла за то, что позволил позаимствовать его шутку с предвидением. На самом деле, разрешения я не спрашивал, но он, по-видимому, настолько добрый малый, что не стал искать меня и разбираться.

  • Фрейю Хольмер, так как при работе над этой статьёй было крайне забавно читать её жалобы о том, что в Unreal физика делается на уровне сантиметров. Жду, когда она перепугается, как и я, когда обнаружит, что в Godot есть такие единицы, как килограммы на пиксель квадратный. Кстати, одну из моих шуток всё-таки заметили.

  • Клэнки с reddit за подсказку, что у меня случайно затесались наносекунды там, где должны быть микросекунды.

 

Источник: https://habr.com/ru/articles/763988/


Интересные статьи

Интересные статьи

В прошлый раз мы остановились на том, как можно разнообразить свою игру, заменив один-два старых предмета на новые. Теперь настала пора переходить к более серьезным изменениям.
Реликтовое излучение В 1964 году Арно Пензиас и Роберт Уилсон в лаборатории Белла работали над одним экспериментом. Они использовали надувные шары в качестве отражателей для передачи данных в мик...
В свете последних событий я бы хотел поделиться своим мировоззрением, которое вполне может заменить "религию" по вопросам морали, и сделать умных людей более гуманными. Данная концепция "новой религии...
На дня компания Qualcomm, один из крупнейших поставщиков чипов связи на рынок электроники, совместно с оператором Vodafone и промышленной группой Thales продемонстрировала в работе новый стандарт SI...
Список из расширений и модулей для Godot 3. Расширения и модули которые я видел и посчитал полезными. Читать далее