Repository navigation
Description
Summary
On the UberSDF path, a paint whose color source UberSDF cannot evaluate, such as a FragmentShader, is drawn as a white SDF mask and the color source rendered separately, then combined with MakeBlend(kSrcIn) through offscreen snapshots (canvas.cc:2318-2335). #192994 covers the cost of that fallback.
This report is about its output. The color source is shifted by the fractional part of the draw's device-space origin. A shader that reads FlutterFragCoord() gets a value up to half a pixel away from the pixel centre, across the whole shape and not only at its edges. The same draw off the SDF path is correct to the code value.
It matters for any shader whose output depends on position: sampling an image at FlutterFragCoord, gradients computed in the shader, SDF shapes drawn by the shader itself. In our case, a glass shader that samples a captured backdrop, and should reproduce it exactly at identity settings, differs from it by up to 136 code values over every row of the glass on a fractional layout.
Steps to reproduce
The reproduction from #193388 covers this too: a shader that encodes FlutterFragCoord in red and green, so every pixel's expected value is known, drawn into a 40×16 rect at an integer and at a fractional origin. In that code, aa=true takes the SDF path, and aa=false primed takes the direct path. The priming works around #193388.
Expected results
Interior pixels equal the shader's value at their centre on both paths, as they do on the direct path and on Impeller Vulkan (0 code values of error in every case).
Actual results
macOS, Impeller MetalSDF, master 4759fcb190. Worst error over pixels at least one pixel inside the rect, in 8-bit code values. The shader's value moves 32 code values per pixel.
| Rect origin | SDF path (aa=true) |
Direct path (aa=false, primed) |
|---|---|---|
| (8, 8) | 8 | 0 |
| (8.37, 8.61) | 16 | 4 |
On one interior row of the fractional rect, the SDF path's red channel is +12 code values off at every pixel (0.37 px, the fractional part of the left edge). Green is −13 (−0.39 px, which is 0.61 − 1). Output of the SDF path at x = 11…20, y = 12, against the value at each pixel centre:
SDF path R [124, 156, 189, 219, 251, 28, 60, 91, 123, 155] G 130
expected R [112, 143, 175, 207, 239, 16, 48, 80, 112, 143] G 143
direct R [112, 143, 176, 208, 240, 16, 48, 80, 112, 143] G 144
At the integer origin the worst pixel is next to the corner, at (9, 9), and the sampled interior row is within 1.
Same results on stable 3.47.1. Android (Impeller Vulkan) has no SDF path and gives 0 everywhere.
Suspected cause
From the numbers, not verified in the source: the color source snapshot looks like it is rendered into a pixel-aligned texture with the entity's fractional translation dropped, or applied twice. Its FlutterFragCoord is then offset by the fraction of the origin when the snapshot is composited.
The fix discussed in #192994 would remove this along with the cost: tessellating axis-aligned rects, or evaluating runtime effects without the fallback.
Activity
suryaKommana2662 commentedon Sep 28, 2026
@PlugFox Thanks for the detailed report. Could you please confirm whether the
FlutterFragCoord()coordinate shift can also be reproduced when the draw is intentionally routed through the UberSDF path. Also, could you please confirm whether the issue is reproducible on the latest stable Flutter version?PlugFox commentedon Sep 29, 2026
@suryaKommana2662 Thanks. Both answered below. Code and full output are in https://gist.github.com/PlugFox/d62e54600d89b31e83e3af3597eb0de0.
1. Through the UberSDF path: yes, that is where the shift happens. On macOS no special routing is needed. A
FragmentShaderrect with the defaultisAntiAlias = truealready takes the UberSDF path, and in the reproduction that is theaa=truearm. The arm that does not take it isaa=false primed. Evidence for which arm took which path:Using the Impeller rendering backend (MetalSDF);aa=falseis pixel-identical toaa=true(0 pixels differ), because of [Impeller] Paint.isAntiAlias = false is ignored when no earlier draw in the picture set it to true, so the SDF opt-out from #186020 does not apply #193388, while the primed arm differs from both in 326–696 pixels;Pipeline Blend Filterpasses on the SDF arm and none on the primed arm. Those passes are theMakeBlend(kSrcIn)fallback atcanvas.cc:2318-2335.To show that the shift is the fractional part of the origin, I swept that fraction (
sweep.dartin the gist). The table gives the mean offset, in px, between the coordinate the shader saw and the pixel centre, over pixels at least 2 px inside a 40×16 rect:On the SDF path the offset is
fracwhen the fraction is below one half andfrac − 1when it is above, independently per axis. Across the interior it varies by only about ±0.03 px, so every interior pixel is shifted, not just the edges. At exactly .5 the interior splits between +0.5 and −0.5, so that row's mean is not meaningful. On the direct path the mean offset stays within 0.01 px at every origin.2. Latest stable: yes. The shift reproduces on stable 3.47.5 (
6a19cca564, engineab59836859) and on today's masterfab991537b(enginef059d397f9), macOS 27.0, M3 Max,flutter build macos --release, with two runs each. The sweep above gives the same output on both. The #193388 reproduction prints the same pixel results as in the original report on both: interior error 8 / 8 / 0 at (8, 8) and 16 / 16 / 4 at (8.37, 8.61), with 326 and 696 pixels differing.suryaKommana2662 commentedon Sep 30, 2026
@PlugFox Thanks for the detailed report. I was able to reproduce this locally on both stable and current master on macOS. Routing this to
team-enginefor further investigationThe results match the reported behavior:
Master
Stable
gaaclarke commentedon Oct 5, 2026
@flar this is related to the stuff you were looking in, care to give this a look?
flar commentedon Oct 6, 2026
It sounds related. I've been concerned that we determine the size of a sub-pass at various places and then just subtract out the (sub-pixel) translation when we should try to only have sub-passes that are translated by whole pixels. The down-side of that technique is to worry about how to handle blur transforms that need to clamp to edge pixels, but there are potential solutions to that. Minimally I can look to see if we can narrow down the times when we move all rendering to the UL corner of a sub-pass only to cases where it is a blur filter with a clamp tile mode, but then we might end up applying the translation incorrectly for custom fragment programs that might want the same treatment.
I'll look into whether a better decision logic (or another solution for clamped edges) would help here.
flar commentedon Oct 6, 2026
For one thing, aligning blur sub-passes to the UL of the sub-pass only fixes the clamping of the top and left edges. The right and bottom edges still "shimmer" if the rendering area is not a whole block of pixels wide or tall. For fairly common cases of integer scales and integer rendering, the bottom and right edges probably clamp appropriately, but I think Android and Windows often have a non-integer DPI value and apps may render a fractional content size.