Skip to content

[Impeller] UberSDF color-source fallback shifts a FragmentShader by the fractional part of the draw's origin #193389

Open

Description

@PlugFox

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

  1. suryaKommana2662 commented on Sep 28, 2026

    @suryaKommana2662
    Contributor

    @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?

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 28, 2026
  3. PlugFox commented on Sep 29, 2026

    @PlugFox
    Author

    @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 FragmentShader rect with the default isAntiAlias = true already takes the UberSDF path, and in the reproduction that is the aa=true arm. The arm that does not take it is aa=false primed. Evidence for which arm took which path:

    To show that the shift is the fractional part of the origin, I swept that fraction (sweep.dart in 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:

    origin          SDF path dx, dy   direct path dx, dy   pixels differing
    (8.000, 8.000)  +0.007, +0.005    +0.000, +0.000       326
    (8.125, 8.000)  +0.132, +0.005    -0.000, +0.000       640
    (8.250, 8.000)  +0.257, +0.005    +0.003, +0.000       656
    (8.375, 8.000)  +0.382, +0.005    +0.003, +0.000       656
    (8.500, 8.000)  -0.155, +0.004    +0.004, +0.000       654
    (8.625, 8.000)  -0.364, +0.004    +0.004, +0.000       656
    (8.750, 8.000)  -0.243, +0.004    +0.009, +0.002       652
    (8.875, 8.000)  -0.121, +0.004    +0.009, +0.002       652
    (8.000, 8.250)  +0.007, +0.250    +0.003, +0.002       680
    (8.000, 8.500)  +0.007, -0.404    +0.003, +0.002       675
    (8.000, 8.750)  +0.008, -0.250    +0.003, +0.002       670
    (8.370, 8.610)  +0.374, -0.389    +0.005, +0.002       695
    

    On the SDF path the offset is frac when the fraction is below one half and frac − 1 when 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, engine ab59836859) and on today's master fab991537b (engine f059d397f9), 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.

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 29, 2026
  5. suryaKommana2662 commented on Sep 30, 2026

    @suryaKommana2662
    Contributor

    @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-engine for further investigation

    The results match the reported behavior:

    (8.0, 8.0): aa=true 8, aa=false primed 0, with 326 differing pixels.
    (8.37, 8.61): aa=true 16, aa=false primed 4, with 696 differing pixels.
    

    Master

    Flutter 3.49.0-1.0.pre-220 • channel master • https://github.com/flutter/flutter.git
    Framework • revision 2741d83954 (40 minutes ago) • 2026-09-30 04:19:31 +0000
    Engine • hash a7a573fdb0dfd60589f910ace1fb4f44ea715f23 (revision 2741d83954) (31 minutes ago) • 2026-09-30 04:19:31.000Z
    Tools • Dart 3.14.0 (build 3.14.0-271.0.dev) • DevTools 2.61.0-dev.0

    Stable

    Flutter 3.47.5 • channel stable • https://github.com/flutter/flutter.git
    Framework • revision 6a19cca564 (12 days ago) • 2026-09-17 14:13:22 -0400
    Engine • hash ab598368592da0064197e2bc15c7f5b0a2c6bb1f (revision af7e796e16) (13 days ago) • 2026-09-16 18:35:09.000Z
    Tools • Dart 3.13.4 • DevTools 2.60.0
  6. added
    engineflutter/engine related. See also e: labels.
    platform-macosBuilding on or for macOS specifically
    e: impellerImpeller rendering backend issues and features requests
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Sep 30, 2026
  7. added
    P2Important issues not at the top of the work list
    on Oct 5, 2026
  8. gaaclarke commented on Oct 5, 2026

    @gaaclarke
    Member

    @flar this is related to the stuff you were looking in, care to give this a look?

  9. flar commented on Oct 6, 2026

    @flar
    Contributor

    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.

  10. flar commented on Oct 6, 2026

    @flar
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: desktopRunning on desktope: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-macosBuilding on or for macOS specificallyteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions