Path: blob/main/crates/bevy_render/src/view/window/screenshot.rs
9371 views
use super::ExtractedWindows;1use crate::{2gpu_readback,3render_asset::RenderAssets,4render_resource::{5BindGroup, BindGroupEntries, Buffer, BufferUsages, PipelineCache,6SpecializedRenderPipeline, SpecializedRenderPipelines, Texture, TextureUsages, TextureView,7},8renderer::RenderDevice,9texture::{GpuImage, ManualTextureViews, OutputColorAttachment},10view::{prepare_view_attachments, prepare_view_targets, ViewTargetAttachments, WindowSurfaces},11ExtractSchedule, MainWorld, Render, RenderApp, RenderStartup, RenderSystems,12};13use alloc::{borrow::Cow, sync::Arc};14use bevy_app::{First, Plugin, Update};15use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Handle, RenderAssetUsages};16use bevy_camera::{ManualTextureViewHandle, NormalizedRenderTarget, RenderTarget};17use bevy_derive::{Deref, DerefMut};18use bevy_ecs::{19entity::EntityHashMap, message::message_update_system, prelude::*, system::SystemState,20};21use bevy_image::{Image, TextureFormatPixelInfo, ToExtents};22use bevy_log::{error, info, warn};23use bevy_material::{24bind_group_layout_entries::{binding_types::texture_2d, BindGroupLayoutEntries},25descriptor::{26BindGroupLayoutDescriptor, CachedRenderPipelineId, FragmentState, RenderPipelineDescriptor,27VertexState,28},29};30use bevy_platform::collections::HashSet;31use bevy_reflect::Reflect;32use bevy_shader::Shader;33use bevy_tasks::AsyncComputeTaskPool;34use bevy_utils::default;35use bevy_window::{PrimaryWindow, WindowRef};36use core::ops::Deref;37use std::{38path::Path,39sync::{40mpsc::{Receiver, Sender},41Mutex,42},43};44use wgpu::{CommandEncoder, Extent3d, TextureFormat};4546#[derive(EntityEvent, Reflect, Deref, DerefMut, Debug)]47#[reflect(Debug)]48pub struct ScreenshotCaptured {49pub entity: Entity,50#[deref]51pub image: Image,52}5354/// A component that signals to the renderer to capture a screenshot this frame.55///56/// This component should be spawned on a new entity with an observer that will trigger57/// with [`ScreenshotCaptured`] when the screenshot is ready.58///59/// Screenshots are captured asynchronously and may not be available immediately after the frame60/// that the component is spawned on. The observer should be used to handle the screenshot when it61/// is ready.62///63/// Note that the screenshot entity will be despawned after the screenshot is captured and the64/// observer is triggered.65///66/// # Usage67///68/// ```69/// # use bevy_ecs::prelude::*;70/// # use bevy_render::view::screenshot::{save_to_disk, Screenshot};71///72/// fn take_screenshot(mut commands: Commands) {73/// commands.spawn(Screenshot::primary_window())74/// .observe(save_to_disk("screenshot.png"));75/// }76/// ```77#[derive(Component, Deref, DerefMut, Reflect, Debug)]78#[reflect(Component, Debug)]79pub struct Screenshot(pub RenderTarget);8081/// A marker component that indicates that a screenshot is currently being captured.82#[derive(Component, Default)]83pub struct Capturing;8485/// A marker component that indicates that a screenshot has been captured, the image is ready, and86/// the screenshot entity can be despawned.87#[derive(Component, Default)]88pub struct Captured;8990impl Screenshot {91/// Capture a screenshot of the provided window entity.92pub fn window(window: Entity) -> Self {93Self(RenderTarget::Window(WindowRef::Entity(window)))94}9596/// Capture a screenshot of the primary window, if one exists.97pub fn primary_window() -> Self {98Self(RenderTarget::Window(WindowRef::Primary))99}100101/// Capture a screenshot of the provided render target image.102pub fn image(image: Handle<Image>) -> Self {103Self(RenderTarget::Image(image.into()))104}105106/// Capture a screenshot of the provided manual texture view.107pub fn texture_view(texture_view: ManualTextureViewHandle) -> Self {108Self(RenderTarget::TextureView(texture_view))109}110}111112struct ScreenshotPreparedState {113pub texture: Texture,114pub buffer: Buffer,115pub bind_group: BindGroup,116pub pipeline_id: CachedRenderPipelineId,117pub size: Extent3d,118}119120#[derive(Resource, Deref, DerefMut)]121pub struct CapturedScreenshots(pub Arc<Mutex<Receiver<(Entity, Image)>>>);122123#[derive(Resource, Deref, DerefMut, Default)]124struct RenderScreenshotTargets(EntityHashMap<NormalizedRenderTarget>);125126#[derive(Resource, Deref, DerefMut, Default)]127struct RenderScreenshotsPrepared(EntityHashMap<ScreenshotPreparedState>);128129#[derive(Resource, Deref, DerefMut)]130struct RenderScreenshotsSender(Sender<(Entity, Image)>);131132/// Saves the captured screenshot to disk at the provided path.133pub fn save_to_disk(path: impl AsRef<Path>) -> impl FnMut(On<ScreenshotCaptured>) {134let path = path.as_ref().to_owned();135move |screenshot_captured| {136let img = screenshot_captured.image.clone();137match img.try_into_dynamic() {138Ok(dyn_img) => match image::ImageFormat::from_path(&path) {139Ok(format) => {140// discard the alpha channel which stores brightness values when HDR is enabled to make sure141// the screenshot looks right142let img = dyn_img.to_rgb8();143#[cfg(not(target_arch = "wasm32"))]144match img.save_with_format(&path, format) {145Ok(_) => info!("Screenshot saved to {}", path.display()),146Err(e) => error!("Cannot save screenshot, IO error: {e}"),147}148149#[cfg(target_arch = "wasm32")]150{151let save_screenshot = || {152use image::EncodableLayout;153use wasm_bindgen::{JsCast, JsValue};154155let mut image_buffer = std::io::Cursor::new(Vec::new());156img.write_to(&mut image_buffer, format)157.map_err(|e| JsValue::from_str(&format!("{e}")))?;158159let parts = js_sys::Array::of1(160&js_sys::Uint8Array::new_from_slice(161image_buffer.into_inner().as_bytes(),162)163.into(),164);165let blob = web_sys::Blob::new_with_u8_array_sequence(&parts)?;166let url = web_sys::Url::create_object_url_with_blob(&blob)?;167let window = web_sys::window().unwrap();168let document = window.document().unwrap();169let link = document.create_element("a")?;170link.set_attribute("href", &url)?;171link.set_attribute(172"download",173path.file_name()174.and_then(|filename| filename.to_str())175.ok_or_else(|| JsValue::from_str("Invalid filename"))?,176)?;177let html_element = link.dyn_into::<web_sys::HtmlElement>()?;178html_element.click();179web_sys::Url::revoke_object_url(&url)?;180Ok::<(), JsValue>(())181};182183match (save_screenshot)() {184Ok(_) => info!("Screenshot saved to {}", path.display()),185Err(e) => error!("Cannot save screenshot, error: {e:?}"),186};187}188}189Err(e) => error!("Cannot save screenshot, requested format not recognized: {e}"),190},191Err(e) => error!("Cannot save screenshot, screen format cannot be understood: {e}"),192}193}194}195196fn clear_screenshots(mut commands: Commands, screenshots: Query<Entity, With<Captured>>) {197for entity in screenshots.iter() {198commands.entity(entity).despawn();199}200}201202pub fn trigger_screenshots(203mut commands: Commands,204captured_screenshots: ResMut<CapturedScreenshots>,205) {206let captured_screenshots = captured_screenshots.lock().unwrap();207while let Ok((entity, image)) = captured_screenshots.try_recv() {208commands.entity(entity).insert(Captured);209commands.trigger(ScreenshotCaptured { image, entity });210}211}212213fn extract_screenshots(214mut targets: ResMut<RenderScreenshotTargets>,215mut main_world: ResMut<MainWorld>,216mut system_state: Local<217Option<218SystemState<(219Commands,220Query<Entity, With<PrimaryWindow>>,221Query<(Entity, &Screenshot), Without<Capturing>>,222)>,223>,224>,225mut seen_targets: Local<HashSet<NormalizedRenderTarget>>,226) {227if system_state.is_none() {228*system_state = Some(SystemState::new(&mut main_world));229}230let system_state = system_state.as_mut().unwrap();231let (mut commands, primary_window, screenshots) = system_state.get_mut(&mut main_world);232233targets.clear();234seen_targets.clear();235236let primary_window = primary_window.iter().next();237238for (entity, screenshot) in screenshots.iter() {239let render_target = screenshot.0.clone();240let Some(render_target) = render_target.normalize(primary_window) else {241warn!(242"Unknown render target for screenshot, skipping: {:?}",243render_target244);245continue;246};247if seen_targets.contains(&render_target) {248warn!(249"Duplicate render target for screenshot, skipping entity {}: {:?}",250entity, render_target251);252// If we don't despawn the entity here, it will be captured again in the next frame253commands.entity(entity).despawn();254continue;255}256seen_targets.insert(render_target.clone());257targets.insert(entity, render_target);258commands.entity(entity).insert(Capturing);259}260261system_state.apply(&mut main_world);262}263264fn prepare_screenshots(265targets: Res<RenderScreenshotTargets>,266mut prepared: ResMut<RenderScreenshotsPrepared>,267window_surfaces: Res<WindowSurfaces>,268render_device: Res<RenderDevice>,269screenshot_pipeline: Res<ScreenshotToScreenPipeline>,270pipeline_cache: Res<PipelineCache>,271mut pipelines: ResMut<SpecializedRenderPipelines<ScreenshotToScreenPipeline>>,272images: Res<RenderAssets<GpuImage>>,273manual_texture_views: Res<ManualTextureViews>,274mut view_target_attachments: ResMut<ViewTargetAttachments>,275) {276prepared.clear();277for (entity, target) in targets.iter() {278match target {279NormalizedRenderTarget::Window(window) => {280let window = window.entity();281let Some(surface_data) = window_surfaces.surfaces.get(&window) else {282warn!("Unknown window for screenshot, skipping: {}", window);283continue;284};285let view_format = surface_data286.texture_view_format287.unwrap_or(surface_data.configuration.format);288let size = Extent3d {289width: surface_data.configuration.width,290height: surface_data.configuration.height,291..default()292};293let (texture_view, state) = prepare_screenshot_state(294size,295view_format,296&render_device,297&screenshot_pipeline,298&pipeline_cache,299&mut pipelines,300);301prepared.insert(*entity, state);302view_target_attachments.insert(303target.clone(),304OutputColorAttachment::new(texture_view.clone(), view_format),305);306}307NormalizedRenderTarget::Image(image) => {308let Some(gpu_image) = images.get(&image.handle) else {309warn!("Unknown image for screenshot, skipping: {:?}", image);310continue;311};312let view_format = gpu_image.view_format();313let (texture_view, state) = prepare_screenshot_state(314gpu_image.texture_descriptor.size,315view_format,316&render_device,317&screenshot_pipeline,318&pipeline_cache,319&mut pipelines,320);321prepared.insert(*entity, state);322view_target_attachments.insert(323target.clone(),324OutputColorAttachment::new(texture_view.clone(), view_format),325);326}327NormalizedRenderTarget::TextureView(texture_view) => {328let Some(manual_texture_view) = manual_texture_views.get(texture_view) else {329warn!(330"Unknown manual texture view for screenshot, skipping: {:?}",331texture_view332);333continue;334};335let view_format = manual_texture_view.view_format;336let size = manual_texture_view.size.to_extents();337let (texture_view, state) = prepare_screenshot_state(338size,339view_format,340&render_device,341&screenshot_pipeline,342&pipeline_cache,343&mut pipelines,344);345prepared.insert(*entity, state);346view_target_attachments.insert(347target.clone(),348OutputColorAttachment::new(texture_view.clone(), view_format),349);350}351NormalizedRenderTarget::None { .. } => {352// Nothing to screenshot!353}354}355}356}357358fn prepare_screenshot_state(359size: Extent3d,360format: TextureFormat,361render_device: &RenderDevice,362pipeline: &ScreenshotToScreenPipeline,363pipeline_cache: &PipelineCache,364pipelines: &mut SpecializedRenderPipelines<ScreenshotToScreenPipeline>,365) -> (TextureView, ScreenshotPreparedState) {366let texture = render_device.create_texture(&wgpu::TextureDescriptor {367label: Some("screenshot-capture-rendertarget"),368size,369mip_level_count: 1,370sample_count: 1,371dimension: wgpu::TextureDimension::D2,372format,373usage: TextureUsages::RENDER_ATTACHMENT374| TextureUsages::COPY_SRC375| TextureUsages::TEXTURE_BINDING,376view_formats: &[],377});378let texture_view = texture.create_view(&Default::default());379let buffer = render_device.create_buffer(&wgpu::BufferDescriptor {380label: Some("screenshot-transfer-buffer"),381size: gpu_readback::get_aligned_size(size, format.pixel_size().unwrap_or(0) as u32) as u64,382usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,383mapped_at_creation: false,384});385let bind_group = render_device.create_bind_group(386"screenshot-to-screen-bind-group",387&pipeline_cache.get_bind_group_layout(&pipeline.bind_group_layout),388&BindGroupEntries::single(&texture_view),389);390let pipeline_id = pipelines.specialize(pipeline_cache, pipeline, format);391392(393texture_view,394ScreenshotPreparedState {395texture,396buffer,397bind_group,398pipeline_id,399size,400},401)402}403404pub struct ScreenshotPlugin;405406impl Plugin for ScreenshotPlugin {407fn build(&self, app: &mut bevy_app::App) {408embedded_asset!(app, "screenshot.wgsl");409410let (tx, rx) = std::sync::mpsc::channel();411app.insert_resource(CapturedScreenshots(Arc::new(Mutex::new(rx))))412.add_systems(413First,414clear_screenshots415.after(message_update_system)416.before(ApplyDeferred),417)418.add_systems(Update, trigger_screenshots);419420let Some(render_app) = app.get_sub_app_mut(RenderApp) else {421return;422};423424render_app425.insert_resource(RenderScreenshotsSender(tx))426.init_resource::<RenderScreenshotTargets>()427.init_resource::<RenderScreenshotsPrepared>()428.init_resource::<SpecializedRenderPipelines<ScreenshotToScreenPipeline>>()429.add_systems(RenderStartup, init_screenshot_to_screen_pipeline)430.add_systems(ExtractSchedule, extract_screenshots.ambiguous_with_all())431.add_systems(432Render,433prepare_screenshots434.after(prepare_view_attachments)435.before(prepare_view_targets)436.in_set(RenderSystems::ManageViews),437);438}439}440441#[derive(Resource)]442pub struct ScreenshotToScreenPipeline {443pub bind_group_layout: BindGroupLayoutDescriptor,444pub shader: Handle<Shader>,445}446447pub fn init_screenshot_to_screen_pipeline(mut commands: Commands, asset_server: Res<AssetServer>) {448let bind_group_layout = BindGroupLayoutDescriptor::new(449"screenshot-to-screen-bgl",450&BindGroupLayoutEntries::single(451wgpu::ShaderStages::FRAGMENT,452texture_2d(wgpu::TextureSampleType::Float { filterable: false }),453),454);455456let shader = load_embedded_asset!(asset_server.as_ref(), "screenshot.wgsl");457458commands.insert_resource(ScreenshotToScreenPipeline {459bind_group_layout,460shader,461});462}463464impl SpecializedRenderPipeline for ScreenshotToScreenPipeline {465type Key = TextureFormat;466467fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {468RenderPipelineDescriptor {469label: Some(Cow::Borrowed("screenshot-to-screen")),470layout: vec![self.bind_group_layout.clone()],471vertex: VertexState {472shader: self.shader.clone(),473..default()474},475primitive: wgpu::PrimitiveState {476cull_mode: Some(wgpu::Face::Back),477..Default::default()478},479multisample: Default::default(),480fragment: Some(FragmentState {481shader: self.shader.clone(),482targets: vec![Some(wgpu::ColorTargetState {483format: key,484blend: None,485write_mask: wgpu::ColorWrites::ALL,486})],487..default()488}),489..default()490}491}492}493494pub(crate) fn submit_screenshot_commands(world: &World, encoder: &mut CommandEncoder) {495let targets = world.resource::<RenderScreenshotTargets>();496let prepared = world.resource::<RenderScreenshotsPrepared>();497let pipelines = world.resource::<PipelineCache>();498let gpu_images = world.resource::<RenderAssets<GpuImage>>();499let windows = world.resource::<ExtractedWindows>();500let manual_texture_views = world.resource::<ManualTextureViews>();501502for (entity, render_target) in targets.iter() {503match render_target {504NormalizedRenderTarget::Window(window) => {505let window = window.entity();506let Some(window) = windows.get(&window) else {507continue;508};509let width = window.physical_width;510let height = window.physical_height;511let Some(texture_format) = window.swap_chain_texture_view_format else {512continue;513};514let Some(swap_chain_texture_view) = window.swap_chain_texture_view.as_ref() else {515continue;516};517render_screenshot(518encoder,519prepared,520pipelines,521entity,522width,523height,524texture_format,525swap_chain_texture_view,526);527}528NormalizedRenderTarget::Image(image) => {529let Some(gpu_image) = gpu_images.get(&image.handle) else {530warn!("Unknown image for screenshot, skipping: {:?}", image);531continue;532};533let width = gpu_image.texture_descriptor.size.width;534let height = gpu_image.texture_descriptor.size.height;535let texture_format = gpu_image.texture_descriptor.format;536let texture_view = gpu_image.texture_view.deref();537render_screenshot(538encoder,539prepared,540pipelines,541entity,542width,543height,544texture_format,545texture_view,546);547}548NormalizedRenderTarget::TextureView(texture_view) => {549let Some(texture_view) = manual_texture_views.get(texture_view) else {550warn!(551"Unknown manual texture view for screenshot, skipping: {:?}",552texture_view553);554continue;555};556let width = texture_view.size.x;557let height = texture_view.size.y;558let texture_format = texture_view.view_format;559let texture_view = texture_view.texture_view.deref();560render_screenshot(561encoder,562prepared,563pipelines,564entity,565width,566height,567texture_format,568texture_view,569);570}571NormalizedRenderTarget::None { .. } => {572// Nothing to screenshot!573}574};575}576}577578fn render_screenshot(579encoder: &mut CommandEncoder,580prepared: &RenderScreenshotsPrepared,581pipelines: &PipelineCache,582entity: &Entity,583width: u32,584height: u32,585texture_format: TextureFormat,586texture_view: &wgpu::TextureView,587) {588if let Some(prepared_state) = &prepared.get(entity) {589let extent = Extent3d {590width,591height,592depth_or_array_layers: 1,593};594encoder.copy_texture_to_buffer(595prepared_state.texture.as_image_copy(),596wgpu::TexelCopyBufferInfo {597buffer: &prepared_state.buffer,598layout: gpu_readback::layout_data(extent, texture_format),599},600extent,601);602603if let Some(pipeline) = pipelines.get_render_pipeline(prepared_state.pipeline_id) {604let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {605label: Some("screenshot_to_screen_pass"),606color_attachments: &[Some(wgpu::RenderPassColorAttachment {607view: texture_view,608depth_slice: None,609resolve_target: None,610ops: wgpu::Operations {611load: wgpu::LoadOp::Load,612store: wgpu::StoreOp::Store,613},614})],615depth_stencil_attachment: None,616timestamp_writes: None,617occlusion_query_set: None,618multiview_mask: None,619});620pass.set_pipeline(pipeline);621pass.set_bind_group(0, &prepared_state.bind_group, &[]);622pass.draw(0..3, 0..1);623}624}625}626627pub(crate) fn collect_screenshots(world: &mut World) {628#[cfg(feature = "trace")]629let _span = bevy_log::info_span!("collect_screenshots").entered();630631let sender = world.resource::<RenderScreenshotsSender>().deref().clone();632let prepared = world.resource::<RenderScreenshotsPrepared>();633634for (entity, prepared) in prepared.iter() {635let entity = *entity;636let sender = sender.clone();637let width = prepared.size.width;638let height = prepared.size.height;639let texture_format = prepared.texture.format();640let Ok(pixel_size) = texture_format.pixel_size() else {641continue;642};643let buffer = prepared.buffer.clone();644645let finish = async move {646let (tx, rx) = async_channel::bounded(1);647let buffer_slice = buffer.slice(..);648// The polling for this map call is done every frame when the command queue is submitted.649buffer_slice.map_async(wgpu::MapMode::Read, move |result| {650if let Err(err) = result {651panic!("{}", err.to_string());652}653tx.try_send(()).unwrap();654});655rx.recv().await.unwrap();656let data = buffer_slice.get_mapped_range();657// we immediately move the data to CPU memory to avoid holding the mapped view for long658let mut result = Vec::from(&*data);659drop(data);660661if result.len() != ((width * height) as usize * pixel_size) {662// Our buffer has been padded because we needed to align to a multiple of 256.663// We remove this padding here664let initial_row_bytes = width as usize * pixel_size;665let buffered_row_bytes =666gpu_readback::align_byte_size(width * pixel_size as u32) as usize;667668let mut take_offset = buffered_row_bytes;669let mut place_offset = initial_row_bytes;670for _ in 1..height {671result.copy_within(take_offset..take_offset + buffered_row_bytes, place_offset);672take_offset += buffered_row_bytes;673place_offset += initial_row_bytes;674}675result.truncate(initial_row_bytes * height as usize);676}677678if let Err(e) = sender.send((679entity,680Image::new(681Extent3d {682width,683height,684depth_or_array_layers: 1,685},686wgpu::TextureDimension::D2,687result,688texture_format,689RenderAssetUsages::RENDER_WORLD,690),691)) {692error!("Failed to send screenshot: {}", e);693}694};695696AsyncComputeTaskPool::get().spawn(finish).detach();697}698}699700701