Skip to content

[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

Closed

Description

@PlugFox

Summary

Paint.isAntiAlias = false is lost when no earlier draw in the same Picture set anti-aliasing to true. DisplayListBuilder starts from DlPaint, whose anti-alias default is false, and records SetAntiAlias only when the value changes. Impeller's dispatcher starts every display list, including nested ones, from impeller::Paint(), whose anti_alias default is true. So when the first anti-alias-relevant draw of a picture asks for false, nothing is recorded, and Impeller draws it with anti-aliasing.

Since #186020 (fixing #185834), isAntiAlias = false is the way to keep a draw off the UberSDF path. For a paint with a color source that path is the three-pass fallback described in #192994. Because of this mismatch the opt-out does not work in the common case: one shader rect in its own CustomPaint, or the first draw after drawPicture.

On an M3 Max, one shader rect costs about 125 µs with the flag lost and 7.5 µs with it honoured. The two paths also produce different pixels.

Steps to reproduce

The only difference between the two isAntiAlias = false arms is a draw that renders nothing, recorded first: a 1×1 rect with BlendMode.dst and the default (true) anti-alias. It makes the builder's state true, so the following false is recorded. An empty rect cannot be used for this, because Canvas.drawRect drops an empty fill in Dart before it reaches the engine (painting.dart:7937).

shaders/coord.frag:

#include <flutter/runtime_effect.glsl>

precision highp float;

out vec4 fragColor;

// Encodes the fragment's own position, so every pixel's expected value is known.
void main() {
  vec2 p = FlutterFragCoord().xy;
  fragColor = vec4(fract(p.x / 8.0), fract(p.y / 8.0), 0.5, 1.0);
}
lib/main.dart
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/widgets.dart';

const int kW = 64, kH = 32;
late ui.FragmentProgram program;
Rect rect = Rect.zero;

/// One shader rect. With [prime], a draw that renders nothing (non-empty rect,
/// BlendMode.dst, default anti-alias) is recorded first.
ui.Picture record({required bool antiAlias, bool prime = false, int count = 1}) {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder);
  if (prime) {
    canvas.drawRect(const Rect.fromLTWH(0, 0, 1, 1), Paint()..blendMode = BlendMode.dst);
  }
  for (var i = 0; i < count; i++) {
    canvas.drawRect(
      rect,
      Paint()
        ..shader = program.fragmentShader()
        ..isAntiAlias = antiAlias,
    );
  }
  return recorder.endRecording();
}

Future<Uint8List> render(ui.Picture picture) async {
  final ui.Image image = await picture.toImage(kW, kH);
  final ByteData data = (await image.toByteData())!;
  image.dispose();
  return data.buffer.asUint8List();
}

/// The shader's value at a pixel centre.
int expected(double coord) => (((coord / 8.0) % 1.0) * 255).round();

/// Worst error against [expected] over pixels at least one pixel inside [rect].
int interiorError(Uint8List px) {
  int wrapped(int d) => d < 128 ? d : 256 - d;
  var worst = 0;
  for (var y = (rect.top + 1).ceil(); y < (rect.bottom - 1).floor(); y++) {
    for (var x = (rect.left + 1).ceil(); x < (rect.right - 1).floor(); x++) {
      final i = (y * kW + x) * 4;
      final int e = [
        wrapped((px[i] - expected(x + 0.5)).abs()),
        wrapped((px[i + 1] - expected(y + 0.5)).abs()),
      ].reduce((a, b) => a > b ? a : b);
      if (e > worst) worst = e;
    }
  }
  return worst;
}

int differing(Uint8List a, Uint8List b) {
  var n = 0;
  for (var i = 0; i < a.length; i += 4) {
    if (a[i] != b[i] || a[i + 1] != b[i + 1] || a[i + 2] != b[i + 2] || a[i + 3] != b[i + 3]) {
      n++;
    }
  }
  return n;
}

Future<double> microsPerDraw({required bool antiAlias, bool prime = false}) async {
  const int count = 64;
  final times = <int>[];
  for (var r = 0; r < 25; r++) {
    final ui.Picture p = record(antiAlias: antiAlias, prime: prime, count: count);
    final sw = Stopwatch()..start();
    final ui.Image image = await p.toImage(kW, kH);
    await image.toByteData();
    sw.stop();
    image.dispose();
    p.dispose();
    times.add(sw.elapsedMicroseconds);
  }
  times.sort();
  return times[times.length ~/ 2] / count;
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  program = await ui.FragmentProgram.fromAsset('shaders/coord.frag');
  for (final Rect r in const <Rect>[
    Rect.fromLTWH(8, 8, 40, 16),
    Rect.fromLTWH(8.37, 8.61, 40, 16),
  ]) {
    rect = r;
    final on = await render(record(antiAlias: true));
    final off = await render(record(antiAlias: false));
    final offPrimed = await render(record(antiAlias: false, prime: true));
    final onPrimed = await render(record(antiAlias: true, prime: true));
    print('rect origin (${r.left}, ${r.top}), 40x16');
    print('  interior error vs expected: aa=true ${interiorError(on)}, '
        'aa=false ${interiorError(off)}, aa=false primed ${interiorError(offPrimed)}');
    print('  pixels differing: aa=false vs aa=false primed ${differing(off, offPrimed)}, '
        'aa=false vs aa=true ${differing(off, on)}, '
        'control aa=true vs aa=true primed ${differing(on, onPrimed)}');
  }
  rect = const Rect.fromLTWH(8, 8, 40, 16);
  print('us per draw (64 rects in one picture): '
      'aa=true ${(await microsPerDraw(antiAlias: true)).toStringAsFixed(1)}, '
      'aa=false ${(await microsPerDraw(antiAlias: false)).toStringAsFixed(1)}, '
      'aa=false primed ${(await microsPerDraw(antiAlias: false, prime: true)).toStringAsFixed(1)}');
  exit(0);
}
flutter build macos --release
build/macos/Build/Products/Release/<app>.app/Contents/MacOS/<app>

Expected results

aa=false and aa=false primed are the same picture, apart from a draw that renders nothing, so they should produce the same pixels at the same cost.

Actual results

macOS, Impeller MetalSDF, master 4759fcb190:

rect origin (8.0, 8.0), 40x16
  interior error vs expected: aa=true 8, aa=false 8, aa=false primed 0
  pixels differing: aa=false vs aa=false primed 326, aa=false vs aa=true 0, control aa=true vs aa=true primed 0
rect origin (8.37, 8.61), 40x16
  interior error vs expected: aa=true 16, aa=false 16, aa=false primed 4
  pixels differing: aa=false vs aa=false primed 696, aa=false vs aa=true 0, control aa=true vs aa=true primed 0
us per draw (64 rects in one picture): aa=true 127.2, aa=false 125.7, aa=false primed 7.5

The unprimed aa=false arm is pixel-identical to aa=true: it went through the SDF path. The primer changes nothing when the flag is true (control row). All 64 rects in the timing picture are affected, not only the first one. The builder already holds false, so none of them records the attribute.

The same code on Android (Impeller Vulkan, SM-S908B), which has no SDF path, gives 0 in every row.

Stable 3.47.1 gives the same numbers on the same machine. In an application, an M3 Max Metal System Trace of twelve shader rects per frame shows 12 Pipeline Blend Filter passes and 1.25–1.34 ms of GPU per frame with the flag lost, against no blend passes and 0.52–0.53 ms with it primed.

The pixel difference between the two paths is reported separately in #193389.

Where it comes from

  • DisplayListBuilder::setAntiAlias records only on a change from current_ (dl_builder.h:277), and current_ starts as DlPaint(), whose is_anti_alias_ is false (dl_paint.h:227).
  • impeller::Paint::anti_alias defaults to true (paint.h:88). The dispatcher's paint_ is default-constructed (dl_dispatcher.h:297) and reset to Paint() for every nested display list (dl_dispatcher.cc:801, and Paint{} at :1200).
  • Canvas::IsCompatibleWithSDFRendering returns false only for !paint.anti_alias (canvas.cc:2776).

This true default is the only field where the two defaults disagree. Color, blend mode, style, stroke width, cap, join, miter and invert all match DlPaint.

Workaround

Record a non-empty draw that renders nothing, with anti-aliasing on, before the first isAntiAlias = false draw in each picture, as the prime arm does. It has to be non-empty because of the Dart-side check above.

Possible fix

Start the dispatcher from the same state the builder assumes. For example, reset paint_ to a Paint with anti_alias = false where it is constructed and at both resets above. Changing the impeller::Paint default instead would also move draws that Canvas users inside the engine make without a display list, so the dispatcher looks like the narrower place.

A regression test could dispatch a display list whose first draw is an aliased rect and check the anti_alias the canvas received.

Activity

  1. saurabh-mirajkar commented on Sep 28, 2026

    @saurabh-mirajkar
    Contributor

    @PlugFox Thanks for the detailed report. I was able to reproduce this locally on both Flutter and current master on macOS 27.0. Routing this for further investigation

    stable
    Flutter 3.47.5 • channel stable • https://github.com/flutter/flutter.git
    Framework • revision 6a19cca564 (10 days ago) • 2026-09-17 14:13:22 -0400
    Engine • hash ab598368592da0064197e2bc15c7f5b0a2c6bb1f (revision af7e796e16) (11 days ago) • 2026-09-16 18:35:09.000Z
    Tools • Dart 3.13.4 • DevTools 2.60.0
    master
    Flutter 3.49.0-1.0.pre-75 • channel master • https://github.com/saurabh-mirajkar/flutter.git
    Framework • revision a2810c0ff0 (6 days ago) • 2026-09-22 05:27:33 +0000
    Engine • hash bc5f3fc53394bd9cba9111fd681d88f128f396f8 (revision a2810c0ff0) (6 days ago) • 2026-09-22 05:27:33.000Z
    Tools • Dart 3.14.0 (build 3.14.0-252.0.dev) • DevTools 2.61.0-dev.0
  2. added
    engineflutter/engine related. See also e: labels.
    platform-macosBuilding on or for macOS specifically
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Sep 28, 2026
  3. added
    P1High-priority issues at the top of the work list
    on Sep 28, 2026
  4. UlisesVoxer commented on Oct 3, 2026

    @UlisesVoxer

    This also reproduces on Windows, where Impeller runs on OpenGL ES through ANGLE with the SDF path (Using the Impeller rendering backend (OpenGLESSDF)).

    A common way to hit it there: a CustomPaint under a RepaintBoundary. The boundary's picture starts fresh, so if its first draws use isAntiAlias = false, none of them records the attribute and every one of them goes through the SDF path. The same painter without the boundary keeps the opt-out, because an earlier draw in the parent's picture (here, the background of a ColoredBox) already set anti-aliasing to true.

    Minimal repro: 60 rects filled with a linear gradient, repainted every frame. Raster time per frame from FrameTiming, median and p90 over 4 s for each case.

    lib/main.dart
    import 'dart:io';
    import 'dart:ui' as ui;
    
    import 'package:flutter/scheduler.dart';
    import 'package:flutter/widgets.dart';
    
    /// 60 rects filled with a linear gradient, repainted every frame.
    class Rects extends CustomPainter {
      Rects({required this.antiAlias, required this.prime, super.repaint});
    
      final bool antiAlias;
    
      /// Records first a draw that renders nothing (non-empty rect,
      /// BlendMode.dst, default anti-alias), so the following
      /// `isAntiAlias = false` is recorded as a change.
      final bool prime;
    
      @override
      void paint(Canvas canvas, Size size) {
        if (prime) {
          canvas.drawRect(
            const Rect.fromLTWH(0, 0, 1, 1),
            Paint()..blendMode = BlendMode.dst,
          );
        }
        const columns = 10;
        final w = size.width / columns;
        final h = size.height / 6;
        for (var i = 0; i < 60; i++) {
          final rect = Rect.fromLTWH(
            (i % columns) * w + 2,
            (i ~/ columns) * h + 2,
            w - 4,
            h - 4,
          );
          canvas.drawRect(
            rect,
            Paint()
              ..shader = ui.Gradient.linear(rect.topLeft, rect.bottomLeft, const [
                Color(0xFF202020),
                Color(0xFFE0E0E0),
              ])
              ..isAntiAlias = antiAlias,
          );
        }
      }
    
      @override
      bool shouldRepaint(Rects oldDelegate) => false;
    }
    
    typedef Arm = ({String name, bool antiAlias, bool boundary, bool prime});
    
    const List<Arm> arms = [
      (name: 'aa=true', antiAlias: true, boundary: false, prime: false),
      (name: 'aa=false', antiAlias: false, boundary: false, prime: false),
      (
        name: 'aa=false in RepaintBoundary',
        antiAlias: false,
        boundary: true,
        prime: false,
      ),
      (
        name: 'aa=false in RepaintBoundary, primed',
        antiAlias: false,
        boundary: true,
        prime: true,
      ),
    ];
    
    class Bench extends StatefulWidget {
      const Bench({super.key});
    
      @override
      State<Bench> createState() => _BenchState();
    }
    
    class _BenchState extends State<Bench> with SingleTickerProviderStateMixin {
      late final AnimationController _repaint = AnimationController(
        vsync: this,
        duration: const Duration(seconds: 1),
      )..repeat();
      final List<int> _raster = [];
      var _arm = 0;
      var _recording = false;
    
      @override
      void initState() {
        super.initState();
        SchedulerBinding.instance.addTimingsCallback(_onTimings);
        _run();
      }
    
      void _onTimings(List<ui.FrameTiming> timings) {
        if (!_recording) return;
        _raster.addAll([
          for (final timing in timings) timing.rasterDuration.inMicroseconds,
        ]);
      }
    
      Future<void> _run() async {
        for (var i = 0; i < arms.length; i++) {
          setState(() => _arm = i);
          await Future<void>.delayed(const Duration(seconds: 2));
          _raster.clear();
          _recording = true;
          await Future<void>.delayed(const Duration(seconds: 4));
          _recording = false;
          final sorted = [..._raster]..sort();
          String ms(double quantile) => sorted.isEmpty
              ? '-'
              : (sorted[((sorted.length - 1) * quantile).round()] / 1000)
                    .toStringAsFixed(2);
          // ignore: avoid_print
          print(
            '${arms[i].name}: raster median ${ms(0.5)} ms, '
            'p90 ${ms(0.9)} ms (${sorted.length} frames)',
          );
        }
        exit(0);
      }
    
      @override
      void dispose() {
        SchedulerBinding.instance.removeTimingsCallback(_onTimings);
        _repaint.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final arm = arms[_arm];
        Widget rects = CustomPaint(
          painter: Rects(
            antiAlias: arm.antiAlias,
            prime: arm.prime,
            repaint: _repaint,
          ),
          size: Size.infinite,
        );
        if (arm.boundary) rects = RepaintBoundary(child: rects);
        return ColoredBox(color: const Color(0xFF101010), child: rects);
      }
    }
    
    void main() => runApp(const Bench());
    flutter create --platforms=windows --empty aa_repro
    # replace lib/main.dart with the code above
    flutter run --profile -d windows

    Windows 11 Pro (build 26200), NVIDIA GeForce RTX 4090, stable 3.47.6 (framework 5fc3468, engine b8c8d3d8d5), default window size:

    aa=true: raster median 4.97 ms, p90 5.86 ms (247 frames)
    aa=false: raster median 0.60 ms, p90 0.81 ms (246 frames)
    aa=false in RepaintBoundary: raster median 5.28 ms, p90 6.53 ms (246 frames)
    aa=false in RepaintBoundary, primed: raster median 0.61 ms, p90 0.76 ms (243 frames)
    

    The unprimed aa=false inside the boundary costs the same as aa=true: the flag is lost for all 60 draws, not only the first. With the priming draw from the workaround above, it costs the same as aa=false outside the boundary.

    An earlier timeline capture of an equivalent 60-rect scene on this backend showed 181 render passes per frame with the flag lost (1 + 3 per rect, the three-pass fallback from #192994) and 1 with it honoured.

  5. flar commented on Oct 6, 2026

    @flar
    Contributor

    We'd have to watch out for any native unit tests that expect the default AA to be false.

    Setting it to true would eliminate the need to set it to true on most every DL created from Dart as that is their default and so gets synched into every DL they create (unless they override their flag value which is rare). But, any unit test that creates a DlPaint might be expecting it to default to false so we need to check them and update.

    The most targeted way to fix this would be to manually override the default setting just for DLs created from the ui.PictureRecorder wrapper. Then unit tests wouldn't need to adapt.

  6. github-actions commented on Oct 27, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  7. locked as resolved and limited conversation to collaborators on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work lista: desktopRunning on desktopc: renderingUI glitches reported at the engine/skia or impeller rendering levele: 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