Wide multi-stop CSS gradient smearing

What this page demonstrates

CSS gradients with more than 128 color stops fall off Skia Ganesh's analytic uniform colorizer onto a 256-texel pre-rasterized bitmap fallback, which visibly smears the color bands at wide canvas widths.

A proposed patch adds a many-stop analytic colorizer in Ganesh that mirrors Graphite's storage-buffer gradient path using existing texture plumbing. The same code path is used by all CSS gradient types -- the sampler below exercises linear-gradient, radial-gradient and conic-gradient at 130 hard-stop segments.

1. Root cause

GrGradientShader::MakeColorizermake_looping_binary_colorizer caps at kMaxLoopingColorCount = 128. Anything above falls to make_textured_colorizer, which pre-rasterizes the gradient into a 1 x 256 RGBA bitmap and bilinearly samples it. At 3800-px gradient widths each canvas pixel maps to ~0.067 of a texel, so band boundaries get bilinearly smeared into 2-3-pixel-wide blends.

2. Linear -- 130 stops, 3801px width

linear-3801 -- background-size: 3801px, 130-stop hard-stop pattern

expected linear-3801
Rendered live in your browser. Expected from patched build.

3. Linear -- 130 stops, 3800px width

linear-3800 -- same pattern, background-size: 3800px

expected linear-3800
Rendered / Expected.

4. Linear -- codepen extra-stop variant

linear-3800-extra -- 64 segments + green 3800px-3801px hard stop

expected linear-3800-extra
Mirrors the third codepen stripe; the extra past-1.0 green stop pushes the input over the cap after CSS normalization.

5. Radial -- 130 stops

radial-130 -- circle at center

expected radial-130
Same colorizer code path -- crisp concentric bands post-fix; pre-fix shows wash from bilinear smear.

6. Conic -- 130 stops

conic-130 -- from 0deg at center

expected conic-130
Crisp pie slices post-fix; pre-fix shows smudged wedge edges.

7. Chromium before / after (SwiftShader Vulkan, Ganesh)

Before System Chromium 146 (unpatched) vs After Chromium dev build with Skia CL 1237936 (PS7) applied to third_party/skia. Both captured on the same host with the exact flags from section 9 (SwiftShader Vulkan, GPU rasterization, Ganesh). The buffered colorizer engagement was verified via debug logging (count=260 stops for the 130-segment stripes).

Unpatched: red→green band boundaries smear across ~13 px (256-texel bitmap, bilinear). Patched: 1-px transitions at the exact 29.24-px band positions. Blend-zone pixel share for radial/conic: 40.7% → 0.5%.

linear-3801 -- screenshot crops

linear-3801 before
linear-3801 after
Before: boundaries smeared over ~13px. After: crisp 1px boundaries.

radial-130 -- screenshot crops

radial-130 before
radial-130 after
Blend-zone pixels 40.7% → 0.5%.

conic-130 -- screenshot crops

conic-130 before
conic-130 after
Blend-zone pixels 40.8% → 0.5%.

8. Skia GM golden images (patched build, PS7)

→ Full golden comparison: main vs PS6 (broken) vs PS7 (fixed) vs raster

dm renders of the Skia GMs that gate this CL, produced with the patched colorizer on Mesa llvmpipe (--config gl) next to the raster ground truth (--config 8888). PS6 rendered the gl images incorrectly: the buffered-colorizer effect cache was keyed by loop count but baked the stop count into the SkSL, so gradients reusing a cached effect clamped at the seeding gradient's stop count. PS7 passes the stop count as a uniform.

EGL_PLATFORM=surfaceless LIBGL_ALWAYS_SOFTWARE=1 \
  dm --config gl 8888 -m hardstop_gradients_many gradients_color_space_many_stops -w out

hardstop_gradients_many -- rows 65-100 exercise the new colorizer (130-200 stops)

hardstop_gradients_many gl
hardstop_gradients_many raster
Remaining GL-vs-raster deltas are single-pixel hardstop boundary columns from the F16 stop-offset encoding (same tradeoff as Graphite's texture path).

gradients_color_space_many_stops -- 200 stops, OKLCH interpolation

gradients_color_space_many_stops gl
gradients_color_space_many_stops raster
maxDiff 30/255 vs raster (was 226 on PS6), from F16 interpolation precision in OKLCH space.

gradient_many_stops / gradients_alpha_many_stops

gradient_many_stops gl
gradient_many_stops raster
gradients_alpha_many_stops gl
gradients_alpha_many_stops raster
Both pixel-clean vs raster (maxDiff 12 and 1).

9. Reproducing locally on Linux

Linux Chrome with hardware GPU rasterization hits the Ganesh path. On a box without a real GPU, force SwiftShader Vulkan so the same code path runs:

chromium \
  --user-data-dir=/tmp/i-443106929 \
  --ignore-gpu-blocklist \
  --enable-unsafe-swiftshader \
  --enable-features=Vulkan --use-vulkan=swiftshader \
  --enable-gpu-rasterization \
  --disable-features=SkiaGraphite \
  https://static.januschka.com/i-443106929/

macOS Chrome and software-rasterization Linux Chrome render this correctly because they do not use the Ganesh many-stop fallback (Graphite has a real SSBO-backed many-stop path; software raster pipeline has no cap).

References

Chromium issue 443106929 Skia CL 1237936 Original codepen repro