批量渲染
你可能已经注意到在玩游戏时输入感觉有点慢。让我们添加一个 FPS 计数器来看看我们的渲染速度如何。如果你不熟悉 FPS 这个术语,它代表每秒帧数(Frames Per Second),我们的目标是达到 60FPS。
FPS 计数器
让我们从添加 FPS 计数器开始,这包含两个部分:
- 获取或计算 FPS 值
- 在屏幕上渲染这个值
对于第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])); /// 代码省略 /// ..... /// ..... // Render FPS let fps = format!("FPS: {:.0}", context.time.fps()); draw_text(&mut canvas, &fps, 525.0, 120.0); /// 代码省略 /// ..... /// ..... // Finally, present the canvas, this will actually display everything // on the screen. canvas.finish(context).expect("expected to present"); } }
运行游戏并用按键移动一下,你会看到 FPS 从预期的 60 显著下降。对我来说,它看起来在 20-30 范围内,但根据你的机器可能会更多或更少。
是什么导致了 FPS 下降?
现在你可能会问自己,我们做了什么导致 FPS 这么低?我们有一个相当简单的游戏,我们的输入和移动逻辑实际上并不复杂,我们也没有太多的实体或组件来导致如此大的 FPS 下降。要理解这一点,我们需要更深入地了解我们当前的渲染系统是如何工作的。
目前,对于每个可渲染的实体,我们都要确定要渲染哪个图像并渲染它。这意味着如果我们有 20 个地板贴图,我们将加载地板图像 20 次并发出 20 个单独的渲染调用。这太昂贵了,这就是导致我们 FPS 大幅下降的原因。
我们如何解决这个问题?我们可以使用一种叫做批量渲染的技术。使用这种技术,我们要做的就是只加载一次图像,并告诉 ggez 在所有需要渲染的 20 个位置渲染它。这样我们不仅只加载一次图像,而且每个图像只调用一次渲染,这将大大提高速度。作为旁注,一些引擎会在底层为你完成这种渲染批处理,但 ggez 不会,这就是为什么我们需要关注这一点。
批量渲染实现
以下是我们实现批量渲染需要做的事情:
- 对于每个可渲染实体,确定我们需要渲染的图像和 DrawParams(这是我们目前给 ggez 的渲染位置指示)
- 将所有(图像,DrawParams)保存为一个方便的格式
- 按照 z 轴排序遍历(图像,DrawParams),每个图像只进行一次渲染调用
在深入渲染代码之前,我们需要做一些集合分组和排序,我们将使用 itertools crate 来完成这个任务。我们可以自己实现这个分组,但没有必要重新发明轮子。让我们将 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 位置并从最高到最小的 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,一切都应该感觉更流畅!
CODELINK: 你可以在这里看到这个示例的完整代码。