Skip to content

[Windows][Impeller] Nonzero blur in TextStyle.shadows causes severe raster slowdown #190395

Closed

Description

@Razorex001

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:

  1. shadows: null
  2. shadows: [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_NoShadow
  • TextShadowRepro_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

  1. Create a Flutter project:

  2. Replace lib/main.dart with the provided code sample.

  3. Run the Windows application in profile mode using Impeller:

    flutter run -d windows --profile
  4. Maximize the application window.

  5. Press F10 or click Run comparison.

  6. Do not interact with the application until both phases finish.

  7. 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.

DevTools timeline trace

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

  1. suryaKommana2662 commented on Aug 3, 2026

    @suryaKommana2662
    Contributor

    @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 blurRadius is used with TextStyle.shadows on 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 the team-engine for further investigation.

  2. added
    engineflutter/engine related. See also e: labels.
    platform-windowsBuilding on or for Windows specifically
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Aug 3, 2026
  3. added
    P2Important issues not at the top of the work list
    on Aug 3, 2026
  4. gaaclarke commented on Aug 3, 2026

    @gaaclarke
    Member

    hey @flar, you've looked into shadows performance quite a bit, what is your take here?

  5. self-assigned this
    on Aug 4, 2026
  6. gaaclarke commented on Aug 4, 2026

    @gaaclarke
    Member

    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.

  7. gaaclarke commented on Aug 4, 2026

    @gaaclarke
    Member

    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

    • Extend font_glyph_pair.h to include a blur descriptor (sigma).
    • When canvas.cc encounters a text paint with a shadow/mask blur, it requests a blurred glyph variant from glyph_atlas.h.
    • During atlas generation, FreeType/Skia (or an engine raster pass) rasterizes the glyph with padding (P ≥ 3σ) and applies a Gaussian blur before packing the tile into the atlas.
    • Text shadows are drawn in the primary pass as standard atlas quads.

    Trade-offs

    • Pros:
      • Zero extra render passes: Eliminates offscreen render target switches entirely.
      • Seamless pipeline integration: Uses existing glyph atlas quad rendering paths.
    • Cons & Gotchas:
      • Atlas Memory Inflation: Blurring adds 6σ to both dimensions. A 16×16 glyph with σ = 5 expands to a ~46×46 tile (~8× memory area). Large radii can rapidly exhaust the atlas texture.
      • Cache State Explosion: Glyph cache keys expand to include blur_sigma, producing duplicate atlas entries for every distinct blur radius.
      • Overlapping Glyph Artifacts: Pre-blurred glyph quads drawn side-by-side using standard alpha blending (srcOver) cause double-darkened margins where adjacent glyph blur bounding boxes overlap, unlike blurring a unified text run mask.

    2. Blur Batching (Shared Offscreen Render Pass)

    Implementation

    • Rather than invoking a dedicated gaussian_blur_filter_contents.h pass per text run, defer mask blur execution during display list recording/dispatch.
    • Collect all text shadow masks across a scene/frame into a single shared offscreen alpha render target.
    • Run one 2-pass (horizontal + vertical) Gaussian blur over the combined offscreen texture.
    • Composite the combined blurred texture back into the main pass in a single draw call.

    Trade-offs

    • Pros:
      • Drastic pass reduction: Collapses N text shadows (2N + offscreen passes) down to 1 shared offscreen blur pass pair per frame.
      • High visual fidelity: Preserves exact Gaussian blur quality and smooth inter-glyph blur accumulation.
      • No atlas bloat: Keeps the glyph atlas small and clean.
    • Cons & Gotchas:
      • Layering & Z-Ordering Complexity: In Flutter, text shadows must render behind their respective text runs but above underlying background elements. Global batching requires
        careful pass ordering so shadows don't incorrectly render on top of interleaved UI elements.
      • Large Target Sizing: The shared offscreen target must span the bounding box of all visible text shadows, potentially requiring a full-screen offscreen alpha buffer.

    3. Single-Pass Blur Shaders (Analytic / Multi-Tap Shaders)

    Implementation

    • Avoid offscreen render targets entirely by blurring directly in the fragment shader during the main pass.
    • Impeller already uses direct analytic blur shaders for simple vector shapes like rounded rects (solid_rrect_blur_contents.cc) using error functions (erf).
    • For text glyphs (which are arbitrary vector paths), a single-pass fragment shader samples neighboring texels from the standard glyph atlas using a small 1D/2D multi-tap kernel or hardware bilinear filtering offsets.

    Trade-offs

    • Pros:
      • Zero offscreen render passes: Rendered in-line inside the main render pass.
      • Low latency: Eliminates GPU framebuffer switches and pipeline stalls.
    • Cons & Gotchas:
      • Kernel size limits: Wide-kernel Gaussian blurs (σ > 3) require many texture samples per fragment (e.g. 15–25 samples), causing severe GPU texture bandwidth saturation.
      • Complex geometry: Closed-form analytic equations (erf) cannot be derived for arbitrary font glyph geometry per fragment, restricting single-pass techniques to multi-tap atlas sampling.

    4. Signed Distance Fields (SDFs) / Distance Field Shadows

    Implementation

    • Rasterize glyphs into the atlas as Signed Distance Fields rather than 8-bit alpha masks. Impeller has underlying SDF primitives (uber_sdf_contents.h).
    • In an SDF fragment shader, the distance value represents the shortest distance from the pixel to the glyph edge:
      • Crisp text edge: smoothstep(-edge, +edge, distance)
      • Blurred text shadow: smoothstep(-blur_radius, +blur_radius, distance + offset)
    • Standard text and blurred drop shadows can be rendered dynamically from the same SDF atlas entry.

    Trade-offs

    • Pros:
      • Zero extra render passes & zero atlas bloat: One single SDF entry in the atlas handles sharp text, stroked text, and any arbitrary blur radius σ dynamically.
      • Resolution independence: Text scales cleanly without re-rasterizing at different font sizes.
      • Ultra-fast GPU execution: Blurring is just a single smoothstep() instruction in the fragment shader.
    • Cons & Gotchas:
      • SDF Generation Overhead: Generating 2D distance fields on CPU/GPU during atlas population is computationally heavy.
      • Fidelity Loss on Sharp Corners: Complex fonts (e.g., CJK characters or delicate serifs) suffer from rounded corner artifacts unless multi-channel distance fields (MSDF) are used.
      • No Color / Emoji Support: SDFs only work for monochromatic vector glyphs, not COLR/CPAL or bitmap emoji glyphs.

    5. Compute Shader Blur (Threadgroup / Shared Memory)

    Concept & Implementation

    • In standard graphics pipelines, a 2-pass Gaussian blur requires two graphics passes (GaussianBlurFilterContents) with intermediate render targets, pipeline state switches, and texture memory writes.
    • Implementation: Modern GPU APIs (Vulkan, Metal, Direct3D 12) support Compute Shaders. Using GPU Threadgroup / Local Shared Memory (L1/L2 cache), a single Compute Shader dispatch loads a row/column tile into shared memory, performs both horizontal and vertical Gaussian blurs in-place, and outputs the blurred mask directly to a texture.

    Trade-offs

    • Pros:
      • Single Dispatch, Zero Intermediate Passes: Eliminates intermediate render target allocations and graphics pipeline switches.
      • Cache Locality: Performs memory fetches within fast GPU threadgroup memory instead of VRAM.
        • Cons:
      • Requires Compute Shader pipeline support across all Impeller backends (OpenGL ES fallback drivers may lack efficient compute support).

    6. Dual / Kawase Filter Blur (Downsample-Upsample Pyramid)

    Concept & Implementation

    • Popularized in real-time game rendering (e.g. Kawase Blur or Dual Filtering by Marius Gjergji), this technique replaces true Gaussian math with a chain of simple downsample and upsample passes using half-texel offset bilinear sampling.
    • Implementation:
      1. Downsample the mask by 50% using a 4-tap box filter.
      2. Upsample back to target resolution using an 8-tap offset filter.

    Trade-offs

    • Pros:
      • Extremely fast; produces a lush, high-quality soft blur with very few texture taps.
    • Cons:
      • Works best for medium-to-large blur radii (σ > 4); less suitable for tiny 1px blur radiuses.

    7. Retained Layer / DisplayList Sub-Tree Caching

    Concept & Implementation

    Trade-offs

    • Pros:
      • Zero blur passes during animation/scrolling: Reduces raster cost to a simple texture copy while scrolling.
      • Works today in Flutter by placing RepaintBoundary around text/list items with complex shadows.
    • Cons:
      • Consumes GPU VRAM to hold cached item textures.
      • Only works for static text (text content changing every frame will still invalidate the cache).

    8. Vector Shadow Mesh / Dilated Path Geometry

    Concept & Implementation

    • Used by Impeller for Material elevation shadows (shadow_path_geometry.h).
    • Implementation: Instead of blurring a raster image, expand (dilate) the text outline vectors outward and generate a mesh with alpha fading from opacity 1.0 at the glyph border to 0. 0 at the outer offset margin.

    Trade-offs

    • Pros:
      • Rendered as pure vector geometry directly in the main pass (0 extra passes).
    • Cons:
      • Complex text glyphs generate intricate vector contours, making GPU outline dilation and tessellation computationally expensive compared to simple RRects.
  8. Razorex001 commented on Aug 5, 2026

    @Razorex001
    Author

    @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: 1 to 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

    This analysis was generated by GPT-5.6-Sol after inspecting the Flutter framework and engine source at revision a6cfc81a426. It has not been validated by modifying the engine or benchmarking a proposed fix. Statements about the current implementation are based on the source; proposed solutions and explanations of the reproduction are engineering inferences.

    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::AttemptBlurredTextOptimization creates a cache key containing:

    • the effective scale;
    • an identifier for the text;
    • the font;
    • the rounded blur sigma;
    • the shadow color.

    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 TextFrame object:

    int64_t identifier = maybe_glyph.has_value()
        ? maybe_glyph.value().index
        : reinterpret_cast<int64_t>(text_frame.get());

    Therefore, multi-glyph caching currently depends on the same TextFrame object 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 TextFrame supplied 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 MarkFrameStart and MarkFrameEnd.

    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 SkTextBlob objects, but they are converted into Impeller TextFrame objects rather than being rendered by Skia.

    When a text shadow is recorded with Impeller enabled, paragraph_skia.cc executes:

    DlTextImpeller::Make(
        impeller::MakeTextFrameFromTextBlobSkia(blob)
    )

    This constructs a new Impeller TextFrame whenever that paragraph shadow is recorded into a display list.

    The reproduction uses a SingleChildScrollView. Its viewport calls markNeedsPaint() when the scroll offset changes and subsequently paints its child at the new offset. The relevant framework code is in single_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 TextFrame objects. 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 RepaintBoundary can help by retaining a display list containing the original TextFrame, 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:

    • the glyph sequence;
    • shaping;
    • line wrapping;
    • glyph positions;
    • the final line’s bounds.

    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:

    1. downsample;
    2. vertical blur;
    3. horizontal blur.

    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, CalculateScale returns a downsample scale of 1.0. In other words, small blurs do not actually become smaller during the downsample stage.

    There is already a TODO in CalculateDownsamplePassArgs stating that the downsample pass could be skipped when the scale is 1.0 and 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:

    • sigma;
    • color and opacity;
    • clip;
    • transform;
    • blend mode;
    • stacking position.

    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:

    • a single small two-dimensional kernel for very small sigma;
    • a reduced separable kernel with efficiently reused transient targets;
    • a backend-specific path selected using measured crossover points.

    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 OpenGLESSDF backend 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:

    • giving multi-glyph TextFrames a stable content identity;
    • retaining reusable entries under a bounded memory budget;
    • avoiding aggressive creation of cache entries for content that changes every frame;
    • allowing stable shaped runs to be shared across rerecorded display lists.

    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 TextFrame object 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:

    • every glyph ID;
    • glyph positions;
    • every font or fallback font used by the frame;
    • scale and subpixel behavior;
    • stroke properties where applicable;
    • blur sigma;
    • shadow color;
    • other properties affecting the mask.

    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:

    • skip the downsample pass when its scale would be 1.0 and padding permits it;
    • use a kernel specialized for the small-radius range;
    • avoid unnecessary render-target and command-buffer transitions;
    • reuse transient render targets safely;
    • preserve the combined text-frame mask so adjacent glyphs do not acquire incorrect overlap artifacts.

    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:

    • compatible-operation batching;
    • better transient texture reuse;
    • compute implementations on Metal and Vulkan when benchmarks justify them;
    • fragment-pass implementations on GLES;
    • approximate large-radius filters where visual compatibility permits them.

    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:

    Text shadow requested
            │
            ├── Stable run/text-frame cache hit
            │       └── Draw cached texture with the current transform
            │
            └── Cache miss
                    ├── Small sigma
                    │       └── Specialized low-overhead blur of combined mask
                    │
                    └── Moderate or large sigma
                            └── Optimized generic backend-specific blur
                                    │
                                    └── Cache result when reuse is likely
    

    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.

  9. gaaclarke commented on Aug 6, 2026

    @gaaclarke
    Member

    We actually are trying to cache shadows already here, I kind of remember this:

  10. Razorex001 commented on Aug 6, 2026

    @Razorex001
    Author

    @gaaclarke Correct. I have also tried wrapping each _TextGroup in a RepaintBoundary in the code sample that I provided, modifying this section only:

    Modified code
    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 RepaintBoundary(
                          child: _TextGroup(
                            index: column * _groupsPerColumn + row,
                            useBlurredTextShadow: useBlurredTextShadow,
                          ),
                        );
                      }),
                    ),
                  ),
                );
              }),
            ),
          ),
        );
      }
    }

    The results from this test:

    Scenario Frames FPS UI p50 UI p95 Raster p50 Raster p90 Raster p95 Raster p99 Raster max Raster over budget
    No shadow 239 59.50 0.689 ms 0.757 ms 2.617 ms 2.770 ms 2.840 ms 4.482 ms 4.655 ms 0/239 (0.0%)
    Shadow blur 1 234 58.25 0.701 ms 0.780 ms 4.430 ms 15.086 ms 20.390 ms 27.568 ms 32.060 ms 18/234 (7.7%)

    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.

  11. Razorex001 commented on Aug 20, 2026

    @Razorex001
    Author

    I re-ran the mini test app after these changes, here's the current performance that I'm seeing on Impeller:

    Mode Frames FPS UI p50 UI p95 Raster p50 Raster p90 Raster p95 Raster p99 Raster max Raster over budget
    No shadow 240 59.75 2.436 ms 2.561 ms 2.812 ms 2.937 ms 3.015 ms 3.123 ms 3.348 ms 0 / 240 (0.0%)
    Shadow blur 1 238 59.25 4.628 ms 4.941 ms 5.016 ms 11.140 ms 13.098 ms 18.417 ms 20.809 ms 11 / 238 (4.6%)

    While the improvement in text shadow blur performance is definitely visible after the changes from #190681, it's worth mentioning that:

    1. It's still not performing as good as Skia, which makes shadow blur for text still unusable in production apps.
    2. Even more concerning is the performance of the "No shadow" case compared to Skia, which has both UI and Raster times visibly higher on Impeller compared to Skia.

    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

  12. gaaclarke commented on Aug 20, 2026

    @gaaclarke
    Member
    • It's still not performing as good as Skia, which makes shadow blur for text still unusable in production apps.
    • Even more concerning is the performance of the "No shadow" case compared to Skia, which has both UI and Raster times visibly higher on Impeller compared to Skia.

    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.

  13. github-actions commented on Sep 3, 2026

    @github-actions

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

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

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.from: performance templateIssues created via a performance issue templateplatform-windowsBuilding on or for Windows specificallyteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions