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}