Repository navigation
Description
Steps to reproduce
- Drop the code sample below into a fresh
flutter createapp aslib/main.dart. It is the whole app — no assets, and no dependencies beyondflutter. - Run it on an iOS Simulator:
flutter run -d "iPhone 17 Pro". - View the Simulator at 100% (Window > Physical Size) — display scaling otherwise masks a one-pixel effect.
- Watch the two bands. Both are the same striped rect, stretched horizontally by 4% over 12s. The top one is drawn with
BlendMode.srcOver, the bottom one withBlendMode.screen.
Expected results
Both bands stretch identically and smoothly. The stripes inside the screen band should scale with the band, exactly as they do in the srcOver band.
Actual results
The srcOver band grows smoothly. The screen band grows in visible whole-pixel steps, and its interior stripes crawl and breathe while it grows — the entire band is being resampled by a factor that changes from frame to frame. The same happens with multiply, overlay and every other blend mode above kModulate in impeller/geometry/color.h.
It is most visible with content whose device-space coverage is fractional and changing every frame, which is the normal state of affairs for animated vector content (beziers, strokes, scale animations). The repro uses a horizontal scale sweep because that makes the device width pass continuously through fractional values; a pure translation keeps the width constant and hides the bug.
Affected: configurations without framebuffer fetch, where advanced blends take Impeller's offscreen path.
- iOS Simulator —
impeller/renderer/backend/metal/context_mtl.mm:25-35hard-returnsfalse("The iOS simulator lies about supporting framebuffer fetch"). - PowerVR (all) and Adreno ≤ 630 on Vulkan —
impeller/renderer/backend/vulkan/workarounds_vk.cc. - Adreno on GLES —
capabilities_gles.cconly acceptsGL_EXT_shader_framebuffer_fetch, which Qualcomm does not expose.
Not affected: physical iOS devices, Mali, modern Adreno on Vulkan. Those have framebuffer fetch and blend in-pass via ApplyFramebufferBlend, which is exact.
Analysis
Line numbers below are identical in master and in the 3.47.3 tag.
Per-draw dispatch (impeller/display_list/canvas.cc:2478-2490): with framebuffer fetch the blend happens in-pass; otherwise it goes through FlipBackdrop + ColorFilterContents::MakeBlend, landing in AdvancedBlend (impeller/entity/contents/filters/blend_filter_contents.cc:96). Three things there must describe the same rectangle, and they don't:
- The texture coordinates come from the raw float coverage —
:124and:141,GetCoverageUVs(coverage). Nothing in the chain fromFilterContents::RenderFilterrounds it. - The quad spans
pass.GetRenderTargetSize()(:171-184) — the integer render-target extent, allocated at:246asISize(subpass_coverage.GetSize()).TSize's converting constructor (impeller/geometry/size.h:38-41) is a plain truncatingstatic_cast, not a round. - The composite back (
:259-266) uses.transform = Matrix::MakeTranslation(subpass_coverage.GetOrigin())— a fractional origin — with.sampler_descriptor = {}, whose defaults areMinMagFilter::kNearest(impeller/core/sampler_descriptor.h:15-16), drawn over the integerRect::MakeSize(texture->GetSize())inEntity::FromSnapshot(impeller/entity/entity.cc:18-34).
So an integer quad displays a float-sized region, and the result is composited back at a fractional offset with nearest sampling.
Correction to my original framing. I assumed this needed content whose device-space coverage is meaningfully fractional. It does not, and that turns out to understate the problem. In this per-draw path the blend runs over the whole pass (FlipBackdrop), so the coverage is nominally the full render target — an integral rect. What actually triggers it is floating-point residue in the transform chain, which leaves that nominally-integral rect very slightly off.
Instrumenting subpass_coverage on master (iPhone 17 Pro simulator, 1206x2622, running the Rive artboard below), over 36,705 blend subpasses:
subpass_coverage on master |
count | share |
|---|---|---|
| origin is fractional | 12,644 | 34.4% |
ISize truncates the extent |
10,659 | 29.0% |
| both | 5,610 | 15.3% |
A representative truncating case:
origin=(0, 0.000100118) size=(1206, 2622) ISize=(1205, 2622)
The extent prints as 1206 because the real value is 1205.99999…, so ISize truncates it to 1205 — the blend subpass render target is a full pixel narrower than the screen, and the backdrop is composited back from it at a fractional origin. Both values drift from frame to frame (0.000100179, 0.000100294, 0.000100446, …), so the error differs every frame.
Two consequences worth noting, neither of which I expected:
- The whole frame is affected, not just the blended element. Because
FlipBackdropruns the entire pass through the blend subpass, unblended content drawn in the same frame is resampled too. In the repro below, thesrcOverband shakes along with thescreenband. - It does not require animated coverage. A residue of 1e-4 is enough. Any frame-to-frame change in the transform chain reshuffles it.
PipelineBlend in the same file has the identical truncation at :518 / :632 / :649.
Canvas::SaveLayer already solves exactly this for save-layer subpasses — it rounds the coverage out (canvas.cc:1879-1895) and floors or rounds the composite position (canvas.cc:2112-2129), with the comment:
Round the subpass texture position for pixel alignment with the parent pass render target. By default, we draw subpass textures with nearest sampling, so aligning here is important for avoiding visual nearest sampling errors caused by limited floating point precision when straddling a half pixel boundary.
That alignment was added for #144213, in flutter-team-archive/engine#52973 ("[Impeller] Pixel align subpass textures"), which was scoped to save-layer subpasses. The blend-filter path never got the same treatment.
Suggested fix
Snap the coverage to the device pixel grid once, up front (Rect::RoundOut, which already exists at geometry/rect.h), and derive the texture coordinates, the render-target extent and the composite translation from that single integral rect — mirroring what SaveLayer does.
Note that simply swapping the truncating cast for ISize::Ceil is not a fix: the quad then becomes larger than the UV'd region, so the content stretches instead of squashing. It changes the sign of the error, not its instability, and it still varies with frac(w). All three derivations have to come from the same rounded rect.
Rounding out makes the composited rect up to 1px larger than the entity's reported coverage. That fringe should be an identity write — snapshots carry a transparent 1px border, and with a fully transparent source the separable blend composite reduces to the destination — but it is worth confirming that no clip or depth bookkeeping depends on the two matching exactly. #145180 covers the round-out-versus-blur-flicker tradeoff that a change here has to respect.
Note that pixel-aligning the subpass necessarily makes it advance in whole-pixel steps instead of gliding sub-pixel. That is the same tradeoff SaveLayer already makes, and it is a large net win here: measured on the repro, roughly 20 sub-pixel jitter events per 60 frames become about one 1px step.
Status: implemented in #192988. Measured against the Rive artboard attached there, tracking a landmark that never moves in the animation, over 40 consecutive frames: 0.655px of drift with jumps on 10 of 39 frame transitions on master, versus 0.000px and zero jumps with the fix.
Code sample
Code sample
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: Repro())));
class Repro extends StatefulWidget {
const Repro({super.key});
@override
State<Repro> createState() => _ReproState();
}
class _ReproState extends State<Repro> with SingleTickerProviderStateMixin {
late final AnimationController _controller =
AnimationController(vsync: this, duration: const Duration(seconds: 12))
..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (_, _) =>
CustomPaint(size: Size.infinite, painter: _Painter(_controller.value)),
);
}
class _Painter extends CustomPainter {
const _Painter(this.t);
final double t;
@override
void paint(Canvas canvas, Size size) {
// Advanced blends read the destination, so give them an opaque one.
canvas.drawRect(Offset.zero & size, Paint()..color = const Color(0xFF9E9E9E));
_band(canvas, 120, BlendMode.srcOver);
_band(canvas, 300, BlendMode.screen);
}
void _band(Canvas canvas, double top, BlendMode mode) {
canvas.save();
canvas.translate(32, top);
canvas.scale(1 + t * 0.04, 1); // width sweeps through fractional device px
canvas.drawRect(
const Rect.fromLTWH(0, 0, 260.5, 90), // fractional width on purpose
Paint()
..blendMode = mode
..shader = ui.Gradient.linear(
Offset.zero,
const Offset(2, 0), // fine stripes: the loudest resampling signal
const [
Color(0xFF111111),
Color(0xFF111111),
Color(0xFFEEEEEE),
Color(0xFFEEEEEE),
],
const [0, 0.5, 0.5, 1],
TileMode.repeated,
),
);
canvas.restore();
}
@override
bool shouldRepaint(covariant _Painter oldDelegate) => oldDelegate.t != t;
}Screenshots or Video
Screenshots / Video demonstration
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-18.at.10.53.49.mov
Logs
Logs
[Paste your logs here]Flutter Doctor output
Doctor output
✓] Flutter (Channel stable, 3.47.3, on macOS 26.6 25G72 darwin-arm64, locale en-FR) [923ms]
• Flutter version 3.47.3 on channel stable at /Users/t.guerin-ext/.local/share/mise/http-tarballs/689f2bd5908b891a46b7fb5dbfc594654ecd7891c377db17a354085a4fb2391a_strip_1
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision e8113bf456 (2 weeks ago), 2026-09-04 13:20:08 -0700
• Engine revision 06a2e2a110
• Dart version 3.13.3
• DevTools version 2.60.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-lldb-debugging, enable-uiscene-migration
[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,445ms]
• Android SDK at /Users/t.guerin-ext/Library/Android/sdk
• Emulator version 36.6.11.0 (build_id 15507667) (CL:N/A)
✗ cmdline-tools component is missing.
Try installing or updating Android Studio.
Alternatively, download the tools from https://developer.android.com/studio#command-line-tools-only and make sure to set the ANDROID_HOME environment variable.
See https://developer.android.com/studio/command-line for more details.
✗ Android license status unknown.
Run `flutter doctor --android-licenses` to accept the SDK licenses.
See https://flutter.dev/to/macos-android-setup for more details.
[!] Xcode - develop for iOS and macOS (Xcode 26.6) [1,487ms]
• Xcode at /Applications/Xcode.app/Contents/Developer
• Build 17F113
! CocoaPods installed but not working.
You appear to have CocoaPods installed but it is not working.
This can happen if the version of Ruby that CocoaPods was installed with is different from the one being used to invoke it.
This can usually be fixed by re-installing CocoaPods.
For re-installation instructions, see https://guides.cocoapods.org/using/getting-started.html#installation
[✓] Chrome - develop for the web [4ms]
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Connected device (4 available) [6.8s]
• iPhone 17 Pro (mobile) • 99A903C4-C523-4C85-A272-F91D5F6127E9 • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator)
• iPhone 17 Pro Max (mobile) • 4C0235FF-980E-4FD9-A041-52570C26BC83 • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator)
• macOS (desktop) • macos • darwin-arm64 • macOS 26.6 25G72 darwin-arm64
• Chrome (web) • chrome • web-javascript • Google Chrome 150.0.7871.125
[✓] Network resources [315ms]
• All expected network resources are available.
! Doctor found issues in 2 categories.
Activity
saurabh-mirajkar commentedon Sep 18, 2026
@tguerin Thanks for the detailed report. I was able to reproduce this issue on both an iPhone 17 Pro running iOS 26.5 and an iPhone 18 Pro running iOS 27.0 simulators. Routing this to the engine team for further investigation.
Related issues: #148213, #149771, #144213, #145180
tguerin commentedon Sep 18, 2026
I think the platform-android label is missing too cause you can reproduce on a Pixel 11 Pro Xl which uses a PowerVR GPU
[Impeller] Pixel align blend subpass textures
[Impeller] Pixel align blend subpass textures
[Impeller] Sample the render target rect in offscreen advanced blends (…
github-actions commentedon Oct 14, 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.[Impeller] Sample the render target rect in offscreen advanced blends (…