Skip to content

[Impeller] Drawing a lot of large circles corrupts further rendering #184091

Closed

Description

@flar

Steps to reproduce

Compile and run the following example. The results I describe below are from running this app on my Macbook Pro with M1 Max. The slider and textfield allow you to adjust the radius. Clicking the "Turn" button will initiate a 5 full 360 degree turn animation that demonstrates if the rendering is smooth at that radius size.

On Skia:

  • the app renders fine at the default setting of 150 radius
  • the app will continue to render fine up to around 200 radius
  • exceeding 250 or so radius will start to have stuttering frames that take significantly longer

On Impeller:

  • the app has rendering glitches at the default setting of 150 radius (the bottom app bar fails to render correctly as you pass the mouse over it)
  • Animating the radius of 150 with the "Turn" button has many frequent stutters that get worse as you increase the radius
  • Setting the radius down to around 100 eliminates the rendering glitches and removes the stuttering.

Expected results

Normal rendering at a radius of 100:

Impeller results at radius == 100 Image

Actual results

Corrupted rendering:

Corrupted Impeller rendering at radius == 150 Image

the results are more dramatic if you move the mouse over the bottom app bar while it is animating and increase the radius.

Code sample

Code sample
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: .fromSeed(seedColor: Colors.deepPurple),
      ),
      showPerformanceOverlay: true, // Enables the performance overlay
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const double _minRadius = 1;
  static const double _maxRadius = 500;
  static const double _defaultRadius = 150;

  double _turns = 0;
  double _radius = _defaultRadius;
  final _controller = TextEditingController(text: '$_defaultRadius');

  void _turn() {
    setState(() {
      _turns += 5;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: .center,
          children: [
            AnimatedRotation(
              turns: _turns,
              duration: Duration(seconds: 10),
              child: CustomPaint(
                foregroundPainter: _MyCirclePainter(rows: 50, cols: 50, radius: _radius),
                size: Size(400, 400),
              ),
            )
          ],
        ),
      ),
      bottomNavigationBar: BottomAppBar(
        child: Row(
          mainAxisAlignment: .center,
          children: <Widget>[
            ElevatedButton(onPressed: _turn, child: Text('Turn')),
            SizedBox(width: 20),
            Text('Radius'),
            Slider(
              value: _radius,
              min: _minRadius,
              max: _maxRadius,
              onChanged: (value) {
                setState(() {
                  _radius = value;
                  _controller.text = _radius.toStringAsFixed(1);
                });
              },
            ),
            SizedBox(
              width: 100,
              child: TextField(
                controller: _controller,
                keyboardType: .number,
                onSubmitted: (newString) {
                  double? newValue = double.tryParse(newString);
                  if (newValue != null) {
                    setState(() {
                      _radius = newValue.clamp(_minRadius, _maxRadius);
                    });
                  }
                },
                decoration: InputDecoration(
                  filled: true,
                  fillColor: Colors.white,
                  border: OutlineInputBorder(),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _MyCirclePainter extends CustomPainter {
  static final List<Color> colors = <Color>[
    Colors.purple,
    Colors.blue,
    Colors.green,
    Colors.yellow,
    Colors.orange,
    Colors.red,
  ];

  _MyCirclePainter({required this.rows, required this.cols, required this.radius});

  final int rows;
  final int cols;
  final double radius;

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
        ..style = PaintingStyle.fill;
    paint.color = Colors.white;
    canvas.drawRect(Offset.zero & size, paint);
    Size gridSize = Size(2 * radius * cols, 2 * radius * rows);
    Size gridSpace = Size(
      size.width - gridSize.width,
      size.height - gridSize.height,
    );
    Size cellPadding = Size(
      gridSpace.width / (cols + 1),
      gridSpace.height / (rows + 1),
    );
    canvas.translate(cellPadding.width + radius, cellPadding.height + radius);

    int colorIndex = 0;
    for (int y = 0; y < rows; y++) {
      double circleY = (cellPadding.height + radius * 2) * y;
      for (int x = 0; x < cols; x++) {
        double circleX = (cellPadding.width + radius * 2) * x;
        paint.color = colors[colorIndex++ % colors.length];
        canvas.drawCircle(Offset(circleX, circleY), radius, paint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Screenshots or Video

No response

Logs

The following errors were noted on the console from which the app was launched.

Logs
[ERROR:flutter/lib/ui/window/platform_configuration.cc(448)] Reported frame time is older than the last one; clamping. 382305749530 < 382305768923 ~= 19393
[ERROR:flutter/lib/ui/window/platform_configuration.cc(448)] Reported frame time is older than the last one; clamping. 382681442288 < 382681443050 ~= 762
[ERROR:flutter/lib/ui/window/platform_configuration.cc(448)] Reported frame time is older than the last one; clamping. 382698331921 < 382698333176 ~= 1255
[ERROR:flutter/lib/ui/window/platform_configuration.cc(448)] Reported frame time is older than the last one; clamping. 382700716091 < 382700743114 ~= 27023

Flutter Doctor output

Doctor output
[!] Flutter (Channel [user-branch], 3.43.0-1.0.pre-240, on macOS 26.3.1 25D771280a darwin-arm64, locale en) [794ms]
    ! Flutter version 3.43.0-1.0.pre-240 on channel [user-branch] at /Users/.../Development/flutter
      Currently on an unknown channel. Run `flutter channel` to switch to an official channel.
      If that doesn't fix the issue, reinstall Flutter by following instructions at https://flutter.dev/setup.
    ! Upstream repository unknown source is not a standard remote.
      Set environment variable "FLUTTER_GIT_URL" to unknown source to dismiss this error.
    • Framework revision ccca54fd8a (4 days ago), 2026-03-20 16:39:34 -0700
    • Engine revision b597ab2688
    • Dart version 3.12.0 (build 3.12.0-269.0.dev)
    • DevTools version 2.56.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android,
      enable-ios, cli-animations, enable-native-assets, enable-swift-package-manager, omit-legacy-version-file,
      enable-lldb-debugging, enable-uiscene-migration, enable-riscv64
    • If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly
      to perform update checks and upgrades.

[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [3.4s]
    • Android SDK at /Users/.../Library/Android/sdk
    • Emulator version 36.4.9.0 (build_id 14788078) (CL:N/A)
    • Platform android-36.1, build-tools 36.1.0
    • ANDROID_HOME = /Users/.../Library/Android/sdk
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/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.9+-14649483-b1163.86)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 26.2) [3.2s]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 17C52
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [11ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (4 available) [12.2s]
    • Pixel 6a (mobile) • 27251JEGR18342            • android-arm64  • Android 16 (API 36)
    • iPhone (mobile)   • 00008030-000325223A53C02E • ios            • iOS 26.2 23C55
    • macOS (desktop)   • macos                     • darwin-arm64   • macOS 26.3.1 25D771280a darwin-arm64
    • Chrome (web)      • chrome                    • web-javascript • Google Chrome 146.0.7680.154

[✓] Network resources [418ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.

Activity

  1. saurabh-mirajkar commented on Mar 25, 2026

    @saurabh-mirajkar
    Contributor

    Thanks for the report. Labelling for team-engine to investigate further.

  2. added
    engineflutter/engine related. See also e: labels.
    platform-macosBuilding on or for macOS specifically
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    e: impellerImpeller rendering backend issues and features requests
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Mar 25, 2026
  3. added
    P2Important issues not at the top of the work list
    on Mar 30, 2026
  4. jason-simmons commented on Apr 7, 2026

    @jason-simmons
    Member

    This appears to be an issue in the macOS embedder.

    On macOS, the embedder is placing more frames in flight than the maximum number expected by Impeller's HostBuffer.

    HostBuffer holds a device_buffers_ array whose size is kHostBufferArenaSize (currently 4). Each entry in device_buffers_ is a per-frame list of buffers. Calling HostBuffer::Reset will advance to the next entry in device_buffers_ and reuse the buffers in that entry's list.

    For this to work, the embedder must ensure that the host platform's graphics API has finished consuming the device buffers before HostBuffer reuses the buffers. Other embedders have mechanisms to limit how many frames are in flight to ensure that reuse is safe. But it appears that this is not provided for macOS.

    If I patch FlutterSurfaceManager.presentSurfaces to call [commandBuffer waitUntilCompleted] instead of [commandBuffer waitUntilScheduled] then this app renders as expected.

    The app also works with [commandBuffer waitUntilScheduled] if I patch FlutterSurfaceManager.presentSurfaces to use a semaphore to limit the number of scheduled frames.

    cc @knopp (original author of FlutterSurfaceManager on macOS)

  5. added
    team-macosOwned by the macOS platform team
    and removed on Apr 7, 2026
  6. 2 remaining items

  7. jason-simmons commented on Apr 8, 2026

    @jason-simmons
    Member

    Frames in flight

    As you noted, unfortunately the embedder API has no explicit communication of the need to limit the number of frames in flight.

    Typically embedders will use the host platform's display API to create a double- or triple-buffered rendering surface. The need to wait for an available surface buffer creates the backpressure that limits the number of frames in flight to what kHostBufferArenaSize can accomodate.

    For this purpose, a "frame in flight" refers to a Flutter display list that is being rendered by Impeller. Specifically, each call to impeller::RenderToTarget with reset_host_buffer=true will advance to a new entry in the HostBuffer::device_buffers_ array. The number of calls to impeller::RenderToTarget that have not yet been fully consumed by the host rendering API must not exceed kHostBufferArenaSize.

    I don't know of any plans to have Impeller provide a way for embedders to give fences to HostBuffer.

    The semaphore experiment that I did was a quick proof of concept. I added a semaphore to FlutterSurfaceManager:
    fml::Semaphore frame_semaphore(3);

    And then patched presentSurfaces to wait on the semaphore before scheduling the command buffer and then signal it when the command buffer completes:

      FML_CHECK(frame_semaphore.Wait());
      [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) {
        frame_semaphore.Signal();
      }];
      [commandBuffer commit];
      [commandBuffer waitUntilScheduled];
    

    That worked for this app. Do you think something like that would be a usable solution?

    Layers

    There are also issues involving how layers interact with impeller::HostBuffer in the embedder library.

    The problem can be demonstrated by running your https://github.com/knopp/layer_playground example on macOS with Impeller:

    • Create four pairs of a Flutter layer followed by a native layer.
    • Create a fifth Flutter layer.
    • Resize the window. The Flutter layers will show corruption when rendered.

    In the embedder library, EmbedderExternalView::Render is called for each layer that contains Flutter content. EmbedderExternalView::Render calls impeller::RenderToTarget and always passes reset_host_buffer=true.

    So each Flutter content layer consumes an entry in HostBuffer::device_buffers_. In the example above the number of Flutter content layers is more than kHostBufferArenaSize, so a device buffer is overwritten during compositing of the frame.

    On iOS, FlutterPlatformViewsController.submitFrame will use the SurfaceFrame::SubmitInfo::frame_boundary flag to control this. FlutterPlatformViewsController will set frame_boundary to false for all layers except the last layer. GPUSurfaceMetalImpeller will then call impeller::RenderToTarget and set reset_host_buffer to the value of frame_boundary. This ensures that only one HostBuffer::device_buffers_ entry is used for each frame.

    The rendering corruption described above does not happen on macOS if I patch EmbedderExternalViewEmbedder/LayerBuilder/Layer to only pass reset_host_buffer=true for the final Flutter content layer in the frame.

  8. jason-simmons commented on Apr 9, 2026

    @jason-simmons
    Member

    I noticed another issue related to composition of embedder layers with Impeller.

    A layer can contain a list of EmbedderExternalView instances that each hold a slice of the Flutter contents display list. All of these slices should be drawn to the layer's render target.

    Layer::RenderFlutterContents calls EmbedderExternalView::Render for each entry in the list. When using Impeller, EmbedderExternalView::Render calls impeller::RenderToTarget to render the slice.

    However, impeller::RenderToTarget completely overwrites the render target's buffer. So each slice replaces the previous slice instead of being composited as intended.

    This can be shown by running the https://github.com/knopp/layer_playground app with Impeller:

    • Run the app and clear the list of layers. The app shows a gray background in the left pane and controls in the right pane.
    • Click "Add Native Layer". The gray background will disappear.

    This happens because the background and the controls are two separate slices within the same Flutter contents layer. The background is drawn first. But the buffer is then cleared to transparent black when the the controls slice is rendered, so the background is lost.

    I'm experimenting with fixing this by changing Layer::RenderFlutterContents to render all of the EmbedderExternalView instances into a single display list and then making one call to impeller::RenderToTarget.

  9. flar commented on Apr 10, 2026

    @flar
    ContributorAuthor

    I'm experimenting with fixing this by changing Layer::RenderFlutterContents to render all of the EmbedderExternalView instances into a single display list and then making one call to impeller::RenderToTarget.

    Aren't the slices supposed to have an embedded native view rendered between each other?

  10. jason-simmons commented on Apr 10, 2026

    @jason-simmons
    Member

    I'm experimenting with fixing this by changing Layer::RenderFlutterContents to render all of the EmbedderExternalView instances into a single display list and then making one call to impeller::RenderToTarget.

    Aren't the slices supposed to have an embedded native view rendered between each other?

    The EmbedderExternalViewEmbedder renders a stack of Layer instances. Each Layer can contain:

    • multiple platform views
    • multiple Flutter content slices that do not intersect the platform views

    See the interaction between EmbedderExternalViewEmbedder, LayerBuilder, and Layer in https://github.com/flutter/flutter/blob/master/engine/src/flutter/shell/platform/embedder/embedder_external_view_embedder.cc

    The proposal described in #184091 (comment) involves joining the slices within a Layer into one display list.

    Image

    The above screenshot from https://github.com/knopp/layer_playground shows the app UI with a native view added. The gray background on the left and the controls on the right are separate slices within one Layer. The native view is a different Layer.

    Image

    This screenshot shows the scenario from #184091 (comment) with interleaved Flutter and native views. Every Flutter view is in a different Layer, and each of those Layer instances contains only one slice.

  11. bdero commented on Jul 4, 2026

    @bdero
    Member

    I've been running into DeviceBuffer corruption on macOS while building heavier Scene example content, and made a violent reproduction by slowing down the GPU with a busy loop runtime effect:

    Screen.Recording.2026-07-04.at.2.12.26.AM.mov

    shaders/heavy.frag

    #version 460 core
    #include <flutter/runtime_effect.glsl>
    
    precision highp float;
    
    uniform vec2 uSize;
    uniform float uTime;
    uniform float uIters;
    
    out vec4 fragColor;
    
    void main() {
      vec2 uv = FlutterFragCoord().xy / uSize;
      float acc = 0.0;
      for (float i = 0.0; i < uIters; i += 1.0) {
        acc += sin(uv.x * (i + 1.0) * 12.9898 + uTime) *
               cos(uv.y * (i + 1.0) * 78.233 - uTime);
      }
      acc /= max(uIters, 1.0);
      vec3 color = 0.5 + 0.5 * vec3(sin(acc * 6.28318 + uTime),
                                    sin(acc * 6.28318 + uTime + 2.09439),
                                    sin(acc * 6.28318 + uTime + 4.18879));
      fragColor = vec4(color, 1.0) * 0.35;
    }

    lib/main.dart

    import 'dart:math';
    import 'dart:ui' as ui;
    
    import 'package:flutter/material.dart';
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      ui.FragmentProgram? program;
      try {
        program = await ui.FragmentProgram.fromAsset('shaders/heavy.frag');
      } catch (_) {
        program = null;
      }
      runApp(MyApp(program: program));
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key, required this.program});
    
      final ui.FragmentProgram? program;
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'HostBuffer overrun repro',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          ),
          home: MyHomePage(title: 'HostBuffer overrun repro', program: program),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key, required this.title, required this.program});
    
      final String title;
      final ui.FragmentProgram? program;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage>
        with SingleTickerProviderStateMixin {
      double _gpuLoad = 500;
      double _squares = 500;
    
      late final AnimationController _ticker;
    
      @override
      void initState() {
        super.initState();
        _ticker =
            AnimationController(vsync: this, duration: const Duration(seconds: 6))
              ..repeat();
      }
    
      @override
      void dispose() {
        _ticker.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            Positioned.fill(
              child: CustomPaint(
                painter: _ReproPainter(
                  shader: widget.program?.fragmentShader(),
                  gpuLoad: _gpuLoad.round().toDouble(),
                  squareCount: _squares.round(),
                  time: _ticker,
                ),
                child: const SizedBox.expand(),
              ),
            ),
            Scaffold(
              backgroundColor: Colors.transparent,
              appBar: AppBar(
                backgroundColor: Theme.of(context).colorScheme.inversePrimary,
                title: Text(widget.title),
              ),
              body: Column(
                children: [
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Row(
                      children: [
                        Text('GPU load: ${_gpuLoad.round()}'),
                        Expanded(
                          child: Slider(
                            min: 0,
                            max: 20000,
                            value: _gpuLoad,
                            onChanged: (value) =>
                                setState(() => _gpuLoad = value),
                          ),
                        ),
                      ],
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Row(
                      children: [
                        Text('Squares: ${_squares.round()}'),
                        Expanded(
                          child: Slider(
                            min: 0,
                            max: 5000,
                            value: _squares,
                            onChanged: (value) =>
                                setState(() => _squares = value),
                          ),
                        ),
                      ],
                    ),
                  ),
                  const Text('Watch this text, the app bar, and the controls '
                      'for corruption.'),
                ],
              ),
            ),
          ],
        );
      }
    }
    
    class _ReproPainter extends CustomPainter {
      _ReproPainter({
        required this.shader,
        required this.gpuLoad,
        required this.squareCount,
        required this.time,
      }) : super(repaint: time);
    
      final ui.FragmentShader? shader;
      final double gpuLoad;
      final int squareCount;
      final Animation<double> time;
    
      @override
      void paint(Canvas canvas, Size size) {
        final t = time.value * 2 * pi;
        final s = shader;
        if (gpuLoad > 0 && s != null) {
          s
            ..setFloat(0, size.width)
            ..setFloat(1, size.height)
            ..setFloat(2, t)
            ..setFloat(3, gpuLoad);
          canvas.drawRect(Offset.zero & size, Paint()..shader = s);
        }
    
        for (int i = 0; i < squareCount; i++) {
          final sq = Random(0x9E3779B9 * (i + 1));
          final width = size.width * (0.05 + sq.nextDouble() * 0.15);
          final height = size.height * (0.05 + sq.nextDouble() * 0.15);
          final phase = sq.nextDouble() * 2 * pi;
          final speed = 1 + sq.nextInt(3);
    
          if (sin(t * speed + phase * 3) < -0.4) {
            continue;
          }
    
          final drift = Offset(
            sin(t + phase) * size.width * 0.05,
            cos(t + phase) * size.height * 0.05,
          );
          final left = sq.nextDouble() * (size.width - width) + drift.dx;
          final top = sq.nextDouble() * (size.height - height) + drift.dy;
    
          final paint = Paint()
            ..color = Color.fromARGB(
              96 + sq.nextInt(96),
              sq.nextInt(256),
              sq.nextInt(256),
              sq.nextInt(256),
            );
          canvas.drawRect(Rect.fromLTWH(left, top, width, height), paint);
        }
      }
    
      @override
      bool shouldRepaint(_ReproPainter oldDelegate) => true;
    }
  12. github-actions commented on Jul 24, 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: renderingUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-macosBuilding on or for macOS specificallyteam-macosOwned by the macOS platform teamtriaged-macosTriaged by the macOS platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions