Skip to content

[Impeller][Windows] drawRect with a FragmentShader takes 3 render passes and 2 MSAA resolves: 30 ms against 2.7 ms on Skia #192994

Open

Description

@krll-kov

Summary

On Windows, Impeller runs the UberSDF path by default. Canvas::DrawRect sends a rectangle to UberSDF, and UberSDF evaluates only two color sources directly: linear and radial gradients. Every other color source falls back to a three pass sequence: a white SDF coverage mask into an offscreen, the color source into a second offscreen, a SrcIn blend of the two into a third target, and a copy of the result into the main pass.

For N such rectangles this is 3N + 1 render passes, 5N draw commands and 2N MSAA resolves.

Measured on the example below, 140 rectangles covering a 2560x1440 window: raster time is 30.0 ms per frame at 33 FPS, against 2.7 ms at 120 FPS with Skia in the same app on the same machine. A build that keeps runtime effects off the SDF path draws the same scene in 2.5 ms at 120 FPS. The cost scales with the number of rectangles, not with the shaded area.

The same fallback also darkens shared edges that land on a fractional device row, and it drops the last device row at the bottom of the window. Skia leaves a milder seam of its own on those boundaries, so this part is a comparison rather than a clean defect, and it is reported at the end because the single pass builds remove it along with the 27 ms.

Flutter already fixed exactly this pattern for gradients in #192124, which replaced the same multi pass fallback with direct evaluation in UberSDF and took linear gradient raster p50 from 19.094 ms to 5.665 ms. Runtime effects, image shaders, conical and sweep gradients were left on the fallback.

This is in the released 3.47 series, not only on master.

Environment

Item Value
Flutter master e619960004d7c07e9d899ce7f368bd100f965f11
Flutter stable 3.47.4
Build Windows x64 release AOT
Renderer Impeller, OpenGL ES 3.0 through ANGLE on D3D11, OpenGLESSDF backend
Machine Windows 11 2560x1440 at 200% scaling, 120 Hz
Comparison The same app in release mode with Skia

Every number below compares variants measured on the same machine in the same session.

Minimal reproduction

pubspec.yaml:

flutter:
  uses-material-design: true
  shaders:
    - shaders/effect.frag

shaders/effect.frag:

#include <flutter/runtime_effect.glsl>

#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif

out vec4 fragColor;

uniform vec2 uSize;
uniform float uTime;

void main() {
  vec2 p = FlutterFragCoord().xy / max(uSize, vec2(1.0));
  float signal = sin((p.x * 18.0 + p.y * 0.65 + uTime * 0.35) * 6.2831853);
  fragColor = vec4(vec3(0.2, 0.55, 0.95) * (0.72 + 0.28 * signal), 1.0);
}

lib/main.dart:

import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final ui.FragmentProgram program =
      await ui.FragmentProgram.fromAsset('shaders/effect.frag');
  _printFrameTimes();
  runApp(App(program));
}

void _printFrameTimes() {
  final List<int> raster = <int>[];
  final Stopwatch watch = Stopwatch()..start();
  SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
    raster.addAll(
      timings.map((FrameTiming t) => t.rasterDuration.inMicroseconds),
    );
    if (watch.elapsedMilliseconds < 1000 || raster.isEmpty) {
      return;
    }
    raster.sort();
    final double median = raster[raster.length ~/ 2] / 1000;
    debugPrint('frames=${raster.length} '
        'fps=${(raster.length * 1000 / watch.elapsedMilliseconds).toStringAsFixed(1)} '
        'raster_median_ms=${median.toStringAsFixed(3)}');
    raster.clear();
    watch.reset();
  });
}

class App extends StatefulWidget {
  const App(this.program, {super.key});

  final ui.FragmentProgram program;

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> with SingleTickerProviderStateMixin {
  late final AnimationController animation = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 8),
  )..repeat();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ColoredBox(
        color: const Color(0xff07111f),
        child: CustomPaint(
          painter: EffectPainter(widget.program.fragmentShader(), animation),
          child: const SizedBox.expand(),
        ),
      ),
    );
  }
}

class EffectPainter extends CustomPainter {
  EffectPainter(this.shader, this.animation) : super(repaint: animation);

  final ui.FragmentShader shader;
  final Animation<double> animation;
  final Paint fillPaint = Paint();

  @override
  void paint(Canvas canvas, Size size) {
    const int columns = 14;
    const int rows = 10;
    final double width = size.width / columns;
    final double height = size.height / rows;
    for (int index = 0; index < columns * rows; index++) {
      shader
        ..setFloat(0, width)
        ..setFloat(1, height)
        ..setFloat(2, animation.value * 8 + index * 0.013);
      fillPaint.shader = shader;
      canvas.drawRect(
        Rect.fromLTWH(
          (index % columns) * width,
          (index ~/ columns) * height,
          width,
          height,
        ),
        fillPaint,
      );
    }
  }

  @override
  bool shouldRepaint(EffectPainter oldDelegate) => false;
}

Run it maximized:

flutter build windows --release
build\windows\x64\runner\Release\<app>.exe

Compare against Skia by building the same app with Impeller disabled.

The numbers below come from this code with two changes for unattended runs: the print above is replaced by a line with frames, FPS, mean raster and p90 raster once a second, and the grid size comes from two environment variables so one build can measure several rectangle counts. A third variable stops the animation at a fixed phase, which is what makes two engines produce comparable screenshots.

Expected results

A drawRect whose paint carries a FragmentShader should cost about one render pass and one draw command, as it did before Windows moved to the UberSDF path and as it still does when the same rectangle is filled with a solid color. Frame time should follow the shaded area, not the number of rectangles.

Actual results

Each such rectangle costs three extra render passes, four extra draw commands and two MSAA resolves. 140 of them take 30.0 ms of raster time at 33 FPS, against 2.7 ms at 120 FPS with Skia and 2.5 ms at 120 FPS with a single pass Impeller build.

Measured results

Every row is the median of per second samples, first four seconds of each run dropped, medians taken per run and then across runs. Impeller and Skia runs were interleaved.

The window content is 2534x1369 device pixels and the grid always covers all of it, so every row below shades the same pixels with the same shader and only the number of drawRect calls changes. 5 runs per cell.

Rectangles Grid One rectangle
7 7x1 362x1369
35 7x5 362x274
70 14x5 181x274
140 14x10 181x137

uSize is the size of the rectangle, so the fragment program runs the same instructions per pixel in every row, only the period of the sine changes. The total area of the intermediate snapshots does not grow either: it is always about two windows.

Rectangles Impeller FPS Impeller raster Impeller p90 Skia FPS Skia raster Skia p90
7 120.1 2.487 ms 3.275 ms 120.1 1.179 ms 1.953 ms
35 112.2 8.883 ms 9.448 ms 120.2 1.695 ms 2.364 ms
70 64.0 15.621 ms 16.329 ms 120.2 2.010 ms 2.253 ms
140 33.3 30.003 ms 32.306 ms 120.2 2.695 ms 2.979 ms

What the engine does per rectangle

Engine labels for one runtime effect rectangle:

  1. PipelineBlend(Dst): one UberSdf Pipeline V#1 command, one MSAA resolve
  2. PipelineBlend(Src): one Runtime Stage command, one MSAA resolve
  3. Pipeline Blend Filter: two TextureFill commands into a target without MSAA
  4. EntityPass Render Pass: one TextureFill command per logical draw

Instrumented release build, 140 rectangles, steady frames:

Counter Value
Render passes per frame 421
Draw commands and GL draw calls per frame 700
MSAA resolves per frame 280

Medians in microseconds per frame from the same build, nested rows are not additive:

Stage Time
Rasterizer::DrawToSurfaceUnsafe 31,048
Display list rasterization 39
Submit 30,984
ReactorGLES::React 28,209
421 render passes 27,870
280 MSAA resolves 15,066
glBlitFramebuffer inside the resolves 14,654
Command loops 11,526
700 GL draw calls 8,471
Clears 811
All uniform binding, built in and runtime 1,386

The resolves are half the frame and the command loops of the extra passes are most of the rest. Uniform binding is 4% of it: I also packed the user uniforms of a six shader production workload, which cut active glUniform* calls per frame from 859 to 580, and raster time moved by 1.16%. This is not a uniform binding problem.

Where it comes from

Canvas::DrawRect sends every antialiased rectangle to UberSDF (canvas.cc:1071), and Canvas::AddRenderSDFEntityToCurrentPass decides how the color source is evaluated (canvas.cc:2300-2335):

  if (!paint.color_source || params.gradient.has_value()) {
    // No color source (solid paint color), or a supported gradient color
    // source.
    ...
  } else {
    // Color source not directly supported by UberSDF (e.g. image, runtime
    // effect, or an unsupported gradient type). Render a solid white mask with
    // UberSDF and blend with ColorSourceContents.
    params.color = Color::White();
    ...
    std::shared_ptr<Contents> final_contents = ColorFilterContents::MakeBlend(
        BlendMode::kSrcIn, {FilterInput::Make(std::move(uber_sdf_contents)),
                            FilterInput::Make(color_source_contents)});

CreateUberSDFGradientParameters returns parameters only for linear and radial gradients, and only when the gradient matrix is affine, with the extra condition of uniform scale for radial gradients. Everything else reaches the else branch.

Scope

Color sources on the fallback: runtime effects, ImageShader, conical gradients, sweep gradients, and linear or radial gradients with a non affine matrix or non uniform scale.

Draw calls that go through UberSDF and therefore through the same fallback: DrawLine, DrawRect, DrawOval, DrawRoundRect, DrawRoundSuperellipse, DrawCircle.

A second app draws the same 140 rectangles of 181x137 and only changes the color source. Impeller on master, 3 runs each, medians:

Color source FPS raster p90 Path
solid color 120.2 2.660 ms 2.935 ms direct
radial gradient 48.0 20.853 ms 45.099 ms direct
linear gradient 47.6 21.008 ms 45.099 ms direct
runtime effect 32.8 30.419 ms 31.513 ms fallback
sweep gradient 31.1 32.134 ms 33.442 ms fallback
image shader 26.8 37.396 ms 38.856 ms fallback

The three fallback sources cost 30 to 37 ms, and a solid color over the same pixels costs 2.7 ms.

One caveat on the gradient rows: this app allocates a new Gradient per rectangle per frame, so those two numbers include building a gradient color ramp texture 140 times per frame, which is what #192948 and #192962 are about. Their pass count is already one per rectangle after #192124, which is why their p90 spikes come from allocation rather than from resolves.

The second app, one color source per run

Same grid and the same shader asset, BW_SOURCE selects the color source.

import 'dart:io';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

final String source = Platform.environment['BW_SOURCE'] ?? 'shader';

class SourcePainter extends CustomPainter {
  SourcePainter(this.shader, this.texture, this.animation)
      : super(repaint: animation);

  final ui.FragmentShader shader;
  final ui.Image texture;
  final Animation<double> animation;
  final Paint fillPaint = Paint();

  static const List<Color> _colors = <Color>[
    Color(0xff3399ee),
    Color(0xff22ddaa),
    Color(0xffffcc55),
  ];
  static const List<double> _stops = <double>[0, 0.5, 1];

  @override
  void paint(Canvas canvas, Size size) {
    const int columns = 14;
    const int rows = 10;
    final double width = size.width / columns;
    final double height = size.height / rows;
    for (int index = 0; index < columns * rows; index++) {
      final Rect rect = Rect.fromLTWH(
        (index % columns) * width,
        (index ~/ columns) * height,
        width,
        height,
      );
      switch (source) {
        case 'solid':
          fillPaint.shader = null;
          fillPaint.color = _colors[index % _colors.length];
        case 'linear':
          fillPaint.shader = ui.Gradient.linear(
              rect.topLeft, rect.bottomRight, _colors, _stops);
        case 'radial':
          fillPaint.shader = ui.Gradient.radial(
              rect.center, rect.shortestSide / 2, _colors, _stops);
        case 'sweep':
          fillPaint.shader = ui.Gradient.sweep(rect.center, _colors, _stops);
        case 'image':
          fillPaint.shader = ui.ImageShader(texture, TileMode.repeated,
              TileMode.repeated, Matrix4.identity().storage);
        default:
          shader
            ..setFloat(0, width)
            ..setFloat(1, height)
            ..setFloat(2, animation.value * 8 + index * 0.013);
          fillPaint.shader = shader;
      }
      canvas.drawRect(rect, fillPaint);
    }
  }

  @override
  bool shouldRepaint(SourcePainter oldDelegate) => false;
}

The texture for the image shader is a 64x64 picture filled with a linear gradient, created once with PictureRecorder and toImage.

When it started, and who is affected

Change Date Effect
#183864 2026-03-27 UberSDF foundation and the SDF rectangle path
#184090 2026-04-01 Generic multi pass color source blending for SDF shapes
#187877 2026-06-12 Windows switched to OpenGLESSDF by default
#192124 2026-09-03 Linear and radial gradients evaluated directly in UberSDF

#184090 documented the cost when it landed: "This adds gradient rendering to the SDF renders by performing gradients as a multi rendering pass. The shape is rendered and the gradient is rendered then they are combined with a porter duff blend. This is suboptimal to performing the gradients directly in the fragment shader. Since we are working on desktop computers there is not a lot of pressure to implement that directly since it comes at a higher maintenance cost. We can revisit that decision once the ubersdf renderer has all the shapes."

#187877 is an ancestor of the 3.47.0 tag, and Impeller is the default on Windows in that series (enable_impeller = true in flutter_windows_engine.cc), so on Windows this arrived for users with 3.47.

Platform defaults today: Windows pushes --impeller-use-sdfs=true itself, macOS and iOS read an opt in flag, Android and Linux never enable it. So the bug is a Windows default and an opt in elsewhere.

Measured on the same machine with the same Dart code, 3 runs each: stable 3.47.4 renders the 140 rectangle grid at 33.6 FPS and 29.726 ms, master at 33.2 FPS and 30.133 ms. The frozen frames of the two are identical, 0 differing pixels out of 2560x1440, seam included. The release behaves exactly like master.

Side effect of the same path: edges on fractional rows

This part is secondary to the frame time, and it is not a clean Impeller only defect, so here is what the pixels say.

The window content is 2534x1369 device pixels. With 14 columns and 10 rows, 2534 / 14 = 181.0 exactly and 1369 / 10 = 136.9, so vertical edges of neighbouring rectangles land on whole pixels and horizontal edges do not. On the vertical edges all three renderers agree to within one unit per channel. On the horizontal ones they do not.

Seam depth per interior boundary, measured on frozen frames of the same animation phase as the brightness of the shared row against the mean of the rows three pixels above and below, averaged over 2200 columns:

Boundary Impeller master and 3.47.4 Skia Impeller, single pass
y=194 0.9% 4.1% 0.0%
y=331 0.9% 7.3% 0.0%
y=468 11.9% 9.5% 0.0%
y=605 11.9% 10.9% 0.0%
y=742 22.7% 11.3% 0.0%
y=879 0.0% 10.9% 0.0%
y=1016 0.0% 9.5% 0.0%
y=1153 0.0% 7.2% 0.0%
y=1290 0.0% 4.1% 0.0%

Impeller leaves three visible seams and the deepest is 23% below its neighbours, Skia leaves a shallow one on every boundary and never passes 11%, and the single pass Impeller build leaves none. Pixel values on the worst boundary, same column: master (28, 76, 131), Skia (37, 101, 175), single pass (48, 131, 227), with neighbours near (50, 137, 236) and (46, 126, 217) and the background at (7, 17, 31).

The two single pass Impeller builds, the one that keeps runtime effects off UberSDF and the one with UberSDF switched off, are identical to each other, and stable 3.47.4 is identical to master.

One row is unambiguous. At the bottom edge of the window the fallback drops the last device row of the bottom rectangles and shows the background instead:

Build Pixel on the last row
Impeller master and stable 3.47.4 (7, 17, 31), the background
Skia (28, 76, 132)
Impeller, single pass (28, 76, 132)

Hypothesis for both, not yet proven: the mechanism of #192980, where a blend subpass texture is truncated to an integer size and composited back at a fractional origin with nearest sampling. The fallback goes through the same ColorFilterContents::MakeBlend machinery, and the deviation appears only on the axis whose boundary is fractional. Whatever the cause, the single pass builds remove it together with the 27 ms, which is why it is reported here rather than separately.

The attached image is a 4x crop of one boundary, master above and the single pass build below.

What I tried

Six builds, one executable, the dll swapped between runs, 3 rounds interleaved, medians:

Engine 7 rectangles 140 rectangles
master, official artifact 120.1 FPS, 2.387 ms 33.2 FPS, 30.133 ms
master, built locally 120.1 FPS, 2.486 ms 33.1 FPS, 30.240 ms
runtime effects skip UberSDF 120.2 FPS, 1.301 ms 120.1 FPS, 2.505 ms
snapshots without MSAA 120.2 FPS, 1.653 ms 54.2 FPS, 18.444 ms
UberSDF off 118.4 FPS, 1.840 ms 120.1 FPS, 2.885 ms
stable 3.47.4, released engine 120.1 FPS, 2.648 ms 33.6 FPS, 29.726 ms

The official artifact and the local build agree, so the local toolchain is not a factor.

1. Keep runtime effects off the SDF path. The complete diff that was measured:

--- a/engine/src/flutter/impeller/display_list/canvas.cc
+++ b/engine/src/flutter/impeller/display_list/canvas.cc
@@ -1068,7 +1068,10 @@ void Canvas::DrawRect(const Rect& rect, const Paint& paint) {
     }
   }
 
-  if (renderer_.GetContext()->GetFlags().use_sdfs &&
+  if ((!paint.color_source ||
+       paint.color_source->type() !=
+           flutter::DlColorSourceType::kRuntimeEffect) &&
+      renderer_.GetContext()->GetFlags().use_sdfs &&
       IsCompatibleWithSDFRendering(paint, GetCurrentTransform())) {
     Rect effective_rect = rect;
     Color effective_color = paint.color;

It turns 421 passes, 700 commands and 280 resolves into one pass, 140 commands and no resolves, and its output is identical to a build with UberSDF switched off, seam and bottom row included. It is a diagnostic, not a proposed fix: it drops ExpandRectToPixelMinimum, which only exists on the SDF path, so rectangles thinner than one device pixel are no longer expanded, and it replaces analytic SDF coverage with MSAA coverage, which changes edges under rotation and subpixel translation. The goldens it would move include AiksTest.CanRenderClippedRuntimeEffects, AiksTest.CanRenderRuntimeEffectFilter, AiksTest.ClippedBackdropFilterWithShader and AiksTest.RuntimeEffectVectorArray in their MetalSDF and OpenGLESSDF configurations.

2. Drop MSAA from the two intermediate snapshots.

-        BlendMode::kSrcIn, {FilterInput::Make(std::move(uber_sdf_contents)),
-                            FilterInput::Make(color_source_contents)});
+        BlendMode::kSrcIn,
+        {FilterInput::Make(std::move(uber_sdf_contents), false),
+         FilterInput::Make(color_source_contents, false)});

30.240 ms goes to 18.444 ms and the frozen frame is byte identical to master, including the seam. The analytic mask already carries the coverage, so MSAA on these two snapshots buys nothing here. It removes 2N resolves, and it leaves 3N+1 passes and 5N commands, so it cannot reach the single pass result. It is a safe intermediate step, but it needs its own golden run.

3. --impeller-use-sdfs=false does nothing. The Windows embedder checks for that exact string before pushing its own --impeller-use-sdfs=true (flutter_windows_engine.cc:306-311), but the setting is parsed with HasOption (switches.cc:567-568), which only tests whether the flag is present:

  settings.impeller_use_sdfs =
      command_line.HasOption(FlagForSwitch(Switch::kImpellerUseSdFs));

So passing false enables the SDF path. A build that simply does not push the switch was needed to measure it. Worth fixing on its own: today there is no way to turn UberSDF off from an application, and in release builds GetSwitchesFromEnvironment is compiled out as well.

What a production fix should preserve

Preferred direction

Evaluate the color source inside the UberSDF pass, as #192124 did for linear and radial gradients, so coverage masks the color source in one fragment pass. For runtime effects this is harder than for gradients because the user program is dynamic; a generated wrapper around the runtime entry point, or an analytic coverage input in the runtime effect pipeline, are both plausible, and the engine owners are better placed to choose. Image shaders, conical and sweep gradients are on the same fallback and would benefit from the same interface. The gradient work in flight, #192948 and #192962, suggests this direction is already being extended.

Related issues

Method

Release AOT builds throughout. Frame statistics come from FrameTiming once a second, the first four samples of every run are dropped, each cell is the median of per second values per run and then the median across runs, and variants were interleaved round by round. Engine counters and timers come from a local instrumentation patch with FML_LOG(ERROR), built in release as well. Frozen frames were captured with the animation stopped at a fixed phase so two engines produce comparable images, and compared with ImageMagick.

Zoom in to see the difference:
Impeller stable:
Image

Impeller master:
Image

Skia stable:
Image

Impeller with diagnosis diff tried:
Image

Activity

  1. added
    engineflutter/engine related. See also e: labels.
    platform-windowsBuilding on or for Windows specifically
    e: impellerImpeller rendering backend issues and features requests
    c: regressionIt was better in the past than it is now
    c: performanceRelates to speed or footprint issues (see "perf:" labels)
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    on Sep 18, 2026
  2. mbcorona commented on Sep 18, 2026

    @mbcorona
    Member

    HI @krll-kov , thanks for the detailed report.

    Confirmed on master: the fallback branch at canvas.cc:2308 and the linear/radial-only
    CreateUberSDFGradientParameters are as described.

    Routing to the Windows team.

  3. krll-kov commented on Sep 19, 2026

    @krll-kov
    ContributorAuthor

    Correction to my initial analysis: Linux is affected by default too. fl_engine.cc adds --impeller-use-sdfs unconditionally ("Linux (and other desktop platforms) always uses SDFs") and fl_dart_project.cc:65 defaults enable_impeller to TRUE, on master and on 3.47. So this is a default on Windows and Linux desktop, opt in on macOS and iOS, off on Android.
    I measured Windows only, but the pass and command counts come from the render graph, so Linux should show the same graph with a different cost per pass. That probably makes it an UberSDF question rather than a Windows one

  4. added
    P2Important issues not at the top of the work list
    on Sep 21, 2026
  5. gaaclarke commented on Sep 21, 2026

    @gaaclarke
    Member

    fyi @walley892

    Benson reminded me that this is a result of using uber_sdf where we create a mask with uber_sdf, then render the custom shader in another pass.

    Benson made a good point that we aren't getting much out of using uber_sdf in axis-aligned rects. Maybe it's worth switching back for tessellation in those cases to avoid the multiple render passes. We won't be affecting our ability to instance anyways because of the multiple passes.

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 listc: performanceRelates to speed or footprint issues (see "perf:" labels)c: regressionIt was better in the past than it is nowe: 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-windowsBuilding on or for Windows 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