В прошлом выпуске я одолел загрузку изображения в формате PNG:
Теперь игровые объекты можно рисовать не квадратиками, а настоящими картинками.
Поверхности и текстуры
Растровое изображение это некий массив байтов, и в SDL2 есть два способа работы с ним.
- Поверхность (Surface) это данные, расположенные в оперативной памяти. С ними можно работать как с обычным массивом – читать оттуда байты или записывать их. Можно выполнять любые манипуляции с изображением, имея доступ к каждому его пикселу. Поверхность может быть экранной, то есть всё, что мы на ней рисуем, отображается на экране.
- Текстура (Texture) это данные, расположенные в видеопамяти. К видеопамяти нет прямого доступа, потому что находится она вообще в другом месте – на видеокарте. Можно отправлять в неё данные или получать их, но это делается через драйвер и занимает много времени. Чтобы, к примеру, изменить пару пикселов в текстуре, нужно получить её (или её часть) из видеопамяти, изменить пикселы и отправить изменённые данные обратно в видеопамять.
Плюсы и минусы
Поверхности, хотя и позволяют манипулировать данными напрямую, оказываются очень медленными в самом банальном случае – когда необходимо рисовать картинки с прозрачностью. При наложении двух пикселов друг на друга нужно вычислить новый цвет пиксела, который образуется из двух исходных цветов с учётом их прозрачности. Данная операция даже на современных компьютерах приводит к существенному замедлению рисования. Частично можно решить проблему с помощью SIMD-инструкций процессора, получив ускорение, к примеру, в 8 раз, но этого не всегда достаточно.
В игре RDS, с учётом того, сколько в ней графики и какая она, я мог бы использовать чисто ручные вычисления прозрачности даже без применения SIMD, и всё равно это было бы быстро.
Текстуры, с другой стороны, используются графическим ускорителем, а графические ускорители работают в тысячи раз быстрее. Проблем с прозрачностью уже нет, а также можно поворачивать и растягивать текстуры – всё посчитается легко.
Когда мы используем текстуры, SDL2 переключается на OpenGL-рендерер. OpenGL имеет свою специфику – нельзя просто так взять и что-то нарисовать. Нужно подготавливать буферы вершин, писать шейдеры, и т.д. Всю грязную работу SDL2 делает "под капотом". Альтернатива это делать всё руками, обращаясь напрямую к методам OpenGL.
У меня уже есть наработки на Rust по этой теме, но я решил в данной игре их не применять, так как сложность кода вырастет в разы. Я оставлю их для другого проекта, где это будет оправданно. Пока же можно ограничиться только теми возможностями, которые предоставляет SDL2.
Рисование изображения
Так как в игре все объекты будут картинками, то единственная операция, которая нужна, это копирование текстуры на холст (canvas). В данном случае холст это экран, который мы видим.
Если мы хотим увидеть на экране изображение вертолёта, нам нужно создать текстуру вертолёта и загрузить её в видеопамять. Операция загрузки в видеопамять медленная, но её надо сделать только один раз. После этого текстура остаётся в видеопамяти, и для рисования мы можем дать команду – скопировать эту текстуру на холст.
Таким образом, если на экране нужно разместить десять вертолётов, мы загружаем один раз одну текстуру вертолёта (медленно) и копируем её десять раз (очень быстро).
Вот, собственно, и вся наука, но есть некоторые нюансы.
Рисование в OpenGL происходит по принципу конвейера (pipeline). Конвейер работает с заранее заданным состоянием. В такое состояние входит и текущая текстура.
Например, чтобы нарисовать 10 вертолётов, нужно для конвейера назначить текущую текстуру вертолёта. Чтобы нарисовать 10 парашютистов, нужно назначить текущую текстуру парашютиста. При смене текущей текстуры меняется состояние конвейера, и он должен остановиться и запуститься заново.
Мы не делаем это руками, а просто вызываем метод копирования текстуры, но SDL2 будет выполнять всю эту работу по перенастройке состояния конвейера.
Это тоже затратное действие, особенно если всё будет происходить так: нарисовать вертолёт, затем парашютиста, затем опять вертолёт, и т.д.
Поэтому для рисования в OpenGL принято группировать спрайты по текстурам и рисовать их пакетами: сначала нарисовали все вертолёты с текстурой "вертолёт", потом всех парашютистов с текстурой "парашютист", и т.д. Тогда конвейер будет работать эффективно.
Но мы ранее уже выяснили, что это не всегда возможно, так как экранные объекты должны перекрывать друг друга в определённом порядке, и этот порядок нельзя менять.
В таком случае применяют текстурный атлас. Это одна огромная текстура, в которую собраны все имеющиеся текстуры. И тогда проблем с переключением текстуры нет – она просто одна.
Как же тогда вывести на экран нужный спрайт? Просто копируется не вся текстура, а её кусочек.
Разработчики игр часто испытывают проблемы с тем, чтобы уместить всё в максимальный размер атласа (что-то порядка 4096*4096), и если это не удаётся, то делается больше атласов. Всё-таки переключения между атласами проще логически организовать (например, фон и передний план), чем переключения между каждой текстурой по отдельности.
Для игры RDS все эти проблемы, конечно, выглядят смешно, но будем работать с OpenGL так, как это принято.
Подготовка графики
Я сделаю атлас из всех необходимых текстур. Ну, пока не всех, но для теста хватит.
Тут я не старался, потому что в финале графика будет другой (и будет она даже не про вертолёты и парашютистов). Сейчас нужно просто протестировать работу. Голубые прямоугольники мне нужны, чтобы руками выписать координаты и размеры текстур, на самом деле в атласе их не будет. Также отмечу, что для вертолёта есть две текстуры: влево и вправо. Я видел решения, где текстура одна и просто отражается зеркально, но если присутствуют блики и тени от освещения, то при развороте они будет выглядеть неестественно. Поэтому я использую две отдельные текстуры, и можно заметить, что блик на фюзеляже сохраняет своё положение.
Добавлю загрузку изображения и создание текстуры:
PNG-файл загружается в изображение img. Затем получаем канвас canvas, от канваса получаем построитель текстур texture_creator, от построителя текстур получаем текстуру texture нужного формата и размера. Нужно отметить флаг создания TextureAccess::Static. Это значит, что мы хотим загрузить текстуру и больше её не менять. Так графический ускоритель сможет (теоретически) оптимизировать работу с ней.
Затем с помощью texture.update() мы копируем в эту текстуру данные изображения (та самая медленная часть).
Чтобы текстура поддерживала прозрачность, нужно у неё вызвать метод set_blend_mode() с аргументом BlendMode::Blend. Аналогичный метод вызывается у канваса.
Небольшой казус произошёл, когда я выпытывал у Дипсика, как сделать прозрачную текстуру. Он горячо уверял меня в том, что нужно вызвать метод canvas.set_blend_mode(), и всё. Когда же это не сработало, он продолжил настаивать, дал мне кучу бесполезных советов и даже сказал, что в моей версии SDL2 есть баг. Так что я потратил кучу времени, общаясь с дебилом.
Вывод текстуры на экран
Я сделал тестовый вывод текстуры, а затем рисование игровой сцены:
Для рисования текстуры используется метод canvas.copy(), куда передаём ссылку на текстуру, исходный прямоугольник копирования (None означает, что копируется вся текстура), и целевой прямоугольник на экране. Если указать None, то это будет весь экран, и текстура растянется по его размеру. Здесь указан прямоугольник Rect с правильными размерами, который завёрнут в вариант Some.
Вот что вышло:
Теперь мне надо часть графики перевести с рисования прямоугольников на рисование текстур.
Для начала нужно упорядочить использование текстур, хоть она всего одна. Я сделаю в структуре своего рендерера список загруженных текстур:
И созданную структуру добавлю в этот список:
Всё, теперь она хранится централизованно и доступ к ней будет через рендерер.
Далее допишу структуру DrawableBitmap:
Она будет содержать номер текстуры и прямоугольник внутри текстуры, отвечающий за нужный спрайт. Здесь мне пришлось использовать самодельную структуру PlainRect вместо стандартного sdl2::rect::Rect, потому что в Rust этот Rect сильно усложнили, сделав инициализацию и доступ к полям только через методы. Конечно, всё только ради безопасности. Но из-за этого Rect нельзя инициализировать статически, поэтому я сделал простую структуру PlainRect вместо него.
Далее реализую метод трейта Drawable для DrawableBitmap:
Конечно, он сам ничего не рисует, а передаёт нужные параметры в рендерер. И наконец, метод самого рендерера:
Он вызывает уже знакомый метод canvas.copy(), добывая ссылку на текстуру по номеру, и указывая исходный прямоугольник rect в текстуре. Целевой прямоугольник для экрана формируется из координат экранного объекта x, y и ширины и высоты исходного rect. Здесь приходится из PlainRect формировать Rect, хотя конечно они структурно совершенно одинаковы. И это бесит, но я уже плюнул.
Теперь переделаю статические данные отображения для летящего влево вертолёта. Вот как они выглядели:
А вот как теперь выглядят:
Да, вот именно здесь и понадобилась статическая инициализация прямоугольника.
И вот мы видим первый затекстуренный вертолёт, в то время как всё остальное ещё рисуется по-старому:
Теперь я молча переделаю остальную графику аналогичным способом. Я добавил отдельный спрайт парашютиста, которому подбили парашют и он теперь в ужасе падает:
Отдельно меня порадовало то, что кроме замены статических Drawable-объектов ничего больше менять не пришлось, разве что подправить размеры и координаты для новых картинок.
Прямоугольниками всё ещё рисуются пушка, снаряды и кровавые брызги. Также нужно добавить самолёты и бомбы в графику и в геймплей, но это уже дело техники. Далее я займусь доработкой самой игры – шрифты, меню, управление.
Заключение
Я приделал рисование с помощью текстур, но также осталось рисование прямоугольниками. Так как всё это выводится на один и тот же экран, можно сделать вывод, что прямоугольники также рисуются через OpenGL. И так как каждый из них рисуется отдельно, я даже не знаю, что там происходит. Может быть, SDL2 перезапускает конвейер для рисования каждого отдельного прямоугольника. Может быть, он как-то кеширует вызовы fill_rect() и потом рисует всё за один раз, когда вызывается canvas.present(). Не знаю.
С точки зрения неопытного программиста, который просто пользуется библиотекой SDL2, я сделал всё что мог. Я вызываю функцию рисования, и что-то рисуется. Если мне понадобится что-то более оптимизированное, надо будет лезть в низкоуровневый OpenGL, чтобы управлять всеми процессами самостоятельно.
Исходный код находится на гитхабе в ветке part6:
Читайте дальше: