From 73da6c68d276ffcec137db7a48993d126aca423f Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 09:24:17 +0100 Subject: [PATCH] Add Apple-style window corners with isolated shell rendering --- compositor/src/presentation.rs | 23 + compositor/src/render/corner_tests.rs | 552 ++++++++++++++++++ compositor/src/render/decorations.rs | 44 +- compositor/src/render/materials.rs | 25 +- compositor/src/render/mod.rs | 115 +++- compositor/src/render/overview.rs | 22 +- compositor/src/render/resources.rs | 39 ++ compositor/src/render/scene.rs | 8 +- .../render/shaders/rounded_border_shader.frag | 6 + .../src/render/shaders/window_corners.glsl | 101 ++++ 10 files changed, 890 insertions(+), 45 deletions(-) create mode 100644 compositor/src/render/corner_tests.rs create mode 100644 compositor/src/render/shaders/window_corners.glsl diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index 814828a..ebab9ea 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -79,19 +79,42 @@ pub(crate) fn physical_rect( ) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum CornerShape { + #[default] + Circular, + Continuous, +} + +/// Conservative extent of the corner shoulders, in physical pixels. +pub(crate) fn corner_extent(radius: f32, size: smithay::utils::Size, shape: CornerShape) -> f32 { + if shape == CornerShape::Circular { + return radius; + } + + (radius * 1.528_665).min(size.w.min(size.h) as f32 * 0.5) +} + /// A single physical outline shared by content and all of its decorations. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct RoundedRect { pub rect: Rectangle, pub radius: f32, + pub shape: CornerShape, } impl RoundedRect { + pub(crate) fn with_shape(mut self, shape: CornerShape) -> Self { + self.shape = shape; + self + } + pub(crate) fn new(rect: ferese_layout::Rect, origin: Point, scale: f64, radius: f64) -> Self { let rect = physical_rect(rect, origin, scale); Self { radius: clamp_radius(radius * scale, rect.size), rect, + shape: CornerShape::Circular, } } diff --git a/compositor/src/render/corner_tests.rs b/compositor/src/render/corner_tests.rs new file mode 100644 index 0000000..e5145a8 --- /dev/null +++ b/compositor/src/render/corner_tests.rs @@ -0,0 +1,552 @@ +//! Independent path oracle for the measured UIKit profile and GPU edge coverage. +use super::*; + +const EXTENT: f64 = 1.52866498; +type Point2 = [f64; 2]; +type Cubic = [Point2; 4]; + +// Fixture: normalized points extracted from UIKit's UIBezierPath output. +// https://liamrosenfeld.com/posts/apple_icon_quest/ +const APPLE: [Cubic; 3] = [ + [ + [0.0, EXTENT], + [0.0, 1.08849296], + [0.0, 0.86840694], + [0.07491139, 0.63149379], + ], + [ + [0.07491139, 0.63149379], + [0.16905956, 0.37282383], + [0.37282383, 0.16905956], + [0.63149379, 0.07491139], + ], + [ + [0.63149379, 0.07491139], + [0.86840694, 0.0], + [1.08849296, 0.0], + [EXTENT, 0.0], + ], +]; + +const CIRCLE: [Cubic; 3] = [ + [ + [0.0, 1.0], + [0.0, 0.8686781289], + [0.0258657631, 0.7386421566], + [0.0761204675, 0.6173165676], + ], + [ + [0.0761204675, 0.6173165676], + [0.1776144241, 0.3722884810], + [0.3722884810, 0.1776144241], + [0.6173165676, 0.0761204675], + ], + [ + [0.6173165676, 0.0761204675], + [0.7386421566, 0.0258657631], + [0.8686781289, 0.0], + [1.0, 0.0], + ], +]; + +fn lerp(a: Point2, b: Point2, t: f64) -> Point2 { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] +} + +fn curve(mut points: Cubic, t: f64) -> Point2 { + // De Casteljau, independent of the shader's polynomial/Newton calculation. + for count in (1..4).rev() { + for index in 0..count { + points[index] = lerp(points[index], points[index + 1], t); + } + } + + points[0] +} + +fn squared_distance(a: Point2, b: Point2) -> f64 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) +} + +fn nearest_distance(points: Cubic, query: Point2) -> f64 { + let index = (0usize..=64) + .min_by(|a, b| { + squared_distance(curve(points, *a as f64 / 64.0), query) + .total_cmp(&squared_distance(curve(points, *b as f64 / 64.0), query)) + }) + .unwrap(); + let mut low = index.saturating_sub(1) as f64 / 64.0; + let mut high = (index + 1).min(64) as f64 / 64.0; + + // Bracketed minimization, rather than reimplementing the GPU Newton solver. + for _ in 0..36 { + let a = low + (high - low) / 3.0; + let b = high - (high - low) / 3.0; + + if squared_distance(curve(points, a), query) < squared_distance(curve(points, b), query) { + high = b; + } else { + low = a; + } + } + + squared_distance(curve(points, (low + high) * 0.5), query).sqrt() +} + +fn reference_distance(point: Point2, rect: [f64; 4], radius: f64, shape: CornerShape) -> f64 { + let half = [rect[2] * 0.5, rect[3] * 0.5]; + let inset = [ + half[0] - (point[0] - rect[0] - half[0]).abs(), + half[1] - (point[1] - rect[1] - half[1]).abs(), + ]; + let limit = half[0].min(half[1]); + + if shape == CornerShape::Circular || radius <= 0.0 || radius >= limit { + let q = [radius - inset[0], radius - inset[1]]; + return q[0].max(0.0).hypot(q[1].max(0.0)) + q[0].max(q[1]).min(0.0) - radius; + } + + let blend = ((limit / radius - 1.0) / (EXTENT - 1.0)).clamp(0.0, 1.0); + let extent = 1.0 + (EXTENT - 1.0) * blend; + + if inset[0].max(inset[1]) >= extent * radius { + return -inset[0].min(inset[1]); + } + + let query = [inset[0] / radius, inset[1] / radius]; + let curves = std::array::from_fn::<_, 3, _>(|segment| { + std::array::from_fn(|index| lerp(CIRCLE[segment][index], APPLE[segment][index], blend)) + }); + let distance = curves + .iter() + .map(|points| nearest_distance(*points, query)) + .fold(f64::INFINITY, f64::min); + let mut inside = false; + + if query[0] >= 0.0 && query[1] >= 0.0 { + for points in curves { + if query[0] >= points[0][0] && query[0] <= points[3][0] { + let mut low = 0.0; + let mut high = 1.0; + + for _ in 0..36 { + let middle = (low + high) * 0.5; + + if curve(points, middle)[0] < query[0] { + low = middle; + } else { + high = middle; + } + } + + inside = query[1] >= curve(points, (low + high) * 0.5)[1]; + break; + } + } + } + + distance * radius * if inside { -1.0 } else { 1.0 } +} + +fn coverage(distance: f64) -> f64 { + (0.5 - distance).clamp(0.0, 1.0) +} + +#[test] +fn shell_roles_and_shader_sources_keep_circular_corners() { + use crate::effects::SemanticRole::*; + + for role in [Panel, PanelElevated, Popover, Menu, Notification, Hud, Modal] { + assert_eq!(corner_shape_for_role(Some(role)), CornerShape::Circular); + } + + assert_eq!(corner_shape_for_role(None), CornerShape::Continuous); + + for source in [BLUR_SHADER, MATERIAL_SHADER, ROUNDED_TEXTURE_SHADER] { + assert_eq!( + corner_shader(source), + source.replace("//_CORNERS_", include_str!("shaders/corners.glsl")) + ); + assert!(!corner_shader(source).contains("CORNER_EXTENT")); + } +} + +fn renderer() -> GlesRenderer { + use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; + + let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device"); + let display = unsafe { EGLDisplay::new(device).unwrap() }; + unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() } +} + +#[test] +#[ignore = "manual EGL timing sample; requires a rendering device"] +fn window_corner_shader_timing_sample() { + let mut renderer = renderer(); + let driver = renderer + .with_context(|gl| { + unsafe { std::ffi::CStr::from_ptr(gl.GetString(0x1F01).cast()) } + .to_string_lossy() + .into_owned() + }) + .unwrap(); + let mut resources = RenderResources::default(); + let size = (1024, 768).into(); + let rect = Rectangle::::from_size(size); + let uniforms = [ + Uniform::new("clip_rect", [0.0f32, 0.0, 1024.0, 768.0]), + Uniform::new("radius", 16.0f32), + ]; + let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (1024, 768).into()).unwrap(); + + for shape in [CornerShape::Circular, CornerShape::Continuous] { + let programs = corner_program(&mut resources, &mut renderer, shape).unwrap(); + let mut samples = Vec::new(); + + for iteration in 0..110 { + let started = std::time::Instant::now(); + let mut target = renderer.bind(&mut texture).unwrap(); + let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap(); + frame.clear(Color32F::TRANSPARENT, &[rect]).unwrap(); + frame + .render_pixel_shader_to( + &programs.solid, + Rectangle::from_size((1024.0, 768.0).into()), + rect, + (1024, 768).into(), + Some(&[rect]), + 1.0, + &uniforms, + ) + .unwrap(); + frame.finish().unwrap().wait().unwrap(); + + if iteration >= 10 { + samples.push(started.elapsed()); + } + } + + samples.sort_unstable(); + eprintln!( + "corner shader {shape:?}: driver={driver}, median={:?}, p95={:?}, size=1024x768, radius=16", + samples[50], samples[95] + ); + } +} + +fn rasterize(renderer: &mut GlesRenderer, program: &GlesPixelProgram, uniforms: &[Uniform<'_>]) -> Vec { + let size = (64, 64).into(); + let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, size).unwrap(); + let rect = Rectangle::::from_size((64, 64).into()); + let mut target = renderer.bind(&mut texture).unwrap(); + { + let mut frame = renderer + .render(&mut target, (64, 64).into(), Transform::Normal) + .unwrap(); + frame.clear(Color32F::TRANSPARENT, &[rect]).unwrap(); + frame + .render_pixel_shader_to( + program, + Rectangle::from_size(size.to_f64()), + rect, + size, + Some(&[rect]), + 1.0, + uniforms, + ) + .unwrap(); + let _ = frame.finish().unwrap(); + } + + let mapping = renderer + .copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888) + .unwrap(); + renderer.map_texture(&mapping).unwrap().to_vec() +} + +#[test] +#[ignore = "requires an EGL rendering device"] +fn apple_window_fill_and_inset_border_match_path_reference_at_fractional_scales() { + let mut renderer = renderer(); + let mut resources = RenderResources::default(); + let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + + // Include tiny radii, fractional physical coordinates, overlapping shoulder + // adaptation, exact pills, square/fullscreen, and broad inset borders. + for (radius, width) in [ + (0.0f32, 1.0f32), + (0.375, 0.75), + (8.25, 1.25), + (12.375, 2.25), + (24.0, 3.0), + (29.49, 2.5), + (29.5, 2.5), + (12.375, 12.0), + ] { + let rect = [1.25f32, 2.5, 61.5, 59.0]; + let fill = rasterize( + &mut renderer, + &programs.solid, + &[ + Uniform::new("clip_rect", rect), + Uniform::new("radius", radius), + Uniform::new("color", [1.0f32; 4]), + ], + ); + let border = BorderParameters { + geometry: Rectangle::from_size((64, 64).into()), + clip_rect: rect, + radius, + width, + color: [1.0; 4], + color_to: [1.0; 4], + gradient_line: [0.0; 4], + focus_color: [1.0; 4], + focus_color_to: [1.0; 4], + focus_gradient_line: [0.0; 4], + focus_mix: 0.0, + }; + let edge = rasterize(&mut renderer, &programs.border, &border_uniforms(&border)); + + for y in 0..64 { + for x in 0..64 { + let distance = reference_distance( + [x as f64 + 0.5, y as f64 + 0.5], + rect.map(f64::from), + radius.into(), + CornerShape::Continuous, + ); + let outer = coverage(distance); + let inner = coverage(distance + f64::from(width)); + let index = (y * 64 + x) * 4; + + for (actual, expected) in [(fill[index + 3], outer), (edge[index + 3], (outer - inner).max(0.0))] { + let expected = (expected * 255.0).round() as u8; + assert!( + actual.abs_diff(expected) <= 2, + "x={x} y={y} radius={radius} width={width}: GPU={actual}, reference={expected}" + ); + } + } + } + } +} + +#[test] +#[ignore = "requires an EGL rendering device"] +fn window_programs_do_not_change_cached_shell_materials_and_blur() { + let mut renderer = renderer(); + let mut resources = RenderResources::default(); + let context = renderer.context_id().erased(); + let circular = rounded_clip_program(&mut resources, &mut renderer).unwrap(); + material_program(&mut resources, &mut renderer).unwrap(); + blur_program(&mut resources, &mut renderer).unwrap(); + let uniforms = [ + Uniform::new("clip_rect", [0.0f32, 0.0, 64.0, 64.0]), + Uniform::new("radius", 12.0f32), + Uniform::new("color", [1.0f32; 4]), + ]; + let before = rasterize(&mut renderer, &circular.solid, &uniforms); + corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + material_program_for_corners(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + let circular = rounded_clip_program(&mut resources, &mut renderer).unwrap(); + let after = rasterize(&mut renderer, &circular.solid, &uniforms); + assert_eq!(before, after); + let cache = &resources.contexts[&context]; + assert!(cache.material.is_some() && cache.window_material.is_some() && cache.blur.is_some()); +} + +#[test] +fn apple_shoulder_extent_never_claims_transparent_pixels_as_opaque() { + let clip = Rectangle::from_size((64, 64).into()); + + for radius in [0.375f32, 8.25, 12.375, 24.0, 31.99, 32.0] { + let extent = corner_extent(radius, clip.size, CornerShape::Continuous); + let opaque = rounded_opaque_regions(&[clip], Point::default(), clip, extent); + + for region in opaque.iter() { + for y in region.loc.y..region.loc.y + region.size.h { + for x in region.loc.x..region.loc.x + region.size.w { + let distance = reference_distance( + [x as f64 + 0.5, y as f64 + 0.5], + [0.0, 0.0, 64.0, 64.0], + radius.into(), + CornerShape::Continuous, + ); + assert_eq!( + coverage(distance), + 1.0, + "transparent opaque claim at ({x}, {y}), radius={radius}" + ); + } + } + } + } +} + +#[test] +#[ignore = "requires an EGL rendering device"] +fn apple_shadow_and_window_tint_match_the_same_outline() { + let mut renderer = renderer(); + let mut resources = RenderResources::default(); + let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + let rect = [1.25f32, 2.5, 61.5, 59.0]; + let radius = 12.375f32; + let shadow = rasterize( + &mut renderer, + &programs.shadow, + &[ + Uniform::new("shadow_rect", rect), + Uniform::new("radius", radius), + Uniform::new("blur", 8.0f32), + Uniform::new("opacity", 1.0f32), + Uniform::new("shadow_color", [1.0f32; 4]), + ], + ); + let tint = rasterize( + &mut renderer, + &material.0, + &[ + Uniform::new("visible_rect", rect), + Uniform::new("material_radius", radius), + Uniform::new("tint", [1.0f32; 4]), + Uniform::new("paint_mode", 0.0f32), + Uniform::new("shadow_rect", rect), + Uniform::new("shadow_values", [0.0f32; 2]), + ], + ); + + for y in 0..64 { + for x in 0..64 { + let distance = reference_distance( + [x as f64 + 0.5, y as f64 + 0.5], + rect.map(f64::from), + radius.into(), + CornerShape::Continuous, + ); + let shadow_alpha = (-0.5 * (distance.max(0.0) / 4.0).powi(2)).exp(); + let index = (y * 64 + x) * 4; + + for (actual, expected) in [(tint[index + 3], coverage(distance)), (shadow[index + 3], shadow_alpha)] { + assert!( + actual.abs_diff((expected * 255.0).round() as u8) <= 2, + "outline mismatch at ({x}, {y})" + ); + } + } + } +} + +#[test] +#[ignore = "requires an EGL rendering device"] +fn window_role_change_invalidates_decorations_and_preserves_the_resize_snapshot() { + let mut renderer = renderer(); + let mut resources = RenderResources::default(); + let id = ferese_layout::WindowId(1); + let context = renderer.context_id().erased(); + assert!(!resources.prepare_window_corners(id, CornerShape::Continuous)); + let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + let parameters = BorderParameters { + geometry: Rectangle::from_size((64, 64).into()), + clip_rect: [0.0, 0.0, 64.0, 64.0], + radius: 12.0, + width: 2.0, + color: [1.0; 4], + color_to: [1.0; 4], + gradient_line: [0.0; 4], + focus_color: [1.0; 4], + focus_color_to: [1.0; 4], + focus_gradient_line: [0.0; 4], + focus_mix: 0.0, + }; + let element = SharedPixelShaderElement::new( + programs.border, + parameters.geometry, + None, + 1.0, + border_uniforms(¶meters), + RenderElementKind::Unspecified, + ); + resources + .windows + .get_mut(&id) + .unwrap() + .borders + .contexts + .insert(context.clone(), CachedBorder { element, parameters }); + let texture = renderer.create_buffer(Fourcc::Abgr8888, (64, 64).into()).unwrap(); + resources.set_snapshot( + id, + ResizeSnapshot { + texture, + context: context.clone(), + id: Id::new(), + commit: CommitCounter::default(), + elapsed: Duration::ZERO, + last_tick: None, + scale: 1.0, + }, + ); + let snapshot_id = resources.snapshot(&id).unwrap().id.clone(); + let commit = resources.snapshot(&id).unwrap().commit; + assert!(!resources.prepare_window_corners(id, CornerShape::Continuous)); + assert_eq!(resources.windows[&id].borders.contexts.len(), 1); + assert!(resources.prepare_window_corners(id, CornerShape::Circular)); + assert!(resources.windows[&id].borders.contexts.is_empty()); + let snapshot = resources.snapshot(&id).unwrap(); + assert_eq!(snapshot.id, snapshot_id); + assert_ne!(snapshot.commit, commit); + let updated_commit = snapshot.commit; + assert!(!resources.prepare_window_corners(id, CornerShape::Circular)); + assert_eq!(resources.snapshot(&id).unwrap().commit, updated_commit); +} + +fn endpoint_derivatives(points: Cubic, end: bool) -> (Point2, Point2) { + if end { + ( + [3.0 * (points[3][0] - points[2][0]), 3.0 * (points[3][1] - points[2][1])], + [ + 6.0 * (points[3][0] - 2.0 * points[2][0] + points[1][0]), + 6.0 * (points[3][1] - 2.0 * points[2][1] + points[1][1]), + ], + ) + } else { + ( + [3.0 * (points[1][0] - points[0][0]), 3.0 * (points[1][1] - points[0][1])], + [ + 6.0 * (points[2][0] - 2.0 * points[1][0] + points[0][0]), + 6.0 * (points[2][1] - 2.0 * points[1][1] + points[0][1]), + ], + ) + } +} + +fn curvature(tangent: Point2, acceleration: Point2) -> f64 { + (tangent[0] * acceleration[1] - tangent[1] * acceleration[0]).abs() / tangent[0].hypot(tangent[1]).powi(3) +} + +#[test] +fn measured_uikit_profile_has_flat_shoulders_but_small_internal_join_discontinuities() { + let (start, start_acceleration) = endpoint_derivatives(APPLE[0], false); + let (end, end_acceleration) = endpoint_derivatives(APPLE[2], true); + assert_eq!(start[0], 0.0); + assert_eq!(end[1], 0.0); + assert_eq!(curvature(start, start_acceleration), 0.0); + assert_eq!(curvature(end, end_acceleration), 0.0); + + for pair in APPLE.windows(2) { + assert_eq!(pair[0][3], pair[1][0], "join must be position-continuous"); + let (before, before_acceleration) = endpoint_derivatives(pair[0], true); + let (after, after_acceleration) = endpoint_derivatives(pair[1], false); + let cosine = + (before[0] * after[0] + before[1] * after[1]) / before[0].hypot(before[1]) / after[0].hypot(after[1]); + let angle = cosine.clamp(-1.0, 1.0).acos().to_degrees(); + let curvature_jump = (curvature(before, before_acceleration) - curvature(after, after_acceleration)).abs(); + // This is a measured Apple-style approximation, not a globally G1/G2 + // curve. Preserve and document its limitation instead of falsely + // passing a continuity test, or silently changing the source profile. + assert!((angle - 2.453_166_93).abs() < 1.0e-6); + assert!((curvature_jump - 0.354_989_57).abs() < 1.0e-6); + } +} diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs index 9dc8275..213c799 100644 --- a/compositor/src/render/decorations.rs +++ b/compositor/src/render/decorations.rs @@ -3,13 +3,20 @@ use super::*; pub(super) fn rounded_clip_program( resources: &mut RenderResources, renderer: &mut GlesRenderer, +) -> Option { + corner_program(resources, renderer, CornerShape::Circular) +} + +pub(super) fn corner_program( + resources: &mut RenderResources, + renderer: &mut GlesRenderer, + shape: CornerShape, ) -> Option { let context = renderer.context_id().erased(); - if let Some(program) = resources - .contexts - .get(&context) - .and_then(|programs| programs.rounded.as_ref()) - { + if let Some(program) = resources.contexts.get(&context).and_then(|programs| match shape { + CornerShape::Circular => programs.rounded.as_ref(), + CornerShape::Continuous => programs.continuous.as_ref(), + }) { return Some(program.clone()); } @@ -36,11 +43,13 @@ pub(super) fn rounded_clip_program( UniformName::new("opacity", UniformType::_1f), UniformName::new("shadow_color", UniformType::_4f), ]; - let texture = renderer.compile_custom_texture_shader(corner_shader(ROUNDED_TEXTURE_SHADER), &texture_uniforms); - let border = renderer.compile_custom_pixel_shader(corner_shader(ROUNDED_BORDER_SHADER), &border_uniforms); - let shadow = renderer.compile_custom_pixel_shader(corner_shader(WINDOW_SHADOW_SHADER), &shadow_uniforms); + let texture = + renderer.compile_custom_texture_shader(corner_shader_for(ROUNDED_TEXTURE_SHADER, shape), &texture_uniforms); + let border = + renderer.compile_custom_pixel_shader(corner_shader_for(ROUNDED_BORDER_SHADER, shape), &border_uniforms); + let shadow = renderer.compile_custom_pixel_shader(corner_shader_for(WINDOW_SHADOW_SHADER, shape), &shadow_uniforms); let solid = renderer.compile_custom_pixel_shader( - corner_shader(ROUNDED_SOLID_SHADER), + corner_shader_for(ROUNDED_SOLID_SHADER, shape), &[ UniformName::new("clip_rect", UniformType::_4f), UniformName::new("radius", UniformType::_1f), @@ -53,16 +62,27 @@ pub(super) fn rounded_clip_program( match compiled { Ok((texture, border, shadow, solid)) => { let programs = RoundedClipPrograms { + shape, texture, solid, border, shadow, }; - resources.contexts.entry(context).or_default().rounded = Some(programs.clone()); + let context = resources.contexts.entry(context).or_default(); + match shape { + CornerShape::Circular => context.rounded = Some(programs.clone()), + CornerShape::Continuous => context.continuous = Some(programs.clone()), + } Some(programs) } Err(error) => { - if !std::mem::replace(&mut resources.contexts.entry(context).or_default().rounded_warned, true) { + let context = resources.contexts.entry(context).or_default(); + let warned = match shape { + CornerShape::Circular => &mut context.rounded_warned, + CornerShape::Continuous => &mut context.continuous_warned, + }; + + if !std::mem::replace(warned, true) { tracing::warn!(%error, "rounded-window shader unavailable; falling back to unrounded rendering"); } None @@ -197,7 +217,7 @@ pub(super) fn window_tint_element( return None; } let mode = output.current_mode()?; - let program = material_program(resources, renderer)?; + let program = material_program_for_corners(resources, renderer, corners.shape)?; let parameters = BorderParameters { geometry, clip_rect: framebuffer_clip_rect(corners.rect, mode.size, output.current_transform().invert()), diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index b18d60f..b68fa3c 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -399,13 +399,20 @@ pub(super) fn expanded_blur_region( pub(super) fn material_program( resources: &mut RenderResources, renderer: &mut GlesRenderer, +) -> Option { + material_program_for_corners(resources, renderer, CornerShape::Circular) +} + +pub(super) fn material_program_for_corners( + resources: &mut RenderResources, + renderer: &mut GlesRenderer, + shape: CornerShape, ) -> Option { let context = renderer.context_id().erased(); - if let Some(program) = resources - .contexts - .get(&context) - .and_then(|programs| programs.material.as_ref()) - { + if let Some(program) = resources.contexts.get(&context).and_then(|programs| match shape { + CornerShape::Circular => programs.material.as_ref(), + CornerShape::Continuous => programs.window_material.as_ref(), + }) { return Some(program.clone()); } @@ -417,10 +424,14 @@ pub(super) fn material_program( UniformName::new("shadow_rect", UniformType::_4f), UniformName::new("shadow_values", UniformType::_2f), ]; - match renderer.compile_custom_pixel_shader(corner_shader(MATERIAL_SHADER), &uniforms) { + match renderer.compile_custom_pixel_shader(corner_shader_for(MATERIAL_SHADER, shape), &uniforms) { Ok(program) => { let program = MaterialProgram(program); - resources.contexts.entry(context).or_default().material = Some(program.clone()); + let context = resources.contexts.entry(context).or_default(); + match shape { + CornerShape::Circular => context.material = Some(program.clone()), + CornerShape::Continuous => context.window_material = Some(program.clone()), + } Some(program) } Err(error) => { diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index 6dbe22a..4b9e325 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -1,5 +1,7 @@ mod backdrop; mod capture; +#[cfg(test)] +mod corner_tests; mod decorations; mod materials; mod overview; @@ -51,7 +53,9 @@ use smithay::wayland::shell::wlr_layer::Layer; use crate::Ferese; use crate::metrics::FrameEffectMetrics; -use crate::presentation::{NativeTextureElement, PhysicalShaderElement, RoundedRect, clamp_radius, physical_rect}; +use crate::presentation::{ + CornerShape, NativeTextureElement, PhysicalShaderElement, RoundedRect, clamp_radius, corner_extent, physical_rect, +}; type SurfaceRenderElement = CropRenderElement>>>; @@ -112,7 +116,39 @@ const MATERIAL_SHADER: &str = include_str!("shaders/material_shader.frag"); const BLUR_SHADER: &str = include_str!("shaders/blur_shader.frag"); fn corner_shader(source: &str) -> String { - source.replace("//_CORNERS_", include_str!("shaders/corners.glsl")) + corner_shader_for(source, CornerShape::Circular) +} + +fn corner_shader_for(source: &str, shape: CornerShape) -> String { + match shape { + CornerShape::Circular => source.replace("//_CORNERS_", include_str!("shaders/corners.glsl")), + CornerShape::Continuous => source + .replace( + "//_CORNERS_", + &format!( + "#define CONTINUOUS_WINDOW_CORNERS\n{}", + include_str!("shaders/window_corners.glsl") + ), + ) + .replace("precision mediump float;", "precision highp float;"), + } +} + +fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape { + // Semantic shell surfaces keep their client-matching circular outlines. + corner_shape_for_role( + window + .toplevel() + .and_then(|toplevel| crate::effects::surface_role(toplevel.wl_surface()).map(|(role, _)| role)), + ) +} + +fn corner_shape_for_role(role: Option) -> CornerShape { + if role.is_some() { + CornerShape::Circular + } else { + CornerShape::Continuous + } } #[derive(Clone, Debug)] @@ -287,6 +323,7 @@ impl RenderElement for BlurRenderElement { #[derive(Clone, Debug)] pub(crate) struct RoundedClipPrograms { + shape: CornerShape, solid: GlesPixelProgram, texture: GlesTexProgram, border: GlesPixelProgram, @@ -436,6 +473,18 @@ fn rounded_opaque_regions( .collect() } +fn clipped_surface_damage( + size: Size, + clip_changed: bool, + client_damage: impl FnOnce() -> DamageSet, +) -> DamageSet { + if clip_changed { + DamageSet::from_slice(&[Rectangle::from_size(size)]) + } else { + client_damage() + } +} + impl Element for RoundedSurfaceRenderElement { fn id(&self) -> &Id { self.inner.id() @@ -458,10 +507,9 @@ impl Element for RoundedSurfaceRenderElement { } fn damage_since(&self, scale: RenderScale, commit: Option) -> DamageSet { - if self.clip_changed { - return DamageSet::from_slice(&[Rectangle::from_size(self.inner.geometry(scale).size)]); - } - self.inner.damage_since(scale, commit) + clipped_surface_damage(self.inner.geometry(scale).size, self.clip_changed, || { + self.inner.damage_since(scale, commit) + }) } fn opaque_regions(&self, scale: RenderScale) -> OpaqueRegions { @@ -479,7 +527,7 @@ impl Element for RoundedSurfaceRenderElement { &self.inner.opaque_regions(scale), self.inner.geometry(scale).loc, clip, - self.radius, + corner_extent(self.radius, clip.size, self.programs.shape), ) } @@ -804,6 +852,7 @@ mod tests { id: Id, geometry: Rectangle, commit: CommitCounter, + clip_changed: bool, } impl DamageElement { @@ -812,6 +861,7 @@ mod tests { id: Id::new(), geometry, commit: CommitCounter::default(), + clip_changed: false, } } } @@ -838,16 +888,53 @@ mod tests { scale: Scale, commit: Option, ) -> smithay::backend::renderer::utils::DamageSet { - if commit == Some(self.current_commit()) { - smithay::backend::renderer::utils::DamageSet::default() - } else { - smithay::backend::renderer::utils::DamageSet::from_slice(&[Rectangle::from_size( - self.geometry(scale).size, - )]) - } + super::clipped_surface_damage(self.geometry(scale).size, self.clip_changed, || { + if commit == Some(self.current_commit()) { + smithay::backend::renderer::utils::DamageSet::default() + } else { + smithay::backend::renderer::utils::DamageSet::from_slice(&[Rectangle::from_size( + self.geometry(scale).size, + )]) + } + }) } } + #[test] + fn overview_role_changes_damage_unchanged_client_buffers_then_settle() { + use ferese_core::OutputId; + use ferese_layout::WindowId; + + use super::CornerShape::{Circular, Continuous}; + + let mut resources = super::RenderResources::default(); + let mut element = DamageElement::new(Rectangle::new((100, 100).into(), (200, 160).into())); + let client_commit = element.commit; + let mut tracker = OutputDamageTracker::new((640, 480), Scale::from(1.0), Transform::Normal); + let window = WindowId(1); + let output = OutputId(1); + tracker.damage_output(0, &[&element]).unwrap(); + + for shape in [Continuous, Circular, Continuous] { + // The main scene and thumbnail have independent clip histories. + resources.prepare_window_corners(window, shape); + element.clip_changed = resources.thumbnail_corner_changed(window, output, shape); + assert_eq!(element.commit, client_commit); + assert!(element.clip_changed); + let damage = tracker.damage_output(1, &[&element]).unwrap().0.cloned().unwrap(); + assert!(damage.iter().any(|rect| rect.contains((100, 100)))); + assert!(damage.iter().any(|rect| rect.contains((299, 259)))); + element.clip_changed = resources.thumbnail_corner_changed(window, output, shape); + assert!(!element.clip_changed); + assert!(tracker.damage_output(1, &[&element]).unwrap().0.is_none()); + } + + assert!(resources.thumbnail_corner_changed(window, OutputId(2), Continuous)); + assert!(!resources.thumbnail_corner_changed(window, output, Continuous)); + resources.remove_output(output); + assert!(resources.thumbnail_corner_changed(window, output, Continuous)); + } + #[test] fn resize_keeps_source_pixels_native_when_growing_and_shrinking() { use smithay::backend::renderer::element::utils::{ConstrainAlign, constrain_render_elements}; diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index eedf555..6d149e4 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -118,7 +118,6 @@ pub(super) fn overview_strip_elements( ) .opacity; let mut elements = Vec::new(); - let programs = rounded_clip_program(&mut state.render, renderer); state.retain_overview_labels(); let card_ids = cards @@ -221,19 +220,24 @@ pub(super) fn overview_strip_elements( elements.push(border.into()); } } - if let Some(programs) = &programs { - for (id, rect) in &card.windows { - if let Some(window) = state.windows.window(*id) { + for (id, rect) in &card.windows { + if let Some(window) = state.windows.window(*id) { + let shape = window_corner_shape(window); + + if let Some(programs) = corner_program(&mut state.render, renderer, shape) { + let clip_changed = state + .output_id(output) + .is_some_and(|output| state.render.thumbnail_corner_changed(*id, output, shape)); elements.extend(rounded_window_elements( renderer, window, - RoundedRect::new(*rect, output_geometry.loc, scale, 4.0), + RoundedRect::new(*rect, output_geometry.loc, scale, 4.0).with_shape(shape), scale, alpha, - // The damage tracker tracks each miniature's crop, - // scale, alpha and geometry. Its corner radius is fixed, - // so an unchanged clip needs no extra full damage. - false, + // Crop/scale/alpha/geometry already track their own + // damage. Role changes also alter the corner shader, + // even when the client buffer itself is unchanged. + clip_changed, output, programs.clone(), ConstrainScaleBehavior::Stretch, diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index dd6e977..8cd7a01 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -20,6 +20,8 @@ pub(super) struct WindowBuffers { pub dim: WindowBorderBuffers, pub resize_fill: WindowBorderBuffers, pub shadow: WindowShadowBuffers, + corner_shape: Option, + thumbnail_shapes: HashMap, snapshot: Option, } @@ -27,17 +29,54 @@ pub(super) struct WindowBuffers { pub(super) struct ContextPrograms { pub rounded: Option, pub rounded_warned: bool, + pub continuous: Option, + pub continuous_warned: bool, + pub window_material: Option, pub material: Option, pub blur: Option, } impl RenderResources { + pub(super) fn prepare_window_corners(&mut self, id: WindowId, shape: CornerShape) -> bool { + let buffers = self.windows.entry(id).or_default(); + + let changed = buffers.corner_shape.is_some_and(|old| old != shape); + + if changed { + buffers.borders.contexts.clear(); + buffers.dim.contexts.clear(); + buffers.resize_fill.contexts.clear(); + buffers.shadow.contexts.clear(); + + if let Some(snapshot) = &mut buffers.snapshot { + snapshot.commit.increment(); + } + } + + buffers.corner_shape = Some(shape); + changed + } + + pub(super) fn thumbnail_corner_changed(&mut self, id: WindowId, output: OutputId, shape: CornerShape) -> bool { + self.windows + .entry(id) + .or_default() + .thumbnail_shapes + .insert(output, shape) + .is_none_or(|previous| previous != shape) + } + pub fn remove_window(&mut self, id: WindowId) { self.windows.remove(&id); } pub fn remove_output(&mut self, id: OutputId) { self.outputs.remove(&id); + + for window in self.windows.values_mut() { + window.thumbnail_shapes.remove(&id); + } + for surface in self.surfaces.values_mut() { surface.contexts.retain(|(_, output, _), _| *output != id); surface.captures.retain(|(_, output, _), _| *output != id); diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index 140c281..307e31a 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -88,7 +88,6 @@ pub(crate) fn sampled_output_elements( return Vec::new(); }; let scale = output.current_scale().fractional_scale(); - let rounded_clip_program = rounded_clip_program(&mut state.render, renderer); let mut elements = if include_cursor { cursor_elements(state, renderer, output_geometry, scale) @@ -160,7 +159,10 @@ pub(crate) fn sampled_output_elements( } else { state.theme_settings.window_radius } * decoration_progress; - let corners = RoundedRect::new(visual, output_geometry.loc, scale, window_radius); + let shape = window_corner_shape(&window); + let corners = RoundedRect::new(visual, output_geometry.loc, scale, window_radius).with_shape(shape); + let corner_shape_changed = state.render.prepare_window_corners(id, shape); + let rounded_clip_program = corner_program(&mut state.render, renderer, shape); let pixels = corners.rect; // Only overview/close intentionally scale the complete application. let scale_content = frame.overview.is_presenting() || sample.close_scale != 1.0; @@ -262,7 +264,7 @@ pub(crate) fn sampled_output_elements( corners, scale, close_alpha, - sample.geometry.presentation_changed, + sample.geometry.presentation_changed || corner_shape_changed, output, programs.clone(), behavior, diff --git a/compositor/src/render/shaders/rounded_border_shader.frag b/compositor/src/render/shaders/rounded_border_shader.frag index 34dcd9d..a359c23 100644 --- a/compositor/src/render/shaders/rounded_border_shader.frag +++ b/compositor/src/render/shaders/rounded_border_shader.frag @@ -24,10 +24,16 @@ void main() { float signed_distance = rounded_rect_distance(gl_FragCoord.xy, clip_rect, radius); float outer_coverage = edge_coverage(signed_distance); +#ifdef CONTINUOUS_WINDOW_CORNERS + // Inset the outer distance field along its normals, rather than shrinking + // the quartic's bounding box/radius and changing border thickness. + float inner_coverage = edge_coverage(signed_distance + border_width); +#else vec2 inner_size = max(clip_rect.zw - vec2(2.0 * border_width), vec2(0.0)); vec4 inner_rect = vec4(clip_rect.xy + (clip_rect.zw - inner_size) * 0.5, inner_size); float inner_radius = max(radius - border_width, 0.0); float inner_coverage = edge_coverage(rounded_rect_distance(gl_FragCoord.xy, inner_rect, inner_radius)); +#endif float coverage = max(outer_coverage - inner_coverage, 0.0); float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0); // Interpolate premultiplied endpoints: a transparent endpoint must not diff --git a/compositor/src/render/shaders/window_corners.glsl b/compositor/src/render/shaders/window_corners.glsl new file mode 100644 index 0000000..8843dba --- /dev/null +++ b/compositor/src/render/shaders/window_corners.glsl @@ -0,0 +1,101 @@ +// Apple-style corner approximation: three cubic Beziers per corner, using +// measured UIKit path coordinates (radius units), rather than a superellipse. +// Source and limits: https://liamrosenfeld.com/posts/apple_icon_quest/ +// CoreGraphics' private implementation is not available on Linux. These +// published cubics have small tangent/curvature jumps at their internal joins; +// they must not be described as an exact globally G1/G2-continuous curve. +const float CORNER_EXTENT = 1.52866498; + +void corner_controls(int segment, float blend, out vec2 a, out vec2 b, out vec2 c, out vec2 d) { + if (segment == 0) { + a = mix(vec2(0.0, 1.0), vec2(0.0, CORNER_EXTENT), blend); + b = mix(vec2(0.0, 0.8686781289), vec2(0.0, 1.08849296), blend); + c = mix(vec2(0.0258657631, 0.7386421566), vec2(0.0, 0.86840694), blend); + d = mix(vec2(0.0761204675, 0.6173165676), vec2(0.07491139, 0.63149379), blend); + } else if (segment == 1) { + a = mix(vec2(0.0761204675, 0.6173165676), vec2(0.07491139, 0.63149379), blend); + b = mix(vec2(0.1776144241, 0.3722884810), vec2(0.16905956, 0.37282383), blend); + c = b.yx; + d = a.yx; + } else { + a = mix(vec2(0.6173165676, 0.0761204675), vec2(0.63149379, 0.07491139), blend); + b = mix(vec2(0.7386421566, 0.0258657631), vec2(0.86840694, 0.0), blend); + c = mix(vec2(0.8686781289, 0.0), vec2(1.08849296, 0.0), blend); + d = mix(vec2(1.0, 0.0), vec2(CORNER_EXTENT, 0.0), blend); + } +} + +void corner_sample(float t, vec2 a, vec2 b, vec2 c, vec2 d, + out vec2 point, out vec2 tangent, out vec2 acceleration) { + vec2 first = 3.0 * (b - a); + vec2 second = 3.0 * (c - 2.0 * b + a); + vec2 third = d - 3.0 * c + 3.0 * b - a; + point = a + t * (first + t * (second + t * third)); + tangent = first + t * (2.0 * second + 3.0 * t * third); + acceleration = 2.0 * second + 6.0 * t * third; +} + +float corner_nearest(vec2 query, float t, vec2 a, vec2 b, vec2 c, vec2 d) { + // Bounded safeguarded Newton steps. Both endpoint seeds are needed for + // interior points with two minima; the midpoint may be a local maximum. + for (int iteration = 0; iteration < 8; iteration++) { + vec2 point, tangent, acceleration; + corner_sample(t, a, b, c, d, point, tangent, acceleration); + vec2 error = point - query; + float speed_squared = dot(tangent, tangent); + float denominator = max(speed_squared + dot(error, acceleration), speed_squared * 0.25); + t = clamp(t - clamp(dot(error, tangent) / denominator, -0.25, 0.25), 0.0, 1.0); + } + + return t; +} + +float rounded_rect_distance(vec2 point, vec4 rect, float radius) { + vec2 half_size = rect.zw * 0.5; + vec2 inset = half_size - abs(point - rect.xy - half_size); + float limit = min(half_size.x, half_size.y); + + // Preserve exact circular pills/circles and square/fullscreen outlines. + if (radius <= 0.0 || radius >= limit) { + vec2 q = vec2(radius) - inset; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; + } + + // The measured profile extends about 1.53 radii along the straight edge. + // When that would overlap its opposite corner, smoothly converge to the + // circular profile. This size adaptation is Ferese policy, not UIKit code. + float blend = clamp((limit / radius - 1.0) / (CORNER_EXTENT - 1.0), 0.0, 1.0); + float extent = radius * mix(1.0, CORNER_EXTENT, blend); + + if (max(inset.x, inset.y) >= extent) { + return -min(inset.x, inset.y); + } + + vec2 query = inset / radius; + float best_squared = 1.0e20; + float side = 1.0; + + for (int segment = 0; segment < 3; segment++) { + vec2 a, b, c, d; + corner_controls(segment, blend, a, b, c, d); + + for (int seed = 0; seed < 2; seed++) { + float t = corner_nearest(query, float(seed), a, b, c, d); + vec2 closest, tangent, acceleration; + corner_sample(t, a, b, c, d, closest, tangent, acceleration); + vec2 error = query - closest; + float squared = dot(error, error); + + if (squared < best_squared) { + best_squared = squared; + side = dot(error, vec2(tangent.y, -tangent.x)); + } + } + } + + return sqrt(best_squared) * radius * (side < 0.0 ? -1.0 : 1.0); +} + +float edge_coverage(float distance) { + return clamp(0.5 - distance, 0.0, 1.0); +}