Игра своими руками. Создание трехмерного игрового движка на базе GLScene
Некоторое время назад мы затрагивали тему создания компьютерных игр и рассказывали об уникальном бесплатном трехмерном движке, написанном на Delphi — GLScene (движок забирайте с нашего CD/DVD ). Тема создания полноценных трехмерных компьютерных игр была вам очень интересна, о чем можно было судить по количеству пришедших писем. Однако тогда мы решили, что рассказывать о программировании под движок — слишком сложно. С тех пор ваш уровень заметно возрос (об этом также можно судить по письмам и по активности на форуме журнала), вы стали более подкованы в вопросах программирования. Особенно после публикации цикла “ Кладовая программиста ”.
Рис. 1. Иногда трехмерные движки вместе с новым уровнем абстракции предлагают довольно экзотический инструментарий, чтобы им управлять.
С этого номера мы начинаем публикацию серии статей, в которых детально рассмотрим различные этапы создания трехмерной игры. Вы повысите свои навыки программирования и, что называется, заглянете за завесу тайны, отделяющую серьезных разработчиков игр от простых смертных. Движок любой игры складывается из многих и часто независимых частей-кирпичиков: контроль столкновений, физическая модель, игровой интерфейс, главное меню, загрузка уровней и многое другое. Есть специфические кирпичики, которые нужны только для какого-то одного жанра. Например, модуль погодных явлений важен и нужен в авиационном или морском симуляторе, а в стратегии реального времени он второстепенен или вообще не нужен, а в футбольном симуляторе ни к чему модуль выстрелов. Но несколько десятков кирпичиков присутствуют в любой игре. В серии статей мы расскажем о каждом из таких кирпичиков, покажем, как он реализуется и как его связать с остальными. К концу цикла вы сможете из этих кирпичиков собрать свою собственную компьютерную игру довольно высокого уровня. Что это вы тут делаете? Для тех, кто пропустил какие-то из предыдущих моих статей (или даже все), отвечу на возникшие у вас вопросы. Так сказать, небольшое техническое вступление. Почему Delphi? Эта среда разработки и язык программирования Object Pascal достаточно гибкие, чтобы создать полноценную трехмерную игру практически любого жанра с современным уровнем графики. Многие возразят, что стандартом де-факто разработки компьютерных игр является MSVC++ или другие среды на основе С++. Но подобные стандарты, как это часто бывает, складываются стихийно. Не будем смешивать два понятия — язык и среда разработки. C++, безусловно, мощнее, чем Object Pascal. Но он и менее высокоуровневый, то есть в разы сложнее. Для новичков С++ подходит слабо. Object Pascal же не только простой, но и достаточно гибкий, чтобы на нем можно было разработать полноценную компьютерную игру современного уровня. Теперь о средах. Тут так категорично не скажешь. Среда разработки
Рис. 2. Иногда уровень абстракции движка включает в себя не только обработку графики и звука, но и поддержку мультиплеера. Таков, например, Fly 3D, результат работы которого вы видите на скриншоте.
— дело вкуса и привычки каждого конкретного программиста. Поделюсь своим мнением на этот счет. MSVC++ генерирует немного более быстрый код, чем Delphi. Собственно, на этом преимущества заканчиваются (повторюсь, на мой субъективный и ни к чему не обязывающий взгляд). Козыри Delphi — большая скорость компиляции (в десятки и даже сотни раз быстрее, чем MSVC++), высокое качество средств отладки (в большинстве случаев Delphi указывает точно ту строку кода, в которой содержится ошибка, тогда как MSVC++ может указать строчку за несколько страниц от искомой) и удобный интерфейс. Почему GLScene? Я повидал и перепробовал много бесплатных трехмерных движков, но остановился именно на этом. Его самое главное преимущество — GLScene постоянно совершенствуется. Разработчики не поставили точку и, скорее всего, не поставят ее никогда. Движок постоянно эволюционирует и впитывает в себя все новинки технического прогресса. Это единственный из известных мне бесплатных движков, про который никогда не скажут “устарел”. Несколько сотен постоянно работающих над “двигателем” энтузиастов не допустят этого. Как пример: в движке поддержка самых первых шейдеров появилась уже через несколько месяцев после того, как NVidia выпустила соответствующие инструментальные средства. Еще одно преимущество: в комплекте с GLScene поставляются его полные исходники. Новичкам этот факт, наверное, вряд ли будет полезен. Хотя познакомиться с исходниками, написанными рукой профессионала, многого стоит. А вот бывалые программисты чувствуют основной смысл этих слов: ведь они смогут перекраивать движок как им вздумается. Единственное условие в соответствии с лицензией MPL — любые изменения в исходниках должны быть доступны координатору проекта (сейчас координатор — Эрик Гранж ). Вдруг ваш код еще кому-нибудь пригодится?!
Рис. 3. Delphi+GLScene — идеальная среда разработки компьютерных игр для новичков.
Несмотря на то что все примеры кода, которые будут приводиться в этом цикле статей, будут написаны на Delphi с применением GLScene, они будут полезны и тем, кто программирует на других языках и с другими графическими библиотеками. Ведь общие принципы создания графического движка не зависят ни о того, ни от другого. Итак… мы начинаем. Зачем нужен трехмерный движок? Товарищи новички, сосредоточьтесь! Возможно, то, что я сейчас скажу, с первого раза будет не очень понятно. Обязательно перечитайте и вникните: это один из основных принципов программирования вообще и разработки сложных систем (а игра — это сложная система) в частности. Представьте себе какую-нибудь проcтенькую игру. Пинг-понг, к примеру. Программист написал его на чистом OpenGL , исходники уместились строк эдак в 200. Что там будет движком, а что основным кодом игры? Так прямо сразу и не скажешь… А если подумать, такое разделение на движок и основной код вообще не нужно.
Рис. 4. Сейчас игрок нажмет на кнопку, и в путь устремятся десятки пуль в секунду. А чтобы игра не тормозила, они не будут создаваться прямо из воздуха, а перенесутся сюда из заэкранного аккумулятора.
Теперь представьте, что мы хотим сделать более или менее серьезный 3D-action (десятки тысяч строк кода). И будем мы программировать таким же способом, как если бы мы делали тот самый пинг-понг. И скоро запутаемся! Да, этот код будет быстрым, там не будет ничего лишнего, но… не всякий программист сможет дописать его до конца. А ошибки в таком плотном коде искать — сущий ад. Значит, его надо как-то упорядочить. Проще всего это сделать с помощью выделения уровней абстракции. Уровень абстракции — это одно из важнейших понятий модульного программирования. Представьте, что вы строитель, и вам нужно построить дом. Вы оперируете кирпичами: берете кирпич, кладете его на строящуюся стену, намазываете раствором, берете следующий кирпич… Кирпичи — ваш уровень абстракции. Теперь представьте, что вы застройщик. И вам нужно построить микрорайон. Вы говорите строителю, где строить дома, какие дома сносить. Дом — это ваш уровень абстракции. Было бы странно, если бы вы указывали строителю, какой кирпич куда класть. Вы сказали: вот здесь будет дом. Все остальные заботы берет на себя строитель. Ну а теперь представьте, что вы мэр города. И вам необходимо дать задание толпе застройщиков к такому-то году дать городу столько-то нового жилья. Вряд ли вы будете лично планировать, где какой дом должен стоять. Это работа застройщика. Уровень абстракции мэра — объем жилого фонда, который можно увеличивать, можно уменьшать, а как это будет выполняться — дело десятое. По большому счету на этом уровне абстракции все равно, из чего строятся дома: хоть из кирпичей, хоть из бивней мамонта. И у мэра в списке команд просто не может быть “ положить кирпич ”, хотя любая его команда через несколько уровней абстракции к этому и приведет. В более или менее сложной компьютерной программе или игре — то же самое. Каждый уровень абстракции отвечает за свою часть работы, опираясь на возможности более низкого уровня. Каждый уровень абстракции обеспечивает более высокому уровню удобный интерфейс для работы с объектами. В компьютерной игре нижний уровень абстракции — это
Рис. 5. Пример Mushroom из комплекта GLScene прекрасно иллюстрирует применение прокси-объектов.
язык программирования (хотя, на самом деле, можно копнуть еще глубже — до железа). Далее идут команды OpenGL API (если мы именно с его помощью программируем). На этом уровне мы можем отдать команду вроде “ нарисовать полигон ” и “ поменять местами видимую и теневую части видеобуфера ”. Потом — команды GLScene. На этом уровне мы можем дать команды вроде “ построить куб ”, “ загрузить модель в формате 3ds ” и “ наложить на модель такую-то текстуру ”. А вот дальше —игровой движок. И, наконец, игровой код, который может давать игровому движку команды вроде “ загрузить уровень ”, “ выстрелить таким-то персонажем из такого-то оружия ” и “ показать заставочный ролик ”. В идеальном случае каждый уровень абстракции пользуется командами только предыдущего уровня. Не всегда это возможно. Но к этому надо стремиться, так как в таком случае код будет быстрым, удобным и легкочитаемым. Динамическое создание объектов Мы рассмотрели вертикальную организацию компьютерной игры. Но каждый уровень абстракции можно разделить на смысловые блоки — модули. Деление это необязательно и всегда будет чисто условным, просто так проще программировать. Сегодня мы разберем маленький, но очень важный модуль-кирпичик — динамическое создание объектов, который присутствует во всех без исключения играх.
Рис. 6. Лес, реализованный прокси-объектами. Заметьте, что все деревья разные по размеру. От объекта-источника объект-клон может наследовать только необходимые элементы, в данном случае — геометрию. А размеры всех прокси-объектов индивидуальны.
Предположим, вы создаете модуль вооружения и хотите запрограммировать очередь из пулемета. Все бы ничего, но откуда вы знаете, сколько игрок может выпустить пуль за всю игру? Через редактор объектов в IDE GLScene можно создавать любые объекты, но только если вы четко знаете, сколько и каких объектов вам нужно. В большинстве случаев это неприемлемо. К примеру, у вас в игре есть 20 уровней, у каждого уровня — свой набор объектов. И что же, создавать перед началом игры все объекты всех уровней? Это долго, да и займет огромное количество памяти. Единственный выход — создавать объекты прямо во время игры, динамически. В GLScene динамическое создание любого объекта состоит из двух стадий — создания экземпляра класса этого объекта и присвоения ему необходимых свойств. Возьмем уже упоминавшийся пример с очередями у пулемета и динамически создадим пулю. Предположим, пуля у нас будет промто-сферой. За сферы в GLScene отвечает класс TGLSphere. Казалось бы, можно написать так: Sphere:=TGLSphere.Create Однако команда работать не будет, так как каждому объекту в GLScene надо зарегистрироваться в очереди объектов. Кроме того, объект нельзя создать в “пустоте”, он должен быть привязан к какому-то объекту более высокого уровня. Корневой объект самого высокого уровня — glscene1.Objects (если объект компонента TGLScene у вас называется glscene1). Правильный вариант: Sphere:=TGLSphere (glscene1.Objects.AddNewChild(TGLSphere)) Разберем эту строчку по частям. У корневого объекта glscene1.Objects мы вызываем метод AddNewChild , который добавляет в корень объект класса, указанный в параметре (в данном случае это сфера — TGLSphere ). Так тоже можно: в параметры процедурам передавать не объекты, а целые классы. Зачем перед присвоением нужно преобразование типа к TGLSphere? Дело в том, что метод AddNewChild , что бы вы ему в параметр ни передали, возвращает объект класса TGLBaseSceneObject. Нас это не устраивает, поэтому мы и преобразуем тип к TGLSphere. Получившийся объект присваивается переменной Sphere. Теперь с помощью этой переменной мы можем нашей пуле задать разные параметры, например положение в пространстве: Sphere.Position.X:= Sphere.Position.Y:= Sphere.Position.Z:= Или цвет: Sphere.Material.FrontProperties.Diffuse=
Рис. 7. А здесь прокси-объекты не деревья (иначе они были бы слишком однообразными), а ветви с листвой. Из ветвей уже собирается финальное дерево.
Динамическое создание моделей мы разобрали, а теперь поговорим о динамическом их уничтожении. В самом деле, пуля когда-нибудь попадает в стену, человека или же улетает в голубую даль. С этого момента она больше не нужна. Если мы так и оставим ее, она будет занимать какую-то область памяти. Учитывая, сколько выстрелов делает среднестатистический кемпер, пока его нычку не обнаружат, нам всей памяти компьютера не хватит, чтобы хранить такое количество пуль. Поэтому любые объекты игры, которые стали ненужными, надо немедленно уничтожать. Единственный правильный способ сделать это — вызвать метод Free , например: Sphere.Free Частенько бывает необходимо проверить, существует ли объект, или его уже уничтожили. Для этого сравниваем объект с универсальной константой нуля — nil , например: If Sphere <>nil then Begin {сферу еще не уничтожили,значит, делаем здесь что-нибудь полезное} End **
Рис. 8. В данном случае дождинки не прокси-объекты, а обычные объекты, которые берутся из аккумулятора. Для программистов так было проще.Или же вызываем функциюAssigned**, которая делает то же самое. И вот тут вас подстерегает один гигантский подводный камень, на который рано или поздно наталкивались все программисты. Если вы освободили объект методомFree, это не гарантирует, что переменная объекта стала равноnil! То есть при определенном стечении обстоятельств в примере выше, даже если сфера уничтожена, условие будет выполняться. Если вы в условии после проверки обращаетесь с этой сфере (а так почти всегда и бывает), произойдет критическая ошибка, что чревато вылетом игры в синие форточки. Чтобы гарантировать, что освобожденный объект станет равным nil, используйте специальную процедуруFreeAndNil, например:**FreeAndNil(Sphere)**Теперь вы можете быть уверенными в том, что никогда не обратитесь к уже несуществующему объекту. Описанную процедуру создания и уничтожения объектов можно применять к любым объектам GLScene.**Зачем играм аккумулятор? ** Рассмотрим пример выше с пулеметом. Обычно в играх пули — это не просто сферы, а сложные объекты, у которых к тому же еще и текстура имеется. Каждый раз, когда вы создаете пулю, освобождается участок памяти, устанавливаются
Рис. 9. Движок любой игры состоит из кирпичиков — так же, как эта кладка. И так же, как в этой стене, верхний уровень в движке не может существовать без нижних.
свойства этой пули, загружается модель пули, загружается текстура (с винчестера!). Все это занимает определенное время. Если число пуль, которые изрыгает пулемет в секунду, очень велико, могут начаться дикие тормоза, особенно на слабых компьютерах. С уничтожением пуль такая же проблема: надо выгрузить объект, освободить память… То же самое относится не только к пулям, но и к любым объектам, которые часто появляются и исчезают, например к каплям дождя, искрам от электропроводки… Подобная расточительность системных ресурсов в компьютерных играх неприемлема. Вы же не хотите, чтобы вашу игру можно было запустить только на суперкрутой графической станции? Выход простой. Прикидываем, сколько в среднем объектов подобного рода может существовать одновременно. Допустим, пулемет может выбросить несколько сотен пуль за десяток секунд, и за этот же десяток секунд пули обязательно долетят до цели. Перед началом игры создаем все сто пуль. Лучше всего это делать во время загрузки уровня. Небольшой задержки никто не заметит. Далее пули помещаются в список или массив, который называем аккумулятором. Делаем пули невидимыми или выносим их куда-нибудь за пределы игрового пространства. Как только пулемет начал стрелять, вместо того чтобы создавать пули, мы перемещаем в нужное место уже созданные пули из аккумулятора и делаем их видимыми. Как только пуля достигнет цели, мы не уничтожаем ее, а вновь делаем невидимой и помещаем в аккумулятор. В итоге для каждой пули мы экономим время создания и время уничтожения. А это очень и очень много! А что если мы немного ошиблись в своих прикидках, пули в аккумуляторе кончились, а пулемет продолжает стрелять? Тут уж ничего не поделаешь — придется новые пули создавать динамически, пока в аккумулятор не вернутся старые. И новые пули тоже не будем уничтожать, а запасем в аккумуляторе — вдруг еще понадобятся… **
Рис. 10. В этой демке из комплекта GLScene все кубики создаются динамически. Обратите внимание, что на этапе создания каждому кубику присваиваются свойства прозрачности.
Атака клонов Пусть у нас есть большой лес, в котором много-много одинаковых деревьев или, скажем, много деревьев нескольких разных видов. Пример похож на предыдущий, только мы тут ничего динамически не создаем и не уничтожаем — на этом уровне деревья есть всегда. Проблема будет при загрузке уровня. Создание стольких деревьев займет огромное время. Но ведь они все одинаковые! То есть мы раз за разом загружаем с винчестера и создаем в памяти копии одного и того же. Загрузили. Играем. Перед рендером каждого из деревьев выполняются подготовительные процедуры. Для каждого дерева они будут одними и теми же, но вызывать мы их будем опять большое число раз, по числу деревьев! Расточительно получается. И память под каждое дерево резервировать надо, и обработка каждого из них время занимает. Вот бы загрузить одно-единственное дерево, а когда надо будет вывести на экран остальные деревья, просто показать графической библиотеке, откуда брать необходимые данные. Это ж какая экономия ресурсов получится, какой пророст FPS! Такие “ложные” деревья (и не только деревья — что угодно), о которых в памяти хранится только частная информация (положение в пространстве, углы поворота), а одинаковая информация хранится только один раз, называютсяпрокси-объектами**. В GLScene для создания прокси-объектов существует специальный класс —TGLProxyObject. Пользоваться им очень просто. Сначала создаем объект-источник, то есть единственное дерево, например так:**
*Рис. 11. Уникальная технология Imposters, которую внедрили в GLScene совсем недавно, автоматически преобразует динамически создаваемые объекты в наборы спрайтов и хранит их в памяти. Если трехмерный объект окажется достаточно далеко от наблюдателя, он в целях повышения производительности заменяется на спрайт.Tree:=TGLFreeFrom(glscene1.objects.AddNewChild(TGLFreeFrom)); //Загружаемего модель: Tree.LoadFromFile(‘Tree.3ds’); //Загружаем его текстуру: Tree.Material.Texture.Disabled:=false; Tree.Material.Texture.Image,LoadFromFile(‘tree.jpg’); //А теперь создадим десять деревьев-клонов в случайных местах: for i:=1 to 10 do begin //Создаем очередной прокси-объект proxy:=TGLProxyObject(glscene1.objects.AddNewChild(TGLProxyObject)); with proxy do begin //В свойство MasterObject записываем наше дерево-образец MasterObject:=Tree; //Показываем, что наследоваться должна только структура объекта ProxyOptions:=[pooObjects]; //Ориентацию дерева в пространстве надо оставить неизменной Direction:= Tree.Direction; Up:= Tree.Up; //А вот положение задаем случайное Position.X:=Random(100); Position.Y:=Random(100); //И повернем дерево на случайный угол, чтобы лучше смотрелось RollAngle:=Random(360); end; end;Теперь у нас есть десяток деревьев по цене одного. Обратите внимание, что если мы как-нибудь изменим объект-оригинал, это изменение мгновенно отразится на всех объектах-клонах. * * Мы рассказали о первом кирпичике. В следующих статьях мы подарим вам целый грузовик таких кирпичей, из которых вы сможете построить трехмерный игровой движок своей мечты. Ну а чтобы вам было проще, на компакт мы выкладываем последнюю протестированную версиюGLScene.