use bevy::camera::{ImageRenderTarget, RenderTarget as BevyRenderTarget};
use bevy::image::Image;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureFormat};
use bevy::ui::ComputedNode;
use bevy::ui::widget::ImageNode;
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 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 RPortal(pub String);
#[derive(Resource)]
pub struct PortalPlaceholder(pub Handle<Image>);
pub fn blank_portal_image() -> Image {
Image::new_fill(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
bevy::render::render_resource::TextureDimension::D2,
&[0, 0, 0, 0],
TextureFormat::Rgba8UnormSrgb,
bevy::asset::RenderAssetUsages::MAIN_WORLD | bevy::asset::RenderAssetUsages::RENDER_WORLD,
)
}
pub fn init_portal_placeholder(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
let handle = images.add(blank_portal_image());
commands.insert_resource(PortalPlaceholder(handle));
}
pub fn bind_portals(
mut targets: ResMut<RenderTargets>,
placeholder: Res<PortalPlaceholder>,
mut portals: Query<(Entity, &RPortal, &mut ImageNode)>,
) {
for (entity, portal, mut node) in &mut portals {
let desired = targets
.entries
.get(&portal.0)
.map(|e| e.handle.clone())
.unwrap_or_else(|| placeholder.0.clone());
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>,
mut cameras: Query<(&PortalCamera, &mut Camera)>,
) {
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;
}
}
for (cam, mut camera) in &mut cameras {
let Some(entry) = targets.entries.get_mut(&cam.0) else {
continue;
};
match entry.mode {
RenderMode::Live => {
if !camera.is_active {
camera.is_active = true;
}
}
RenderMode::Snapshot => {
let active = entry.dirty;
if camera.is_active != active {
camera.is_active = active;
}
entry.dirty = false;
}
}
}
}
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 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(Startup, init_portal_placeholder);
app.add_systems(Update, bind_portals);
app.update();
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 = app.world().resource::<PortalPlaceholder>().0.clone();
let portal = app
.world_mut()
.spawn((
RPortal("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);
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 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)
);
}
}