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| {