Repository navigation
Description
Steps to reproduce
Summary
On Windows, with Impeller, there are some issues with BackdropBlur:
- Performance issue: The raster performance on Impeller is significantly worse than how it is on Skia for all tested cases except for C and G (grouped, unbounded) that are the only cases that display a similar performance to Skia
Performance issue: Bounded blur loses almost all raster performance benefit from(WAI, clarified docstring: Clarifies blur group optimization #191613 )BackdropGroup/BackdropFilter.groupedVisual bug: Bounded blur is rendered incorrectly when it is used inside(I verified this is covered under [Impeller] ImageFilterConfig.blur(bounded: true) in BackdropFilter does not apply blur in a ListView #184447)SingleChildScrollView. This seems similar to [Impeller] ImageFilterConfig.blur(bounded: true) in BackdropFilter does not apply blur in a ListView #184447 (here the issue was happening when using it in aListView) and to [impeller] FadeTransition wrapping CupertinoPopupSurface breaks appearance on Impeller #182066
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
- Run the app on windows on latest channel master version in profile mode on Skia with
flutter run -d windows --profile --no-enable-impeller - Maximize the app and press F10, do not interact with the app and let it complete the 8 automatic tests
- See in the app the results, which you can find also in the terminal, and annotate them
- Run the app on windows again, but this time on Impeller with
flutter run -d windows --profile - Maximize the app and press F10, do not interact with the app and let it complete the 8 automatic tests
- See in the app the results, which you can find also in the terminal, and annotate them
- 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 withSingleChildScrollView. Note that In this test app I usedSingleChildScrollView, but I see that for [Impeller] ImageFilterConfig.blur(bounded: true) in BackdropFilter does not apply blur in a ListView #184447 it was happening withListView. This visual issue does not happen for bounded test cases F and H that useTransform.translateinside 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
gaaclarke commentedon Aug 20, 2026
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.
gaaclarke commentedon Aug 21, 2026
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.
Razorex001 commentedon Aug 21, 2026
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:
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.
gaaclarke commentedon Aug 24, 2026
Spawned issue for this bug on its own: #191611
gaaclarke commentedon Aug 24, 2026
This is working as intended. I've filed a PR that clarifies this: #191613
gaaclarke commentedon Aug 24, 2026
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.
Razorex001 commentedon Aug 24, 2026
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
5 remaining items
Clarifies blur group optimization (flutter#191613)
Switches windows to opengles 3 (flutter#191516)
gaaclarke commentedon Sep 4, 2026
#191838 performs an optimization for blur groups that will affect this.
Razorex001 commentedon Sep 4, 2026
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.
gaaclarke commentedon Sep 9, 2026
Here's the results on a Radeon Vega 8 at 63b9518, which is about 1/6 as powerful as the OP's gpu:
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:
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 )
Razorex001 commentedon Sep 9, 2026
@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:
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?
gaaclarke commentedon Sep 9, 2026
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.
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.
For reference, here is the comparison with passmark:
https://www.videocardbenchmark.net/compare/3895vs3736/Radeon-Vega-8-vs-Radeon-RX-580
Razorex001 commentedon Sep 14, 2026
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.
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.
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:
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).
Razorex001 commentedon Sep 21, 2026
@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
gaaclarke commentedon Sep 21, 2026
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.
SunkenInTime commentedon Oct 5, 2026
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: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:
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 withenabled: false). It never changes; on Skia it comes from the raster cache.SvgPicturebehind aRepaintBoundary, saves only about 0.1 ms more (4.49 ms).RenderPassGLES::EncodeCommandsInReactortotalling about 4.4 ms, and about 5Canvas::saveLayer. Besides the blur, the layer tree has oneOpacityLayerbelow 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.改(性能): 转场期间把首页悬浮钮与回今日钮冻结成预糊位图