[Перевод] Простые сопрограммы для игр на C++
Реализация такой последовательности в коде должна быть весьма непосредственной — как-никак, компьютер тоже работает по своеобразному сценарию. Но в играх такие сценарии могут выполняться на протяжении нескольких минут, а ведь там, помимо этого, осуществляется много других процессов (звук, анимация и т. д.). В такой ситуации самым очевидным решением будет поместить сценарий в отдельный поток выполнения. Но тогда появляется риск возникновения состояния гонки или других неприятных багов, связанных с потоками выполнения. Что же делать?
Одно из возможных решений — использование конечного автомата. Но переписывание сценария в соответствии с конечным автоматом рискует обернуться сущей пыткой, к тому же в этом случае результирующий код будет гораздо сложнее понять.
Более простым решением, на котором мы сегодня остановимся, будет использование сопрограмм. В двух словах, сопрограмма — это что-то вроде функции, поддерживающей остановку и продолжение выполнения с сохранением определенного положения. Таким образом, можно выполнить какую-либо часть подпрограммы (одной строки сценария), вернуться к основному потоку и затем продолжить выполнение сопрограммы с прежнего положения. Выходит, сопрограмма работает во многом как поток выполнения, но запускается только по команде и с готовностью возвращает выполнение последовательному приложению.
Сопрограммы являются неотъемлемой частью многих языков, таких как 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 (моей библиотеки журналирования), но вы можете удалить оттуда то, что считаете лишним.