Skip to content

ImageFilter.blur breaks ImageFilter.shader in a BackdropFilter together #170820

Closed

Description

@timcreatedit

Steps to reproduce

When trying to find a workaround for #170792 I stumbled on another roadblock.

In an Impeller build, do the following:

  1. create a BackdropFilterLayer with its filter set to ImageFilter.combined, with inner being a blur and outer being a shader
  2. When you change sigmaX and sigmaY, the size of the texture that's passed to the shader seems to change, but not in a uniform way

Check the code sample for a reliable repro.

Expected results

The red circle stays in the same position with the same size the entire time.

Actual results

See video

Code sample

Code sample
// main.dart
import 'dart:ui';

import 'package:flutter/material.dart';

void main() async {
  final shader = await FragmentProgram.fromAsset('shaders/circle.frag');

  runApp(MainApp(shader: shader));
}

class MainApp extends StatefulWidget {
  const MainApp({super.key, required this.shader});

  final FragmentProgram shader;

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
  late final animationController = AnimationController(
    vsync: this,
    lowerBound: 0,
    upperBound: 10,
    duration: Duration(seconds: 2),
  )..repeat(reverse: true);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ListenableBuilder(
        listenable: animationController,
        builder: (context, child) {
          return Scaffold(
            body: Stack(
              children: [
                const Center(child: FlutterLogo(size: 100)),
                Positioned.fill(
                  child: LayoutBuilder(
                    builder: (context, constraints) {
                      return BackdropFilter(
                        filter: ImageFilter.compose(
                          inner: ImageFilter.blur(
                            sigmaX: animationController.value,
                            sigmaY: animationController.value,
                            tileMode: TileMode.clamp,
                          ),
                          outer: ImageFilter.shader(
                            widget.shader.fragmentShader()
                              ..setFloat(2, 100) // uCirclePos.x (center)
                              ..setFloat(3, 100) // uCirclePos.y (center)
                              ..setFloat(4, 100.0), // uCircleRadius
                          ),
                        ),
                        child: Center(
                          child: Text(
                            animationController.value.toStringAsFixed(2),
                          ),
                        ),
                      );
                    },
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

With this fragment shader:

#version 320 es
precision mediump float;

uniform vec2 uResolution;
uniform vec2 uCirclePos;
uniform float uCircleRadius;
uniform sampler2D uTexture;

out vec4 fragColor;

#include <flutter/runtime_effect.glsl>

float sdfCircle(vec2 p, float radius) {
    return length(p) - radius;
}

void main() {
    vec2 fragCoord = FlutterFragCoord().xy;
    vec2 screenUV = fragCoord / uResolution;
    
    vec2 normalizedCoord = (fragCoord - uResolution * 0.5) / min(uResolution.x, uResolution.y);
    vec2 normalizedCirclePos = (uCirclePos - uResolution * 0.5) / min(uResolution.x, uResolution.y);
    
    float normalizedRadius = uCircleRadius / min(uResolution.x, uResolution.y);
    float distance = sdfCircle(normalizedCoord - normalizedCirclePos, normalizedRadius);
    float circleAlpha = 1.0 - smoothstep(-0.01, 0.01, distance);
    vec4 backgroundColor = texture(uTexture, screenUV);
    vec4 circleColor = vec4(1.0, 0.0, 0.0, circleAlpha);
    fragColor = mix(backgroundColor, circleColor, circleAlpha * 0.8);
}

Screenshots or Video

Screenshots / Video demonstration
CleanShot.2025-06-18.at.19.51.11.mp4

Logs

No response

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.32.4, on macOS 15.5 24F74 darwin-arm64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.4)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.101.0)
[✓] Connected device (2 available)
    ! Error: Browsing on the local area network for iPhone von mir. Ensure the device is
      unlocked and attached with a cable or associated with the same local area network
      as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for Tim’s iPad. Ensure the device is
      unlocked and attached with a cable or associated with the same local area network
      as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources

Activity

  1. dkwingsmt commented on Jun 18, 2025

    @dkwingsmt
    Contributor
  2. jonahwilliams commented on Jun 18, 2025

    @jonahwilliams
    Contributor

    Probably what is happening is that we're dropping and/or not accounting for the scaling factor from the backdrop blur.

  3. timcreatedit commented on Jun 18, 2025

    @timcreatedit
    ContributorAuthor

    Hi @jonahwilliams, thanks for the swift response! Could you perhaps point me to where that scaling factor is calculated, so I can work around the issue until it is fixed?

  4. darshankawar commented on Jun 19, 2025

    @darshankawar
    Member

    Thanks for the report. Seeing the same behavior with latest sdk versions.

    stable : 3.32.4
    master : 3.33.0-1.0.pre.584
    
  5. added
    engineflutter/engine related. See also e: labels.
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Jun 19, 2025
  6. sangddn commented on Jun 20, 2025

    @sangddn

    I found this temporary workaround which looks fine to me. You can push 2 separate layers instead of using ImageFilter.compose:

    import 'dart:ui';
    
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    void main() async {
      final shader = await FragmentProgram.fromAsset('shaders/circle.frag');
      runApp(MainApp(shader: shader));
    }
    
    class MainApp extends StatefulWidget {
      const MainApp({super.key, required this.shader});
    
      final FragmentProgram shader;
    
      @override
      State<MainApp> createState() => _MainAppState();
    }
    
    class _MainAppState extends State<MainApp> with SingleTickerProviderStateMixin {
      late final animationController = AnimationController(
        vsync: this,
        lowerBound: 0,
        upperBound: 10,
        duration: Duration(seconds: 2),
      )..repeat(reverse: true);
    
      @override
      void dispose() {
        animationController.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: ListenableBuilder(
            listenable: animationController,
            builder: (context, child) {
              return Scaffold(
                body: Stack(
                  children: [
                    const Center(child: FlutterLogo(size: 100)),
                    Positioned.fill(
                      child: LayeredBackdropEffect(
                        blurValue: animationController.value,
                        shader: widget.shader,
                        child: Center(
                          child: Text(animationController.value.toStringAsFixed(2)),
                        ),
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        );
      }
    }
    
    class LayeredBackdropEffect extends SingleChildRenderObjectWidget {
      const LayeredBackdropEffect({
        super.key,
        required this.blurValue,
        required this.shader,
        super.child,
      });
    
      final double blurValue;
      final FragmentProgram shader;
    
      @override
      RenderObject createRenderObject(BuildContext context) =>
          LayeredBackdropRenderObject(blurValue: blurValue, shader: shader);
    
      @override
      void updateRenderObject(
        BuildContext context,
        LayeredBackdropRenderObject renderObject,
      ) {
        renderObject
          ..blurValue = blurValue
          ..shader = shader;
      }
    }
    
    class LayeredBackdropRenderObject extends RenderProxyBox {
      LayeredBackdropRenderObject({
        required double blurValue,
        required FragmentProgram shader,
        RenderBox? child,
      }) : _blurValue = blurValue,
           _shader = shader,
           super(child);
    
      double _blurValue;
      double get blurValue => _blurValue;
      set blurValue(double value) {
        if (_blurValue == value) return;
        _blurValue = value;
        markNeedsPaint();
      }
    
      FragmentProgram _shader;
      FragmentProgram get shader => _shader;
      set shader(FragmentProgram value) {
        if (_shader == value) return;
        _shader = value;
        markNeedsPaint();
      }
    
      @override
      bool get alwaysNeedsCompositing => true;
    
      @override
      void paint(PaintingContext context, Offset offset) {
        final blur = ImageFilter.blur(
          sigmaX: blurValue,
          sigmaY: blurValue,
          tileMode: TileMode.clamp,
        );
        final blurLayer = BackdropFilterLayer(
          filter: blur,
          blendMode: BlendMode.srcOver,
        );
    
        final shaderFilter = ImageFilter.shader(
          shader.fragmentShader()
            ..setFloat(2, 100) // uCirclePos.x (center)
            ..setFloat(3, 100) // uCirclePos.y (center)
            ..setFloat(4, 100.0), // uCircleRadius
        );
        final shaderLayer = BackdropFilterLayer(
          filter: shaderFilter,
          blendMode: BlendMode.srcOver,
        );
    
        context.pushLayer(blurLayer, (ctx, offset) {
          ctx.pushLayer(shaderLayer, (_, _) {}, offset);
        }, offset);
    
        if (child != null) {
          context.paintChild(child!, offset);
        }
      }
    }
    Screen.Recording.2025-06-20.at.11.18.21.AM.mov
  7. timcreatedit commented on Jun 20, 2025

    @timcreatedit
    ContributorAuthor

    Thanks @sangddn, that's a smart solution but it's functionally not quite the same. The crucial thing I'm looking for is for my shader to be able to discard pixels from the blur based on a mask. Your workaround works for the repro, but it also leads to the blur always being painted, no matter what the shader does after.

  8. arihant23 commented on Jun 23, 2025

    @arihant23
  9. added
    P2Important issues not at the top of the work list
    on Jun 23, 2025
  10. jonahwilliams commented on Jun 25, 2025

    @jonahwilliams
    Contributor

    I don't have it tested yet, and I confirm this is different than #170973

    If the snapshot transform has a translation, we need to render to texture first. note that this diff also scales back up but that is unnecessary.

    diff --git a/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc b/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc
    index 445cd0cf094..f17692c7009 100644
    --- a/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc
    +++ b/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc
    @@ -10,6 +10,7 @@
     #include "impeller/base/validation.h"
     #include "impeller/entity/contents/anonymous_contents.h"
     #include "impeller/entity/contents/runtime_effect_contents.h"
    +#include "impeller/entity/contents/texture_contents.h"
     #include "impeller/geometry/size.h"
     
     namespace impeller {
    @@ -62,11 +63,39 @@ std::optional<Entity> RuntimeEffectFilterContents::RenderFilter(
         return std::nullopt;
       }
     
    +  // If the input snapshot does not have an identity transform, the
    +  // ImageFilter.shader will not correctly render as it does not know what the
    +  // transform is in order to incorporate this into sampling. We could fix this
    +  // with ImageFilter.shader2 that lets us write the transform matrix into the
    +  // uniforms. Until that point, we need to re-rasterize the input snapshot so
    +  // that the transform is absorbed into the texture.
    +  if (!input_snapshot->transform.IsIdentity()) {
    +    Matrix inverse = input_snapshot->transform.Invert();
    +    Quad quad = inverse.Transform(Quad{
    +        coverage.GetLeftTop(),     //
    +        coverage.GetRightTop(),    //
    +        coverage.GetLeftBottom(),  //
    +        coverage.GetRightBottom()  //
    +    });
    +    TextureContents texture_contents;
    +    texture_contents.SetTexture(input_snapshot->texture);
    +    std::optional<Rect> bounds =
    +        Rect::MakePointBounds(quad.begin(), quad.end());
    +    if (bounds.has_value()) {
    +      texture_contents.SetSourceRect(bounds.value());
    +      texture_contents.SetDestinationRect(coverage);
    +      texture_contents.SetStencilEnabled(false);
    +      texture_contents.SetSamplerDescriptor(input_snapshot->sampler_descriptor);
    +
    +      Entity entity;
    +      input_snapshot = texture_contents.RenderToSnapshot(renderer, entity);
    +    }
    +  }
    +
  11. gaaclarke commented on Oct 27, 2025

    @gaaclarke
    Member

    So, the reason this happens is because the blur will return different sized images depending on the sigma. When you blur something a lot there is no reason to perform the blur in a high resolution because the result is blurry. So for performance reasons we do the blur in lower resolutions. Thus the normalization math are getting different results.

    I attempted a workaround by sending the uniforms for the circle as normalized already. This doesn't really work because as the sigma is increased the circle will drift away from the origin because there is also an offset baked into those textures for the blur radius. The animation also gets a bit chunky presumably because of the lower resolution.

    I think jonah's estimation may be correct that we should snapshot in this case. In his comment though he was talking about snapshotting if the transform is not identity. We've already shown that we can render it correctly without snapshotting when the transform is just a translation since that was the thing we fixed in #177510. I think in this case a better heuristic may be something like "if the coverage of the input to inner doesn't match the coverage of the output to inner, do a snapshot"? Or maybe "does the inner output transform have a non-translation transform"?

    Alternatives considered:

    • Pass the information of the transform of the input into the fragment shader so that we could calculate the normalized coordinates correctly. That isn't ideal though because we'd still be rendering the circle at a lower resolution which is not what we want.
    • Add the ability to turn off the downsampling of the blur -- This would hurt the performance of the blur and potentially crash the blur as the kernel gets big.
    • User could implement their own blur with the FragmentShader API -- Obviously this is onerous.
  12. gaaclarke commented on Oct 27, 2025

    @gaaclarke
    Member

    Side note: I don't think what the op is attempting here is going to be portable because of high dpi screens. They'll probably have to query the devicePixelRatio. edit: or send in the coordinates as normalized coordinates?

  13. timcreatedit commented on Oct 28, 2025

    @timcreatedit
    ContributorAuthor

    or send in the coordinates as normalized coordinates?

    That's usually what I do, but it was besides the point here, this example is really just to show that the coordinate system changes where it shouldn't. Not anything real that I'm working on :)

  14. self-assigned this
    on Oct 29, 2025
  15. added
    r: fixedIssue is closed as already fixed in a newer version
    on Oct 31, 2025
  16. github-actions commented on Nov 14, 2025

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

  17. locked as resolved and limited conversation to collaborators on Nov 14, 2025
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 liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.found in release: 3.32Found to occur in 3.32found in release: 3.33Found to occur in 3.33has reproducible stepsThe issue has been confirmed reproducible and is ready to work onr: fixedIssue is closed as already fixed in a newer versionteam-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