From c179f1994d1645595d84e0e17eaee126f85975da Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 18 Jul 2026 19:11:45 +0800 Subject: [PATCH] Improve box-shadow rendering for Opera parity References isu issue 281. Filed isu issue 410 for remaining Opera parity gaps after analytic shadow rendering improved but did not fully resolve the screenshot mismatch. --- .isu/issues.json | 16 +++++- crates/platform/src/metal.rs | 35 ++++++++++--- crates/render/src/gpu.rs | 99 +++++++++++++++++++++++++++++++++++- crates/render/src/lib.rs | 69 +++++++++++++++++-------- 4 files changed, 189 insertions(+), 30 deletions(-) diff --git a/.isu/issues.json b/.isu/issues.json index 6d3fc30..c917031 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 410, + "next_id": 411, "issues": [ { "id": 1, @@ -5020,6 +5020,20 @@ "author": "piefev", "state": "open", "created_at": "2026-07-18T10:48:35Z" + }, + { + "id": 410, + "repo": "we", + "title": "Opera parity remains blocked after analytic box-shadow rendering", + "body": "Parent: isu issue 281.\n\nThis pass replaced the old layered rounded-rect box-shadow approximation with an analytic blurred rounded-rect shadow in the Metal renderer. That substantially improved the Opera cookie-panel region, but the real-web scenario still cannot remove its xfail.\n\nRepro:\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\n\nCurrent result after this pass:\n- desktop L96: 88.09% match (146342/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile L111: 80.11% match (65454/329160 px differ, tol=4, max_diff=0.1000%)\n- DOM and interactivity assertions pass; console captures only expected offline GTM fetch misses.\n\nArtifacts:\n- crates/e2e/artifacts/real-web/opera.com/desktop.png\n- crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt\n- crates/e2e/artifacts/real-web/opera.com/mobile.png\n- crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt\n\nRemaining blockers are broad glyph raster/antialiasing differences, first-viewport WebP/image parity, and smaller cookie-panel edge/text fidelity gaps. Keep crates/e2e/scenarios/real-web/opera.com.we xfailed until both Chromium screenshot assertions pass within the committed threshold.", + "labels": [ + "real-web", + "render" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T11:06:40Z" } ] } diff --git a/crates/platform/src/metal.rs b/crates/platform/src/metal.rs index fea3f68..389f19d 100644 --- a/crates/platform/src/metal.rs +++ b/crates/platform/src/metal.rs @@ -184,12 +184,16 @@ pub const TEXTURE_MODE_MASK: f32 = 2.0; /// `corner_radii`, `stroke_width`) and antialiases the coverage, giving /// resolution-independent corners for `border-radius`, capsules, and rings. pub const TEXTURE_MODE_SDF: f32 = 3.0; +/// Vertex mode for an analytic blurred rounded-rectangle shadow. It reuses the +/// SDF fields; `stroke_width` carries the blur radius in logical pixels. +pub const TEXTURE_MODE_SHADOW: f32 = 4.0; /// A vertex for 2D quad rendering via the Metal pipeline. /// /// Layout matches the MSL `Vertex` struct (packed floats, 72 bytes total). The -/// trailing SDF fields are only meaningful when `use_texture == TEXTURE_MODE_SDF`; -/// other modes leave them zero (see [`Vertex::default`]). +/// trailing SDF fields are only meaningful when `use_texture` is +/// `TEXTURE_MODE_SDF` or `TEXTURE_MODE_SHADOW`; other modes leave them zero +/// (see [`Vertex::default`]). #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct Vertex { @@ -199,7 +203,8 @@ pub struct Vertex { pub color: [f32; 4], /// UV texture coordinates (unused for solid-color quads). pub tex_coord: [f32; 2], - /// Texture mode: solid color, color texture, alpha mask, or SDF rounded rect. + /// Texture mode: solid color, color texture, alpha mask, SDF rounded rect, + /// or SDF shadow. pub use_texture: f32, /// SDF: fragment position relative to the rounded rect's center, in the /// same (logical) units as `position`. Interpolated across the quad. @@ -209,7 +214,8 @@ pub struct Vertex { /// SDF: per-corner radii (top-left, top-right, bottom-right, bottom-left). pub corner_radii: [f32; 4], /// SDF: stroke width for ring outlines, drawn inward from the outer edge. - /// `0.0` fills the interior instead of stroking. + /// `0.0` fills the interior instead of stroking. For shadow mode this is + /// the blur radius. pub stroke_width: f32, } @@ -453,7 +459,15 @@ fragment float4 fragment_main( ) { float4 c; if (in.use_texture > 0.5) { - if (in.use_texture > 2.5) { + if (in.use_texture > 3.5) { + // Blurred rounded-rect shadow. The SDF describes the pre-blur + // shadow shape; smoothstep approximates the alpha ramp produced by + // blurring that mask while staying deterministic and cheap. + float d = sd_rounded_box(in.local_pos, in.rect_half, in.corner_radii); + float blur = max(in.stroke_width, 1e-4); + float coverage = 1.0 - smoothstep(-blur, blur, d); + c = float4(in.color.rgb, in.color.a * coverage); + } else if (in.use_texture > 2.5) { // SDF rounded rect: analytic coverage with screen-space AA. float d = sd_rounded_box(in.local_pos, in.rect_half, in.corner_radii); if (in.stroke_width > 0.0) { @@ -1853,8 +1867,9 @@ mod tests { #[test] fn vertex_size_and_alignment() { // The Vertex struct must match the MSL packed layout: 9 base floats - // (position 2, color 4, tex_coord 2, use_texture 1) plus 9 SDF floats - // (local_pos 2, rect_half 2, corner_radii 4, stroke_width 1) = 72 bytes. + // (position 2, color 4, tex_coord 2, use_texture 1) plus 9 SDF/shadow + // floats (local_pos 2, rect_half 2, corner_radii 4, stroke_width 1) + // = 72 bytes. assert_eq!(std::mem::size_of::(), 72); assert_eq!(std::mem::align_of::(), 4); } @@ -1947,6 +1962,12 @@ mod tests { assert!(SHADER_SOURCE.contains("out.world_pos = pos;")); } + #[test] + fn shader_applies_sdf_shadow_coverage() { + assert!(SHADER_SOURCE.contains("1.0 - smoothstep(-blur, blur, d)")); + assert!(SHADER_SOURCE.contains("in.use_texture > 3.5")); + } + #[test] fn clip_params_layout_matches_msl() { // MSL packs ClipParams as: center @0, half_extent @8, radii @16 diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index 9d6338e..40d0e79 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -20,7 +20,7 @@ use we_platform::metal::{ self, ClearColor, ClipParams, CommandBuffer, CommandQueue, Device, MetalLayer, MetalRenderer, RenderCommandEncoder, SamplerState, Texture, TextureCache, Uniforms, Vertex, MTL_PIXEL_FORMAT_BGRA8_UNORM, MTL_PRIMITIVE_TYPE_TRIANGLE, TEXTURE_MODE_COLOR, - TEXTURE_MODE_MASK, TEXTURE_MODE_SDF, TEXTURE_MODE_SOLID, + TEXTURE_MODE_MASK, TEXTURE_MODE_SDF, TEXTURE_MODE_SHADOW, TEXTURE_MODE_SOLID, }; use we_style::computed::{BackgroundRepeat, LengthOrAuto, ObjectFit}; use we_text::font::{Font, FontRegistry}; @@ -755,6 +755,33 @@ impl GpuRenderer { ); } + PaintCommand::BoxShadow { + x, + y, + width, + height, + radii, + blur_radius, + color, + } => { + let tex_key = TextureKey::Solid; + if tex_key != batch_texture && !batch_vertices.is_empty() { + self.flush_batch(&batch_vertices, batch_texture, &encoder); + batch_vertices.clear(); + } + batch_texture = tex_key; + push_sdf_box_shadow( + &mut batch_vertices, + *x + coord_offset.0, + *y + coord_offset.1, + *width, + *height, + *radii, + *blur_radius, + color_to_f32(color), + ); + } + PaintCommand::DrawGlyphs { line, font_size: _, @@ -1628,6 +1655,54 @@ fn push_sdf_rounded_rect( out.push(vert(x0, y1)); } +/// Push 6 vertices for an analytic blurred rounded-rect shadow. +/// +/// The input rect is the shadow-casting shape after CSS spread has been +/// applied. The generated quad is expanded by the blur radius so the shader has +/// room to shade the soft falloff outside the shape. +#[allow(clippy::too_many_arguments)] +fn push_sdf_box_shadow( + out: &mut Vec, + x: f32, + y: f32, + w: f32, + h: f32, + radii: [f32; 4], + blur_radius: f32, + color: [f32; 4], +) { + if w <= 0.0 || h <= 0.0 { + return; + } + + let blur = blur_radius.max(0.0); + let pad = blur + 1.0; + let qx0 = x - pad; + let qy0 = y - pad; + let qx1 = x + w + pad; + let qy1 = y + h + pad; + let half = [w * 0.5, h * 0.5]; + let cx = x + half[0]; + let cy = y + half[1]; + let vert = |px: f32, py: f32| Vertex { + position: [px, py], + color, + tex_coord: [0.0, 0.0], + use_texture: TEXTURE_MODE_SHADOW, + local_pos: [px - cx, py - cy], + rect_half: half, + corner_radii: radii, + stroke_width: blur, + }; + + out.push(vert(qx0, qy0)); + out.push(vert(qx1, qy0)); + out.push(vert(qx0, qy1)); + out.push(vert(qx1, qy0)); + out.push(vert(qx1, qy1)); + out.push(vert(qx0, qy1)); +} + /// Push 6 vertices (2 triangles) for a textured glyph quad. #[cfg(test)] fn push_textured_quad(out: &mut Vec, quad: &TexturedQuad, color: [f32; 4]) { @@ -2245,6 +2320,28 @@ mod tests { } } + #[test] + fn sdf_box_shadow_vertices_expand_for_blur() { + let mut verts = Vec::new(); + push_sdf_box_shadow( + &mut verts, + 10.0, + 20.0, + 100.0, + 50.0, + [8.0; 4], + 12.0, + [0.0, 0.0, 0.0, 0.5], + ); + + assert_eq!(verts.len(), 6); + assert!(verts.iter().all(|v| v.use_texture == TEXTURE_MODE_SHADOW)); + assert!(verts.iter().all(|v| v.stroke_width == 12.0)); + assert!(verts.iter().any(|v| v.position == [-3.0, 7.0])); + assert!(verts.iter().any(|v| v.position == [123.0, 83.0])); + assert!(verts.iter().all(|v| v.rect_half == [50.0, 25.0])); + } + fn test_radial_gradient() -> RadialGradient { RadialGradient { stops: vec![ diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4f9da2b..de770e8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -70,6 +70,18 @@ pub enum PaintCommand { stroke_width: f32, color: Color, }, + /// Draw an outer blurred rounded-rectangle shadow. The geometry describes + /// the shadow-casting box after spread has been applied; the renderer + /// expands the draw quad by `blur_radius` and shades the soft alpha falloff. + BoxShadow { + x: f32, + y: f32, + width: f32, + height: f32, + radii: [f32; 4], + blur_radius: f32, + color: Color, + }, /// Fill a rectangle with a CSS linear gradient. LinearGradient { x: f32, @@ -838,26 +850,41 @@ fn paint_box_shadow( return; } - let layers = ((blur / 2.0).ceil() as usize).clamp(3, 12); - let weight_sum = (layers * (layers + 1) / 2) as f32; - for i in 0..layers { - let progress = if layers == 1 { - 1.0 - } else { - i as f32 / (layers - 1) as f32 - }; - let expansion = blur * (1.0 - progress); - let alpha_factor = (i + 1) as f32 / weight_sum; - paint_shadow_layer( - layout_box, - shadow, - border_box, - list, - translate, - expansion, - alpha_factor, - ); + paint_blurred_shadow_layer(layout_box, shadow, border_box, list, translate, blur); +} + +fn paint_blurred_shadow_layer( + layout_box: &LayoutBox, + shadow: &BoxShadow, + border_box: Rect, + list: &mut DisplayList, + translate: (f32, f32), + blur: f32, +) { + let spread = shadow.spread_radius; + let x = border_box.x + translate.0 + shadow.offset_x - spread; + let y = border_box.y + translate.1 + shadow.offset_y - spread; + let w = border_box.width + spread * 2.0; + let h = border_box.height + spread * 2.0; + if w <= 0.0 || h <= 0.0 { + return; } + + let radii = [ + (layout_box.border_radii[0] + spread).max(0.0), + (layout_box.border_radii[1] + spread).max(0.0), + (layout_box.border_radii[2] + spread).max(0.0), + (layout_box.border_radii[3] + spread).max(0.0), + ]; + list.push(PaintCommand::BoxShadow { + x, + y, + width: w, + height: h, + radii, + blur_radius: blur, + color: shadow.color, + }); } fn paint_shadow_layer( @@ -3321,11 +3348,11 @@ div { .position(|cmd| { matches!( cmd, - PaintCommand::RoundedRect { color, .. } + PaintCommand::BoxShadow { color, .. } if color.r == 0 && color.g == 0 && color.b == 0 && color.a > 0 ) }) - .expect("box-shadow should emit rounded shadow paint"); + .expect("box-shadow should emit blurred shadow paint"); let background_index = list .iter() .position(|cmd| { -- 2.51.2