Repository navigation
Description
Summary
On Windows, with Impeller, adding a nonzero blur radius to TextStyle.shadows causes a severe and sustained raster slowdown while scrolling even a basic text-only scene.
The Code Sample that I provided measures two phases automatically:
shadows: nullshadows: [Shadow(color: Colors.black, blurRadius: 1)]
This mini Flutter app compares Windows rendering performance when scrolling text with and without a small blurred shadow. It automatically runs both scenarios at a fixed scroll speed, 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 in the app, and prints the same summary to the terminal. It also emits the following timeline events for optional DevTools verification:
TextShadowRepro_NoShadowTextShadowRepro_BlurOne
In my tests, with Impeller, raster p50 increases from 3.609 ms to 98.236 ms, and FPS fall from 59.5 to 10.25. Every blur-1 frame exceeds the 60 Hz raster budget.
Skia completes both phases at 59.5 FPS.
Steps to reproduce
-
Create a Flutter project:
-
Replace
lib/main.dartwith the provided code sample. -
Run the Windows application in profile mode using Impeller:
flutter run -d windows --profile -
Maximize the application window.
-
Press
F10or click Run comparison. -
Do not interact with the application until both phases finish.
-
Repeat using Skia:
flutter run -d windows --profile --no-enable-impeller
Expected results
A text shadow with a small nonzero blur should have a significantly more reasonable rendering cost, and should not reduce scrolling from 60 FPS to approximately 10 FPS.
Actual results
Tests were performed using a maximized 1920×1017 physical viewport, DPR 1, a 60 Hz display, and deterministic scrolling at 1400 logical pixels per second.
| Renderer | Scenario | Frames | Observed FPS | UI p50 / p95 | Raster p50 / p90 / p95 / p99 / max | Raster over budget |
|---|---|---|---|---|---|---|
| Impeller | No shadow | 239 | 59.50 | 2.375 / 2.487 ms | 3.609 / 3.822 / 3.858 / 4.006 / 4.222 ms | 0/239 (0%) |
| Impeller | Shadow blur 1 | 42 | 10.25 | 5.035 / 6.033 ms | 98.236 / 109.488 / 114.297 / 118.276 / 118.531 ms | 42/42 (100%) |
| Skia | No shadow | 239 | 59.50 | 1.245 / 1.357 ms | 1.596 / 1.767 / 1.841 / 1.902 / 1.921 ms | 0/239 (0%) |
| Skia | Shadow blur 1 | 239 | 59.50 | 2.564 / 2.724 ms | 2.298 / 2.467 / 2.490 / 2.592 / 2.755 ms | 0/239 (0%) |
At raster p50:
- Impeller blur 1 is approximately 27× slower than Impeller without the shadow.
- Impeller blur 1 is approximately 43× slower than the equivalent Skia workload.
Impeller identifies the active backend as:
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(127)] Using the Impeller rendering backend (OpenGLESSDF).
As a secondary observation, the test without shadow also has a higher raster p50 with Impeller than with Skia. Impeller: 3.609 ms, vs Skia: 1.596 ms, approximately 2.26× higher. Both remain within the 60 Hz frame budget, so this does not cause the severe visible stuttering by itself, but I thought it was still worth mentioning. However, the primary issue reported here is the severe raster issue produced by adding a nonzero text-shadow blur.
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);
},
),
);
}
}
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();
late final FrameComparisonRunner _runner;
bool _useBlurredTextShadow = false;
bool _running = false;
String _status = 'Maximize the window, then run the comparison.';
List<ComparisonResult>? _results;
@override
void initState() {
super.initState();
_runner = FrameComparisonRunner(vsync: this, controller: _scrollController);
}
@override
void dispose() {
_runner.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _runComparison() async {
if (_running) {
return;
}
if (!_scrollController.hasClients ||
!_scrollController.position.hasContentDimensions ||
_scrollController.position.maxScrollExtent <= 0) {
setState(() => _status = 'The scroll view is not ready. Try again.');
return;
}
setState(() {
_running = true;
_results = null;
_status =
'Running automatically: no shadow, 1-second pause, then blur 1.';
});
try {
final List<ComparisonResult> results = await _runner.run(
view: View.of(context),
onShadowChanged: (bool enabled) {
if (mounted) {
setState(() => _useBlurredTextShadow = enabled);
}
},
);
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(0xff080808),
body: Column(
children: <Widget>[
SizedBox(height: 190, child: _buildControls()),
Expanded(
child: _TextOnlyScrollScene(
controller: _scrollController,
useBlurredTextShadow: _useBlurredTextShadow,
),
),
],
),
),
),
);
}
Widget _buildControls() {
return ColoredBox(
color: const Color(0xff202020),
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(
'Windows text-shadow comparison',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'Automatic no-shadow / blur-1 comparison',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
],
),
),
Text(
kProfileMode ? 'PROFILE MODE' : 'NOT PROFILE',
style: TextStyle(
color: kProfileMode
? const Color(0xff65e6a8)
: const Color(0xffffad66),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(width: 16),
const Text('Performance overlay'),
Switch(
value: widget.showPerformanceOverlay,
onChanged: _running
? null
: widget.onPerformanceOverlayChanged,
),
const SizedBox(width: 12),
FilledButton(
onPressed: _running ? null : _runComparison,
child: const Text('Run comparison (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 overlay off for the numeric run.'
: '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.45,
),
);
}
}
class _TextOnlyScrollScene extends StatelessWidget {
const _TextOnlyScrollScene({
required this.controller,
required this.useBlurredTextShadow,
});
static const int _columnCount = 7;
static const int _groupsPerColumn = 28;
final ScrollController controller;
final bool useBlurredTextShadow;
@override
Widget build(BuildContext context) {
// Seven columns keep the visible text workload consistent in both phases.
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: controller,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List<Widget>.generate(_columnCount, (int column) {
return Expanded(
child: Padding(
padding: const EdgeInsets.all(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List<Widget>.generate(_groupsPerColumn, (int row) {
return _TextGroup(
index: column * _groupsPerColumn + row,
useBlurredTextShadow: useBlurredTextShadow,
);
}),
),
),
);
}),
),
),
);
}
}
class _TextGroup extends StatelessWidget {
const _TextGroup({required this.index, required this.useBlurredTextShadow});
final int index;
final bool useBlurredTextShadow;
@override
Widget build(BuildContext context) {
// The first phase assigns null; the second assigns a blur-1 shadow.
final List<Shadow>? shadows = useBlurredTextShadow
? const <Shadow>[Shadow(color: Colors.black, blurRadius: 1)]
: null;
return SizedBox(
height: 230,
child: Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Synthetic text item $index',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: shadows,
),
),
Row(
children: <Widget>[
Text(
'${8 + index % 14}:30',
style: TextStyle(
color: const Color(0xff43ddff),
fontSize: 9,
fontWeight: FontWeight.bold,
shadows: shadows,
),
),
const Spacer(),
Text(
'Ep ${1 + index % 24}',
style: TextStyle(
color: Colors.white70,
fontSize: 9,
shadows: shadows,
),
),
],
),
],
),
),
),
);
}
}
/// Runs the no-shadow/blur-1 sequence and summarizes Flutter's engine-reported
/// [FrameTiming] values for each measurement window.
class FrameComparisonRunner {
FrameComparisonRunner({
required this._vsync,
required this._controller,
});
static const double _scrollSpeed = 1400;
static const Duration _unmeasuredScrollDuration = 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;
Ticker? _activeTicker;
bool _running = false;
bool _disposed = false;
Future<List<ComparisonResult>> run({
required FlutterView view,
required ValueChanged<bool> onShadowChanged,
}) async {
if (_running || _disposed) {
return const <ComparisonResult>[];
}
_running = true;
try {
final ComparisonResult noShadow = await _runPhase(
view: view,
label: 'No shadow',
timelineLabel: 'TextShadowRepro_NoShadow',
useBlurredTextShadow: false,
onShadowChanged: onShadowChanged,
);
_controller.jumpTo(0);
await Future<void>.delayed(_interPhasePause);
final ComparisonResult blurOne = await _runPhase(
view: view,
label: 'Shadow blur 1',
timelineLabel: 'TextShadowRepro_BlurOne',
useBlurredTextShadow: true,
onShadowChanged: onShadowChanged,
);
final List<ComparisonResult> results = <ComparisonResult>[
noShadow,
blurOne,
];
_printResults(results);
return results;
} finally {
_running = false;
}
}
Future<ComparisonResult> _runPhase({
required FlutterView view,
required String label,
required String timelineLabel,
required bool useBlurredTextShadow,
required ValueChanged<bool> onShadowChanged,
}) async {
onShadowChanged(useBlurredTextShadow);
await SchedulerBinding.instance.endOfFrame;
final double extent = _controller.position.maxScrollExtent;
_controller.jumpTo(0);
await SchedulerBinding.instance.endOfFrame;
await _drive(duration: _unmeasuredScrollDuration, extent: extent);
_controller.jumpTo(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(
timelineLabel,
arguments: <String, Object>{
'shadow_enabled': useBlurredTextShadow,
'shadow_blur_radius': useBlurredTextShadow ? 1 : 0,
},
);
try {
await _drive(
duration: _measurementDuration,
extent: extent,
onFirstFrame: (int value) => startMicros = value,
onLastFrame: (int value) => endMicros = value,
);
// FrameTiming batches can arrive after their frames have completed.
await Future<void>.delayed(_timingDrainDuration);
} finally {
task.finish();
SchedulerBinding.instance.removeTimingsCallback(collect);
}
// Keep only frames whose build started inside the driven measurement.
final List<FrameTiming> measurementWindow =
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));
});
return ComparisonResult.fromTimings(
label: label,
timings: measurementWindow,
measurementDuration: _measurementDuration,
refreshRate: view.display.refreshRate,
);
}
/// Moves the scroll offset once per ticker callback. The delta cap prevents
/// a delayed callback from producing one abnormally large scroll jump.
Future<void> _drive({
required Duration duration,
required double extent,
ValueChanged<int>? onFirstFrame,
ValueChanged<int>? onLastFrame,
}) async {
final Completer<void> done = Completer<void>();
Duration? previous;
double direction = 1;
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);
}
double target =
_controller.offset + _scrollSpeed * direction * deltaSeconds;
if (target >= extent) {
target = extent;
direction = -1;
} else if (target <= 0) {
target = 0;
direction = 1;
}
_controller.jumpTo(target);
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 text-shadow comparison ===');
debugPrint(
'mode=${kProfileMode ? 'profile' : 'NOT_PROFILE'} '
'speed=${_scrollSpeed.round()}px/s',
);
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(20)} '
'${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) / 1000,
p90: _percentile(sorted, 0.90) / 1000,
p95: _percentile(sorted, 0.95) / 1000,
p99: _percentile(sorted, 0.99) / 1000,
max: sorted.last / 1000,
);
}
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
This was collected when running the app with `flutter run -d windows --profile` which uses Impeller.Video demonstration
Video demonstration
test-IMPELLER.mp4
What target platforms are you seeing this bug on?
Windows
OS/Browser name and version | Device information
Environment
flutter --version
Flutter 3.47.0-1.0.pre-323 • channel master • https://github.com/flutter/flutter.git
Framework • revision 421defc (14 hours ago) • 2026-08-01 09:16:00 -0400
Engine • hash b0fdede3ee25badfe93316fc2c64850866c6913d (revision 421defc) (13 hours ago) • 2026-08-01 13:16:00.000Z
Tools • Dart 3.14.0 (build 3.14.0-82.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
No response
Flutter Doctor output
Doctor output
flutter doctor -v
[√] Flutter (Channel master, 3.47.0-1.0.pre-323, on Microsoft Windows [Version 10.0.19045.7548], locale it-IT) [3,0s]
• Flutter version 3.47.0-1.0.pre-323 on channel master at C:\src\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 421defc7a7 (14 hours ago), 2026-08-01 09:16:00 -0400
• Engine revision 421defc7a7
• Dart version 3.14.0 (build 3.14.0-82.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) [3,0s]
[√] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [3,4s]
• 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 [130ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.14.36) [128ms]
• Visual Studio at C:\Program Files\Microsoft Visual Studio\2022\Community
• Visual Studio Community 2022 version 17.14.37502.11
• Windows 10 SDK version 10.0.26100.0
[√] Connected device (3 available) [297ms]
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [Version 10.0.19045.7548]
• Chrome (web) • chrome • web-javascript • Google Chrome 150.0.7871.187
• Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.105
[√] Network resources [694ms]
• All expected network resources are available.
• No issues found!
Activity
suryaKommana2662 commentedon Aug 3, 2026
@Razorex001 Thanks for the detailed report, reproduction code, and performance measurements. I reviewed the reported behavior and the related issue #164438. While both issues involve shadows and Impeller, #190395 specifically reports a severe raster performance regression when a nonzero
blurRadiusis used withTextStyle.shadowson Windows. The report also demonstrates a significant performance difference between Impeller and Skia and confirms that the issue persists on the master channel. I am keeping this issue open and routing it to theteam-enginefor further investigation.gaaclarke commentedon Aug 3, 2026
hey @flar, you've looked into shadows performance quite a bit, what is your take here?
gaaclarke commentedon Aug 4, 2026
Here is the renderdoc capture for the blur case:
textblur.zip.zip
The slowdown is because the blur of the text is falling down the generic gaussian blur path which is multiple render passes (down sample, blur vertical, blur horizontal). One of those frames has 85 render passes. In order to speed this up we'd have to come up with some way to simplify that calculation like rrect_blur, cache the blurred text to the glyph atlas, or maybe batch the blurs somehow.
@Razorex001 can you provide some context for how this is practically affecting you? That can help us prioritize and guide a remedy.
gaaclarke commentedon Aug 4, 2026
I poked around with gemini and brainstormed options for solving this. It's a bit long winded, but I think it's a good overview of the solution space.
Text shadow optimization options
Details
1. Blurred Glyph Atlas / Cache
Implementation
Trade-offs
2. Blur Batching (Shared Offscreen Render Pass)
Implementation
Trade-offs
careful pass ordering so shadows don't incorrectly render on top of interleaved UI elements.
3. Single-Pass Blur Shaders (Analytic / Multi-Tap Shaders)
Implementation
Trade-offs
4. Signed Distance Fields (SDFs) / Distance Field Shadows
Implementation
Trade-offs
5. Compute Shader Blur (Threadgroup / Shared Memory)
Concept & Implementation
Trade-offs
• Cons:
6. Dual / Kawase Filter Blur (Downsample-Upsample Pyramid)
Concept & Implementation
Trade-offs
7. Retained Layer / DisplayList Sub-Tree Caching
Concept & Implementation
rendered GPU texture of the text item + shadow.
Trade-offs
8. Vector Shadow Mesh / Dilated Path Geometry
Concept & Implementation
Trade-offs
Razorex001 commentedon Aug 5, 2026
@gaaclarke Thank you very much for investigating this issue so fast. I'm genuinely impressed by the speed at which you guys work on Flutter.
In my app, there's a screen with some scrollable columns of cards with unpredictable images in them, so I was experimenting with things that could improve text visibility. I was trying something like
Shadow(color: Colors.black, blurRadius: 4)and different values for the blur, as well as other things like that.I also have another app that has AI text streaming in it which uses this same blurRadius, I have not tested it with impeller as I have not been working on this app in the last couple weeks, but for sure the problem happens there too as it uses the same
Shadow(color: Colors.black, blurRadius: 4)When I tested that part of my app on Impeller, I saw the massive performance decrease and had to spend quite some time trying to figure out which part of my scene was causing the performance issue, so that I could isolate the problem and write the issue here.
The sample code that I provided uses
blurRadius: 1to show that even a very small nonzero blur triggers the performance issue.I guess in my case the intention was essentially to add a subtle dark halo around the glyphs, so that the text remains visually somewhat separated from bright and complex parts of the underlying image.
I'm not sure about which solution to fix the issue may be best. Caching surely helps in many cases but alone it may not be the proper universal fix because as you noted text content changing every frame will invalidate the cache (imagine AI streaming text).
I asked GPT 5.6-Sol in Codex to have a look at this specific issue in the flutter code, I'll just write what its analysis result is below and it's up to you to decide if it can be useful or not, I personally am not competent enough to be able to say how accurate it is or what's the best solution. I wish I could help more.
AI-generated technical analysis by GPT-5.6-Sol
Current implementation
Impeller already contains a dedicated
TextShadowCache. It was introduced in #166228, so adding an engine-level text-shadow cache from scratch would not by itself be a new solution.When a blurred text frame is drawn,
Canvas::AttemptBlurredTextOptimizationcreates a cache key containing:For a single glyph, such as an icon, the identifier is the glyph ID. This allows separate instances of the same glyph to share a cached shadow.
For multi-glyph text, however, the identifier is the address of the
TextFrameobject:Therefore, multi-glyph caching currently depends on the same
TextFrameobject surviving across frames. It is not content-addressed by the shaped glyphs and their positions.The cached result is not an independently blurred glyph atlas. It is the output of blurring the complete
TextFramesupplied to that draw operation. This is beneficial for visual correctness because glyph masks within that frame are combined before the result is cached.The cache supports translation changes: on a hit, it clones the cached entity and adjusts its transform. Scrolling can therefore reuse a cached shadow if the same text-frame identity remains alive.
Cache entries are marked unused at the start of every frame and are removed at frame end if they were not referenced during that frame. See
MarkFrameStartandMarkFrameEnd.Why the reproduction may miss the existing cache
Flutter currently uses SkParagraph as its paragraph-layout frontend even when Impeller is the renderer. SkParagraph supplies shaped
SkTextBlobobjects, but they are converted into ImpellerTextFrameobjects rather than being rendered by Skia.When a text shadow is recorded with Impeller enabled,
paragraph_skia.ccexecutes:This constructs a new Impeller
TextFramewhenever that paragraph shadow is recorded into a display list.The reproduction uses a
SingleChildScrollView. Its viewport callsmarkNeedsPaint()when the scroll offset changes and subsequently paints its child at the new offset. The relevant framework code is insingle_child_scroll_view.dart.Because the reproduction does not isolate its text groups with repaint boundaries, scrolling appears capable of rerecording the text and producing new multi-glyph
TextFrameobjects. Those new object addresses would not match the previous frame’s cache keys, even though the strings, fonts and styles are unchanged.This is an inference from the source rather than an instrumented cache-hit measurement, but it is consistent with the sustained blur passes seen in the RenderDoc capture.
A
RepaintBoundarycan help by retaining a display list containing the originalTextFrame, but that is a workaround with its own layer and memory costs. It should not be necessary for avoiding catastrophic performance when using an ordinary text shadow.What happens with streaming text
Actively streaming text represents a real cache-miss case rather than merely an identity problem.
Each newly appended token can change:
A texture containing the previously blurred paragraph cannot represent the updated paragraph. Even an ideal content-addressed cache must render genuinely new content somewhere.
Previous completed chat messages can remain retained and cached, especially when individual list items are repaint boundaries. The actively growing message, however, requires an efficient uncached path.
Partial reuse may be possible. For example, if completed lines are emitted as stable text blobs, a content-aware run-level cache could reuse those lines while rebuilding only the changing tail. This is not implemented currently, and line wrapping means that the boundary between stable and changing content must be handled carefully.
Therefore caching is an important complementary optimization, but it cannot be the fundamental performance guarantee for dynamic text.
Current uncached blur path
Impeller’s generic Gaussian blur is documented as a three-stage process:
It already uses a separable Gaussian kernel and a hardware-linear-interpolation optimization that approximately halves the required texture samples. See
gaussian_blur_filter_contents.h.However, for sigma values up to 4,
CalculateScalereturns a downsample scale of1.0. In other words, small blurs do not actually become smaller during the downsample stage.There is already a TODO in
CalculateDownsamplePassArgsstating that the downsample pass could be skipped when the scale is1.0and the necessary transparent gutter has already been acquired.That optimization alone would not eliminate the problem because the two directional blur passes and the input snapshot would remain. It nevertheless identifies unnecessary work in exactly the radius range used by this report.
The Gaussian implementation also uses three separate command buffers. The source explains that this is intentional because combining the work caused device-lost errors on some older Adreno devices. A universal optimization therefore needs to respect backend-specific stability constraints.
Assessment of the proposed directions
Blurred glyph atlas
A blurred glyph atlas could be effective for isolated or repeatedly used glyphs, but it is not an ideal universal representation.
Its key would need to include blur sigma in addition to the existing font, scale, glyph and subpixel properties. This would multiply atlas entries and padding requirements.
More importantly, blurring and compositing each glyph independently can produce different results where adjacent glyph halos overlap. Caching the combined text-frame mask preserves those interactions better.
Impeller’s existing special treatment for single-glyph shadows is consequently a reasonable place for glyph-level sharing. Multi-glyph text is better cached at a run or text-frame level.
Blur batching
Batching compatible shadows could reduce the number of render targets and passes. It is not safe to collect every text shadow into one global mask, because shadows can differ in:
Combining masks before blurring can also produce a different result from blurring and compositing them independently.
Batching is therefore best viewed as a render-graph optimization for compatible adjacent work, not the primary representation for all text shadows.
Small-radius shader
A specialized path for very small sigma values is highly relevant to this report.
Directly sampling the normal glyph atlas is not completely straightforward. Glyph tiles need sufficient transparent padding, and blurring glyph quads independently can change overlapping-halo behavior.
A safer specialized implementation would operate on the combined text-frame mask. Possible implementations include:
The correct choice should be benchmarked on the Windows GLES backend rather than assumed.
SDF shadows
The normal Impeller text pipeline currently uses alpha-bitmap and color-bitmap glyph atlases. The
OpenGLESSDFbackend name does not mean ordinary text is already represented as signed-distance-field glyphs.An SDF softness function is also not mathematically equivalent to a Gaussian blur. It may be visually acceptable for some use cases, but fine fonts, interior contours, CJK text and emoji make it unsuitable as the universal implementation of
TextStyle.shadows.Compute shaders
Impeller supports compute operations on Metal and Vulkan, but its GLES backend explicitly reports that compute is unsupported. See
CapabilitiesGLES::SupportsCompute.The reported Windows configuration is using Impeller’s OpenGLES backend, so a compute-only solution would not fix this case.
Compute may improve generic blur on supported backends, but it still needs an optimized fragment/render-pass fallback. It also does not automatically guarantee that a two-dimensional blur requires no intermediate storage or synchronization.
Kawase or dual filtering
Kawase-style filtering is useful for larger approximate soft blurs. It is less suitable for a precise radius-1 shadow and does not directly address the excessive fixed overhead exposed here.
It could supplement the large-radius generic path if Flutter’s visual compatibility requirements allow the approximation.
Retained caching
Caching is already present and should be improved rather than duplicated.
The most useful improvements appear to be:
TextFrames a stable content identity;A whole-paragraph cache may be too coarse. A shaped text-blob or text-run cache can preserve correct inter-glyph blurring while offering finer reuse for multiline and partially changing text.
Vector shadow geometry
Generating expanded fading geometry from glyph outlines is plausible for stylized outlines or specialized effects. Arbitrary font contours can be extremely complex, making this unlikely to be the best general implementation of text blur.
Recommended solution
The strongest solution is a layered improvement to the existing implementation.
1. Improve the existing cache identity
Multi-glyph shadows should not depend only on the
TextFrameobject address.A stable identity could be propagated from the shaped
SkTextBlob, or derived from the actual text-frame contents. A correct content key would need to represent the rendered result, including:Hashing only the original string would not be sufficient because shaping and font fallback affect the rendered mask.
The cached object should remain the combined text-frame shadow rather than a collection of independently blurred glyphs.
This change would allow logically unchanged text to reuse its shadow even when scrolling or another ancestor causes the display list to be recorded again.
2. Use an adaptive, bounded retention policy
The current cache retains an entry while it is referenced every frame and removes it immediately after a frame in which it is unused.
A byte-budgeted LRU policy could retain recently visible text long enough for scrolling back to reuse it, while limiting GPU memory. The policy could also require evidence of reuse before retaining a large texture for longer.
Actively streaming paragraphs should not continually populate a long-lived cache with one-use intermediate versions. Detecting repeated misses or requiring a second use before extended retention could reduce cache churn.
3. Create a genuinely cheap small-blur miss path
Caching cannot help the changing tail of streaming text, cold entries or animated content. The uncached path must therefore be fast enough on its own.
For small sigma values, the implementation should avoid the full general pipeline wherever possible:
1.0and padding permits it;This is probably the most important universal part of the fix because it covers both the reproduction’s cold/cache-miss frames and genuinely dynamic text.
4. Retain the optimized general fallback
Moderate and large blurs should continue to use an optimized separable/downsampled pipeline. Further improvements can include:
These backend-specific improvements should supplement, rather than replace, the cache and small-radius path.
5. Treat partial streaming reuse as an additional optimization
If stable shaped runs or completed lines can be identified across successive paragraph versions, they could reuse cached shadows while only the changing tail is rebuilt.
This would be valuable for AI streaming, subtitles and editors, but it should not be required for acceptable performance. Wrapping and reshaping can invalidate supposedly stable regions, so the dynamic blur path must remain the baseline.
Overall conclusion
A cache-only fix is insufficient, but the current problem is not simply that Impeller lacks caching. Impeller already caches complete blurred text frames; its current multi-glyph identity appears too fragile when display lists are rerecorded.
The recommended architecture is therefore:
For the scrolling reproduction, improving cache identity may eliminate much of the repeated work. For continuously changing text, the specialized and generic miss paths remain essential. Both areas should therefore be addressed; neither caching nor a new blur algorithm alone is a complete solution.
gaaclarke commentedon Aug 6, 2026
We actually are trying to cache shadows already here, I kind of remember this:
flutter/engine/src/flutter/impeller/entity/contents/text_shadow_cache.h
Line 31 in 1abf8f4
Razorex001 commentedon Aug 6, 2026
@gaaclarke Correct. I have also tried wrapping each
_TextGroupin aRepaintBoundaryin the code sample that I provided, modifying this section only:Modified code
The results from this test:
This is still far too expensive even for a 60 fps target, let alone 120, but it shows what retaining and reusing unchanged painted content can provide. It also somewhat confirms Sol’s analysis that scrolling was recreating multi-glyph
TextFrames and missing the existing text-shadow cache.Adds benchmark for windows shadow text. (flutter#190861)
Started caching text shadows by content. (flutter#190681)
Razorex001 commentedon Aug 20, 2026
I re-ran the mini test app after these changes, here's the current performance that I'm seeing on Impeller:
While the improvement in text shadow blur performance is definitely visible after the changes from #190681, it's worth mentioning that:
flutter --version
Flutter 3.48.0-1.0.pre-237 • channel master • https://github.com/flutter/flutter.git
Framework • revision 3ce270e (3 days ago) • 2026-08-17 08:05:13 +0000
Engine • hash d1f5ddb72d2dba68881ca4da1acefbba6bce438b (revision 3ce270e) (3 days 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
gaaclarke commentedon Aug 20, 2026
Highlighting one specific use case where impeller is slower doesn't mean everything is slower. Here are our benchmarks with a real world app, skia vs impeller vulkan on android: benchmarks. Here is impeller vulkan vs opengles.
The reason the ceiling is lower on impeller is that impeller is designed to use a fixed set of shaders, whereas skia is generating shaders dynamically at run time which results in shader jank. Impeller on Windows has some unique challenges though and it's not as mature so we'll have to continue to address problems as they arise. I appreciate the reports you've given so far to help out with that effort.
github-actions commentedon Sep 3, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.