Bevy 像素完美渲染:pixel_grid_snap 双相机管线

仅仅设置 ImagePlugin::default_nearest() 并不能解决所有像素美学问题。当游戏窗口缩放为非整数倍比例时,像素仍然会出现子像素失真(边缘在不同屏幕像素之间分布不均)。官方示例 pixel_grid_snap 提供了完整的解决方案。

核心思路

  1. 用一个低分辨率相机(如 160×90)将游戏世界渲染到一张 off-screen 纹理(canvas)
  2. 用第二个相机把这张纹理作为 Sprite 渲染到屏幕
  3. 窗口缩放时只允许整数倍比例,避免子像素模糊

这种方案的好处是你可以把游戏世界渲染在一个很低的分辨率,然后整数倍放大到屏幕,看起来就是完美的像素风格。

关键组件与系统

Canvas 纹理创建

use bevy::render::render_resource::{
    Extent3d, TextureDescriptor, TextureDimension, TextureFormat,
    TextureUsages,
};
 
const RES_WIDTH: u32 = 160;
const RES_HEIGHT: u32 = 90;
 
fn create_canvas_image(images: &mut Assets<Image>) -> Handle<Image> {
    let canvas_size = Extent3d {
        width: RES_WIDTH,
        height: RES_HEIGHT,
        ..default()
    };
 
    let mut canvas = Image {
        texture_descriptor: TextureDescriptor {
            label: None,
            size: canvas_size,
            dimension: TextureDimension::D2,
            format: TextureFormat::Bgra8UnormSrgb,
            mip_level_count: 1,
            sample_count: 1,
            usage: TextureUsages::TEXTURE_BINDING
                | TextureUsages::COPY_DST
                | TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        },
        ..default()
    };
    canvas.resize(canvas_size);
    images.add(canvas)
}

在 Bevy 0.18+ 中,可用 Image::new_target_texture() 简化上述繁琐的 TextureDescriptor 手动配置。

双相机与 RenderLayers

官方示例的核心机制是 RenderLayers 隔离:

实体RenderLayer渲染目标作用
游戏相机layer(0)RenderTarget::Image(canvas)把游戏世界渲染到 canvas
游戏实体默认layer 0被游戏相机捕获
Canvas Spritelayer(1)屏幕把 canvas 纹理当作背景渲染
外层相机layer(1)屏幕把 canvas sprite 渲染到屏幕

如果没有 RenderLayers 隔离:

  • 游戏相机会把 canvas sprite 也渲染进 canvas,产生递归/视觉错误
  • 外层相机会直接渲染游戏世界实体,低分辨率内容导致模糊

窗口缩放算法

fn fit_canvas(
    mut resize_events: EventReader<WindowResized>,
    mut projection: Single<&mut Projection, With<OuterCamera>>,
) {
    let Projection::Orthographic(projection) = &mut **projection else {
        return;
    };
    for window_resized in resize_events.read() {
        let h_scale = window_resized.width / RES_WIDTH as f32;
        let v_scale = window_resized.height / RES_HEIGHT as f32;
        // round() 确保只用整数倍比例
        projection.scale = 1.0 / h_scale.min(v_scale).round();
    }
}

关键是 round() 而不是 floor() — 官方示例使用 round() 来确保缩放比例是整数倍,避免子像素失真。

完整 main() 注册

fn main() {
    App::new()
        .insert_resource(Msaa::Off) // 全局关闭抗锯齿
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_systems(Startup, setup_camera)
        .add_systems(Update, fit_canvas)
        .run();
}

版本注意事项

  • Bevy 0.15+: 使用 Camera2dEventReader<WindowResized>Single<&mut Projection>
  • Bevy 0.17+: 窗口事件移至缓冲消息系统,需改用 MessageReader<WindowResized>
  • Bevy 0.18+: 可用 Image::new_target_texture() 简化 canvas 创建

业界对照

游戏内部分辨率渲染方式
Celeste320×180off-screen 渲染 + 整数倍放大
Stardew Valley16×16 瓦片固定虚拟分辨率 + Nearest
Shovel Knight400×240严格整数倍缩放

相关页面