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}