From 8067e46049f222d37ac394745805bad98979980f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nurzhan=20Sak=C3=A9n?= Date: Tue, 26 Dec 2023 21:15:50 +0400 Subject: [PATCH] Add example for pixel-perfect grid snapping in 2D (#8112) # Objective Provide an example of how to achieve pixel-perfect "grid snapping" in 2D via rendering to a texture. This is a common use case in retro pixel art game development. ## Solution Render sprites to a canvas via a Camera, then use another (scaled up) Camera to render the resulting canvas to the screen. This example is based on the `3d/render_to_texture.rs` example. Furthermore, this example demonstrates mixing retro-style graphics with high-resolution graphics, as well as pixel-snapped rendering of a `MaterialMesh2dBundle`. --- Cargo.toml | 11 +- assets/pixel/bevy_pixel_dark.png | Bin 1989 -> 560 bytes assets/pixel/bevy_pixel_light.png | Bin 182 -> 3318 bytes examples/2d/pixel_grid_snap.rs | 174 ++++++++++++++++++++++++++++++ examples/2d/pixel_perfect.rs | 49 --------- examples/README.md | 2 +- 6 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 examples/2d/pixel_grid_snap.rs delete mode 100644 examples/2d/pixel_perfect.rs diff --git a/Cargo.toml b/Cargo.toml index 0121c1646c..49ec8ddcf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -489,13 +489,12 @@ category = "2D Rendering" wasm = true [[example]] -name = "pixel_perfect" -path = "examples/2d/pixel_perfect.rs" -doc-scrape-examples = true +name = "pixel_grid_snap" +path = "examples/2d/pixel_grid_snap.rs" -[package.metadata.example.pixel_perfect] -name = "Pixel Perfect" -description = "Demonstrates pixel perfect in 2d" +[package.metadata.example.pixel_grid_snap] +name = "Pixel Grid Snapping" +description = "Shows how to create graphics that snap to the pixel grid by rendering to a texture in 2D" category = "2D Rendering" wasm = true diff --git a/assets/pixel/bevy_pixel_dark.png b/assets/pixel/bevy_pixel_dark.png index 563531d0a5c95c90b8577707ee89b536155dd82a..93fe567b34bed627273785e4a51e77a7609da20f 100644 GIT binary patch literal 560 zcmV-00?+-4P)EX>4Tx04R}tkv&MmKpe$i(`rR34t5Z6$WWauh!t_vDionYs1;guFuC*#ni!H4 z7e~Rh;NZt%)xpJCR|i)?5c~jfb8}L3krMxx6k5c1aNLh~_a1le0HIN3niU!cG~G5c zsic_8uZZDSbR&c)5)fr(8MBgEj=A{Svtpa#g^{ zF^>&skX=9cAN=mtDkdhpq(~CzdU2eO5g@z^H0zG@ee5{R6Cn5uT)|5Tqat9cEGGtSBr65hASOnhB=$rDuz%9_b>h;#z$LRx*rLNL9z`-Ff zTB7VVpLh3k_V(|YR)0T+(Q>bfaI$Ow000kAOjJbx0000004OLZ!^6XMb#>j!)NB9% z00neXPE!E?|Ns9S&?Zs<002BmL_t&tnO%!P4geqs!xpyxzgb1Z1#ayMP)8De8{3dc yq@-_BKp3VHwulJBBM{D2*jE-N)cfVpzik16`~gpZ7#hR?0000 z|LPq%v!WsX|9@{;RfQlS%Xq8RVY6CMhvakf9)KWIJ3jx! zoE4fnuCC?SsN6TRC$r6U3&uw$uuZwqsgu)R-G1cK$;}@Z9u;a=e^;ECuTVvGt9S1i zv!@nSYR+FMn07HMzxP5|%!=^g!yepscX8LF!q$@CUpxD78Mx8-SaELHu<+D`qNc51 zM31B{$vYF8&%Zve{hTFacURrKF})M<`YmN?DWl>HJ9BcB0!6f}n|E+ZEIGe>#XrA@ z+Y`o)u6FLvy=rXZH!Yu@boGy8v+Arz7VNKG9buRsJ4Z>`m&jcBG`=xWO1r<3P5jI_ z=aEzE$+@_*bN0FmznMm5kF*{|JEo79R)F0Zi|T)`-nFXb=%e;y;KQ=l@?yDd%;uiz zGx1OFp0#KE`;qNr?hjS>53UtT&o4W&=f?Z-RYh0A-rIQPgr?`*n&O*z-FJ4JEf4=` zgSe>hY(wF`ziJ+OVHw1QGTYl#H%mgG3OC1sr1h0UB16_KpakiiRN235Qs z!A~m|EI>@dwd&SlXaFLYS+IP&1GV}jfKnPtL*N+-znH`z35q65E;l_lJ+q$#S{AHS zmPJ~tt*EHbR3vG9Qi)b)Hk-8ssU=AqB5;4DATtUs_!Ig_`f1XEpObh|=6wRH(lV^C zT()2^kG=%GqTN2gF8KR(fQo1pM%3ywgx2d-vksvBa>im9+@H%!(SB#82()v7-&Zbi zAY(BQu+=P^-7$bxt1jWaVju!4Jb;pU_y5|w zkdOK$kbZD;7&gH70y0>q3WQ?N={~Mp&BvB*!PHG@myhFJbYPjp=-rH-ap49tOW+g$ z9L}1|Cfsdg-FiZ2rhuLe^4J8w%m^G%c_6cfha@i6Wi}dS3YqHq&qVsQ?* zb#4O(Sb{MIq=x7>9Uew@L#PGI=7n;l-|OVPAWvpg;dBO) zG*N_(q|ACfMF!*E0g@kfmP(}~G$e(gs_ryQ9SX>(O$S2*1<_UsFtSf_`g|S>rsjaE zF~MGi-P)&q7Vn3s%D_E91HZs+NMlL{x%zwC!=r(Yq#3SnD?hUsxUhaFSIP(_0A5+x zEqyTlB`3lHA}NV7Q@EQY^f*NsSe!ADW}MI)+&a#{aAsA>zIeaSEmtrSNG*ZY!6IOD z1d2eX22`6C#8s36RT6^K;e-JvD5uUq>-98A!8J`l?X-hxN!F^@a&Udg+LuYuhZ}UA z`$k0|8qNx6!MNb~QLTXy1_UUA1P7FJ?2nmnxSg=2r#efmcg{2qUrr1|a!a50ZaNSf zH=&?Djh}Q&2#x8n$KG+zirMtR&}g)HAyS1EZN0ZMEIisVqV*89CaU9W+q-K6rr6ea z?flrt%F$>_Yi3R2b-cYxb38H{{f4ON*~?viGCKxMNtuM)Xo>3yZQXzTTO{O_2xQ}$ zXCaRpxQ@2!kXOR4BF`f`brtl(AvL;Zn?%F)oOadyG_JKPe`(wLtQsVw2U-7CgQc+H omP%HLJXpDCsab`LKXq~9!6})C$Il48r_O9!Mo#+AX~nhw0hH*u!~g&Q diff --git a/assets/pixel/bevy_pixel_light.png b/assets/pixel/bevy_pixel_light.png index f6225fe25eabb74acbc9256f2a4fec01eb28ba21..03edd462457cfdb97673afb85e0d0875c95d393e 100644 GIT binary patch literal 3318 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 AH~;_u delta 165 zcmew+xs6e=Gr-TCmrII^fq{Y7)59eQNH+j+0uwWkTp=>)*5qm)=_oy7fyh64QL@G7-XKc5hBCAsVg|R4U6TiUD;6`!dOFI8Vg73)y P?O^b9^>bP0l+XkK6!19j diff --git a/examples/2d/pixel_grid_snap.rs b/examples/2d/pixel_grid_snap.rs new file mode 100644 index 0000000000..15e5732993 --- /dev/null +++ b/examples/2d/pixel_grid_snap.rs @@ -0,0 +1,174 @@ +//! Shows how to create graphics that snap to the pixel grid by rendering to a texture in 2D + +use bevy::{ + prelude::*, + render::{ + camera::RenderTarget, + render_resource::{ + Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, + }, + view::RenderLayers, + }, + sprite::MaterialMesh2dBundle, + window::WindowResized, +}; + +/// In-game resolution width. +const RES_WIDTH: u32 = 160; + +/// In-game resolution height. +const RES_HEIGHT: u32 = 90; + +/// Default render layers for pixel-perfect rendering. +/// You can skip adding this component, as this is the default. +const PIXEL_PERFECT_LAYERS: RenderLayers = RenderLayers::layer(0); + +/// Render layers for high-resolution rendering. +const HIGH_RES_LAYERS: RenderLayers = RenderLayers::layer(1); + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest())) + .insert_resource(Msaa::Off) + .add_systems(Startup, (setup_camera, setup_sprite, setup_mesh)) + .add_systems(Update, (rotate, fit_canvas)) + .run(); +} + +/// Low-resolution texture that contains the pixel-perfect world. +/// Canvas itself is rendered to the high-resolution world. +#[derive(Component)] +struct Canvas; + +/// Camera that renders the pixel-perfect world to the [`Canvas`]. +#[derive(Component)] +struct InGameCamera; + +/// Camera that renders the [`Canvas`] (and other graphics on [`HIGH_RES_LAYERS`]) to the screen. +#[derive(Component)] +struct OuterCamera; + +#[derive(Component)] +struct Rotate; + +fn setup_sprite(mut commands: Commands, asset_server: Res) { + // the sample sprite that will be rendered to the pixel-perfect canvas + commands.spawn(( + SpriteBundle { + texture: asset_server.load("pixel/bevy_pixel_dark.png"), + transform: Transform::from_xyz(-40., 20., 2.), + ..default() + }, + Rotate, + PIXEL_PERFECT_LAYERS, + )); + + // the sample sprite that will be rendered to the high-res "outer world" + commands.spawn(( + SpriteBundle { + texture: asset_server.load("pixel/bevy_pixel_light.png"), + transform: Transform::from_xyz(-40., -20., 2.), + ..default() + }, + Rotate, + HIGH_RES_LAYERS, + )); +} + +/// Spawns a capsule mesh on the pixel-perfect layer. +fn setup_mesh( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn(( + MaterialMesh2dBundle { + mesh: meshes.add(Mesh::from(shape::Capsule::default())).into(), + transform: Transform::from_xyz(40., 0., 2.).with_scale(Vec3::splat(32.)), + material: materials.add(ColorMaterial::from(Color::BLACK)), + ..default() + }, + Rotate, + PIXEL_PERFECT_LAYERS, + )); +} + +fn setup_camera(mut commands: Commands, mut images: ResMut>) { + let canvas_size = Extent3d { + width: RES_WIDTH, + height: RES_HEIGHT, + ..default() + }; + + // this Image serves as a canvas representing the low-resolution game screen + 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() + }; + + // fill image.data with zeroes + canvas.resize(canvas_size); + + let image_handle = images.add(canvas); + + // this camera renders whatever is on `PIXEL_PERFECT_LAYERS` to the canvas + commands.spawn(( + Camera2dBundle { + camera: Camera { + // render before the "main pass" camera + order: -1, + target: RenderTarget::Image(image_handle.clone()), + ..default() + }, + ..default() + }, + InGameCamera, + PIXEL_PERFECT_LAYERS, + )); + + // spawn the canvas + commands.spawn(( + SpriteBundle { + texture: image_handle, + ..default() + }, + Canvas, + HIGH_RES_LAYERS, + )); + + // the "outer" camera renders whatever is on `HIGH_RES_LAYERS` to the screen. + // here, the canvas and one of the sample sprites will be rendered by this camera + commands.spawn((Camera2dBundle::default(), OuterCamera, HIGH_RES_LAYERS)); +} + +/// Rotates entities to demonstrate grid snapping. +fn rotate(time: Res