use bevy::camera::visibility::InheritedVisibility;
use bevy::camera::{ImageRenderTarget, RenderTarget as BevyRenderTarget};
use bevy::image::{Image, TRANSPARENT_IMAGE_HANDLE};
use bevy::platform::collections::HashMap;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureFormat};
use bevy::ui::widget::ImageNode;
use bevy::ui::{CalculatedClip, ComputedNode, UiGlobalTransform};
const MAX_DIM: u32 = 2048;
const SIZE_STEP: u32 = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderMode {
Live,
Snapshot,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resolution {
Auto,
Fixed(UVec2),
}
#[derive(Clone, Copy, Debug)]
pub struct RenderTargetSpec {
pub size: Resolution,
pub mode: RenderMode,
pub format: TextureFormat,
}
impl Default for RenderTargetSpec {
fn default() -> Self {
Self {
size: Resolution::Auto,
mode: RenderMode::Live,
format: TextureFormat::Rgba8UnormSrgb,
}
}
}
#[derive(Clone, Debug)]
pub struct RenderTarget {
pub handle: Handle<Image>,
}
impl RenderTarget {
pub fn camera_target(&self) -> BevyRenderTarget {
BevyRenderTarget::Image(ImageRenderTarget {
handle: self.handle.clone(),
scale_factor: 1.0,
})
}
}
struct Entry {
handle: Handle<Image>,
mode: RenderMode,
resolution: Resolution,
binder: Option<Entity>,
dirty: bool,
last_size: UVec2,
}
#[derive(Resource, Default)]
pub struct RenderTargets {
entries: HashMap<String, Entry>,
}
impl RenderTargets {
pub fn create(
&mut self,
images: &mut Assets<Image>,
name: impl Into<String>,
spec: RenderTargetSpec,
) -> RenderTarget {
let size = match spec.size {
Resolution::Fixed(s) => s.max(UVec2::ONE).min(UVec2::splat(MAX_DIM)),
Resolution::Auto => UVec2::splat(SIZE_STEP),
};
let image = Image::new_target_texture(size.x, size.y, spec.format, None);
let handle = images.add(image);
self.entries.insert(
name.into(),
Entry {
handle: handle.clone(),
mode: spec.mode,
resolution: spec.size,
binder: None,
dirty: true,
last_size: size,
},
);
RenderTarget { handle }
}
pub fn register(&mut self, name: impl Into<String>, handle: Handle<Image>) {
self.entries.insert(
name.into(),
Entry {
handle,
mode: RenderMode::Snapshot,
resolution: Resolution::Fixed(UVec2::ONE),
binder: None,
dirty: false,
last_size: UVec2::ONE,
},
);
}
pub fn get(&self, name: &str) -> Option<Handle<Image>> {
self.entries.get(name).map(|e| e.handle.clone())
}
pub fn invalidate(&mut self, name: &str) {
if let Some(e) = self.entries.get_mut(name) {
e.dirty = true;
}
}
pub fn set_mode(&mut self, name: &str, mode: RenderMode) {
if let Some(e) = self.entries.get_mut(name) {
if e.mode != mode {
e.dirty = true; }
e.mode = mode;
}
}
pub fn remove(&mut self, name: &str) {
self.entries.remove(name);
}
}
#[derive(Component, Clone, Debug)]
pub struct PortalCamera(pub String);
#[derive(Component, Clone, Debug)]
pub struct TargetView(pub String);
pub fn bind_target_views(
mut targets: ResMut<RenderTargets>,
mut portals: Query<(Entity, &TargetView, &mut ImageNode)>,
) {
for (entity, portal, mut node) in &mut portals {
let desired = targets
.entries
.get(&portal.0)
.map(|e| e.handle.clone())
.unwrap_or(TRANSPARENT_IMAGE_HANDLE);
if node.image != desired {
node.image = desired;
}
if let Some(entry) = targets.entries.get_mut(&portal.0) {
entry.binder = Some(entity);
}
}
}
pub fn drive_render_targets(
mut targets: ResMut<RenderTargets>,
mut images: ResMut<Assets<Image>>,
nodes: Query<&ComputedNode>,
) {
for entry in targets.entries.values_mut() {
if entry.resolution != Resolution::Auto {
continue;
}
let Some(binder) = entry.binder else { continue };
let Ok(node) = nodes.get(binder) else {
continue;
};
let want = quantize_size(node.size());
if want.x == 0 || want.y == 0 || want == entry.last_size {
continue;
}
if let Some(mut image) = images.get_mut(&entry.handle) {
image.resize(Extent3d {
width: want.x,
height: want.y,
depth_or_array_layers: 1,
});
entry.last_size = want;
entry.dirty = true;
}
}
}
pub fn drive_target_cameras(
mut targets: ResMut<RenderTargets>,
portals: Query<(
&TargetView,
&InheritedVisibility,
&ComputedNode,
&UiGlobalTransform,
Option<&CalculatedClip>,
)>,
mut cameras: Query<(&PortalCamera, &mut Camera)>,
) {
let mut showing: HashSet<&str> = HashSet::default();
for (portal, visibility, node, transform, clip) in &portals {
if visibility.get() && !node.is_empty() && !clipped_out(node, transform, clip) {
showing.insert(portal.0.as_str());
}
}
for (cam, mut camera) in &mut cameras {
let Some(entry) = targets.entries.get_mut(&cam.0) else {
continue;
};
let active = match entry.mode {
RenderMode::Live => showing.contains(cam.0.as_str()),
RenderMode::Snapshot => {
let dirty = entry.dirty;
entry.dirty = false;
dirty
}
};
if camera.is_active != active {
camera.is_active = active;
}
}
}
fn clipped_out(
node: &ComputedNode,
transform: &UiGlobalTransform,
clip: Option<&CalculatedClip>,
) -> bool {
let Some(clip) = clip else {
return false;
};
let half = node.size() / 2.0;
let corners = [
Vec2::new(-half.x, -half.y),
Vec2::new(half.x, -half.y),
Vec2::new(-half.x, half.y),
half,
];
let rect = corners.iter().fold(Rect::EMPTY, |rect, corner| {
rect.union_point(transform.transform_point2(*corner))
});
clip.clip.intersect(rect).is_empty()
}
fn quantize_size(size: Vec2) -> UVec2 {
let q = |v: f32| {
let px = v.round().max(0.0) as u32;
let stepped = px.div_ceil(SIZE_STEP) * SIZE_STEP;
stepped.clamp(SIZE_STEP, MAX_DIM)
};
UVec2::new(q(size.x), q(size.y))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_resource::<RenderTargets>();
app
}
#[test]
fn create_get_remove() {
let mut app = test_app();
let handle = app
.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets
.create(&mut images, "follow", RenderTargetSpec::default())
.handle
});
let targets = app.world().resource::<RenderTargets>();
assert_eq!(targets.get("follow"), Some(handle));
assert_eq!(targets.get("nope"), None);
app.world_mut()
.resource_mut::<RenderTargets>()
.remove("follow");
assert_eq!(app.world().resource::<RenderTargets>().get("follow"), None);
}
#[test]
fn register_app_texture_is_inert() {
let mut app = test_app();
app.add_systems(Update, (drive_render_targets, drive_target_cameras).chain());
let handle = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
images.add(crate::raster::blank_image())
};
app.world_mut()
.resource_mut::<RenderTargets>()
.register("checker", handle.clone());
assert_eq!(
app.world().resource::<RenderTargets>().get("checker"),
Some(handle.clone())
);
app.update();
assert_eq!(
app.world().resource::<RenderTargets>().get("checker"),
Some(handle),
"the registered handle survives the drive system"
);
app.world_mut()
.resource_mut::<RenderTargets>()
.remove("checker");
assert_eq!(app.world().resource::<RenderTargets>().get("checker"), None);
}
#[test]
fn set_mode_and_invalidate_mark_dirty() {
let mut app = test_app();
app.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets.create(
&mut images,
"follow",
RenderTargetSpec {
mode: RenderMode::Live,
..default()
},
);
});
let mut targets = app.world_mut().resource_mut::<RenderTargets>();
targets.entries.get_mut("follow").unwrap().dirty = false;
targets.set_mode("follow", RenderMode::Live); assert!(!targets.entries["follow"].dirty);
targets.set_mode("follow", RenderMode::Snapshot); assert!(targets.entries["follow"].dirty);
assert_eq!(targets.entries["follow"].mode, RenderMode::Snapshot);
targets.entries.get_mut("follow").unwrap().dirty = false;
targets.invalidate("follow");
assert!(targets.entries["follow"].dirty);
}
#[test]
fn bind_portals_binds_and_reverts() {
let mut app = test_app();
app.add_systems(Update, bind_target_views);
let target_handle =
app.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets
.create(&mut images, "follow", RenderTargetSpec::default())
.handle
});
let placeholder = TRANSPARENT_IMAGE_HANDLE;
let portal = app
.world_mut()
.spawn((
TargetView("follow".into()),
ImageNode::new(placeholder.clone()),
))
.id();
app.update(); assert_eq!(
app.world().entity(portal).get::<ImageNode>().unwrap().image,
target_handle,
"portal binds to the registered target texture"
);
assert_eq!(
app.world().resource::<RenderTargets>().entries["follow"].binder,
Some(portal),
"the portal is recorded as the target's binder"
);
app.world_mut()
.resource_mut::<RenderTargets>()
.remove("follow");
app.update();
assert_eq!(
app.world().entity(portal).get::<ImageNode>().unwrap().image,
placeholder,
"a removed target reverts the portal to the placeholder"
);
}
#[test]
fn snapshot_camera_renders_once_then_deactivates() {
let mut app = test_app();
app.add_systems(Update, (drive_render_targets, drive_target_cameras).chain());
app.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets.create(
&mut images,
"shot",
RenderTargetSpec {
mode: RenderMode::Snapshot,
..default()
},
);
});
let cam = app
.world_mut()
.spawn((PortalCamera("shot".into()), Camera::default()))
.id();
app.update();
assert!(
app.world().entity(cam).get::<Camera>().unwrap().is_active,
"a dirty snapshot renders this frame"
);
app.update();
assert!(
!app.world().entity(cam).get::<Camera>().unwrap().is_active,
"a clean snapshot stops rendering"
);
app.world_mut()
.resource_mut::<RenderTargets>()
.invalidate("shot");
app.update();
assert!(
app.world().entity(cam).get::<Camera>().unwrap().is_active,
"invalidate re-renders the snapshot once"
);
}
#[test]
fn live_camera_renders_only_while_a_portal_is_showing() {
let mut app = test_app();
app.add_systems(Update, (drive_render_targets, drive_target_cameras).chain());
app.world_mut()
.resource_scope(|world, mut targets: Mut<RenderTargets>| {
let mut images = world.resource_mut::<Assets<Image>>();
targets.create(&mut images, "live", RenderTargetSpec::default());
});
let cam = app
.world_mut()
.spawn((PortalCamera("live".into()), Camera::default()))
.id();
let active = |app: &App| app.world().entity(cam).get::<Camera>().unwrap().is_active;
app.update();
assert!(
!active(&app),
"no portal shows the target → the camera idles"
);
let shown = ComputedNode {
size: Vec2::splat(64.0),
..default()
};
let portal = app
.world_mut()
.spawn((
TargetView("live".into()),
InheritedVisibility::VISIBLE,
shown,
UiGlobalTransform::default(),
))
.id();
app.update();
assert!(active(&app), "a visible portal turns the camera on");
app.world_mut()
.entity_mut(portal)
.insert(InheritedVisibility::HIDDEN);
app.update();
assert!(!active(&app), "a hidden portal turns it off");
app.world_mut()
.entity_mut(portal)
.insert((InheritedVisibility::VISIBLE, ComputedNode::default()));
app.update();
assert!(
!active(&app),
"an empty (display: none) portal keeps it off"
);
app.world_mut().entity_mut(portal).insert((
shown,
CalculatedClip {
clip: Rect::new(100.0, -10.0, 200.0, 10.0),
},
));
app.update();
assert!(
!active(&app),
"a portal scrolled out of its clip keeps it off"
);
app.world_mut().entity_mut(portal).insert(CalculatedClip {
clip: Rect::new(0.0, 0.0, 200.0, 200.0),
});
app.update();
assert!(active(&app), "a partly clipped portal turns it back on");
app.world_mut().entity_mut(portal).despawn();
app.update();
assert!(!active(&app), "an unmounted portal turns it off");
}
#[test]
fn quantize_rounds_up_to_step_and_clamps() {
assert_eq!(quantize_size(Vec2::new(1.0, 1.0)), UVec2::splat(SIZE_STEP));
assert_eq!(quantize_size(Vec2::new(17.0, 31.0)), UVec2::new(32, 32));
assert_eq!(quantize_size(Vec2::new(0.0, 0.0)), UVec2::splat(SIZE_STEP));
assert_eq!(
quantize_size(Vec2::new(99999.0, 10.0)),
UVec2::new(MAX_DIM, SIZE_STEP)
);
}
}