Skip to content

[impeller] TextFrame::SetPerFrameData is weird and possibly buggy #177424

Closed

Description

@eyebrowsoffire

This is a brain dump of a potential problem I may have discovered in our text rendering.

I was looking through our text rendering code and I was looking at TextFrame::SetPerFrameData. This function is called in LazyGlyphAtlas::AddTextFrame, and that happens in FirstPassDispatcher::DrawText, which happens on the raster thread right before we render a displaylist to a target.

The frame data includes the matrix transform of the text, which is not actually specific to the text frame, but specific to that draw command of that text frame, so it seems wrong to attach it to the text frame itself. In addition, it seems at this point in the pipeline, the text frame itself being mutated feels a little wrong. I can imagine a scenario that leads wrong behavior:

  • Create a picture recorder
  • Draw a paragraph to that picture recorder and end recording, yielding a picture
  • Add this picture to multiple places in a single display list tree that have different matrix transforms on them
  • When we render this display list tree, the first pass dispatcher will run, and each time it encounters this picture it will set the matrix transform on the text frame based on the current transform, overwriting it multiple times.
  • When we actually get to rendering this info and populating the glyph atlas, it will use the last written transform for all of the instances of that text frame in the tree. (Which is wrong).

This might be a contrived example, but it sort of illustrates why this data shouldn't be mutated on the text frame itself. Ideally, the information attached to the text frame itself should be agnostic to the surrounding drawing context, and the lazy glyph atlas can store this extra information either in a wrapper object or in a sibling object to the text frame.

Activity

  1. added
    in triagePresently being triaged by the triage team
    engineflutter/engine related. See also e: labels.
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Oct 23, 2025
  2. added
    P2Important issues not at the top of the work list
    on Oct 27, 2025
  3. flar commented on Feb 4, 2026

    @flar
    Contributor

    The description actually produces results, though they can be subtle. Run the following test case on Skia, note that it will randomize the scales of the individual cells on every repaint even if you are just mousing over the checkbox, but the text will look fine - reasonably well rendered whether the reuse checkbox is set or not.

    Now run it with Impeller. It will look fine, like under Skia, if the reuse checkbox is off, but you can often see subtle mistakes in how it lays out and renders the text if reuse is on.

    Test case of (optionally) reusing a DisplayList with text in a scene.
    import 'dart:math';
    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: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: .fromSeed(seedColor: Colors.deepPurple),
          ),
          home: const MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key, required this.title});
    
      final String title;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      bool reuseDisplayList = true;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: Text(widget.title),
          ),
          body: Center(
            child: CustomPaint(
              painter: _MyPainter(reuseDisplayList: reuseDisplayList),
              size: Size(600, 400),
            ),
          ),
          bottomNavigationBar: BottomAppBar(
            child: Row(
              mainAxisAlignment: .center,
              children: [
                Text('Reuse DL: '),
                Checkbox(value: reuseDisplayList, onChanged: (value) {
                  setState(() {
                    reuseDisplayList = value!;
                  });
                },)
              ],
            ),
          ),
        );
      }
    }
    
    class _MyPainter extends CustomPainter {
      static final Random random = Random();
      static final staticPicture = makePicture();
    
      static Picture makePicture() {
        PictureRecorder recorder = PictureRecorder();
        Canvas canvas = Canvas(recorder, Rect.fromLTRB(0, 0, 40, 40));
        canvas.drawRect(
          Rect.fromLTRB(5, 5, 35, 35),
          Paint()
            ..color = Colors.blue
            ..strokeWidth = 2
            ..style = .stroke,
        );
        TextSpan span = TextSpan(
          text: 'Hi',
          style: TextStyle(color: Colors.purple),
        );
        TextPainter painter = TextPainter(
          text: span,
          textDirection: .ltr,
          textAlign: .center
        );
        painter.layout();
        Offset offset = Offset(
            (40 - painter.width) * 0.5,
            (40 - painter.height) * 0.5);
        painter.paint(canvas, offset);
        return recorder.endRecording();
      }
    
      _MyPainter({required this.reuseDisplayList});
      final bool reuseDisplayList;
    
      @override
      void paint(Canvas canvas, Size size) {
        for (int x = 0; x < size.width; x += 40) {
          for (int y = 0; y < size.height; y += 40) {
            canvas.save();
            double zoom = random.nextDouble() * 0.75 + 0.5;
            canvas.translate(x + 20, y + 20);
            canvas.scale(zoom, zoom);
            canvas.translate(-20, -20);
            canvas.drawPicture(reuseDisplayList ? staticPicture : makePicture());
            canvas.restore();
          }
        }
      }
    
      @override
      bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
    }
    
    Example output for Skia with or without the reuse flag Image Image
    Example for Impeller not reusing the DisplayList Image
    Example for Impeller reusing the DisplayList Image
  4. flar commented on Feb 4, 2026

    @flar
    Contributor

    The following Impeller playground test also exhibits the problem:

    Unit test code for aiks_dl_text_unittests.cc
    namespace {
    std::shared_ptr<TextFrame> MakeDefaultTextFrame(std::string text,
                                                    Scalar font_size) {
      // Construct the text blob.
      auto mapping = flutter::testing::OpenFixtureAsSkData("wtf.otf");
      if (mapping == nullptr) {
        return nullptr;
      }
    
      sk_sp<SkFontMgr> font_mgr = txt::GetDefaultFontManager();
      SkFont sk_font(font_mgr->makeFromData(mapping), font_size);
      sk_sp<SkTextBlob> blob = SkTextBlob::MakeFromString("Hi", sk_font);
    
      std::shared_ptr<TextFrame> text_frame = MakeTextFrameFromTextBlobSkia(blob);
      return text_frame;
    }
    void DrawTextFramesMultipleScalesWithWithoutReuse(AiksTest* test,
                                                      Scalar first_scale,
                                                      Scalar second_scale)
    {
      DisplayListBuilder builder;
      builder.Scale(test->GetContentScale().x, test->GetContentScale().y);
      builder.DrawColor(DlColor::kWhite(), DlBlendMode::kSrc);
    
      std::shared_ptr<TextFrame> reuse_frame = MakeDefaultTextFrame("Hi", 20.0f);
      ASSERT_NE(reuse_frame, nullptr);
    
      builder.Save();
      builder.Translate(100, 100);
      builder.Scale(first_scale, first_scale);
      builder.DrawText(DlTextImpeller::Make(reuse_frame), 0, 0,
                       DlPaint(DlColor::kBlue()));
      builder.Restore();
    
      builder.Save();
      builder.Translate(400, 100);
      builder.Scale(second_scale, second_scale);
      builder.DrawText(DlTextImpeller::Make(reuse_frame), 0, 0,
                       DlPaint(DlColor::kPurple()));
      builder.Restore();
    
      builder.Save();
      builder.Translate(100, 400);
      builder.Scale(first_scale, first_scale);
      std::shared_ptr<TextFrame> single_use_frame1 =
          MakeDefaultTextFrame("Hi", 20.0f);
      builder.DrawText(DlTextImpeller::Make(single_use_frame1), 0, 0,
                       DlPaint(DlColor::kBlue()));
      builder.Restore();
    
      builder.Save();
      builder.Translate(400, 400);
      builder.Scale(second_scale, second_scale);
      std::shared_ptr<TextFrame> single_use_frame2 =
          MakeDefaultTextFrame("Hi", 20.0f);
      builder.DrawText(DlTextImpeller::Make(single_use_frame2), 0, 0,
                       DlPaint(DlColor::kPurple()));
      builder.Restore();
    
      ASSERT_TRUE(test->OpenPlaygroundHere(builder.Build()));
    }
    }  // namespace
    
    TEST_P(AiksTest, TextFramesDoNotShareRenderDataBigSmall) {
      DrawTextFramesMultipleScalesWithWithoutReuse(this,  //
                                                   /*first_scale=*/4.0f,  //
                                                   /*second_scale=*/0.5f);
    }
    
    TEST_P(AiksTest, TextFramesDoNotShareRenderDataSmallBig) {
      DrawTextFramesMultipleScalesWithWithoutReuse(this,  //
                                                   /*first_scale=*/0.5f,  //
                                                   /*second_scale=*/4.0f);
    }
    
    Output for the BigSmall variant (showing large hinting fringes on the Big text) Image
    Output for the BigSmall variant (showing lack of fringes on the Small text) Image
  5. github-actions commented on Mar 13, 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.

  6. locked as resolved and limited conversation to collaborators on Mar 13, 2026
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 liste: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.team-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