批量渲染

你可能已经注意到在玩游戏时输入感觉有点慢。让我们添加一个 FPS 计数器来看看我们的渲染速度如何。如果你不熟悉 FPS 这个术语,它代表每秒帧数(Frames Per Second),我们的目标是达到 60FPS。

FPS 计数器

让我们从添加 FPS 计数器开始,这包含两个部分:

  1. 获取或计算 FPS 值
  2. 在屏幕上渲染这个值

现在,

  1. 幸运的是,ggez提供了获取FPS的方法 - 参见这里
  2. 我们已经在渲染系统中实现了文本渲染功能,只需将FPS显示出来即可。

让我们把这些整合到代码中。


#![allow(unused)]
fn main() {
// rendering.rs
pub fn run_rendering(world: &World, context: &mut Context) {
    // Clearing the screen (this gives us the background colour)
    let mut canvas =
        graphics::Canvas::from_frame(context, graphics::Color::from([0.95, 0.95, 0.95, 1.0]));

    /// Code omitted
    /// .....
    /// .....
    
    // Render FPS
    let fps = format!("FPS: {:.0}", context.time.fps());
    draw_text(&mut canvas, &fps, 525.0, 120.0);

    /// Code omitted
    /// .....
    /// .....

    // Finally, present the canvas, this will actually display everything
    // on the screen.
    canvas.finish(context).expect("expected to present");
}
}

运行游戏并用按键移动一下,你会发现FPS从预期的60明显下降。在我的机器上,FPS大约在20-30之间,但根据你的设备,可能会有所不同。

低fps

是什么导致了 FPS 下降?

你可能会问,是什么导致FPS这么低?我们的游戏逻辑并不复杂,输入和移动处理也不难,实体和组件数量也不多,不至于造成如此大的FPS下降。要理解这个问题,我们需要深入分析当前渲染系统的工作原理。

目前,对于每个可渲染的实体,我们都会确定要渲染的图像并渲染它。这意味着如果有20个地板图块,我们会加载地板图像20次,并发出20次单独的渲染调用。这种做法开销太大,正是导致FPS大幅下降的原因。

如何解决这个问题?我们可以使用一种称为批量渲染的技术。通过这种技术,我们只需加载图像一次,并告诉ggez在所有需要渲染的20个位置渲染它。这样不仅图像只加载一次,而且每种图像只需调用一次渲染,这将显著提升性能。顺便提一下,有些引擎会自动处理这种批量渲染,但ggez不会,所以我们需要手动优化。

批量渲染实现

以下是我们实现批量渲染需要做的事情:

  • 对于每个可渲染实体,确定我们需要渲染的图像和 DrawParams(这是我们目前给 ggez 的渲染位置指示)
  • 将所有(图像,DrawParams)保存为一个方便的格式
  • 按照 z 轴排序遍历(图像,DrawParams),每个图像只进行一次渲染调用

在深入渲染代码之前,我们需要进行一些集合的分组和排序操作,为此我们将使用itertools库。虽然我们可以自己实现分组功能,但没有必要重复造轮子。让我们将itertools添加为项目的依赖。

// Cargo.toml
[dependencies]
ggez = "0.9.3"
glam = { version = "0.24", features = ["mint"] }
hecs = "0.10.5"
itertools = "0.13.0"

我们也在渲染系统中导入它


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

还记得我们在动画章节中编写的get_image函数吗?它用于确定每一帧所需的图像。我们可以复用这个函数,只需确保它不实际加载图像,而是返回图像的路径。


#![allow(unused)]
fn main() {
// rendering.rs
pub fn get_image(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.rs
    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 = 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;

        // draw
        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.rs
    // 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::from_path(context, image_path).unwrap();
            let mut mesh_batch = graphics::InstanceArray::new(context, Some(image));

            for draw_param in draw_params.iter() {
                mesh_batch.push(*draw_param);
            }

            canvas.draw(&mesh_batch, graphics::DrawParam::new());
        }
    }
}

搞定!再次运行游戏,你应该会看到稳定的60FPS,操作也会更加流畅!

高fps

代码链接: 你可以在这里看到这个示例的完整代码。