Path: blob/main/crates/bevy_render/src/view/window/screenshot.rs
6849 views
use super::ExtractedWindows;1use crate::{2gpu_readback,3render_asset::RenderAssets,4render_resource::{5binding_types::texture_2d, BindGroup, BindGroupEntries, BindGroupLayout,6BindGroupLayoutEntries, Buffer, BufferUsages, CachedRenderPipelineId, FragmentState,7PipelineCache, RenderPipelineDescriptor, SpecializedRenderPipeline,8SpecializedRenderPipelines, Texture, TextureUsages, TextureView, VertexState,9},10renderer::RenderDevice,11texture::{GpuImage, ManualTextureViews, OutputColorAttachment},12view::{prepare_view_attachments, prepare_view_targets, ViewTargetAttachments, WindowSurfaces},13ExtractSchedule, MainWorld, Render, RenderApp, RenderStartup, RenderSystems,14};15use alloc::{borrow::Cow, sync::Arc};16use bevy_app::{First, Plugin, Update};17use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Handle, RenderAssetUsages};18use bevy_camera::{ManualTextureViewHandle, NormalizedRenderTarget, RenderTarget};19use bevy_derive::{Deref, DerefMut};20use bevy_ecs::{21entity::EntityHashMap, message::message_update_system, prelude::*, system::SystemState,22};23use bevy_image::{Image, TextureFormatPixelInfo, ToExtents};24use bevy_platform::collections::HashSet;25use bevy_reflect::Reflect;26use bevy_shader::Shader;27use bevy_tasks::AsyncComputeTaskPool;28use bevy_utils::default;29use bevy_window::{PrimaryWindow, WindowRef};30use core::ops::Deref;31use std::{32path::Path,33sync::{34mpsc::{Receiver, Sender},35Mutex,36},37};38use tracing::{error, info, warn};39use wgpu::{CommandEncoder, Extent3d, TextureFormat};4041#[derive(EntityEvent, Reflect, Deref, DerefMut, Debug)]42#[reflect(Debug)]43pub struct ScreenshotCaptured {44pub entity: Entity,45#[deref]46pub image: Image,47}4849/// A component that signals to the renderer to capture a screenshot this frame.50///51/// This component should be spawned on a new entity with an observer that will trigger52/// with [`ScreenshotCaptured`] when the screenshot is ready.53///54/// Screenshots are captured asynchronously and may not be available immediately after the frame55/// that the component is spawned on. The observer should be used to handle the screenshot when it56/// is ready.57///58/// Note that the screenshot entity will be despawned after the screenshot is captured and the59/// observer is triggered.60///61/// # Usage62///63/// ```64/// # use bevy_ecs::prelude::*;65/// # use bevy_render::view::screenshot::{save_to_disk, Screenshot};66///67/// fn take_screenshot(mut commands: Commands) {68/// commands.spawn(Screenshot::primary_window())69/// .observe(save_to_disk("screenshot.png"));70/// }71/// ```72#[derive(Component, Deref, DerefMut, Reflect, Debug)]73#[reflect(Component, Debug)]74pub struct Screenshot(pub RenderTarget);7576/// A marker component that indicates that a screenshot is currently being captured.77#[derive(Component, Default)]78pub struct Capturing;7980/// A marker component that indicates that a screenshot has been captured, the image is ready, and81/// the screenshot entity can be despawned.82#[derive(Component, Default)]83pub struct Captured;8485impl Screenshot {86/// Capture a screenshot of the provided window entity.87pub fn window(window: Entity) -> Self {88Self(RenderTarget::Window(WindowRef::Entity(window)))89}9091/// Capture a screenshot of the primary window, if one exists.92pub fn primary_window() -> Self {93Self(RenderTarget::Window(WindowRef::Primary))94}9596/// Capture a screenshot of the provided render target image.97pub fn image(image: Handle<Image>) -> Self {98Self(RenderTarget::Image(image.into()))99}100101/// Capture a screenshot of the provided manual texture view.102pub fn texture_view(texture_view: ManualTextureViewHandle) -> Self {103Self(RenderTarget::TextureView(texture_view))104}105}106107struct ScreenshotPreparedState {108pub texture: Texture,109pub buffer: Buffer,110pub bind_group: BindGroup,111pub pipeline_id: CachedRenderPipelineId,112pub size: Extent3d,113}114115#[derive(Resource, Deref, DerefMut)]116pub struct CapturedScreenshots(pub Arc<Mutex<Receiver<(Entity, Image)>>>);117118#[derive(Resource, Deref, DerefMut, Default)]119struct RenderScreenshotTargets(EntityHashMap<NormalizedRenderTarget>);120121#[derive(Resource, Deref, DerefMut, Default)]122struct RenderScreenshotsPrepared(EntityHashMap<ScreenshotPreparedState>);123124#[derive(Resource, Deref, DerefMut)]125struct RenderScreenshotsSender(Sender<(Entity, Image)>);126127/// Saves the captured screenshot to disk at the provided path.128pub fn save_to_disk(path: impl AsRef<Path>) -> impl FnMut(On<ScreenshotCaptured>) {129let path = path.as_ref().to_owned();130move |screenshot_captured| {131let img = screenshot_captured.image.clone();132match img.try_into_dynamic() {133Ok(dyn_img) => match image::ImageFormat::from_path(&path) {134Ok(format) => {135// discard the alpha channel which stores brightness values when HDR is enabled to make sure136// the screenshot looks right137let img = dyn_img.to_rgb8();138#[cfg(not(target_arch = "wasm32"))]139match img.save_with_format(&path, format) {140Ok(_) => info!("Screenshot saved to {}", path.display()),141Err(e) => error!("Cannot save screenshot, IO error: {e}"),142}143144#[cfg(target_arch = "wasm32")]145{146let save_screenshot = || {147use image::EncodableLayout;148use wasm_bindgen::{JsCast, JsValue};149150let mut image_buffer = std::io::Cursor::new(Vec::new());151img.write_to(&mut image_buffer, format)152.map_err(|e| JsValue::from_str(&format!("{e}")))?;153// SAFETY: `image_buffer` only exist in this closure, and is not used after this line154let parts = js_sys::Array::of1(&unsafe {155js_sys::Uint8Array::view(image_buffer.into_inner().as_bytes())156.into()157});158let blob = web_sys::Blob::new_with_u8_array_sequence(&parts)?;159let url = web_sys::Url::create_object_url_with_blob(&blob)?;160let window = web_sys::window().unwrap();161let document = window.document().unwrap();162let link = document.create_element("a")?;163link.set_attribute("href", &url)?;164link.set_attribute(165"download",166path.file_name()167.and_then(|filename| filename.to_str())168.ok_or_else(|| JsValue::from_str("Invalid filename"))?,169)?;170let html_element = link.dyn_into::<web_sys::HtmlElement>()?;171html_element.click();172web_sys::Url::revoke_object_url(&url)?;173Ok::<(), JsValue>(())174};175176match (save_screenshot)() {177Ok(_) => info!("Screenshot saved to {}", path.display()),178Err(e) => error!("Cannot save screenshot, error: {e:?}"),179};180}181}182Err(e) => error!("Cannot save screenshot, requested format not recognized: {e}"),183},184Err(e) => error!("Cannot save screenshot, screen format cannot be understood: {e}"),185}186}187}188189fn clear_screenshots(mut commands: Commands, screenshots: Query<Entity, With<Captured>>) {190for entity in screenshots.iter() {191commands.entity(entity).despawn();192}193}194195pub fn trigger_screenshots(196mut commands: Commands,197captured_screenshots: ResMut<CapturedScreenshots>,198) {199let captured_screenshots = captured_screenshots.lock().unwrap();200while let Ok((entity, image)) = captured_screenshots.try_recv() {201commands.entity(entity).insert(Captured);202commands.trigger(ScreenshotCaptured { image, entity });203}204}205206fn extract_screenshots(207mut targets: ResMut<RenderScreenshotTargets>,208mut main_world: ResMut<MainWorld>,209mut system_state: Local<210Option<211SystemState<(212Commands,213Query<Entity, With<PrimaryWindow>>,214Query<(Entity, &Screenshot), Without<Capturing>>,215)>,216>,217>,218mut seen_targets: Local<HashSet<NormalizedRenderTarget>>,219) {220if system_state.is_none() {221*system_state = Some(SystemState::new(&mut main_world));222}223let system_state = system_state.as_mut().unwrap();224let (mut commands, primary_window, screenshots) = system_state.get_mut(&mut main_world);225226targets.clear();227seen_targets.clear();228229let primary_window = primary_window.iter().next();230231for (entity, screenshot) in screenshots.iter() {232let render_target = screenshot.0.clone();233let Some(render_target) = render_target.normalize(primary_window) else {234warn!(235"Unknown render target for screenshot, skipping: {:?}",236render_target237);238continue;239};240if seen_targets.contains(&render_target) {241warn!(242"Duplicate render target for screenshot, skipping entity {}: {:?}",243entity, render_target244);245// If we don't despawn the entity here, it will be captured again in the next frame246commands.entity(entity).despawn();247continue;248}249seen_targets.insert(render_target.clone());250targets.insert(entity, render_target);251commands.entity(entity).insert(Capturing);252}253254system_state.apply(&mut main_world);255}256257fn prepare_screenshots(258targets: Res<RenderScreenshotTargets>,259mut prepared: ResMut<RenderScreenshotsPrepared>,260window_surfaces: Res<WindowSurfaces>,261render_device: Res<RenderDevice>,262screenshot_pipeline: Res<ScreenshotToScreenPipeline>,263pipeline_cache: Res<PipelineCache>,264mut pipelines: ResMut<SpecializedRenderPipelines<ScreenshotToScreenPipeline>>,265images: Res<RenderAssets<GpuImage>>,266manual_texture_views: Res<ManualTextureViews>,267mut view_target_attachments: ResMut<ViewTargetAttachments>,268) {269prepared.clear();270for (entity, target) in targets.iter() {271match target {272NormalizedRenderTarget::Window(window) => {273let window = window.entity();274let Some(surface_data) = window_surfaces.surfaces.get(&window) else {275warn!("Unknown window for screenshot, skipping: {}", window);276continue;277};278let format = surface_data.configuration.format.add_srgb_suffix();279let size = Extent3d {280width: surface_data.configuration.width,281height: surface_data.configuration.height,282..default()283};284let (texture_view, state) = prepare_screenshot_state(285size,286format,287&render_device,288&screenshot_pipeline,289&pipeline_cache,290&mut pipelines,291);292prepared.insert(*entity, state);293view_target_attachments.insert(294target.clone(),295OutputColorAttachment::new(texture_view.clone(), format.add_srgb_suffix()),296);297}298NormalizedRenderTarget::Image(image) => {299let Some(gpu_image) = images.get(&image.handle) else {300warn!("Unknown image for screenshot, skipping: {:?}", image);301continue;302};303let format = gpu_image.texture_format;304let (texture_view, state) = prepare_screenshot_state(305gpu_image.size,306format,307&render_device,308&screenshot_pipeline,309&pipeline_cache,310&mut pipelines,311);312prepared.insert(*entity, state);313view_target_attachments.insert(314target.clone(),315OutputColorAttachment::new(texture_view.clone(), format.add_srgb_suffix()),316);317}318NormalizedRenderTarget::TextureView(texture_view) => {319let Some(manual_texture_view) = manual_texture_views.get(texture_view) else {320warn!(321"Unknown manual texture view for screenshot, skipping: {:?}",322texture_view323);324continue;325};326let format = manual_texture_view.format;327let size = manual_texture_view.size.to_extents();328let (texture_view, state) = prepare_screenshot_state(329size,330format,331&render_device,332&screenshot_pipeline,333&pipeline_cache,334&mut pipelines,335);336prepared.insert(*entity, state);337view_target_attachments.insert(338target.clone(),339OutputColorAttachment::new(texture_view.clone(), format.add_srgb_suffix()),340);341}342NormalizedRenderTarget::None { .. } => {343// Nothing to screenshot!344}345}346}347}348349fn prepare_screenshot_state(350size: Extent3d,351format: TextureFormat,352render_device: &RenderDevice,353pipeline: &ScreenshotToScreenPipeline,354pipeline_cache: &PipelineCache,355pipelines: &mut SpecializedRenderPipelines<ScreenshotToScreenPipeline>,356) -> (TextureView, ScreenshotPreparedState) {357let texture = render_device.create_texture(&wgpu::TextureDescriptor {358label: Some("screenshot-capture-rendertarget"),359size,360mip_level_count: 1,361sample_count: 1,362dimension: wgpu::TextureDimension::D2,363format,364usage: TextureUsages::RENDER_ATTACHMENT365| TextureUsages::COPY_SRC366| TextureUsages::TEXTURE_BINDING,367view_formats: &[],368});369let texture_view = texture.create_view(&Default::default());370let buffer = render_device.create_buffer(&wgpu::BufferDescriptor {371label: Some("screenshot-transfer-buffer"),372size: gpu_readback::get_aligned_size(size, format.pixel_size().unwrap_or(0) as u32) as u64,373usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,374mapped_at_creation: false,375});376let bind_group = render_device.create_bind_group(377"screenshot-to-screen-bind-group",378&pipeline.bind_group_layout,379&BindGroupEntries::single(&texture_view),380);381let pipeline_id = pipelines.specialize(pipeline_cache, pipeline, format);382383(384texture_view,385ScreenshotPreparedState {386texture,387buffer,388bind_group,389pipeline_id,390size,391},392)393}394395pub struct ScreenshotPlugin;396397impl Plugin for ScreenshotPlugin {398fn build(&self, app: &mut bevy_app::App) {399embedded_asset!(app, "screenshot.wgsl");400401let (tx, rx) = std::sync::mpsc::channel();402app.insert_resource(CapturedScreenshots(Arc::new(Mutex::new(rx))))403.add_systems(404First,405clear_screenshots406.after(message_update_system)407.before(ApplyDeferred),408)409.add_systems(Update, trigger_screenshots);410411let Some(render_app) = app.get_sub_app_mut(RenderApp) else {412return;413};414415render_app416.insert_resource(RenderScreenshotsSender(tx))417.init_resource::<RenderScreenshotTargets>()418.init_resource::<RenderScreenshotsPrepared>()419.init_resource::<SpecializedRenderPipelines<ScreenshotToScreenPipeline>>()420.add_systems(RenderStartup, init_screenshot_to_screen_pipeline)421.add_systems(ExtractSchedule, extract_screenshots.ambiguous_with_all())422.add_systems(423Render,424prepare_screenshots425.after(prepare_view_attachments)426.before(prepare_view_targets)427.in_set(RenderSystems::ManageViews),428);429}430}431432#[derive(Resource)]433pub struct ScreenshotToScreenPipeline {434pub bind_group_layout: BindGroupLayout,435pub shader: Handle<Shader>,436}437438pub fn init_screenshot_to_screen_pipeline(439mut commands: Commands,440render_device: Res<RenderDevice>,441asset_server: Res<AssetServer>,442) {443let bind_group_layout = render_device.create_bind_group_layout(444"screenshot-to-screen-bgl",445&BindGroupLayoutEntries::single(446wgpu::ShaderStages::FRAGMENT,447texture_2d(wgpu::TextureSampleType::Float { filterable: false }),448),449);450451let shader = load_embedded_asset!(asset_server.as_ref(), "screenshot.wgsl");452453commands.insert_resource(ScreenshotToScreenPipeline {454bind_group_layout,455shader,456});457}458459impl SpecializedRenderPipeline for ScreenshotToScreenPipeline {460type Key = TextureFormat;461462fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {463RenderPipelineDescriptor {464label: Some(Cow::Borrowed("screenshot-to-screen")),465layout: vec![self.bind_group_layout.clone()],466vertex: VertexState {467shader: self.shader.clone(),468..default()469},470primitive: wgpu::PrimitiveState {471cull_mode: Some(wgpu::Face::Back),472..Default::default()473},474multisample: Default::default(),475fragment: Some(FragmentState {476shader: self.shader.clone(),477targets: vec![Some(wgpu::ColorTargetState {478format: key,479blend: None,480write_mask: wgpu::ColorWrites::ALL,481})],482..default()483}),484..default()485}486}487}488489pub(crate) fn submit_screenshot_commands(world: &World, encoder: &mut CommandEncoder) {490let targets = world.resource::<RenderScreenshotTargets>();491let prepared = world.resource::<RenderScreenshotsPrepared>();492let pipelines = world.resource::<PipelineCache>();493let gpu_images = world.resource::<RenderAssets<GpuImage>>();494let windows = world.resource::<ExtractedWindows>();495let manual_texture_views = world.resource::<ManualTextureViews>();496497for (entity, render_target) in targets.iter() {498match render_target {499NormalizedRenderTarget::Window(window) => {500let window = window.entity();501let Some(window) = windows.get(&window) else {502continue;503};504let width = window.physical_width;505let height = window.physical_height;506let Some(texture_format) = window.swap_chain_texture_format else {507continue;508};509let Some(swap_chain_texture) = window.swap_chain_texture.as_ref() else {510continue;511};512let texture_view = swap_chain_texture.texture.create_view(&Default::default());513render_screenshot(514encoder,515prepared,516pipelines,517entity,518width,519height,520texture_format,521&texture_view,522);523}524NormalizedRenderTarget::Image(image) => {525let Some(gpu_image) = gpu_images.get(&image.handle) else {526warn!("Unknown image for screenshot, skipping: {:?}", image);527continue;528};529let width = gpu_image.size.width;530let height = gpu_image.size.height;531let texture_format = gpu_image.texture_format;532let texture_view = gpu_image.texture_view.deref();533render_screenshot(534encoder,535prepared,536pipelines,537entity,538width,539height,540texture_format,541texture_view,542);543}544NormalizedRenderTarget::TextureView(texture_view) => {545let Some(texture_view) = manual_texture_views.get(texture_view) else {546warn!(547"Unknown manual texture view for screenshot, skipping: {:?}",548texture_view549);550continue;551};552let width = texture_view.size.x;553let height = texture_view.size.y;554let texture_format = texture_view.format;555let texture_view = texture_view.texture_view.deref();556render_screenshot(557encoder,558prepared,559pipelines,560entity,561width,562height,563texture_format,564texture_view,565);566}567NormalizedRenderTarget::None { .. } => {568// Nothing to screenshot!569}570};571}572}573574fn render_screenshot(575encoder: &mut CommandEncoder,576prepared: &RenderScreenshotsPrepared,577pipelines: &PipelineCache,578entity: &Entity,579width: u32,580height: u32,581texture_format: TextureFormat,582texture_view: &wgpu::TextureView,583) {584if let Some(prepared_state) = &prepared.get(entity) {585let extent = Extent3d {586width,587height,588depth_or_array_layers: 1,589};590encoder.copy_texture_to_buffer(591prepared_state.texture.as_image_copy(),592wgpu::TexelCopyBufferInfo {593buffer: &prepared_state.buffer,594layout: gpu_readback::layout_data(extent, texture_format),595},596extent,597);598599if let Some(pipeline) = pipelines.get_render_pipeline(prepared_state.pipeline_id) {600let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {601label: Some("screenshot_to_screen_pass"),602color_attachments: &[Some(wgpu::RenderPassColorAttachment {603view: texture_view,604depth_slice: None,605resolve_target: None,606ops: wgpu::Operations {607load: wgpu::LoadOp::Load,608store: wgpu::StoreOp::Store,609},610})],611depth_stencil_attachment: None,612timestamp_writes: None,613occlusion_query_set: None,614});615pass.set_pipeline(pipeline);616pass.set_bind_group(0, &prepared_state.bind_group, &[]);617pass.draw(0..3, 0..1);618}619}620}621622pub(crate) fn collect_screenshots(world: &mut World) {623#[cfg(feature = "trace")]624let _span = tracing::info_span!("collect_screenshots").entered();625626let sender = world.resource::<RenderScreenshotsSender>().deref().clone();627let prepared = world.resource::<RenderScreenshotsPrepared>();628629for (entity, prepared) in prepared.iter() {630let entity = *entity;631let sender = sender.clone();632let width = prepared.size.width;633let height = prepared.size.height;634let texture_format = prepared.texture.format();635let Ok(pixel_size) = texture_format.pixel_size() else {636continue;637};638let buffer = prepared.buffer.clone();639640let finish = async move {641let (tx, rx) = async_channel::bounded(1);642let buffer_slice = buffer.slice(..);643// The polling for this map call is done every frame when the command queue is submitted.644buffer_slice.map_async(wgpu::MapMode::Read, move |result| {645let err = result.err();646if err.is_some() {647panic!("{}", err.unwrap().to_string());648}649tx.try_send(()).unwrap();650});651rx.recv().await.unwrap();652let data = buffer_slice.get_mapped_range();653// we immediately move the data to CPU memory to avoid holding the mapped view for long654let mut result = Vec::from(&*data);655drop(data);656657if result.len() != ((width * height) as usize * pixel_size) {658// Our buffer has been padded because we needed to align to a multiple of 256.659// We remove this padding here660let initial_row_bytes = width as usize * pixel_size;661let buffered_row_bytes =662gpu_readback::align_byte_size(width * pixel_size as u32) as usize;663664let mut take_offset = buffered_row_bytes;665let mut place_offset = initial_row_bytes;666for _ in 1..height {667result.copy_within(take_offset..take_offset + buffered_row_bytes, place_offset);668take_offset += buffered_row_bytes;669place_offset += initial_row_bytes;670}671result.truncate(initial_row_bytes * height as usize);672}673674if let Err(e) = sender.send((675entity,676Image::new(677Extent3d {678width,679height,680depth_or_array_layers: 1,681},682wgpu::TextureDimension::D2,683result,684texture_format,685RenderAssetUsages::RENDER_WORLD,686),687)) {688error!("Failed to send screenshot: {}", e);689}690};691692AsyncComputeTaskPool::get().spawn(finish).detach();693}694}695696697