diff --git a/.isu/issues.json b/.isu/issues.json index c280251..8594473 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 383, + "next_id": 384, "issues": [ { "id": 1, @@ -4686,6 +4686,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-16T11:10:08Z" + }, + { + "id": 383, + "repo": "we", + "title": "Bing parity still above threshold after gradient stop tessellation", + "body": "Parent: isu issue 280.\n\nThe renderer now tessellates CSS linear gradients per resolved stop interval, so intermediate, positioned, and repeated stops are no longer approximated with a single corner-colored quad. This improved the same-cache Chromium comparison, but the Bing real-web scenario still remains well above the screenshot threshold and issue 280 cannot be closed yet.\n\nRepro:\n\ncargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/bing.com.we --out-dir crates/e2e/artifacts\n\nCurrent committed-golden failure:\n- desktop: 4.85% match (1168914/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile: 5.17% match (312140/329160 px differ, tol=4, max_diff=0.1000%)\n\nCurrent same-cache Chromium comparison:\n- desktop: 56.15% match (538754/1228500 px differ, tol=4)\n- mobile: 49.22% match (167154/329160 px differ, tol=4)\n\nPrevious same-cache comparison before gradient stop tessellation:\n- desktop: 50.70% match (605673/1228500 px differ, tol=4)\n- mobile: 43.92% match (184603/329160 px differ, tol=4)\n\nArtifacts:\n- crates/e2e/artifacts/real-web/bing.com/desktop.png\n- crates/e2e/artifacts/real-web/bing.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png.diff.png\n- crates/e2e/artifacts/real-web/bing.com/desktop.scenario-cache.chromium.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.scenario-cache.chromium.png\n\nNotes:\n- The committed Bing Chromium goldens are still stale against the SichuanTea snapshot; see isu issue 305.\n- Same-cache Chromium still differs materially, so there is an actual deterministic parity gap beyond the stale committed goldens.\n- Remaining candidates include image/layout/text/compositing differences around the search box, hero treatment, and lower content.\n\nAcceptance: after the stale Bing golden issue is resolved, fix the remaining deterministic same-cache visual gap enough to remove the xfail from crates/e2e/scenarios/real-web/bing.com.we.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-16T11:37:55Z" } ] } diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index 8fe10d2..f5b3c8a 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -1045,7 +1045,28 @@ fn push_solid_quad(out: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: }); } -/// Push 6 vertices for a CSS linear-gradient rectangle. +#[derive(Clone, Copy)] +struct GradientPoint { + x: f32, + y: f32, +} + +#[derive(Clone, Copy)] +struct ResolvedGradientStop { + color: Color, + position: f32, +} + +const GRADIENT_EPSILON: f32 = 0.0001; + +/// Push vertices for a CSS linear-gradient rectangle. +/// +/// A single quad with only corner colors is not enough for positioned or +/// intermediate color stops: the GPU would interpolate across the whole box and +/// smear stops such as `transparent 60%, black`. Instead, clip the rectangle +/// into one polygon per stop interval and triangulate each interval. Because +/// `t` is affine for the supported keyword directions, interpolating vertex +/// colors within one stop interval is exact. fn push_linear_gradient_quad( out: &mut Vec, x: f32, @@ -1054,26 +1075,23 @@ fn push_linear_gradient_quad( h: f32, gradient: &LinearGradient, ) { - let x0 = x; - let y0 = y; - let x1 = x + w; - let y1 = y + h; - let tl = color_to_f32(&gradient_color_at( - gradient, - gradient_t(gradient.direction, 0.0, 0.0), - )); - let tr = color_to_f32(&gradient_color_at( - gradient, - gradient_t(gradient.direction, 1.0, 0.0), - )); - let br = color_to_f32(&gradient_color_at( - gradient, - gradient_t(gradient.direction, 1.0, 1.0), - )); - let bl = color_to_f32(&gradient_color_at( - gradient, - gradient_t(gradient.direction, 0.0, 1.0), - )); + if w <= 0.0 || h <= 0.0 { + return; + } + + let stops = resolve_gradient_stops(gradient); + if stops.is_empty() { + return; + } + + let mut cuts = vec![0.0, 1.0]; + for stop in &stops { + if stop.position > 0.0 && stop.position < 1.0 { + cuts.push(stop.position); + } + } + cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + cuts.dedup_by(|a, b| (*a - *b).abs() <= GRADIENT_EPSILON); let vert = |px: f32, py: f32, color: [f32; 4]| Vertex { position: [px, py], @@ -1083,12 +1101,28 @@ fn push_linear_gradient_quad( ..Vertex::default() }; - out.push(vert(x0, y0, tl)); - out.push(vert(x1, y0, tr)); - out.push(vert(x0, y1, bl)); - out.push(vert(x1, y0, tr)); - out.push(vert(x1, y1, br)); - out.push(vert(x0, y1, bl)); + for pair in cuts.windows(2) { + let t0 = pair[0]; + let t1 = pair[1]; + if t1 - t0 <= GRADIENT_EPSILON { + continue; + } + let polygon = clip_gradient_interval(gradient.direction, t0, t1); + if polygon.len() < 3 { + continue; + } + + let p0 = polygon[0]; + for i in 1..polygon.len() - 1 { + let p1 = polygon[i]; + let p2 = polygon[i + 1]; + for p in [p0, p1, p2] { + let t = gradient_t(gradient.direction, p.x, p.y); + let color = color_to_f32(&gradient_color_at_interval(&stops, t, t0, t1)); + out.push(vert(x + p.x * w, y + p.y * h, color)); + } + } + } } fn gradient_t(direction: LinearGradientDirection, x: f32, y: f32) -> f32 { @@ -1104,21 +1138,192 @@ fn gradient_t(direction: LinearGradientDirection, x: f32, y: f32) -> f32 { } } +#[cfg(test)] fn gradient_color_at(gradient: &LinearGradient, t: f32) -> Color { - let Some(first) = gradient.stops.first() else { + gradient_color_at_resolved(&resolve_gradient_stops(gradient), t) +} + +fn resolve_gradient_stops(gradient: &LinearGradient) -> Vec { + let len = gradient.stops.len(); + if len == 0 { + return Vec::new(); + } + if len == 1 { + return vec![ResolvedGradientStop { + color: gradient.stops[0].color, + position: gradient.stops[0].position.unwrap_or(0.0), + }]; + } + + let mut positions: Vec> = gradient.stops.iter().map(|s| s.position).collect(); + if positions[0].is_none() { + positions[0] = Some(0.0); + } + if positions[len - 1].is_none() { + positions[len - 1] = Some(1.0); + } + + let mut i = 0; + while i < len { + if positions[i].is_some() { + i += 1; + continue; + } + let run_start = i; + while i < len && positions[i].is_none() { + i += 1; + } + let run_end = i; + let prev = positions[run_start - 1].unwrap_or(0.0); + let next = positions[run_end].unwrap_or(prev); + let count = (run_end - run_start + 1) as f32; + for (offset, slot) in positions[run_start..run_end].iter_mut().enumerate() { + let step = (offset as f32 + 1.0) / count; + *slot = Some(prev + (next - prev) * step); + } + } + + let mut last = f32::NEG_INFINITY; + gradient + .stops + .iter() + .zip(positions) + .map(|(stop, position)| { + let mut position = position.unwrap_or(last.max(0.0)); + if position < last { + position = last; + } + last = position; + ResolvedGradientStop { + color: stop.color, + position, + } + }) + .collect() +} + +fn gradient_color_at_resolved(stops: &[ResolvedGradientStop], t: f32) -> Color { + let Some(first) = stops.first() else { return Color::new(0, 0, 0, 0); }; - let Some(last) = gradient.stops.last() else { + if t <= first.position { return first.color; + } + + for pair in stops.windows(2) { + let a = pair[0]; + let b = pair[1]; + if t <= b.position { + let span = b.position - a.position; + if span.abs() <= f32::EPSILON { + return b.color; + } + let local = ((t - a.position) / span).clamp(0.0, 1.0); + return lerp_color(a.color, b.color, local); + } + } + + stops.last().map(|s| s.color).unwrap_or(first.color) +} + +fn gradient_color_at_interval( + stops: &[ResolvedGradientStop], + t: f32, + interval_start: f32, + interval_end: f32, +) -> Color { + let t = if (t - interval_start).abs() <= GRADIENT_EPSILON + && has_repeated_gradient_stop_at(stops, interval_start) + { + (t + GRADIENT_EPSILON).min(interval_end) + } else { + t }; - let first_pos = first.position.unwrap_or(0.0); - let last_pos = last.position.unwrap_or(1.0); - let span = last_pos - first_pos; - if span.abs() <= f32::EPSILON { - return last.color; + gradient_color_at_resolved(stops, t) +} + +fn has_repeated_gradient_stop_at(stops: &[ResolvedGradientStop], position: f32) -> bool { + stops.windows(2).any(|pair| { + (pair[0].position - position).abs() <= GRADIENT_EPSILON + && (pair[1].position - position).abs() <= GRADIENT_EPSILON + }) +} + +fn clip_gradient_interval( + direction: LinearGradientDirection, + min_t: f32, + max_t: f32, +) -> Vec { + let polygon = vec![ + GradientPoint { x: 0.0, y: 0.0 }, + GradientPoint { x: 1.0, y: 0.0 }, + GradientPoint { x: 1.0, y: 1.0 }, + GradientPoint { x: 0.0, y: 1.0 }, + ]; + let polygon = clip_gradient_half_plane(polygon, direction, min_t, true); + clip_gradient_half_plane(polygon, direction, max_t, false) +} + +fn clip_gradient_half_plane( + polygon: Vec, + direction: LinearGradientDirection, + boundary: f32, + keep_greater: bool, +) -> Vec { + if polygon.is_empty() { + return polygon; + } + + let mut out = Vec::new(); + let mut prev = *polygon.last().unwrap(); + let mut prev_t = gradient_t(direction, prev.x, prev.y); + let mut prev_inside = gradient_inside(prev_t, boundary, keep_greater); + for current in polygon { + let current_t = gradient_t(direction, current.x, current.y); + let current_inside = gradient_inside(current_t, boundary, keep_greater); + + if current_inside != prev_inside { + if let Some(intersection) = + gradient_boundary_intersection(prev, prev_t, current, current_t, boundary) + { + out.push(intersection); + } + } + if current_inside { + out.push(current); + } + + prev = current; + prev_t = current_t; + prev_inside = current_inside; + } + out +} + +fn gradient_inside(t: f32, boundary: f32, keep_greater: bool) -> bool { + if keep_greater { + t >= boundary - GRADIENT_EPSILON + } else { + t <= boundary + GRADIENT_EPSILON + } +} + +fn gradient_boundary_intersection( + a: GradientPoint, + a_t: f32, + b: GradientPoint, + b_t: f32, + boundary: f32, +) -> Option { + let denom = b_t - a_t; + if denom.abs() <= f32::EPSILON { + return None; } - let local = ((t - first_pos) / span).clamp(0.0, 1.0); - lerp_color(first.color, last.color, local) + let local = ((boundary - a_t) / denom).clamp(0.0, 1.0); + Some(GradientPoint { + x: a.x + (b.x - a.x) * local, + y: a.y + (b.y - a.y) * local, + }) } fn lerp_color(a: Color, b: Color, t: f32) -> Color { @@ -1482,6 +1687,125 @@ mod tests { assert!((f[3] - 1.0).abs() < 0.01); } + #[test] + fn gradient_color_at_uses_intermediate_stops() { + let gradient = LinearGradient { + direction: LinearGradientDirection::ToBottom, + stops: vec![ + we_css::values::GradientStop { + color: Color::rgb(255, 0, 0), + position: Some(0.0), + }, + we_css::values::GradientStop { + color: Color::rgb(0, 255, 0), + position: Some(0.5), + }, + we_css::values::GradientStop { + color: Color::rgb(0, 0, 255), + position: Some(1.0), + }, + ], + }; + + assert_eq!(gradient_color_at(&gradient, 0.0), Color::rgb(255, 0, 0)); + assert_eq!(gradient_color_at(&gradient, 0.5), Color::rgb(0, 255, 0)); + assert_eq!(gradient_color_at(&gradient, 1.0), Color::rgb(0, 0, 255)); + } + + #[test] + fn unspecified_gradient_stops_are_evenly_distributed() { + let gradient = LinearGradient { + direction: LinearGradientDirection::ToBottom, + stops: vec![ + we_css::values::GradientStop { + color: Color::rgb(255, 0, 0), + position: None, + }, + we_css::values::GradientStop { + color: Color::rgb(0, 255, 0), + position: None, + }, + we_css::values::GradientStop { + color: Color::rgb(0, 0, 255), + position: None, + }, + ], + }; + + let stops = resolve_gradient_stops(&gradient); + assert_eq!(stops.len(), 3); + assert!((stops[0].position - 0.0).abs() < 0.001); + assert!((stops[1].position - 0.5).abs() < 0.001); + assert!((stops[2].position - 1.0).abs() < 0.001); + } + + #[test] + fn repeated_gradient_stop_uses_right_color_for_next_interval() { + let gradient = LinearGradient { + direction: LinearGradientDirection::ToBottom, + stops: vec![ + we_css::values::GradientStop { + color: Color::rgb(255, 0, 0), + position: Some(0.0), + }, + we_css::values::GradientStop { + color: Color::rgb(0, 255, 0), + position: Some(0.5), + }, + we_css::values::GradientStop { + color: Color::rgb(0, 0, 0), + position: Some(0.5), + }, + we_css::values::GradientStop { + color: Color::rgb(255, 255, 255), + position: Some(1.0), + }, + ], + }; + + let stops = resolve_gradient_stops(&gradient); + assert_eq!( + gradient_color_at_interval(&stops, 0.5, 0.0, 0.5), + Color::rgb(0, 255, 0) + ); + assert_eq!( + gradient_color_at_interval(&stops, 0.5, 0.5, 1.0), + Color::rgb(0, 0, 0) + ); + } + + #[test] + fn positioned_gradient_stop_splits_quad() { + let gradient = LinearGradient { + direction: LinearGradientDirection::ToBottom, + stops: vec![ + we_css::values::GradientStop { + color: Color::new(0, 0, 0, 0), + position: Some(0.6), + }, + we_css::values::GradientStop { + color: Color::new(0, 0, 0, 255), + position: Some(1.0), + }, + ], + }; + let mut verts = Vec::new(); + push_linear_gradient_quad(&mut verts, 0.0, 0.0, 100.0, 100.0, &gradient); + + assert_eq!(verts.len(), 12); + assert!( + verts.iter().any(|v| (v.position[1] - 60.0).abs() < 0.01), + "expected a split at the 60% stop" + ); + assert!(verts + .iter() + .filter(|v| v.position[1] <= 60.01) + .all(|v| v.color[3].abs() < 0.01)); + assert!(verts + .iter() + .any(|v| (v.position[1] - 100.0).abs() < 0.01 && v.color[3] > 0.99)); + } + #[test] fn solid_quad_vertex_count() { let mut verts = Vec::new();