Skip to content

[Windows][Impeller 3.47 SDF] CJK text rendering shows no improvement over Skia at 1080p — real-world enterprise IM case and request for DirectWrite rendering path #194931

Closed

Description

@SuperAiPm

flutter pc

Image

Summary

Flutter on Windows renders CJK (Chinese/Japanese/Korean) text using grayscale
antialiasing instead of the system's DirectWrite/ClearType pipeline. This has
been tracked in #63043 since 2020 with no fix merged.

Flutter 3.47 introduced SDF (Signed Distance Function) text rendering via
Impeller as the new default on desktop. We have tested 3.47 in a real
production environment and observed no improvement in CJK text rendering
quality at 1080p.
This comment documents that finding and renews the request
for a DirectWrite-based fix.


Environment

  • Flutter 3.47 (stable, Impeller + SDF enabled by default)
  • Windows 10 / Windows 11
  • Display: 1080p at 100% DPI (most common enterprise workstation config)
  • Verified startup log shows: Using the Impeller rendering backend (OpenGLESSDF)
  • Fonts tested: Microsoft YaHei UI, Segoe UI, HarmonyOS Sans SC, Noto Sans SC

Real-World Context

We are building an enterprise IM (instant messaging) product serving
business users on Windows daily. Message readability is the core function —
users read dense streams of 14px Chinese/English mixed text for extended
periods.

We conducted a systematic comparison of five CJK typefaces at 12px and 14px
across font weights (w400, w500), both before and after upgrading to 3.47.
The conclusion is consistent across all configurations:

All fonts rendered by Flutter (Skia and Impeller/SDF) appear visibly
softer than the same font rendered by native Windows applications (Notepad,
Edge, Word) at 1080p.

Upgrading to 3.47 and enabling Impeller/SDF produced no measurable
improvement
in CJK text clarity.


Why SDF Does Not Help CJK Text at 1080p

SDF rendering improves geometric curve fidelity for Latin glyphs, but CJK
characters have complex stroke intersections where the distance field
approximation introduces visible rounding. At 96 DPI (1080p, 100% scaling),
the physical pixel density is insufficient for SDF's sub-pixel interpolation
to outperform DirectWrite's hinted bitmap rasterization — which is precisely
tuned for this density range.

The 3.47 migration guide itself notes: "SDF glyphs plus gamma correction;
glyph edges and weights shift slightly"
— this shift is acceptable for Latin
but degrades CJK stroke weight consistency.


Root Cause (unchanged from #63043)

Flutter's text rendering chain on Windows:

Activity

  1. SuperAiPm commented on Oct 21, 2026

    @SuperAiPm
    Author

    Add:web
    Image

  2. puneetkukreja98 commented on Oct 22, 2026

    @puneetkukreja98
    Contributor

    @SuperAiPm Thanks for filing this issue and providing the detailed analysis!

    Could you please share a minimal reproducible example (MRE) along with side-by-side screenshots comparing the default Impeller rendering versus Impeller disabled (--no-enable-impeller)? This will help the team investigate and compare the rendering output directly.

  3. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Oct 22, 2026
  4. SuperAiPm commented on Oct 27, 2026

    @SuperAiPm
    Author

    Thanks @puneetkukreja98 — MRE and the A/B comparison are below.

    Short version: on Windows at 100% DPI, Impeller (SDF) and Skia produce pixel-identical CJK output. The 3.47 renderer migration therefore cannot change what users see; the gap versus native apps is entirely the missing subpixel AA tracked in #63043.


    1. Minimal reproducible example

    flutter create --platforms=windows text_render_mre
    

    Replace lib/main.dart (no custom fonts, no packages — system font stack only):

    lib/main.dart
    import 'package:flutter/material.dart';
    
    void main() => runApp(const TextRenderMreApp());
    
    class TextRenderMreApp extends StatelessWidget {
      const TextRenderMreApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          theme: ThemeData.light(useMaterial3: true),
          home: Scaffold(
            backgroundColor: Colors.white,
            body: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: const [
                  Text('你好,世界 Hello World 0123456789',
                      style: TextStyle(fontSize: 14, color: Colors.black)),
                  SizedBox(height: 10),
                  Text('Flutter 3.47 在 Windows 桌面端默认启用 Impeller 引擎与 SDF 文本渲染。',
                      style: TextStyle(fontSize: 14, color: Colors.black)),
                  SizedBox(height: 10),
                  Text('订单金额 ¥12,345.67 · 密码强度:强 · 版本 v2.1.0-beta.3',
                      style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black)),
                  SizedBox(height: 10),
                  Text('在 RenderObject 树和 Layer 树的协作下,只有标记为 dirty 的节点才会参与重绘。',
                      style: TextStyle(fontSize: 14, color: Colors.black)),
                ],
              ),
            ),
          ),
        );
      }
    }
    

    Run: flutter run -d windows --release

    For the screenshots below I additionally wrapped the content in a RepaintBoundary and dumped it with toImage(pixelRatio: 1.0) on the first frame, so the comparison uses the actual raster output and cannot be affected by screen-capture APIs or window composition.

    2. Heads-up: --no-enable-impeller is a no-op on Windows release builds

    invocation startup log actual renderer
    flutter run -d windows --profile Using the Impeller rendering backend (OpenGLESSDF) Impeller
    flutter run -d windows --profile --no-enable-impeller no Impeller backend line Skia
    release .exe + FLUTTER_ENGINE_SWITCHES=1 / FLUTTER_ENGINE_SWITCH_1=enable-impeller=false ...OpenGLESSDF still printed Impeller

    So the flag only works for profile/debug launches; in a release run the engine switch is ignored and there is no way to fall back to Skia. flutter build windows --help doesn't even list the flag (hide: !verboseHelp), and addEnableImpellerFlag() carries the help text "On other platforms, this flag will be ignored." Might be worth documenting (or fixing) — it makes engine A/B testing on Windows harder than it should be.

    3. Result: Impeller == Skia, pixel for pixel

    Same profile build, same binary, only the engine switch differs:

    Impeller : 1264x681 dump, 7,071 inked px, subpixel chroma 0.00%
    Skia     : 1264x681 dump, 7,071 inked px, subpixel chroma 0.00%
    pixel diff: 0 of 860,784  (max |Δ| = 0, mean |Δ| = 0)
    

    The same text rendered by GDI + ClearType on the same machine, same font (Microsoft YaHei), same 14px, carries chroma on 69.5% of inked pixels (share of pixels where max(R,G,B) - min(R,G,B) > 12).

    Screenshots:

    1. 3-column view (ClearType / Impeller / Skia)
    Image
    1. 5x zoom of the first line
    Image

    4. Interpretation

    • At 96 DPI, SDF does not change CJK glyph rasterization. Both backends end up with the same grayscale-coverage bitmaps, so the 3.44 → 3.47 upgrade produces zero visible change at 14px — matching the production observation in the issue.
    • What users perceive next to Notepad/Edge/Word is only the subpixel AA difference, not glyph shape, hinting or the rasterizer.
    • CJK suffers more than Latin: hinted ClearType stems are what make 14px Chinese legible; grayscale AA turns dense strokes into a gray mush even when the outline is correct.

    5. Ask

    Same request as #63043: rasterize Windows text through DirectWrite (ClearType / DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE), or expose an opt-in subpixel-antialiasing setting on the Windows embedder. Even a text-backend switch limited to the Windows embedder would unblock enterprise IM apps.

    Environment: Windows 10 22H2 (10.0.19045), Intel HD Graphics 630, 1920x1080 @100% (FontSmoothing=2, FontSmoothingType=2 i.e. ClearType on), Flutter 3.47.1 stable, engine 11d79658c444.

  5. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 27, 2026
  6. 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 27, 2026
  7. puneetkukreja98 commented on Oct 27, 2026

    @puneetkukreja98
    Contributor

    @SuperAiPm Thanks for the response, routing this to team-engine for further investigation.

  8. added
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    a: typographyText rendering, possibly libtxt
    on Oct 27, 2026
  9. 3 remaining items

  10. gaaclarke commented on Nov 2, 2026

    @gaaclarke
    Member

    The SDF renderer uses the same text rendering as the non text rendering. We sample directly from skia at the scale we intend to draw, so there is no SDF rendering happening from our side. It's just sampled from skia.

  11. SuperAiPm commented on Nov 3, 2026

    @SuperAiPm
    Author

    Thanks @gaaclarke — that closes the SDF question, and in doing so sharpens the real one.

    Confirmed: Impeller samples Skia's glyph bitmaps, so 3.44 → 3.47 could never have
    changed CJK output (our dump: 0 differing pixels of 860,784). There is no SDF text
    path to tune, which also means there is no future Impeller release that fixes this.
    The gap is the Windows text backend itself — tracked in #63043 since 2020.

    That matters well beyond this bug. We ship an enterprise IM client on Windows:
    dense 14px CJK read for hours. At 1080p @ 100% design cannot sign off on type
    weights (grayscale AA collapses w400 vs w500), every customer side-by-side against
    native apps reads as lower quality rather than a different typeface, and six
    typefaces produced identical results because the rasterizer — not the font — is the
    bottleneck. Electron, WinUI and Qt inherit ClearType from the OS for free; Flutter
    is the only major desktop framework that opts out of the platform rasterizer, and
    it does so with no opt-in.

    So the ask isn't a 3.47 fix. It's three things:

    1. One canonical tracker and a named owner for the root cause — two issues, six
      years, no assignee on either.
    2. An opt-in subpixel AA switch on the Windows embedder —
      DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE behind a flag, zero change to defaults,
      so no risk to existing apps.
    3. If a design doc / RFC is the required path, say so and I'll draft it.

    Desktop text quality is what decides whether "we'll build our UI in Flutter" survives
    contact with Windows. Happy to supply captures, the pixel-diff harness, and validation
    on any branch.

  12. gaaclarke commented on Nov 3, 2026

    @gaaclarke
    Member

    So, the idea of rendering the text with chroma is unlikely to happen. Economically it just doesn't make a lot of sense. It's a non-trivial deviation that only benefits windows users on low-dpi screens. So, a cohort that is diminishing over time.

    However, looking at your screenshots, ignoring the chroma, the values of the CJK glyphs looks much clearer to read. So, I think there there is some improvement that can be made there. It's interesting that the values of the latin glyphs is actually better in Flutter's implementation though.

    Image

    @Rusino in the above screenshot the user has compared GDI text rendering to skia text rendering. Originally GDI was rendered with chroma subpixel rendering. I've converted the image to greyscale to just demonstrate the values for the pixels. The GDI CJK text looks much more crisp. It's almost like the glyphs are shifted over 1/2 pixel and it results in more crisp glyphs. Do you have any idea what's going on there or theories on how we could improve this in Skia?

  13. gaaclarke commented on Nov 4, 2026

    @gaaclarke
    Member
    1. The text thickness issue in light and dark themes

    We have done our own gamma correction for text on the impeller side. It sounds like maybe we could have fixed that by holding skia differently. We'd have to be careful not to double correct. (fyi @b-luk )

  14. SuperAiPm commented on Nov 4, 2026

    @SuperAiPm
    Author

    Thanks @gaaclarke — fair call on chroma, dropping that ask. Let me answer the question you put to @Rusino with measurements instead of theories, because the "GDI looks crisper even in grayscale" effect reproduces perfectly on our machine and splits into two independent causes. Neither of them is a half-pixel offset in the sampler.

    Method is the same as before so it cannot be a screen-capture artifact: one glyph per RepaintBoundary, dumped straight out of the raster pipeline with toImage(pixelRatio: 1.0), then the face that was actually selected is identified by exact pixel match against a render with an explicit fontFamily (mean absolute error 0.000 ⇒ same face). 14px, 1920x1080 @ 100%.

    1. Cause A — the CJK glyphs are not coming from a Chinese font

    text locale 金 订 额 历
    none (engine default) Yu Gothic UI MS Gothic MS Gothic MS Gothic
    en-US pixel-identical to "none" (mae 0.000 over the whole string)
    zh-CN Microsoft YaHei UI Microsoft YaHei UI Microsoft YaHei UI Microsoft YaHei UI

    Each cell is the only face out of Microsoft YaHei, Microsoft YaHei UI, Yu Gothic, Yu Gothic UI, MS Gothic, Microsoft JhengHei, SimSun that matched those pixels exactly. (金 is the glyph that separates the two Japanese families; for 订/额/历 the Yu Gothic faces emit an identical bitmap, so MS Gothic is the name to use for them — either way, not a Chinese face.)

    Two things fall out of this:

    • "no locale" and en-US produce identical pixels, so the engine is passing en-US rather than the OS locale — exactly Rusino's diagnosis.
    • One sentence is assembled from two different Japanese faces, and none of them is a Chinese face. That is also your "shifted by half a pixel" impression: the Japanese designs put the top strokes on a different sub-pixel phase and at a different weight, so the row of stems does not line up with YaHei's.

    Weight, same face size (14px), summed over the four probed glyphs: Japanese fallback 166.7 vs YaHei UI 209.9 coverage units ⇒ −20.6% (−19.6% measured over the whole line: 651.8 vs 810.8). So the "thin and pale" part is glyph design, not gamma.

    Image

    Applications can work around it today — the locale only has to reach the paragraph, no flutter_localizations needed:

    DefaultTextStyle(
      style: const TextStyle(locale: Locale('zh', 'CN')),
      child: app,
    );

    Measured with that wrapper: ink mass 1588 vs 1598 for a per-TextStyle zh-CN and 1329 for the control wrapper without locale (default 1334) — it does switch the face.

    Ask: when the app doesn't specify a locale, use the OS UI locale for font fallback. If you can point me at where the fallback locale is chosen, I'll happily send the patch.

    2. The Latin aside

    That part of the comparison is not like-for-like. Flutter's Latin on Windows resolves to Segoe UI (a fontFamily: 'Segoe UI' probe is pixel-identical to the default render, mae 0.000; Microsoft YaHei is not). The GDI reference you looked at was drawn with Microsoft YaHei, so it shows YaHei's Latin. You are comparing Segoe UI against YaHei's Latin, which says nothing about Skia's rasterizer — force the same face on both sides and the Latin comparison flips.

    3. Cause B — the rasterization does no grid-fitting

    Same face (Microsoft YaHei), same 14px, grayscale on both sides, so chroma is out of the picture. Numbers are over the ink pixels of one line:

    advance (px) ink mass inked px solid (L<64) fringe (L≥64)
    GDI / DirectWrite, ANTIALIASED_QUALITY 196 743 1020 59.1% 40.9%
    GDI / DirectWrite, ClearType converted to gray (luma) 196 858 1310 43.7% 56.3%
    Flutter 3.47 / Impeller, YaHei 200 811 1276 41.4% 58.6%
    Flutter 3.47 / Impeller, default fallback (JP) 198 652 1100 36.4% 63.6%

    The interesting row is the second one: Flutter's grayscale output has almost exactly the statistics of ClearType converted to gray (the same conversion you did for your screenshot) (41.4% vs 43.7% solid), and is far away from GDI's real grayscale path (59.1%). In other words Flutter's text has the coverage profile of subpixel AA with the subpixel information discarded — which is precisely the "mush" it reads as at 14px. GDI's grayscale path still snaps stems to the pixel grid, so a 1px CJK stem becomes one near-solid column; Flutter spreads the same stem over two gray columns. That is grid-fitting/hinting, not sampling.

    (GDI's advance is 196 vs Flutter's 200 because GDI rounds advances to whole pixels — that rounding is part of the same grid-fitting behaviour, so this is not a pure rasterizer A/B, but it does not affect stem widths, which is what the solid/fringe split measures.)

    Image

    Context for why this still matters: 96 DPI / 100% scaling desktops are not a niche that can be skipped. The Steam Hardware & Software Survey (Jan 2026) still reports 1920x1080 as the primary display resolution on ~52% of systems — #1 by a wide margin, more than 1440p + 4K + ultrawide combined. StatCounter likewise lists 1920x1080 as the #1 desktop resolution worldwide (~24% of desktop page views). The share is slowly declining, but the absolute base is huge, and that is exactly the configuration where unhinted grayscale AA hurts CJK most. Flutter desktop adoption on Windows will keep running into this for years, so I hope 96 DPI text quality is not written off.

    Ask: is unhinted rasterization deliberate? If it's a cross-platform text-layout stability decision I completely understand, but could it be an opt-in engine switch — grayscale only, default off? Even vertical stem snapping alone would close most of what is left.

    4. On the gamma suggestion — I'd hold off

    We measured it rather than assume it, and it does not point where expected. Ink mass of black-on-white vs white-on-black, same face and size:

    • Flutter: 1652.5 / 1994.5 ⇒ dark-on-light is −17.1%
    • GDI: 1478.2 / 1889.4 ⇒ dark-on-light is −21.8%

    Native Windows is more asymmetric than Flutter, so Flutter's text gamma is not deviating from native in the "light theme too thin" direction. Combined with your point that Impeller already does its own correction, forcing SK_GAMMA_EXPONENT=1.6 / SK_GAMMA_CONTRAST=0.5 looks like a straightforward way to double-correct. Happy to be shown a different measurement, but I would not spend effort there before A and B.

    5. Net position

    Not asking for a 3.47 or an Impeller release, and not asking for chroma any more. Two defaults:

    1. Fallback locale ← OS UI locale when the app doesn't set one. No rasterizer change, no risk to existing apps, and it removes wrong-glyph-shape + 20% missing ink for every CJK app on Windows.
    2. An opt-in hinted-text switch on the Windows embedder, grayscale only. This is the post-chroma lever for low-DPI CJK.

    I'll keep tracking both items in this issue. I'll keep supplying captures, diffs and validation either way — the harness is a ~150-line Flutter app (one glyph per boundary, PNG dumped via toImage) plus a ~60-line pixel-diff script, and I can post both if useful.

    Environment: Windows 10 22H2 (10.0.19045), Intel HD Graphics 630, 1920x1080 @100% (FontSmoothing=2, FontSmoothingType=2, ClearType on), Flutter 3.47.1 stable, engine 11d79658c444, Impeller OpenGLESSDF.

  15. Rusino commented on Nov 4, 2026

    @Rusino
    Contributor

    @gaaclarke, @SuperAiPm's analysis matches what the code does, and I agree gamma is not the lever (Impeller already corrects it, so the Skia gamma defines would double-correct).

    1. Fallback locale. The en-US comes from the framework, not the engine. WidgetsApp/MaterialApp.supportedLocales defaults to [Locale('en', 'US')], so on a zh-CN system Localizations still resolves to en_US, and RichText/EditableText pass Localizations.maybeLocaleOf(context) into the paragraph style. That is why "no locale" and en-US give identical pixels in the measurements above, and why DirectWrite fallback picks Japanese faces for Han characters. An app that lists Chinese in supportedLocales (with the flutter_localizations delegates) or sets TextStyle.locale, as in the workaround above, should get Microsoft YaHei today. A framework or engine change would be to use the OS UI locale for font fallback when the resolved locale is only the default en-US.
    2. Crispness. This is not missing hinting: Impeller already asks Skia for SkFontHinting::kSlight, and Skia's DirectWrite scaler keeps grid fitting on for every level except kNone. The difference from GDI comes from the rendering mode Skia picks (NATURAL/NATURAL_SYMMETRIC for fonts with a gasp table, while GDI uses classic mode) and from setSubpixel(true) in the Impeller glyph atlas, which puts glyphs at fractional x positions and spreads 1px stems over two columns. An opt-in Windows switch that turns off subpixel positioning for small CJK text (and possibly asks for a GDI-classic rendering mode) is the experiment I would try first.
  16. self-assigned this
    on Nov 4, 2026
  17. gaaclarke commented on Nov 4, 2026

    @gaaclarke
    Member

    @SuperAiPm I've filed a PR to fix the default locale issue in the framework. Let's consider this issue that problem. Can you file another issue for your second request ("An opt-in hinted-text switch"). Please incorporate Rusino's notes in #194931 (comment).

  18. SuperAiPm commented on Nov 5, 2026

    @SuperAiPm
    Author

    @gaaclarke I filed the follow-up issue for the opt-in hinted-text switch as requested:#196024

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 lista: typographyText rendering, possibly libtxtc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterc: renderingUI glitches reported at the engine/skia or impeller rendering levele: 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