Skip to main content

bevy_react/
surface.rs

1//! The `surface` host element: the **inverse** of [`crate::portal`]. Where a
2//! portal draws an offscreen Bevy camera *into* the React UI, a surface renders a
3//! React UI subtree *out* into an offscreen [`Image`] the app can drape over any
4//! 3D mesh/material (a diegetic monitor, a control panel, a curved hologram — with
5//! the app's own shader on top).
6//!
7//! ## Ownership split
8//!
9//! The consuming app owns the **surface registry** ([`Surfaces`]): it
10//! [`create`](Surfaces::create)s a named surface at a fixed pixel resolution and
11//! gets back a [`Handle<Image>`] to use as a material texture. React references the
12//! same name with `<surface name={…}>…</surface>`; the bevy-react reconciler spawns
13//! that subtree as a **detached UI root** carrying [`RSurface`], and
14//! [`bind_surfaces`] spawns a dedicated 2D UI camera that draws the subtree into the
15//! registered image (via [`UiTargetCamera`]). An unregistered name renders nowhere
16//! until the app registers it.
17//!
18//! ## Render model
19//!
20//! Each surface is [`RenderMode::Live`] (the UI camera renders every frame — the
21//! default, correct for animated/interactive UI) or [`RenderMode::Snapshot`]
22//! (renders once on register/[`invalidate`](Surfaces::invalidate), then freezes —
23//! cheap for static panels). [`drive_surfaces`] toggles `Camera::is_active`.
24//!
25//! ## Interaction
26//!
27//! A surface is **clickable in-world**: tag the mesh that displays the texture with
28//! [`SurfacePointer`] (the mesh needs UVs). [`drive_surface_pointer`] ray-casts the
29//! main camera through the cursor, reads the hit **UV**, and drives a single virtual
30//! [`PointerId::Custom`] pointer parked on the surface's image render target. Bevy's
31//! UI picking backend then hit-tests the offscreen subtree and fires the usual
32//! `Pointer<…>` events on the React nodes — which bevy-react's core turns back into
33//! `onClick`/`onPointer*` calls. The virtual pointer's id is published in
34//! [`SurfaceVirtualPointer`] so the core crate can scope its event collection to it.
35
36use bevy::camera::{ImageRenderTarget, NormalizedRenderTarget, RenderTarget as BevyRenderTarget};
37use bevy::mesh::{Indices, VertexAttributeValues};
38use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayMeshHit};
39use bevy::picking::pointer::{Location, PointerAction, PointerButton, PointerId, PointerInput};
40use bevy::platform::collections::HashMap;
41use bevy::prelude::*;
42use bevy::render::render_resource::TextureFormat;
43
44/// Which of a mesh's UV sets a surface texture is mapped to. Re-exported from
45/// `bevy_mesh` so apps pass the same value to the material's `*_channel` fields and
46/// to [`SurfacePointer`].
47pub use bevy::mesh::UvChannel;
48
49/// Largest surface dimension we allocate, in pixels — a guard against a typo
50/// asking for an enormous texture.
51const MAX_DIM: u32 = 4096;
52
53/// The fixed id of the single virtual pointer that drives in-world surface clicks.
54/// Stable so the core crate (and tests) can recognize surface-originated picking
55/// events without sharing state.
56const SURFACE_POINTER_UUID: uuid::Uuid =
57    uuid::Uuid::from_u128(0xB5_2E_5F_AC_E0_00_00_00_00_00_00_00_00_00_01);
58
59/// How often a surface's UI camera renders into its texture.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum RenderMode {
62    /// The camera renders every frame (animated or interactive UI — the default).
63    Live,
64    /// The camera renders once when the surface is registered or
65    /// [`invalidate`](Surfaces::invalidate)d, then freezes (static panels).
66    Snapshot,
67}
68
69/// Parameters for [`Surfaces::create`].
70#[derive(Clone, Copy, Debug)]
71pub struct SurfaceSpec {
72    /// The texture resolution in pixels. The UI subtree lays out in this space.
73    pub size: UVec2,
74    /// The color the UI camera clears the texture to before drawing the subtree.
75    /// Opaque (`Color::BLACK`) by default — a screen; use a translucent/`NONE`
76    /// color for a decal that should show the surface behind transparent UI.
77    pub clear_color: Color,
78    /// Render model (default [`RenderMode::Live`]).
79    pub mode: RenderMode,
80}
81
82impl Default for SurfaceSpec {
83    fn default() -> Self {
84        Self {
85            size: UVec2::new(512, 512),
86            clear_color: Color::BLACK,
87            mode: RenderMode::Live,
88        }
89    }
90}
91
92/// One registered surface.
93struct Entry {
94    handle: Handle<Image>,
95    size: UVec2,
96    clear_color: Color,
97    mode: RenderMode,
98    /// The UI camera drawing this surface, spawned lazily by [`bind_surfaces`].
99    camera: Option<Entity>,
100    /// Set when the texture should (re)render: on create, on
101    /// [`invalidate`](Surfaces::invalidate), or on a `Live → Snapshot` switch.
102    dirty: bool,
103}
104
105/// The registry of named UI surfaces. Insert it (the plugin does) and have app
106/// systems [`create`](Self::create) surfaces, then use the returned handle as a
107/// material texture.
108#[derive(Resource, Default)]
109pub struct Surfaces {
110    entries: HashMap<String, Entry>,
111}
112
113impl Surfaces {
114    /// Allocate an offscreen texture and register it under `name`, returning the
115    /// [`Handle<Image>`] to use as a material texture. React's `<surface
116    /// name={name}>` renders its subtree into it. Re-creating an existing name
117    /// replaces the texture (the old camera is dropped on the next bind).
118    pub fn create(
119        &mut self,
120        images: &mut Assets<Image>,
121        name: impl Into<String>,
122        spec: SurfaceSpec,
123    ) -> Handle<Image> {
124        let size = spec.size.max(UVec2::ONE).min(UVec2::splat(MAX_DIM));
125        let image = Image::new_target_texture(size.x, size.y, TextureFormat::Rgba8UnormSrgb, None);
126        let handle = images.add(image);
127        self.entries.insert(
128            name.into(),
129            Entry {
130                handle: handle.clone(),
131                size,
132                clear_color: spec.clear_color,
133                mode: spec.mode,
134                camera: None,
135                dirty: true,
136            },
137        );
138        handle
139    }
140
141    /// The backing texture handle for `name`, if registered.
142    pub fn get(&self, name: &str) -> Option<Handle<Image>> {
143        self.entries.get(name).map(|e| e.handle.clone())
144    }
145
146    /// Mark a surface for one more render (a [`RenderMode::Snapshot`] re-captures;
147    /// a [`RenderMode::Live`] surface renders every frame anyway).
148    pub fn invalidate(&mut self, name: &str) {
149        if let Some(e) = self.entries.get_mut(name) {
150            e.dirty = true;
151        }
152    }
153
154    /// Switch a surface's render model at runtime.
155    pub fn set_mode(&mut self, name: &str, mode: RenderMode) {
156        if let Some(e) = self.entries.get_mut(name) {
157            if e.mode != mode {
158                e.dirty = true;
159            }
160            e.mode = mode;
161        }
162    }
163
164    /// Drop a surface. Its UI camera is despawned on the next [`bind_surfaces`];
165    /// React `<surface>` roots bound to the name hide until it is re-registered.
166    pub fn remove(&mut self, name: &str) {
167        self.entries.remove(name);
168    }
169}
170
171/// Marks a reconciler node as a `<surface name=…>` detached UI root. The
172/// bevy-react reconciler inserts it (and keeps the node out of the on-screen
173/// layout); [`bind_surfaces`] points its [`UiTargetCamera`] at the surface's
174/// offscreen UI camera.
175#[derive(Component, Clone, Debug)]
176pub struct RSurface(pub String);
177
178/// Marks the offscreen 2D UI camera [`bind_surfaces`] spawns for a named surface,
179/// so [`drive_surfaces`] can control its activity for [`RenderMode::Snapshot`].
180#[derive(Component, Clone, Debug)]
181pub struct SurfaceCamera(pub String);
182
183/// App-facing marker: put this on the 3D entity (mesh) that displays a surface's
184/// texture, naming the surface it shows. [`drive_surface_pointer`] ray-casts these
185/// meshes so clicking them drives the surface's React UI. The mesh must have UVs.
186///
187/// [`uv_channel`](Self::uv_channel) selects which of the mesh's UV sets the surface
188/// texture is mapped to — picking reads the in-world hit's UV from the **same**
189/// channel so clicks land on the right pixel. Set it to match the `*_channel` you
190/// bound the surface texture to on the material (e.g. a dedicated [`UvChannel::Uv1`]
191/// for the UI, leaving [`UvChannel::Uv0`] for the model's own maps). Defaults to
192/// [`UvChannel::Uv0`].
193#[derive(Component, Clone, Debug)]
194pub struct SurfacePointer {
195    /// The surface (registry key) whose texture this mesh displays.
196    pub surface: String,
197    /// Which mesh UV set the surface texture (and picking) uses.
198    pub uv_channel: UvChannel,
199}
200
201impl SurfacePointer {
202    /// Mark a mesh as displaying `surface`, with the UI on [`UvChannel::Uv0`].
203    pub fn new(surface: impl Into<String>) -> Self {
204        Self {
205            surface: surface.into(),
206            uv_channel: UvChannel::Uv0,
207        }
208    }
209
210    /// Map the surface texture (and picking) to a specific UV set.
211    pub fn with_uv_channel(mut self, channel: UvChannel) -> Self {
212        self.uv_channel = channel;
213        self
214    }
215}
216
217/// Holds the id of the single virtual pointer driving in-world surface clicks, plus
218/// the small bit of frame-to-frame state the driver needs. Published so the core
219/// crate can recognize (and scope to) surface-originated picking events.
220#[derive(Resource)]
221pub struct SurfaceVirtualPointer {
222    /// The custom pointer id. Picking events carrying this id originated from a
223    /// surface mesh hit.
224    pub id: PointerId,
225    /// Last UV-derived position (texture pixels) we drove the pointer to.
226    last_pos: Vec2,
227    /// The image render target the pointer currently sits on (the surface under the
228    /// cursor), so we can move it off-bounds to generate `Out`/release when the
229    /// cursor leaves every surface mesh.
230    over_target: Option<Handle<Image>>,
231    /// Per-button "we have emitted a press the matching release is still owed
232    /// for", indexed by [`button_index`].
233    pressed: [bool; FORWARDED_BUTTONS.len()],
234}
235
236/// The mouse buttons forwarded to the virtual pointer, with their picking
237/// analogues — the same left/middle/right set bevy_picking itself forwards for
238/// the window pointer (Back/Forward/Other are ignored there too).
239const FORWARDED_BUTTONS: [(MouseButton, PointerButton); 3] = [
240    (MouseButton::Left, PointerButton::Primary),
241    (MouseButton::Right, PointerButton::Secondary),
242    (MouseButton::Middle, PointerButton::Middle),
243];
244
245/// Index of a forwarded button in [`SurfaceVirtualPointer::pressed`].
246fn button_index(button: PointerButton) -> usize {
247    match button {
248        PointerButton::Primary => 0,
249        PointerButton::Secondary => 1,
250        PointerButton::Middle => 2,
251    }
252}
253
254/// Spawn the virtual surface pointer at startup and publish its id.
255pub fn init_surface_pointer(mut commands: Commands) {
256    let id = PointerId::Custom(SURFACE_POINTER_UUID);
257    // `PointerId` requires `PointerLocation`/`PointerPress`/`PointerInteraction`,
258    // which are added automatically.
259    commands.spawn(id);
260    commands.insert_resource(SurfaceVirtualPointer {
261        id,
262        last_pos: Vec2::ZERO,
263        over_target: None,
264        pressed: [false; FORWARDED_BUTTONS.len()],
265    });
266}
267
268/// Spawn a UI camera for each registered surface, then bind every `<surface>` root
269/// to its camera (and hide roots whose name isn't registered, so they never spill
270/// onto the main screen). Runs after the reconciler op drain so a freshly-mounted
271/// surface binds the same frame.
272pub fn bind_surfaces(
273    mut commands: Commands,
274    mut surfaces: ResMut<Surfaces>,
275    mut roots: Query<(Entity, &RSurface, Option<&UiTargetCamera>, &mut Visibility)>,
276) {
277    // 1. Ensure each registered surface has a UI camera rendering into its image.
278    for (name, entry) in surfaces.entries.iter_mut() {
279        if entry.camera.is_some() {
280            continue;
281        }
282        let camera = commands
283            .spawn((
284                Camera2d,
285                Camera {
286                    clear_color: ClearColorConfig::Custom(entry.clear_color),
287                    // Render the surface into its texture before the main camera
288                    // (order 0) samples it, so the screen is never a frame stale.
289                    order: -1,
290                    ..default()
291                },
292                BevyRenderTarget::Image(ImageRenderTarget {
293                    handle: entry.handle.clone(),
294                    scale_factor: 1.0,
295                }),
296                SurfaceCamera(name.clone()),
297            ))
298            .id();
299        entry.camera = Some(camera);
300        entry.dirty = true;
301    }
302
303    // 2. Bind each root to its surface camera; hide unregistered ones.
304    for (entity, surface, target_cam, mut visibility) in &mut roots {
305        match surfaces.entries.get(&surface.0).and_then(|e| e.camera) {
306            Some(camera) => {
307                if target_cam.map(|t| t.0) != Some(camera) {
308                    commands.entity(entity).insert(UiTargetCamera(camera));
309                }
310                if *visibility != Visibility::Inherited {
311                    *visibility = Visibility::Inherited;
312                }
313            }
314            None => {
315                if target_cam.is_some() {
316                    commands.entity(entity).remove::<UiTargetCamera>();
317                }
318                if *visibility != Visibility::Hidden {
319                    *visibility = Visibility::Hidden;
320                }
321            }
322        }
323    }
324}
325
326/// Toggle each surface camera's activity: always on for [`RenderMode::Live`]; on
327/// for one frame after a dirty [`RenderMode::Snapshot`], then off. Mirrors the
328/// portal crate's `drive_render_targets`. Also despawns the camera of a surface
329/// that has been [`remove`](Surfaces::remove)d, so a torn-down surface (e.g. on a
330/// scene switch) leaves no orphan camera rendering into a freed texture.
331pub fn drive_surfaces(
332    mut commands: Commands,
333    mut surfaces: ResMut<Surfaces>,
334    mut cameras: Query<(Entity, &SurfaceCamera, &mut Camera)>,
335) {
336    for (entity, cam, mut camera) in &mut cameras {
337        let Some(entry) = surfaces.entries.get_mut(&cam.0) else {
338            commands.entity(entity).despawn();
339            continue;
340        };
341        // A re-created surface allocates a fresh camera; retire the stale one whose
342        // image no longer matches the registry entry.
343        if entry.camera != Some(entity) {
344            commands.entity(entity).despawn();
345            continue;
346        }
347        match entry.mode {
348            RenderMode::Live => {
349                if !camera.is_active {
350                    camera.is_active = true;
351                }
352            }
353            RenderMode::Snapshot => {
354                let active = entry.dirty;
355                if camera.is_active != active {
356                    camera.is_active = active;
357                }
358                entry.dirty = false;
359            }
360        }
361    }
362}
363
364/// Ray-cast the main camera through the cursor at the [`SurfacePointer`] meshes and
365/// drive the virtual pointer to the hit UV (mapped into the surface's texture
366/// pixels), so Bevy's UI picking backend hit-tests the offscreen subtree. Emits
367/// `PointerInput` move/press/release events for the [`SurfaceVirtualPointer`].
368///
369/// Scheduled before `bevy_picking`'s input processing so the pointer's new location
370/// is consumed the same frame.
371#[allow(clippy::too_many_arguments)]
372pub fn drive_surface_pointer(
373    surfaces: Res<Surfaces>,
374    mut state: ResMut<SurfaceVirtualPointer>,
375    windows: Query<&Window>,
376    cameras: Query<(&Camera, &BevyRenderTarget, &GlobalTransform)>,
377    pointer_meshes: Query<&SurfacePointer>,
378    mesh3ds: Query<&Mesh3d>,
379    meshes: Res<Assets<Mesh>>,
380    buttons: Res<ButtonInput<MouseButton>>,
381    mut ray_cast: MeshRayCast,
382    mut input: MessageWriter<PointerInput>,
383) {
384    let pointer_id = state.id;
385
386    // Nearest `SurfacePointer` mesh under the cursor (cloned out of the cast borrow).
387    let hit = cursor_ray(&windows, &cameras).and_then(|ray| {
388        let filter = |entity: Entity| pointer_meshes.contains(entity);
389        let settings = MeshRayCastSettings::default().with_filter(&filter);
390        ray_cast
391            .cast_ray(ray, &settings)
392            .first()
393            .map(|(entity, hit)| (*entity, hit.clone()))
394    });
395
396    if let Some((entity, hit)) = hit
397        && let Ok(pointer) = pointer_meshes.get(entity)
398        && let Some(handle) = surfaces.get(&pointer.surface)
399        && let Some(size) = surfaces.entries.get(&pointer.surface).map(|e| e.size)
400        && let Some(uv) = hit_uv(&pointer.uv_channel, &hit, entity, &mesh3ds, &meshes)
401    {
402        // UV (0,0)=top-left of the texture, matching the UI's pixel origin.
403        let position = Vec2::new(uv.x * size.x as f32, uv.y * size.y as f32);
404        let location = image_location(&handle, position);
405        let delta = position - state.last_pos;
406        input.write(PointerInput::new(
407            pointer_id,
408            location.clone(),
409            PointerAction::Move { delta },
410        ));
411        state.last_pos = position;
412        state.over_target = Some(handle);
413
414        for (mb, pb) in FORWARDED_BUTTONS {
415            if buttons.just_pressed(mb) {
416                input.write(PointerInput::new(
417                    pointer_id,
418                    location.clone(),
419                    PointerAction::Press(pb),
420                ));
421                state.pressed[button_index(pb)] = true;
422            }
423            if buttons.just_released(mb) && state.pressed[button_index(pb)] {
424                input.write(PointerInput::new(
425                    pointer_id,
426                    location.clone(),
427                    PointerAction::Release(pb),
428                ));
429                state.pressed[button_index(pb)] = false;
430            }
431        }
432        return;
433    }
434
435    // No surface under the cursor: move the pointer off-bounds so picking fires an
436    // `Out`, and release every press we still owe so a control never sticks.
437    if let Some(handle) = state.over_target.clone() {
438        let location = image_location(&handle, Vec2::splat(-1.0));
439        for (_, pb) in FORWARDED_BUTTONS {
440            if state.pressed[button_index(pb)] {
441                input.write(PointerInput::new(
442                    pointer_id,
443                    location.clone(),
444                    PointerAction::Release(pb),
445                ));
446                state.pressed[button_index(pb)] = false;
447            }
448        }
449        input.write(PointerInput::new(
450            pointer_id,
451            location,
452            PointerAction::Move { delta: Vec2::ZERO },
453        ));
454        state.over_target = None;
455    }
456}
457
458/// The surface-texture UV at a ray hit, read from the pointer's chosen UV channel.
459/// [`UvChannel::Uv0`] uses Bevy's precomputed [`RayMeshHit::uv`]; [`UvChannel::Uv1`]
460/// interpolates the mesh's `ATTRIBUTE_UV_1` at the hit triangle — mirroring Bevy's own
461/// `UV0` interpolation (`barycentric_coords` is already `(w,u,v)`; the triangle's three
462/// vertices are `indices[3*triangle_index + k]`).
463fn hit_uv(
464    channel: &UvChannel,
465    hit: &RayMeshHit,
466    entity: Entity,
467    mesh3ds: &Query<&Mesh3d>,
468    meshes: &Assets<Mesh>,
469) -> Option<Vec2> {
470    match channel {
471        UvChannel::Uv0 => hit.uv,
472        UvChannel::Uv1 => {
473            let mesh = meshes.get(&mesh3ds.get(entity).ok()?.0)?;
474            let VertexAttributeValues::Float32x2(uvs) = mesh.attribute(Mesh::ATTRIBUTE_UV_1)?
475            else {
476                return None;
477            };
478            let base = hit.triangle_index? * 3;
479            let vertex = |k: usize| -> Option<usize> {
480                Some(match mesh.indices() {
481                    Some(Indices::U16(v)) => *v.get(base + k)? as usize,
482                    Some(Indices::U32(v)) => *v.get(base + k)? as usize,
483                    None => base + k,
484                })
485            };
486            let uv = |k: usize| -> Option<Vec2> { uvs.get(vertex(k)?).map(|&p| Vec2::from(p)) };
487            let bc = hit.barycentric_coords;
488            Some(bc.x * uv(0)? + bc.y * uv(1)? + bc.z * uv(2)?)
489        }
490    }
491}
492
493/// A pointer [`Location`] on a surface's image render target at `position` pixels.
494fn image_location(handle: &Handle<Image>, position: Vec2) -> Location {
495    Location {
496        target: NormalizedRenderTarget::Image(ImageRenderTarget {
497            handle: handle.clone(),
498            scale_factor: 1.0,
499        }),
500        position,
501    }
502}
503
504/// A world-space ray from the active window camera through the cursor, if any.
505fn cursor_ray(
506    windows: &Query<&Window>,
507    cameras: &Query<(&Camera, &BevyRenderTarget, &GlobalTransform)>,
508) -> Option<Ray3d> {
509    let cursor = windows.iter().find_map(|w| w.cursor_position())?;
510    let (camera, _, transform) = cameras
511        .iter()
512        .find(|(c, target, _)| c.is_active && matches!(target, BevyRenderTarget::Window(_)))?;
513    camera.viewport_to_world(transform, cursor).ok()
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn test_app() -> App {
521        let mut app = App::new();
522        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
523        app.init_asset::<Image>();
524        app.init_resource::<Surfaces>();
525        app
526    }
527
528    /// `create` registers a surface and `get` returns its handle; `remove` drops it.
529    #[test]
530    fn create_get_remove() {
531        let mut app = test_app();
532        let handle = app
533            .world_mut()
534            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
535                let mut images = world.resource_mut::<Assets<Image>>();
536                surfaces.create(&mut images, "monitor", SurfaceSpec::default())
537            });
538        assert_eq!(
539            app.world().resource::<Surfaces>().get("monitor"),
540            Some(handle)
541        );
542        assert_eq!(app.world().resource::<Surfaces>().get("nope"), None);
543
544        app.world_mut().resource_mut::<Surfaces>().remove("monitor");
545        assert_eq!(app.world().resource::<Surfaces>().get("monitor"), None);
546    }
547
548    /// `set_mode`/`invalidate` mark dirty only when they should.
549    #[test]
550    fn set_mode_and_invalidate_mark_dirty() {
551        let mut app = test_app();
552        app.world_mut()
553            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
554                let mut images = world.resource_mut::<Assets<Image>>();
555                surfaces.create(
556                    &mut images,
557                    "monitor",
558                    SurfaceSpec {
559                        mode: RenderMode::Live,
560                        ..default()
561                    },
562                );
563            });
564
565        let mut surfaces = app.world_mut().resource_mut::<Surfaces>();
566        surfaces.entries.get_mut("monitor").unwrap().dirty = false;
567        surfaces.set_mode("monitor", RenderMode::Live); // no change
568        assert!(!surfaces.entries["monitor"].dirty);
569        surfaces.set_mode("monitor", RenderMode::Snapshot); // change → dirty
570        assert!(surfaces.entries["monitor"].dirty);
571        surfaces.entries.get_mut("monitor").unwrap().dirty = false;
572        surfaces.invalidate("monitor");
573        assert!(surfaces.entries["monitor"].dirty);
574    }
575
576    /// `bind_surfaces` spawns a camera for a registered surface and binds the root
577    /// to it (and shows it); an unregistered root is hidden with no camera.
578    #[test]
579    fn bind_surfaces_binds_registered_and_hides_unregistered() {
580        let mut app = test_app();
581        app.add_systems(Update, bind_surfaces);
582
583        // A root for a surface that isn't registered yet.
584        let root = app
585            .world_mut()
586            .spawn((RSurface("monitor".into()), Visibility::Inherited))
587            .id();
588        app.update();
589        assert!(app.world().entity(root).get::<UiTargetCamera>().is_none());
590        assert_eq!(
591            app.world().entity(root).get::<Visibility>().copied(),
592            Some(Visibility::Hidden),
593            "an unregistered surface root is hidden"
594        );
595
596        // Register it → next bind spawns a camera and binds + shows the root.
597        app.world_mut()
598            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
599                let mut images = world.resource_mut::<Assets<Image>>();
600                surfaces.create(&mut images, "monitor", SurfaceSpec::default());
601            });
602        app.update();
603        let cam = app
604            .world()
605            .entity(root)
606            .get::<UiTargetCamera>()
607            .map(|t| t.0)
608            .expect("root binds to its surface camera");
609        assert!(
610            app.world().entity(cam).get::<SurfaceCamera>().is_some(),
611            "the bound camera is a surface camera"
612        );
613        assert_eq!(
614            app.world().entity(root).get::<Visibility>().copied(),
615            Some(Visibility::Inherited),
616            "a bound surface root is shown"
617        );
618    }
619}