Skip to content

[Windows][Impeller] BackdropFilter blur has significantly higher raster cost than Skia and bounded blur loses most grouping benefit #191207

Open

Description

@Razorex001

Steps to reproduce

Summary

On Windows, with Impeller, there are some issues with BackdropBlur:

Based on what I can see from my tests, the visual bug (when there's a bounded blur inside SingleChildScrollView) doesn't seem to be the direct cause of the performance issues

Code Sample Info

The Code Sample that I provided contains 36 frosted-glass panels using BackdropFilter with a blur sigma of 15.

It measures 8 cases automatically:

  • A — scroll, ungrouped, unbounded
  • B — scroll, ungrouped, bounded
  • C — scroll, grouped, unbounded
  • D — scroll, grouped, bounded
  • E — translated, ungrouped, unbounded
  • F — translated, ungrouped, bounded
  • G — translated, grouped, unbounded
  • H — translated, grouped, bounded

A-D use a SingleChildScrollView.

E-H use the same tall scene, moving at the same fixed speed, but the scene moves using Transform.translate inside a clipped area.

The grouped cases use BackdropGroup together with BackdropFilter.grouped

This small Flutter app runs all eight cases automatically at 800 logical pixels per second, and reports UI/raster frame-time percentiles, FPS, and missed-frame counts in the console. It uses Flutter's FrameTiming API, and it displays the results both inside the app and in the terminal

Steps to Reproduce

  1. Run the app on windows on latest channel master version in profile mode on Skia with flutter run -d windows --profile --no-enable-impeller
  2. Maximize the app and press F10, do not interact with the app and let it complete the 8 automatic tests
  3. See in the app the results, which you can find also in the terminal, and annotate them
  4. Run the app on windows again, but this time on Impeller with flutter run -d windows --profile
  5. Maximize the app and press F10, do not interact with the app and let it complete the 8 automatic tests
  6. See in the app the results, which you can find also in the terminal, and annotate them
  7. Compare the results between the 8 different runs and Skia vs Impeller

What should happen

While I'm aware that Skia doesn't currently have bounded blur implemented, and while I do not know exactly how backdropBlur is implemented on Skia or if it even has the grouping optimization implemented (so if there's anything that I'm missing here, I'm sorry), I would expect Impeller's performance, at least for unbounded blur test cases, to match (or improve on) the performance of Skia.
I also would expect that bounded blur should be able to have about ~all the performance benefits from grouping that can be seen in test cases C and G on Impeller
I would also expect no visual bugs with bounded blur inside SingleChildScrollView

What happens instead

Here's the tables with the results from testing on my pc:
These tests were performed using a maximized 1920×1017 physical viewport, a 60 Hz display, DPR 1, and deterministic scrolling at 800 logical pixels per second.

Impeller

Test case Frames FPS UI p50 / p95 Raster p50 / p90 / p95 / p99 / max Raster over budget
A — scroll, ungrouped, unbounded 239 59.50 2.099 / 2.410 ms 11.233 / 13.551 / 14.578 / 18.325 / 22.457 ms 4/239 (1.7%)
B — scroll, ungrouped, bounded 239 59.50 2.124 / 2.216 ms 10.942 / 13.089 / 13.893 / 15.313 / 17.161 ms 1/239 (0.4%)
C — scroll, grouped, unbounded 240 59.75 2.114 / 2.256 ms 3.853 / 4.088 / 4.182 / 4.290 / 5.234 ms 0/240 (0.0%)
D — scroll, grouped, bounded 240 59.75 2.151 / 2.295 ms 8.661 / 10.617 / 10.846 / 11.524 / 13.714 ms 0/240 (0.0%)
E — translated, ungrouped, unbounded 240 59.75 2.216 / 2.434 ms 10.790 / 12.956 / 13.671 / 14.792 / 17.976 ms 2/240 (0.8%)
F — translated, ungrouped, bounded 240 59.75 2.254 / 2.382 ms 10.739 / 12.759 / 13.670 / 14.806 / 15.511 ms 0/240 (0.0%)
G — translated, grouped, unbounded 240 59.75 2.248 / 2.358 ms 3.841 / 4.099 / 4.139 / 4.244 / 4.315 ms 0/240 (0.0%)
H — translated, grouped, bounded 240 59.75 2.265 / 2.426 ms 8.712 / 10.646 / 10.836 / 11.519 / 13.787 ms 0/240 (0.0%)

Skia

(Does not have implemented bounded blur, but included anyways to display the performance differences for the test cases that don't uses bounded blur)

Test case Frames FPS UI p50 / p95 Raster p50 / p90 / p95 / p99 / max Raster over budget
A — scroll, ungrouped, unbounded 239 59.50 1.574 / 1.791 ms 4.061 / 4.913 / 5.064 / 5.409 / 6.515 ms 0/239 (0.0%)
B — scroll, ungrouped, bounded 239 59.50 1.626 / 1.949 ms 4.096 / 4.867 / 5.070 / 5.714 / 6.198 ms 0/239 (0.0%)
C — scroll, grouped, unbounded 239 59.50 1.637 / 1.755 ms 3.877 / 4.773 / 4.855 / 4.907 / 5.287 ms 0/239 (0.0%)
D — scroll, grouped, bounded 239 59.50 1.678 / 1.777 ms 3.792 / 4.619 / 4.693 / 4.801 / 4.881 ms 0/239 (0.0%)
E — translated, ungrouped, unbounded 239 59.50 1.708 / 1.891 ms 3.830 / 4.682 / 4.792 / 5.560 / 6.390 ms 0/239 (0.0%)
F — translated, ungrouped, bounded 239 59.50 1.717 / 1.841 ms 3.830 / 4.656 / 4.765 / 4.884 / 5.354 ms 0/239 (0.0%)
G — translated, grouped, unbounded 240 59.75 1.698 / 2.181 ms 3.961 / 5.223 / 5.708 / 6.321 / 7.901 ms 0/240 (0.0%)
H — translated, grouped, bounded 240 59.75 1.729 / 1.853 ms 3.814 / 4.838 / 5.049 / 5.423 / 6.700 ms 0/240 (0.0%)

Based on these results, and from visually running the app, the issues I mentioned above appear evident:

  • Overall UI/Raster performance on Impeller is significantly worse than on skia. UI p50 is consistently about 28–33% higher on Impeller compared to Skia, for all 8 test cases (an extra ~0.5 ms on the UI thread may look small when viewed alone relative to a 16.67 ms frame budget, but performance losses accumulate in real scenes that are never as basic as this small test app that I posted here, and this seems to be a common denominator for Impeller). Raster performance shows an even larger difference for most of the test cases. See for example test case A Skia vs Impeller (with the exception for test cases C and G (grouped, unbounded) which seem to be the only ones on Impeller that show similar raster performance to Skia)
  • Bounded blur loses almost all grouping raster performance benefit, see Impeller test cases C and G vs D and H.
  • By looking at the app during the test it can be noticed quite obviously that there's a visual bug that happens for bounded test cases on Impeller that use SingleChildScrollView. These are Impeller test cases B and D. As mentioned above, this bug seems similar to [Impeller] ImageFilterConfig.blur(bounded: true) in BackdropFilter does not apply blur in a ListView #184447 and to [impeller] FadeTransition wrapping CupertinoPopupSurface breaks appearance on Impeller #182066, but it also appears to be not directly related to the performance issues. It seems that the visual bug happens when bounded blur is used together with SingleChildScrollView. Note that In this test app I used SingleChildScrollView, but I see that for [Impeller] ImageFilterConfig.blur(bounded: true) in BackdropFilter does not apply blur in a ListView #184447 it was happening with ListView. This visual issue does not happen for bounded test cases F and H that use Transform.translate inside a clipped area to move the scene.

Why BackdropFilter performance matters

I think backdropFilter is one of the most used things in modern apps UIs, developers are using it more and more, and I've seen this both with apps from other people and also in my own apps too. I heavily use it in all my apps, mostly because it looks great, and because on Skia it is usable performance wise. I have been trying to see if I could use Impeller for my windows apps but the performance of Impeller overall seems to be so much worse than Skia that it has been impossible for me so far to even consider switching from Skia to Impeller, and this issue addresses backdropBlur which is, I suppose, one of the things that more noticeably performs worse on Impeller compared to Skia, at least on my pc.
It would be great if this could get properly optimized and fixed, especially seeing how common it is in modern UIs.

I would also like to take this chance to say that I'm very thankful for the Flutter team's fast fixes and replies, especially gaaclarke and b-luk that have been doing lots of work on Flutter for windows to fix other issues that I had found.

Code sample

Code sample
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'dart:ui';

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

void main() => runApp(const ReproductionApp());

class ReproductionApp extends StatefulWidget {
  const ReproductionApp({super.key});

  @override
  State<ReproductionApp> createState() => _ReproductionAppState();
}

class _ReproductionAppState extends State<ReproductionApp> {
  bool _showPerformanceOverlay = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      showPerformanceOverlay: _showPerformanceOverlay,
      theme: ThemeData.dark(useMaterial3: true),
      home: ReproductionScreen(
        showPerformanceOverlay: _showPerformanceOverlay,
        onPerformanceOverlayChanged: (bool value) {
          setState(() => _showPerformanceOverlay = value);
        },
      ),
    );
  }
}

enum FilterCase {
  a(
    label: 'A — scroll, ungrouped, unbounded',
    usesScrollView: true,
    grouped: false,
    bounded: false,
  ),
  b(
    label: 'B — scroll, ungrouped, bounded',
    usesScrollView: true,
    grouped: false,
    bounded: true,
  ),
  c(
    label: 'C — scroll, grouped, unbounded',
    usesScrollView: true,
    grouped: true,
    bounded: false,
  ),
  d(
    label: 'D — scroll, grouped, bounded',
    usesScrollView: true,
    grouped: true,
    bounded: true,
  ),
  e(
    label: 'E — translated, ungrouped, unbounded',
    usesScrollView: false,
    grouped: false,
    bounded: false,
  ),
  f(
    label: 'F — translated, ungrouped, bounded',
    usesScrollView: false,
    grouped: false,
    bounded: true,
  ),
  g(
    label: 'G — translated, grouped, unbounded',
    usesScrollView: false,
    grouped: true,
    bounded: false,
  ),
  h(
    label: 'H — translated, grouped, bounded',
    usesScrollView: false,
    grouped: true,
    bounded: true,
  );

  const FilterCase({
    required this.label,
    required this.usesScrollView,
    required this.grouped,
    required this.bounded,
  });

  final String label;
  final bool usesScrollView;
  final bool grouped;
  final bool bounded;
}

class ReproductionScreen extends StatefulWidget {
  const ReproductionScreen({
    super.key,
    required this.showPerformanceOverlay,
    required this.onPerformanceOverlayChanged,
  });

  final bool showPerformanceOverlay;
  final ValueChanged<bool> onPerformanceOverlayChanged;

  @override
  State<ReproductionScreen> createState() => _ReproductionScreenState();
}

class _ReproductionScreenState extends State<ReproductionScreen>
    with TickerProviderStateMixin {
  final ScrollController _scrollController = ScrollController();
  final ValueNotifier<double> _translatedOffset = ValueNotifier<double>(0);
  late final FrameComparisonRunner _runner;

  bool _useBackdropBlur = true;
  bool _useBoundedFilter = false;
  bool _useGroupedFilter = false;
  bool _useScrollView = true;
  bool _running = false;

  String _status = 'Showing A — scroll, ungrouped, unbounded.';
  List<ComparisonResult>? _results;

  @override
  void initState() {
    super.initState();
    _runner = FrameComparisonRunner(
      vsync: this,
      controller: _scrollController,
      translatedOffset: _translatedOffset,
    );
  }

  @override
  void dispose() {
    _runner.dispose();
    _translatedOffset.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  FilterCase get _currentCase {
    for (final FilterCase value in FilterCase.values) {
      if (value.usesScrollView == _useScrollView &&
          value.grouped == _useGroupedFilter &&
          value.bounded == _useBoundedFilter) {
        return value;
      }
    }
    return FilterCase.a;
  }

  void _updateManualStatus() {
    _results = null;
    _status = 'Showing ${_currentCase.label}.';
  }

  Future<void> _runComparison() async {
    if (_running) {
      return;
    }

    if (!_useScrollView) {
      setState(() {
        _useScrollView = true;
        _useGroupedFilter = false;
        _useBoundedFilter = false;
      });
      await SchedulerBinding.instance.endOfFrame;
    }

    if (!_scrollController.hasClients ||
        !_scrollController.position.hasContentDimensions ||
        _scrollController.position.maxScrollExtent <= 0) {
      setState(() => _status = 'The scroll view is not ready. Try again.');
      return;
    }

    setState(() {
      _running = true;
      _useBackdropBlur = true;
      _useBoundedFilter = false;
      _useGroupedFilter = false;
      _useScrollView = true;
      _results = null;
      _status = 'Running A/B/C/D/E/F/G/H comparison.';
    });

    try {
      final List<ComparisonResult> results = await _runner.run(
        view: View.of(context),
        onCaseChanged: (FilterCase value) {
          if (mounted) {
            setState(() {
              _useBackdropBlur = true;
              _useBoundedFilter = value.bounded;
              _useGroupedFilter = value.grouped;
              _useScrollView = value.usesScrollView;
              _status = 'Measuring ${value.label}...';
            });
          }
        },
      );

      if (mounted) {
        setState(() {
          _results = results;
          _status = 'Comparison complete. Results are shown below.';
        });
      }
    } catch (error, stackTrace) {
      debugPrint('Comparison failed: $error\n$stackTrace');
      if (mounted) {
        setState(() => _status = 'Comparison failed: $error');
      }
    } finally {
      _running = false;
      if (mounted) {
        setState(() {});
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return CallbackShortcuts(
      bindings: <ShortcutActivator, VoidCallback>{
        const SingleActivator(LogicalKeyboardKey.f10): _runComparison,
      },
      child: Focus(
        autofocus: true,
        child: Scaffold(
          backgroundColor: const Color(0xff080d18),
          body: Column(
            children: <Widget>[
              SizedBox(height: 188, child: _buildControls()),
              Expanded(
                child: _BackdropBlurScene(
                  controller: _scrollController,
                  translatedOffset: _translatedOffset,
                  useBackdropBlur: _useBackdropBlur,
                  useBoundedFilter: _useBoundedFilter,
                  useGroupedFilter: _useGroupedFilter,
                  useScrollView: _useScrollView,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildControls() {
    return ColoredBox(
      color: const Color(0xff202632),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Row(
              children: <Widget>[
                const Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        'Bounded BackdropFilter A–H comparison',
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      Text(
                        'A–D use SingleChildScrollView; E–H use the same moving scene without a scrollable',
                        style: TextStyle(color: Colors.white60, fontSize: 12),
                      ),
                    ],
                  ),
                ),
                Text(
                  kProfileMode ? 'PROFILE MODE' : 'DEBUG MODE',
                  style: TextStyle(
                    color: kProfileMode
                        ? const Color(0xff65e6a8)
                        : const Color(0xffffad66),
                    fontWeight: FontWeight.bold,
                    fontSize: 12,
                  ),
                ),
                const SizedBox(width: 14),
                const Text('Blur'),
                Switch(
                  value: _useBackdropBlur,
                  onChanged: _running
                      ? null
                      : (bool value) {
                          setState(() => _useBackdropBlur = value);
                        },
                ),
                const Text('Bounded'),
                Switch(
                  value: _useBoundedFilter,
                  onChanged: _running
                      ? null
                      : (bool value) {
                          setState(() {
                            _useBoundedFilter = value;
                            _updateManualStatus();
                          });
                        },
                ),
                const Text('Grouped'),
                Switch(
                  value: _useGroupedFilter,
                  onChanged: _running
                      ? null
                      : (bool value) {
                          setState(() {
                            _useGroupedFilter = value;
                            _updateManualStatus();
                          });
                        },
                ),
                const Text('ScrollView'),
                Switch(
                  value: _useScrollView,
                  onChanged: _running
                      ? null
                      : (bool value) {
                          setState(() {
                            _useScrollView = value;
                            if (value) {
                              _translatedOffset.value = 0;
                            } else if (_scrollController.hasClients) {
                              _scrollController.jumpTo(0);
                            }
                            _updateManualStatus();
                          });
                        },
                ),
                const Text('Overlay'),
                Switch(
                  value: widget.showPerformanceOverlay,
                  onChanged: _running
                      ? null
                      : widget.onPerformanceOverlayChanged,
                ),
                const SizedBox(width: 10),
                FilledButton(
                  onPressed: _running ? null : _runComparison,
                  child: const Text('Run A–H (F10)'),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              _status,
              style: const TextStyle(color: Color(0xff79e4ff), fontSize: 12),
            ),
            const SizedBox(height: 8),
            Expanded(child: _buildResults()),
          ],
        ),
      ),
    );
  }

  Widget _buildResults() {
    final List<ComparisonResult>? results = _results;
    if (results == null) {
      return Text(
        kProfileMode
            ? 'Leave the performance overlay off for the numeric run. '
                  'The ScrollView switch lets you compare the same scene with '
                  'scrolling machinery present or absent.'
            : 'Relaunch with --profile before collecting reportable results.',
        style: const TextStyle(color: Colors.white54, fontSize: 12),
      );
    }

    return SelectableText(
      <String>[
        'Mode                                     FPS    UI p50/p95       '
            'Raster p50/p95/p99/max                 Over budget',
        ...results.map((ComparisonResult result) => result.displayLine),
      ].join('\n'),
      style: const TextStyle(
        color: Colors.white,
        fontFamily: 'Consolas',
        fontSize: 11,
        height: 1.38,
      ),
    );
  }
}

const int _panelCount = 3;
const int _panelRowCount = 12;
const double _panelTop = 150;
const double _panelRowStride = 320;
const double _panelRowGap = 64;
const double _panelSampleTopOffset = 20;
const double _panelSampleHeight = 270;
const double _panelSceneBottomPadding = 180;
const double _panelSceneHeight =
    _panelTop +
    (_panelRowCount - 1) * _panelRowStride +
    _panelSampleTopOffset +
    _panelSampleHeight +
    _panelSceneBottomPadding;

class _BackdropBlurScene extends StatelessWidget {
  const _BackdropBlurScene({
    required this.controller,
    required this.translatedOffset,
    required this.useBackdropBlur,
    required this.useBoundedFilter,
    required this.useGroupedFilter,
    required this.useScrollView,
  });

  final ScrollController controller;
  final ValueListenable<double> translatedOffset;
  final bool useBackdropBlur;
  final bool useBoundedFilter;
  final bool useGroupedFilter;
  final bool useScrollView;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        final Widget tallScene = SizedBox(
          width: constraints.maxWidth,
          height: _panelSceneHeight,
          child: Stack(
            fit: StackFit.expand,
            children: <Widget>[
              const CustomPaint(painter: _BackdropScenePainter()),
              Align(
                alignment: Alignment.topCenter,
                child: Padding(
                  padding: const EdgeInsets.only(
                    top: _panelTop,
                    left: 24,
                    right: 24,
                  ),
                  child: IgnorePointer(
                    child: Column(
                      children: List<Widget>.generate(
                        _panelRowCount,
                        (int rowIndex) => Padding(
                          padding: EdgeInsets.only(
                            bottom: rowIndex == _panelRowCount - 1
                                ? 0
                                : _panelRowGap,
                          ),
                          child: Wrap(
                            alignment: WrapAlignment.center,
                            spacing: 24,
                            runSpacing: 24,
                            children: List<Widget>.generate(
                              _panelCount,
                              (int index) => _GlassPanel(
                                blurEnabled: useBackdropBlur,
                                bounded: useBoundedFilter,
                                grouped: useGroupedFilter,
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        );

        Widget viewport;

        if (useScrollView) {
          viewport = ScrollConfiguration(
            behavior: ScrollConfiguration.of(context)
                .copyWith(scrollbars: false),
            child: SingleChildScrollView(
              controller: controller,
              child: tallScene,
            ),
          );
        } else {
          viewport = ClipRect(
            child: OverflowBox(
              alignment: Alignment.topCenter,
              minWidth: constraints.maxWidth,
              maxWidth: constraints.maxWidth,
              minHeight: _panelSceneHeight,
              maxHeight: _panelSceneHeight,
              child: ValueListenableBuilder<double>(
                valueListenable: translatedOffset,
                child: tallScene,
                builder: (BuildContext context, double offset, Widget? child) {
                  return Transform.translate(
                    offset: Offset(0, -offset),
                    child: child,
                  );
                },
              ),
            ),
          );
        }

        if (useGroupedFilter) {
          viewport = BackdropGroup(child: viewport);
        }

        return viewport;
      },
    );
  }
}

class _GlassPanel extends StatelessWidget {
  const _GlassPanel({
    required this.blurEnabled,
    required this.bounded,
    required this.grouped,
  });

  static const BorderRadius _borderRadius = BorderRadius.all(
    Radius.circular(28),
  );

  final bool blurEnabled;
  final bool bounded;
  final bool grouped;

  @override
  Widget build(BuildContext context) {
    final Widget surface = SizedBox(
      width: 360,
      height: 256,
      child: DecoratedBox(
        decoration: BoxDecoration(
          color: Colors.white.withAlpha(13),
          borderRadius: _borderRadius,
          border: Border.all(color: Colors.white.withAlpha(26), width: 1),
          boxShadow: <BoxShadow>[
            BoxShadow(
              color: Colors.black.withAlpha(61),
              blurRadius: 24,
              spreadRadius: -8,
              offset: const Offset(0, 12),
            ),
          ],
        ),
        child: const Padding(
          padding: EdgeInsets.fromLTRB(24, 22, 24, 20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Row(
                children: <Widget>[
                  DecoratedBox(
                    decoration: BoxDecoration(
                      color: Color(0x3079e4ff),
                      borderRadius: BorderRadius.all(Radius.circular(99)),
                    ),
                    child: Padding(
                      padding: EdgeInsets.symmetric(
                        horizontal: 10,
                        vertical: 5,
                      ),
                      child: Text(
                        'BACKDROP FILTER',
                        style: TextStyle(
                          color: Color(0xff79e4ff),
                          fontSize: 10,
                          fontWeight: FontWeight.bold,
                          letterSpacing: 1.2,
                        ),
                      ),
                    ),
                  ),
                  Spacer(),
                  Text(
                    'sigma 15',
                    style: TextStyle(
                      color: Colors.white70,
                      fontSize: 11,
                      fontFamily: 'Consolas',
                    ),
                  ),
                ],
              ),
              SizedBox(height: 24),
              Text(
                'Frosted panel',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 22,
                  fontWeight: FontWeight.w700,
                ),
              ),
              SizedBox(height: 10),
              Text(
                'Identical content in every benchmark mode.',
                style: TextStyle(
                  color: Colors.white70,
                  fontSize: 13,
                  height: 1.35,
                ),
              ),
              Spacer(),
              Text(
                'ImageFilterConfig.blur(sigmaX: 15, sigmaY: 15)',
                style: TextStyle(
                  color: Color(0xff79e4ff),
                  fontSize: 11,
                  fontFamily: 'Consolas',
                ),
              ),
            ],
          ),
        ),
      ),
    );

    if (!blurEnabled) {
      return surface;
    }

    final ImageFilterConfig config = ImageFilterConfig.blur(
      sigmaX: 15,
      sigmaY: 15,
      bounded: bounded,
    );

    final Widget filtered = grouped
        ? BackdropFilter.grouped(filterConfig: config, child: surface)
        : BackdropFilter(filterConfig: config, child: surface);

    return ClipRRect(
      borderRadius: _borderRadius,
      clipBehavior: Clip.antiAlias,
      child: filtered,
    );
  }
}

class _BackdropScenePainter extends CustomPainter {
  const _BackdropScenePainter();

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawColor(const Color(0xff081121), BlendMode.srcOver);

    final Paint linePaint = Paint()
      ..color = const Color(0x287da2c9)
      ..strokeWidth = 1;

    for (int row = 0; row < 30; row++) {
      final double y = 34 + row * 80;
      canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint);
    }

    for (int row = 0; row < _panelRowCount; row++) {
      final double sampleTop =
          _panelTop + _panelSampleTopOffset + row * _panelRowStride;
      _paintHighContrastBackdrop(canvas, size, sampleTop);
    }
  }

  void _paintHighContrastBackdrop(Canvas canvas, Size size, double sampleTop) {
    final double sampleWidth = math.min(1400, size.width * 0.92);
    final double sampleLeft = (size.width - sampleWidth) / 2;
    const double sampleHeight = _panelSampleHeight;
    final double firstBlockWidth = sampleWidth * 0.34;
    final double secondBlockWidth = sampleWidth * 0.26;
    final double thirdBlockWidth = sampleWidth * 0.12;

    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(sampleLeft, sampleTop, firstBlockWidth, sampleHeight),
        const Radius.circular(24),
      ),
      Paint()..color = const Color(0xfff76707),
    );

    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(
          sampleLeft + firstBlockWidth + 12,
          sampleTop + 28,
          secondBlockWidth,
          sampleHeight - 56,
        ),
        const Radius.circular(20),
      ),
      Paint()..color = const Color(0xff7048e8),
    );

    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(
          sampleLeft + sampleWidth * 0.62,
          sampleTop + 18,
          thirdBlockWidth,
          sampleHeight - 36,
        ),
        const Radius.circular(18),
      ),
      Paint()..color = const Color(0xfff06595),
    );

    canvas.drawCircle(
      Offset(sampleLeft + sampleWidth * 0.78, sampleTop + 116),
      94,
      Paint()..color = const Color(0xff12b886),
    );

    for (int index = 0; index < 6; index++) {
      final double x = sampleLeft + 20 + index * sampleWidth / 6;
      canvas.drawLine(
        Offset(x, sampleTop + 12),
        Offset(x + 48, sampleTop + sampleHeight - 12),
        Paint()
          ..color = Colors.white.withAlpha(190)
          ..strokeWidth = 3,
      );
    }
  }

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

/// A–D use a SingleChildScrollView.
/// E–H use the same tall scene and the same 800 px/s motion, but there is no
/// Scrollable/Viewport/ScrollPosition. The scene is translated inside ClipRect.
class FrameComparisonRunner {
  FrameComparisonRunner({
    required TickerProvider vsync,
    required ScrollController controller,
    required ValueNotifier<double> translatedOffset,
  }) : _vsync = vsync,
       _controller = controller,
       _translatedOffset = translatedOffset;

  static const double _movementSpeed = 800;
  static const Duration _unmeasuredMotionDuration = Duration(seconds: 2);
  static const Duration _measurementDuration = Duration(seconds: 4);
  static const Duration _timingDrainDuration = Duration(seconds: 1);
  static const Duration _interPhasePause = Duration(seconds: 1);
  static const double _maximumDeltaSeconds = 1 / 30;

  final TickerProvider _vsync;
  final ScrollController _controller;
  final ValueNotifier<double> _translatedOffset;

  Ticker? _activeTicker;
  bool _running = false;
  bool _disposed = false;
  double? _knownExtent;

  Future<List<ComparisonResult>> run({
    required FlutterView view,
    required ValueChanged<FilterCase> onCaseChanged,
  }) async {
    if (_running || _disposed) {
      return const <ComparisonResult>[];
    }

    _running = true;
    try {
      final List<ComparisonResult> results = <ComparisonResult>[];

      for (final FilterCase filterCase in FilterCase.values) {
        if (results.isNotEmpty) {
          await Future<void>.delayed(_interPhasePause);
        }

        final List<FrameTiming> timings = await _runPhase(
          filterCase: filterCase,
          onCaseChanged: onCaseChanged,
        );

        results.add(
          ComparisonResult.fromTimings(
            label: filterCase.label,
            timings: timings,
            measurementDuration: _measurementDuration,
            refreshRate: view.display.refreshRate,
          ),
        );
      }

      _printResults(results);
      return results;
    } finally {
      _running = false;
    }
  }

  Future<List<FrameTiming>> _runPhase({
    required FilterCase filterCase,
    required ValueChanged<FilterCase> onCaseChanged,
  }) async {
    onCaseChanged(filterCase);
    await SchedulerBinding.instance.endOfFrame;

    final double extent;

    if (filterCase.usesScrollView) {
      if (!_controller.hasClients ||
          !_controller.position.hasContentDimensions) {
        throw StateError('ScrollView is not ready for ${filterCase.name}.');
      }

      extent = _controller.position.maxScrollExtent;
      _knownExtent = extent;
    } else {
      final double? knownExtent = _knownExtent;
      if (knownExtent == null || knownExtent <= 0) {
        throw StateError(
          'No scroll extent has been measured before ${filterCase.name}.',
        );
      }
      extent = knownExtent;
    }

    _setPosition(filterCase, 0);
    await SchedulerBinding.instance.endOfFrame;

    await _drive(
      filterCase: filterCase,
      duration: _unmeasuredMotionDuration,
      extent: extent,
    );

    _setPosition(filterCase, 0);
    await SchedulerBinding.instance.endOfFrame;

    final List<FrameTiming> timings = <FrameTiming>[];
    int? startMicros;
    int? endMicros;
    void collect(List<FrameTiming> batch) => timings.addAll(batch);

    SchedulerBinding.instance.addTimingsCallback(collect);

    final TimelineTask task = TimelineTask()
      ..start(
        'BackdropFilterRepro_${filterCase.name.toUpperCase()}',
        arguments: <String, Object>{
          'case': filterCase.name.toUpperCase(),
          'uses_scroll_view': filterCase.usesScrollView,
          'motion': filterCase.usesScrollView
              ? 'SingleChildScrollView'
              : 'Transform.translate',
          'bounded': filterCase.bounded,
          'grouped': filterCase.grouped,
          'backdrop_group_present': filterCase.grouped,
          'backdrop_filter_grouped_constructor': filterCase.grouped,
          'panels_per_row': _panelCount,
          'panel_row_count': _panelRowCount,
          'panel_count': _panelCount * _panelRowCount,
          'blur_sigma': 15,
          'movement_speed_px_per_second': _movementSpeed,
        },
      );

    try {
      await _drive(
        filterCase: filterCase,
        duration: _measurementDuration,
        extent: extent,
        onFirstFrame: (int value) => startMicros = value,
        onLastFrame: (int value) => endMicros = value,
      );

      await Future<void>.delayed(_timingDrainDuration);
    } finally {
      task.finish();
      SchedulerBinding.instance.removeTimingsCallback(collect);
    }

    return timings.where((FrameTiming timing) {
      final int timestamp = timing.timestampInMicroseconds(
        FramePhase.buildStart,
      );
      return startMicros != null &&
          endMicros != null &&
          timestamp >= startMicros! &&
          timestamp <= endMicros!;
    }).toList()..sort((FrameTiming a, FrameTiming b) {
      return a
          .timestampInMicroseconds(FramePhase.buildStart)
          .compareTo(b.timestampInMicroseconds(FramePhase.buildStart));
    });
  }

  void _setPosition(FilterCase filterCase, double value) {
    if (filterCase.usesScrollView) {
      if (_controller.hasClients) {
        _controller.jumpTo(value);
      }
    } else {
      _translatedOffset.value = value;
    }
  }

  Future<void> _drive({
    required FilterCase filterCase,
    required Duration duration,
    required double extent,
    ValueChanged<int>? onFirstFrame,
    ValueChanged<int>? onLastFrame,
  }) async {
    final Completer<void> done = Completer<void>();
    Duration? previous;
    double direction = 1;
    double position = 0;
    bool started = false;

    late final Ticker ticker;
    ticker = _vsync.createTicker((Duration elapsed) {
      final Duration? last = previous;
      previous = elapsed;
      if (last == null) {
        return;
      }

      final double deltaSeconds = math.min(
        (elapsed - last).inMicroseconds / Duration.microsecondsPerSecond,
        _maximumDeltaSeconds,
      );

      final int frameTimestamp =
          SchedulerBinding.instance.currentSystemFrameTimeStamp.inMicroseconds;

      if (!started) {
        started = true;
        onFirstFrame?.call(frameTimestamp);
      }

      position += _movementSpeed * direction * deltaSeconds;

      if (position >= extent) {
        position = extent;
        direction = -1;
      } else if (position <= 0) {
        position = 0;
        direction = 1;
      }

      _setPosition(filterCase, position);

      if (elapsed >= duration) {
        onLastFrame?.call(frameTimestamp);
        ticker.stop();
        done.complete();
      }
    });

    _activeTicker = ticker;
    ticker.start();
    await done.future;
    ticker.dispose();

    if (identical(_activeTicker, ticker)) {
      _activeTicker = null;
    }
  }

  void _printResults(List<ComparisonResult> results) {
    debugPrint(
      '=== Windows bounded BackdropFilter A/B/C/D/E/F/G/H comparison ===',
    );
    debugPrint(
      'mode=${kProfileMode ? 'profile' : 'NOT_PROFILE'} '
      'speed=${_movementSpeed.round()}px/s order=A/B/C/D/E/F/G/H',
    );

    for (final ComparisonResult result in results) {
      debugPrint(result.consoleLine);
    }

    debugPrint(
      '================================================================',
    );
  }

  void dispose() {
    _disposed = true;
    _activeTicker?.dispose();
    _activeTicker = null;
  }
}

class ComparisonResult {
  const ComparisonResult({
    required this.label,
    required this.frameCount,
    required this.fps,
    required this.build,
    required this.raster,
    required this.rasterOverBudget,
    required this.rasterOverBudgetPercent,
  });

  factory ComparisonResult.fromTimings({
    required String label,
    required List<FrameTiming> timings,
    required Duration measurementDuration,
    required double refreshRate,
  }) {
    final List<int> buildMicros = timings
        .map((FrameTiming timing) => timing.buildDuration.inMicroseconds)
        .toList();

    final List<int> rasterMicros = timings
        .map((FrameTiming timing) => timing.rasterDuration.inMicroseconds)
        .toList();

    final double budgetMicros = Duration.microsecondsPerSecond / refreshRate;

    final int overBudget = rasterMicros
        .where((int value) => value > budgetMicros)
        .length;

    final double seconds =
        measurementDuration.inMicroseconds / Duration.microsecondsPerSecond;

    return ComparisonResult(
      label: label,
      frameCount: timings.length,
      fps: timings.length < 2 ? 0 : (timings.length - 1) / seconds,
      build: TimingSummary.fromMicroseconds(buildMicros),
      raster: TimingSummary.fromMicroseconds(rasterMicros),
      rasterOverBudget: overBudget,
      rasterOverBudgetPercent: timings.isEmpty
          ? 0
          : overBudget * 100 / timings.length,
    );
  }

  final String label;
  final int frameCount;
  final double fps;
  final TimingSummary build;
  final TimingSummary raster;
  final int rasterOverBudget;
  final double rasterOverBudgetPercent;

  String get displayLine =>
      '${label.padRight(40)} '
      '${fps.toStringAsFixed(2).padLeft(6)}  '
      '${build.p50.toStringAsFixed(2).padLeft(5)}/'
      '${build.p95.toStringAsFixed(2).padRight(5)} ms   '
      '${raster.p50.toStringAsFixed(2).padLeft(6)}/'
      '${raster.p95.toStringAsFixed(2)}/'
      '${raster.p99.toStringAsFixed(2)}/'
      '${raster.max.toStringAsFixed(2)} ms   '
      '$rasterOverBudget/$frameCount '
      '(${rasterOverBudgetPercent.toStringAsFixed(1)}%)';

  String get consoleLine =>
      '$label | frames=$frameCount fps=${fps.toStringAsFixed(2)} '
      '| ui p50/p95=${build.p50.toStringAsFixed(3)}/'
      '${build.p95.toStringAsFixed(3)}ms '
      '| raster p50/p90/p95/p99/max=${raster.p50.toStringAsFixed(3)}/'
      '${raster.p90.toStringAsFixed(3)}/'
      '${raster.p95.toStringAsFixed(3)}/'
      '${raster.p99.toStringAsFixed(3)}/'
      '${raster.max.toStringAsFixed(3)}ms '
      '| raster_over_budget=$rasterOverBudget/$frameCount '
      '(${rasterOverBudgetPercent.toStringAsFixed(1)}%)';
}

class TimingSummary {
  const TimingSummary({
    required this.p50,
    required this.p90,
    required this.p95,
    required this.p99,
    required this.max,
  });

  factory TimingSummary.fromMicroseconds(List<int> values) {
    if (values.isEmpty) {
      return const TimingSummary(p50: 0, p90: 0, p95: 0, p99: 0, max: 0);
    }

    final List<int> sorted = List<int>.of(values)..sort();

    return TimingSummary(
      p50: _percentile(sorted, 0.50) / Duration.microsecondsPerMillisecond,
      p90: _percentile(sorted, 0.90) / Duration.microsecondsPerMillisecond,
      p95: _percentile(sorted, 0.95) / Duration.microsecondsPerMillisecond,
      p99: _percentile(sorted, 0.99) / Duration.microsecondsPerMillisecond,
      max: sorted.last / Duration.microsecondsPerMillisecond,
    );
  }

  final double p50;
  final double p90;
  final double p95;
  final double p99;
  final double max;

  static double _percentile(List<int> sorted, double fraction) {
    final double position = (sorted.length - 1) * fraction;
    final int lower = position.floor();
    final int upper = position.ceil();

    if (lower == upper) {
      return sorted[lower].toDouble();
    }

    return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
  }
}

Performance profiling on master channel

  • The issue still persists on the master channel

Timeline Traces

Timeline Traces JSON zip

backdropBlur_issues_dart_devtools_2026-08-17_11_53_55.291.zip

Video demonstration

Video demonstration

Skia

https://youtu.be/JDyHtW_MLVQ

Impeller

https://youtu.be/kpZdq0XjM8w

What target platforms are you seeing this bug on?

Windows

OS/Browser name and version | Device information

Environment

flutter --version
Flutter 3.48.0-1.0.pre-237 • channel master • https://github.com/flutter/flutter.git
Framework • revision 3ce270e (4 hours ago) • 2026-08-17 08:05:13 +0000
Engine • hash d1f5ddb72d2dba68881ca4da1acefbba6bce438b (revision 3ce270e) (3 hours ago) • 2026-08-17 08:05:13.000Z
Tools • Dart 3.14.0 (build 3.14.0-134.0.dev) • DevTools 2.61.0-dev.0

  • OS: Windows 10 Pro 22H2, build 19045.7548
  • GPU: Radeon RX 580 Series 4GB
  • GPU driver: 31.0.21923.11000
  • GPU driver date: 2025-07-01 (which is latest one available for my GPU)
  • Display refresh rate: 60 Hz

Does the problem occur on emulator/simulator as well as on physical devices?

Unknown

Is the problem only reproducible with Impeller?

Yes

Logs

Logs
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(127)] Using the Impeller rendering backend (OpenGLESSDF).

Flutter Doctor output

Doctor output
flutter doctor -v               
[√] Flutter (Channel master, 3.48.0-1.0.pre-237, on Microsoft Windows [Version 10.0.19045.7663], locale it-IT) [2,6s]
    • Flutter version 3.48.0-1.0.pre-237 on channel master at C:\src\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 3ce270e8b9 (4 hours ago), 2026-08-17 08:05:13 +0000
    • Engine revision 3ce270e8b9
    • Dart version 3.14.0 (build 3.14.0-134.0.dev)
    • DevTools version 2.61.0-dev.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
      enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-windowing, enable-lldb-debugging, enable-uiscene-migration, enable-riscv64

[√] Windows Version (10 Pro 64-bit, 22H2, 2009) [1.691ms]

[√] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [3,0s]
    • Android SDK at C:\Users\moret\AppData\Local\Android\sdk
    • Emulator version 36.6.11.0 (build_id 15507667) (CL:N/A)
    • Platform android-36.1, build-tools 36.1.0
    • Java binary at: C:\Program Files\Android\Android Studio\jbr\bin\java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.10+-14961533-b1163.108)
    • All Android licenses accepted.

[√] Chrome - develop for the web [120ms]
    • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe

[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.14.38) [119ms]
    • Visual Studio at C:\Program Files\Microsoft Visual Studio\2022\Community
    • Visual Studio Community 2022 version 17.14.37531.7
    • Windows 10 SDK version 10.0.26100.0

[√] Connected device (3 available) [638ms]
    • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.19045.7663]
    • Chrome (web)      • chrome  • web-javascript • Google Chrome 151.0.7922.138
    • Edge (web)        • edge    • web-javascript • Microsoft Edge 151.0.4129.86

[√] Network resources [636ms]
    • All expected network resources are available.

• No issues found!

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
    team-windowsOwned by the Windows platform team
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    on Aug 17, 2026
  2. added and removed
    team-windowsOwned by the Windows platform team
    on Aug 17, 2026
  3. gaaclarke commented on Aug 20, 2026

    @gaaclarke
    Member

    Without measuring this, this is almost assuredly the same problems we were seeing with the other 2 reports where render passes have an extra high cost. Here's a renderdoc from the text shadows pr: https://github.com/user-attachments/files/30720361/textblur.zip.zip

    Blur's are going to be a bit trickier to address. For text shadows and gradients we already had ideas on hand or half implemented to reduce render pass counts. Blur's have already been highly optimized. We have to find a way to hold ANGLE in such a way it's happier about multiple passes or update ANGLE itself.

    In the long term having a modern backend for windows like vulkan or dx12 will get around this. Either of those are somewhat in the distant future though.

  4. gaaclarke commented on Aug 21, 2026

    @gaaclarke
    Member

    I poked around at the trace with gemini. It is claiming the largest overhead we have in map/unmap calls (50% of the cost). It also says using the vulkan backend would eliminate this completely (thanks).

    Here are the suggested fixes that are doable.

    1. Batch Buffer Uploads into a Single Pre-Pass/Pre-Frame Transfer

    • The Problem: In device_buffer_gles.cc:91-135, buffer sub-data is uploaded via glBufferSubData on-demand as each draw command is encoded.
    • The Solution:
    • Impeller's host_buffer.cc:21-48 already stages all geometry (vertices, indices, instance data) in host memory.
    • Instead of uploading piecemeal per draw command, perform a single bulk upload of the active host buffer range to the device buffer once at the start of the frame or once per
    pass.
    • This reduces hundreds of vertex/index buffer Map calls down to 1–2 calls per frame.

    ──────

    2. Consolidate Uniforms with UBO Offsets (D3D11.1 Dynamic Offsets)

    • The Problem: Setting individual uniforms (glUniform* / small per-draw uniform buffers) forces ANGLE to re-map its dynamic Constant Buffer before every draw call
    (VSSetConstantBuffers1 and PSSetConstantBuffers1).
    • The Solution:
    • Use Uniform Buffer Objects (UBOs) with glBindBufferRange.
    • Emplace uniforms for all draw commands in the frame into one continuous UBO backing buffer.
    • ANGLE maps the underlying D3D11 constant buffer once, and each draw command simply binds with an offset (ID3D11DeviceContext1::VSSetConstantBuffers1(..., pFirstConstant,
    pNumConstants)), avoiding per-draw Map calls entirely.

  5. Razorex001 commented on Aug 21, 2026

    @Razorex001
    Author

    Correct me if I'm wrong, but as far as I understand it option 2 means also something like have Windows prefer a GLES3 context, and retain GLES2 as a fallback for machines that can't support GLES3 (?).

    I did some quick A/B testing with one of my actual production apps, and while it wasn't a fully deterministic A/B, the testing shows that this app's mean raster time is around 60–70% higher on Impeller compared to Skia.

    I do not have a very clear understanding of how much those 2 fixes could improve the performance differences between skia and Impeller on windows, but I believe it would be important to get a better sense of whether it's worth the engineering effort.

    I can continue trying to help to identify things that are performing worse, and this may help in providing a broader picture of Impeller's current performance on Windows, and you guys can continue to find ways to fix them, but at the end of the day these fixes are likely only worth the effort if they result in Impeller becoming properly usable on windows. I think based on my experience with my own apps, and on all the tests I've done, as of today what I've seen is that it's very far from being performance competitive with skia.
    I'm trying to avoid making bold claims, but for example for what concerns my apps, even if that 60-70% slower mean raster time on Impeller decreased to something like, say 40%, it would be still very hard to justify switching to Impeller and dealing with 40% worse raster time and likely having to redesign/adapt the app around that.

    While I do not know how thin are flutter's engineering resources spread, and while I'm in no way as competent as you, I humbly believe there's a point where spending more engineering time on trying to patch holes in opengles/angle performance on windows makes little sense, and that effort would be logically best spent starting work on a dx12 /vulkan backend (I know it's a massive effort, but like all things in life the sooner it starts, the sooner it's done). In the meantime while that is not ready, keeping Skia available (and possibly returning it as the main renderer for windows, although that's just a policy decision) could be the real short term solution.

    I know that you probably already know this, but just for context:

    Desktop OS Market share
    Windows 71.11%
    macOS / OS X 19.90%
    Linux 7.51%
    Chrome OS 1.47%
    Other ~0.01%
    OS Market share
    Android 41.39%
    Windows 27.67%
    iOS 19.68%
    macOS / OS X 7.71%
    Linux 2.94%
    Other 0.61%

    These are from statcounter. Gemini's estimates are also very similar.

    I believe Windows market share does fully justify investing the engineering time needed to implement a proper modern backend, and ensuring it's not a 2nd class citizen.

  6. gaaclarke commented on Aug 24, 2026

    @gaaclarke
    Member

    Spawned issue for this bug on its own: #191611

  7. gaaclarke commented on Aug 24, 2026

    @gaaclarke
    Member
    • Performance issue: Bounded blur loses almost all raster performance benefit from BackdropGroup / BackdropFilter.grouped

    This is working as intended. I've filed a PR that clarifies this: #191613

  8. gaaclarke commented on Aug 24, 2026

    @gaaclarke
    Member

    There is a bug in the reproduction code that makes it so that the scroll only advances if there are events happening. I have to scroll around my mouse to make it to continue advancing.

  9. Razorex001 commented on Aug 24, 2026

    @Razorex001
    Author

    There is a bug in the reproduction code that makes it so that the scroll only advances if there are events happening. I have to scroll around my mouse to make it to continue advancing.

    The reproduction code is made to be used as a deterministic automated test by pressing F10, so I'm not sure what you mean by that because in theory you should not be touching the mouse at all after pressing F10, it does all by itself

  10. 5 remaining items

  11. gaaclarke commented on Sep 4, 2026

    @gaaclarke
    Member

    #191838 performs an optimization for blur groups that will affect this.

  12. Razorex001 commented on Sep 4, 2026

    @Razorex001
    Author

    #191838 performs an optimization for blur groups that will affect this.

    I saw that pr the other day and I'm very glad to see these optimizations, so thank you for that :)
    I ran again this benchmark on latest master and cases C/G (grouped, unbounded) look basically unchanged (~4.1 ms p50). It may be that the layout of this benchmark app isn't the most ideal to highlight this optimization because these blur panels still form a somewhat large coverage union (?). I did see however a visible improvement in blur groups performance in a different benchmark that I have locally.

  13. gaaclarke commented on Sep 9, 2026

    @gaaclarke
    Member

    Here's the results on a Radeon Vega 8 at 63b9518, which is about 1/6 as powerful as the OP's gpu:

    AMD Ryzen 7 5800H with Radeon Graphics 
    AMD Radeon Vega 8
    
    === Windows bounded BackdropFilter A/B/C/D/E/F/G/H comparison ===
    mode=profile speed=800px/s order=A/B/C/D/E/F/G/H
    A — scroll, ungrouped, unbounded | frames=252 fps=62.75 | ui p50/p95=2.370/2.781ms | raster p50/p90/p95/p99/max=8.702/12.369/13.867/18.251/25.696ms | raster_over_budget=4/252 (1.6%)
    B — scroll, ungrouped, bounded | frames=242 fps=60.25 | ui p50/p95=2.311/2.701ms | raster p50/p90/p95/p99/max=7.638/9.335/10.070/13.199/15.386ms | raster_over_budget=0/242 (0.0%)
    C — scroll, grouped, unbounded | frames=259 fps=64.50 | ui p50/p95=1.611/2.045ms | raster p50/p90/p95/p99/max=2.188/2.480/2.611/2.757/3.316ms | raster_over_budget=0/259 (0.0%)
    D — scroll, grouped, bounded | frames=254 fps=63.25 | ui p50/p95=1.774/2.395ms | raster p50/p90/p95/p99/max=6.444/8.203/8.497/9.904/13.834ms | raster_over_budget=0/254 (0.0%)
    E — translated, ungrouped, unbounded | frames=246 fps=61.25 | ui p50/p95=2.891/4.285ms | raster p50/p90/p95/p99/max=11.872/16.631/17.628/24.322/27.683ms | raster_over_budget=20/246 (8.1%)
    F — translated, ungrouped, bounded | frames=250 fps=62.25 | ui p50/p95=2.462/2.848ms | raster p50/p90/p95/p99/max=7.670/9.187/10.105/11.661/12.781ms | raster_over_budget=0/250 (0.0%)
    G — translated, grouped, unbounded | frames=260 fps=64.75 | ui p50/p95=1.707/2.149ms | raster p50/p90/p95/p99/max=2.188/2.436/2.526/2.932/3.381ms | raster_over_budget=0/260 (0.0%)
    H — translated, grouped, bounded | frames=255 fps=63.50 | ui p50/p95=1.847/2.438ms | raster p50/p90/p95/p99/max=6.513/8.227/8.646/9.699/11.769ms | raster_over_budget=0/255 (0.0%)
    ================================================================
    

    The differences are a lot more stark on machines without GPUs. I would have liked to have gotten them closer but we have a few things in the works:

    1. Eventually we are going to do batching of uber_sdf calls which is something that neither skia or non-sdf impeller can do Batch ubersdf calls #185556
    2. We have a PR that is removing derivatives making uber_sdf faster uber_sdf: removes derivatives from rect path #192267
    3. @b-luk is going to audit the gradients one more time since we could use SSBO for faster gradients ( [uber_sdf]: investigate using SSBO for gradients #192503)
    4. I want to audit one more time the final composition to the swapchain. It seems like there might be a way we can simplify it. audit swapchain composition on windows #192496

    I think the Radeon Vega 8 is a good benchmark as an integrated gpu and everything is running in 60fps, especially when blur groups are used. So I'm going to drop the priority of this.

    One other thing to note. I did see a lot of time being spent in the super ellipse fragment shaders too in this reproduction. We never did an optimization pass on those. We should give them another pass at some point (cc @walley892 )

  14. removed their assignment
    on Sep 9, 2026
  15. added
    P2Important issues not at the top of the work list
    and removed
    P1High-priority issues at the top of the work list
    on Sep 9, 2026
  16. Razorex001 commented on Sep 9, 2026

    @Razorex001
    Author

    @gaaclarke The benchmark app runs at 60 fps because it is a minimal repro, in a real app the performance differences add up from the different components in a scene (I'm sure you know this).
    I don't really understand how a gpu that is supposed to be 8 times less powerful than mine like the Vega 8 is performing so much better than mine. I ran the benchmark again now, on latest master:

    AMD Ryzen 1600
    AMD Radeon Rx580 4GB
    
    === Windows bounded BackdropFilter A/B/C/D/E/F/G/H comparison ===
    mode=profile speed=800px/s order=A/B/C/D/E/F/G/H
    A — scroll, ungrouped, unbounded | frames=239 fps=59.50 | ui p50/p95=2.172/2.866ms | raster p50/p90/p95/p99/max=12.609/15.014/16.313/19.824/21.825ms | raster_over_budget=10/239 (4.2%)
    B — scroll, ungrouped, bounded | frames=239 fps=59.50 | ui p50/p95=2.203/2.698ms | raster p50/p90/p95/p99/max=12.410/14.893/15.581/17.586/19.368ms | raster_over_budget=4/239 (1.7%)
    C — scroll, grouped, unbounded | frames=239 fps=59.50 | ui p50/p95=2.117/2.315ms | raster p50/p90/p95/p99/max=4.162/4.418/4.469/4.598/4.761ms | raster_over_budget=0/239 (0.0%)
    D — scroll, grouped, bounded | frames=239 fps=59.50 | ui p50/p95=2.152/2.397ms | raster p50/p90/p95/p99/max=9.756/12.291/12.543/12.793/14.859ms | raster_over_budget=0/239 (0.0%)
    E — translated, ungrouped, unbounded | frames=239 fps=59.50 | ui p50/p95=2.221/2.633ms | raster p50/p90/p95/p99/max=12.476/15.102/16.205/16.720/17.933ms | raster_over_budget=4/239 (1.7%)
    F — translated, ungrouped, bounded | frames=239 fps=59.50 | ui p50/p95=2.245/2.587ms | raster p50/p90/p95/p99/max=12.382/14.797/16.031/16.628/17.049ms | raster_over_budget=2/239 (0.8%)
    G — translated, grouped, unbounded | frames=240 fps=59.75 | ui p50/p95=2.297/2.495ms | raster p50/p90/p95/p99/max=4.161/4.405/4.492/4.733/4.954ms | raster_over_budget=0/240 (0.0%)
    H — translated, grouped, bounded | frames=240 fps=59.75 | ui p50/p95=2.342/2.497ms | raster p50/p90/p95/p99/max=9.832/12.490/12.640/13.022/13.046ms | raster_over_budget=0/240 (0.0%)
    ================================================================
    

    I know you and the flutter engine team have been trying what was possible, and I'm really thankful for that, and the gradient improvements have been quite noticeable too, so I guess that helps a bit.
    I just wonder if there's something us users can do to get work on a modern backend for Windows to start sooner rather than later. This is simply because in Flutter's current state, I am still completely unable to use Impeller for my production apps, it's not even close, screen transitions feel like the app is having 6 heart attacks all in one..
    If the real problem is the backend, is there any reason why a modern backend for Windows is not being prioritized?

  17. gaaclarke commented on Sep 9, 2026

    @gaaclarke
    Member

    The benchmark app runs at 60 fps because it is a minimal repro, in a real app the performance differences add up from the different components in a scene (I'm sure you know this).

    What would be helpful would be a reproduction that more closely represents your exact situation then. We have to balance optimizing contrived benchmarks and real world examples. We're also not going to spend a lot of time trying to get from 16ms frames to 8ms per frame on baseline hardware if the net result for the user is the same. FWIW we use the app wonderous a lot as a standin for a real-world app when evaluating performance. You can also communicate in terms of that if need be.

    If the real problem is the backend, is there any reason why a modern backend for Windows is not being prioritized?

    Skia is built on the same backend (ANGLE) and you are happy with that performance so I don't think that's necessarily the solution. We have the option to productize the vulkan renderer for windows. It is a major investment since it means maintaining our own list of bad-driver-workarounds that already exists in ANGLE. We'd need very compelling evidence to make that investment in implementation, but more importantly in maintenance.

    I don't really understand how a gpu that is supposed to be 8 times less powerful than mine like the Vega 8 is performing so much better than mine.

    For reference, here is the comparison with passmark:

    https://www.videocardbenchmark.net/compare/3895vs3736/Radeon-Vega-8-vs-Radeon-RX-580

  18. Razorex001 commented on Sep 14, 2026

    @Razorex001
    Author

    What would be helpful would be a reproduction that more closely represents your exact situation then. We have to balance optimizing contrived benchmarks and real world examples. We're also not going to spend a lot of time trying to get from 16ms frames to 8ms per frame on baseline hardware if the net result for the user is the same. FWIW we use the app wonderous a lot as a standin for a real-world app when evaluating performance. You can also communicate in terms of that if need be.

    You're right. I'll try to build a closer repro of the type of frontend workload of my app and see if that helps, together with adding more test cases to a bigger benchmark to show a more complete picture of how different things perform. I'll open a new issue in the next days with all that.

    Skia is built on the same backend (ANGLE) and you are happy with that performance so I don't think that's necessarily the solution. We have the option to productize the vulkan renderer for windows. It is a major investment since it means maintaining our own list of bad-driver-workarounds that already exists in ANGLE. We'd need very compelling evidence to make that investment in implementation, but more importantly in maintenance.

    I wouldn't say that I'm "happy" with Skia's performance. I just built my app calibrating it on what Skia's performance allowed because that was the only option given to me with flutter. There were many occasions where I had to make "sacrifices" in the UI of my apps to stay within the performance budget allowed by Skia, so if you asked me if in a perfect world I'd want/need better performance than what Skia allows, that would be a solid yes.

    For reference, here is the comparison with passmark:

    https://www.videocardbenchmark.net/compare/3895vs3736/Radeon-Vega-8-vs-Radeon-RX-580

    I took some time to look into this.

    It's true that my Rx580 has way more power and GPU resources (36 CUs / 2304 shaders, ~6.2 TFLOPS and 256 GB/s GDDR5 bandwidth, versus Vega 8's 8 CUs / 512 shaders, ~2.05 TFLOPS and much lower shared memory bandwidth).
    In a flutter 2d app, my gpu is unlikely to be limited by raw shaders or memory bandwidth. On paper, it should be able to handle most 2d workloads relatively easily, even if the rx580 is not built on the newest architecture.

    Vega however has some internal hardware efficiency improvements that Polaris doesn't have. Some of the more relevant ones are about its pixel engine:

    • DSBR (Draw Stream Binning Rasterizer): It allows for "fetch once, shade once" of pixels through the use of a smart on-chip bin cache and early culling of pixels invisible in a final scene.
    • Vega's pixel engine is a client of the onboard L2 cache, enabling considerable overhead reduction for graphics workloads which perform frequent read-after-write operations.

    And the cpu that comes with the vega 8 is better than mine. This may or may not play some part, I'm not sure.

    Now, I'm no gpu expert, but these differences seem relevant to many things Flutter does, where performance is not just about shader count or memory bandwidth, but also how efficiently specific operations are handled. Given Impeller's current performance on Windows, I feel there should still be a lot that can be improved on the software side so that the work reaching the gpu can be significantly more efficient.

    Whether those improvements come from a Vulkan backend, ANGLE, or Impeller itself, that's a separate matter. I do feel Vulkan has more room for substantial improvements compared to what fighting with GLES/ANGLE could provide, also seeing the link you showed me at the end of the text shadow issue, which was: impeller vulkan vs opengles, where vulkan seems to perform about 40% faster than opengles (I know that's android, but it still seems like a relevant data point to consider for what to expect from a Windows Vulkan implementation, and I would imagine on Windows there's a decent chance that the gains could be even bigger), and also seeing that ANGLE's D3D backend is in maintenance mode.

    And because the rx580 does have raw power but doesn't have these hardware efficiency improvements, it seems to me that the rx580 is a useful gpu to benchmark on, because it can't "make up" for inefficiencies on the software side as easily as newer gpus through their internal hardware optimizations (but of course it's always good to have more data points).

  19. Razorex001 commented on Sep 21, 2026

    @Razorex001
    Author

    What would be helpful would be a reproduction that more closely represents your exact situation then. We have to balance optimizing contrived benchmarks and real world examples. We're also not going to spend a lot of time trying to get from 16ms frames to 8ms per frame on baseline hardware if the net result for the user is the same. FWIW we use the app wonderous a lot as a standin for a real-world app when evaluating performance. You can also communicate in terms of that if need be.

    You're right. I'll try to build a closer repro of the type of frontend workload of my app and see if that helps, together with adding more test cases to a bigger benchmark to show a more complete picture of how different things perform. I'll open a new issue in the next days with all that.

    @gaaclarke Is there any test case that you and the engine team would like to see benchmarked? I have a bigger benchmark app nearly ready with about 51 test cases, just checking some last things and trying to think of which additional test cases could be useful for you guys to include

  20. gaaclarke commented on Sep 21, 2026

    @gaaclarke
    Member

    The way it usually works is we benchmark "real world" example apps. Microbenchmarks are made in response to responding to users "real world" issues where we don't have full reproductions. So there isn't really a gap from our perspective.

  21. SunkenInTime commented on Oct 5, 2026

    @SunkenInTime
    Contributor

    A real-app data point from Icarus, a Flutter desktop strategy editor for Valorant: a map canvas, draggable agent icons, and a sidebar of agent portraits.

    Same app, same machine, release builds, dragging one agent icon across the map for 8 s on a 144 Hz display. Raster time per frame from FrameTiming:

    p50 p99 max
    3.44.4, Skia 1.67 ms 2.88 ms 4.03 ms
    3.47.6, Impeller (OpenGLESSDF) 5.65 ms 8.68 ms 9.96 ms

    With our view-cone overlay on the agent (a large path filled with a radial gradient, clipped to a complex path), Skia is 2.1 ms p50 / 3.6–3.9 ms p99 and Impeller 5.1–5.9 ms p50 / 7.6–8.7 ms p99.

    Where the Impeller time goes:

    • One ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 1, sigmaY: 1)) around an 80% opacity 180×53 thumbnail costs about 1 ms per frame (p50 5.65 → 4.61 ms with enabled: false). It never changes; on Skia it comes from the raster cache.
    • Hiding the map, a static SvgPicture behind a RepaintBoundary, saves only about 0.1 ms more (4.49 ms).
    • In a profile timeline, each frame has about 26 RenderPassGLES::EncodeCommandsInReactor totalling about 4.4 ms, and about 5 Canvas::saveLayer. Besides the blur, the layer tree has one OpacityLayer below 255 and a few anti-aliased clip layers. I couldn't attribute the passes further without a GPU capture.

    Impeller does remove one problem we have on Skia: a few 10–13 ms raster frames per session from shader_compile / cache_miss (Skia GL through ANGLE). But the steady per-frame cost is about 3.4× higher, so we're staying on Skia for now.

    Setup: Windows 11 (build 26200), RTX 4070 (driver 596.49), Ryzen 7 5800X, 1920×1080 at 144 Hz, app window 1584×891. Another application was using about 20% of the GPU during both runs. Frames were driven by synthetic pointer events dispatched once per frame from a frame callback, and timings collected with addTimingsCallback.

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 liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.from: performance templateIssues created via a performance issue templatehas 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