Как написать собственный игровой движок на C++

Перевод статьи Джеффа Прешинга (Jeff Preshing) How to Write Your Own C++ Game Engine.


Как написать собственный игровой движок на C++


В последнее время я занят тем, что пишу игровой движок на C++. Я пользуюсь им для создания небольшой мобильной игры Hop Out. Вот ролик, записанный с моего iPhone 6. (Можете включить звук!)



Hop Out — та игра, в которую мне хочется играть самому: ретро-аркада с мультяшной 3D-графикой. Цель игры — перекрасить каждую из платформ, как в Q*Bert.


Hop Out всё ещё в разработке, но движок, который приводит её в действие, начинает принимать зрелые очертания, так что я решил поделиться здесь несколькими советами о разработке движка.


С чего бы кому-то хотеть написать игровой движок? Возможных причин много:


  • Вы — ремесленник. Вам нравится строить системы с нуля и видеть, как они оживают.
  • Вы хотите узнать больше о разработке игр. Я в игровой индустрии 14 лет и всё ещё пытаюсь в ней разобраться. Я даже не был уверен, что смогу написать движок с чистого листа, ведь это так сильно отличается от повседневных рабочих обязанностей программиста в большой студии. Я хотел проверить.
  • Вам нравится ощущение контроля. Организовать код именно так, как вам хочется, и всегда знать, где что находится — это приносит удовольствие.
  • Вас вдохновляют классические игровые движки, такие как AGI (1984), id Tech 1 (1993), Build (1995), и гиганты индустрии вроде Unity и Unreal.
  • Вы верите, что мы, индустрия игр, должны сбросить покров таинственности с процесса разработки движков. Мы пока не очень-то освоили искусство разработки игр — куда там! Чем тщательнее мы рассмотрим этот процесс, тем выше наши шансы усовершенствовать его.

Игровые платформы в 2017-ом — мобильные, консоли и ПК — очень мощные и во многом похожи друг на друга. Разработка игрового движка перестала быть борьбой со слабым и редким железом, как это было в прошлом. По-моему, теперь это скорее борьба со сложностью вашего собственного произведения. Запросто можно сотворить монстра! Вот почему все советы в этой статье вращаются вокруг того, как сохранить код управляемым. Я объединил их в три группы:


  1. Используйте итеративный подход
  2. Дважды подумайте, прежде чем слишком обобщать
  3. Осознайте, что сериализация — обширная тема.

Эти советы применимы к любому игровому движку. Я не собираюсь рассказывать, как написать шейдер, что такое октодерево или как добавить физику. Я полагаю, вы и так в курсе, что должны это знать — и во многом эти темы зависят от типа игры, которую вы хотите сделать. Вместо этого я сознательно выбрал темы, которые не освещаются широко — темы, которые я нахожу наиболее интересными, когда пытаюсь развеять завесу тайны над чем-либо.




Используйте итеративный подход


Мой первый совет — не задерживаясь заставьте что-нибудь (что угодно!) работать, затем повторите.


По возможности, начните с образца приложения, которое инициализирует устройство и рисует что-нибудь на экране. В данном случае я скачал SDL, открыл Xcode-iOS/Test/TestiPhoneOS.xcodeproj, затем запустил на своём iPhone пример testgles2.



Вуаля! У меня появился замечательный вращающийся кубик, использующий OpenGL ES 2.0.


Моим следующим шагом было скачивание сделанной кем-то 3D-модели Марио. Я быстро написал черновой загрузчик OBJ-файлов — этот формат не так уж сложен — и подправил пример, чтобы он отрисовывал Марио вместо кубика. Ещё я интегрировал SDL_Image, чтобы загружать текстуры.



Затем я реализовал управление двумя стиками, чтобы перемещать Марио. (Поначалу я рассматривал идею создания dual-stick шутера. Впрочем, не с Марио).



Следующим делом я хотел познакомиться со скелетной анимацией, так что открыл Blender, создал модель щупальца и привязал к нему скелет из двух костей, которые колебались туда-сюда.



К тому моменту я отказался от формата OBJ и написал скрипт на Python для экспорта собственных JSON-файлов из Blender. Эти JSON-файлы описывали заскиненный меш, скелет и данные анимации. Я загружал эти файлы в игру с помощью библиотеки C++ JSON.



Как только всё заработало, я вернулся в Blender и создал более проработанного персонажа (Это был первый сделанный и зариганный мной трёхмерный человек. Я им весьма гордился.)



В течение следующих нескольких месяцев я сделал такие шаги:


  • Начал выделять функции работы с векторами и матрицами в собственную библиотеку трёхмерной математики.
  • Заменил .xcodeproj на проект CMake
  • Заставил движок запускаться и на Windows, и на iOS, потому что мне нравится работать в Visual Studio.
  • Начал перемещать код в отдельные библиотеки "engine" и "game". Со временем, я разделил их на ещё более мелкие библиотеки.
  • Написал отдельное приложение, чтобы конвертировать мои JSON-файлы в бинарные данные, которые игра может загружать напрямую.
  • В какой-то момент убрал все библиотеки SDL из iOS-сборки. (Cборка для Windows всё ещё использует SDL.)

Ключевой момент в следующем: я не планировал архитектуру движка до того как начал программировать. Это был осознанный выбор. Вместо этого я всего лишь писал максимально простой код, реализующий следующую часть функционала, затем смотрел на него, чтобы увидеть, какая архитектура возникла естественным образом. Под "архитектурой движка" я понимаю набор модулей, которые составляют игровой движок, зависимости между этими модулями и API для взаимодействия с каждым модулем.



Этот подход итеративен, потому что фокусируется на небольших практических результатах. Он хорошо работает при написании игрового движка, потому что на каждом шаге у вас есть работающая программа. Если что-то идёт не так, когда вы выделяете код в новый модуль, вы всегда можете сравнить изменения с кодом, который раньше работал. Разумеется, я предполагаю, что вы пользуетесь какой-нибудь системой контроля версий.


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


Готов поспорить, что больше времени тратится при противоположном подходе: пытаться заранее продумать архитектуру, которая будет делать всё, что вам понадобится. Две моих любимых статьи про опасности чрезмерной инженерии — The Vicious Circle of Generalization Томаша Дабровски и Don’t Let Architecture Astronauts Scare You Джоэла Спольски.


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


Итеративный подход дал мне куда более элегантную архитектуру, чем я мог бы вообразить, глядя на чистый лист бумаги. iOS-сборка моего движка сегодня на 100% состоит из оригинального кода, включая собственную математическую библиотеку, шаблоны контейнеров, систему рефлексии/сериализации, фреймворк рендеринга, физику и аудио микшер. У меня были причины писать каждый из этих модулей самостоятельно, но для вас это может быть необязательным. Вместо этого есть множество отличных библиотек с открытым исходным кодом и разрешительной лицензией, которые могут оказаться подходящими вашему движку. GLM, Bullet Physics и STB headers — лишь некоторые из интересных примеров.




Дважды подумайте, прежде чем слишком обобщать


Как программисты, мы стремимся избегать дублирования кода, и нам нравится, когда код следует единому стилю. Тем не менее, я думаю, что полезно не давать этим инстинктам управлять всеми решениями.


Время от времени нарушайте принцип DRY


Приведу пример: мой движок содержит несколько шаблонных классов умных указателей, близких по духу к std::shared_ptr. Каждый из них помогает избежать утечек памяти, выступая обёрткой вокруг сырого указателя.


  • Owned<> для динамически выделяемых объектов, имеющих единственного владельца.
  • Reference<> использует подсчёт ссылок чтобы позволить объекту иметь несколько владельцев.
  • audio::AppOwned<> используется кодом за пределами аудио микшера. Это позволяет игровым системам владеть объектами, которые аудио микшер использует, такими как голос, который в данный момент воспроизводится.
  • audio::AudioHandle<> использует систему подсчёта ссылок, внутреннюю для аудио микшера.

Может показаться, что некоторые из этих классов дублируют функциональность других, нарушая принцип DRY. В самом деле, в начале разработки я пытался повторно использовать существующий класс Reference<> как можно больше. Однако, я выяснил, что время жизни аудио-объекта подчиняется особым правилам: если объект закончил воспроизведение фрагмента, и игра не владеет указателем на этот объект, его можно сразу же поместить в очередь на удаление. Если игра захватила указатель, тогда аудио-объект не должен быть удалён. А если игра захватила указатель, но владелец указателя уничтожен до того, как воспроизведение закончилось, оно должно быть отменено. Вместо того чтобы усложнять Reference<>, я решил, что будет практичнее ввести отдельные классы шаблонов.


95% времени повторное использование существующего кода — верный путь. Но если оно начинает вас сковывать или вы обнаруживаете, что усложняете что-то, однажды бывшее простым, спросите себя: не должна ли эта часть кодовой базы в действительности быть разделена надвое.


Использовать разные соглашения о вызове — это нормально


Одна из вещей, которая мне не нравится в Java — то, что она заставляет вас определять каждую функцию внутри класса. По-моему, это бессмысленно. Это может придать вашему коду более единообразный вид, но также поощряет переусложнение и не поддерживает итеративный подход, описанный мной ранее.


В моём C++ движке некоторые функции принадлежат классами, а некоторые — нет. Например, каждый противник в игре — это класс, и бо́льшая часть поведения противника реализована в этом классе, как и следовало ожидать. С другой стороны, sphere casts в моём движке выполняются вызовом sphereCast(), функции в пространстве имён physics. sphereCast() не принадлежит какому-либо классу — это просто часть модуля physics. У меня есть система сборки, которая управляет зависимостями между модулями, что сохраняет код достаточно (для меня) хорошо организованным. Заворачивание этой функции в произвольный класс никоим образом не улучшит организацию кода.


А ещё есть динамическая диспетчеризация, которая является формой полиморфизма. Часто нам нужно вызвать функцию объекта, не зная точного типа этого объекта. Первый порыв программиста на C++ — определить абстрактный базовый класс с виртуальными функциями, затем перегрузить эти функции в производном классе. Работает, но это лишь одна из техник. Существуют и другие методы динамической диспетчеризации, которые не привносят так много дополнительного кода, или имеют другие преимущества:


  • С++11 ввел std::function, и это удобный способ хранить функции обратного вызова. Также можно написать собственную версию std::function, не вызывающую столько боли, когда заходишь в неё в отладчике.
  • Многие функции обратного вызова могут быть реализованы с помощью пары указателей: указателя на функцию и непрозрачного аргумента. Требуется только явное приведение внутри функции обратного вызова. Это часто встречается в библиотеках на чистом C.
  • Иногда базовый тип известен во время компиляции, и можно привязать вызов функции вообще без накладных расходов времени выполнения. Turf — библиотека, которой я пользуюсь в своём игровом движке, сильно полагается на этот способ. Взгляните на turf::Mutex для примера. Это просто typedef над платформо-специфичными классами.
  • Иногда самый прямой путь — создать и поддерживать таблицу сырых указателей на функцию своими силами. Я использовал этот подход в своих аудио микшере и системе сериализации. Интерпретатор Python также на полную использует эту технику, как будет показано ниже.
  • Вы можете даже хранить указатели на функцию в хэш-таблице, используя имена функций как ключи. Я пользуюсь этой техникой для диспетчеризации событий ввода, таких как события мультитача. Это часть стратегии по записи ввода игры и воспроизведения его в системе реплеев.

Динамическая диспетчеризация — обширная тема. Я лишь поверхностно рассказал о ней, чтобы показать как много способов реализации существует. Чем больше растяжимого низкоуровневого кода вы пишите — что не редкость для игрового движка — тем чаще обнаруживаете себя за изучением альтернатив. Если вы не привыкли к программированию в таком виде, интерпретатор Python, написанный на C — отличный пример для изучения. Он реализует мощную объектную модель: каждый PyObject указывает на PyTypeObject, а каждый PyTypeObjeсt содержит таблицу указателей на функцию для динамической диспетчеризации. Документ Defining New Types — хорошая начальная точка, если вы хотите сразу погрузиться в детали.




Осознайте, что сериализация — обширная тема


Сериализация — это преобразование объектов времени выполнения в последовательность байтов и обратно. Другими словами, сохранение и загрузка данных.


Для многих, если не большинства, движков игровой контент создаётся в разных редактируемых, таких как .png, .json, .blend или проприетарных форматах, затем в конце концов конвертируется в платформо-специфичные форматы игры, которые движок может быстро загрузить. Последнее приложение в этом процессе часто называют "cooker". Cooker может быть интегрирован в другой инструмент или даже распределяться между несколькими машинами. Обычно, cooker и некоторое количество инструментов разрабатываются и поддерживаются в тандеме с самим игровым движком.



При подготовке такого пайплайна выбор форматов файлов на каждой из стадий остаётся за вами. Вы можете определить несколько собственных форматов, и они могут эволюционировать в процессе того как вы добавляете функциональность в движок. В то время как они эволюционируют, у вас может возникнуть необходимость сохранить совместимость некоторых программ с ранее сохранёнными файлами. Не важно в каком формате, в конце концов вам придётся сериализовать их в C++.


В C++ есть бесчисленное множество способов организовать сериализацию. Один из довольно очевидных заключается в добавлении функций save и load классам, которые вы хотите сериализовать. Вы можете добиться обратной совместимости, храня номер версии в заголовке файла, затем передавая это число в каждую функцию load. Это работает, хотя код может стать громоздким.


    void load(InStream& in, u32 fileVersion) {
        // Загрузить ожидаемые переменные-члены
        in >> m_position;
        in >> m_direction;

        // Загрузить более новую переменную только если версия загружаемого файла больше 2.
        if (fileVersion >= 2) {
            in >> m_velocity;
        }
    }

Можно писать более гибкий, менее подверженный ошибкам код сериализации, пользуясь преимуществом рефлексии — а именно, созданием данных времени выполнения, описывающих расположение ваших C++ типов. Чтобы получить краткое представление о том, как рефлексия может помочь с сериализацией, взглянем на то, как это делает Blender, проект с открытым исходным кодом.



Когда вы собираете Blender из исходников, выполняется много шагов. Во-первых, компилируется и запускается собственная утилита makesdna. Эта утилита парсит набор заголовочных файлов C в дереве исходников Blender, а затем выводит краткую сводку со всеми определёнными типами в собственном формате, известном как SDNA. Эти SDNA-данные служат данными рефлексии. SDNA затем компонуется с самим Blender, и сохраняется с каждым .blend-файлом, который Blender записывает. С этого момента, каждый раз когда Blender загружает .blend-файл, он сравнивает SDNA .blend-файла cо SDNA, скомпонованной с текущей версией во время исполнения и использует общий код сериализации для обработки всех различий. Эта стратегия даёт Blender впечатляющий диапазон обратной и прямой совместимости. Вы всё ещё можете загрузить файлы версии 1.0 в последней версии Blender, а новые .blend-файлы могут быть загружены в старых версиях.


Как и Blender, многие игровые движки — и связанные с ними инструменты — создают и используют собственные данные рефлексии. Есть много способов делать это: вы можете разбирать собственный исходный код на C/C++, чтобы извлечь информацию о типах, как это делает Blender. Можете создать отдельный язык описания данных и написать инструмент для генерации описаний типов и данных рефлексии C++ из этого языка. Можете использовать макросы препроцессора и шаблоны C++ для генерации данных рефлексии во время выполнения. И как только у вас под рукой появятся данные рефлексии, открываются бесчисленные способы написать общий сериализатор поверх всего этого.


Несомненно, я упускаю множество деталей. В этой статье я хотел только показать, что есть много способов сериализовать данные, некоторые из которых очень сложны. Программисты просто не обсуждают сериализацию столько же, сколько другие системы движка, даже несмотря на то, что большинство других систем зависят от неё. Например, из 96 программистских докладов GDC 2017, я насчитал 31 доклад о графике, 11 об онлайне, 10 об инструментах, 3 о физике, 2 об аудио — и только один, касающийся непосредственно сериализации.


Как минимум, постарайтесь представить, насколько сложными будут ваши требования. Если вы делаете маленькую игру вроде Flappy Bird, с несколькими ассетами, вам скорее всего не придётся много думать о сериализации. Вероятно, вы можете загружать текстуры напрямую из PNG и этого будет достаточно. Если вам нужен компактный бинарный формат с обратной совместимостью, но вы не хотите разрабатывать свой — взгляните на сторонние библиотеки, такие как Cereal или Boost.Serialization. Не думаю, что Google Protocol Buffers идеально подходят для сериализации игровых ресурсов, но они всё равно стоят изучения.


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


Я люблю сравнивать наблюдения по этой теме, так что мне очень интересно услышать мнение других разработчиков. Если вы писали движок, привел ли ваш опыт к тем же выводам? А если не писали или ещё только собираетесь, ваши мысли мне тоже интересны. Что вы считаете хорошим ресурсом для обучения? Какие аспекты ещё кажутся вам загадочными? Не стесняйтесь оставлять комментарии ниже или свяжитесь со мной через Twitter.

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 17
  • 0
    Игрушка очень прикольная.
    Можно ссылку на скачивание?
    • 0
      Меня ролик тоже порадовал. Такой он сочный, ух! Но игра не моя (эта статья — перевод), и вроде бы игра ещё не вышла. Могу только предложить следить за автором: preshing.com
    • 0
      раскажите как вы двигали в движке скелет? Просто подменяли один меш позы на другой?
      • 0
        «Мопед не мой...»

        Двигал не я, двигал автор статьи и движка: Джефф Прешинг (см. коммент выше). Думаю, можно задать ему этот вопрос в твиттере. Если смущает языковой барьер — готов помочь.

        Но я не совсем понимаю, что вы имеете в виду под «подменой одного меша позы на другой», можете пояснить?
        • 0
          Извините, не заметил перевод.
          Когда вы в Blender или Unity двигаете скелет, движок сам деформирует мэш, привязанный к скелету, на основании весов каждого вертекса. Таким образом, анимация — это 1 исходный меш, и траектории движения костей.
          В простых «велосипедных» движках, что б помахать рукой модели, например, приходится экспортировать из 3D редактора все кадры движения руки в отдельные obj, и проигрывать «покадрово», что довольно затратно.
        • 0
          Наверно написал модуль (функция), который запускает заранее подготовленные анимации скелета.
        • +2
          Дважды подумайте, прежде чем слишком обобщать
          Золотые слова.

          Я порой вижу как, особенно молодые разработчики, пытаются применить какие-то интерфейсы, паттерны везде где можно, обьясняя это тем что «ну если делать, то сразу как следует».
          Но всегда нужно сопоставлять теорию с реальностью. А реальность такова, что этот интерфейс будет иметь один метод, и использоваться между компонентами над которыми работает один и тот же человек.
          Нужно всегда себя спрашивать как это поможет тебе в ежедневной работе. Что это упростит?
          Если ничего, то это слепая жертва моде.
          Вот я пришу тесты на page object паттерне. Там все плоскенько и туповатенько. Недавно я подумал, а что если сделать все еще ооп-шнее, с абстрактными базовыми классами и пр. Попробовал, работает, выглядит ооп-шно, ничерта не понятно стало, простота для стороннего читателя снизилась до нуля. Вовремя остановился.
          Не надо стесняться выглядеть не-круто. Я думаю хорошие решения они всегда компромиссные. Они никогда не идеальные, но в них есть равновесие Нэша, когда любое стремление в сторону что-то ухудшит.

          Т.е вот тут:

          надо понимать, что улучшение это два колеса, а не кародж (см. Специалист по этике. Гарри Гаррисон),
          которым другие просто не смогут эффективно пользоваться.
          • –1

            Картинка ваша вообще не к месту.
            1) Чтобы поменять колеса нужно остановиться.
            2) 1 меняет, 2 поднимают. Хватит сил поднять 2м?
            3) Можно выложить камни. А если порядок важен? Кто будет запоминать его?
            4) Чувак с колесами проверил, что они подойдут?
            5) Впереди нет горы, с которой с колесами может скатиться?
            6) Колеса не увязнут в грязи?

            • 0
              да, я подозревал, что эта картинка уже настолько заезжена, что трудно будет воспринимать ее под другим углом. Я имел ввиду «good enough solution». Не переусердствовать с улучшением, вовремя остановиться. Улучшать только то, что нужно улучшить.
              А ваши замечания по классической ее интерпретации очень удачны. Это тоже проблема, даже более серьезная, отдельно взятое рацпредложение может оказаться не к месту.
          • +1
            Создавать свои движки конечно круто и интересно. Но вот я лично просто не потяну, мозгов тупо не хватит, плюс не очень хорошее знание С++. Да и зачем свой движок создавать, сейчас уже 2018, а не 2000 год, лучше взять готовый, и модернизировать под свой проект.
            Ну а так, лично я ковыряю XRay С.Т.А.Л.К.Е.Р. движок несколько устарел, но зато я его освоил вне которой степени. Ещё можно взять CRYENGINE, Unity, Unreal и т.д. У каждого есть свои достоинства и недостатки, хотя я по прежнему буду ковырять XRay как хорошо освоенный. Хотя тут про мобильную платформу, не знаю, может тут и есть смысл что своё замутить.
            • 0
              зачем свой движок создавать
              я лично просто не потяну, мозгов тупо не хватит, плюс не очень хорошее знание С++
              Затем, чтобы мозгов начало хватать и лучше знать С++. Это поможет потом ковырять другие движки.
              • +1
                «Зачем свой движок создавать» автор в статье неплохо описывает. Попробую провести аналогию с пищей: можно сходить поужинать в ресторан, а можно пойти в магазин за продуктами и приготовить самому. И там и там свои плюсы и минусы. (Ну вот люблю я готовить, потому что можно сделать именно так, как мне нравится, а не как шеф решил! Даже если часть любимых рецептов я пока не освоил.) А есть ещё люди, которые в деревне живут своим хозяйством: курицы, коровы, огород, вот это вот всё. Так что тут тоже много уровней абстракции. И если бы отдельный человек всю жизнь был ограничен только одним из вариантов — было бы очень неприятно.

                Что касается личных возможностей — на мой взгляд, единственный ограничитель — это время. Автор статьи в комментариях признаётся, что это не первая его попытка написать движок (а ещё, что он уже 14 лет в индустрии). Очень часто «не хватает мозгов» на самом деле означает «не хватает терпения разобраться, изучить, научиться».

                На С++ свет клином тоже не сошёлся, кстати.

                И совершенно верно про «достоинства и недостатки». Общее место, но, видимо, ещё актуально повторять: не практично заявлять «лучше делать так» или «лучше делать эдак». Есть определённые цели, есть определённые проблемы, есть условия работы. Исходя из них надо выбирать наиболее подходящие инструменты. Не потому что «этот движок круче» или «моднее» или «может X». А потому что «из всех известных альтернатив он лучше всего подходит для решения моей задачи». И в таком случае может оказаться, что написание собственного движка — действительно наиболее подходящий вариант.

                Впрочем, я бы точно не советовал заниматься движкостроением начинающим. Лучше, КМК, действительно изучить чью-то чужую работу, чтобы не тратить время (главный ограничитель!) на изобретение велосипедов.
              • 0
                Демо в ролике приятное и интригующее, но первый вопрос — тоже самое сложно ли организовать на другом (готовом) движке?
                • 0
                  возможно, я не в теме, но что такое «зариганный»?
                  • 0
                    Это моя недоработка как переводчика :)

                    Как-то не удалось найти или придумать адекватный перевод «риггингу», то есть привязке скелета к 3D-модели: www.mir3d.ru/learning/930

                    С радостью приму более грамотные варианты перевода фразы «This was the first rigged 3D human I ever created». Что-нибудь вроде «Это был первый созданный мной трёхмерный человек, для которого я выполнил риггинг (привязку скелета к модели?)», может быть?
                    • 0
                      Например, «эта была моя первая ригидная 3D модель человека»

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое