Repository navigation
[Impeller] Drawing a lot of large circles corrupts further rendering #184091
Copy link
Copy link
Closed
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work listc: renderingUI glitches reported at the engine/skia or impeller rendering levelUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.flutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onplatform-macosBuilding on or for macOS specificallyBuilding on or for macOS specificallyteam-macosOwned by the macOS platform teamOwned by the macOS platform teamtriaged-macosTriaged by the macOS platform teamTriaged by the macOS platform team
Description
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:
Actual results
Corrupted rendering:
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 ~= 27023Flutter 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.Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work listc: renderingUI glitches reported at the engine/skia or impeller rendering levelUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.flutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onplatform-macosBuilding on or for macOS specificallyBuilding on or for macOS specificallyteam-macosOwned by the macOS platform teamOwned by the macOS platform teamtriaged-macosTriaged by the macOS platform teamTriaged by the macOS platform team


Activity
saurabh-mirajkar commentedon Mar 25, 2026
Thanks for the report. Labelling for
team-engineto investigate further.jason-simmons commentedon Apr 7, 2026
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.HostBufferholds adevice_buffers_array whose size iskHostBufferArenaSize(currently 4). Each entry indevice_buffers_is a per-frame list of buffers. CallingHostBuffer::Resetwill advance to the next entry indevice_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
HostBufferreuses 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.presentSurfacesto call[commandBuffer waitUntilCompleted]instead of[commandBuffer waitUntilScheduled]then this app renders as expected.The app also works with
[commandBuffer waitUntilScheduled]if I patchFlutterSurfaceManager.presentSurfacesto use a semaphore to limit the number of scheduled frames.cc @knopp (original author of
FlutterSurfaceManageron macOS)2 remaining items
jason-simmons commentedon Apr 8, 2026
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
kHostBufferArenaSizecan 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::RenderToTargetwithreset_host_buffer=truewill advance to a new entry in theHostBuffer::device_buffers_array. The number of calls toimpeller::RenderToTargetthat have not yet been fully consumed by the host rendering API must not exceedkHostBufferArenaSize.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
presentSurfacesto wait on the semaphore before scheduling the command buffer and then signal it when the command buffer completes: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::HostBufferin the embedder library.The problem can be demonstrated by running your https://github.com/knopp/layer_playground example on macOS with Impeller:
In the embedder library,
EmbedderExternalView::Renderis called for each layer that contains Flutter content.EmbedderExternalView::Rendercallsimpeller::RenderToTargetand always passesreset_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 thankHostBufferArenaSize, so a device buffer is overwritten during compositing of the frame.On iOS,
FlutterPlatformViewsController.submitFramewill use theSurfaceFrame::SubmitInfo::frame_boundaryflag to control this.FlutterPlatformViewsControllerwill setframe_boundaryto false for all layers except the last layer.GPUSurfaceMetalImpellerwill then callimpeller::RenderToTargetand setreset_host_bufferto the value offrame_boundary. This ensures that only oneHostBuffer::device_buffers_entry is used for each frame.The rendering corruption described above does not happen on macOS if I patch
EmbedderExternalViewEmbedder/LayerBuilder/Layerto only passreset_host_buffer=truefor the final Flutter content layer in the frame.jason-simmons commentedon Apr 9, 2026
I noticed another issue related to composition of embedder layers with Impeller.
A layer can contain a list of
EmbedderExternalViewinstances 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::RenderFlutterContentscallsEmbedderExternalView::Renderfor each entry in the list. When using Impeller,EmbedderExternalView::Rendercallsimpeller::RenderToTargetto render the slice.However,
impeller::RenderToTargetcompletely 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:
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::RenderFlutterContentsto render all of theEmbedderExternalViewinstances into a single display list and then making one call toimpeller::RenderToTarget.flar commentedon Apr 10, 2026
Aren't the slices supposed to have an embedded native view rendered between each other?
jason-simmons commentedon Apr 10, 2026
The
EmbedderExternalViewEmbedderrenders a stack ofLayerinstances. EachLayercan contain:See the interaction between
EmbedderExternalViewEmbedder,LayerBuilder, andLayerin https://github.com/flutter/flutter/blob/master/engine/src/flutter/shell/platform/embedder/embedder_external_view_embedder.ccThe proposal described in #184091 (comment) involves joining the slices within a
Layerinto one display list.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 differentLayer.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 thoseLayerinstances contains only one slice.Fix some issues in the integration between EmbedderExternalViewEmbedd…
Fix some issues in the integration between EmbedderExternalViewEmbedd…
Fix some issues in the integration between EmbedderExternalViewEmbedd…
Fix some issues in the integration between EmbedderExternalViewEmbedd…
Fix some issues in the integration between EmbedderExternalViewEmbedd…
bdero commentedon Jul 4, 2026
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
lib/main.dart
[Impeller] Recycle HostBuffer arena entries only after GPU completion (…
github-actions commentedon Jul 24, 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.