Пакетная отрисовка

Во время игры вы могли поймать ощущение, что ввод тормозит. Предлагаю добавить FPS-счётчик, чтобы следить за скоростью отрисовки. FPS (Frames per second) — это количество кадров в секунду. Мы будем ориентироваться на 60 FPS.

Счётчик FPS

Начнём с добавления счётчика FPS. Эта задача состоит из двух частей:

  1. получение или вычисление значения FPS,
  2. отображение этого значения на экране.

Для первой ggez предоставляет метод получения FPS. Для второй в нашей системе отрисовки уже есть способ работы с текстом, так что нам нужно просто вывести количество кадров в секунду. Объединим эти знания в следующем коде:


#![allow(unused)]
fn main() {
// rendering_system.rs
    fn run(&mut self, data: Self::SystemData) {
        ...

        // Render any text
        self.draw_text(&gameplay.state.to_string(), 525.0, 80.0);
        self.draw_text(&gameplay.moves_count.to_string(), 525.0, 100.0);
        let fps = format!("FPS: {:.0}", timer::fps(self.context));
        self.draw_text(&fps, 525.0, 120.0);

        ...
    }
}

Запустите игру и немного подвигайтесь туда-сюда. Вы увидите, что FPS значительно отличается от ожидаемых 60. У меня это что-то в районе 20-30, но это зависит от мощности вашей машины.

low fps

Что вызывает падение частоты кадров?

Вы можете задаться вопросом, что же такого мы натворили, чтобы игра работала так медленно? У нас довольно простая игра — и логика передвижения и обработки событий клавиш совсем не такая и сложная. Кроме того, у нас нет большого количества сущностей и компонентов, чтобы оправдать значительное падение частоты кадров. Чтобы его объяснить, нам нужно немного углубиться в принцип работы нашей системы отрисовки.

Сейчас перед тем, как отрисовать сущность, мы должны понять, какое изображение ей соответствует. Это значит, что если нам, например, нужно отрисовать 20 плиток пола, то мы загрузим 20 картинок пола и сделаем 20 вызовов отрисовки. Это очень дорогостоящий процесс — и именно он вызывает падение частоты кадров.

Как это можно исправить? Мы воспользуемся магией пакетной отрисовки. Используя эту технику, мы загрузим изображение только один раз и сообщим ggez, что нам нужно отрисовать его на двадцати необходимых позициях. Это значительно ускорит процесс. В качестве примечания: некоторые движки делают это самостоятельно, без вашего вмешательства — но не 'ggez'. Здесь нам нужно позаботиться обо всём вручную.

Пакетная отрисовка

Вот что нам нужно сделать, чтобы реализовать пакетную отрисовку:

  • Для каждой отрисовываемой сущности мы должны понять, какое изображение нам нужно отрисовать и какой параметр DrawParam нужно использовать. Для ggez DrawParam будет служить индикатором места отрисовки.
  • После чего мы должны сохранить пару (image, DrawParams) в удобный формат.
  • Для каждого изображения пройдём по (image, DrawParams), отсортированным по z, и сделаем по одному вызову отрисовки

Прежде чем мы погрузимся в код отрисовки, мы должны сгруппировать и отсортировать нашу коллекцию. Для этого мы будем использовать крейт Itertools. Мы могли бы реализовать группировку самостоятельно, но нет никакой причины заново изобретать велосипед. Добавим Itertools как зависимость в наш проект.

// Cargo.toml
[dependencies]
ggez = "0.7"
glam = { version = "0.20.0", features = ["mint"] }
specs = { version = "0.17.0", features = ["specs-derive"] }

А также импортируем его в нашу систему отрисовки.


#![allow(unused)]
fn main() {
// rendering_system.rs
use itertools::Itertools;
}

Помните функцию get_image, которую мы написали в главе "Анимация", чтобы понять, какое изображение нужно для каждого кадра? Мы сможем использовать её снова — нужно только убедиться, что мы не загружаем изображение, а просто возвращаем его путь.


#![allow(unused)]
fn main() {
// rendering_system.rs
    pub fn get_image(&mut self, renderable: &Renderable, delta: Duration) -> String {
        let path_index = match renderable.kind() {
            RenderableKind::Static => {
                // We only have one image, so we just return that
                0
            }
            RenderableKind::Animated => {
                // If we have multiple, we want to select the right one based on the delta time.
                // First we get the delta in milliseconds, we % by 1000 to get the milliseconds
                // only and finally we divide by 250 to get a number between 0 and 4. If it's 4
                // we technically are on the next iteration of the loop (or on 0), but we will let
                // the renderable handle this logic of wrapping frames.
                ((delta.as_millis() % 1000) / 250) as usize
            }
        };

        renderable.path(path_index)
    }
}

Теперь определим формат, в котором мы будем хранить наши пакетные данные. Мы будем использовать HashMap<u8, HashMap<String, Vec<DrawParam>>>, где:

  • Первый ключ (u8) — это позиция z. Помните о том, что мы должны следить за ней и отрисовывать объекты начиная с самого большого z до самого маленького, чтобы соблюдать правильный порядок (например, чтобы полы были позади игрока).
  • Первое значение — это ещё одна HashMap, где второй ключ (String) — это путь к изображению.
  • Наконец, второе значение — это Vec<DrawParam>, где хранятся все параметры, с которыми мы должны отрисовать это изображение.

Теперь напишем код, чтобы заполнить нашу rendering_batches.


#![allow(unused)]
fn main() {
// rendering_system.rs
    fn run(&mut self, data: Self::SystemData) {
        ...

        // Get all the renderables with their positions.
        let rendering_data = (&positions, &renderables).join().collect::<Vec<_>>();
        let mut rendering_batches: HashMap<u8, HashMap<String, Vec<DrawParam>>> = HashMap::new();

        // Iterate each of the renderables, determine which image path should be rendered
        // at which drawparams, and then add that to the rendering_batches.
        for (position, renderable) in rendering_data.iter() {
            // Load the image
            let image_path = self.get_image(renderable, time.delta);

            let x = position.x as f32 * TILE_WIDTH;
            let y = position.y as f32 * TILE_WIDTH;
            let z = position.z;

            // Add to rendering batches
            let draw_param = DrawParam::new().dest(Vec2::new(x, y));
            rendering_batches
                .entry(z)
                .or_default()
                .entry(image_path)
                .or_default()
                .push(draw_param);
        }

        ...
    }
}

И наконец, отрисуем пакеты. Мы больше не можем использовать функцию draw(image), которой мы пользовались раньше, но, к счастью, у ggez есть пакетный API — SpriteBatch. Также отметьте себе строчку sorted_by — это функция из Itertools.


#![allow(unused)]
fn main() {
// rendering_system.rs
    fn run(&mut self, data: Self::SystemData) {
        ...

        // Iterate spritebatches ordered by z and actually render each of them
        for (_z, group) in rendering_batches
            .iter()
            .sorted_by(|a, b| Ord::cmp(&a.0, &b.0))
        {
            for (image_path, draw_params) in group {
                let image = Image::new(self.context, image_path).expect("expected image");
                let mut sprite_batch = SpriteBatch::new(image);

                for draw_param in draw_params.iter() {
                    sprite_batch.add(*draw_param);
                }

                graphics::draw(self.context, &sprite_batch, graphics::DrawParam::new())
                    .expect("expected render");
            }
        }

        ...
    }
}

Ну вот и всё! Снова запустите игру — и вы увидите новенькие и блестящие 60 FPS! Теперь всё должно работать гораздо плавнее.

low fps

КОД: Увидеть весь код из данной главы можно здесь.