Эта статья выросла из бесед с 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 за подсказку, что у меня случайно затесались наносекунды там, где должны быть микросекунды.

Комментарии (28)


  1. natexriver
    28.09.2023 07:34
    +6

    Главная проблема Godot, как бы странно это не звучало, его бесплатность. Ну не могут бесплатные продукты (не путайте с условно бесплатными) сравниться с платными по масштабу и качеству, нет тех ресурсов и драйверов роста. Вторая проблема - маркетплейс ассетов, что является одним из решающих факторов при выборе движка. Просто как пример, я решил попробовать и перенести свой самый популярный 2D ассет для Unity в Godot Asset Marketplace. Потратил часа два на перенос и публикацию, нажал Publish и получил ошибку "Error! I swear this is not your product". Весь прогресс потерян, как будто я ничего и не делал. На сайте нет раздела Contacts, а в разделе Support нет никаких возможностей создать запрос, просто надпись "No tickets found!". Вот приблизительно такой уровень вы и получаете. Да, Unity немного перегнул палку с Runtime Fee, но им есть за что просить деньги. Это и качественный движок, и куча полезных сервисов, и топовый маркетплейс с ассетами. Все это отражается на продуктивности и качестве вашей работы.


    1. deseven
      28.09.2023 07:34
      +1

      Задним числом заставить платить за установки всех когда-либо вышедших или находящихся в разработке игр это вы считаете "немного перегнул палку"? А что тогда не немного?


      1. Njordy
        28.09.2023 07:34

        Никто не просил платить за старые установки. Если лимит в 200к/1м был уже пересечен, то по 1.5-недельной давности правилам** оплата пошла бы со всех новых установок, начиная с 1-го января.


        1. Njordy
          28.09.2023 07:34

          И то, только при выполнении второго условия, что игра до сих пор хорошо продается (200к/1м за последние 12 месяцев). Да, они по-убл*дски перевели всех на новые правила, ретроактивно, но правила нормальные для всех, кто не занимался ф2п (тут они по-убл*дски решили перестадить на свой сервис). Опять же, скамбагство было, но я поражаюсь тому, сколько людей не вчитались в старые условия.


          1. buriska
            28.09.2023 07:34

            Почему ретроактивно? Новые правила же будут применяться к новым продажам с 1 января 2024. Разве нет?


            1. Njordy
              28.09.2023 07:34

              По совсем_новым правилам, да. И то, если ты будешь переходить на новую 2024 (?) версию движка, то типа должен будешь принять новые условия.

              А вот до этого, как раз нет. Может быть они не имели такое ввиду, но это точно так звучало (+ удаление архива старых лицензий) -- то есть ты автоматически переводился на новые првила -- причем я не знаю, насколько это вообще юридически корректно и легально -- и должен бы начать платить runtime fee на новые установки с той даты. Это одна из главнейших ошибок, что Unity делала. Так поступать 100% было нельзя.


              1. Njordy
                28.09.2023 07:34

                Они вообще палок наломали, и сделали в итоге сложную систему, вместо того, чтобы пойти по более унреальной модели. С самого начала бы обьявили, что остаются подписки + 4.5 процента на прибыль, и получилось бы немного дешевле Унреала. И никаких подсчетов установок и прочего дерьма, как бы они сладко это всё не обуславливали бы. И не было бы проблем. И деньги бы потекли в движок.


        1. deseven
          28.09.2023 07:34

          Возможно неудачно выразился. Я не говорил про старые установки, я говорил про новые установки уже вышедших игр.


    1. n7nexus
      28.09.2023 07:34
      +3

      Могут, но этим нужно заниматься. Blender тому пример.


      1. Njordy
        28.09.2023 07:34
        +1

        Блендер пример хорошего опенсорса, с кучей опенсорсных проблем. Масса новых фишек, каждый год, активное развитие. Но при этом делаются те фишки, которыми можно выпедриться, и которые авторам хочется делать, а не те, которые нужны инструменту в индустрии, к примеру поддержка USD, которая сильно проигрывает сейчас даже такому легендарно тормозному динозавру, как Maya. Те же UV, которые вроде как наконец-то стали нормально паковаться, на 5 лет позже всех остальных. Почему? Потому что либо не хочется никому из команды это делать, либо на страничу с new features это не так красиво будет выставить, как новые волосы.


    1. Myxach
      28.09.2023 07:34
      +1

      Это конечно весело...ну юнити же самый сухой по фунционалу из движков . Где 99% стандартного функционала надо или велосипедть, или искать в магазине велосипеды ..А 1% настолько лагающие, что надо писать свои все равно


      1. buriska
        28.09.2023 07:34

        Если всё так плохо, то чего ж на нём всё ещё кто-то сидит?


        1. Myxach
          28.09.2023 07:34

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


    1. buriska
      28.09.2023 07:34

      "Ну не могут бесплатные продукты (не путайте с условно бесплатными) сравниться с платными по масштабу и качеству" — несколько странное заявление на фоне линукса, андроида, Stable Diffusion, VIM, Gimp, VirtualBox, Chromium, Docker...

      Да, на короткой дистанции платные движки аля Unity выигрывают за счёт денежного буста. Но на длинной дистанции, открытый движок постепенно будет решать свои фундаментальные проблемы и догонять платных конкурентов. Чем лучше будет открытый движок, тем больше вокруг него будет сообщество, которое ещё сильнее будет бустить проект и наполнять магазин ассетов.

      Так что, в долгосрочной перспективе, я ставлю на open source.

      Посмотрим. Время покажет.


  1. Freeman322
    28.09.2023 07:34
    -7

    Вы уж меня простите, но а есть какая то причина которая заставляет Вас в открытом движке вместо C++ использовать C#? Конечно он будет медленнее, смысл жаловаться?


    1. Newbilius
      28.09.2023 07:34
      +3

      У движков-конкурентов такой значительной просадки нет. А при подходе "не нравится - иди к конкурентам" все к конкурентам и идут) И дальше уже вопрос к авторам движка, хотят ли они своему детищу популярности или их устраивает текущая ситуация.


    1. natexriver
      28.09.2023 07:34
      +6

      То, что Unity разработчики знают только C#, не сойдет за причину? Про порог входа, скорость написания кода и другие преимущества # спорить не будем.


      1. Freeman322
        28.09.2023 07:34
        -3

        Порог входа, это не аргумент от слова совсем, так и представляю разработчиков авиационного ПО, аргументирующих переход от Asm и Си в пользу шарпа и пайтона скоростью разработки)

        Вы в геймдеве, если дрожите от вида указателей и не поддаетесь обучению идите на фронт, кучу модных фреймворков на Js напишите.

        Если конечно это для вас хобби, то тут не спорю, пишите на чем хотите. Хотя опять же, есть языки в разы быстрее шарпа и проще, LuaJit и Julia в помощь, прикрутили и поехали) Ах сори, плюсы не знаете, прикрутить не получится)


        1. FeNUMe
          28.09.2023 07:34

          Как раз в геймдеве порог входа еще какой аргумент. Для крупных студий с ААА проектами конечно это роли не играет, а вот для миллионов инди - еще как. Очень много людей мечтает сделать свою игру, но не имеет времени/возможности/желания полноценно изучать С++/С# и "дружелюбные" движки позволяют им реализовать свои проекты. Некоторые умудряются создавать полноценные игры чисто на блюпринтах в том же анриле.


  1. viruseg
    28.09.2023 07:34

    Только не рассказывайте автору статьи про RaycastCommand в Unity. А то он совсем в депрессию впадёт.


  1. Myxach
    28.09.2023 07:34

    Так. стоп Raycast в godot'e это отдельный компотент жеж. Зачем велосипед или разрабы на движке без стандартного функционала привыкли велосипедеть?


  1. dmiche
    28.09.2023 07:34
    +1

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

    Зато он офигенно удобный для новичка, по виду (и тактильно) - обычный питон с мелким сахаром. Для детей просто отлично.

    Вообще, порог вхождения очень низкий. Да, наверное, для профессионалов там не хватает возможностей в интерфейсе и каких-то AAA-фишек, но для новичков (я написал пару поделок зимой, ещё на 3.5) всё вполне приемлемо: когда делаем что-то на Джанге, пыхе или Vue, то мы всего этого вообще лишёны - и ничего, никто не жужжит :) А новичок видит по большей части разницу в загрузке: 5 секунд Godot и 20 минут Unity.

    Мне видится, что Godot грозится повторить судьбу Blender: от средне удобной, ограниченной, но рабочей замены Maya и 3DS для гиков до профессионального стандарта.

    А Опенсорс сейчас будет очень на руку, потому что это прямая возможность энтузиастам встраивать ИИ и этот же ИИ (через годик-два) привлечь к рефакторингу. Поэтому на что будет похож интерфейс через лет 5, я бы вообще не брался загадывать. Я бы ожидал там со временем прямо интеллектуального ассистента с обучением под разраба, способного, в т.ч. искать и качать рецепты и сцены, рожать что-то своё и прописывать пакеты настроек по одной команде.

    При этом не сказать, что Godot сильно бедствует - его там Мета подкармливает и, вроде, ещё кто-то.

    Это, конечно, личное мнение, немного более восторженное, чем должно быть в реальности, но должны же мы немного мечтать и проецировать мечты на планы грядущего :)


    1. buriska
      28.09.2023 07:34

      Godot проще unity? Приятная неожиданность. Не знал, что кто-то обошёл Unity в этом вопросе. В остальном полностью согласен. Похоже на blender. Спасибо Unity, что подали пример простого движка, показали что не надо делать, и активно отворачивают сообщество от себя в пользу открытых альтернатив.


    1. FeNUMe
      28.09.2023 07:34

      После начала скандала с юнити годотовцы организовали фонд для сбора пожертвований, там практически сразу собралось 25к/мес, сейчас прошло две недели и уже 50к/мес.


  1. brick_btv
    28.09.2023 07:34

    del


  1. buriska
    28.09.2023 07:34

    C# слишком медленный, а C++ слишком страдальческий. Предлагаю писать пользовательские сценарии на Rust. Скорость и функционал как у C++, а удобство ближе к C#. Хороший компромиссный вариант. Кто что думает? Есть варианты получше? Интересно мнение каждого.


    1. Freeman322
      28.09.2023 07:34

      Не вижу ничего страдальческого в плюсах, и если уж хочется скорости исполнения кода близкого к Си и простоты шарпа или проще, есть LuaJit. Утверждение о том, что Rust сильно проще плюсов как минимум спорно.


  1. Dementor
    28.09.2023 07:34
    -1

    Я не разбираюсь в программировании игровой графики, но со стороны странно слышать "мы в связи с последними событиями ищем замену юнити и пошли тестировать некий опенсурс". Замечания с дивана:

    1. А тестировать опенсурс нужно только когда проблемы?

    2. Новость про Юнити давно протухла - они испугались угроз разработчикам; сначала закрыли один офис, а потом отказались от монетизации.

    3. Если юнити так сильно не нравится, то зачем искать ноунейм и грызть кактус, когда есть промышленный стандарт Анриал Энжын?