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    /// The backing texture handle for `name`, if registered.
170    pub fn get(&self, name: &str) -> Option<Handle<Image>> {
171        self.entries.get(name).map(|e| e.handle.clone())
172    }
173
174    /// Mark a target for one more render (a [`RenderMode::Snapshot`] re-captures;
175    /// a [`RenderMode::Live`] target is unaffected — it renders every frame anyway).
176    pub fn invalidate(&mut self, name: &str) {
177        if let Some(e) = self.entries.get_mut(name) {
178            e.dirty = true;
179        }
180    }
181
182    /// Switch a target's render model at runtime. `Snapshot → Live` reactivates
183    /// the camera; `Live → Snapshot` renders one last frame, then freezes.
184    pub fn set_mode(&mut self, name: &str, mode: RenderMode) {
185        if let Some(e) = self.entries.get_mut(name) {
186            if e.mode != mode {
187                e.dirty = true; // render once at the moment of the switch
188            }
189            e.mode = mode;
190        }
191    }
192
193    /// Drop a target. The app is responsible for despawning the camera/scene it
194    /// spawned for it; portals bound to the name revert to a blank placeholder.
195    pub fn remove(&mut self, name: &str) {
196        self.entries.remove(name);
197    }
198}
199
200/// Marks a camera as the renderer for a named target, so [`drive_render_targets`]
201/// can control its activity for [`RenderMode::Snapshot`]. The app inserts it on
202/// the camera it spawns for a target.
203#[derive(Component, Clone, Debug)]
204pub struct PortalCamera(pub String);
205
206/// Marks a reconciler node as a `<portal>` displaying the named target. The
207/// bevy-react reconciler inserts it; [`bind_portals`] keeps the node's
208/// [`ImageNode`] pointed at the registry's texture for this name.
209#[derive(Component, Clone, Debug)]
210pub struct RPortal(pub String);
211
212/// A shared 1×1 transparent texture a portal shows until (and after) it is bound
213/// to a live target. Held in a resource so every unbound portal shares one image.
214#[derive(Resource)]
215pub struct PortalPlaceholder(pub Handle<Image>);
216
217/// A 1×1 transparent image, mirroring [`crate::canvas::blank_canvas_image`].
218pub fn blank_portal_image() -> Image {
219    Image::new_fill(
220        Extent3d {
221            width: 1,
222            height: 1,
223            depth_or_array_layers: 1,
224        },
225        bevy::render::render_resource::TextureDimension::D2,
226        &[0, 0, 0, 0],
227        TextureFormat::Rgba8UnormSrgb,
228        bevy::asset::RenderAssetUsages::MAIN_WORLD | bevy::asset::RenderAssetUsages::RENDER_WORLD,
229    )
230}
231
232/// Create the shared [`PortalPlaceholder`] image at startup.
233pub fn init_portal_placeholder(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
234    let handle = images.add(blank_portal_image());
235    commands.insert_resource(PortalPlaceholder(handle));
236}
237
238/// Point every `<portal>`'s [`ImageNode`] at the texture for its target name (or
239/// the placeholder when the name isn't registered), and record the portal as the
240/// target's binder for [`Resolution::Auto`] sizing. Only writes `image` when it
241/// actually changes, so it doesn't needlessly re-extract the node every frame.
242///
243/// This is what decouples ordering: a portal may mount before its target exists
244/// and rebinds the instant it appears (and reverts to the placeholder on
245/// [`remove`](RenderTargets::remove)).
246pub fn bind_portals(
247    mut targets: ResMut<RenderTargets>,
248    placeholder: Res<PortalPlaceholder>,
249    mut portals: Query<(Entity, &RPortal, &mut ImageNode)>,
250) {
251    for (entity, portal, mut node) in &mut portals {
252        let desired = targets
253            .entries
254            .get(&portal.0)
255            .map(|e| e.handle.clone())
256            .unwrap_or_else(|| placeholder.0.clone());
257        if node.image != desired {
258            node.image = desired;
259        }
260        if let Some(entry) = targets.entries.get_mut(&portal.0) {
261            entry.binder = Some(entity);
262        }
263    }
264}
265
266/// Drive resolution and the snapshot lifecycle each frame:
267/// - For [`Resolution::Auto`] targets, size the texture to the binding portal's
268///   laid-out physical size (quantized to [`SIZE_STEP`]) and mark dirty on change.
269/// - For each [`PortalCamera`], set `is_active`: always on for [`RenderMode::Live`];
270///   on for one frame for a dirty [`RenderMode::Snapshot`], then off.
271pub fn drive_render_targets(
272    mut targets: ResMut<RenderTargets>,
273    mut images: ResMut<Assets<Image>>,
274    nodes: Query<&ComputedNode>,
275    mut cameras: Query<(&PortalCamera, &mut Camera)>,
276) {
277    // 1. Resolution: resize Auto textures to their binding portal.
278    for entry in targets.entries.values_mut() {
279        if entry.resolution != Resolution::Auto {
280            continue;
281        }
282        let Some(binder) = entry.binder else { continue };
283        let Ok(node) = nodes.get(binder) else {
284            continue;
285        };
286        let want = quantize_size(node.size());
287        if want.x == 0 || want.y == 0 || want == entry.last_size {
288            continue;
289        }
290        if let Some(mut image) = images.get_mut(&entry.handle) {
291            image.resize(Extent3d {
292                width: want.x,
293                height: want.y,
294                depth_or_array_layers: 1,
295            });
296            entry.last_size = want;
297            entry.dirty = true;
298        }
299    }
300
301    // 2. Snapshot lifecycle: toggle each portal camera's activity.
302    for (cam, mut camera) in &mut cameras {
303        let Some(entry) = targets.entries.get_mut(&cam.0) else {
304            continue;
305        };
306        match entry.mode {
307            RenderMode::Live => {
308                if !camera.is_active {
309                    camera.is_active = true;
310                }
311            }
312            RenderMode::Snapshot => {
313                // Active for exactly the frame we clear `dirty`, so the camera
314                // renders once; off until the next invalidate/resize.
315                let active = entry.dirty;
316                if camera.is_active != active {
317                    camera.is_active = active;
318                }
319                entry.dirty = false;
320            }
321        }
322    }
323}
324
325/// Round a laid-out physical size up to the next [`SIZE_STEP`] multiple, clamped
326/// to `[SIZE_STEP, MAX_DIM]` on each axis.
327fn quantize_size(size: Vec2) -> UVec2 {
328    let q = |v: f32| {
329        let px = v.round().max(0.0) as u32;
330        let stepped = px.div_ceil(SIZE_STEP) * SIZE_STEP;
331        stepped.clamp(SIZE_STEP, MAX_DIM)
332    };
333    UVec2::new(q(size.x), q(size.y))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn test_app() -> App {
341        let mut app = App::new();
342        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
343        app.init_asset::<Image>();
344        app.init_resource::<RenderTargets>();
345        app
346    }
347
348    /// `create` registers a target and `get` returns its handle; `remove` drops it.
349    #[test]
350    fn create_get_remove() {
351        let mut app = test_app();
352        let handle = app
353            .world_mut()
354            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
355                let mut images = world.resource_mut::<Assets<Image>>();
356                targets
357                    .create(&mut images, "follow", RenderTargetSpec::default())
358                    .handle
359            });
360        let targets = app.world().resource::<RenderTargets>();
361        assert_eq!(targets.get("follow"), Some(handle));
362        assert_eq!(targets.get("nope"), None);
363
364        app.world_mut()
365            .resource_mut::<RenderTargets>()
366            .remove("follow");
367        assert_eq!(app.world().resource::<RenderTargets>().get("follow"), None);
368    }
369
370    /// `set_mode` flips the mode and marks dirty only when it actually changes;
371    /// `invalidate` always marks dirty.
372    #[test]
373    fn set_mode_and_invalidate_mark_dirty() {
374        let mut app = test_app();
375        app.world_mut()
376            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
377                let mut images = world.resource_mut::<Assets<Image>>();
378                targets.create(
379                    &mut images,
380                    "follow",
381                    RenderTargetSpec {
382                        mode: RenderMode::Live,
383                        ..default()
384                    },
385                );
386            });
387
388        let mut targets = app.world_mut().resource_mut::<RenderTargets>();
389        // A fresh target is dirty; clear it by pretending a render happened.
390        targets.entries.get_mut("follow").unwrap().dirty = false;
391        targets.set_mode("follow", RenderMode::Live); // no change → still clean
392        assert!(!targets.entries["follow"].dirty);
393        targets.set_mode("follow", RenderMode::Snapshot); // change → dirty
394        assert!(targets.entries["follow"].dirty);
395        assert_eq!(targets.entries["follow"].mode, RenderMode::Snapshot);
396
397        targets.entries.get_mut("follow").unwrap().dirty = false;
398        targets.invalidate("follow");
399        assert!(targets.entries["follow"].dirty);
400    }
401
402    /// `bind_portals` points an `RPortal`'s `ImageNode` at the registered texture,
403    /// records the binder, and reverts to the placeholder after the target is gone.
404    #[test]
405    fn bind_portals_binds_and_reverts() {
406        let mut app = test_app();
407        app.add_systems(Startup, init_portal_placeholder);
408        app.add_systems(Update, bind_portals);
409        app.update(); // run startup → placeholder exists
410
411        let target_handle =
412            app.world_mut()
413                .resource_scope(|world, mut targets: Mut<RenderTargets>| {
414                    let mut images = world.resource_mut::<Assets<Image>>();
415                    targets
416                        .create(&mut images, "follow", RenderTargetSpec::default())
417                        .handle
418                });
419        let placeholder = app.world().resource::<PortalPlaceholder>().0.clone();
420        let portal = app
421            .world_mut()
422            .spawn((
423                RPortal("follow".into()),
424                ImageNode::new(placeholder.clone()),
425            ))
426            .id();
427
428        app.update(); // bind_portals runs
429        assert_eq!(
430            app.world().entity(portal).get::<ImageNode>().unwrap().image,
431            target_handle,
432            "portal binds to the registered target texture"
433        );
434        assert_eq!(
435            app.world().resource::<RenderTargets>().entries["follow"].binder,
436            Some(portal),
437            "the portal is recorded as the target's binder"
438        );
439
440        app.world_mut()
441            .resource_mut::<RenderTargets>()
442            .remove("follow");
443        app.update();
444        assert_eq!(
445            app.world().entity(portal).get::<ImageNode>().unwrap().image,
446            placeholder,
447            "a removed target reverts the portal to the placeholder"
448        );
449    }
450
451    /// `drive_render_targets` renders a snapshot camera for exactly one frame after
452    /// it is created/invalidated, and keeps a live camera always active.
453    #[test]
454    fn snapshot_camera_renders_once_then_deactivates() {
455        let mut app = test_app();
456        app.add_systems(Update, drive_render_targets);
457        app.world_mut()
458            .resource_scope(|world, mut targets: Mut<RenderTargets>| {
459                let mut images = world.resource_mut::<Assets<Image>>();
460                targets.create(
461                    &mut images,
462                    "shot",
463                    RenderTargetSpec {
464                        mode: RenderMode::Snapshot,
465                        ..default()
466                    },
467                );
468            });
469        let cam = app
470            .world_mut()
471            .spawn((PortalCamera("shot".into()), Camera::default()))
472            .id();
473
474        // Frame 1: the fresh (dirty) target activates its camera for one render.
475        app.update();
476        assert!(
477            app.world().entity(cam).get::<Camera>().unwrap().is_active,
478            "a dirty snapshot renders this frame"
479        );
480        // Frame 2: no longer dirty → camera deactivates.
481        app.update();
482        assert!(
483            !app.world().entity(cam).get::<Camera>().unwrap().is_active,
484            "a clean snapshot stops rendering"
485        );
486
487        // Invalidate → renders one more frame.
488        app.world_mut()
489            .resource_mut::<RenderTargets>()
490            .invalidate("shot");
491        app.update();
492        assert!(
493            app.world().entity(cam).get::<Camera>().unwrap().is_active,
494            "invalidate re-renders the snapshot once"
495        );
496    }
497
498    #[test]
499    fn quantize_rounds_up_to_step_and_clamps() {
500        assert_eq!(quantize_size(Vec2::new(1.0, 1.0)), UVec2::splat(SIZE_STEP));
501        assert_eq!(quantize_size(Vec2::new(17.0, 31.0)), UVec2::new(32, 32));
502        assert_eq!(quantize_size(Vec2::new(0.0, 0.0)), UVec2::splat(SIZE_STEP));
503        assert_eq!(
504            quantize_size(Vec2::new(99999.0, 10.0)),
505            UVec2::new(MAX_DIM, SIZE_STEP)
506        );
507    }
508}