Skip to content

Handle hairline strokes in UberSDF#184895

Merged
auto-submit[bot] merged 20 commits into
flutter:masterfrom
b-luk:hairline
Apr 24, 2026
Merged

Handle hairline strokes in UberSDF#184895
auto-submit[bot] merged 20 commits into
flutter:masterfrom
b-luk:hairline

Conversation

@b-luk

@b-luk b-luk commented Apr 10, 2026

Copy link
Copy Markdown
Contributor
  • Update UberSDFGeometry to account for padding for a thin stroke. Stroke widths are dynamically clamped to a minimum that depends on the transform. So it can't be calculated when the Geometry is instantiated and included in base_bounds_. It needs to be calculated in a similar way to AA padding.
  • Make UberSDFGeometry's AA padding and stroke padding use separate expansions for X vs Y values. This properly handles transforms that scale differently along the X and Y axes.
  • Update uber_sdf.frag with special case code to use a 1 pixel minimum stroke width for stroked SDFs.

Part of #184352
fixes #185162

AiksTest.CanRenderSkewedHairlineSdfCircle

Before:
image
After:
image
Non-SDF:
image

Note: Non-SDF version has inconsistent hairline width #185222

AiksTest.StrokedRectsRenderCorrectly

Before:
image
After:
image
Non-SDF
image

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. e: impeller Impeller rendering backend issues and features requests labels Apr 10, 2026
@b-luk b-luk added the CICD Run CI/CD label Apr 10, 2026
@b-luk b-luk marked this pull request as ready for review April 10, 2026 20:29

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for hairline strokes in the Uber SDF shader and geometry. Changes include adding a helper to identify hairline strokes, updating geometry calculations to account for hairline padding, and implementing a new hairlineSDF function in the fragment shader. Feedback was provided regarding the use of float literals and type consistency in C++, as well as a typo in a shader comment.

// AA padding.
Vector2 aa_padding = UberSDFParameters::kAntialiasPixels * device_pixel_size;
Vector2 hairline_stroke_padding =
params_.IsHairlineStroked() ? 0.5 * device_pixel_size : Point{0, 0};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use 0.5f instead of 0.5 to avoid double precision literal and potential promotion. Also, use Vector2{} for consistency with the variable type and to avoid mixing Point and Vector2 types.

Suggested change
params_.IsHairlineStroked() ? 0.5 * device_pixel_size : Point{0, 0};
params_.IsHairlineStroked() ? 0.5f * device_pixel_size : Vector2{};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

float hairlineSDF(vec2 p) {
float base_sdf = filledSDF(p);
float pixel_size = fwidth(base_sdf);
// Harline SDF is a 1-pixel wide band around the edge of the base SDF.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Typo in comment: "Harline" should be "Hairline".

  // Hairline SDF is a 1-pixel wide band around the edge of the base SDF.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@flutter-dashboard

Copy link
Copy Markdown

It looks like this pull request may not have tests. Please make sure to add tests or get an explicit test exemption before merging.

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. If you believe this PR qualifies for a test exemption, contact "@test-exemption-reviewer" in the #hackers channel in Discord (don't just cc them here, they won't see it!). The test exemption team is a small volunteer group, so all reviewers should feel empowered to ask for tests, without delegating that responsibility entirely to the test exemption group.

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 10, 2026
@b-luk b-luk added the CICD Run CI/CD label Apr 10, 2026
@b-luk b-luk assigned walley892 and flar and unassigned walley892 and flar Apr 10, 2026
@b-luk b-luk requested review from flar and walley892 April 10, 2026 20:38

@flar flar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bad implementation. We don't special case 0.0.

The stroke should always be at least a pixel wide everywhere. The default value of 0.0 relies on that rule to produce hairlines, but the same issue would come into play if the developer specified a width of 10 and a scale of 0.0001...

There should be no "test" on the width, there should be a clamp on the smallest stroke value to be a pixel, and that clamp would be computing the width perpendicular to the shape at its location.

Look at the original Circle SDF at the work done with dFdxy and dot products to make sure we did not have a width value that went less than 1 pixel.

@flar

flar commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Also, does this implementation work if the scale is 0.000001? The work done elsewhere will work regardless of scale.

Neither the padding nor the minimum stroke should be computed globally for the operation. What if there is a 10x scale in one direction and a 1x scale in the other? You can't pick a single width or aa pixel size that won't be stretched too much in one direction or squeezed too much in the other. This must be computed in the SDF calculations perpendicular to the outline.

@b-luk

b-luk commented Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

This is a bad implementation. We don't special case 0.0.

The stroke should always be at least a pixel wide everywhere. The default value of 0.0 relies on that rule to produce hairlines, but the same issue would come into play if the developer specified a width of 10 and a scale of 0.0001...

There should be no "test" on the width, there should be a clamp on the smallest stroke value to be a pixel, and that clamp would be computing the width perpendicular to the shape at its location.

Look at the original Circle SDF at the work done with dFdxy and dot products to make sure we did not have a width value that went less than 1 pixel.

I actually did check the behavior of 0.0 vs small values (less than a device pixel). From my tests, it did seem like 0.0 was special cased to render at 1 device pixel, and would render thicker than non-zero stroke width values that are less than 1 device pixel.

Flutter web example showing this: https://dartpad.dev/?id=c4c1fe8149859f9c743317820a25a3e6

I now realize why my tests showed this. I was testing with a Skia backend, not Impeller (I was testing with Mac app, not Flutter web like the link above. But both of those use Skia). I do see that Impeller does force a minimum stroke width of a device pixel, and running the linked app above with impeller enabled does show this minimum stroke width. This is different from Skia's behavior, but I think it makes sense. I'll update my PR to enforce a minimum value.

@b-luk

b-luk commented Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

Also, does this implementation work if the scale is 0.000001? The work done elsewhere will work regardless of scale.

Neither the padding nor the minimum stroke should be computed globally for the operation. What if there is a 10x scale in one direction and a 1x scale in the other? You can't pick a single width or aa pixel size that won't be stretched too much in one direction or squeezed too much in the other. This must be computed in the SDF calculations perpendicular to the outline.

I think it properly supports all of this. This PR modifies the padding to treat the X and Y components separately. So if there's a 10x scale in one direction and a 1x scale in the other, the padding should be properly handled in both axes.

And the SDF calculation uses fwidth(base_sdf) to size the hairline width to be 1 device pixel perpendicular to the outline.

@flar

flar commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Ugh, I missed the X/Y scale adjustments. But I'm not sure they are the right equation. We need "width across the outline" and so I'd need to think more about the math to consider if those are the right values.

WRT fwidth in the shader code - that equation is not recommended for our purposes because it computes a "taxicab geometry" dimension by adding the absolute values of the dimensions rather than rotating them to the correct orientation and taking the length of that vector. The older Circle SDF shader used fwidth and we got a bug report on it because the 45 degree parts of circles were too thick (see #182708 and the fix https://github.com/flutter/flutter/pull/183184/changes#diff-3a0c3ce6c5c0303a024bbef598aab0863c70a1072c7ae6243e6be63f77d9d4d3).

I like that Skia does try to represent "less than a pixel width" using ?? alpha ?? reducing coverage ?? I'm not sure. I think we also have parts of Impeller that adjust the alpha of the color when objects get small, so I'm not sure we are 100% consistent.

If apps have depended on Skia for a long time then they may be used to its behavior, so is the Impeller enforcement of "always at least a pixel" really correct? We also have a year or two of app developers getting used to the Impeller rules, so perhaps we should keep going with this philosophy. Also, we've been dealing with MSAA in the past so enforcing a minimum width is almost needed to keep the width from disappearing entirely if we miss all of the sub-samples in a pixel - it's the only tool we have to bring to bear. But with SDF we have a choice to compute smaller coverages instead.

I need to review more of the math here. I think we are better computing this stuff in the shader. If the code here is already doing fwidth then it is already paying the cost of dFdx/y and those directional functions plus a dot product can compute the values anyway, so the CPU adjustments are sort of unnecessary.

@flar

flar commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

The SDF shaders have 2 issues where we need to adjust for (relative) pixel dimensions.

  • We don't want strokes to disappear so we need to make sure we aren't computing the outer-inner difference on pixels that are too close together. What coordinate space is best for making sure this doesn't happen? Doing an adjustment on the device results or the source geometry?
  • We want the AA pixel gradient to go from full to zero coverage over the approximate distance of aa_pixels. That computation definitely needs to be based on the size of a pixel, but since it is applied to the results of the sdf distance which are computed in local space - dFdx/dFdy can help us in that transformation.

One approach is that we could just have the SDF computations compute the distance to the outline. The differential of that value vec2(dFdx(sdf_value), dFdy(sdf_value)) will point in the direction of maximum change in the gradient (is that true?) and so gives us the direction across (perpendicular to?) the outline. Normalizing that vector helps us in 2 ways, it helps us figure out the pixel size, and provides a minimum stroke width.

We can fold those 2 cases together by:

  • for filled primitives, just use the sdf_result.
  • for stroke primitives, use the computed pixel size as a minum and compute abs(sdf_result) - half-stroke-width and then we can just proceed as if we are filled.
  • for both primitives we then do a smoothstep on the resulting value from -/+half_aa_pixel_size

That technique doesn't work for primitive that have a join consideration, currently only rects, but it should work for other primitives. For round joins, the above results might work, but for miter and bevel we need different geometries and the regular outer - inner calculation and making sure it honors "minimum stroke width".

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 11, 2026
@b-luk b-luk added the CICD Run CI/CD label Apr 11, 2026
@b-luk

b-luk commented Apr 13, 2026

Copy link
Copy Markdown
Contributor Author

Those are good suggestions. I implemented them and I think the results are improved.

I think the suggestion to change our pixel size measurement to use euclidean distance (length(vec2(dFdx(sdf_value), dFdy(sdf_value)))) rather than taxicab distance(fwidth(sdf_value)) is correct. However, it produces a ton of golden changes that are not directly related to this hairline stroke PR.

I want to decouple the pixel size change from this hairline stroke change, so I'm going to make a separate PR for the pixel size change. I'm going to try to get that PR submitted first, then I'll update this PR once the other one is merged. This PR on hold for now.

@b-luk b-luk marked this pull request as draft April 13, 2026 18:33
@flutter-dashboard

Copy link
Copy Markdown

This pull request has been changed to a draft. The currently pending flutter-gold status will not be able to resolve until a new commit is pushed or the change is marked ready for review again.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 20, 2026

@walley892 walley892 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 23, 2026
@b-luk b-luk added CICD Run CI/CD autosubmit Merge PR when tree becomes green via auto submit App labels Apr 23, 2026
@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Apr 23, 2026
@auto-submit

auto-submit Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/184895, because - The status or check suite Linux linux_fuchsia has failed. Please fix the issues identified (or deflake) before re-applying this label.

@b-luk b-luk added autosubmit Merge PR when tree becomes green via auto submit App CICD Run CI/CD and removed CICD Run CI/CD labels Apr 23, 2026
@fluttergithubbot

Copy link
Copy Markdown
Contributor

An existing Git SHA, 5b964e092b6bc76b9595e8c17b353a21182d75f3, was detected, and no actions were taken.

To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with --force) that already was pushed before, push a blank commit (git commit --allow-empty -m "Trigger Build") or rebase to continue.

@b-luk b-luk enabled auto-merge April 23, 2026 21:54
@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 23, 2026
@b-luk b-luk disabled auto-merge April 23, 2026 21:58
@b-luk b-luk added the CICD Run CI/CD label Apr 23, 2026
@github-actions github-actions Bot removed the CICD Run CI/CD label Apr 23, 2026
@b-luk b-luk added the CICD Run CI/CD label Apr 23, 2026
@auto-submit auto-submit Bot added this pull request to the merge queue Apr 24, 2026
Merged via the queue into flutter:master with commit 5bf85a6 Apr 24, 2026
195 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Apr 24, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Apr 24, 2026
Roll Flutter from 5e4f16931847 to aeb96234de86 (42 revisions)

flutter/flutter@5e4f169...aeb9623

2026-04-24 robert.ancell@canonical.com Fix leak on error case (flutter/flutter#185516)
2026-04-24 engine-flutter-autoroll@skia.org Roll Skia from 04c6c369cfd0 to 1c9b0b9141e9 (2 revisions) (flutter/flutter#185529)
2026-04-24 engine-flutter-autoroll@skia.org Roll Dart SDK from f386b11262f6 to c26627715892 (1 revision) (flutter/flutter#185526)
2026-04-24 engine-flutter-autoroll@skia.org Roll Skia from 290a056fcd0e to 04c6c369cfd0 (2 revisions) (flutter/flutter#185525)
2026-04-24 chris@bracken.jp [ios] Extract SplashScreenManager from FlutterViewController (flutter/flutter#185405)
2026-04-24 engine-flutter-autoroll@skia.org Roll Fuchsia Linux SDK from j3UCWZhWx7zSl9Asy... to 9fPnyEo9PaNdXtasl... (flutter/flutter#185523)
2026-04-24 engine-flutter-autoroll@skia.org Roll Skia from 4c8bedd3c932 to 290a056fcd0e (1 revision) (flutter/flutter#185518)
2026-04-24 engine-flutter-autoroll@skia.org Roll Dart SDK from 70665fc3fd2e to f386b11262f6 (2 revisions) (flutter/flutter#185512)
2026-04-24 97480502+b-luk@users.noreply.github.com Handle hairline strokes in UberSDF (flutter/flutter#184895)
2026-04-24 engine-flutter-autoroll@skia.org Roll Skia from ea20c73ac72c to 4c8bedd3c932 (3 revisions) (flutter/flutter#185509)
2026-04-24 58529443+srujzs@users.noreply.github.com Use relative path for reloadedSourcesUri and reloaded modules (flutter/flutter#184598)
2026-04-24 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Run all flutter/flutter macOS tests using Xcode 26 and iOS 26 simulator (#185431)" (flutter/flutter#185513)
2026-04-24 engine-flutter-autoroll@skia.org Roll Skia from e8d00d634c22 to ea20c73ac72c (8 revisions) (flutter/flutter#185500)
2026-04-23 okorohelijah@google.com Run all flutter/flutter macOS tests using Xcode 26 and iOS 26 simulator (flutter/flutter#185431)
2026-04-23 rmolivares@renzo-olivares.dev update team-text-input pr triage link to filter out "waiting for response" label (flutter/flutter#185499)
2026-04-23 jason-simmons@users.noreply.github.com Check for overflow when computing the pixel buffer size for an animated PNG frame (flutter/flutter#185442)
2026-04-23 reinar@crypt.ws Impeller: Recreate Vulkan transients on surface size change (flutter/flutter#185122)
2026-04-23 matt.kosarek@canonical.com Updating the windowing API for sized to content regular and dialog windows + removing the decorated flag (flutter/flutter#184977)
2026-04-23 engine-flutter-autoroll@skia.org Roll Dart SDK from 634991935e9a to 70665fc3fd2e (2 revisions) (flutter/flutter#185488)
2026-04-23 louisehsu@google.com Updating ios triage link (flutter/flutter#185437)
2026-04-23 34871572+gmackall@users.noreply.github.com Revert "Preprovision Android NDK for flavored builds and reuse matchi… (flutter/flutter#185439)
2026-04-23 1961493+harryterkelsen@users.noreply.github.com fix(web): Fix LateInitializationError in CkSurface and SkwasmSurface (flutter/flutter#185116)
2026-04-23 1961493+harryterkelsen@users.noreply.github.com [web] Implement stepped image downscaling for CanvasKit and Skwasm (flutter/flutter#184741)
2026-04-23 30870216+gaaclarke@users.noreply.github.com Made wide_gamut_macos only run on arm64 machines (flutter/flutter#185486)
2026-04-23 chris@bracken.jp [ios] Update documentation for FlutterAppDelegate.pluginRegistrant (flutter/flutter#185201)
2026-04-23 engine-flutter-autoroll@skia.org Roll Dart SDK from bdf48933f3cf to 634991935e9a (1 revision) (flutter/flutter#185462)
2026-04-23 engine-flutter-autoroll@skia.org Roll Skia from 5fe6162546b1 to e8d00d634c22 (3 revisions) (flutter/flutter#185459)
2026-04-23 engine-flutter-autoroll@skia.org Roll Skia from 0049c5d91b08 to 5fe6162546b1 (1 revision) (flutter/flutter#185455)
2026-04-23 engine-flutter-autoroll@skia.org Roll Dart SDK from 9648f446f131 to bdf48933f3cf (19 revisions) (flutter/flutter#185451)
2026-04-23 engine-flutter-autoroll@skia.org Roll Skia from 11640d1cbc5c to 0049c5d91b08 (11 revisions) (flutter/flutter#185453)
2026-04-23 ishaquehassan@gmail.com Add disposal guidance to CurvedAnimation and CurveTween docs (flutter/flutter#184569)
2026-04-23 ishaquehassan@gmail.com Add `clipBehavior` parameter to `AnimatedCrossFade` (flutter/flutter#184545)
2026-04-23 rmacnak@google.com [fuchsia] Ask for only VMEX, not ambient-replace-as-executable. (flutter/flutter#185099)
2026-04-23 47866232+chunhtai@users.noreply.github.com Unify SemanticUpdateBuilder API for web and non-web (flutter/flutter#185433)
2026-04-22 68919043+Istiak-Ahmed78@users.noreply.github.com Fix ImageInfo.isCloneOf tests by moving async work to setUp (flutter/flutter#185254)
2026-04-22 engine-flutter-autoroll@skia.org Roll Fuchsia Linux SDK from UdpQnaP5eSaDZd3-i... to j3UCWZhWx7zSl9Asy... (flutter/flutter#185438)
2026-04-22 30870216+gaaclarke@users.noreply.github.com Adds script to run malioc locally (flutter/flutter#185371)
2026-04-22 47866232+chunhtai@users.noreply.github.com Add await mechanism to setClipboard in android_semantics_integration test (flutter/flutter#185428)
2026-04-22 43054281+camsim99@users.noreply.github.com Add ability to pass flags to `et run` (flutter/flutter#185109)
2026-04-22 47866232+chunhtai@users.noreply.github.com Add more guidelines for code review bot (flutter/flutter#185367)
2026-04-22 danny@tuppeny.com Roll pub packages (flutter/flutter#185274)
2026-04-22 saurabhmirajkar000@gmail.com Fix incorrect scale parameter reference in Image constructor docs (flutter/flutter#185403)

If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
https://autoroll.skia.org/r/flutter-packages
...
@b-luk b-luk deleted the hairline branch April 24, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD e: impeller Impeller rendering backend issues and features requests engine flutter/engine related. See also e: labels. will affect goldens Changes to golden files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve performance of stroked SDFs

5 participants