ITADN

vello Classic ramp_cache interpolates gradients in premultiplied alpha; diverges from tiny_skia / SVG renderers for stops at differing alpha (split from #1056)

#1657Openmxaddict 创建于 2026-05-17
M
mxaddictcommented
## Summary `vello` Classic (via `vello_encoding::ramp_cache::make_ramp`) interpolates gradient stops in **premultiplied alpha space**, whereas `tiny_skia` (and per #1056, eventually `vello_hybrid`) interpolates in **straight alpha space**. For any gradient whose stops have non-uniform alpha, this produces a visibly different mid-gradient color — by tens of intensity levels per channel, not a precision artifact. Splitting this off from #1056, which was opened against the original premultiplied behavior and closed when `vello_hybrid` switched. The Classic codepath was not updated; the last comment on #1056 said "working on fixing this in vello classic" but there is no tracking issue. This is that issue. ## Repro (no SVG) A 256×32 rect filled with a linear gradient: cream (`#ffecbb` opaque) → 50%-alpha black, rendered via `vello::Renderer::render_to_texture` and `tiny_skia::PixmapMut::fill_rect` at the same size. Standalone, no `vello_svg` / `usvg` in the path. ```rust let stops = vec![ ColorStop { offset: 0.0, color: DynamicColor::from_alpha_color( Color::from_rgba8(0xff, 0xec, 0xbb, 0xff)) }, ColorStop { offset: 1.0, color: DynamicColor::from_alpha_color( Color::from_rgba8(0x00, 0x00, 0x00, 0x80)) }, ]; let gradient = Gradient::new_linear((0.0, 0.0), (256.0, 0.0)) .with_stops(stops.as_slice()); scene.fill( Fill::NonZero, Affine::IDENTITY, &Brush::Gradient(gradient), None, &Rect::new(0.0, 0.0, 256.0, 32.0), ); // ... render_to_texture into an Rgba8Unorm target, readback ... ``` Tiny-skia equivalent uses `tiny_skia::LinearGradient::new` with the same two stops and `fill_rect`. ## Output Mid-gradient pixel (x = 128, y = 16): | | RGBA (as stored) | Storage format | Un-premultiplied RGB | | ------------ | ---------------- | ---------------------- | -------------------- | | `vello` | (170, 158, 124, 191) | Rgba8Unorm (straight) | (170, 158, 124) | | `tiny_skia` | ( 95, 89, 70, 191) | Rgba8 premultiplied | (127, 118, 93) | Normalizing both to *unpremultiplied* RGB at the mid pixel: - vello: (170, 158, 124) — ≈ 89 % of the cream stop, lerped towards black via **premultiplied** path. Matches `lerp(premul((255,236,187,255)), premul((0,0,0,128)), 0.5) → un_premultiply` = (170, 157, 125). - tiny_skia: (127, 118, 93) — straight RGB lerp `(255+0)/2, (236+0)/2, (187+0)/2`, alpha lerped separately to 191. Mean abs diff over the full 256 × 32 RGBA = **33.54** (out of 255). Not AA noise. ## Root cause `vello_encoding/src/ramp_cache.rs` `make_ramp` calls `AlphaColor::lerp`: ```rust let c = if du < 1e-9 { this_c } else { last_c.lerp(this_c, (u - last_u) / du, HueDirection::default()) }; c.premultiply().to_rgba8().to_u32() ``` `AlphaColor::lerp` in `color` 0.3.3 is defined as: ```rust pub fn lerp(self, other: Self, t: f32, direction: HueDirection) -> Self { self.premultiply() .lerp(other.premultiply(), t, direction) .un_premultiply() } ``` So the stops are premultiplied, lerped in premul space, un-premultiplied, then premultiplied again for storage — equivalent to a straight premultiplied lerp. `Gradient::with_interpolation_alpha_space(InterpolationAlphaSpace::Unpremultiplied)` does **not** change this — `make_ramp` doesn't read that field. (Verified: I set it both ways from `vello_svg`, identical pixel output.) ## Why this matters `vello_svg` users (consumed via `floem` for instance, which is how I hit this) get visibly different gradient renders from every other SVG renderer in the ecosystem — `resvg`/`tiny_skia`, Inkscape, Firefox, Chromium. The affected case is "gradient stops at differing alpha", which is common in stock app icons (the originating case was the `qv4l2.svg` shipped with `v4l-utils`). See https://github.com/linebender/vello_svg/issues/83 for the full bisection across a 49-icon XDG corpus. ## Suggested fix Either: 1. Have `make_ramp` do component-wise straight-RGBA lerp on `(r, g, b, a)` directly, premultiplying only at the `to_rgba8()` step. Simplest, matches `tiny_skia` and SVG renderers in practice. 2. Honour `Gradient::interpolation_alpha_space` in `make_ramp` and pick path 1 when it's `Unpremultiplied`. Slightly more work, preserves both behaviours. Happy to do (1) as a PR if maintainers prefer that direction — but I don't know whether the original #1056 thread reached consensus on whether vello Classic should default to straight or premultiplied (CSS Color Module 4 says premultiplied; HTML Canvas / SVG-in-practice say straight). Pointer appreciated. Repro binary lives at https://github.com/mxaddict/vello_svg/blob/fix/gradient-transform-composition/icon-corpus/src/alpha_ramp_repro.rs — `cargo run -p icon-corpus --bin alpha-ramp-repro --release` from the fork's root reproduces the table.
0 条评论