Repository navigation
Description
Steps to reproduce
- Build a Windows app with the compositor fix from [Windows] Use an unsized texture format for backing stores on OpenGL ES 2 #192917 applied. Without it the window stays black on this hardware class, see [Windows] Regression in 3.47.0: black window on GPUs limited to D3D11 feature level 9_3 — compositor passes sized internal formats on an ES 2.0 context #191978.
- Limit Direct3D to feature level 10_0 for the app: Graphics Tools,
dxcpl, Scope list: add the exe, Feature level limit: 10_0. The limit only takes effect whendxcplitself saves the entry. ANGLE then creates an OpenGL ES 2.0 context. - Run the code sample below with Impeller enabled.
Expected results
The gradient, the rings, the shadow card and all labels render, and the middle card blurs the content behind it. This is what Skia shows at 10_0 and what Impeller shows without the limit.
Actual results
Everything drawn before the BackdropFilter in the same pass is erased: the gradient BoxDecoration, the CustomPaint rings, the BoxShadow inside Opacity and its label. The blurred card and the content drawn after it remain. Removing the BackdropFilter makes everything render. --impeller-use-sdfs makes no difference. There are no GL errors besides glVertexAttribDivisor INVALID_OPERATION, which appears with and without the filter.
Cause: on this context CapabilitiesGLES reports no offscreen MSAA, no framebuffer fetch and no read from resolve. For a non-MSAA pass InlinePassContext::GetRenderPass does not flip the target and uses LoadAction::kLoad:
flutter/engine/src/flutter/impeller/entity/inline_pass_context.cc
Lines 100 to 111 in aced6c4
| if (pass_count_ > 0 && is_msaa) { | |
| pass_target_.Flip(renderer_); | |
| } | |
| } | |
| // Find the color attachment a second time, since the target may have just | |
| // flipped. | |
| ColorAttachment color0 = pass_target_.GetRenderTarget().GetColorAttachment(0); | |
| bool is_msaa = color0.resolve_texture != nullptr; | |
| if (pass_count_ > 0) { | |
| color0.load_action = is_msaa ? LoadAction::kClear : LoadAction::kLoad; |
EntityPassTarget::Flip says that non-MSAA passes "can be trivially loaded". Canvas::FlipBackdrop then draws input_texture with BlendMode::kSrc ("MSAA backdrop") into the new pass, whose color attachment is the same texture:
| const std::shared_ptr<Texture>& input_texture = |
flutter/engine/src/flutter/impeller/display_list/canvas.cc
Lines 2621 to 2627 in aced6c4
| msaa_backdrop_contents->SetLabel("MSAA backdrop"); | |
| msaa_backdrop_contents->SetSourceRect(size_rect); | |
| msaa_backdrop_contents->SetTexture(input_texture); | |
| Entity msaa_backdrop_entity; | |
| msaa_backdrop_entity.SetContents(std::move(msaa_backdrop_contents)); | |
| msaa_backdrop_entity.SetBlendMode(BlendMode::kSrc); |
D3D11 does not allow a texture to be sampled and rendered to at once, and ANGLE samples zeros, so the pass overwrites the backdrop with transparent black. A log added to FlipBackdrop shows target_is_input=1 target_samples=1 load=kLoad resolve=0 at 10_0 and target_is_input=0 load=kClear resolve=1 without the limit.
A fix that swaps the non-MSAA target to a second color texture in FlipBackdrop renders the full frame at 10_0 at 120 FPS, raster 2.5 ms. The second texture is created from the render target cache on the first non-MSAA flip only, 13 MB at 2534x1369. The MSAA path does not change, and BlendMode.multiply renders the same with and without the fix.
Tested on 3.47.4 x64 and master e619960 arm64, Windows 11 in Parallels with the dxcpl limit. Not tested on real 10_0 hardware.
Code sample
Code sample
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const RichApp());
class RichApp extends StatelessWidget {
const RichApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.teal),
home: const RichPage(),
);
}
}
class RichPage extends StatefulWidget {
const RichPage({super.key});
@override
State<RichPage> createState() => _RichPageState();
}
class _RichPageState extends State<RichPage> with SingleTickerProviderStateMixin {
late final AnimationController _spin =
AnimationController(vsync: this, duration: const Duration(seconds: 4))..repeat();
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF0F2027), Color(0xFF2C5364), Color(0xFFE96443)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Positioned.fill(
child: AnimatedBuilder(
animation: _spin,
builder: (context, _) => CustomPaint(painter: _Rings(_spin.value)),
),
),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_card('Shadow + opacity', Opacity(
opacity: 0.8,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(24),
boxShadow: const [BoxShadow(blurRadius: 24, spreadRadius: 4, color: Colors.black54)],
),
),
)),
const SizedBox(width: 24),
_card('Backdrop blur', ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(width: 160, height: 120, color: Colors.white24),
),
)),
const SizedBox(width: 24),
_card('Shader mask', ShaderMask(
shaderCallback: (r) => const RadialGradient(
colors: [Colors.cyanAccent, Colors.pinkAccent],
).createShader(r),
child: const Text('Flutter', style: TextStyle(fontSize: 48, fontWeight: FontWeight.w900, color: Colors.white)),
)),
],
),
),
],
),
);
}
Widget _card(String label, Widget child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
const SizedBox(height: 12),
Text(label, style: const TextStyle(color: Colors.white, fontSize: 16)),
],
);
}
}
class _Rings extends CustomPainter {
_Rings(this.t);
final double t;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
for (var i = 0; i < 12; i++) {
final angle = t * 2 * math.pi + i * math.pi / 6;
final paint = Paint()
..color = HSVColor.fromAHSV(0.6, (i * 30.0) % 360, 0.8, 1).toColor()
..style = PaintingStyle.stroke
..strokeWidth = 6;
canvas.drawCircle(center + Offset(math.cos(angle), math.sin(angle)) * 180, 40, paint);
}
}
@override
bool shouldRepaint(_Rings old) => old.t != t;
}


Activity
krll-kov commentedon Sep 17, 2026
I tried multiple ways to fix this, but i'm not sure which one is better:
Both were built on 3.47.4 x64 with #192917 applied and tested on Windows 11 with the
dxcplfeature level limit at 10_0 (OpenGL ES 2.0, no offscreen MSAA). The diffs below are rebased on main.A: flip the non-MSAA target to a second texture
EntityPassTarget::FlipWithoutMSAAswaps color attachment 0 with a lazily allocated second texture, andFlipBackdropcalls it before the next pass, so that pass writes to a different texture than the one the backdrop is read from.Diff, +32 −1
Cost: one more color texture of the pass size per pass target that reads a backdrop, 4 bytes per pixel, so 13.2 MiB for a 2534x1369 window (computed, not measured), and it contradicts the comment in EntityPassTarget::Flip that non-MSAA passes never need a flip.
Tests: two
EntityPassTargetTestcases (the swap and its reversal on a non-MSAA target, no change on an MSAA target). They pass on Vulkan with SwiftShader. Removing thestd::swapor theresolve_texturecheck fails one of them. With the fix, 131*Backdrop*,*Blur*,*SaveLayer*and*EntityPass*playground tests pass on Vulkan with SwiftShader, which exercises the MSAA path only.B: skip drawing the backdrop into itself
A non-MSAA pass already loads its previous contents with
LoadAction::kLoad, so the "MSAA backdrop" draw is only needed when the pass writes to a different texture. B skips that draw when the target isinput_texture.Diff, +10 −1
No extra texture and no extra draw. B assumes that filters read the backdrop in their own passes, which run before the parent pass. That holds on the GLES backend here, but I have not checked it anywhere else. It has no test yet. It needs a
Canvaslevel test with a context without offscreen MSAA, and I did not find an existing harness for that.Results
Static scenes, Impeller at 10_0 compared pixel by pixel with Impeller without the limit (MSAA):
BackdropFilterblur over a ringBackdropFilterwithColorFilter.matrixBackdropFilter.groupedblursBlendMode.multiplyover a gradientA and B render the same frame in every scene. Both differ from the MSAA frame in 809 pixels on antialiased edges and nowhere else. Without the limit, B renders the same frame as A. The animated app from the issue renders fully with both, and A runs at 120 FPS there. I did not measure the frame rate of B.
The tested build of B had the same condition written on one line, the diff above only reformats it.
puneetkukreja98 commentedon Sep 17, 2026
@krll-kov Thanks for reporting this and for the detailed write-up! Could you please confirm if my understanding is correct:
After applying these steps I get a solid black screen in both cases (with impeller enabled and disabled).
So once we apply the fixes from your PR- #192917 for the black window issue- #191978
krll-kov commentedon Sep 17, 2026
Thanks for trying it! Yes, the black window in both modes is expected there: without #192917 every Flutter 3.47+ window is black at feature level 10_0, with Impeller and with Skia (#191978). That hides this bug.
About the dxcpl settings: I only set the scope list and Feature level limit = 10_0. I left Disable Feature Level Upgrade and Force WARP unchecked, so ANGLE ran on the GPU driver, not on WARP. I haven't tested with WARP, so I'd uncheck both to match my setup. The limit only works for paths saved by dxcpl itself, so a quick check is that the build without #192917 shows a black window.
With #192917 applied:
BackdropFilterin the same pass is erased, and only the blurred card, "Flutter" and the labels drawn after it remain.BackdropFilterremoved from the sample. It shows that Impeller renders everything else correctly and that the filter is the trigger.puneetkukreja98 commentedon Sep 17, 2026
@krll-kov Thanks for the response, routing this to team-engine for further investigation.
3 remaining items
gaaclarke commentedon Sep 22, 2026
This test shows mocking out capabilities to force things like that:
flutter/engine/src/flutter/impeller/display_list/canvas_unittests.cc
Line 699 in 860ac7c
I get that we are getting a feedback loop where we are sampling from the same texture we are drawing to. Gemini seems against option A. It's proposing a tweak to option B. It seems to believe that when we are drawing with the feedback look, it's a superfluous draw. The contents are already in there from the load. I haven't had a chance to dig into it and verify yet though.
Here's what it is proposing which looks much cleaner if it is correct. After an InlinePassContext keeps track if it loaded the contents or not in a new method
bool DidLoadPreviousContents() const.RenderPass& current_render_pass = *render_passes_.back().GetInlinePassContext()->GetRenderPass(); - // Eagerly restore the BDF contents. - - // If the pass context returns a backdrop texture, we need to draw it to the - // current pass. We do this because it's faster and takes significantly less - // memory than storing/loading large MSAA textures. Also, it's not possible - // to blit the non-MSAA resolve texture of the previous pass to MSAA - // textures (let alone a transient one). - Rect size_rect = Rect::MakeSize(input_texture->GetSize()); - auto msaa_backdrop_contents = TextureContents::MakeRect(size_rect); - msaa_backdrop_contents->SetStencilEnabled(false); - msaa_backdrop_contents->SetLabel("MSAA backdrop"); - msaa_backdrop_contents->SetSourceRect(size_rect); - msaa_backdrop_contents->SetTexture(input_texture); - - Entity msaa_backdrop_entity; - msaa_backdrop_entity.SetContents(std::move(msaa_backdrop_contents)); - msaa_backdrop_entity.SetBlendMode(BlendMode::kSrc); - msaa_backdrop_entity.SetClipDepth(std::numeric_limits<uint32_t>::max()); - if (!msaa_backdrop_entity.Render(renderer_, current_render_pass)) { - VALIDATION_LOG << "Failed to render MSAA backdrop entity."; - return nullptr; - } + // Eagerly restore the BDF contents. + // + // If the new pass did not load what the pass target already held, we draw + // the backdrop texture into it. We do this because it's faster and takes + // significantly less memory than storing/loading large MSAA textures. Also, + // it's not possible to blit the non-MSAA resolve texture of the previous + // pass to MSAA textures (let alone a transient one). + // + // If the new pass did load them, the attachment is `input_texture` itself + // and the contents are already there. Drawing them again would sample a + // texture that is attached to the active pass, which is undefined on every + // backend: a rendering feedback loop in GL, an illegal SRV/RTV pair in + // D3D11 (ANGLE unbinds the SRV and the shader samples zeros), and an image + // layout hazard in Vulkan. + const bool contents_already_present = + render_passes_.back().GetInlinePassContext()->DidLoadPreviousContents(); + + FML_DCHECK(!contents_already_present || + render_passes_.back() + .GetEntityPassTarget() + ->GetRenderTarget() + .GetColorAttachment(0) + .texture == input_texture) + << "A pass that loaded its previous contents must be writing to the " + "texture the backdrop is read from."; + + if (!contents_already_present) { + Rect size_rect = Rect::MakeSize(input_texture->GetSize()); + auto msaa_backdrop_contents = TextureContents::MakeRect(size_rect); + msaa_backdrop_contents->SetStencilEnabled(false); + msaa_backdrop_contents->SetLabel("MSAA backdrop"); + msaa_backdrop_contents->SetSourceRect(size_rect); + msaa_backdrop_contents->SetTexture(input_texture); + + Entity msaa_backdrop_entity; + msaa_backdrop_entity.SetContents(std::move(msaa_backdrop_contents)); + msaa_backdrop_entity.SetBlendMode(BlendMode::kSrc); + msaa_backdrop_entity.SetClipDepth(std::numeric_limits<uint32_t>::max()); + if (!msaa_backdrop_entity.Render(renderer_, current_render_pass)) { + VALIDATION_LOG << "Failed to render MSAA backdrop entity."; + return nullptr; + } + }Even though this bug seems to be showing up in OpenGLES renderer, the feedback loop is undefined everywhere and it's good to fix it at the canvas level.
krll-kov commentedon Sep 22, 2026
I just understood that i missed one case for grouped filters (where they differ and another backdrop filter or advanced blend comes between them) and fix B is not final, tested Gemini/B proposal again, and Gemini's version renders the same frames as B. It fixes 13 scenes, but breaks
BackdropGroupwith different filters when another backdrop filter or advanced blend comes between the grouped ones. The later grouped filter reads the cachedtexture_slot, which without MSAA is the pass target itself, so it also sees what was drawn after the first grouped filter:Flipping to a second texture when the backdrop is cached (
should_remove_texture) fixes this. With that flip, Gemini'sDCHECKfails in every group scene: the pass still loads withkLoad, but it writes to the second texture. So the diff below skips the draw by comparing the attachment withinput_texture. Without any fix, advanced blends break the same way, for example inside aClipRect.Unlike A, the diff only allocates the second texture for such groups and doesn't copy the frame on every flip. The diff is on main, I tested the same change on 3.47.4.
Diff, +41 −2
Impeller on non 10_0 (and same output for new diff) (like it has to work):


Impeller 10_0 + gemin (first = red square, second = blue square)i:

As for the test:
According to what's written in comments
MockCapabilitiesonly works with Metal playground (PlaygroundImplVKandPlaygroundImplGLESreturnkUnimplemented), and I only have 3.47.4 x64 with #192917 testsdk on Windows. The test I'd add is the group scene above withSupportsOffscreenMSAA()returning false, checking that the red square isn't in the last filter, but i can not verity it without building the same engine on mac for this test.krll-kov commentedon Sep 22, 2026
And as far as i understand Skia seems to ignore the grouping and renders the same as gemini proposal but differs from non-broken impeller version that is launched on non 10_0 feature level. Is that right, and such result is expected? I assume the Impeller behavior is intended one
gaaclarke commentedon Sep 22, 2026
I'm not 100% clear on the reproduction but duplicating the output of Skia is good. If you can avoid it I'd just push the fix for all those cases, duplicating Skia. Then we can file a follow up issue for that last case. It sounds like there may be some difficulty testing on the windows platform. Let me know if you need to hand it off to one of us. Windows isn't our main development platform, so I wouldn't be surprised if there are some gaps there, and it's probably non-trivial to get it set up. Thanks @krll-kov for digging into this.
krll-kov commentedon Sep 22, 2026
@gaaclarke added pr #193178 with the fix that duplicates Skia everywhere, as you suggested
The PR is a draft, because I am at the open pull request limit, but it's ready and tested
gaaclarke commentedon Sep 22, 2026
krll-kov commentedon Sep 22, 2026
gaaclarke commentedon Sep 22, 2026
gaaclarke commentedon Sep 22, 2026
Removes feedback loop from advanced filters without offscreen msaa an…
Removes feedback loop from advanced filters without offscreen msaa an…
Removes feedback loop from advanced filters without offscreen msaa an…
github-actions commentedon Oct 10, 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.