vello Classic ramp_cache interpolates gradients in premultiplied alpha; diverges from tiny_skia / SVG renderers for stops at differing alpha (split from #1056)
## 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 条评论