Skip to main content

bevy_react/
portal.rs

1//! The `portal` host element: a UI rectangle that displays an **offscreen render
2//! target** — the live (or snapshot) output of a Bevy camera drawing into a GPU
3//! texture.
4//!
5//! It is the GPU sibling of [`crate::canvas`]: both are a styled [`ImageNode`]
6//! whose backing [`Image`] this crate manages. Where the canvas CPU-rasterizes a
7//! display list, a portal's image is a **render target** a secondary camera draws
8//! into (render-to-texture), so a portal can embed a minimap, a picture-in-picture,
9//! or a per-item 3D preview directly inside the React UI.
10//!
11//! ## Ownership split
12//!
13//! This crate owns only the **texture registry** ([`RenderTargets`]) and the
14//! portal↔texture **binding**. The consuming app owns the cameras, meshes, and
15//! render layers: it [`create`](RenderTargets::create)s a named target, spawns a
16//! camera pointed at [`RenderTarget::camera_target`], tags that camera with
17//! [`PortalCamera`], and (for snapshots) [`invalidate`](RenderTargets::invalidate)s
18//! or [`set_mode`](RenderTargets::set_mode)s it. React never invents target names —
19//! it receives them from the app over the typed event channel and echoes them back
20//! as `<portal target={name} />`.
21//!
22//! ## Render model
23//!
24//! Each target is [`RenderMode::Live`] (its camera renders every frame — minimaps,
25//! rotating previews) or [`RenderMode::Snapshot`] (renders once when registered or
26//! invalidated, then its camera is deactivated and the texture reused — cheap for
27//! static thumbnails). [`drive_render_targets`] toggles `Camera::is_active`.
28//!
29//! ## Resolution
30//!
31//! Each target's [`Resolution`] is [`Auto`](Resolution::Auto) (the texture is sized
32//! to the binding portal's laid-out box, like the canvas — crisp output and correct
33//! camera aspect for free) or [`Fixed`](Resolution::Fixed) (a fixed cost, for a
34//! target shared by several portals).
35
36use bevy::camera::{ImageRenderTarget, RenderTarget as BevyRenderTarget};
37use bevy::image::Image;
38use bevy::platform::collections::HashMap;
39use bevy::prelude::*;
40use bevy::render::render_resource::{Extent3d, TextureFormat};
41use bevy::ui::ComputedNode;
42use bevy::ui::widget::ImageNode;
43
44/// Largest render-target dimension we allocate, in physical pixels — a guard
45/// against a degenerate layout asking for an enormous texture.
46const MAX_DIM: u32 = 2048;
47
48/// Quantization step for [`Resolution::Auto`] sizing, in physical pixels. The
49/// texture is sized to the next multiple of this, so small sub-pixel layout
50/// jitter during a resize doesn't reallocate the GPU texture every frame.
51const SIZE_STEP: u32 = 16;
52
53/// How often a target's camera renders into its texture.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum RenderMode {
56    /// The camera renders every frame (minimaps, rotating/animated previews).
57    Live,
58    /// The camera renders once when the target is registered or
59    /// [`invalidate`](RenderTargets::invalidate)d, then deactivates and the
60    /// texture is reused (static thumbnails — cheap for many slots).
61    Snapshot,
62}
63
64/// How a target's texture resolution is chosen.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum Resolution {
67    /// Track the binding portal's laid-out physical size (crisp + correct aspect).
68    /// Assumes one portal per target; with several, the last to bind wins.
69    Auto,
70    /// A fixed texture size, regardless of how the portal is laid out.
71    Fixed(UVec2),
72}
73
74/// Parameters for [`RenderTargets::create`].
75#[derive(Clone, Copy, Debug)]
76pub struct RenderTargetSpec {
77    /// Resolution policy (default [`Resolution::Auto`]).
78    pub size: Resolution,
79    /// Render model (default [`RenderMode::Live`]).
80    pub mode: RenderMode,
81    /// Texture format. Default [`TextureFormat::Rgba8UnormSrgb`] — display-ready
82    /// for a UI thumbnail. Use an HDR format (e.g. `Rgba16Float`) if the camera
83    /// needs bloom/HDR before tonemapping.
84    pub format: TextureFormat,
85}
86
87impl Default for RenderTargetSpec {
88    fn default() -> Self {
89        Self {
90            size: Resolution::Auto,
91            mode: RenderMode::Live,
92            format: TextureFormat::Rgba8UnormSrgb,
93        }
94    }
95}
96
97/// A handle to a freshly [`create`](RenderTargets::create)d target. Use
98/// [`camera_target`](Self::camera_target) to point a camera at it.
99#[derive(Clone, Debug)]
100pub struct RenderTarget {
101    /// The backing render-target image (also stored in the registry).
102    pub handle: Handle<Image>,
103}
104
105impl RenderTarget {
106    /// The [`bevy::render::camera::RenderTarget`] to set on a camera's `target`
107    /// so it renders into this texture.
108    pub fn camera_target(&self) -> BevyRenderTarget {
109        BevyRenderTarget::Image(ImageRenderTarget {
110            handle: self.handle.clone(),
111            scale_factor: 1.0,
112        })
113    }
114}
115
116/// One registered render target.
117struct Entry {
118    handle: Handle<Image>,
119    mode: RenderMode,
120    resolution: Resolution,
121    /// The portal node currently displaying this target (for [`Resolution::Auto`]).
122    binder: Option<Entity>,
123    /// Set when the texture should (re)render: on create, on resize, on
124    /// [`invalidate`](RenderTargets::invalidate), or on a `Live → Snapshot` switch.
125    dirty: bool,
126    /// Last physical size we sized an [`Resolution::Auto`] texture to.
127    last_size: UVec2,
128}
129
130/// The registry of named offscreen render targets. Insert it (the plugin does)
131/// and have app systems [`create`](Self::create) targets as game state demands.
132#[derive(Resource, Default)]
133pub struct RenderTargets {
134    entries: HashMap<String, Entry>,
135}
136
137impl RenderTargets {
138    /// Allocate a render-target texture and register it under `name`, returning a
139    /// [`RenderTarget`] whose [`camera_target`](RenderTarget::camera_target) a
140    /// camera should point at. Re-creating an existing name replaces it.
141    pub fn create(
142        &mut self,
143        images: &mut Assets<Image>,
144        name: impl Into<String>,
145        spec: RenderTargetSpec,
146    ) -> RenderTarget {
147        // An Auto target starts tiny; `drive_render_targets` resizes it to the
148        // portal once laid out. A Fixed target is allocated at its final size.
149        let size = match spec.size {
150            Resolution::Fixed(s) => s.max(UVec2::ONE).min(UVec2::splat(MAX_DIM)),
151            Resolution::Auto => UVec2::splat(SIZE_STEP),
152        };
153        let image = Image::new_target_texture(size.x, size.y, spec.format, None);
154        let handle = images.add(image);
155        self.entries.insert(
156            name.into(),
157            Entry {
158                handle: handle.clone(),
159                mode: spec.mode,
160                resolution: spec.size,
161                binder: None,
162                dirty: true,
163                last_size: size,
164            },
165        );
166        RenderTarget { handle }
167    }
168
169    /// Register an **app-owned texture** under `name` — a procedurally
170    /// generated, pre-rendered, or loaded `Image` the app already holds — so
171    /// a `<portal target>` or a `backgroundImage` `{ texture }` source can
172    /// display it. Static content: nothing renders into, resizes, or
173    /// invalidates it (the entry is inert to [`drive_render_targets`]); for
174    /// live/continuously-updating content use [`create`](Self::create) with a
175    /// camera — and prefer a `<portal>` on the UI side. Re-registering an
176    /// existing name replaces it.
177    pub fn register(&mut self, name: impl Into<String>, handle: Handle<Image>) {
178        self.entries.insert(
179            name.into(),
180            Entry {
181                handle,
182                mode: RenderMode::Snapshot,
183                resolution: Resolution::Fixed(UVec2::ONE),
184                binder: None,
185                dirty: false,
186                last_size: UVec2::ONE,
187            },
188        );
189    }
190
191    /// The backing texture handle for `name`, if registered.
192    pub fn get(&self, name: &str) -> Option<Handle<Image>> {
193        self.entries.get(name).map(|e| e.handle.clone())
194    }
195
196    /// Mark a target for one more render (a [`RenderMode::Snapshot`] re-captures;
197    /// a [`RenderMode::Live`] target is unaffected — it renders every frame anyway).
198    pub fn invalidate(&mut self, name: &str) {
199        if let Some(e) = self.entries.get_mut(name) {
200            e.dirty = true;
201        }
202    }
203
204    /// Switch a target's render model at runtime. `Snapshot → Live` reactivates
205    /// the camera; `Live → Snapshot` renders one last frame, then freezes.
206    pub fn set_mode(&mut self, name: &str, mode: RenderMode) {
207        if let Some(e) = self.entries.get_mut(name) {
208            if e.mode != mode {
209                e.dirty = true; // render once at the moment of the switch
210            }
211            e.mode = mode;
212        }
213    }
214
215    /// Drop a target. The app is responsible for despawning the camera/scene it
216    /// spawned for it; portals bound to the name revert to a blank placeholder.
217    pub fn remove(&mut self, name: &str) {
218        self.entries.remove(name);
219    }
220}
221
222/// Marks a camera as the renderer for a named target, so [`drive_render_targets`]
223/// can control its activity for [`RenderMode::Snapshot`]. The app inserts it on
224/// the camera it spawns for a target.
225#[derive(Component, Clone, Debug)]
226pub struct PortalCamera(pub String);
227
228/// Marks a reconciler node as a `<portal>` displaying the named target. The
229/// bevy-react reconciler inserts it; [`bind_portals`] keeps the node's
230/// [`ImageNode`] pointed at the registry's texture for this name.
231#[derive(Component, Clone, Debug)]
232pub struct RPortal(pub String);
233
234/// A shared 1×1 transparent texture a portal shows until (and after) it is bound
235/// to a live target. Held in a resource so every unbound portal shares one image.
236#[derive(Resource)]
237pub struct PortalPlaceholder(pub Handle<Image>);
238
239/// A 1×1 transparent image, mirroring [`crate::canvas::blank_canvas_image`].
240pub fn blank_portal_image() -> Image {
241    Image::new_fill(
242        Extent3d {
243            width: 1,
244            height: 1,
245            depth_or_array_layers: 1,
246        },
247        bevy::render::render_resource::TextureDimension::D2,
248        &[0, 0, 0, 0],
249        TextureFormat::Rgba8UnormSrgb,
250        bevy::asset::RenderAssetUsages::MAIN_WORLD | bevy::asset::RenderAssetUsages::RENDER_WORLD,
251    )
252}
253
254/// Create the shared [`PortalPlaceholder`] image at startup.
255pub fn init_portal_placeholder(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
256    let handle = images.add(blank_portal_image());
257    commands.insert_resource(PortalPlaceholder(handle));
258}
259
260/// Point every `<portal>`'s [`ImageNode`] at the texture for its target name (or
261/// the placeholder when the name isn't registered), and record the portal as the
262/// target's binder for [`Resolution::Auto`] sizing. Only writes `image` when it
263/// actually changes, so it doesn't needlessly re-extract the node every frame.
264///
265/// This is what decouples ordering: a portal may mount before its target exists
266/// and rebinds the instant it appears (and reverts to the placeholder on
267/// [`remove`](RenderTargets::remove)).
268pub fn bind_portals(
269    mut targets: ResMut<RenderTargets>,
270    placeholder: Res<PortalPlaceholder>,
271    mut portals: Query<(Entity, &RPortal, &mut ImageNode)>,
272) {
273    for (entity, portal, mut node) in &mut portals {
274        let desired = targets
275            .entries
276            .get(&portal.0)
277            .map(|e| e.handle.clone())
278            .unwrap_or_else(|| placeholder.0.clone());
279        if node.image != desired {
280            node.image = desired;
281        }
282        if let Some(entry) = targets.entries.get_mut(&portal.0) {
283            entry.binder = Some(entity);
284        }
285    }
286}
287
288/// Drive resolution and the snapshot lifecycle each frame:
289/// - For [`Resolution::Auto`] targets, size the texture to the binding portal's
290///   laid-out physical size (quantized to [`SIZE_STEP`]) and mark dirty on change.
291/// - For each [`PortalCamera`], set `is_active`: always on for [`RenderMode::Live`];
292///   on for one frame for a dirty [`RenderMode::Snapshot`], then off.
293pub fn drive_render_targets(
294    mut targets: ResMut<RenderTargets>,
295    mut images: ResMut<Assets<Image>>,
296    nodes: Query<&ComputedNode>,
297    mut cameras: Query<(&PortalCamera, &mut Camera)>,
298) {
299    // 1. Resolution: resize Auto textures to their binding portal.
300    for entry in targets.entries.values_mut() {
301        if entry.resolution != Resolution::Auto {
302            continue;
303        }
304        let Some(binder) = entry.binder else { continue };
305        let Ok(node) = nodes.get(binder) else {
306            continue;
307        };
308        let want = quantize_size(node.size());
309        if want.x == 0 || want.y == 0 || want == entry.last_size {
310            continue;
311        }
312        if let Some(mut image) = images.get_mut(&entry.handle) {
313            image.resize(Extent3d {
314                width: want.x,
315                height: want.y,
316                depth_or_array_layers: 1,
317            });
318            entry.last_size = want;
319            entry.dirty = true;
320        }
321    }
322
323    // 2. Snapshot lifecycle: toggle each portal camera's activity.
324    for (cam, mut camera) in &mut cameras {
325        let Some(entry) = targets.entries.get_mut(&cam.0) else {
326            continue;
327        };
328        match entry.mode {
329            RenderMode::Live => {
330                if !camera.is_active {
331                    camera.is_active = true;
332                }
333            }
334            RenderMode::Snapshot => {
335                // Active for exactly the frame we clear `dirty`, so the camera
336                // renders once; off until the next invalidate/resize.
337                let active = entry.dirty;
338                if camera.is_active != active {
339                    camera.is_active = active;
340                }
341                entry.dirty = false;
342            }
343        }
344    }
345}
346
347/// Round a laid-out physical size up to the next [`SIZE_STEP`] multiple, clamped
348/// to `[SIZE_STEP, MAX_DIM]` on each axis.
349fn quantize_size(size: Vec2) -> UVec2 {
350    let q = |v: f32| {
351        let px = v.round().max(0.0) as u32;
352        let stepped = px.div_ceil(SIZE_STEP) * SIZE_STEP;
353        stepped.clamp(SIZE_STEP, MAX_DIM)
354    };
355    UVec2::new(q(size.x), q(size.y))
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn test_app() -> App {
363        let mut app = App::new();
364        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
365        app.init_asset::<Image>();
366        app.init_resource::<RenderTargets>();
367        app
368    }
369
370    /// `create` registers a target and `get` returns its handle; `remove` drops it.
371    #[test]
372    fn create_get_remove() {
373        let mut app = test_app();
374        let handle = app
375            .world_mut()
376            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
377                let mut images = world.resource_mut::<Assets<Image>>();
378                targets
379                    .create(&mut images, "follow", RenderTargetSpec::default())
380                    .handle
381            });
382        let targets = app.world().resource::<RenderTargets>();
383        assert_eq!(targets.get("follow"), Some(handle));
384        assert_eq!(targets.get("nope"), None);
385
386        app.world_mut()
387            .resource_mut::<RenderTargets>()
388            .remove("follow");
389        assert_eq!(app.world().resource::<RenderTargets>().get("follow"), None);
390    }
391
392    /// `register` exposes an app-owned handle under a name (static content):
393    /// `get` resolves it, `drive_render_targets` leaves it untouched, and
394    /// `remove` drops it.
395    #[test]
396    fn register_app_texture_is_inert() {
397        let mut app = test_app();
398        app.add_systems(Update, drive_render_targets);
399        let handle = {
400            let mut images = app.world_mut().resource_mut::<Assets<Image>>();
401            images.add(blank_portal_image())
402        };
403        app.world_mut()
404            .resource_mut::<RenderTargets>()
405            .register("checker", handle.clone());
406        assert_eq!(
407            app.world().resource::<RenderTargets>().get("checker"),
408            Some(handle.clone())
409        );
410
411        // A camera-less, Fixed-resolution entry: the drive system must not
412        // resize or otherwise touch it.
413        app.update();
414        assert_eq!(
415            app.world().resource::<RenderTargets>().get("checker"),
416            Some(handle),
417            "the registered handle survives the drive system"
418        );
419
420        app.world_mut()
421            .resource_mut::<RenderTargets>()
422            .remove("checker");
423        assert_eq!(app.world().resource::<RenderTargets>().get("checker"), None);
424    }
425
426    /// `set_mode` flips the mode and marks dirty only when it actually changes;
427    /// `invalidate` always marks dirty.
428    #[test]
429    fn set_mode_and_invalidate_mark_dirty() {
430        let mut app = test_app();
431        app.world_mut()
432            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
433                let mut images = world.resource_mut::<Assets<Image>>();
434                targets.create(
435                    &mut images,
436                    "follow",
437                    RenderTargetSpec {
438                        mode: RenderMode::Live,
439                        ..default()
440                    },
441                );
442            });
443
444        let mut targets = app.world_mut().resource_mut::<RenderTargets>();
445        // A fresh target is dirty; clear it by pretending a render happened.
446        targets.entries.get_mut("follow").unwrap().dirty = false;
447        targets.set_mode("follow", RenderMode::Live); // no change → still clean
448        assert!(!targets.entries["follow"].dirty);
449        targets.set_mode("follow", RenderMode::Snapshot); // change → dirty
450        assert!(targets.entries["follow"].dirty);
451        assert_eq!(targets.entries["follow"].mode, RenderMode::Snapshot);
452
453        targets.entries.get_mut("follow").unwrap().dirty = false;
454        targets.invalidate("follow");
455        assert!(targets.entries["follow"].dirty);
456    }
457
458    /// `bind_portals` points an `RPortal`'s `ImageNode` at the registered texture,
459    /// records the binder, and reverts to the placeholder after the target is gone.
460    #[test]
461    fn bind_portals_binds_and_reverts() {
462        let mut app = test_app();
463        app.add_systems(Startup, init_portal_placeholder);
464        app.add_systems(Update, bind_portals);
465        app.update(); // run startup → placeholder exists
466
467        let target_handle =
468            app.world_mut()
469                .resource_scope(|world, mut targets: Mut<RenderTargets>| {
470                    let mut images = world.resource_mut::<Assets<Image>>();
471                    targets
472                        .create(&mut images, "follow", RenderTargetSpec::default())
473                        .handle
474                });
475        let placeholder = app.world().resource::<PortalPlaceholder>().0.clone();
476        let portal = app
477            .world_mut()
478            .spawn((
479                RPortal("follow".into()),
480                ImageNode::new(placeholder.clone()),
481            ))
482            .id();
483
484        app.update(); // bind_portals runs
485        assert_eq!(
486            app.world().entity(portal).get::<ImageNode>().unwrap().image,
487            target_handle,
488            "portal binds to the registered target texture"
489        );
490        assert_eq!(
491            app.world().resource::<RenderTargets>().entries["follow"].binder,
492            Some(portal),
493            "the portal is recorded as the target's binder"
494        );
495
496        app.world_mut()
497            .resource_mut::<RenderTargets>()
498            .remove("follow");
499        app.update();
500        assert_eq!(
501            app.world().entity(portal).get::<ImageNode>().unwrap().image,
502            placeholder,
503            "a removed target reverts the portal to the placeholder"
504        );
505    }
506
507    /// `drive_render_targets` renders a snapshot camera for exactly one frame after
508    /// it is created/invalidated, and keeps a live camera always active.
509    #[test]
510    fn snapshot_camera_renders_once_then_deactivates() {
511        let mut app = test_app();
512        app.add_systems(Update, drive_render_targets);
513        app.world_mut()
514            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
515                let mut images = world.resource_mut::<Assets<Image>>();
516                targets.create(
517                    &mut images,
518                    "shot",
519                    RenderTargetSpec {
520                        mode: RenderMode::Snapshot,
521                        ..default()
522                    },
523                );
524            });
525        let cam = app
526            .world_mut()
527            .spawn((PortalCamera("shot".into()), Camera::default()))
528            .id();
529
530        // Frame 1: the fresh (dirty) target activates its camera for one render.
531        app.update();
532        assert!(
533            app.world().entity(cam).get::<Camera>().unwrap().is_active,
534            "a dirty snapshot renders this frame"
535        );
536        // Frame 2: no longer dirty → camera deactivates.
537        app.update();
538        assert!(
539            !app.world().entity(cam).get::<Camera>().unwrap().is_active,
540            "a clean snapshot stops rendering"
541        );
542
543        // Invalidate → renders one more frame.
544        app.world_mut()
545            .resource_mut::<RenderTargets>()
546            .invalidate("shot");
547        app.update();
548        assert!(
549            app.world().entity(cam).get::<Camera>().unwrap().is_active,
550            "invalidate re-renders the snapshot once"
551        );
552    }
553
554    #[test]
555    fn quantize_rounds_up_to_step_and_clamps() {
556        assert_eq!(quantize_size(Vec2::new(1.0, 1.0)), UVec2::splat(SIZE_STEP));
557        assert_eq!(quantize_size(Vec2::new(17.0, 31.0)), UVec2::new(32, 32));
558        assert_eq!(quantize_size(Vec2::new(0.0, 0.0)), UVec2::splat(SIZE_STEP));
559        assert_eq!(
560            quantize_size(Vec2::new(99999.0, 10.0)),
561            UVec2::new(MAX_DIM, SIZE_STEP)
562        );
563    }
564}