From cd4f1a6efbaf96d8d95eb73c31af8e67d4d87615 Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Thu, 8 Oct 2026 00:10:24 +0800 Subject: [PATCH] refactor(renderer): require prepared child composition resources --- .../render_thread/render_pass/chrome/mod.rs | 5 +- .../render_thread/render_pass/full_render.rs | 12 +- .../src/render_thread/render_pass/mod.rs | 23 ++- .../render_pass/retained_static.rs | 7 +- .../src/render_thread/render_pass/scene.rs | 63 +++++--- .../scene/native_boundary_tests.rs | 6 +- .../scene/pane_size_boundary_tests.rs | 6 +- .../render_pass/scene/retained_owner_tests.rs | 3 +- .../render_thread/render_pass/scene/tests.rs | 89 +++++++++- .../src/renderer/child_frames.rs | 152 +++++++++++++++--- .../child_frames/tests/child_frames_test.rs | 25 +++ .../neomacs-renderer-wgpu/src/renderer/mod.rs | 2 +- .../tests/offscreen_frame/opacity_test.rs | 72 +++++++++ 13 files changed, 392 insertions(+), 73 deletions(-) diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs index d7e508cf94..d4c0afcff4 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs @@ -52,7 +52,7 @@ pub(super) fn render_frame_window_overlays_with_toolbar_resources( child_frame_style: &ChildFrameStyle, scroll_indicators_enabled: bool, toolbar: &ToolbarResources, -) { +) -> Result<(), super::surface::FrameRenderFailure> { // Content overlays are the *editor's* picture — child frames, breadcrumbs, // scroll indicators, watermarks — and belong to `scene`, which is where // they are drawn. They open this pass rather than closing `scene` because @@ -68,7 +68,7 @@ pub(super) fn render_frame_window_overlays_with_toolbar_resources( animated_cursor, child_frame_style, scroll_indicators_enabled, - ); + )?; frame_bands::draw(renderer, native, render, surface_view, frame, toolbar); transient_overlays::draw_panels( @@ -101,4 +101,5 @@ pub(super) fn render_frame_window_overlays_with_toolbar_resources( native.content_size().1, ); transient_overlays::draw_typing_speed(renderer, render, surface_view, frame); + Ok(()) } diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs index 406e44cf64..64548f6e99 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs @@ -40,7 +40,7 @@ pub(super) fn through_composition_ring( cursor_visible: bool, accept_derived_effects: bool, pane_blits: &[PaneBlit], -) { +) -> Result<(), super::surface::FrameRenderFailure> { super::render_frame_window_contents( renderer, native, @@ -50,7 +50,7 @@ pub(super) fn through_composition_ring( inputs, cursor_visible, false, - ); + )?; detect_transitions(acquired, renderer, render, frame, accept_derived_effects); if render.compositor.renderer_effects.needs_redraw() { @@ -106,7 +106,8 @@ pub(super) fn through_composition_ring( inputs.child_frame_style, inputs.scroll_indicators_enabled, inputs.toolbar, - ); + )?; + Ok(()) } /// Place and pin a pane picture using the content-local geometry supplied by @@ -155,7 +156,7 @@ pub(super) fn onto_target( inputs: &FrameDrawInputs<'_>, cursor_visible: bool, accept_derived_effects: bool, -) { +) -> Result<(), super::surface::FrameRenderFailure> { super::render_frame_window_contents( renderer, native, @@ -165,9 +166,10 @@ pub(super) fn onto_target( inputs, cursor_visible, true, - ); + )?; detect_transitions(acquired, renderer, render, frame, accept_derived_effects); render.mark_active_visuals_dirty(); + Ok(()) } /// Measure this frame against the last presented one and start whatever motion diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs index 7befe69e37..e4a5e5900e 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs @@ -106,7 +106,7 @@ fn render_frame_window_contents( inputs: &FrameDrawInputs<'_>, cursor_visible: bool, include_overlays: bool, -) { +) -> Result<(), FrameRenderFailure> { scene::render_frame_root_glyphs( renderer, render, @@ -122,7 +122,7 @@ fn render_frame_window_contents( if !include_overlays { render.set_dirty(renderer_effects_still_active); - return; + return Ok(()); } chrome::render_frame_window_overlays_with_toolbar_resources( @@ -136,10 +136,11 @@ fn render_frame_window_contents( inputs.child_frame_style, inputs.scroll_indicators_enabled, inputs.toolbar, - ); + )?; if renderer_effects_still_active { render.mark_dirty(); } + Ok(()) } fn composition_surface( @@ -412,7 +413,7 @@ fn render_frame_window_contents_reserved( .unwrap_or((-1.0, -1.0)); if retained_static::is_eligible(compositor_only_hint, &pane_blits, render) { let hovered_scroll_bar = render.hovered_scroll_bar(&frame); - retained_static::draw( + let draw_result = retained_static::draw( renderer, native, render, @@ -422,6 +423,12 @@ fn render_frame_window_contents_reserved( cursor_visible, hovered_scroll_bar, ); + if let Err(error) = draw_result { + renderer.set_scale_factor(old_scale_factor); + renderer.resize(old_width, old_height); + render.mark_dirty(); + return Err(error); + } if frame_post_active { renderer.frame_post_to_view( &composition_view, @@ -462,7 +469,7 @@ fn render_frame_window_contents_reserved( let composition = need_offscreen .then(|| composition_targets::advance_frame_composition(renderer, render, surface_size)) .flatten(); - match composition.as_ref() { + let draw_result = match composition.as_ref() { Some(composition) => full_render::through_composition_ring( &acquired, renderer, @@ -487,6 +494,12 @@ fn render_frame_window_contents_reserved( cursor_visible, feature_plan.accept_derived_effects, ), + }; + if let Err(error) = draw_result { + renderer.set_scale_factor(old_scale_factor); + renderer.resize(old_width, old_height); + render.mark_dirty(); + return Err(error); } if frame_post_active { diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs index a5402b77bf..bb670d0ab4 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs @@ -85,13 +85,13 @@ pub(super) fn draw( inputs: &FrameDrawInputs<'_>, cursor_visible: bool, hovered_scroll_bar: Option, -) { +) -> Result<(), super::surface::FrameRenderFailure> { let Some(native_size) = SnapshotSize::new(native.content_size().0, native.content_size().1) else { // wgpu rejects a zero extent, so there is no scene to retain and // nothing to composite from. Reaching this would mean the window was // asked to draw at a size `resize` already refuses to configure. - return; + return Ok(()); }; let generation = render.compositor.current_scene_generation; let retained_valid = is_valid(render, native_size); @@ -116,7 +116,7 @@ pub(super) fn draw( inputs, false, true, - ); + )?; let cells = build_filled_box_cursor_cells( frame, native.scale_factor as f32, @@ -160,6 +160,7 @@ pub(super) fn draw( ); } frame_stats::count(&frame_stats::COMPOSITE_ONLY_FRAMES); + Ok(()) } /// Build a single-glyph mini-frame for each filled-box cursor in the frame diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs index a76a8f3d28..f8f360044c 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs @@ -99,7 +99,7 @@ pub(super) fn render_frame_content_overlays( animated_cursor: Option, child_frame_style: &ChildFrameStyle, scroll_indicators_enabled: bool, -) { +) -> Result<(), super::surface::FrameRenderFailure> { let pointer_appearance = render.pointer_appearance; // One sample drives every child-frame lifecycle animation on this // surface. Sampling -- not stepping -- is what makes a frame redrawn at @@ -122,7 +122,7 @@ pub(super) fn render_frame_content_overlays( // Whether the corpse-clearing (or crossfade-closing) repaint was // requested this pass. let mut crossfade_finished = false; - renderer.with_frame_effects(&mut render.compositor.renderer_effects, |renderer| { + let result = renderer.with_frame_effects(&mut render.compositor.renderer_effects, |renderer| { // One merged draw order: living frames and dying corpses // interleaved in z-path order, a corpse drawing before a living // frame at equal z -- a dismissed popup is normally being replaced @@ -309,6 +309,32 @@ pub(super) fn render_frame_content_overlays( scale, ) }; + let resize = crossfade_layer.map(|(old, old_width, old_height, mix)| { + neomacs_renderer_wgpu::renderer::ChildResizePicture { + old, + old_width, + old_height, + mix, + } + }); + let size = neomacs_renderer_wgpu::SnapshotSize::new(content_size.0, content_size.1) + .ok_or(super::surface::FrameRenderFailure::WindowNotReady)?; + let prepared = neomacs_renderer_wgpu::renderer::PreparedChildFrame::new( + child_frame, + alpha, + size, + render.child_opacity_src.as_ref(), + resize, + render.child_resize_src.as_ref(), + ) + .map_err(|error| { + tracing::error!( + ?error, + child_id, + "child preparation rejected; retrying without present" + ); + super::surface::FrameRenderFailure::WindowNotReady + })?; let pointer_selection = pointer_appearance.selection_for(child_frame); if let Some(atlas) = render.compositor.glyph_atlas.as_mut() { atlas.set_current_frame_fonts(child_frame.font_bindings()); @@ -335,15 +361,16 @@ pub(super) fn render_frame_content_overlays( "child_frame_lifecycle: render_child_frame_start" ); } + if crossfade_layer.is_some() { + frame_stats::count(&frame_stats::CHILD_FRAME_CROSSFADE_QUADS); + } renderer.render_child_frame_prepared( surface_view, - child_frame, + prepared, base_x, base_y + offset_y, clip_in_root, render.compositor.glyph_atlas.as_mut().unwrap(), - content_size.0, - content_size.1, cursor_visible, animated_cursor.filter(|ac| ac.frame_id == DisplayFrameId::new(child_id)), child_frame_style.corner_radius, @@ -352,25 +379,10 @@ pub(super) fn render_frame_content_overlays( child_frame_style.shadow_offset, child_frame_style.shadow_opacity, pointer_selection, - alpha, scale, // The scale anchors at the frame's drawn top-left, so the // picture grows outward from the point that anchored it. [base_x, base_y + offset_y], - render.child_opacity_src.as_ref(), - crossfade_layer.map(|(old, old_width, old_height, mix)| { - frame_stats::count(&frame_stats::CHILD_FRAME_CROSSFADE_QUADS); - neomacs_renderer_wgpu::renderer::ChildResizePicture { - old, - old_width, - old_height, - mix, - composition: render - .child_resize_src - .as_ref() - .expect("resize composition was preflighted"), - } - }), ); if !dying { tracing::debug!( @@ -380,7 +392,13 @@ pub(super) fn render_frame_content_overlays( ); } } + Ok(()) }); + // Restore the root font bindings even when a later child fails preparation. + if let Some(atlas) = render.compositor.glyph_atlas.as_mut() { + atlas.set_current_frame_fonts(frame.font_bindings()); + } + result?; // The pass reports continued animation the only way it may: by marking // the frame dirty, so the scheduler asks for another one. Finished dying // frames are pruned afterwards, on the same sample they were drawn with. @@ -422,10 +440,6 @@ pub(super) fn render_frame_content_overlays( render.mark_dirty(); } - if let Some(atlas) = render.compositor.glyph_atlas.as_mut() { - atlas.set_current_frame_fonts(frame.font_bindings()); - } - renderer.with_frame_effects(&mut render.compositor.renderer_effects, |renderer| { render_frame_common_overlays( renderer, @@ -440,6 +454,7 @@ pub(super) fn render_frame_content_overlays( if render.compositor.renderer_effects.needs_redraw() { render.mark_dirty(); } + Ok(()) } fn render_frame_common_overlays( diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs index 52233f28ad..27fce811f6 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs @@ -96,7 +96,8 @@ fn obsolete_native_owner_retires_before_required_child_under_same_pressure() { None, &style, false, - ); + ) + .unwrap(); renderer.place_native_content_with_opacity( NativeContentPlacement::new(RenderTarget::new(&view, surface), &retained).unwrap(), root.background, @@ -172,7 +173,8 @@ fn obsolete_native_owner_retires_before_required_child_under_same_pressure() { None, &style, false, - ); + ) + .unwrap(); let data = pixels(&renderer, &out); assert_eq!(px(&data, 60, 40), [0, 0, 255, 255], "sibling order"); assert!( diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs index fc3a388f42..c6142327ee 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs @@ -356,7 +356,8 @@ fn pane_conversion_refuses_before_motion_and_recovers_under_held_pressure() { None, &style, false, - ); + ) + .unwrap(); assert_eq!( px(&pixels(&renderer, &out), 48, 40)[3], 255, @@ -452,7 +453,8 @@ fn wrong_size_required_native_owner_retires_before_child_under_same_pressure() { None, &style, false, - ); + ) + .unwrap(); let out = target(&renderer); let view = out.create_view(&Default::default()); renderer.place_native_content_with_opacity( diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs index a72ce8ca74..7f36b02cfa 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs @@ -179,7 +179,8 @@ fn present( None, &style, false, - ); + ) + .unwrap(); if let Some(native) = &result.native { renderer.place_native_content_with_opacity( NativeContentPlacement::new( diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs index 1bce252049..4f90e4b65d 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs @@ -254,7 +254,8 @@ fn mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure() { None, &style, false, - ); + ) + .unwrap(); let data = pixels(&renderer, &target); assert_eq!(px(&data, 48, 40), [0, 255, 0, 255]); assert_eq!( @@ -325,7 +326,8 @@ fn mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure() { None, &style, false, - ); + ) + .unwrap(); let data = pixels(&renderer, &target); assert!((127..=129).contains(&px(&data, 48, 40)[3])); assert_eq!(px(&data, 25, 25), [255; 4], "foreground remains opaque"); @@ -564,7 +566,8 @@ fn expired_child_resize_recovers_under_unchanged_budget_pressure() { None, &style, false, - ); + ) + .unwrap(); let data = pixels(&renderer, &target); assert_eq!(px(&data, 48, 40), [0, 0, 255, 255]); assert_eq!( @@ -610,7 +613,8 @@ fn expired_child_resize_recovers_under_unchanged_budget_pressure() { None, &style, false, - ); + ) + .unwrap(); assert!((127..=129).contains(&px(&pixels(&renderer, &target), 48, 40)[3])); } @@ -1054,7 +1058,8 @@ fn production_child_resize_applies_complete_picture_opacity_once() { None, &style, false, - ); + ) + .unwrap(); let data = pixels(&renderer, &target); let bg = px(&data, 48, 40); let fg = px(&data, 25, 25); @@ -1106,3 +1111,77 @@ fn production_child_resize_applies_complete_picture_opacity_once() { } } } + +#[test] +fn missing_child_scratch_returns_retry_without_drawing_and_recovers() { + let mut renderer = + WgpuRenderer::new(None, W, H).expect("GPU required for preparation regression"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = picture(42, 0, W as f32); + root.height = H as f32; + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root.background = Color::BLUE; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let mut child = picture(43, 42, 40.0); + child.background_alpha = 0.5; + assert!(render.compositor.child_frames.update_frame(child)); + let texture = target(&renderer); + let view = texture.create_view(&Default::default()); + draw_root(&mut renderer, &mut render, &view, &root); + let before = pixels(&renderer, &texture); + let interaction = format!("{:?}", render.compositor.interaction); + let style = ChildFrameStyle::default(); + assert!(matches!( + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false + ), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert_eq!(pixels(&renderer, &texture), before); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + (render.child_opacity_src, render.child_resize_src) = + super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + SnapshotSize::new(W, H).unwrap(), + ) + .unwrap(); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ) + .unwrap(); + assert_ne!(pixels(&renderer, &texture), before); +} diff --git a/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs b/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs index f7cacf44dd..954992f4f1 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs @@ -7,13 +7,116 @@ use neomacs_display_protocol::frame_glyphs::FrameGlyphBuffer; use neomacs_display_protocol::types::{AnimatedCursor, Color}; use neomacs_display_protocol::{PointerAppearanceSelection, RootSurfaceRect}; -/// Unscaled old picture and preflighted scratch for a child resize mix. +/// Unscaled old picture and mix parameters for child resize preparation. pub struct ChildResizePicture<'a> { pub old: &'a super::SnapshotLease, pub old_width: f32, pub old_height: f32, pub mix: f32, - pub composition: &'a super::SnapshotLease, +} + +/// A child picture whose opacity and required composition resources were checked. +/// Consumed by rendering; the borrowed leases stay live until that draw finishes. +/// +/// The prepared payload cannot acquire a new resource requirement after checking: +/// ```compile_fail +/// use neomacs_display_protocol::frame_glyphs::FrameGlyphBuffer; +/// use neomacs_renderer_wgpu::{renderer::PreparedChildFrame, SnapshotSize}; +/// let mut child = FrameGlyphBuffer::with_size(96.0, 64.0); +/// let prepared = PreparedChildFrame::new( +/// &child, 1.0, SnapshotSize::new(96, 64).unwrap(), None, None, None, +/// ).unwrap(); +/// child.background_alpha = 0.5; +/// drop(prepared); +/// ``` +/// A draw consumes its preparation: +/// ```compile_fail +/// use neomacs_renderer_wgpu::renderer::PreparedChildFrame; +/// fn consume(prepared: PreparedChildFrame<'_>) { +/// drop(prepared); +/// drop(prepared); +/// } +/// ``` +pub struct PreparedChildFrame<'a> { + child: &'a FrameGlyphBuffer, + alpha: f32, + size: super::SnapshotSize, + composition: ChildComposition<'a>, +} + +enum ChildComposition<'a> { + Direct, + Picture { + picture: &'a super::SnapshotLease, + resize: Option<(ChildResizePicture<'a>, &'a super::SnapshotLease)>, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ChildPreparationError { + #[error("child picture composition requires a scratch lease")] + MissingPicture, + #[error("child resize composition requires a second scratch lease")] + MissingResizePicture, + #[error("child scratch dimensions do not match the render target")] + WrongSize, + #[error("child resize composition cannot sample its render attachment")] + AliasedResizePicture, +} + +fn child_alpha(alpha: f32) -> f32 { + if alpha.is_finite() { + alpha.clamp(0.0, 1.0) + } else { + 1.0 + } +} + +impl<'a> PreparedChildFrame<'a> { + /// Validate pre-admitted resources without allocating or drawing anything. + /// Opacity and frame payload travel with the proof, so the draw cannot + /// independently replace them with inputs requiring unreserved resources. + pub fn new( + child: &'a FrameGlyphBuffer, + alpha: f32, + size: super::SnapshotSize, + picture: Option<&'a super::SnapshotLease>, + resize: Option>, + resize_picture: Option<&'a super::SnapshotLease>, + ) -> Result { + let alpha = child_alpha(alpha); + let composition = if child.background_alpha != 1.0 || alpha != 1.0 || resize.is_some() { + let picture = picture.ok_or(ChildPreparationError::MissingPicture)?; + if picture.size() != size { + return Err(ChildPreparationError::WrongSize); + } + let resize = resize + .map(|resize| { + let mixed = + resize_picture.ok_or(ChildPreparationError::MissingResizePicture)?; + if mixed.size() != size { + return Err(ChildPreparationError::WrongSize); + } + if mixed.id() == picture.id() + || mixed.id() == resize.old.id() + || picture.id() == resize.old.id() + { + return Err(ChildPreparationError::AliasedResizePicture); + } + Ok((resize, mixed)) + }) + .transpose()?; + ChildComposition::Picture { picture, resize } + } else { + ChildComposition::Direct + }; + Ok(Self { + child, + alpha, + size, + composition, + }) + } } impl WgpuRenderer { @@ -210,6 +313,7 @@ impl WgpuRenderer { ) -> Result<(), super::BudgetExceeded> { let size = super::SnapshotSize::new(surface_width, surface_height) .expect("child target dimensions are nonzero"); + let alpha = child_alpha(alpha); let picture = if child.background_alpha != 1.0 || alpha != 1.0 { Some(self.acquire_snapshot(size)?) } else { @@ -217,13 +321,22 @@ impl WgpuRenderer { }; self.render_child_frame_prepared( view, - child, + PreparedChildFrame { + child, + alpha, + size, + composition: match picture.as_ref() { + Some(picture) => ChildComposition::Picture { + picture, + resize: None, + }, + None => ChildComposition::Direct, + }, + }, offset_x, offset_y, clip_in_root, glyph_atlas, - surface_width, - surface_height, cursor_visible, animated_cursor, corner_radius, @@ -232,11 +345,8 @@ impl WgpuRenderer { shadow_offset, shadow_opacity, pointer_selection, - alpha, scale, pivot, - picture.as_ref(), - None, ); Ok(()) } @@ -247,13 +357,11 @@ impl WgpuRenderer { pub fn render_child_frame_prepared( &mut self, view: &wgpu::TextureView, - child: &FrameGlyphBuffer, + prepared: PreparedChildFrame<'_>, offset_x: f32, offset_y: f32, clip_in_root: RootSurfaceRect, glyph_atlas: &mut WgpuGlyphAtlas, - surface_width: u32, - surface_height: u32, cursor_visible: bool, animated_cursor: Option, corner_radius: f32, @@ -262,19 +370,18 @@ impl WgpuRenderer { shadow_offset: f32, shadow_opacity: f32, pointer_selection: Option, - alpha: f32, scale: f32, pivot: [f32; 2], - picture: Option<&super::SnapshotLease>, - resize: Option>, ) { - let alpha = if alpha.is_finite() { - alpha.clamp(0.0, 1.0) - } else { - 1.0 - }; - if child.background_alpha != 1.0 || alpha != 1.0 || resize.is_some() { - let picture = picture.expect("non-opaque child composition was preflighted"); + let PreparedChildFrame { + child, + alpha, + size, + composition, + } = prepared; + let surface_width = size.width(); + let surface_height = size.height(); + if let ChildComposition::Picture { picture, resize } = composition { self.clear_child_picture(picture.view()); // Backgrounds replace one another inside the child's own picture; // text is source-over coverage. Apply lifecycle opacity only once, @@ -300,8 +407,7 @@ impl WgpuRenderer { scale, pivot, ); - let composed = if let Some(resize) = resize { - let mixed = resize.composition; + let composed = if let Some((resize, mixed)) = resize { self.clear_child_picture(mixed.view()); let scissor = self.child_frame_scissor(clip_in_root, surface_width, surface_height); // Both pictures are already premultiplied. Sum their weighted diff --git a/crates/neomacs-renderer-wgpu/src/renderer/child_frames/tests/child_frames_test.rs b/crates/neomacs-renderer-wgpu/src/renderer/child_frames/tests/child_frames_test.rs index c38c19ecc5..cca9a57fba 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/child_frames/tests/child_frames_test.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/child_frames/tests/child_frames_test.rs @@ -11,3 +11,28 @@ fn child_clip_outside_surface_has_no_renderable_scissor() { let clip = RootSurfaceRect::new(300.0, 300.0, 20.0, 20.0).unwrap(); assert_eq!(child_scissor(clip, 1.0, 200, 200), None); } + +#[test] +fn opaque_child_preparation_needs_no_scratch() { + let child = FrameGlyphBuffer::with_size(96.0, 64.0); + let size = super::super::SnapshotSize::new(96, 64).unwrap(); + let prepared = PreparedChildFrame::new(&child, 1.0, size, None, None, None).unwrap(); + assert!(matches!(prepared.composition, ChildComposition::Direct)); + // Match the existing draw boundary's normalization of invalid alpha. + assert!(PreparedChildFrame::new(&child, f32::NAN, size, None, None, None).is_ok()); +} + +#[test] +fn fractional_child_preparation_refuses_missing_scratch() { + let mut child = FrameGlyphBuffer::with_size(96.0, 64.0); + let size = super::super::SnapshotSize::new(96, 64).unwrap(); + assert_eq!( + PreparedChildFrame::new(&child, 0.5, size, None, None, None).err(), + Some(ChildPreparationError::MissingPicture) + ); + child.background_alpha = 0.5; + assert_eq!( + PreparedChildFrame::new(&child, 1.0, size, None, None, None).err(), + Some(ChildPreparationError::MissingPicture) + ); +} diff --git a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs index 7e66aa9d40..a7f5acb66c 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs @@ -18,7 +18,7 @@ use super::webview_cache::WgpuWebViewCache; mod box_tessellation; mod child_frames; -pub use child_frames::ChildResizePicture; +pub use child_frames::{ChildPreparationError, ChildResizePicture, PreparedChildFrame}; mod composition_ring; pub use composition_ring::CompositionRing; mod content; diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs index 809dc9941f..2f9b9826fc 100644 --- a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs @@ -889,3 +889,75 @@ fn source_over_transitions_fill_uncovered_geometry_with_the_background() { } } } + +#[test] +fn prepared_child_rejects_missing_wrong_size_and_aliased_resize_scratch() { + use neomacs_renderer_wgpu::renderer::{ + ChildPreparationError, ChildResizePicture, PreparedChildFrame, + }; + let mut h = try_harness().expect("GPU required for child preparation regression"); + let child = FrameGlyphBuffer::with_size(W as f32, H as f32); + let size = SnapshotSize::new(W, H).unwrap(); + let picture = h.renderer.acquire_snapshot(size).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let mixed = h.renderer.acquire_snapshot(size).unwrap(); + let small = h + .renderer + .acquire_snapshot(SnapshotSize::new(8, 8).unwrap()) + .unwrap(); + let resize = || ChildResizePicture { + old: &old, + old_width: W as f32, + old_height: H as f32, + mix: 0.5, + }; + assert_eq!( + PreparedChildFrame::new(&child, 0.5, size, Some(&small), None, None).err(), + Some(ChildPreparationError::WrongSize) + ); + assert_eq!( + PreparedChildFrame::new(&child, 1.0, size, Some(&picture), Some(resize()), None).err(), + Some(ChildPreparationError::MissingResizePicture) + ); + assert_eq!( + PreparedChildFrame::new( + &child, + 1.0, + size, + Some(&picture), + Some(resize()), + Some(&small) + ) + .err(), + Some(ChildPreparationError::WrongSize) + ); + for aliased in [&picture, &old] { + assert_eq!( + PreparedChildFrame::new( + &child, + 1.0, + size, + Some(&picture), + Some(resize()), + Some(aliased) + ) + .err(), + Some(ChildPreparationError::AliasedResizePicture) + ); + } + assert_eq!( + PreparedChildFrame::new(&child, 1.0, size, Some(&old), Some(resize()), Some(&mixed)).err(), + Some(ChildPreparationError::AliasedResizePicture) + ); + assert!( + PreparedChildFrame::new( + &child, + 1.0, + size, + Some(&picture), + Some(resize()), + Some(&mixed) + ) + .is_ok() + ); +}