Skip to content

[Impeller] [Windows] BackdropFilter erases content drawn before it when offscreen MSAA is unavailable (OpenGL ES 2.0, D3D11 feature level 10_0) #192918

Closed

Description

@krll-kov

Steps to reproduce

  1. 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.
  2. 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 when dxcpl itself saves the entry. ANGLE then creates an OpenGL ES 2.0 context.
  3. 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:

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 =

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;
}

no backdrop:
Image

before:
Image

after:
Image

Activity

  1. krll-kov commented on Sep 17, 2026

    @krll-kov
    ContributorAuthor

    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 dxcpl feature 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::FlipWithoutMSAA swaps color attachment 0 with a lazily allocated second texture, and FlipBackdrop calls it before the next pass, so that pass writes to a different texture than the one the backdrop is read from.

    Diff, +32 −1
    --- a/engine/src/flutter/impeller/display_list/canvas.cc
    +++ b/engine/src/flutter/impeller/display_list/canvas.cc
    @@ -2567,7 +2567,9 @@
         return nullptr;
       }
     
    -  const std::shared_ptr<Texture>& input_texture =
    +  // A copy, not a reference: FlipWithoutMSAA below replaces the texture the
    +  // pass target holds
    +  std::shared_ptr<Texture> input_texture =
           rendering_config.GetInlinePassContext()->GetTexture();
     
       if (!input_texture) {
    @@ -2599,6 +2601,9 @@
         requires_readback_ = false;
       } else {
         render_passes_.emplace_back(std::move(rendering_config));
    +    // Without MSAA the next pass would load and write the same texture the
    +    // backdrop is read from, and the backend reads zeros from its own target
    +    render_passes_.back().GetEntityPassTarget()->FlipWithoutMSAA(renderer_);
         // If the current texture is being cached for a BDF we need to ensure we
         // don't recycle it during recording; remove it from the entity pass target.
         if (should_remove_texture) {
    --- a/engine/src/flutter/impeller/entity/entity_pass_target.cc
    +++ b/engine/src/flutter/impeller/entity/entity_pass_target.cc
    @@ -76,6 +76,28 @@
       return target_.IsValid();
     }
     
    +void EntityPassTarget::FlipWithoutMSAA(const ContentContext& renderer) {
    +  ColorAttachment color0 = target_.GetColorAttachment(0);
    +  if (color0.resolve_texture || !color0.texture) {
    +    return;
    +  }
    +  if (!secondary_color_texture_) {
    +    RenderTarget target = renderer.GetRenderTargetCache()->CreateOffscreen(
    +        *renderer.GetContext(), color0.texture->GetSize(), /*mip_count=*/1,
    +        "EntityPassTarget Secondary", RenderTarget::kDefaultColorAttachmentConfig,
    +        /*stencil_attachment_config=*/std::nullopt,
    +        /*existing_color_texture=*/nullptr,
    +        /*existing_depth_stencil_texture=*/nullptr,
    +        color0.texture->GetTextureDescriptor().format);
    +    secondary_color_texture_ = target.GetRenderTargetTexture();
    +    if (!secondary_color_texture_) {
    +      return;
    +    }
    +  }
    +  std::swap(color0.texture, secondary_color_texture_);
    +  target_.SetColorAttachment(color0, 0);
    +}
    +
     void EntityPassTarget::RemoveSecondary() {
       secondary_color_texture_ = nullptr;
     }
    --- a/engine/src/flutter/impeller/entity/entity_pass_target.h
    +++ b/engine/src/flutter/impeller/entity/entity_pass_target.h
    @@ -27,6 +27,10 @@
       ///         method).
       std::shared_ptr<Texture> Flip(const ContentContext& renderer);
     
    +  /// Moves a target without MSAA to a second color texture, so the next pass
    +  /// can read the previous texture as a backdrop. Does nothing for MSAA targets.
    +  void FlipWithoutMSAA(const ContentContext& renderer);
    +
       RenderTarget& GetRenderTarget();
     
       /// @brief Remove the cached secondary color texture.

    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 EntityPassTargetTest cases (the swap and its reversal on a non-MSAA target, no change on an MSAA target). They pass on Vulkan with SwiftShader. Removing the std::swap or the resolve_texture check 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 is input_texture.

    Diff, +10 −1
    --- a/engine/src/flutter/impeller/display_list/canvas.cc
    +++ b/engine/src/flutter/impeller/display_list/canvas.cc
    @@ -2626,7 +2626,16 @@
       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)) {
    +  // A non-MSAA pass writes to the input texture and loads it with
    +  // LoadAction::kLoad, so the backdrop is already there. Drawing the texture
    +  // into itself is a feedback loop, and ANGLE on D3D11 samples zeros.
    +  bool target_is_input = render_passes_.back()
    +                             .GetEntityPassTarget()
    +                             ->GetRenderTarget()
    +                             .GetColorAttachment(0)
    +                             .texture == input_texture;
    +  if (!target_is_input &&
    +      !msaa_backdrop_entity.Render(renderer_, current_render_pass)) {
         VALIDATION_LOG << "Failed to render MSAA backdrop entity.";
         return nullptr;
       }

    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 Canvas level 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):

    Scene A B
    BackdropFilter blur over a ring blurred area identical identical to A
    BackdropFilter with ColorFilter.matrix identical identical to A
    Two BackdropFilter.grouped blurs identical identical to A
    Red square drawn between two grouped blurs, under the second one covered, as with MSAA identical to A
    BlendMode.multiply over a gradient identical identical to A

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

    Image Image Image
  2. puneetkukreja98 commented on Sep 17, 2026

    @puneetkukreja98
    Contributor

    @krll-kov Thanks for reporting this and for the detailed write-up! Could you please confirm if my understanding is correct:

    1. Initially, I ran your sample code on my machine using the latest master branch. The output was normal without any black screen, both with Impeller enabled and disabled.
    2. Since my hardware doesn't match the constraint, it didn't reproduce out of the box. So, I followed Step 2 using dxcpl:
    • Win + R > typed dxcpl
    • Clicked Edit List...
    • Added the Flutter Windows executable path: build\windows\x64\runner\Debug\issue.exe
    • In Device Settings, set the Feature level limit dropdown to 10_0
    • Checked Disable Feature Level Upgrade and Force WARP
    • Clicked Apply and OK

    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

    1. With Impeller enabled (default), will we see the "before" state?
    2. If we disable Impeller (Skia fallback), we will see the "no backdrop"?
    3. And once either Fix A or Fix B from your second comment is applied on top, it will produce the "after" image?
  3. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 17, 2026
  4. krll-kov commented on Sep 17, 2026

    @krll-kov
    ContributorAuthor

    @krll-kov Thanks for reporting this and for the detailed write-up! Could you please confirm if my understanding is correct:

    1. Initially, I ran your sample code on my machine using the latest master branch. The output was normal without any black screen, both with Impeller enabled and disabled.
    
    2. Since my hardware doesn't match the constraint, it didn't reproduce out of the box. So, I followed Step 2 using dxcpl:
    
    
    * Win + R > typed dxcpl
    
    * Clicked Edit List...
    
    * Added the Flutter Windows executable path: build\windows\x64\runner\Debug\issue.exe
    
    * In Device Settings, set the Feature level limit dropdown to 10_0
    
    * Checked Disable Feature Level Upgrade and Force WARP
    
    * Clicked Apply and OK
    

    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

    1. With Impeller enabled (default), will we see the "before" state?
    
    2. If we disable Impeller (Skia fallback), we will see the "no backdrop"?
    
    3. And once either Fix A or Fix B from your second comment is applied on top, it will produce the "after" image?
    

    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:

    • Impeller (default): yes, the "before" state. Everything drawn before the BackdropFilter in the same pass is erased, and only the blurred card, "Flutter" and the labels drawn after it remain.
    • Skia: the full scene with the blur, the same as the "after" image. The "no BackdropFilter" screenshot is Impeller with the BackdropFilter removed from the sample. It shows that Impeller renders everything else correctly and that the filter is the trigger.
    • Impeller with Fix A or Fix B on top: the "after" image. The whole scene renders, with the same blur as Impeller without the limit.
    Image Image
  5. added
    engineflutter/engine related. See also e: labels.
    e: impellerImpeller rendering backend issues and features requests
    platform-windowsBuilding on or for Windows specifically
    frameworkflutter/packages/flutter repository. See also f: labels.
    and removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 17, 2026
  6. puneetkukreja98 commented on Sep 17, 2026

    @puneetkukreja98
    Contributor

    @krll-kov Thanks for the response, routing this to team-engine for further investigation.

  7. added
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    on Sep 17, 2026
  8. 3 remaining items

  9. gaaclarke commented on Sep 22, 2026

    @gaaclarke
    Member

    It needs a Canvas level test with a context without offscreen MSAA, and I did not find an existing harness for that.

    This test shows mocking out capabilities to force things like that:

    TEST_P(AiksTest, EmulatedAdvancedBlendPreservesDepthMonotonicity) {

    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.

  10. krll-kov commented on Sep 22, 2026

    @krll-kov
    ContributorAuthor

    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.

    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 BackdropGroup with different filters when another backdrop filter or advanced blend comes between the grouped ones. The later grouped filter reads the cached texture_slot, which without MSAA is the pass target itself, so it also sees what was drawn after the first grouped filter:

    BackdropGroup(child: Stack(children: [
      background,
      grouped(blur(6)),          
      redSquareUnderTheLastOne,
      BackdropFilter(blur(10)), 
      grouped(blur(18)),  
    ]))

    Flipping to a second texture when the backdrop is cached (should_remove_texture) fixes this. With that flip, Gemini's DCHECK fails in every group scene: the pass still loads with kLoad, but it writes to the second texture. So the diff below skips the draw by comparing the attachment with input_texture. Without any fix, advanced blends break the same way, for example inside a ClipRect.

    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
    diff --git a/engine/src/flutter/impeller/display_list/canvas.cc b/engine/src/flutter/impeller/display_list/canvas.cc
    --- a/engine/src/flutter/impeller/display_list/canvas.cc
    +++ b/engine/src/flutter/impeller/display_list/canvas.cc
    @@ -2567,7 +2567,9 @@
         return nullptr;
       }
     
    -  const std::shared_ptr<Texture>& input_texture =
    +  // FlipWithoutMSAA below replaces the texture the pass target holds, so this
    +  // is a copy and not a reference
    +  std::shared_ptr<Texture> input_texture =
           rendering_config.GetInlinePassContext()->GetTexture();
     
       if (!input_texture) {
    @@ -2602,6 +2604,10 @@
         // If the current texture is being cached for a BDF we need to ensure we
         // don't recycle it during recording; remove it from the entity pass target.
         if (should_remove_texture) {
    +      // Without MSAA the pass would keep writing to the cached texture, and a
    +      // later flip would store the content drawn after it before the next
    +      // grouped filter reads it, so we switch to a second texture
    +      render_passes_.back().GetEntityPassTarget()->FlipWithoutMSAA(renderer_);
           render_passes_.back().GetEntityPassTarget()->RemoveSecondary();
         }
       }
    @@ -2626,7 +2632,16 @@
       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)) {
    +  // A pass without MSAA that writes to `input_texture` already loaded it with
    +  // `LoadAction::kLoad`. Drawing it into itself is a feedback loop, and on
    +  // ANGLE with D3D11 the shader samples zeros.
    +  const bool target_is_input = render_passes_.back()
    +                                   .GetEntityPassTarget()
    +                                   ->GetRenderTarget()
    +                                   .GetColorAttachment(0)
    +                                   .texture == input_texture;
    +  if (!target_is_input &&
    +      !msaa_backdrop_entity.Render(renderer_, current_render_pass)) {
         VALIDATION_LOG << "Failed to render MSAA backdrop entity.";
         return nullptr;
       }
    diff --git a/engine/src/flutter/impeller/entity/entity_pass_target.cc b/engine/src/flutter/impeller/entity/entity_pass_target.cc
    --- a/engine/src/flutter/impeller/entity/entity_pass_target.cc
    +++ b/engine/src/flutter/impeller/entity/entity_pass_target.cc
    @@ -76,6 +76,28 @@
       return target_.IsValid();
     }
     
    +void EntityPassTarget::FlipWithoutMSAA(const ContentContext& renderer) {
    +  ColorAttachment color0 = target_.GetColorAttachment(0);
    +  if (color0.resolve_texture || !color0.texture) {
    +    return;
    +  }
    +  if (!secondary_color_texture_) {
    +    RenderTarget target = renderer.GetRenderTargetCache()->CreateOffscreen(
    +        *renderer.GetContext(), color0.texture->GetSize(), /*mip_count=*/1,
    +        "EntityPassTarget Secondary", RenderTarget::kDefaultColorAttachmentConfig,
    +        /*stencil_attachment_config=*/std::nullopt,
    +        /*existing_color_texture=*/nullptr,
    +        /*existing_depth_stencil_texture=*/nullptr,
    +        color0.texture->GetTextureDescriptor().format);
    +    secondary_color_texture_ = target.GetRenderTargetTexture();
    +    if (!secondary_color_texture_) {
    +      return;
    +    }
    +  }
    +  std::swap(color0.texture, secondary_color_texture_);
    +  target_.SetColorAttachment(color0, 0);
    +}
    +
     void EntityPassTarget::RemoveSecondary() {
       secondary_color_texture_ = nullptr;
     }
    diff --git a/engine/src/flutter/impeller/entity/entity_pass_target.h b/engine/src/flutter/impeller/entity/entity_pass_target.h
    --- a/engine/src/flutter/impeller/entity/entity_pass_target.h
    +++ b/engine/src/flutter/impeller/entity/entity_pass_target.h
    @@ -27,6 +27,10 @@
       ///         method).
       std::shared_ptr<Texture> Flip(const ContentContext& renderer);
     
    +  /// Moves a target without MSAA to a second color texture, so the next pass
    +  /// can read the previous texture as a backdrop. Does nothing for MSAA targets.
    +  void FlipWithoutMSAA(const ContentContext& renderer);
    +
       RenderTarget& GetRenderTarget();
     
       /// @brief Remove the cached secondary color texture.
    

    Impeller on non 10_0 (and same output for new diff) (like it has to work):
    Image
    Image

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

    Image

    As for the test:
    According to what's written in comments MockCapabilities only works with Metal playground (PlaygroundImplVK and PlaygroundImplGLES return kUnimplemented), and I only have 3.47.4 x64 with #192917 testsdk on Windows. The test I'd add is the group scene above with SupportsOffscreenMSAA() 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.

  11. krll-kov commented on Sep 22, 2026

    @krll-kov
    ContributorAuthor

    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

    Image Image
  12. gaaclarke commented on Sep 22, 2026

    @gaaclarke
    Member

    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

    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.

  13. krll-kov commented on Sep 22, 2026

    @krll-kov
    ContributorAuthor

    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

    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.

    @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

  14. gaaclarke commented on Sep 22, 2026

    @gaaclarke
  15. krll-kov commented on Sep 22, 2026

    @krll-kov
    Author
  16. gaaclarke commented on Sep 22, 2026

    @gaaclarke
  17. gaaclarke commented on Sep 22, 2026

    @gaaclarke
  18. github-actions commented on Oct 10, 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.

  19. locked as resolved and limited conversation to collaborators on Oct 10, 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 lista: desktopRunning on desktopc: renderingUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.frameworkflutter/packages/flutter repository. See also f: labels.platform-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