Простые сопрограммы для игр на C++

Original author: Эмиль Эрнерфельдт
  • Translation
Использование в играх сценарных последовательностей действий, таких как диалоги или видеозаставки, является вполне распространенной практикой. Но суть не в том, что эти последовательности пишутся на сценарных языках (хотя и такое бывает), а в том, что они следуют определенному сценарию, как кино или пьеса. Конечно, в отличие от фильмов, сценарии игр могут иметь множество условных переходов (к примеру, в зависимости от выбранного игроком вопроса, последует определенный ответ неигрового персонажа).



Реализация такой последовательности в коде должна быть весьма непосредственной – как-никак, компьютер тоже работает по своеобразному сценарию. Но в играх такие сценарии могут выполняться на протяжении нескольких минут, а ведь там, помимо этого, осуществляется много других процессов (звук, анимация и т. д.). В такой ситуации самым очевидным решением будет поместить сценарий в отдельный поток выполнения. Но тогда появляется риск возникновения состояния гонки или других неприятных багов, связанных с потоками выполнения. Что же делать?

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

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

Сопрограммы являются неотъемлемой частью многих языков, таких как Lua. Но если вы решили создать игру на чистом C++, дело обстоит сложнее. Сторонние реализации библиотек, которые можно найти, например, на boost, в основном предназначены для выполнения нескольких тысяч сопрограмм, а значит, они должны быть очень легковесными. Конечно, при таком упоре на производительность страдает простота использования и переносимость этих библиотек.

Для работы со сценарной последовательностью действий в играх, как правило, требуется лишь одна или несколько работающих одновременно сопрограмм. Однако в таком случае библиотекам вовсе не обязательно быть легковесными. Чтобы исправить эту проблему, я решил создать очень простую реализацию сопрограмм, которая по сути является оберткой для std::thread, но с механизмами, обеспечивающими передачу выполнения от внешнего потока к внутреннему (сопрограмме); таким образом за раз выполнялся только один поток. Используя подходящий поток выполнения, мы никоим образом не ограничены в том, что можно делать из потока. Этот подход также хорошо работает в связке с многими другими инструментами вроде отладчика, отображающего все запущенные потоки выполнения в их текущем состоянии. Поскольку вызывающий поток ставится на паузу в то время, когда выполняется сопрограмма, отпадает необходимость использовать мьютексы или какие-либо другие способы синхронизации состояния игры.

Вот что у меня получилось в итоге:

GameUnit camera = ...;
GameUnit juliet = ...;
GameUnit curtains = ...;

cr::CoroutineSet coroutine_set;

coroutine_set.start("end_scene", [&](cr::InnerControl& ic){
    while (!camera.looking_at(juliet)) {
        camera.turn_towards(juliet);
        ic.yield(); // Return to the calling thread
    }
    juliet.speak("Romeo, I come! This do I drink to thee.");
    ic.wait_sec(2.0); // Yield to main thread for the next two seconds
    auto drink_animation = juliet.animate("drink_poison");
    ic.wait_for([&](){ return drink_animation.is_done(); });
    auto fall_animation = juliet.animate("fall_to_the_ground");;
    ic.wait_for([&](){ return fall_animation.is_done(); });
    ic.wait_sec(1.0);
    curtains.animate("drop");
    ic.wait_sec(2.0);
});

// Game loop:
for (;;) {
    double dt = seconds_since_last_frame();
    input();
    update(dt);
    coroutine_set.poll(dt); // Allow coroutines to run for a short while
    paint();
}


Моя библиотека сопрограмм доступна на Github, не стесняйтесь использовать ее на свое усмотрение. Это единая .hpp/.cpp пара, которая зависит только от Loguru (моей библиотеки журналирования), но вы можете удалить оттуда то, что считаете лишним.
Plarium
Разработчик мобильных и браузерных игр

Comments 3

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

    Я как-то написал аналогичную систему сопрограмм для C++, но со следующими отличиями:

    1. Вместо потоков использовал Fiber (Windows) и getcontext (Linux). В моём случае было именно много легковесных сопрограмм, работающих с сетью, поэтому использование потоков было неуместно.

    2. Для работы с сокетами использовал неблокирующие вызовы и epoll, вызываемый собственным планировщиком.

    Недостатки же были следующие: сложность отладки и высокое потребление памяти. Смена контекста полностью прятала сопрограмму для отладчика. А память потреблял независимый стек каждой из сопрограмм.

    А затем я на всё это плюнул и перешёл на язык со встроенной реализацией сопрограмм в виде машин состояния. При этом переписал встроенный планировщик и операции с сокетами для достижения максимальной масштабируемости, а именно: принудительный однопоточный режим вместо использования пула потоков и epoll для ожидания дескрипторов вместо колбэков на пуле потоков.
      +1

      Писал тут игру недавно на C++ CX (да, windows-specific) но зато для корутин блестящее/удобное ключевое слово co_await (аналог async/await в C# быть может когда-нибудь будет в стандарте :):


      task<void> PlayScript()
      {
              co_await Delay(1f); // подождать 1 секунду игрового времени
              text_->SetText("start");
              // повернуть узел на 90 градусов за 1 секунду игрового времени
              co_await Rotate(1f, boxNode_, Quaternion(0, 90, 0)); 
              boxNode_->SetScale(1f);
              // изменить масштаб узла за 1 секунду и.в.
              co_await ScaleBy(1f, boxNode_, Vector3(2, 2, 2));
              text_->SetText("End");
      }

      В этом коде не создается дополнительных поток и нет блокировок/слипов :-)

        +1
        Поправьте, если я ошибаюсь, но вы же просто плодите потоки. По крайней мере, меня в этом убеждает std::make_unique в конструкторе Coroutine, и не похоже, чтобы они шарились внутри CoroutineSet. И получается, что вы замысловато обернули потоки, что, согласитесь, не совсем отражает суть сопрограмм.
        Во избежание срачей, да, я понимаю, что сопрограммы можно симулировать через потоки, но это, всё-таки, не эквивалентные понятия.

        Я ожидал всё-таки какого-нибуть хитрого класса Coroute со стеком функций и их входных параметров и методом\макросом yield. Ну или, хотя бы, красиво обёрнутые списки задач, с функторами и лямбдами-транзакциями. Который будет жить в одном потоке с вызывающим потоком, делить плоть и кровь, что называется…

        Only users with full accounts can post comments. Log in, please.