diff --git a/native/shared/src/renderer/texture_store.rs b/native/shared/src/renderer/texture_store.rs index 751b0f8..ae5ebc8 100644 --- a/native/shared/src/renderer/texture_store.rs +++ b/native/shared/src/renderer/texture_store.rs @@ -362,7 +362,11 @@ impl Renderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: self.surface_config.format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + // COPY_SRC so render-target contents are readable (tests, + // screenshots of offscreen renders). + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); diff --git a/native/shared/tests/render_targets.rs b/native/shared/tests/render_targets.rs index 7348f37..f1f7971 100644 --- a/native/shared/tests/render_targets.rs +++ b/native/shared/tests/render_targets.rs @@ -17,6 +17,8 @@ //! whenever a scene-reading translucent material was in view. use bloom_shared::renderer::Renderer; +use bloom_shared::scene::SceneGraph; +use bloom_shared::profiler::Profiler; fn try_renderer() -> Option { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { @@ -74,3 +76,112 @@ fn render_targets_track_render_extent_through_changes() { r.set_taa_enabled(false); assert_eq!(r.render_target_extent(), r.render_extent(), "after set_taa_enabled(false)"); } + +/// The deferred frame path must honor the render-target override — the +/// regression this pins: until 2026-07-17, `end_frame_with_scene` acquired +/// the surface unconditionally and ignored `rt_color_view`, so +/// render-to-texture silently no-oped for every 3D app (the editor's +/// thumbnails drew as nothing; even a bare clear never reached the texture). +/// +/// The test shape makes the old behavior impossible to miss: headless there +/// is no surface, so without the override branch the frame early-returns and +/// the RT stays all zeros. With the fix, the frame's final passes write the +/// RT — we read it back and require the clear color to have arrived. +#[test] +fn deferred_frame_writes_render_target_override() { + let Some(mut r) = try_renderer() else { + eprintln!("no GPU adapter — skipping"); + return; + }; + + const W: u32 = 64; + const H: u32 = 64; + let (_bg_idx, tex_idx) = r.create_render_texture(W, H); + + // Arm the override exactly the way bloom_begin_texture_mode does. + let color_view = r + .get_texture_ref(tex_idx) + .expect("render texture registered") + .create_view(&wgpu::TextureViewDescriptor::default()); + let depth_tex = r.device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_rt_depth"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + r.rt_depth_view = Some(depth_tex.create_view(&wgpu::TextureViewDescriptor::default())); + r.rt_depth_texture = Some(depth_tex); + r.rt_color_view = Some(color_view); + r.rt_width = W; + r.rt_height = H; + + // A distinctive clear so "the frame arrived" is unambiguous. + r.set_clear_color(255.0, 0.0, 255.0, 255.0); + + let mut scene = SceneGraph::new(); + let mut profiler = Profiler::new(); + r.begin_frame(); + r.end_frame_with_scene(&mut scene, &mut profiler); + + // Clear the override (endTextureMode). + r.end_texture_mode(); + + // Read the RT back and look at the center pixel. + let tex = r.get_texture_ref(tex_idx).expect("render texture registered"); + let bpr = (W * 4 + 255) & !255; + let buf = r.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("test_rt_readback"), + size: (bpr * H) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut enc = r + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + r.queue.submit(std::iter::once(enc.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |res| { + let _ = tx.send(res); + }); + let _ = r.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); + rx.recv().expect("map callback").expect("map ok"); + + let data = slice.get_mapped_range(); + let center = ((H / 2) * bpr + (W / 2) * 4) as usize; + let px = [data[center], data[center + 1], data[center + 2], data[center + 3]]; + drop(data); + + // Channel order differs by surface format (RGBA vs BGRA) and the clear + // rides through tonemapping — so the contract is deliberately loose: + // the pixel must be MAGENTA-ISH (both outer channels well above the + // middle one), which an untouched all-zeros texture can never satisfy. + assert!( + px[0] > 60 && px[2] > 60 && px[1] < px[0] / 2 && px[1] < px[2] / 2, + "render target did not receive the deferred frame (center pixel {:?}) — \ + end_frame_with_scene is ignoring rt_color_view again", + px + ); +}