Skip to content

UI glitches when Impeller is enabled on some Android devices. #171772

Closed

Description

@gvozditskiy

Steps to reproduce

  1. Launch the app with Impeller rendering enabled (reproduced on Samsung S25 Ultra)
  2. On the main screen, scroll up and down repeatedly
  3. Open the second page

Expected results

Absence of visual glitches, matching the behaviour observed with Impeller disabled

Actual results

UI glitches occur while scrolling or overscrolling, and also when opening the second screen

Code sample

Code sample
import 'dart:ui';
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: 'Impeller Glitch Reproduction',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const ChatPage(),
    );
  }
}

class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  final List<MessageData> messages = [];
  final ScrollController _scrollController = ScrollController();
  final Set<String> _placedItems = {};

  @override
  void initState() {
    super.initState();
    for (var i = 0; i < 50; i++) {
      _addMessage();
    }
  }

  void _addMessage() {
    final newMessage = MessageData(
      id: 'msg_${messages.length}',
      text: 'Message ${messages.length + 1}',
      isFromUser: messages.length % 2 == 0,
    );

    setState(() {
      messages.insert(0, newMessage);
    });
  }

  void _onItemPlaced(String itemId) {
    setState(() {
      _placedItems.add(itemId);
    });
  }

  void _navigateToBlurScreen() {
    Navigator.of(context).push(
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) => const BlurEffectsScreen(),
        transitionDuration: const Duration(milliseconds: 800),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return Stack(
            children: [
              BackdropFilter(
                filter: ImageFilter.blur(
                  sigmaX: animation.value * 15,
                  sigmaY: animation.value * 15,
                ),
                child: Container(color: Colors.black.withValues(alpha: animation.value * 0.3)),
              ),
              SlideTransition(
                position: Tween<Offset>(
                  begin: const Offset(1.0, 0.0),
                  end: Offset.zero,
                ).animate(animation),
                child: FadeTransition(opacity: animation, child: child),
              ),
            ],
          );
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey,
      appBar: AppBar(
        title: const Text('Impeller Glitch Test'),
        actions: [IconButton(icon: const Icon(Icons.blur_on), onPressed: _navigateToBlurScreen)],
      ),
      body: Stack(
        children: [
          // Essential shader mask with gradient
          Padding(
            padding: const EdgeInsets.only(top: 48, bottom: 60),
            child: ShaderMask(
              shaderCallback: (bounds) => LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [
                  Colors.black,
                  Colors.black.withValues(alpha: 0.6),
                  Colors.black.withValues(alpha: 0.2),
                  Colors.black.withValues(alpha: 0),
                ],
                stops: const [0, 0.5, 0.65, 0.9],
              ).createShader(Rect.fromLTWH(0, 0, bounds.width, 130)),
              blendMode: BlendMode.dstOut,
              child: ShaderMask(
                shaderCallback: (bounds) => LinearGradient(
                  begin: Alignment.bottomCenter,
                  end: Alignment.topCenter,
                  colors: [
                    Colors.black,
                    Colors.black.withValues(alpha: 0.6),
                    Colors.black.withValues(alpha: 0.2),
                    Colors.black.withValues(alpha: 0),
                  ],
                  stops: const [0, 0.5, 0.65, 0.9],
                ).createShader(Rect.fromLTRB(0, bounds.height - 24, bounds.width, bounds.height)),
                blendMode: BlendMode.dstOut,
                child: ListView.builder(
                  controller: _scrollController,
                  reverse: true,
                  padding: const EdgeInsets.only(top: 154, bottom: 60),
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    final message = messages[index];
                    final isPlaced = _placedItems.contains(message.id);
                    final isLast = index == 0;

                    if (!isPlaced && mounted) {
                      Future.delayed(Duration(milliseconds: 100 + (index * 50)), () {
                        if (mounted) _onItemPlaced(message.id);
                      });
                    }

                    return MessageBubble(
                      key: ValueKey(message.id),
                      message: message,
                      maxWidth: MediaQuery.of(context).size.width * 0.75,
                      placed: isPlaced,
                      last: isLast,
                    );
                  },
                ),
              ),
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addMessage,
        child: const Icon(Icons.add),
      ),
    );
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

class BlurEffectsScreen extends StatefulWidget {
  const BlurEffectsScreen({super.key});

  @override
  State<BlurEffectsScreen> createState() => _BlurEffectsScreenState();
}

class _BlurEffectsScreenState extends State<BlurEffectsScreen> with TickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(seconds: 2), vsync: this)..repeat();
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // Essential backdrop filter that triggers glitches
        AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return BackdropFilter(
              filter: ImageFilter.blur(
                sigmaX: 15 + (_animation.value * 10),
                sigmaY: 15 + (_animation.value * 10),
              ),
              child: Container(color: Colors.white.withValues(alpha: 0.1)),
            );
          },
        ),

        Center(
          child: AnimatedBuilder(
            animation: _animation,
            builder: (context, child) {
              return BackdropFilter(
                filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Icon(Icons.auto_awesome, color: Colors.white, size: 60),
                    const SizedBox(height: 20),
                    const Text(
                      'Blur Effects Screen',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 20),
                    ElevatedButton(
                      onPressed: () => Navigator.of(context).pop(),
                      child: const Text('Back to Chat'),
                    ),
                  ],
                ),
              );
            },
          ),
        ),

        Positioned(
          top: MediaQuery.of(context).padding.top + 10,
          right: 16,
          child: IconButton(
            icon: const Icon(Icons.close, color: Colors.white, size: 30),
            onPressed: () => Navigator.of(context).pop(),
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

class MessageData {
  final String id;
  final String text;
  final bool isFromUser;

  MessageData({required this.id, required this.text, required this.isFromUser});
}

class MessageBubble extends StatefulWidget {
  const MessageBubble({
    required this.message,
    required this.maxWidth,
    required this.placed,
    required this.last,
    super.key,
  });

  final MessageData message;
  final double maxWidth;
  final bool placed;
  final bool last;

  @override
  State<MessageBubble> createState() => _MessageBubbleState();
}

class _MessageBubbleState extends State<MessageBubble> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      lowerBound: widget.placed ? 1 : 0.000001,
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
    _animation = _animationController.drive(CurveTween(curve: Curves.linearToEaseOut));

    Future.delayed(const Duration(milliseconds: 100)).whenComplete(() {
      if (mounted) {
        if (!widget.placed) {
          _animationController.forward();
        } else {
          _animationController.value = 1;
        }
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final alignment = widget.message.isFromUser ? Alignment.centerRight : Alignment.centerLeft;
    final color = widget.message.isFromUser
        ? Colors.blue.withValues(alpha: 0.9)
        : Colors.grey.shade200;

    return Align(
      alignment: alignment,
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, _) {
          return Padding(
            padding: EdgeInsets.only(top: 5, bottom: widget.last ? 20 * _animation.value : 0),
            child: FittedBox(
              fit: BoxFit.scaleDown,
              child: SizeTransition(
                sizeFactor: _animation,
                child: ScaleTransition(
                  scale: _animation,
                  alignment: alignment,
                  child: FadeTransition(
                    opacity: _animation,
                    child: BackdropFilter(
                      filter: ImageFilter.blur(sigmaX: 0.5, sigmaY: 0.5),
                      child: _BubbleContent(
                        message: widget.message,
                        color: color,
                        maxWidth: widget.maxWidth,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

class _BubbleContent extends StatelessWidget {
  const _BubbleContent({required this.message, required this.color, required this.maxWidth});

  final MessageData message;
  final Color color;
  final double maxWidth;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        right: message.isFromUser ? 15 : 10,
        left: message.isFromUser ? 14 : 15,
      ),
      child: ClipPath(
        clipper: BubbleClipper(fromRobot: !message.isFromUser),
        child: DecoratedBox(
          decoration: BoxDecoration(color: color),
          child: Container(
            constraints: BoxConstraints(maxWidth: maxWidth * 0.8),
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
            child: Text(
              message.text,
              style: TextStyle(color: message.isFromUser ? Colors.white : Colors.black),
            ),
          ),
        ),
      ),
    );
  }
}

class BubbleClipper extends CustomClipper<Path> {
  const BubbleClipper({required this.fromRobot});

  final bool fromRobot;

  @override
  Path getClip(Size size) {
    final path = Path();
    const radius = 20.0;
    const tailSize = 8.0;

    if (fromRobot) {
      path.addRRect(
        RRect.fromLTRBAndCorners(
          tailSize,
          0,
          size.width,
          size.height,
          topLeft: const Radius.circular(radius),
          topRight: const Radius.circular(radius),
          bottomLeft: const Radius.circular(radius),
          bottomRight: const Radius.circular(radius),
        ),
      );
      path.moveTo(0, size.height - tailSize);
      path.lineTo(tailSize, size.height - tailSize);
      path.lineTo(tailSize, size.height);
      path.close();
    } else {
      path.addRRect(
        RRect.fromLTRBAndCorners(
          0,
          0,
          size.width - tailSize,
          size.height,
          topLeft: const Radius.circular(radius),
          topRight: const Radius.circular(radius),
          bottomLeft: const Radius.circular(radius),
          bottomRight: const Radius.circular(radius),
        ),
      );
      path.moveTo(size.width - tailSize, size.height - tailSize);
      path.lineTo(size.width - tailSize, size.height);
      path.lineTo(size.width, size.height - tailSize);
      path.close();
    }

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

Screenshots or Video

Screenshots / Video demonstration Impeller disabled:
telegram-cloud-document-2-5433647737025362042.mp4

Impeller enabled

telegram-cloud-document-2-5433647737025362045.mp4

Logs

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.32.5, on macOS 15.5 24F74 darwin-arm64, locale en-RU)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[!] Xcode - develop for iOS and macOS (Xcode 16.4)
    ! CocoaPods 1.15.0 out of date (1.16.2 is recommended).
        CocoaPods is a package manager for iOS or macOS platform code.
        Without CocoaPods, plugins will not work on iOS or macOS.
        For more info, see https://flutter.dev/to/platform-plugins
      To update CocoaPods, see
      https://guides.cocoapods.org/using/getting-started.html#updating-cocoapods
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.3)
[✓] Android Studio (version 2024.1)
[✓] Android Studio (version 2024.2.2)
[✓] IntelliJ IDEA Community Edition (version 2024.2.3)
[✓] VS Code (version 1.101.2)
[✓] Connected device (2 available)
[✓] Network resources

Activity

  1. darshankawar commented on Jul 9, 2025

    @darshankawar
    Member
    1. reproduced on Samsung S25 Ultra

    https://www.gsmarena.com/samsung_galaxy_s25_ultra-13322.php

    GPU : Adreno 830
    Chipset : Qualcomm SM8750-AB Snapdragon 8 Elite

  2. darshankawar commented on Jul 9, 2025

    @darshankawar
    Member

    I was unable to replicate this on S10+ device. Maybe this is related to #159876 but it was fixed in previous version already.

    @gvozditskiy Can you please switch to latest master and re-run to check if the behavior still persist ?

  3. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Jul 9, 2025
  4. gvozditskiy commented on Jul 9, 2025

    @gvozditskiy
    Author

    @darshankawar Flutter 3.34.0-0.1.pre - glitch persists

  5. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Jul 9, 2025
  6. changed the title [-]UI glitches when Impeller is enabled on Android[/-] [+]UI glitches when Impeller is enabled on Android (on Samsung S25 Ultra)[/+] on Jul 10, 2025
  7. added
    platform-androidAndroid applications specifically
    e: impellerImpeller rendering backend issues and features requests
    engineflutter/engine related. See also e: labels.
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    and removed
    in triagePresently being triaged by the triage team
    on Jul 10, 2025
  8. gvozditskiy commented on Jul 10, 2025

    @gvozditskiy
    Author

    @darshankawar also reproduced on:

    • samsung a52 (Qualcomm Snapdragon 720G, Adreno 618)
    • onePlus 8 pro (Qualcomm Snapdragon 865 5G, Adreno 650)

    I'll test it on a few other devices as well, once I have access to them

  9. 15 remaining items

  10. gaaclarke commented on Aug 19, 2025

    @gaaclarke
    Member

    I tried out 6a368d8 and 6a368d8^1, which is the roll that included the reland of flutter-team-archive/engine@04e4989, with the reproduction steps from #171772 (comment).

    SHA Result
    5a47cfb (6a368d8^1) There are full screen glitches during the animation.
    6a368d8 There are flashes at the beginning and ending of the animation.
    138a60c (HEAD) A trail is seen left where the button was rendered.

    So, I don't think 6a368d8 introduced the bug, but it definitely had an effect on it.

  11. gaaclarke commented on Aug 19, 2025

    @gaaclarke
    Member

    Here's the reproduction in renderdoc. It looks like the render pass isn't set to clear. That would explain why the artifacts are ghostly, looking like they are incorporating content from old renders.

    Image
  12. gaaclarke commented on Aug 19, 2025

    @gaaclarke
    Member

    As suspected, the following PR removes the glitch (but is obviously wasteful and not the correct fix):

    diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_builder_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_builder_vk.cc
    index d55cedfe488..dc809004c3a 100644
    --- a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_builder_vk.cc
    +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_builder_vk.cc
    @@ -43,6 +43,7 @@ RenderPassBuilderVK& RenderPassBuilderVK::SetColorAttachment(
         StoreAction store_action,
         vk::ImageLayout current_layout,
         bool is_swapchain) {
    +  load_action = LoadAction::kClear;
       vk::AttachmentDescription desc;
       desc.format = ToVKImageFormat(format);
       desc.samples = ToVKSampleCount(sample_count);
  13. gaaclarke commented on Aug 19, 2025

    @gaaclarke
    Member

    This appears to be the source of the problem. This fixes the problem but is contrary to the comment in the code. Maybe it's out of date?

    diff --git a/engine/src/flutter/impeller/entity/inline_pass_context.cc b/engine/src/flutter/impeller/entity/inline_pass_context.cc
    index 31bb6b360c7..e536c87762e 100644
    --- a/engine/src/flutter/impeller/entity/inline_pass_context.cc
    +++ b/engine/src/flutter/impeller/entity/inline_pass_context.cc
    @@ -111,7 +111,7 @@ const std::shared_ptr<RenderPass>& InlinePassContext::GetRenderPass() {
         // When MSAA is being used, we end up overriding the entire backdrop by
         // drawing the previous pass texture, and so we don't have to clear it and
         // can use kDontCare.
    -    color0.load_action = is_msaa ? LoadAction::kDontCare : LoadAction::kLoad;
    +    color0.load_action = LoadAction::kLoad;
       } else {
         color0.load_action = LoadAction::kClear;
       }
  14. gaaclarke commented on Aug 20, 2025

    @gaaclarke
    Member

    The x-factor that describes devices that demonstrate this bug seems to be supports_read_from_resolve_. The reason there wasn't a Clear here was that EntityPassTarget::Flip was supposed to leave the buffer in a state where it didn't need a Clear.

    The bug doesn't present itself on my iPhone which does support "read from resolve" providing further evidence of this.

    Here is all the logic that executes when supports_read_from_resolve_==false:

    One suspicious thing is that EntityPassTarget::Flip returns a std::shared_ptr<Texture>. Originally that was used: https://github.com/flutter/flutter/blame/74eadd80c5e89d81d99fbfe8afeddef43d603f06/engine/src/flutter/impeller/entity/inline_pass_context.cc#L125

    After flutter-team-archive/engine#55943 though the result was ignored.

    We could switch the logic to only do Clear when we don't support read from resolve, that would at least partially maintain the optimization and would favor new devices.

  15. gaaclarke commented on Aug 20, 2025

    @gaaclarke
    Member

    I verified:

    1. The bug manifests before and after [Impeller] allocate the impeller onscreen texture from the render target cache. flutter-team-archive/engine#55943
    2. The reproduction test I created for [Impeller] Make sure inline passes always do a clear action. #174083 does not demonstrate the bug, despite not supporting "read from resolve". That leaves us in a position where it isn't quite clear where it may still be safe to do DontCare.
  16. gaaclarke commented on Aug 20, 2025

    @gaaclarke
    Member

    Here's the frame debugger for the same render pass in #171772 (comment) as far as I can tell. It seems like vulkan is doing DontCare on the color attachment while Metal is doing Clear.

    Image

    DontCare seems to be used effectively in render passes leading up to that. But that doesn't seem to match what I saw in vulkan.

    frame capture:
    Runner.gputrace.zip

  17. gvozditskiy commented on Aug 21, 2025

    @gvozditskiy
    Author

    This is very frustrating. The ability to build with a precompiled shader was removed and Impeller was enabled by default. Now we either have to build with Impeller disabled, which causes numerous freezes, or use an older version of Flutter, which prevents us from using new framework features. Building with Impeller is simply not an option, because the entire application ends up covered in artifacts.

    Image

  18. gaaclarke commented on Aug 26, 2025

    @gaaclarke
    Member

    Closed via #174083

    I was using the reproduction steps in #171772 (comment) which is a derivative of the description's reproduction. The descriptions reproduction only partially showed the artifacts on my device. So it would be helpful if someone could double check to make sure there wasn't 2 issues being reported. If some of the artifacts still show, please file a separate issue and feel free to tag me.

  19. moved this from Device/Vendor Specific to Done in Slimpelleron Aug 26, 2025
  20. added
    r: fixedIssue is closed as already fixed in a newer version
    on Aug 27, 2025
  21. github-actions commented on Sep 10, 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.

  22. locked as resolved and limited conversation to collaborators on Sep 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: renderingUI glitches reported at the engine/skia or impeller rendering levele: device-specificOnly manifests on certain devicese: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.platform-androidAndroid applications specificallyr: fixedIssue is closed as already fixed in a newer versionslimpellerEngine binary size reduction. go/slimpellerteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions