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        // A zero-delta move carries no information (picking drops it before any
407        // `Pointer<Move>`/`Drag` dispatch) — unless the target image changed,
408        // where the move is what retargets `PointerLocation` to the new surface.
409        if delta != Vec2::ZERO || state.over_target.as_ref() != Some(&handle) {
410            input.write(PointerInput::new(
411                pointer_id,
412                location.clone(),
413                PointerAction::Move { delta },
414            ));
415        }
416        state.last_pos = position;
417        state.over_target = Some(handle);
418
419        for (mb, pb) in FORWARDED_BUTTONS {
420            if buttons.just_pressed(mb) {
421                input.write(PointerInput::new(
422                    pointer_id,
423                    location.clone(),
424                    PointerAction::Press(pb),
425                ));
426                state.pressed[button_index(pb)] = true;
427            }
428            if buttons.just_released(mb) && state.pressed[button_index(pb)] {
429                input.write(PointerInput::new(
430                    pointer_id,
431                    location.clone(),
432                    PointerAction::Release(pb),
433                ));
434                state.pressed[button_index(pb)] = false;
435            }
436        }
437        return;
438    }
439
440    // No surface under the cursor: move the pointer off-bounds so picking fires an
441    // `Out`, and release every press we still owe so a control never sticks.
442    if let Some(handle) = state.over_target.clone() {
443        let location = image_location(&handle, Vec2::splat(-1.0));
444        for (_, pb) in FORWARDED_BUTTONS {
445            if state.pressed[button_index(pb)] {
446                input.write(PointerInput::new(
447                    pointer_id,
448                    location.clone(),
449                    PointerAction::Release(pb),
450                ));
451                state.pressed[button_index(pb)] = false;
452            }
453        }
454        input.write(PointerInput::new(
455            pointer_id,
456            location,
457            PointerAction::Move { delta: Vec2::ZERO },
458        ));
459        state.over_target = None;
460    }
461}
462
463/// The surface-texture UV at a ray hit, read from the pointer's chosen UV channel.
464/// [`UvChannel::Uv0`] uses Bevy's precomputed [`RayMeshHit::uv`]; [`UvChannel::Uv1`]
465/// interpolates the mesh's `ATTRIBUTE_UV_1` at the hit triangle — mirroring Bevy's own
466/// `UV0` interpolation (`barycentric_coords` is already `(w,u,v)`; the triangle's three
467/// vertices are `indices[3*triangle_index + k]`).
468fn hit_uv(
469    channel: &UvChannel,
470    hit: &RayMeshHit,
471    entity: Entity,
472    mesh3ds: &Query<&Mesh3d>,
473    meshes: &Assets<Mesh>,
474) -> Option<Vec2> {
475    match channel {
476        UvChannel::Uv0 => hit.uv,
477        UvChannel::Uv1 => {
478            let mesh = meshes.get(&mesh3ds.get(entity).ok()?.0)?;
479            let VertexAttributeValues::Float32x2(uvs) = mesh.attribute(Mesh::ATTRIBUTE_UV_1)?
480            else {
481                return None;
482            };
483            let base = hit.triangle_index? * 3;
484            let vertex = |k: usize| -> Option<usize> {
485                Some(match mesh.indices() {
486                    Some(Indices::U16(v)) => *v.get(base + k)? as usize,
487                    Some(Indices::U32(v)) => *v.get(base + k)? as usize,
488                    None => base + k,
489                })
490            };
491            let uv = |k: usize| -> Option<Vec2> { uvs.get(vertex(k)?).map(|&p| Vec2::from(p)) };
492            let bc = hit.barycentric_coords;
493            Some(bc.x * uv(0)? + bc.y * uv(1)? + bc.z * uv(2)?)
494        }
495    }
496}
497
498/// A pointer [`Location`] on a surface's image render target at `position` pixels.
499fn image_location(handle: &Handle<Image>, position: Vec2) -> Location {
500    Location {
501        target: NormalizedRenderTarget::Image(ImageRenderTarget {
502            handle: handle.clone(),
503            scale_factor: 1.0,
504        }),
505        position,
506    }
507}
508
509/// A world-space ray from the active window camera through the cursor, if any.
510fn cursor_ray(
511    windows: &Query<&Window>,
512    cameras: &Query<(&Camera, &BevyRenderTarget, &GlobalTransform)>,
513) -> Option<Ray3d> {
514    let cursor = windows.iter().find_map(|w| w.cursor_position())?;
515    let (camera, _, transform) = cameras
516        .iter()
517        .find(|(c, target, _)| c.is_active && matches!(target, BevyRenderTarget::Window(_)))?;
518    camera.viewport_to_world(transform, cursor).ok()
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn test_app() -> App {
526        let mut app = App::new();
527        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
528        app.init_asset::<Image>();
529        app.init_resource::<Surfaces>();
530        app
531    }
532
533    /// `create` registers a surface and `get` returns its handle; `remove` drops it.
534    #[test]
535    fn create_get_remove() {
536        let mut app = test_app();
537        let handle = app
538            .world_mut()
539            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
540                let mut images = world.resource_mut::<Assets<Image>>();
541                surfaces.create(&mut images, "monitor", SurfaceSpec::default())
542            });
543        assert_eq!(
544            app.world().resource::<Surfaces>().get("monitor"),
545            Some(handle)
546        );
547        assert_eq!(app.world().resource::<Surfaces>().get("nope"), None);
548
549        app.world_mut().resource_mut::<Surfaces>().remove("monitor");
550        assert_eq!(app.world().resource::<Surfaces>().get("monitor"), None);
551    }
552
553    /// `set_mode`/`invalidate` mark dirty only when they should.
554    #[test]
555    fn set_mode_and_invalidate_mark_dirty() {
556        let mut app = test_app();
557        app.world_mut()
558            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
559                let mut images = world.resource_mut::<Assets<Image>>();
560                surfaces.create(
561                    &mut images,
562                    "monitor",
563                    SurfaceSpec {
564                        mode: RenderMode::Live,
565                        ..default()
566                    },
567                );
568            });
569
570        let mut surfaces = app.world_mut().resource_mut::<Surfaces>();
571        surfaces.entries.get_mut("monitor").unwrap().dirty = false;
572        surfaces.set_mode("monitor", RenderMode::Live); // no change
573        assert!(!surfaces.entries["monitor"].dirty);
574        surfaces.set_mode("monitor", RenderMode::Snapshot); // change → dirty
575        assert!(surfaces.entries["monitor"].dirty);
576        surfaces.entries.get_mut("monitor").unwrap().dirty = false;
577        surfaces.invalidate("monitor");
578        assert!(surfaces.entries["monitor"].dirty);
579    }
580
581    /// `bind_surfaces` spawns a camera for a registered surface and binds the root
582    /// to it (and shows it); an unregistered root is hidden with no camera.
583    #[test]
584    fn bind_surfaces_binds_registered_and_hides_unregistered() {
585        let mut app = test_app();
586        app.add_systems(Update, bind_surfaces);
587
588        // A root for a surface that isn't registered yet.
589        let root = app
590            .world_mut()
591            .spawn((RSurface("monitor".into()), Visibility::Inherited))
592            .id();
593        app.update();
594        assert!(app.world().entity(root).get::<UiTargetCamera>().is_none());
595        assert_eq!(
596            app.world().entity(root).get::<Visibility>().copied(),
597            Some(Visibility::Hidden),
598            "an unregistered surface root is hidden"
599        );
600
601        // Register it → next bind spawns a camera and binds + shows the root.
602        app.world_mut()
603            .resource_scope(|world, mut surfaces: Mut<Surfaces>| {
604                let mut images = world.resource_mut::<Assets<Image>>();
605                surfaces.create(&mut images, "monitor", SurfaceSpec::default());
606            });
607        app.update();
608        let cam = app
609            .world()
610            .entity(root)
611            .get::<UiTargetCamera>()
612            .map(|t| t.0)
613            .expect("root binds to its surface camera");
614        assert!(
615            app.world().entity(cam).get::<SurfaceCamera>().is_some(),
616            "the bound camera is a surface camera"
617        );
618        assert_eq!(
619            app.world().entity(root).get::<Visibility>().copied(),
620            Some(Visibility::Inherited),
621            "a bound surface root is shown"
622        );
623    }
624}