Skip to content

Transform.rotate is super slow #24627

Closed
Closed

Description

@arsen

I'm implementing a card swiping tinder like ui. Cards are being moved during onPanUpdate (within GestureDetector).
When applying just regular Transform.translate it is super smooth, but with when I apply also Transform.rotate it is missing a lot of frames (even on iPhone X).

This is the sample code:

class MemberCard extends StatelessWidget {
  final double width;
  final double height;
  final String photo;
  final double rotation;
  final Offset offset;
  final double scale;
  final bool dropShadow;

  MemberCard({
    this.width,
    this.height,
    this.photo,
    this.rotation = 0.0,
    this.offset = const Offset(0.0, 0.0),
    this.scale = 1.0,
    this.dropShadow = false,
  });

  @override
  Widget build(BuildContext context) {
    double angle = rotation * (math.pi / 180.0);
    var matrix = Matrix4.translationValues(offset.dx, offset.dy, 0.0);
    if (scale != 1.0) {
      matrix..scale(scale);
    }
    if (rotation != 0.0) {
      matrix.rotateZ(angle);
    }

    return Transform(
      alignment: Alignment.center,
      transform: matrix,
      child: Stack(
        children: <Widget>[
          RepaintBoundary(
            child: Container(
              width: width,
              height: height,
              decoration: BoxDecoration(
                boxShadow: dropShadow
                    ? [
                        BoxShadow(
                            color: Colors.black.withOpacity(0.4),
                            blurRadius: 5.0,
                            offset: Offset(0.0, 1.0))
                      ]
                    : [],
                borderRadius: BorderRadius.all(Radius.circular(10.0)),
                color: Colors.white,
                // image: DecorationImage(
                //   image: NetworkImage(photo),
                //   fit: BoxFit.cover,
                // ),
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10.0),
                child: Image.network(
                  photo,
                  fit: BoxFit.cover,
                  filterQuality: FilterQuality.none,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Activity

  1. arsen commented on Nov 21, 2018

    @arsen
    Author

    Output of flutter doctor -v

    [✓] Flutter (Channel master, v0.11.10-pre.1, on Mac OS X 10.13.6 17G65, locale en-US)
        • Flutter version 0.11.10-pre.1 at /Users/user/flutter
        • Framework revision 8470b4a441 (13 hours ago), 2018-11-20 22:25:03 -0800
        • Engine revision 5c8147450d
        • Dart version 2.1.0 (build 2.1.0-dev.9.4 f9ebf21297)
    
    [✓] Android toolchain - develop for Android devices (Android SDK 28.0.2)
        • Android SDK at /Users/user/Library/Android/sdk
        • Android NDK location not configured (optional; useful for native profiling support)
        • Platform android-28, build-tools 28.0.2
        • ANDROID_HOME = /Users/user/Library/Android/sdk
        • Java binary at: /Applications/Android Studio.app/Contents/jre/jdk/Contents/Home/bin/java
        • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-1024-b01)
        • All Android licenses accepted.
    
    [!] iOS toolchain - develop for iOS devices (Xcode 10.1)
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Xcode 10.1, Build version 10B61
        ✗ Verify that all connected devices have been paired with this computer in Xcode.
          If all devices have been paired, libimobiledevice and ideviceinstaller may require updating.
          To update with Brew, run:
            brew update
            brew uninstall --ignore-dependencies libimobiledevice
            brew uninstall --ignore-dependencies usbmuxd
            brew install --HEAD usbmuxd
            brew unlink usbmuxd
            brew link usbmuxd
            brew install --HEAD libimobiledevice
            brew install ideviceinstaller
        • ios-deploy 1.9.2
        ✗ ios-deploy out of date (1.9.4 is required). To upgrade with Brew:
            brew upgrade ios-deploy
        • CocoaPods version 1.5.3
    
    [✓] Android Studio (version 3.1)
        • Android Studio at /Applications/Android Studio.app/Contents
        • Flutter plugin version 29.0.1
        • Dart plugin version 173.4700
        • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-1024-b01)
    
    [✓] VS Code (version 1.29.0)
        • VS Code at /Applications/Visual Studio Code.app/Contents
        • Flutter extension version 2.20.0
    
    [✓] Connected device (2 available)
        • Nexus 5X                  • 00b128f22f8aa2c7 • android-arm64 • Android 8.1.0 (API 27)
        • Android SDK built for x86 • emulator-5554    • android-x86   • Android 8.1.0 (API 27) (emulator)
    
    ! Doctor found issues in 1 category.
    
  2. added
    engineflutter/engine related. See also e: labels.
    c: performanceRelates to speed or footprint issues (see "perf:" labels)
    on Nov 22, 2018
  3. added this to the milestone on Nov 22, 2018
  4. GaryQian commented on Dec 6, 2018

    @GaryQian
    Contributor
  5. liyuqian commented on Dec 7, 2018

    @liyuqian
    Contributor

    Rotation is much slower than translation because we can raster cache during translation, but we can't do it during rotation in order to be pixel perfect.

    For example, an axis-aligned rectangle may not have any anti-aliased pixels, while a slightly rotated rectangle may have tons of anti-aliased pixels as shown below. Therefore we can't reuse the cached pixels with rotation.

    slightly rotated rectangle
    image

    the anti-aliased pixels of the slightly rotated rectangle:
    image

    In your specific case, I guess that the shadow is an expensive operation and we currently can't draw a big shadow at 60fps without caching. A solution might be for you to do a snapshot of your shadowed image and rotate that snapshot every frame so Flutter won't try to paint an expensive but pixel-perfect shadow every frame.

    Another possibility is for us to add an option to RepaintBoundary which enables raster cache during rotation with a sacrifice in fidelity. @Hixie @chinmaygarde : what's your thought on adding that option to RepaintBoundary? (FYI, We will add raster cache option to RepaintBoundary for retained rendering.)

  6. arsen commented on Dec 12, 2018

    @arsen
    Author

    Hi @liyuqian,
    Thank you for explaining in details.

    I have a quick question, what do you mean by "do a snapshot of your shadowed image"?
    Do you mean create a separate widget and wrap in RepaintBoundary?

  7. liyuqian commented on Dec 13, 2018

    @liyuqian
    Contributor

    Hi @arsen , I'm referring to the code here: https://gist.github.com/liyuqian/5b9eed3d9d80dde0c93da84d4c45aa34

    Using my Moto G4 test device,

    • with useSnapshot = true , I get an average frame render time of ~4ms
    • with useSnapshot = false, I get an average frame render time of ~18ms.
  8. arsen commented on Dec 26, 2018

    @arsen
    Author

    Hi @liyuqian,
    Thanks for example code. This is really really helpful.
    I have implemented the same approach, but I do see a flicker when it is switching to a snapshot version.

    Even in your example, this flicker is noticeable.

    I have tried putting them on top of each other (in a stack) and hide the original version (with a delay) once a snapshot is ready, but it is not as smooth also (boxShadow gets darker because of two containers with the same shadow).

    Any thoughts?

    Thanks.

  9. liyuqian commented on Dec 26, 2018

    @liyuqian
    Contributor

    The flicker is due to sending the image from CPU memory to GPU memory. I modified your Stack hack to wait for 1 second to populate the GPU memory before showing the snapshot. I used a white Container in the middle of the stack to completely occlude the snapshot when it's not ready to avoid the flicker and the double shadow: https://gist.github.com/liyuqian/5b9eed3d9d80dde0c93da84d4c45aa34 (check _gpuImageCached and cacheStack).

    But this might be too much of a hack. @chinmaygarde, @jason-simmons, @cbracken : is there a more elegant way to tell a widget to show an image only when that image is in the GPU memory?

  10. Hixie commented on Jan 4, 2019

    @Hixie
    Contributor

    We could have a flag on RepaintBoundary that says to cache the bitmap when we first paint it and then reuse that even if the transform changes? That seems cleaner than having people send pixels from the GPU to the CPU and back to the GPU.

    Even better would be to make this scene something that can actually paint at a reasonable speed.

  11. arsen commented on Jan 9, 2019

    @arsen
    Author

    Hi @Hixie ,
    That would be really great :)
    Should I wait for it? how hard will it be to implement?

  12. Hixie commented on Jan 15, 2019

    @Hixie
    Contributor

    I wouldn't recommend waiting for it, nobody is currently signed up to do it. :-)
    Not sure how easy it would be to implement either the "original bitmap cache" idea or the "just make it faster" solution. @liyuqian may be able to advise us.

  13. liyuqian commented on Jan 15, 2019

    @liyuqian
    Contributor

    Yes, don't wait for it. Although I really want to implement the best solution for it ASAP, I have many other tasks piled up... My really aggressive goal is to have it done by Q1, but maybe a safer bet is Q2.

  14. liyuqian commented on Jun 26, 2019

    @liyuqian
    Contributor

    @flar : since you're the assignee of #32662 which will fix this issue, can I also assign you to this issue? BTW, it would be nice to add the reproduction code here to our benchmarks :)

  15. 20 remaining items

  16. flutter-triage-bot commented on Jul 8, 2023

    @flutter-triage-bot

    This issue is assigned but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

  17. added
    ⏳Bot is counting down the days until it unassigns the issue
    on Dec 4, 2023
  18. flutter-triage-bot commented on Dec 4, 2023

    @flutter-triage-bot

    This issue is assigned to @flar but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

  19. removed their assignment
    on Dec 4, 2023
  20. removed
    ⏳Bot is counting down the days until it unassigns the issue
    on Dec 4, 2023
  21. huycozy commented on Apr 12, 2024

    @huycozy
    Member

    Re-checking the issue on the latest Flutter version. On iOS (with and without Impeller, it looks better, sometimes I see jank but not much). But on Android (Skia), I can observe a lot of janks there.

    Recorded video - Android device - profile mode
    Screen.Recording.2024-04-12.at.16.16.13.mov
    Sample code (be compatible with the new flutter version)
    import 'dart:ui' as ui;
    import 'dart:typed_data';
    
    import 'package:http/http.dart' as http;
    
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          home: MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
      final double width = 400;
      final double height = 400;
      final bool dropShadow = true;
      final bool useSnapshot = true; // CHANGE ME AND COLD RESTART TO SEE THE DIFFERENCE
      final String photo = 'https://storage.googleapis.com/cms-storage-bucket/ed2e069ee37807f5975a.jpg';
    
      GlobalKey _snapshotWidgetKey = new GlobalKey();
      Uint8List? _snapshotByteData;
      Uint8List? _photoBytes;
    
      bool _gpuImageCached = false;
    
      late Animation<double> animation;
      late AnimationController controller;
    
      void _fetchImage() async {
        http.Response response = await http.get(Uri.parse(photo));
        setState(() {
          _photoBytes = response.bodyBytes;
        });
      }
    
      void _trySnapshot() async {
        do {
          await Future.delayed(const Duration(milliseconds: 20));
          if (_photoBytes == null || _snapshotWidgetKey.currentContext == null) {
            continue;
          }
          await Future.delayed(const Duration(milliseconds: 200)); // wait for image to be shown
          RenderRepaintBoundary boundary = _snapshotWidgetKey.currentContext?.findRenderObject() as RenderRepaintBoundary;
          if (boundary == null) {
            continue;
          }
          ui.Image image = await boundary.toImage();
          _snapshotByteData = (await image.toByteData(format: ui.ImageByteFormat.png))?.buffer.asUint8List();
        } while (_snapshotByteData == null);
    
        await Future.delayed(const Duration(seconds: 1));
        _gpuImageCached = true;
      }
    
      Widget createSnapshotWidget() {
        if (useSnapshot && _snapshotByteData != null && _gpuImageCached) {
          return Image.memory(_snapshotByteData!);
        }
    
        Widget cacheStack = Stack(
          children: <Widget>[
            _snapshotByteData == null ? Container() : Image.memory(_snapshotByteData!),
            Container(color: Colors.white),
            _photoBytes == null ? Container() : Image.memory(_photoBytes!),
          ],
        );
    
        return RepaintBoundary(
            key: _snapshotWidgetKey,
            child: Container(
              padding: const EdgeInsets.all(10), // padding for box shadow
              child: Container(
                width: width,
                height: height,
                decoration: BoxDecoration(
                  boxShadow: dropShadow ? [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 5.0, offset: const Offset(0.0, 1.0))] : [],
                  borderRadius: const BorderRadius.all(Radius.circular(10.0)),
                  color: Colors.white,
                ),
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10.0),
                  child: cacheStack,
                ),
              ),
            ));
      }
    
      @override
      void initState() {
        super.initState();
        _fetchImage();
        if (useSnapshot) {
          _trySnapshot();
        }
        controller = AnimationController(vsync: this);
        animation = Tween(begin: 0.0, end: 360.0).animate(controller)
          ..addListener(() {
            setState(() {});
          });
        controller.repeat(period: const Duration(seconds: 2));
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Rotation Demo'),
          ),
          body: Center(
            child: MemberCard(
              snapshotWidget: createSnapshotWidget(),
              rotation: animation.value,
            ),
          ),
        );
      }
    }
    
    class MemberCard extends StatelessWidget {
      final double rotation;
      final Offset offset;
      final double scale;
      final Widget snapshotWidget;
    
      MemberCard({
        required this.snapshotWidget,
        this.rotation = 0.0,
        this.offset = const Offset(0.0, 0.0),
        this.scale = 1.0,
      });
    
      @override
      Widget build(BuildContext context) {
        double angle = rotation * (3.1415926 / 180.0);
        var matrix = Matrix4.translationValues(offset.dx, offset.dy, 0.0);
        if (scale != 1.0) {
          matrix..scale(scale);
        }
        if (rotation != 0.0) {
          matrix.rotateZ(angle);
        }
    
        return Transform(
          alignment: Alignment.center,
          transform: matrix,
          child: Stack(
            children: <Widget>[snapshotWidget],
          ),
        );
      }
    }
    
    flutter doctor -v (stable and master)
    [✓] Flutter (Channel stable, 3.19.5, on macOS 14.1 23B74 darwin-x64, locale en-VN)
        • Flutter version 3.19.5 on channel stable at /Users/huynq/Documents/GitHub/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 300451adae (8 days ago), 2024-03-27 21:54:07 -0500
        • Engine revision e76c956498
        • Dart version 3.3.3
        • DevTools version 2.31.1
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
        • Android SDK at /Users/huynq/Library/Android/sdk
        • Platform android-34, build-tools 34.0.0
        • ANDROID_HOME = /Users/huynq/Library/Android/sdk
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
        • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 15.2)
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 15C500b
        • CocoaPods version 1.15.2
    
    [✓] Chrome - develop for the web
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Android Studio (version 2023.2)
        • Android Studio at /Applications/Android Studio.app/Contents
        • Flutter plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/9212-flutter
        • Dart plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/6351-dart
        • android-studio-dir = /Applications/Android Studio.app/
        • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874)
    
    [✓] VS Code (version 1.87.2)
        • VS Code at /Applications/Visual Studio Code.app/Contents
        • Flutter extension version 3.84.0
    
    [✓] Connected device (2 available)
        • macOS (desktop) • macos  • darwin-x64     • macOS 14.1 23B74 darwin-x64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 123.0.6312.106
    
    [✓] Network resources
        • All expected network resources are available.
    
    • No issues found!
    [!] Flutter (Channel master, 3.22.0-9.0.pre.17, on macOS 14.1 23B74 darwin-x64, locale en-VN)
        • Flutter version 3.22.0-9.0.pre.17 on channel master at /Users/huynq/Documents/GitHub/flutter_master
        ! Warning: `flutter` on your path resolves to /Users/huynq/Documents/GitHub/flutter/bin/flutter, which is not inside your current Flutter SDK checkout at /Users/huynq/Documents/GitHub/flutter_master. Consider adding /Users/huynq/Documents/GitHub/flutter_master/bin to the front of your path.
        ! Warning: `dart` on your path resolves to /Users/huynq/Documents/GitHub/flutter/bin/dart, which is not inside your current Flutter SDK checkout at /Users/huynq/Documents/GitHub/flutter_master. Consider adding /Users/huynq/Documents/GitHub/flutter_master/bin to the front of your path.
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision a34086a573 (3 hours ago), 2024-04-11 18:07:20 -0700
        • Engine revision 6b37b17099
        • Dart version 3.5.0 (build 3.5.0-47.0.dev)
        • DevTools version 2.34.1
        • If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades.
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
        • Android SDK at /Users/huynq/Library/Android/sdk
        • Platform android-34, build-tools 34.0.0
        • ANDROID_HOME = /Users/huynq/Library/Android/sdk
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
        • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 15.3)
        • Xcode at /Applications/Xcode15.3.app/Contents/Developer
        • Build 15E204a
        • CocoaPods version 1.15.2
    
    [✓] Chrome - develop for the web
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Android Studio (version 2023.2)
        • Android Studio at /Applications/Android Studio.app/Contents
        • Flutter plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/9212-flutter
        • Dart plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/6351-dart
        • android-studio-dir = /Applications/Android Studio.app/
        • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874)
    
    [✓] VS Code (version 1.88.0)
        • VS Code at /Applications/Visual Studio Code.app/Contents
        • Flutter extension version 3.86.0
    
    [✓] Connected device (2 available)
        • macOS (desktop) • macos  • darwin-x64     • macOS 14.1 23B74 darwin-x64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 123.0.6312.123
    
    [✓] Network resources
        • All expected network resources are available.
    
    ! Doctor found issues in 1 category.
  22. jonahwilliams commented on Feb 28, 2025

    @jonahwilliams
    Contributor

    I'm looking at the difference on a Pixel 7, granted a very new/fast phone. Non-snapshot version:

    Skia

    Image

    Impeller

    Image

    I'm pretty happy with these results and would like to call this fixed (as Impeller is on by default for both Android/iOS)

  23. github-actions commented on Mar 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.

  24. locked as resolved and limited conversation to collaborators on Mar 14, 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: performanceRelates to speed or footprint issues (see "perf:" labels)engineflutter/engine related. See also e: labels.found in release: 3.19Found to occur in 3.19found in release: 3.22Found to occur in 3.22has reproducible stepsThe issue has been confirmed reproducible and is ready to work onteam-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