├── .gitignore ├── Cargo.toml ├── assets ├── dungeon │ ├── attribution.txt │ ├── tileset.png │ └── tileset_padded.png ├── example.gif ├── gabe-idle-run.png └── icon.png ├── examples ├── dungeon.rs ├── sprite.rs └── sprite_sheet.rs ├── license.md ├── readme.md ├── spritesheet_padding.py └── src ├── lib.rs └── prelude.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | *.DS_Store 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_sprite3d" 3 | version = "5.0.0" 4 | edition = "2021" 5 | 6 | description = "Bevy Plugin to allow using 2d sprites in a 3d scene." 7 | license = "MIT" 8 | readme = "readme.md" 9 | repository = "https://github.com/FraserLee/bevy_sprite3d" 10 | keywords = ["gamedev", "bevy", "sprite", "3d"] 11 | 12 | [dependencies.bevy] 13 | version = "0.16.0" 14 | default-features = false 15 | features = ["bevy_asset", "bevy_pbr", "bevy_sprite", "png", "std"] 16 | 17 | [dev-dependencies] 18 | bevy.version = "0.16.0" # (include default features when running examples) 19 | rand = "0.8" 20 | -------------------------------------------------------------------------------- /assets/dungeon/attribution.txt: -------------------------------------------------------------------------------- 1 | Dungeon tileset by Calciumtrice, usable under Creative Commons Attribution 3.0 license. 2 | https://opengameart.org/content/dungeon-tileset-1 3 | -------------------------------------------------------------------------------- /assets/dungeon/tileset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FraserLee/bevy_sprite3d/ba62917fafe7104f2cb87613a396edee12205da8/assets/dungeon/tileset.png -------------------------------------------------------------------------------- /assets/dungeon/tileset_padded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FraserLee/bevy_sprite3d/ba62917fafe7104f2cb87613a396edee12205da8/assets/dungeon/tileset_padded.png -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FraserLee/bevy_sprite3d/ba62917fafe7104f2cb87613a396edee12205da8/assets/example.gif -------------------------------------------------------------------------------- /assets/gabe-idle-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FraserLee/bevy_sprite3d/ba62917fafe7104f2cb87613a396edee12205da8/assets/gabe-idle-run.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FraserLee/bevy_sprite3d/ba62917fafe7104f2cb87613a396edee12205da8/assets/icon.png -------------------------------------------------------------------------------- /examples/dungeon.rs: -------------------------------------------------------------------------------- 1 | use bevy::{prelude::*, window::WindowResolution}; 2 | use bevy::core_pipeline::bloom::Bloom; 3 | use bevy::core_pipeline::tonemapping::Tonemapping; 4 | use std::time::Duration; 5 | use bevy::pbr::ScreenSpaceAmbientOcclusion; 6 | use bevy::core_pipeline::experimental::taa::TemporalAntiAliasing; 7 | 8 | use bevy_sprite3d::prelude::*; 9 | 10 | use rand::{prelude::SliceRandom, Rng}; 11 | 12 | #[derive(States, Hash, Clone, PartialEq, Eq, Debug, Default)] 13 | enum GameState { #[default] Loading, Ready } 14 | 15 | // #[derive(AssetCollection, Resource)] 16 | // struct ImageAssets { 17 | // #[asset(texture_atlas(tile_size_x = 16., tile_size_y = 16., 18 | // columns = 30, rows = 35, padding_x = 10., padding_y = 10., 19 | // offset_x = 5., offset_y = 5.))] 20 | // #[asset(path = "dungeon/tileset_padded.png")] 21 | // tileset: Handle, 22 | // } 23 | #[derive(Resource, Default)] 24 | struct ImageAssets { 25 | image: Handle, 26 | layout: Handle, 27 | } 28 | 29 | 30 | fn main() { 31 | 32 | App::new() 33 | .add_plugins(DefaultPlugins 34 | .set(ImagePlugin::default_nearest()) 35 | .set(WindowPlugin { 36 | primary_window: Some( Window{ 37 | resolution: WindowResolution::new(1080.0, 1080.0 * 3./4.), 38 | ..default() 39 | }), ..default() 40 | })) 41 | .add_plugins(Sprite3dPlugin) 42 | .init_state::() 43 | 44 | // initially load assets 45 | .add_systems(Startup, |asset_server: Res, 46 | mut assets: ResMut, 47 | mut layouts: ResMut>| { 48 | 49 | assets.image = asset_server.load("dungeon/tileset_padded.png"); 50 | 51 | assets.layout = layouts.add( 52 | TextureAtlasLayout::from_grid( 53 | UVec2::new(16, 16), 54 | 30, 55 | 35, 56 | Some(UVec2::new(10, 10)), 57 | Some(UVec2::new(5, 5))) 58 | ); 59 | }) 60 | 61 | // every frame check if assets are loaded. Once they are, we can proceed with setup. 62 | .add_systems(Update, ( 63 | |asset_server : Res, 64 | assets : Res, 65 | mut next_state : ResMut>| { 66 | 67 | if asset_server.get_load_state(assets.image.id()).is_some_and(|s| s.is_loaded()) { 68 | next_state.set(GameState::Ready); 69 | } 70 | }).run_if(in_state(GameState::Loading)) ) 71 | .add_systems( OnEnter(GameState::Ready), setup ) 72 | .add_systems( OnEnter(GameState::Ready), spawn_sprites ) 73 | .add_systems( Update, animate_camera.run_if(in_state(GameState::Ready)) ) 74 | .add_systems( Update, animate_sprites.run_if(in_state(GameState::Ready)) ) 75 | .add_systems( Update, face_camera.run_if(in_state(GameState::Ready)) ) 76 | .insert_resource(ImageAssets::default()) 77 | .run(); 78 | 79 | } 80 | 81 | #[derive(Component)] 82 | struct FaceCamera; // tag entity to make it always face the camera 83 | 84 | #[derive(Component)] 85 | struct Animation { 86 | frames: Vec, // indices of all the frames in the animation 87 | current: usize, 88 | timer: Timer, 89 | } 90 | 91 | fn setup( 92 | mut commands: Commands, 93 | mut meshes: ResMut>, 94 | mut materials: ResMut>, 95 | ) { 96 | // cube 97 | commands.spawn(( 98 | Mesh3d(meshes.add(Mesh::from(Cuboid::from_size(Vec3::splat(1.0))))), 99 | MeshMaterial3d(materials.add(Color::WHITE)), 100 | Transform::from_xyz(-0.9, 0.5, -3.1), 101 | )); 102 | // sphere 103 | commands.spawn(( 104 | Mesh3d(meshes.add(Sphere::new(0.6))), 105 | MeshMaterial3d(materials.add(Color::WHITE)), 106 | Transform::from_xyz(-0.9, 0.5, -4.2), 107 | )); 108 | 109 | // camera 110 | commands 111 | .spawn(Camera3d::default()) 112 | .insert(Camera { 113 | hdr: true, 114 | clear_color: ClearColorConfig::Custom(Color::NONE), 115 | ..default() 116 | }) 117 | .insert(Msaa::Off) 118 | .insert(Bloom { 119 | intensity: 0.3, 120 | ..default() 121 | }) 122 | .insert(bevy::prelude::Projection::Perspective(PerspectiveProjection { 123 | fov: std::f32::consts::PI / 6.0, 124 | ..default() 125 | })) 126 | .insert(ScreenSpaceAmbientOcclusion::default()) 127 | .insert(TemporalAntiAliasing::default()); 128 | 129 | commands.spawn(Tonemapping::AcesFitted); 130 | } 131 | 132 | fn spawn_sprites( 133 | mut commands: Commands, 134 | images: Res, 135 | mut sprite_params: Sprite3dParams, 136 | ) { 137 | // ------------------ Tilemap for the floor ------------------ 138 | 139 | // we first set up a few closures to help generate variations of tiles 140 | 141 | // random floor tile 142 | let options_f = [(7,0), (7,0), (7,0), (9,1), (9,2), (9,3), (9,4)]; 143 | let f = || { *options_f.choose(&mut rand::thread_rng()).unwrap() }; 144 | 145 | let options_d = [(9,9), (9,10), (9,11)]; // random darker floor tile 146 | let d = || { *options_d.choose(&mut rand::thread_rng()).unwrap() }; 147 | 148 | let options_l = [(7,5), (7,6), (7,7)]; // left wall tile 149 | let l = || { *options_l.choose(&mut rand::thread_rng()).unwrap() }; 150 | let options_t = [(7,8), (7,9), (7,10)]; // top wall tile 151 | let t = || { *options_t.choose(&mut rand::thread_rng()).unwrap() }; 152 | let options_b = [(7,11), (7,12), (7,13)]; // bottom wall tile 153 | let b = || { *options_b.choose(&mut rand::thread_rng()).unwrap() }; 154 | let options_r = [(7,14), (7,15), (7,16)]; // right wall tile 155 | let r = || { *options_r.choose(&mut rand::thread_rng()).unwrap() }; 156 | 157 | let tl = || { (7,1) }; // top left corner 158 | let tr = || { (7,2) }; // top right corner 159 | let bl = || { (7,3) }; // bottom left corner 160 | let br = || { (7,4) }; // bottom right corner 161 | 162 | let options_tb = [(7,21), (7,22)]; // top and bottom wall tile 163 | let tb = || { *options_tb.choose(&mut rand::thread_rng()).unwrap() }; 164 | 165 | // in reality, you'd probably want to import a map generated by an 166 | // external tool, or maybe proc-gen it yourself. For this example, a 167 | // 2d array should suffice. 168 | 169 | let mut map = vec![ 170 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), tl(), t(), d(), d(), d(), t(), tr() ], 171 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), f(), f(), f(), f(), r() ], 172 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), d(), f(), d(), d(), d(), f(), d() ], 173 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), d(), f(), d(), d(), d(), f(), d() ], 174 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), d(), f(), d(), d(), d(), f(), d() ], 175 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), f(), f(), f(), f(), r() ], 176 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), bl(), b(), (8,21), d(), (8,22), b(), br() ], 177 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), r(), (0,0), (0,0)], 178 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), d(), r(), (0,0), (0,0)], 179 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), tl(), (8,19), f(), (8,20), tr(), (0,0)], 180 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), d(), f(), r(), (0,0)], 181 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), f(), f(), r(), (0,0)], 182 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), d(), f(), r(), (0,0)], 183 | vec![(0,0), (0,0), (0,0), (0,0), (0,0), (0,0), l(), f(), f(), f(), r(), (0,0)], 184 | vec![tl(), t(), tr(), (0,0), (0,0), (0,0), l(), f(), f(), f(), r(), (0,0)], 185 | vec![l(), f(), (8,25), tb(), tb(), tb(), (8,24),f(), f(), f(), r(), (0,0)], 186 | vec![bl(), b(), br(), (0,0), (0,0), (0,0), bl(), b(), b(), b(), br(), (0,0)], 187 | ]; 188 | 189 | // add zero padding to the map 190 | map.insert(0, vec![(0,0); map[0].len()]); 191 | map.push(vec![(0,0); map[0].len()]); 192 | for row in map.iter_mut() { 193 | row.insert(0, (0,0)); 194 | row.push((0,0)); 195 | } 196 | 197 | // might be nice to add built-in support for sprite-merging for tilemaps... 198 | // though since all the meshes and materials are already cached and reused, 199 | // I wonder how much of a speedup that'd actually be. Food for thought. 200 | 201 | for y in 0..map.len() { 202 | for x in 0..map[y].len() { 203 | let index = map[y][x].0 * 30 + map[y][x].1; 204 | let (x, y) = (x as f32 - map[y].len() as f32 / 2.0, y as f32 - map.len() as f32 / 2.0); 205 | if index == 0 { continue; } 206 | 207 | let atlas = TextureAtlas { 208 | layout: images.layout.clone(), 209 | index: index as usize, 210 | }; 211 | 212 | commands.spawn(( 213 | Sprite3dBuilder { 214 | image: images.image.clone(), 215 | pixels_per_metre: 16., 216 | double_sided: false, 217 | ..default() 218 | }.bundle_with_atlas(&mut sprite_params, atlas), 219 | Transform::from_xyz(x, 0.0, y) 220 | .with_rotation(Quat::from_rotation_x(-std::f32::consts::PI / 2.0)) 221 | )); 222 | } 223 | } 224 | 225 | // --------------------------- add some walls ------------------------- 226 | 227 | // first horizontally, then vertically, scan along the map. If we find 228 | // a point transitioning from (0,0) to something else, add a wall there. 229 | 230 | let mut rng = rand::thread_rng(); 231 | 232 | // quick closure to get a random wall tile, avoiding staircases right next 233 | // to each other (since that can look weird) 234 | let mut time_since_staircase = 0; 235 | let mut wall_index = || { 236 | if time_since_staircase > 3 && rng.gen_bool(0.075) { 237 | time_since_staircase = 0; 238 | if rng.gen_bool(0.5) { 7 } else { 8 } 239 | } else { 240 | time_since_staircase += 1; 241 | if rng.gen_bool(0.6) { 1 } else { rng.gen_range(2..=4) } 242 | } 243 | }; 244 | 245 | for y in 1..(map.len() - 1) { 246 | for x in 0..(map[y].len() - 1) { 247 | if (map[y][x] != (0,0)) ^ (map[y][x+1] == (0,0)) { continue; } 248 | let dir = if map[y][x] == (0,0) { 1.0 } else { -1.0 }; 249 | 250 | let mut tile_x = wall_index(); 251 | 252 | if map[y][x] == (0,0) { // literal corner cases. hah. 253 | if map[y+1][x+1] == (0,0) { tile_x = 0; } 254 | if map[y-1][x+1] == (0,0) { tile_x = 5; } 255 | } else { 256 | if map[y-1][x] == (0,0) { tile_x = 0; } 257 | if map[y+1][x] == (0,0) { tile_x = 5; } 258 | } 259 | 260 | let (x, y) = (x as f32 - map[y].len() as f32 / 2.0, y as f32 - map.len() as f32 / 2.0); 261 | 262 | for i in [0,1] { // add bottom and top piece 263 | let atlas = TextureAtlas { 264 | layout: images.layout.clone(), 265 | index: (tile_x + (5 - i) * 30) as usize, 266 | }; 267 | 268 | commands.spawn(( 269 | Sprite3dBuilder { 270 | image: images.image.clone(), 271 | pixels_per_metre: 16., 272 | double_sided: false, 273 | ..default() 274 | }.bundle_with_atlas(&mut sprite_params, atlas), 275 | Transform::from_xyz(x+0.5, i as f32 + 0.499, y) 276 | .with_rotation(Quat::from_rotation_y(dir * std::f32::consts::PI / 2.0)) 277 | )); 278 | } 279 | } 280 | } 281 | 282 | // same thing again, but for the vertical walls 283 | for x in 1..(map[0].len() - 1) { 284 | for y in 0..(map.len() - 1) { 285 | if (map[y][x] != (0,0)) ^ (map[y+1][x] == (0,0)) { continue; } 286 | let dir = if map[y][x] == (0,0) { 1.0 } else { -1.0 }; 287 | 288 | let mut tile_x = wall_index(); 289 | 290 | if map[y][x] == (0,0) { 291 | if map[y+1][x-1] == (0,0) { tile_x = 0; } 292 | if map[y+1][x+1] == (0,0) { tile_x = 5; } 293 | } else { 294 | if map[y][x+1] == (0,0) { tile_x = 0; } 295 | if map[y][x-1] == (0,0) { tile_x = 5; } 296 | } 297 | 298 | let (x, y) = (x as f32 - map[y].len() as f32 / 2.0, y as f32 - map.len() as f32 / 2.0); 299 | 300 | for i in [0,1]{ // add bottom and top piece 301 | let atlas = TextureAtlas { 302 | layout: images.layout.clone(), 303 | index: (tile_x + (5 - i) * 30) as usize, 304 | }; 305 | 306 | commands.spawn(( 307 | Sprite3dBuilder { 308 | image: images.image.clone(), 309 | pixels_per_metre: 16., 310 | double_sided: false, 311 | ..default() 312 | }.bundle_with_atlas(&mut sprite_params, atlas), 313 | Transform::from_xyz(x, i as f32 + 0.499, y + 0.5) 314 | .with_rotation(Quat::from_rotation_y((dir - 1.0) * std::f32::consts::PI / 2.0)), 315 | )); 316 | } 317 | } 318 | } 319 | 320 | // --------------------- characters, enemies, props --------------------- 321 | 322 | let mut entity = |(x, y), tile_x, tile_y, height, frames| { 323 | let mut timer = Timer::from_seconds(0.4, TimerMode::Repeating); 324 | timer.set_elapsed(Duration::from_secs_f32(rng.gen_range(0.0..0.4))); 325 | 326 | for i in 0usize..height { 327 | let atlas = TextureAtlas { 328 | layout: images.layout.clone(), 329 | index: (tile_x + (tile_y - i) * 30), 330 | }; 331 | 332 | let mut c = commands.spawn(( 333 | Sprite3dBuilder { 334 | image: images.image.clone(), 335 | pixels_per_metre: 16., 336 | ..default() 337 | }.bundle_with_atlas(&mut sprite_params, atlas), 338 | FaceCamera {}, 339 | Transform::from_xyz(x as f32, i as f32 + 0.498, y), 340 | )); 341 | 342 | if frames > 1 { 343 | c.insert(Animation { 344 | frames: (0..frames).map(|j| j + tile_x + (tile_y - i) * 30_usize).collect(), 345 | current: 0, 346 | timer: timer.clone(), 347 | }); 348 | } 349 | } 350 | }; 351 | 352 | // 3 humans 353 | entity((4.5, -4.0), 8, 27, 2, 2); 354 | entity((1.5, -7.0), 4, 27, 2, 2); 355 | entity((0.5, 2.0), 6, 27, 2, 2); 356 | 357 | // 5 containers 358 | entity((3.5, 1.0), 0, 19, 1, 1); 359 | entity((4.0, 6.0), 1, 19, 1, 1); 360 | entity((0.0, 5.0), 4, 19, 1, 1); 361 | entity((-4.0, 5.5), 5, 19, 1, 1); 362 | entity((-0.5, -8.5), 2, 19, 1, 1); 363 | 364 | // ikea chair 365 | entity((4.2, -8.), 13, 16, 2, 1); 366 | 367 | // fire 368 | let atlas = TextureAtlas { 369 | layout: images.layout.clone(), 370 | index: 30*32 + 14, 371 | }; 372 | 373 | commands.spawn((Sprite3dBuilder { 374 | image: images.image.clone(), 375 | pixels_per_metre: 16., 376 | emissive: LinearRgba::rgb(1.0, 0.5, 0.0) * 10.0, 377 | unlit: true, 378 | ..default() 379 | }.bundle_with_atlas(&mut sprite_params, atlas), 380 | Transform::from_xyz(2.0, 0.5, -5.5), 381 | Animation { 382 | frames: vec![30*32 + 14, 30*32 + 15, 30*32 + 16], 383 | current: 0, 384 | timer: Timer::from_seconds(0.2, TimerMode::Repeating), 385 | }, 386 | FaceCamera {} 387 | )); 388 | commands.spawn(( 389 | PointLight { 390 | intensity: 500_000.0, 391 | color: Color::srgb(1.0, 231./255., 221./255.), 392 | shadows_enabled: true, 393 | ..default() 394 | }, 395 | Transform::from_xyz(2.0, 1.8, -5.5), 396 | )); 397 | 398 | // glowy book 399 | let atlas = TextureAtlas { 400 | layout: images.layout.clone(), 401 | index: 22*30 + 22, 402 | }; 403 | 404 | commands.spawn(( 405 | Sprite3dBuilder { 406 | image: images.image.clone(), 407 | pixels_per_metre: 16., 408 | emissive: LinearRgba::rgb(165./255., 1.0, 160./255.), 409 | unlit: true, 410 | ..default() 411 | }.bundle_with_atlas(&mut sprite_params, atlas), 412 | Transform::from_xyz(-5., 0.7, 6.5), 413 | FaceCamera {} 414 | )); 415 | commands.spawn(( 416 | PointLight { 417 | intensity: 70_000.0, 418 | color: Color::srgb(91./255., 1.0, 92./255.), 419 | shadows_enabled: true, 420 | ..default() 421 | }, 422 | Transform::from_xyz(-5., 1.1, 6.5), 423 | )); 424 | 425 | 426 | } 427 | 428 | // parameters for how the camera orbits the area 429 | const CAM_DISTANCE: f32 = 25.0; 430 | const CAM_HEIGHT: f32 = 16.0; 431 | const CAM_SPEED: f32 = -0.1; 432 | 433 | // camera will always orbit 0,0,0, but can look somewhere slightly different 434 | const CAM_TARGET_X: f32 = 2.0; 435 | const CAM_TARGET_Z: f32 = -5.5; 436 | 437 | const CAM_T_OFFSET: f32 = -0.4; 438 | 439 | fn animate_camera( 440 | time: Res