Архитектура, производительность и игры
Прежде чем мы с головой нырнем в кучу шаблонов, я думаю обрисовать для вас общую картину того, что я думаю об архитектуре программного обеспечения в целом и игр в частности. Это поможет вам легче понять остальное содержимое книги. По крайней мере у вас появятся аргументы для непрекращающихся споров о том - хорошая вещь шаблоны или полный отстой.
Обратите внимание, что я не настаиваю на том, чтобы вы приняли одну или другую сторону в этом противоборстве. Как у любого торговца оружием у меня есть, что предложить всем комбатантам.
Что такое архитектура программы?
Если вы прочтете эту книгу от корки до корки, вы не подчерпнете для себя новых знаний по алгебре, используемой в 3D графике или вычислениями, используемыми в игровой физике. Вы не увидите реализацию альфа/бета отсечения (alpha/beta pruning) для вашего ИИ или симуляцию реверберации комнаты для звукового движка.
Вау! Не параграф получился, а просто готовая реклама для книги.
Вместо этого мы уделим внимание коду между всем этим. Не столько написанию кода, сколько его организации. В каждой программе есть своя организация, даже если это просто "давайте запихаем весь код в функцию main()
и посмотрим, что получится". Поэтому я считаю что гораздо интереснее поговорить о том, как получается хорошая организация. Как отличить хорошую архитектуру от плохой?
Я обдумывал этот вопрос не меньше пяти лет. Конечно, как и у каждого из вас, у меня есть интуитивное представление о хорошей архитектуре. Мы все страдаем от настолько плохой кодовой базы, что лучшее, что с ней можно сделать это немного разгрести ее и продолжить страдать дальше.
Давайте признаем что большинство из нас хотя бы в какой-то степени за это отвечает.
У некоторых счастливчиков есть противоположный опыт: возможность работать с прекрасно спроектированным кодом. Такой тип кодовой базы ощущается как прекрасно меблированный отель с услужливыми консьержами, следящими за каждым вашим шагом. В чем же заключается разница между ними?
Что такое хорошая архитектура программы?
Для меня хорошее проектирование заключается в том, что когда мне нужно внести изменение, вся остальная часть программы как будто специально сделана так чтобы мне было легко. Я могу добиться желаемого результата с помощью всего нескольких вызовов функций, делающихся так просто, что они не оставляют ни малейшей ряби на глади остального кода.
Звучит прекрасно, но не слишком конкретно. "Пиши такой код, чтобы его изменения не порождали рябь на глади воды". Мда.
Давайте немного углубимся в детали. Первая ключевая особенность архитектуры - это приспособленность к изменениям. Кому-то обязательно придется перерабатывать кодовую базу. Если никому больше к коду прикасаться не придется - по причине того что он совершенен, закончен или наоборот настолько ужасен, что никто не решится открыть его в своем редакторе - проектирование не важно. Проектирование оценивается по простоте внесения изменений. Без изменений это все равно что бегун, никогда не покидавший стартовой линии.
Как вносить изменения?
Прежде чем изменять код и добавлять новый функционал или исправлять ошибки или вообще запускать по какой-либо причине свой редактор, вам нужно иметь представление о том, что делает уже существующий код. Конечно, вам не нужно понимать всю программу целиком, но нужно по крайней мере загрузить в свой мозг примата все связанные части.
Странно об этом говорить, но фактически это Оптическое распознавание образов (OCR).
Мы все склонны недооценивать важность этого шага, но зачастую он оказывается самой затратной в плане времени частью программирования. Если вы думаете, что вас тормозит сброс данных из памяти на диск, задумайтесь лучше о скорости работы вашего обезьяньего мозга с оптическими нервами.
Как только вам удается загрузить всю нужную информацию в свою черепушку, вы немного думаете и выдаете решение. Конечно, вам приходится обдумывать некоторые детали, но, в целом, процесс достаточно прямолинеен. Как только вы понимаете проблему и часть кода, которую она затрагивает, сам процесс написания становится тривиальной задачей.
Вы начинаете тыкать своими пальчиками в клавиатуру, пока на экране не появляются нужные вам черточки и на этом все, верно? А вот и нет! Прежде чем писать тесты и отсылать код на ревью, вам нужно кое-что подчистить.
Вы засунули в игру еще немного кода, но не хотите чтобы следующий кто будет работать с этим кодом спотыкался о следы вашей деятельности. Если это не совсем мелкое изменение, вам нужно предпринять некоторые меры по реорганизации кода, чтобы новый код естественным образом вписывался в уже существующий. Если вы это сделаете, следующий, кто будет после вас работать с этим кодом, даже и не поймет, когда и какая часть кода была написана.
Я сказал "тесты"? А, да, сказал. Для многих частей кодовой базы игрового кода сложно написать юнит тесты, но некоторая его доля все таки отлично тестируется.
Я не собираюсь лезть на трибуну, но все-таки призываю вас делать больше автоматизированных тестов. Неужели у вас нет более важных дел, чем тестировать одно и то же в ручном режиме?
Упрощенно диаграмма потоков в программировании выглядит следующим образом:
Меня даже несколько пугает что цикл на диаграмме не имеет выхода.
Как нам может помочь уменьшение связности (decoupling)?
Хотя это и не очевидно, архитектура программы больше всего влияет на фазу изучения кода. Загрузка кода в нейроны настолько мучительно медленна, что стоит предпринимать любые стратегии для уменьшения его объема. В этой книге есть целый раздел, посвященный шаблонам уменьшения связности (decoupling) и большая часть книги Паттерны проектирования посвящена той же идее.
Уменьшение связности можно определять по-всякому, но лично я считаю два куска кода связанными, если я не могу понять как работает один кусок без понимания работы другого. Если уменьшить их связность (decouple), каждый из них можно будет рассматривать независимо. И это прекрасно, потому что, если к решаемой вами проблеме имеет отношение только один кусок кода, вам не придется загружать в свой обезьяний мозг второй кусок.
Для меня это является главной задачей архитектуры программы: минимизация количества знаний, которые нужно поместить в свою черепушку прежде, чем двигаться дальше.
Последующие этапы, конечно, тоже вступают в игру. Еще одно определение уменьшения связности состоит в том, что изменение одного куска кода не вызывает необходимость изменять другой. Нам обязательно придется что-то изменить, но чем меньше у нас связность, тем меньше частей игры это изменение затронет.
Какой ценой?
Звучит здорово, верно? Избавимся от связности и начнем кодить со скоростью ветра. Каждое изменение будет затрагивать всего один или несколько методов и вы будете порхать над кодовой базой, практически не отбрасывая на нее тень.
Именно благодаря этому чувству людей так привлекает абстрагирование, модульность, шаблоны проектирования и вообще архитектура программ. Программа с хорошей архитектурой превращает работу над собой в удовольствие, потому что все любят разработчиков с высокой производительностью. А хорошая архитектура дает громадный прирост производительности. Тяжело переоценить получаемый на выходе эффект.
Однако, как и все хорошее в жизни, ничего не дается бесплатно. Хорошая архитектура требует значительных усилий и дисциплины. Каждый раз, когда вы вносите изменения или добавляете новую функциональность, вам нужно прикладывать усилия к тому, чтобы эти изменения изящно интегрировались в остальную часть программы. Вам нужно приложить большие усилия к организации кода и поддерживать эту организованность на протяжении тысяч маленьких изменений, которые предстоит совершить на протяжении всего цикла разработки.
Второй этап этого процесса - поддержка архитектуры - требует особого внимания. Я видел множество примеров того как программисты начинали за здравие с блестящим кодом и заканчивали за упокой, когда насыщали код тысячами хаков "чуть подправить здесь и готово". И так раз за разом.
Также как в садоводстве здесь не достаточно просто посадить новые растения. Нужно еще бороться с сорняками и подстригать деревья.
Вам нужно решить связность между каким частями программы вы хотите уменьшить и добавить необходимое абстрагирование. Кроме того вам нужно предусмотреть пути расширения функциональности чтобы было проще работать в будущем.
Люди приходят от этого в восторг. Они представляют себе как разработчики будущего (или они сами в будущем) открывают кодовую базу и видят какая она вся понятная, мощная и так и просит себя расширить. Они представляют себе Один Игровой Движок Который Всем Повелевает.
Но вот тут-то и кроется сложность. Добавляете ли вы новый уровень абстракции или предусматриваете место для расширения, вы должны предполагать что эта гибкость понадобится вам в будущем. Вы добавляете код и усложняете игру, тратя при этом время на разработку, отладку и поддержку.
Эти затраты с лихвой окупятся в том случае, если вы угадали и будете изменять код в этом направлении в дальнейшем. К сожалению предсказывать будущее довольно сложно и если модульность вам в дальнейшем не понадобится, вскоре она начнет вам активно вредить. В конце концов вам просто придется работать с более громоздким кодом.
Какие-то умники даже придумали термин "YAGNI" - Вам это не понадобится (You aren’t gonna need it) - специальную мантру, которая поможет вам бороться со злоупотреблениями в предположениях о том, что может понадобиться вам в будущем.
Когда люди проявляют в этом чрезмерные усилия, в результате получается кодовая база, архитектура которой все больше выходит из-под контроля. У вас повсюду будут сплошные интерфейсы и абстракции. Системы плагинов, абстрактные базовые классы, изобилие виртуальных методов и куча точек для расширения.
Потребуется вечность, чтобы прорваться через завалы всего этого богатства и добраться до настоящего кода, который хоть что-то делает. Конечно, если вам нужно внести какие-то изменения, у вас скорее сего найдется интерфейс, который вам поможет, но вы еще попробуйте его найти. В теории такое уменьшения связности означает, что вам нужно понимать меньше кода для того, чтобы его расширять, но само по себе нагромождение абстракций закончится тем, что кеш вашего мозга просто переполнится.
Кодовые базы такого типа только отталкивают людей от работы над архитектурой программ и от шаблонов проектирования в частности. Зарыться в код довольно просто, только не нужно забывать о том, что мы все-таки занимаемся созданием игры. Сладкие песни сирен про расширяемость поймали в свои сети множество игровых разработчиков, которые годами занимаются работой над "движком", даже не понимая какой конкретно движок им нужен.
Производительность и скорость
Довольно часто увлечение архитектурой программы и абстракциями критикуют, особенно в игровом программировании за то, что это вредит производительности игры. Многие шаблоны, делающие ваш код более гибким используют виртуальную диспетчеризацию, интерфейсы, указатели, сообщения и другие механизмы, за которые приходится платить производительностью работы приложения.
Такая критика имеет все основания. Зачастую архитектура программы предназначена для того чтобы сделать ее более гибкой. Для того чтобы ее было легче изменять. Это значит, что при кодинге вы допускаете меньше допущений. Вы используете интерфейсы для того чтобы можно было работать с любыми классами их реализующими, вместо того чтобы ограничиться тем, что необходимо сегодня. Вы используете шаблоны наблюдатель (observer) и сообщения (messaging) для того чтобы между собой могли легко общаться не только два куска кода сегодня, но и три и четыре в будущем.
Тем не менее производительность предполагает допущения. Искусство оптимизации основывается на введении конкретных ограничений. Можем ли мы предположить что у нас никогда не будет больше 256 противников? Отлично, значит для ID каждого из них достаточно всего одного байта. Будем ли мы вызывать здесь метод конкретного класса? Отлично, значит его можно вызвать статически или использовать inline вызов. Принадлежат ли все сущности к одному классу? Замечательно, значит мы можем организовать их в виде непрерывного массива (contiguous array).
При этом не подразумевается вообще никакой гибкости! Мы можем быстро изменять игру, а для того чтобы сделать хорошую игру жизненно важна именно скорость разработки. Никто, даже Уилл Райт не способен создать сбалансированный игровой дизайн на бумаге. Необходимы итерации и эксперименты.
Еще один интересный контр-пример - это шаблоны в
С++
. Метапрограммирование на основе шаблонов зачастую позволяет организовать абстрактный интерфейс без ущерба для производительности.Гибкость здесь довольно высока. Когда вы пишете код вызова конкретного метода в некотором классе, вы фиксируете этот класс во время написания - жестко вшиваете в код, какой класс вы вызываете. Когда же вы используете виртуальные методы или интерфейсы, вызываемый класс становится неизвестным до момента выполнения. Такой подход достаточно гибкий, но требует накладных расходов в плане производительности.
Метапрограммирование на основе шаблонов представляет собой нечто среднее. Здесь вы принимаете решение о том какой класс вызывать на этапе компиляции, когда создается экземпляр шаблона.
Чем быстрее вы сможете попробовать идею и увидеть как она играется, тем больше вы сможете попробовать и с большей долей вероятности придумаете что-то действительно стоящее. Даже после того, как правильная игровая механика найдена, потребуется еще куча времени на ее тюнинг. Даже небольшой дисбаланс способен похоронить всю игру.
Ответ здесь простой. Делая свою программу боле гибкой, вы ускоряете процесс прототипирования, но жертвуете производительностью. С другой стороны, любая оптимизация кода делает его менее гибким.
Мой собственный опыт показывает, что проще сделать интересную игру быстрой, чем сделать быструю игру интересной. Компромиссным решением здесь может быть принцип стараться делать код гибким до тех пор, пока дизайн игры более менее не устаканится, а затем избавиться от некоторых уровней абстракции в целях увеличения производительности.
Чем хорош плохой код
Теперь мы переходим к следующему ключевому вопросу относительно стилей кодинга. Большая часть этой книги посвящена тому, как писать легко поддерживаемый чистый код, так что можно считать, что я сторонник "правильного" подхода. Однако неряшливый метод кодинга тоже не стоит забывать.
Написание кода с хорошей архитектурой требует больше усилий и выливается в трату большего количества времени. Более того, поддержание кода с хорошей архитектурой на протяжении всей жизни проекта также требует много усилий. Вы должны обращаться со своей кодовой базой также, как порядочный турист, который, покидая стоянку, старается оставить ее в лучшем состоянии, чем нашел.
Это замечательно в случае, если вам придется жить и работать с этим кодом на протяжении долгого времени. Но, как было сказано выше, поддержание игрового дизайна требует проведения множества исследований и экспериментов. Особенно на ранних этапах, когда вы точно знаете что большую часть кода вы просто выкините.
Если вы просто хотите найти наиболее удачное для геймплея решение, следить за красотой архитектуры бессмысленно, потому что так вы потеряете больше времени прежде, чем увидите результат на экране и получите обратную связь. Если то, что было сделано, не заработает, какой вам будет прок от того, что вы потратили столько времени на элегантный код, который вы все равно выбрасываете.
Прототипирование - это просто лепка кода в кучу, достаточно функционального для того чтобы геймдизайнер мог понять насколько идея хороша - т.е. совершенно легитимная в программировании практика. Здесь главное не забывать о самом важном принципе прототипирования. Если вы пишите код для выкидывания, вы обязаны его выкинуть. К сожалению я раз за разом вижу менеджеров, пренебрегающих этим правилом.
Босс: "Слушай, есть идея которую нужно опопробовать. Сделай прототип по-быстренькому. Можешь сильно не стараться. Сколько времени тебе нужно?"
Разработчик: "Ну, если совсем по-быстрому, ничего не тестировать и не документировать и с кучей багов, то можно написать временный код за несколько дней."
Босс: "Отлично!"
Через пару дней...
Босс: "Слушай, прототип классный. Можешь за несколько дней его подлатать и мы возьмем его за основу?"
Вам нужно быть уверенным что люди использующие код, написанный на выброс понимали бы, что даже если он выглядит рабочим, его невозможно поддерживать и его обязательно нужно переписать. Если есть хотя бы малейшая возможность того, что его придется оставить, вам обязательно нужно, несмотря ни на что, писать его уже правильно.
Хорошим трюком можно признать привычку написания прототипа на другом языке программирования. Не том, на котором будет писаться игра. В этом случае вам обязательно придется переписать его прежде, чем он попадет в настоящую игру.
Подведем итоги
Как мы увидили в игру вступает несколько сил:
- Нам нужна хорошая архитектура для того, чтобы легче было понимать код во время цикла разработки проекта.
- Нам нужна хорошая производительность.
- Нам нужно иметь возможность быстро внедрять новую функциональность.
Я нахожу даже забавным тот факт, что в любом случае нам нужно думать о скорости: скорости разработки в долгосрочной перспективе, скорости работы игры, скорости разработки в короткосрочной перспективе.
Между этими целями наблюдаются некоторые противоречия. Хорошая архитектура ускоряет производительность труда в длительной перспективе, но ее поддержание требует затрат дополнительных усилий после каждого изменения.
Быстрее всего написанная реализация совсем не обязательно самая быстрая в плане производительности. Наоборот, оптимизация требует дополнительного времени разработки. И как только она выполнена, кодовая база сразу начинает костенеть: высокооптимизированный код крайне негибок и его очень сложно менять.
Всегда существует соблазн закончить сегодняшнюю работу сегодня, а завтра заняться чем-то еще. Но, если добавлять функциональность так быстро, насколько это возможно, кодовая база быстро превратится в месиво хаков, багов и противоречий, которые замедлят нашу продуктивность в будущем.
Здесь нет простого ответа, возможны лишь компромиссы. Судя по почте, которую я получаю, многих людей это просто обескураживает. Особенно новичков, которые просто хотят сделать игру. Согласитесь, звучит пугающе, когда слышишь: "Правильного ответа не существует, есть только разные варианты неправильных".
Но на мой взгляд это просто замечательно! Посмотрите на любую другую область человеческой деятельности и, скорее всего, вы увидите в основе набор непреложных истин. В конце концов, если бы существовал простой ответ, все бы только так и делали. Область, в которой мастером можно стать за неделю просто скучна. Вы никогда не услышите потрясающих карьерных историй от копателя канав.
А может быть и можно. Я не особо задумывался об этой аналогии. Всегда найдутся энтузиасты, которые копают так глубоко, насколько это только возможно. Даже целые субкультуры организуют. Кто я такой чтобы об этом судить?
На мой взгляд все это очень похоже на сами игры. В игре наподобие шахмат никогда нельзя стать непревзойденным мастером потому что все части игры отлично сбалансированы. Это значит что вы можете потратить целую жизнь на перебор всех возможных стратегий. Игра с плохим дизайном наоборот очень быстро скатывается к одной выигрышной тактике, которой начинает придерживаться игрок пока она ему не надоест.
Упрощение
Гораздо позднее я открыл для себя еще один метод, смягчающий эти ограничения - упрощение. Сейчас я в своем коде стараюсь писать чистое, максимально прямолинейное решение проблемы. Это такой тип кода, после прочтения которого у вас не остается ни малейших сомнений относительно того, что именно он делает и вы не можете представить никакого другого решения.
Я стараюсь выбрать правильные структуры данных и алгоритмы (именно в такой очередности) и в дальнейшем от них отталкиваюсь. При этом я заметил, что чем проще решение - тем меньше кода на выходе. А это значит, что и свою голову мне приходится забивать меньшим количеством кода, когда приходит время его менять.
Зачастую код получается быстрым, потому что не требует слишком больших накладных расходов и сам объем кода невелик. (Конечно, это вовсе не правило. Даже в совсем маленький участок кода можно поместить кучу циклов и рекурсий.)
Однако, обратите внимание, что я не говорю, что написание простого кода требует мало времени. Вы могли бы так предположить, потому что в результате кода будет совсем немного, однако хорошее решение - это не просто разрастание кода - это его дистиллят.
Блез Паскаль закончил свое знаменитое письмо следующими словами "Я хотел написать письмо покороче, но мне не хватило времени".
Еще одну интересную мысль можно найти у Антуана де Сент-Экзюпери "Совершенство достижимо, но не тогда, когда уже нечего добавить, а когда уже нечего убавить".
И еще один пример ближе к телу. Каждый раз когда я просматривал главы этой книги, они становились все короче и короче. Некоторые главы потеряли до 20% объема.
Мы редко сталкиваемся с элегантными проблемами. Вместо этого у нас обычно есть набор вариантов использования. Нам нужно заставить X делать Y когда выполняется условие Z, а W когда выполняется A и т.д. Другими словами все, что у нас есть - это длинный список примеров поведения.
Решение, требующее меньше всего мыслительных усилий - это просто закодировать все эти условия по отдельности. Если вы посмотрите на новичков - они зачастую именно так и делают: они разбиваю решение на большое дерево отдельных случаев.
Никакой элегантности здесь конечно нет и код, написанный в таком стиле имеет тенденцию падать при входных данных, хоть немного отличающихся от тех, на которые рассчитывал программист. Когда мы говорим об элегантном решении, мы чаще всего имеем в виду обобщенное решение: небольшой логический блок, который покрывает большую область вариантов использования.
Поиск такого блока похож на подбор нужного шаблона или разгадывание паззла. Требуются большие усилия чтобы увидеть сквозь разрозненное множество примеров вариантов использования скрытую закономерность, объединяющую их все. И какое же это замечательное чувство, когда разгадка находится.
Просто смиритесь
Большинство людей предпочитают пропускать вступление, так что я поздравляю вас с тем, что вы его одолели. Мне нечем отблагодарить вас за это, кроме нескольких советов, которые я надеюсь будут вам полезны:
Абстрагирование и уменьшение связности позволяет вашей программе эволюционировать быстрее, но не увлекайтесь этим если не уверены в том, что данный код требует гибкости.
Во время разработки помните и про дизайн и про производительность, только откладывайте по возможности всяческие тонкие оптимизации на самый конец цикла разработки.
Поверьте мне, два месяца до даты релиза - это не тот срок когда нужно, наконец, приступать к решению проблемы "игра работает, но выдает только 1 FPS ".
Старайтесь исследовать поле дизайнерских решений быстрее, но не настолько, чтобы оставлять после себя месиво в коде. В конце концов, вам ведь еще с этим кодом жить.
Если собираетесь выбросить код, не тратьте много времени на его совершенствование. Рок звезды ведь именно потому так часто устраивают погромы в номерах отелей, что знают о том, что на следующий день оттуда уедут.
И самое главное - если хотите сделать что-то интересное, получайте удовольствие от процесса.