Skip to main content

mirror/
mirror.rs

1//! Demonstrates how to create a mirror with a second camera.
2
3use std::f32::consts::FRAC_PI_2;
4
5use crate::widgets::{RadioButton, WidgetClickEvent, WidgetClickSender};
6use bevy::camera::RenderTarget;
7use bevy::{
8    asset::RenderAssetUsages,
9    color::palettes::css::GREEN,
10    input::mouse::AccumulatedMouseMotion,
11    math::{reflection_matrix, uvec2, vec3},
12    pbr::{ExtendedMaterial, MaterialExtension},
13    prelude::*,
14    render::render_resource::{
15        AsBindGroup, Extent3d, TextureDimension, TextureFormat, TextureUsages,
16    },
17    shader::ShaderRef,
18    window::{PrimaryWindow, WindowResized},
19};
20
21#[path = "../helpers/widgets.rs"]
22mod widgets;
23
24/// A resource that stores a handle to the image that contains the rendered
25/// mirror world.
26#[derive(Resource)]
27struct MirrorImage(Handle<Image>);
28
29/// A marker component for the camera that renders the mirror world.
30#[derive(Component)]
31struct MirrorCamera;
32
33/// A marker component for the mirror mesh itself.
34#[derive(Component)]
35struct Mirror;
36
37/// The dummy material extension that we use for the mirror surface.
38///
39/// This shader samples its emissive texture at the screen space position of
40/// each fragment rather than at the UVs. Effectively, this uses a PBR shader as
41/// a mask that copies a portion of the emissive texture to the screen, all in
42/// screen space.
43///
44/// We use [`ExtendedMaterial`], as that's the easiest way to implement custom
45/// shaders that modify the built-in [`StandardMaterial`]. We don't require any
46/// extra data to be passed to the shader beyond the [`StandardMaterial`] PBR
47/// fields, but currently Bevy requires at least one field to be present in the
48/// extended material, so we simply have an unused field.
49#[derive(Clone, AsBindGroup, Asset, Reflect)]
50struct ScreenSpaceTextureExtension {
51    /// An unused value that we have just to satisfy [`ExtendedMaterial`]
52    /// requirements.
53    #[uniform(100)]
54    dummy: f32,
55}
56
57impl MaterialExtension for ScreenSpaceTextureExtension {
58    fn fragment_shader() -> ShaderRef {
59        "shaders/screen_space_texture_material.wgsl".into()
60    }
61}
62
63/// The action that will be performed when the user drags the mouse: either
64/// moving the camera or moving the rigged model.
65#[derive(Clone, Copy, PartialEq, Default)]
66enum DragAction {
67    /// Dragging will move the camera.
68    #[default]
69    MoveCamera,
70    /// Dragging will move the animated fox.
71    MoveFox,
72}
73
74/// The settings that the user has currently chosen.
75///
76/// Currently, this just consists of the [`DragAction`].
77#[derive(Resource, Default)]
78struct AppStatus {
79    /// The action that will be performed when the user drags the mouse: either
80    /// moving the camera or moving the rigged model.
81    drag_action: DragAction,
82}
83
84/// A marker component for the help text at the top of the screen.
85#[derive(Clone, Copy, Component)]
86struct HelpText;
87
88/// The coordinates that the camera looks at.
89const CAMERA_TARGET: Vec3 = vec3(-25.0, 20.0, 0.0);
90/// The camera stays this distance in meters from the camera target.
91const CAMERA_ORBIT_DISTANCE: f32 = 500.0;
92/// The speed at which the user can move the camera vertically, in radians per
93/// mouse input unit.
94const CAMERA_PITCH_SPEED: f32 = 0.003;
95/// The speed at which the user can move the camera horizontally, in radians per
96/// mouse input unit.
97const CAMERA_YAW_SPEED: f32 = 0.004;
98// Limiting pitch stops some unexpected rotation past 90° up or down.
99const CAMERA_PITCH_LIMIT: f32 = FRAC_PI_2 - 0.01;
100
101/// The angle that the mirror faces.
102///
103/// The mirror is rotated across the X axis in this many radians.
104const MIRROR_ROTATION_ANGLE: f32 = -FRAC_PI_2;
105const MIRROR_POSITION: Vec3 = vec3(-25.0, 75.0, 0.0);
106
107/// The path to the animated fox model.
108static FOX_ASSET_PATH: &str = "models/animated/Fox.glb";
109
110/// The app entry point.
111fn main() {
112    App::new()
113        .add_plugins(DefaultPlugins.set(WindowPlugin {
114            primary_window: Some(Window {
115                title: "Bevy Mirror Example".into(),
116                ..default()
117            }),
118            ..default()
119        }))
120        .add_plugins(MaterialPlugin::<
121            ExtendedMaterial<StandardMaterial, ScreenSpaceTextureExtension>,
122        >::default())
123        .init_resource::<AppStatus>()
124        .add_message::<WidgetClickEvent<DragAction>>()
125        .add_systems(Startup, setup)
126        .add_systems(Update, handle_window_resize_messages)
127        .add_systems(Update, (move_camera_on_mouse_down, move_fox_on_mouse_down))
128        .add_systems(Update, widgets::handle_ui_interactions::<DragAction>)
129        .add_systems(
130            Update,
131            (handle_mouse_action_change, update_radio_buttons)
132                .after(widgets::handle_ui_interactions::<DragAction>),
133        )
134        .add_systems(
135            Update,
136            update_mirror_camera_on_main_camera_transform_change.after(move_camera_on_mouse_down),
137        )
138        .add_systems(Update, play_fox_animation)
139        .add_systems(Update, update_help_text)
140        .run();
141}
142
143/// A startup system that spawns the scene and sets up the mirror render target.
144fn setup(
145    mut commands: Commands,
146    windows_query: Query<&Window>,
147    asset_server: Res<AssetServer>,
148    mut meshes: ResMut<Assets<Mesh>>,
149    mut standard_materials: ResMut<Assets<StandardMaterial>>,
150    mut screen_space_texture_materials: ResMut<
151        Assets<ExtendedMaterial<StandardMaterial, ScreenSpaceTextureExtension>>,
152    >,
153    mut images: ResMut<Assets<Image>>,
154    app_status: Res<AppStatus>,
155) {
156    // Spawn the main camera.
157    let camera_projection = PerspectiveProjection::default();
158    let camera_transform = spawn_main_camera(&mut commands, &camera_projection);
159
160    // Spawn the light.
161    spawn_light(&mut commands);
162
163    // Spawn the objects reflected in the mirror.
164    spawn_ground_plane(&mut commands, &mut meshes, &mut standard_materials);
165    spawn_fox(&mut commands, &asset_server);
166
167    // Spawn the mirror and associated camera.
168    let mirror_render_target_image =
169        create_mirror_texture_resource(&mut commands, &windows_query, &mut images);
170    let mirror_transform = spawn_mirror(
171        &mut commands,
172        &mut meshes,
173        &mut screen_space_texture_materials,
174        mirror_render_target_image.clone(),
175    );
176    spawn_mirror_camera(
177        &mut commands,
178        &camera_transform,
179        &camera_projection,
180        &mirror_transform,
181        mirror_render_target_image,
182    );
183
184    // Spawn the UI.
185    spawn_buttons(&mut commands);
186    spawn_help_text(&mut commands, &app_status);
187}
188
189/// Spawns the main camera (not the mirror camera).
190fn spawn_main_camera(
191    commands: &mut Commands,
192    camera_projection: &PerspectiveProjection,
193) -> Transform {
194    let camera_transform = Transform::from_translation(
195        vec3(-2.0, 1.0, -2.0).normalize_or_zero() * CAMERA_ORBIT_DISTANCE,
196    )
197    .looking_at(CAMERA_TARGET, Vec3::Y);
198
199    commands.spawn((
200        Camera3d::default(),
201        camera_transform,
202        Projection::Perspective(camera_projection.clone()),
203    ));
204
205    camera_transform
206}
207
208/// Spawns a directional light to illuminate the scene.
209fn spawn_light(commands: &mut Commands) {
210    commands.spawn((
211        DirectionalLight {
212            illuminance: 5000.0,
213            ..default()
214        },
215        Transform::from_xyz(-85.0, 16.0, -200.0).looking_at(vec3(-50.0, 0.0, 100.0), Vec3::Y),
216    ));
217}
218
219/// Spawns the circular ground plane object.
220fn spawn_ground_plane(
221    commands: &mut Commands,
222    meshes: &mut Assets<Mesh>,
223    standard_materials: &mut Assets<StandardMaterial>,
224) {
225    commands.spawn((
226        Mesh3d(meshes.add(Circle::new(200.0))),
227        MeshMaterial3d(standard_materials.add(Color::from(GREEN))),
228        Transform::from_rotation(Quat::from_rotation_x(-FRAC_PI_2))
229            .with_translation(vec3(-25.0, 0.0, 0.0)),
230    ));
231}
232
233/// Creates the initial image that the mirror camera will render the mirror
234/// world to.
235fn create_mirror_texture_resource(
236    commands: &mut Commands,
237    windows_query: &Query<&Window>,
238    images: &mut Assets<Image>,
239) -> Handle<Image> {
240    let window = windows_query.iter().next().expect("No window found");
241    let window_size = uvec2(window.physical_width(), window.physical_height());
242    let image = create_mirror_texture_image(images, window_size);
243    commands.insert_resource(MirrorImage(image.clone()));
244    image
245}
246
247/// Spawns the camera that renders the mirror world.
248fn spawn_mirror_camera(
249    commands: &mut Commands,
250    camera_transform: &Transform,
251    camera_projection: &PerspectiveProjection,
252    mirror_transform: &Transform,
253    mirror_render_target: Handle<Image>,
254) {
255    let (mirror_camera_transform, mirror_camera_projection) =
256        calculate_mirror_camera_transform_and_projection(
257            camera_transform,
258            camera_projection,
259            mirror_transform,
260        );
261
262    commands.spawn((
263        Camera3d::default(),
264        Camera {
265            order: -1,
266            // Reflecting the model across the mirror will flip the winding of
267            // all the polygons. Therefore, in order to properly backface cull,
268            // we need to turn on `invert_culling`.
269            invert_culling: true,
270            ..default()
271        },
272        RenderTarget::Image(mirror_render_target.clone().into()),
273        mirror_camera_transform,
274        Projection::Perspective(mirror_camera_projection),
275        MirrorCamera,
276    ));
277}
278
279/// Spawns the animated fox.
280///
281/// Note that this doesn't play the animation; that's handled in
282/// [`play_fox_animation`].
283fn spawn_fox(commands: &mut Commands, asset_server: &AssetServer) {
284    commands.spawn((
285        WorldAssetRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(FOX_ASSET_PATH))),
286        Transform::from_xyz(-50.0, 0.0, -100.0),
287    ));
288}
289
290/// Spawns the mirror plane mesh and returns its transform.
291fn spawn_mirror(
292    commands: &mut Commands,
293    meshes: &mut Assets<Mesh>,
294    screen_space_texture_materials: &mut Assets<
295        ExtendedMaterial<StandardMaterial, ScreenSpaceTextureExtension>,
296    >,
297    mirror_render_target: Handle<Image>,
298) -> Transform {
299    let mirror_transform = Transform::from_scale(vec3(300.0, 1.0, 150.0))
300        .with_rotation(Quat::from_rotation_x(MIRROR_ROTATION_ANGLE))
301        .with_translation(MIRROR_POSITION);
302
303    commands.spawn((
304        Mesh3d(meshes.add(Plane3d::default().mesh().size(1.0, 1.0))),
305        MeshMaterial3d(screen_space_texture_materials.add(ExtendedMaterial {
306            base: StandardMaterial {
307                base_color: Color::BLACK,
308                emissive: Color::WHITE.into(),
309                emissive_texture: Some(mirror_render_target),
310                perceptual_roughness: 0.0,
311                metallic: 1.0,
312                ..default()
313            },
314            extension: ScreenSpaceTextureExtension { dummy: 0.0 },
315        })),
316        mirror_transform,
317        Mirror,
318    ));
319
320    mirror_transform
321}
322
323/// Spawns the buttons at the bottom of the screen.
324fn spawn_buttons(commands: &mut Commands) {
325    // Spawn the radio buttons that allow the user to select an object to
326    // control.
327    commands.spawn((
328        widgets::main_ui_node(),
329        children![widgets::option_buttons(
330            "Drag Action",
331            &[
332                (DragAction::MoveCamera, "Move Camera"),
333                (DragAction::MoveFox, "Move Fox"),
334            ],
335        )],
336    ));
337}
338
339/// Given the transform and projection of the main camera, returns an
340/// appropriate transform and projection for the mirror camera.
341fn calculate_mirror_camera_transform_and_projection(
342    main_camera_transform: &Transform,
343    main_camera_projection: &PerspectiveProjection,
344    mirror_transform: &Transform,
345) -> (Transform, PerspectiveProjection) {
346    // Calculate the reflection matrix (a.k.a. Householder matrix) that will
347    // reflect the scene across the mirror plane.
348    //
349    // Note that you must calculate this in *matrix* form and only *afterward*
350    // convert to a `Transform` instead of composing `Transform`s. This is
351    // because the reflection matrix has non-uniform scale, and composing
352    // transforms can't always handle composition of matrices with non-uniform
353    // scales.
354    let mirror_camera_transform = Transform::from_matrix(
355        Mat4::from_mat3a(reflection_matrix(Vec3::NEG_Z)) * main_camera_transform.to_matrix(),
356    );
357
358    // Compute the distance from the camera to the mirror plane. This will be
359    // used to calculate the distance to the near clip plane for the mirror
360    // world.
361    let distance_from_camera_to_mirror = InfinitePlane3d::new(mirror_transform.rotation * Vec3::Y)
362        .signed_distance(
363            Isometry3d::IDENTITY,
364            mirror_transform.translation - main_camera_transform.translation,
365        );
366
367    // Compute the normal of the mirror plane in view space.
368    let view_from_world = main_camera_transform.compute_affine().matrix3.inverse();
369    let mirror_projection_plane_normal =
370        (view_from_world * (mirror_transform.rotation * Vec3::NEG_Y)).normalize();
371
372    // Compute the final projection. It should match the main camera projection,
373    // except that `near` and `near_normal` should be set to the updated near
374    // plane and near normal plane as above.
375    let mirror_camera_projection = PerspectiveProjection {
376        near_clip_plane: mirror_projection_plane_normal.extend(distance_from_camera_to_mirror),
377        ..*main_camera_projection
378    };
379
380    (mirror_camera_transform, mirror_camera_projection)
381}
382
383/// A system that resizes the render target image when the user resizes the window.
384///
385/// Since the image that stores the rendered mirror world has the same physical
386/// size as the window, we need to reallocate it and reattach it to the mirror
387/// material whenever the window size changes.
388fn handle_window_resize_messages(
389    windows_query: Query<&Window>,
390    mut mirror_cameras_query: Query<&mut RenderTarget, With<MirrorCamera>>,
391    mut images: ResMut<Assets<Image>>,
392    mut mirror_image: ResMut<MirrorImage>,
393    mut screen_space_texture_materials: ResMut<
394        Assets<ExtendedMaterial<StandardMaterial, ScreenSpaceTextureExtension>>,
395    >,
396    mut resize_messages: MessageReader<WindowResized>,
397) {
398    // We run at most once, regardless of the number of window resize messages
399    // there were this frame.
400    let Some(resize_message) = resize_messages.read().next() else {
401        return;
402    };
403    let Ok(window) = windows_query.get(resize_message.window) else {
404        return;
405    };
406
407    let window_size = uvec2(window.physical_width(), window.physical_height());
408    let image = create_mirror_texture_image(&mut images, window_size);
409    images.remove(mirror_image.0.id());
410
411    mirror_image.0 = image.clone();
412
413    for mut target in mirror_cameras_query.iter_mut() {
414        *target = image.clone().into();
415    }
416
417    for (_, material) in screen_space_texture_materials.iter_mut() {
418        material.base.emissive_texture = Some(image.clone());
419    }
420}
421
422/// Creates the image that will be used to store the reflected scene.
423fn create_mirror_texture_image(images: &mut Assets<Image>, window_size: UVec2) -> Handle<Image> {
424    let mirror_image_extent = Extent3d {
425        width: window_size.x,
426        height: window_size.y,
427        depth_or_array_layers: 1,
428    };
429
430    let mut image = Image::new_uninit(
431        mirror_image_extent,
432        TextureDimension::D2,
433        TextureFormat::Bgra8UnormSrgb,
434        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
435    );
436    image.texture_descriptor.usage |=
437        TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
438
439    images.add(image)
440}
441
442// Moves the fox when the user moves the mouse with the left button down.
443fn move_fox_on_mouse_down(
444    mut scene_roots_query: Query<&mut Transform, With<WorldAssetRoot>>,
445    windows_query: Query<&Window, With<PrimaryWindow>>,
446    cameras_query: Query<(&Camera, &GlobalTransform)>,
447    interactions_query: Query<&Interaction, With<RadioButton>>,
448    buttons: Res<ButtonInput<MouseButton>>,
449    app_status: Res<AppStatus>,
450) {
451    // Only process the mouse motion if the left mouse button is pressed, the
452    // mouse action is set to move the fox, and the pointer isn't over a UI
453    // widget.
454    if app_status.drag_action != DragAction::MoveFox
455        || !buttons.pressed(MouseButton::Left)
456        || interactions_query
457            .iter()
458            .any(|interaction| *interaction != Interaction::None)
459    {
460        return;
461    }
462
463    // Find out where the user clicked the mouse.
464    let Some(mouse_position) = windows_query
465        .iter()
466        .next()
467        .and_then(Window::cursor_position)
468    else {
469        return;
470    };
471
472    // Grab the camera.
473    let Some((camera, camera_transform)) = cameras_query.iter().next() else {
474        return;
475    };
476
477    // Figure out where the user clicked on the plane.
478    let Ok(ray) = camera.viewport_to_world(camera_transform, mouse_position) else {
479        return;
480    };
481    let Some(ray_distance) = ray.intersect_plane(Vec3::ZERO, InfinitePlane3d::new(Vec3::Y)) else {
482        return;
483    };
484    let plane_intersection = ray.origin + ray.direction.normalize() * ray_distance;
485
486    // Move the fox.
487    for mut transform in scene_roots_query.iter_mut() {
488        transform.translation = transform.translation.with_xz(plane_intersection.xz());
489    }
490}
491
492/// A system that changes the drag action when the user clicks on one of the
493/// radio buttons.
494fn handle_mouse_action_change(
495    mut app_status: ResMut<AppStatus>,
496    mut messages: MessageReader<WidgetClickEvent<DragAction>>,
497) {
498    for message in messages.read() {
499        app_status.drag_action = **message;
500    }
501}
502
503/// A system that updates the radio buttons at the bottom of the screen to
504/// reflect the current drag action.
505fn update_radio_buttons(
506    mut widgets_query: Query<(
507        Entity,
508        Option<&mut BackgroundColor>,
509        Has<Text>,
510        &WidgetClickSender<DragAction>,
511    )>,
512    app_status: Res<AppStatus>,
513    mut text_ui_writer: TextUiWriter,
514) {
515    for (entity, maybe_bg_color, has_text, sender) in &mut widgets_query {
516        let selected = app_status.drag_action == **sender;
517        if let Some(mut bg_color) = maybe_bg_color {
518            widgets::update_ui_radio_button(&mut bg_color, selected);
519        }
520        if has_text {
521            widgets::update_ui_radio_button_text(entity, &mut text_ui_writer, selected);
522        }
523    }
524}
525
526/// A system that processes user mouse actions that move the camera.
527///
528/// This is mostly copied from `examples/camera/camera_orbit.rs`.
529fn move_camera_on_mouse_down(
530    mut main_cameras_query: Query<&mut Transform, (With<Camera>, Without<MirrorCamera>)>,
531    interactions_query: Query<&Interaction, With<RadioButton>>,
532    mouse_buttons: Res<ButtonInput<MouseButton>>,
533    mouse_motion: Res<AccumulatedMouseMotion>,
534    app_status: Res<AppStatus>,
535) {
536    // Only process the mouse motion if the left mouse button is pressed, the
537    // mouse action is set to move the fox, and the pointer isn't over a UI
538    // widget.
539    if app_status.drag_action != DragAction::MoveCamera
540        || !mouse_buttons.pressed(MouseButton::Left)
541        || interactions_query
542            .iter()
543            .any(|interaction| *interaction != Interaction::None)
544    {
545        return;
546    }
547
548    let delta = mouse_motion.delta;
549
550    // Mouse motion is one of the few inputs that should not be multiplied by delta time,
551    // as we are already receiving the full movement since the last frame was rendered. Multiplying
552    // by delta time here would make the movement slower that it should be.
553    let delta_pitch = delta.y * CAMERA_PITCH_SPEED;
554    let delta_yaw = delta.x * CAMERA_YAW_SPEED;
555
556    for mut main_camera_transform in &mut main_cameras_query {
557        // Obtain the existing pitch and yaw values from the transform.
558        let (yaw, pitch, _) = main_camera_transform.rotation.to_euler(EulerRot::YXZ);
559
560        // Establish the new yaw and pitch, preventing the pitch value from exceeding our limits.
561        let pitch = (pitch + delta_pitch).clamp(-CAMERA_PITCH_LIMIT, CAMERA_PITCH_LIMIT);
562        let yaw = yaw + delta_yaw;
563        main_camera_transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, 0.0);
564
565        // Adjust the translation to maintain the correct orientation toward the orbit target.
566        // In our example it's a static target, but this could easily be customized.
567        let target = Vec3::ZERO;
568        main_camera_transform.translation =
569            target - main_camera_transform.forward() * CAMERA_ORBIT_DISTANCE;
570    }
571}
572
573/// Updates the position, rotation, and projection of the mirror camera when the
574/// main camera is moved.
575///
576/// When the main camera is moved, the mirror camera must be moved to match it.
577/// The *projection* on the mirror camera must also be altered, because the
578/// projection takes the view-space rotation of and distance to the mirror into
579/// account.
580fn update_mirror_camera_on_main_camera_transform_change(
581    main_cameras_query: Query<
582        (&Transform, &Projection),
583        (Changed<Transform>, With<Camera>, Without<MirrorCamera>),
584    >,
585    mut mirror_cameras_query: Query<
586        (&mut Transform, &mut Projection),
587        (With<Camera>, With<MirrorCamera>, Without<Mirror>),
588    >,
589    mirrors_query: Query<&Transform, (Without<MirrorCamera>, With<Mirror>)>,
590) {
591    let Some((main_camera_transform, Projection::Perspective(main_camera_projection))) =
592        main_cameras_query.iter().next()
593    else {
594        return;
595    };
596
597    let Some(mirror_transform) = mirrors_query.iter().next() else {
598        return;
599    };
600
601    // Here we need the transforms of both the camera and the mirror in order to
602    // properly calculate the new projection.
603    let (new_mirror_camera_transform, new_mirror_camera_projection) =
604        calculate_mirror_camera_transform_and_projection(
605            main_camera_transform,
606            main_camera_projection,
607            mirror_transform,
608        );
609
610    for (mut mirror_camera_transform, mut mirror_camera_projection) in &mut mirror_cameras_query {
611        *mirror_camera_transform = new_mirror_camera_transform;
612        *mirror_camera_projection = Projection::Perspective(new_mirror_camera_projection.clone());
613    }
614}
615
616/// Plays the initial animation on the fox model.
617fn play_fox_animation(
618    mut commands: Commands,
619    mut animation_players_query: Query<
620        (Entity, &mut AnimationPlayer),
621        Without<AnimationGraphHandle>,
622    >,
623    asset_server: Res<AssetServer>,
624    mut animation_graphs: ResMut<Assets<AnimationGraph>>,
625) {
626    // Only pick up animation players that don't already have an animation graph
627    // handle.
628    // This ensures that we only start playing the animation once.
629    if animation_players_query.is_empty() {
630        return;
631    }
632
633    let fox_animation = asset_server.load(GltfAssetLabel::Animation(0).from_asset(FOX_ASSET_PATH));
634    let (fox_animation_graph, fox_animation_node) =
635        AnimationGraph::from_clip(fox_animation.clone());
636    let fox_animation_graph = animation_graphs.add(fox_animation_graph);
637
638    for (entity, mut animation_player) in animation_players_query.iter_mut() {
639        commands
640            .entity(entity)
641            .insert(AnimationGraphHandle(fox_animation_graph.clone()));
642        animation_player.play(fox_animation_node).repeat();
643    }
644}
645
646/// Spawns the help text at the top of the screen.
647fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) {
648    commands.spawn((
649        Text::new(create_help_string(app_status)),
650        Node {
651            position_type: PositionType::Absolute,
652            top: px(12),
653            left: px(12),
654            ..default()
655        },
656        HelpText,
657    ));
658}
659
660/// Creates the help string at the top left of the screen.
661fn create_help_string(app_status: &AppStatus) -> String {
662    format!(
663        "Click and drag to move the {}",
664        match app_status.drag_action {
665            DragAction::MoveCamera => "camera",
666            DragAction::MoveFox => "fox",
667        }
668    )
669}
670
671/// Updates the help text in the top left of the screen to reflect the current
672/// drag mode.
673fn update_help_text(mut help_text: Query<&mut Text, With<HelpText>>, app_status: Res<AppStatus>) {
674    for mut text in &mut help_text {
675        text.0 = create_help_string(&app_status);
676    }
677}