Skip to content

[Impeller] Scaled text is lower resolution and has more jitter than Skia #165583

Closed

Description

@yakagami

Steps to reproduce

  1. Run the following code with Impeller:
code
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
      ),
      home: const ZoomViewMvp(),
    );
  }
}

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

  @override
  State<ZoomViewMvp> createState() => _ZoomViewMvpState();
}

class _ZoomViewMvpState extends State<ZoomViewMvp> {

  final controller = ScrollController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ZoomView(
        controller: controller,
        child: ListView.builder(
          controller: controller,
          itemBuilder: (context, index) {
            return Center(child: Text("simple text ${index.toString()}"));
          },
        )
      ),
    );
  }
}


///Allows a ListView or other Scrollables that implement ScrollPosition and
///jumpTo(offset) in their controller to be zoomed and scrolled.
class ZoomView extends StatefulWidget {
  const ZoomView({
    super.key,
    required this.child,
    required this.controller,
    this.maxScale = 4.0,
    this.minScale = 1.0,
    this.scrollAxis = Axis.vertical,
  });

  final Widget child;
  final ScrollController controller;

  ///scrollAxis must be set to Axis.horizontal if the Scrollable is horizontal
  final Axis scrollAxis;

  ///The maximum scale that the ZoomView can be zoomed to. Set to double.infinity to allow infinite zoom in
  final double maxScale;

  ///The minimum scale that the ZoomView can be zoomed to. Set to 0 to allow infinite zoom out
  final double minScale;

  @override
  State<ZoomView> createState() => _ZoomViewState();
}

class _ZoomViewState extends State<ZoomView> with TickerProviderStateMixin {
  @override
  void initState() {
    if (widget.scrollAxis == Axis.vertical) {
      _verticalController = widget.controller;
      _horizontalController = ScrollController();
    } else {
      _verticalController = ScrollController();
      _horizontalController = widget.controller;
    }

    super.initState();
  }

  ///The current scale of the ZoomView
  double _scale = 1;

  ///The scale of the ZoomView before the last scale update event
  double _lastScale = 1;

  late final ScrollController _verticalController;
  late final ScrollController _horizontalController;

  ///The focal point of pointers at the start of a scale event
  late Offset _localFocalPoint;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        double height = constraints.maxHeight;
        double width = constraints.maxWidth;
        return GestureDetector(
          behavior: HitTestBehavior.translucent,
          onScaleStart: (ScaleStartDetails details) {
            if (details.pointerCount != 1) {
              _localFocalPoint = details.localFocalPoint;
            }
          },
          onScaleUpdate: (ScaleUpdateDetails details) {
            if (details.pointerCount > 1) {
              final newScale = _lastScale / details.scale;
              final verticalOffset =
                  _verticalController.position.pixels + (_scale - newScale) * _localFocalPoint.dy;
              final horizontalOffset = _horizontalController.position.pixels +
                  (_scale - newScale) * _localFocalPoint.dx;
              //This is the main logic to actually perform the scaling
              setState(() {
                _scale = newScale;
              });
              _verticalController.jumpTo(verticalOffset);
              _horizontalController.jumpTo(horizontalOffset);
            } else {
              final correctedDelta = details.focalPointDelta * _scale;
              _verticalController.jumpTo(_verticalController.position.pixels - correctedDelta.dy);
              _horizontalController.jumpTo(_horizontalController.position.pixels - correctedDelta.dx);
            }
          },
          onScaleEnd: (ScaleEndDetails details) {
            _lastScale = _scale;
          },
          child: Column(
            children: [
              Expanded(
                //When scale decreases, the SizedBox will shrink and the FittedBox
                //will scale the child to fit the maximum constraints of the ZoomView
                child: FittedBox(
                  fit: BoxFit.fill,
                  child: SizedBox(
                    height: height * _scale,
                    width: width * _scale,
                    child: Center(
                      child: ScrollConfiguration(
                        behavior: const ScrollBehavior().copyWith(
                          overscroll: false,
                          //Disable all inputs on the list as we will handle them
                          //ourselves using the gesture detector and scroll controllers
                          dragDevices: <PointerDeviceKind>{},
                          scrollbars: false,
                        ),
                        child: ListView(
                          physics: const ClampingScrollPhysics(),
                          controller: widget.scrollAxis == Axis.vertical
                              ? _horizontalController
                              : _verticalController,
                          scrollDirection: widget.scrollAxis == Axis.vertical
                              ? Axis.horizontal
                              : Axis.vertical,
                          children: [
                            SizedBox(
                            width: widget.scrollAxis == Axis.vertical ? width : null,
                            height: widget.scrollAxis == Axis.vertical ? null : height,
                            child: widget.child,
                          ),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}
2. Zoom into the letters 3. Observe that the text jitters and is jagged

Expected results

Text is not jittery and is smooth like skia

Actual results

the text jitters and is jagged

Code sample

See above

Screenshots or Video

Screenshots / Video demonstration

Impeller:

screen-20250320-134229.mp4

Skia:

screen-20250320-134348.mp4

I should note skia looked even smoother on device. I think the video encoding might be making it look more jagged there.

another example
screen-20250320-140112.mp4

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel master, 3.31.0-1.0.pre.155)
• No issues found!

Activity

  1. jonahwilliams commented on Mar 20, 2025

    @jonahwilliams
    Contributor

    We cap the maximum rasterized size of text glyphs. This is not something we intend to work on in the near future.

  2. added
    P3Issues that are less important to the Flutter project
    e: impellerImpeller rendering backend issues and features requests
    on Mar 20, 2025
  3. yakagami commented on Mar 20, 2025

    @yakagami
    ContributorAuthor

    @jonahwilliams Is the cap set to a static number? I would like to know so I can decide what to set the maximum scale size to in my widget.

  4. jonahwilliams commented on Mar 20, 2025

    @jonahwilliams
    Contributor

    We could convert to paths above a certain size like Skia does.

  5. jonahwilliams commented on Mar 21, 2025

    @jonahwilliams
    Contributor

    We actually already have the logic to switch to Path rendering: https://github.com/flutter/flutter/blob/master/engine/src/flutter/txt/src/skia/paragraph_skia.cc#L78

    When this code runs we don't yet know the total scale of the picture. We'd need to add the ability to defer this decision until dispatch. This makes the text smooth at high resolutions/scales. Otherwise we end up magnifying the rasterized glyph past the max size.

    Patched to use path rendering:

    Image

  6. added
    P1High-priority issues at the top of the work list
    and removed
    P3Issues that are less important to the Flutter project
    on Mar 25, 2025
  7. github-actions commented on Apr 18, 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.

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

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work liste: impellerImpeller rendering backend issues and features requeststeam-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