Flutter Windows Multi-Window Focus Bug Fixed, but Impeller Breaks Thin Lines
Because I've been using Flutter multi-window for a while, there was a Windows multi-window bug that was quite annoying. When switching to a project child window, it would activate the main window by default, and only then could you click again to return to the child window:
More precisely, activating a child
RegularWindowwould cause the main window to come back to the foreground and cover it.
This problem had many triggers, but the main issue was that the code was written too carelessly.
One reason was in the Engine. When the Engine received the WM_ACTIVATE message from Windows, regardless of whether the window was being activated or deactivated, it would execute:
FocusRootViewOf(this);
That is, it would give focus to the Flutter view in the current window. But the problem was, when the main window was losing focus, it would also execute this line, causing the main window, which should have retreated to the back, to actively SetFocus itself, and then Windows would reactivate the main window and bring it to the front.
This Bug aside, the main issue was that the Flutter framework also attached the focus trees of multiple windows at the wrong level. In Flutter's multi-window setup, each window has its own View, but in the Framework, a child window's FocusScope could still be attached under the parent window's FocusScope. This created a new problem:
When a child window gained focus, the main window's focus scope was also considered "focused," and then the main window would again notify the engine: "I have focus, activate my native window," and again, it would pull the main window to the front...
Moreover, the entire process did not check whether another window had already taken focus, which meant this Bug was caused by a combination of several issues and couldn't be fundamentally fixed for a long time.
I could only write fallback code to force a switch.
However, these three problems were finally fixed recently:
- If
WA_INACTIVEis received, meaning the window is losing focus, it returns directly and no longer grabs focus. - Each
View's focus subtree is directly attached to the global root scope. - The old focus is only restored when no other window has requested focus.
After I merged this part of the code, I immediately fell into another pit: Flutter Windows started enabling Impeller. Yes, Flutter is starting to adapt Impeller on Windows using OpenGLESSDF:
Then the problem appeared again. When drawing on a canvas, thin lines appeared roughly 1.3–1.6 physical pixels thicker than in Skia, and they were also darker:
If alpha was overlaid, it would also directly fail. These thin lines, which were previously faintly visible in my project, became thick and bold after Impeller was introduced:
According to calculations from the issue, the change in thickness varies at different scales on Impeller:
| Stroke Width (logical→physical) | Skia Coverage | Impeller Coverage | Multiplier |
|---|---|---|---|
| 0.5 → 0.75 px | 0.75 | 2.00 | 2.7× |
| 1.0 → 1.5 px | 1.50 | 2.78 | 1.9× |
| 2.0 → 3.0 px | 3.00 | 4.59 | 1.5× |
| 4.0 → 6.0 px | 6.00 | 7.50 | 1.25× |
And thickness isn't even the main problem. The main problems are:
- Under canvas transform scaling, the AA edges are also scaled up, causing lines to become blurrier as the zoom ratio increases (at zoom=4, a 0.5px line becomes a 12px wide blurry band).
- Alpha values are completely ignored:
0xCCand0x66render with exactly the same pixel results. - Extremely thin lines disappear entirely in some cases.
This problem looks like an issue with Impeller's SDFAlpha implementation. The smoothstep range is a fixed physical pixel width. For example, smoothstep(-fade_size, fade_size, sdf) produces a gradient transition band of about half a pixel_size * aa_pixels on each side of the stroke edge, and this transition band is a fixed width (depending on the aa_pixels parameter, which is set as a constant in the code).
For analytical AA like Skia's, coverage = stroke width. But for SDF's smoothstep, coverage = stroke width + fixed edge transition band. So when the stroke is only 0.75 physical pixels wide, this fixed ~1.2px transition band becomes very noticeable, causing the rendered line to be a "solid wide line + blur on both sides."
Additionally, the minimum width clamping in float half_stroke = max(stroke_width, base_pixel_size) * 0.5 does not compensate for alpha. When stroke_width < 1 pixel, this forces the width to be clamped to at least 1 pixel (base_pixel_size), but it doesn't proportionally reduce alpha to compensate.
Skia's approach is sub-pixel line width: it turns the line into a 1-pixel wide line but proportionally reduces alpha (a 0.75px line ≈ 38% opaque 1px line). Impeller's SDF implementation directly gives a fully opaque 1px+ wide line, so it looks darker and thicker.
That is, SDFStroke does not incorporate alpha into the calculation at all. The alpha value is processed elsewhere by the time it reaches the fragment shader and is not linked to the width clamping.
Current temporary workarounds include:
- Use
drawRectinstead ofdrawLine: Replace a stroked line with a filled rect whose height equals the desired line width. The UberSDF coverage for fill paths is correct. - Force the use of Skia:
--no-enable-impeller - 1px integer pixel line width: Use lines with a 1.0 physical pixel width.
It seems that Impeller on Windows will likely have to go through the same issues it had on Android. It must be said, the maintenance cost of a self-rendering engine is indeed high. However, multi-window on Windows is now truly usable. Since the bug was fixed, there have been basically no major problems.
Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.
Will multi-window still not make it to the stable release next time?
A bit difficult.
Front row [strong]