Skip to content

Screenshot::image() no longer works with off-screen textures not attached to a Camera's RenderTarget #25215

Open

Description

@MisteryDust

Bevy version and features

bevy = { version = "0.19.0", default-features = false, features = [
"android-game-activity",
"async_executor",
"bevy_anti_alias",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_core_pipeline",
"bevy_gizmos",
"bevy_gizmos_render",
"bevy_gltf",
"bevy_image",
"bevy_input_focus",
"bevy_light",
"bevy_log",
"bevy_mesh",
"bevy_mikktspace",
"bevy_pbr",
"bevy_picking",
"bevy_post_process",
"bevy_render",
"bevy_scene",
"bevy_shader",
"bevy_state",
"bevy_text",
"bevy_ui",
"bevy_ui_render",
"bevy_window",
"bevy_winit",
"default_font",
"hdr",
"jpeg",
"ktx2",
"mesh_picking",
"multi_threaded",
"png",
"reflect_auto_register",
"std",
"sysinfo_plugin",
"tonemapping_luts",
"ui_picking",
"wayland",
"webgl2",
"x11",
"zstd_rust",
] }

What you did

I created an off-screen texture via myself, and wrote content to it using my own rendering pipeline—without attaching it to any Camera's RenderTarget. Then I called Screenshot::image(image_handle) to save the texture content as an image file.

Previously, this workflow worked perfectly: the screenshot API would capture the current content of any valid image handle, regardless of whether it belonged to a camera's render target.

What went wrong

Now, Screenshot::image(image_handle) no longer works for off-screen textures.

What I expected: The API to output the texture's current pixel data as an image, just like it did in earlier versions.

What actually happened: The output is blank. It seems the screenshot function now requires the image_handle to be strictly tied to a Camera's RenderTarget::Image(). To get any usable output, I have to move my rendering logic into the PostProcess stage and attach the texture to a camera.

Additional information

Question: Is this change intentional (e.g., design decision or optimization), or is it a regression? If intentional, do you plan to restore support for off-screen textures in future releases? Or should I assume this is no longer supported and implement my own export logic?

Activity

  1. added
    C-BugAn unexpected or incorrect behavior
    S-Needs-TriageThis issue needs to be labelled
    on Jul 29, 2026
  2. added 2 commits that reference this issue on Jul 30, 2026
    f589542
    814924e
  3. added
    A-RenderingDrawing game state to the screen
    S-Needs-InvestigationThis issue requires detective work to figure out what's going wrong
    and removed
    S-Needs-TriageThis issue needs to be labelled
    on Aug 6, 2026
  4. moved this to Needs SME Triage in Renderingon Aug 6, 2026
  5. AIMentalModel commented on Sep 28, 2026

    @AIMentalModel

    I can reproduce a broader form of this on 0.19 — in my case the output is all-black even when the texture is attached to a camera's RenderTarget::Image, and Screenshot::primary_window() is equally affected.

    Environment

    • bevy 0.19.0/0.19.1 (wgpu 29); reproduced with default features and with a minimal feature set
    • macOS 26.6.2 (kernel 25.6.0), Apple M1, Metal: AdapterInfo { name: "Apple M1", device_type: IntegratedGpu, backend: Metal }
    • Reproduced in all of: visible window, visible: false window, and headless (WinitPlugin disabled + ScheduleRunnerPlugin)

    Key evidence: rendering and GPU readback are both fine

    Same process, same texture: a manual copy_texture_to_buffer + map_async readback (the approach from examples/app/headless_renderer.rs) returns the correct pixels, while Screenshot::image on that texture returns all zeros:

    manual copy readback: 3686400 bytes, 50% nonzero   (solid-red ClearColor → R+A channels, exactly as expected)
    Screenshot::image:    0% nonzero                   (all black)
    

    No wgpu validation errors at WGPU_LOG=warn. The capture reports success (completion: screenshot_captured, correct dimensions, file written) — it's just full of zeros.

    So this looks like a regression in the Screenshot system's internal blit pipeline (crates/bevy_render/src/view/window/screenshot.rs), not an intentional scoping change — unless dropping support for camera-attached render targets and the primary window is also intended. The blit step in ScreenshotPreparedState (offscreen texture + pipeline) is my prime suspect.

    Minimal reproduction

    use bevy::{
        app::{AppExit, ScheduleRunnerPlugin},
        camera::RenderTarget,
        prelude::*,
        render::{
            render_resource::{TextureFormat, TextureUsages},
            view::screenshot::{save_to_disk, Screenshot, ScreenshotCaptured},
        },
        window::ExitCondition,
        winit::WinitPlugin,
    };
    use std::time::Duration;
    
    fn main() {
        App::new()
            .insert_resource(ClearColor(Color::srgb(1.0, 0.0, 0.0))) // solid red
            .add_plugins(
                DefaultPlugins
                    .set(WindowPlugin {
                        primary_window: None,
                        exit_condition: ExitCondition::DontExit,
                        ..default()
                    })
                    .disable::<WinitPlugin>(),
            )
            .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(1.0 / 60.0)))
            .add_systems(Startup, setup)
            .add_systems(Update, capture_at_frame_30)
            .run();
    }
    
    #[derive(Resource, Clone)]
    struct Target(Handle<Image>);
    
    fn setup(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
        let mut img = Image::new_target_texture(1280, 720, TextureFormat::Rgba8UnormSrgb, None);
        img.texture_descriptor.usage |= TextureUsages::COPY_SRC;
        let handle = images.add(img);
        commands.spawn((Camera2d, RenderTarget::Image(handle.clone().into())));
        commands.insert_resource(Target(handle));
    }
    
    fn capture_at_frame_30(mut commands: Commands, target: Option<Res<Target>>, mut frame: Local<u32>) {
        *frame += 1;
        if *frame != 30 { return; }
        let Some(target) = target else { return };
        commands
            .spawn(Screenshot::image(target.0.clone()))
            .observe(save_to_disk("shot.png"))
            .observe(|_: On<ScreenshotCaptured>, mut exit: MessageWriter<AppExit>| {
                exit.write(AppExit::Success);
            });
    }

    Expected: shot.png is solid red. Actual: every pixel byte is 0. The manual-readback counterpart harness (extracts the same GpuImage in the Render schedule and copies it to a MAP_READ buffer) prints the correct data — happy to share it if useful.

    Related: PR #25229 attempted a fix for the no-camera case, but its author closed it unmerged before GPU verification — so no fix has landed in any release, and the broader camera-attached / primary-window cases reported above remain unaddressed.

    One more note: bevy main moved to wgpu 30 in July (#24841), so if this involves a wgpu 29 interaction it may already behave differently on main — I haven't tested main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-RenderingDrawing game state to the screenC-BugAn unexpected or incorrect behaviorS-Needs-InvestigationThis issue requires detective work to figure out what's going wrong

    Type

    No type

    Projects

    • Status
      Needs SME Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions