Skip to main content

testbed_2d/
2d.rs

1//! 2d testbed
2//!
3//! You can switch scene by pressing the spacebar
4
5mod helpers;
6
7use argh::FromArgs;
8use bevy::prelude::*;
9
10use helpers::Next;
11
12#[derive(FromArgs)]
13/// 2d testbed
14pub struct Args {
15    #[argh(positional)]
16    scene: Option<Scene>,
17}
18
19fn main() {
20    #[cfg(not(target_arch = "wasm32"))]
21    let args: Args = argh::from_env();
22    #[cfg(target_arch = "wasm32")]
23    let args: Args = Args::from_args(&[], &[]).unwrap();
24
25    let mut app = App::new();
26    app.add_plugins((DefaultPlugins,))
27        .add_systems(OnEnter(Scene::Shapes), shapes::setup)
28        .add_systems(OnEnter(Scene::Bloom), bloom::setup)
29        .add_systems(OnEnter(Scene::Text), text::setup)
30        .add_systems(OnEnter(Scene::Sprite), sprite::setup)
31        .add_systems(OnEnter(Scene::SpriteSlicing), sprite_slicing::setup)
32        .add_systems(OnEnter(Scene::Gizmos), gizmos::setup)
33        .add_systems(
34            OnEnter(Scene::TextureAtlasBuilder),
35            texture_atlas_builder::setup,
36        )
37        .add_systems(OnEnter(Scene::ColorConsistency), color_consistency::setup)
38        .add_systems(OnExit(Scene::ColorConsistency), color_consistency::teardown)
39        .add_systems(Update, switch_scene)
40        .add_systems(Update, gizmos::draw_gizmos.run_if(in_state(Scene::Gizmos)));
41
42    match args.scene {
43        None => app.init_state::<Scene>(),
44        Some(scene) => app.insert_state(scene),
45    };
46
47    #[cfg(feature = "bevy_ci_testing")]
48    app.add_systems(Update, helpers::switch_scene_in_ci::<Scene>);
49
50    app.run();
51}
52
53#[derive(Debug, Clone, Eq, PartialEq, Hash, States, Default)]
54enum Scene {
55    #[default]
56    Shapes,
57    Bloom,
58    Text,
59    Sprite,
60    SpriteSlicing,
61    Gizmos,
62    TextureAtlasBuilder,
63    ColorConsistency,
64}
65
66impl std::str::FromStr for Scene {
67    type Err = String;
68
69    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
70        let mut isit = Self::default();
71        while s.to_lowercase() != format!("{isit:?}").to_lowercase() {
72            isit = isit.next();
73            if isit == Self::default() {
74                return Err(format!("Invalid Scene name: {s}"));
75            }
76        }
77        Ok(isit)
78    }
79}
80
81impl Next for Scene {
82    fn next(&self) -> Self {
83        match self {
84            Scene::Shapes => Scene::Bloom,
85            Scene::Bloom => Scene::Text,
86            Scene::Text => Scene::Sprite,
87            Scene::Sprite => Scene::SpriteSlicing,
88            Scene::SpriteSlicing => Scene::Gizmos,
89            Scene::Gizmos => Scene::TextureAtlasBuilder,
90            Scene::TextureAtlasBuilder => Scene::ColorConsistency,
91            Scene::ColorConsistency => Scene::Shapes,
92        }
93    }
94}
95
96fn switch_scene(
97    keyboard: Res<ButtonInput<KeyCode>>,
98    scene: Res<State<Scene>>,
99    mut next_scene: ResMut<NextState<Scene>>,
100) {
101    if keyboard.just_pressed(KeyCode::Space) {
102        info!("Switching scene");
103        next_scene.set(scene.get().next());
104    }
105}
106
107mod shapes {
108    use bevy::prelude::*;
109
110    const X_EXTENT: f32 = 900.;
111
112    pub fn setup(
113        mut commands: Commands,
114        mut meshes: ResMut<Assets<Mesh>>,
115        mut materials: ResMut<Assets<ColorMaterial>>,
116    ) {
117        commands.spawn((Camera2d, DespawnOnExit(super::Scene::Shapes)));
118
119        let shapes = [
120            meshes.add(Circle::new(50.0)),
121            meshes.add(CircularSector::new(50.0, 1.0)),
122            meshes.add(CircularSegment::new(50.0, 1.25)),
123            meshes.add(Ellipse::new(25.0, 50.0)),
124            meshes.add(Annulus::new(25.0, 50.0)),
125            meshes.add(Capsule2d::new(25.0, 50.0)),
126            meshes.add(Rhombus::new(75.0, 100.0)),
127            meshes.add(Rectangle::new(50.0, 100.0)),
128            meshes.add(RegularPolygon::new(50.0, 6)),
129            meshes.add(Triangle2d::new(
130                Vec2::Y * 50.0,
131                Vec2::new(-50.0, -50.0),
132                Vec2::new(50.0, -50.0),
133            )),
134        ];
135        let num_shapes = shapes.len();
136
137        for (i, shape) in shapes.into_iter().enumerate() {
138            // Distribute colors evenly across the rainbow.
139            let color = Color::hsl(360. * i as f32 / num_shapes as f32, 0.95, 0.7);
140
141            commands.spawn((
142                Mesh2d(shape),
143                MeshMaterial2d(materials.add(color)),
144                Transform::from_xyz(
145                    // Distribute shapes from -X_EXTENT/2 to +X_EXTENT/2.
146                    -X_EXTENT / 2. + i as f32 / (num_shapes - 1) as f32 * X_EXTENT,
147                    0.0,
148                    0.0,
149                ),
150                DespawnOnExit(super::Scene::Shapes),
151            ));
152        }
153    }
154}
155
156mod bloom {
157    use bevy::{core_pipeline::tonemapping::Tonemapping, post_process::bloom::Bloom, prelude::*};
158
159    pub fn setup(
160        mut commands: Commands,
161        mut meshes: ResMut<Assets<Mesh>>,
162        mut materials: ResMut<Assets<ColorMaterial>>,
163    ) {
164        commands.spawn((
165            Camera2d,
166            Tonemapping::TonyMcMapface,
167            Bloom::default(),
168            DespawnOnExit(super::Scene::Bloom),
169        ));
170
171        commands.spawn((
172            Mesh2d(meshes.add(Circle::new(100.))),
173            MeshMaterial2d(materials.add(Color::srgb(7.5, 0.0, 7.5))),
174            Transform::from_translation(Vec3::new(-200., 0., 0.)),
175            DespawnOnExit(super::Scene::Bloom),
176        ));
177
178        commands.spawn((
179            Mesh2d(meshes.add(RegularPolygon::new(100., 6))),
180            MeshMaterial2d(materials.add(Color::srgb(6.25, 9.4, 9.1))),
181            Transform::from_translation(Vec3::new(200., 0., 0.)),
182            DespawnOnExit(super::Scene::Bloom),
183        ));
184    }
185}
186
187mod text {
188    use bevy::color::palettes;
189    use bevy::prelude::*;
190    use bevy::sprite::Anchor;
191    use bevy::text::TextBounds;
192
193    pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
194        commands.spawn((Camera2d, DespawnOnExit(super::Scene::Text)));
195
196        for (i, justify) in [
197            Justify::Left,
198            Justify::Right,
199            Justify::Center,
200            Justify::Justified,
201        ]
202        .into_iter()
203        .enumerate()
204        {
205            let y = 230. - 150. * i as f32;
206            spawn_anchored_text(&mut commands, -300. * Vec3::X + y * Vec3::Y, justify, None);
207            spawn_anchored_text(
208                &mut commands,
209                300. * Vec3::X + y * Vec3::Y,
210                justify,
211                Some(TextBounds::new(150., 60.)),
212            );
213        }
214
215        let sans_serif = TextFont::from(asset_server.load("fonts/FiraSans-Bold.ttf"));
216
217        const NUM_ITERATIONS: usize = 10;
218        for i in 0..NUM_ITERATIONS {
219            let fraction = i as f32 / (NUM_ITERATIONS - 1) as f32;
220
221            commands.spawn((
222                Text2d::new("Bevy"),
223                sans_serif.clone(),
224                Transform::from_xyz(0.0, fraction * 200.0, i as f32)
225                    .with_scale(1.0 + Vec2::splat(fraction).extend(1.))
226                    .with_rotation(Quat::from_rotation_z(fraction * core::f32::consts::PI)),
227                TextColor(Color::hsla(fraction * 360.0, 0.8, 0.8, 0.8)),
228                DespawnOnExit(super::Scene::Text),
229            ));
230        }
231
232        commands.spawn((
233            Text2d::new("This text is invisible."),
234            Visibility::Hidden,
235            DespawnOnExit(super::Scene::Text),
236        ));
237    }
238
239    fn spawn_anchored_text(
240        commands: &mut Commands,
241        dest: Vec3,
242        justify: Justify,
243        bounds: Option<TextBounds>,
244    ) {
245        commands.spawn((
246            Sprite {
247                color: palettes::css::YELLOW.into(),
248                custom_size: Some(5. * Vec2::ONE),
249                ..Default::default()
250            },
251            Transform::from_translation(dest),
252            DespawnOnExit(super::Scene::Text),
253        ));
254
255        for anchor in [
256            Anchor::TOP_LEFT,
257            Anchor::TOP_RIGHT,
258            Anchor::BOTTOM_RIGHT,
259            Anchor::BOTTOM_LEFT,
260        ] {
261            let mut text = commands.spawn((
262                Text2d::new("L R\n"),
263                TextLayout::justify(justify),
264                Transform::from_translation(dest + Vec3::Z),
265                anchor,
266                DespawnOnExit(super::Scene::Text),
267                ShowAabbGizmo {
268                    color: Some(palettes::tailwind::AMBER_400.into()),
269                },
270                children![
271                    (
272                        TextSpan::new(format!("{}, {}\n", anchor.x, anchor.y)),
273                        TextFont::from_font_size(14.0),
274                        TextColor(palettes::tailwind::BLUE_400.into()),
275                    ),
276                    (
277                        TextSpan::new(format!("{justify:?}")),
278                        TextFont::from_font_size(14.0),
279                        TextColor(palettes::tailwind::GREEN_400.into()),
280                    ),
281                ],
282            ));
283            if let Some(bounds) = bounds {
284                text.insert(bounds);
285
286                commands.spawn((
287                    Sprite {
288                        color: palettes::tailwind::GRAY_900.into(),
289                        custom_size: Some(Vec2::new(bounds.width.unwrap(), bounds.height.unwrap())),
290                        ..Default::default()
291                    },
292                    Transform::from_translation(dest - Vec3::Z),
293                    anchor,
294                    DespawnOnExit(super::Scene::Text),
295                ));
296            }
297        }
298    }
299}
300
301mod sprite {
302    use bevy::color::palettes::css::{BLUE, LIME, RED};
303    use bevy::prelude::*;
304    use bevy::sprite::Anchor;
305
306    pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
307        commands.spawn((Camera2d, DespawnOnExit(super::Scene::Sprite)));
308        for (anchor, flip_x, flip_y, color) in [
309            (Anchor::BOTTOM_LEFT, false, false, Color::WHITE),
310            (Anchor::BOTTOM_RIGHT, true, false, RED.into()),
311            (Anchor::TOP_LEFT, false, true, LIME.into()),
312            (Anchor::TOP_RIGHT, true, true, BLUE.into()),
313        ] {
314            commands.spawn((
315                Sprite {
316                    image: asset_server.load("branding/bevy_logo_dark.png"),
317                    flip_x,
318                    flip_y,
319                    color,
320                    ..default()
321                },
322                anchor,
323                DespawnOnExit(super::Scene::Sprite),
324            ));
325        }
326    }
327}
328
329mod sprite_slicing {
330    use bevy::prelude::*;
331    use bevy::sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer};
332
333    pub fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
334        commands.spawn((Camera2d, DespawnOnExit(super::Scene::SpriteSlicing)));
335
336        let texture = asset_server.load("textures/slice_square_2.png");
337        let font = asset_server.load("fonts/FiraSans-Bold.ttf");
338
339        commands.spawn((
340            Sprite {
341                image: texture.clone(),
342                ..default()
343            },
344            Transform::from_translation(Vec3::new(-150.0, 50.0, 0.0)).with_scale(Vec3::splat(2.0)),
345            DespawnOnExit(super::Scene::SpriteSlicing),
346        ));
347
348        commands.spawn((
349            Sprite {
350                image: texture,
351                image_mode: SpriteImageMode::Sliced(TextureSlicer {
352                    border: BorderRect::all(20.0),
353                    center_scale_mode: SliceScaleMode::Stretch,
354                    ..default()
355                }),
356                custom_size: Some(Vec2::new(200.0, 200.0)),
357                ..default()
358            },
359            Transform::from_translation(Vec3::new(150.0, 50.0, 0.0)),
360            DespawnOnExit(super::Scene::SpriteSlicing),
361        ));
362
363        commands.spawn((
364            Text2d::new("Original"),
365            TextFont {
366                font: FontSource::from(font.clone()),
367                font_size: FontSize::Px(20.0),
368                ..default()
369            },
370            Transform::from_translation(Vec3::new(-150.0, -80.0, 0.0)),
371            DespawnOnExit(super::Scene::SpriteSlicing),
372        ));
373
374        commands.spawn((
375            Text2d::new("Sliced"),
376            TextFont {
377                font: FontSource::from(font.clone()),
378                font_size: FontSize::Px(20.0),
379                ..default()
380            },
381            Transform::from_translation(Vec3::new(150.0, -80.0, 0.0)),
382            DespawnOnExit(super::Scene::SpriteSlicing),
383        ));
384    }
385}
386
387mod gizmos {
388    use bevy::{color::palettes::css::*, prelude::*};
389
390    pub fn setup(mut commands: Commands) {
391        commands.spawn((Camera2d, DespawnOnExit(super::Scene::Gizmos)));
392    }
393
394    pub fn draw_gizmos(mut gizmos: Gizmos) {
395        gizmos.rect_2d(
396            Isometry2d::from_translation(Vec2::new(-200.0, 0.0)),
397            Vec2::new(200.0, 200.0),
398            RED,
399        );
400        gizmos
401            .circle_2d(
402                Isometry2d::from_translation(Vec2::new(-200.0, 0.0)),
403                200.0,
404                GREEN,
405            )
406            .resolution(64);
407
408        gizmos.text_2d(
409            Isometry2d::from_translation(Vec2::new(-200.0, 0.0)),
410            "text_2d gizmo",
411            15.,
412            Vec2 { x: 0., y: 0. },
413            Color::WHITE,
414        );
415
416        // 2d grids with all variations of outer edges on or off
417        for i in 0..4 {
418            let x = 200.0 * (1.0 + (i % 2) as f32);
419            let y = 150.0 * (0.5 - (i / 2) as f32);
420            let mut grid = gizmos.grid(
421                Vec3::new(x, y, 0.0),
422                UVec2::new(5, 4),
423                Vec2::splat(30.),
424                Color::WHITE,
425            );
426            if i & 1 > 0 {
427                grid = grid.outer_edges_x();
428            }
429            if i & 2 > 0 {
430                grid.outer_edges_y();
431            }
432        }
433    }
434}
435
436mod texture_atlas_builder {
437    use bevy::{
438        asset::RenderAssetUsages,
439        image::ImageSampler,
440        prelude::*,
441        render::render_resource::{Extent3d, TextureDimension, TextureFormat},
442        sprite::Anchor,
443    };
444
445    const ATLAS_SIZE: UVec2 = UVec2::splat(64);
446    const IMAGE_SIZE: UVec2 = UVec2::splat(28);
447    const PADDING_SIZE: UVec2 = UVec2::splat(2);
448    const ATLAS_SCALE: f32 = 4.;
449    const IMAGE_SCALE: f32 = 4.;
450
451    pub fn setup(
452        mut commands: Commands,
453        mut textures: ResMut<Assets<Image>>,
454        mut texture_atlases: ResMut<Assets<TextureAtlasLayout>>,
455    ) {
456        commands.spawn((Camera2d, DespawnOnExit(super::Scene::TextureAtlasBuilder)));
457
458        for (i, padding) in [UVec2::ZERO, PADDING_SIZE].into_iter().enumerate() {
459            // generate solid red green and blue and yellow images
460            let images = [
461                [255, 0, 0, 255],
462                [0, 255, 0, 255],
463                [0, 0, 255, 255],
464                [255, 255, 0, 255],
465            ]
466            .map(|pixel| {
467                Image::new_fill(
468                    Extent3d {
469                        width: 28,
470                        height: 28,
471                        depth_or_array_layers: 1,
472                    },
473                    TextureDimension::D2,
474                    &pixel,
475                    TextureFormat::Rgba8UnormSrgb,
476                    RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
477                )
478            });
479
480            let mut texture_atlas_builder = TextureAtlasBuilder::default();
481            texture_atlas_builder
482                .initial_size(ATLAS_SIZE)
483                .max_size(ATLAS_SIZE)
484                .padding(padding);
485            for image in &images {
486                texture_atlas_builder.add_texture(None, image);
487            }
488
489            let (atlas_layout, _, atlas_texture) = texture_atlas_builder.build().expect(
490                "The images are 28 pixels square, so they should fit with 4 pixels left over",
491            );
492            let atlas_layout = texture_atlases.add(atlas_layout);
493
494            let mut nearest_atlas_image = atlas_texture.clone();
495            nearest_atlas_image.sampler = ImageSampler::nearest();
496
497            let atlas_handle = textures.add(atlas_texture);
498            let nearest_atlas_handle = textures.add(nearest_atlas_image);
499
500            let position = ((2. * i as f32 - 1.) * (0.625 * ATLAS_SIZE.x as f32 * ATLAS_SCALE))
501                .round()
502                * Vec3::X;
503
504            commands.spawn((
505                Sprite {
506                    image: nearest_atlas_handle,
507                    custom_size: Some(ATLAS_SIZE.as_vec2() * ATLAS_SCALE),
508                    ..default()
509                },
510                Anchor::BOTTOM_CENTER,
511                ShowAabbGizmo {
512                    color: Some(Color::WHITE),
513                },
514                DespawnOnExit(super::Scene::TextureAtlasBuilder),
515                Transform::from_translation(position),
516            ));
517
518            for (index, anchor) in [
519                Anchor::BOTTOM_RIGHT,
520                Anchor::BOTTOM_LEFT,
521                Anchor::TOP_LEFT,
522                Anchor::TOP_RIGHT,
523            ]
524            .into_iter()
525            .enumerate()
526            {
527                commands.spawn((
528                    Sprite {
529                        image: atlas_handle.clone(),
530                        texture_atlas: Some(TextureAtlas {
531                            layout: atlas_layout.clone(),
532                            index,
533                        }),
534                        custom_size: Some(IMAGE_SIZE.as_vec2() * IMAGE_SCALE),
535                        ..default()
536                    },
537                    Transform::from_translation(
538                        position
539                            + -2.
540                                * IMAGE_SCALE
541                                * (Vec3::Y * IMAGE_SIZE.y as f32 + anchor.as_vec().extend(0.)),
542                    ),
543                    anchor,
544                    DespawnOnExit(super::Scene::TextureAtlasBuilder),
545                ));
546            }
547        }
548    }
549}
550
551mod color_consistency {
552    //! Visual regression test for <https://github.com/bevyengine/bevy/issues/23577>.
553    //!
554    //! The clear color and shapes rendered using different pipelines (sprites,
555    //! 2D meshes, UI background) should produce identical pixel values for the
556    //! same sRGB input color.
557    //!
558    //! If the color conversion paths are consistent, the entire window will appear
559    //! as a uniform solid color with no visible boundaries between the strips.
560
561    use bevy::{core_pipeline::tonemapping::Tonemapping, prelude::*};
562
563    // We've chosen the sRGB value from issue #23577, in case it can be reproduced elsewhere.
564    const TEST_COLOR: Color = Color::srgb(0.1, 0.1, 0.1);
565    const DEFAULT_WIDTH: f32 = 1280.0;
566    const DEFAULT_HEIGHT: f32 = 720.0;
567    const STRIP_WIDTH: f32 = DEFAULT_WIDTH / 3.0;
568    const STRIP_HEIGHT: f32 = DEFAULT_HEIGHT / 3.0;
569
570    pub fn setup(
571        mut commands: Commands,
572        mut meshes: ResMut<Assets<Mesh>>,
573        mut materials: ResMut<Assets<ColorMaterial>>,
574    ) {
575        // The window background is drawn with the clear color.
576        commands.insert_resource(ClearColor(TEST_COLOR));
577
578        // Make sure there's no tonemapping
579        commands.spawn((
580            Camera2d,
581            Tonemapping::None,
582            DespawnOnExit(super::Scene::ColorConsistency),
583        ));
584
585        // Top third for sprites
586        commands.spawn((
587            Sprite {
588                color: TEST_COLOR,
589                custom_size: Some(Vec2::new(STRIP_WIDTH, STRIP_HEIGHT)),
590                ..default()
591            },
592            Transform::from_xyz(0.0, STRIP_HEIGHT, 0.0),
593            DespawnOnExit(super::Scene::ColorConsistency),
594        ));
595
596        // Middle third for 2D meshes
597        commands.spawn((
598            Mesh2d(meshes.add(Rectangle::new(STRIP_WIDTH, STRIP_HEIGHT))),
599            MeshMaterial2d(materials.add(ColorMaterial::from_color(TEST_COLOR))),
600            Transform::from_xyz(0.0, 0.0, 0.0),
601            DespawnOnExit(super::Scene::ColorConsistency),
602        ));
603
604        // Bottom third for UI nodes
605        commands.spawn((
606            Node {
607                position_type: PositionType::Absolute,
608                bottom: Val::Px(0.0),
609                left: Val::Px(0.0),
610                width: Val::Percent(33.3),
611                height: Val::Px(STRIP_HEIGHT),
612                ..default()
613            },
614            BackgroundColor(TEST_COLOR),
615            DespawnOnExit(super::Scene::ColorConsistency),
616        ));
617    }
618
619    // Remember to reset the clear color
620    // Tonemapping is per-camera, and is reset when the camera despawns
621    pub fn teardown(mut commands: Commands) {
622        commands.insert_resource(ClearColor::default());
623    }
624}