Skip to content

[Impeller] Windows OpenGLESSDF renders thin strokes ~2x thicker and darker than Skia (constant ~1.4px extra coverage at all widths) #188911

Description

@amantinband

Summary

On Windows, the new default Impeller backend (banner: Using the Impeller rendering backend (OpenGLESSDF)) renders stroked lines and paths with a roughly constant +1.3–1.6 physical px of extra coverage ("ink"), independent of stroke width, compared to Skia on the same machine. For wide strokes this is a subtle softening; for strokes in the 0.5–2 logical px range it makes lines read 1.7×–2.7× thicker and noticeably darker. Sub-pixel strokes get no coverage compensation (Skia renders a 0.75-physical-px line as faint ~38% gray; OpenGLESSDF renders a ~2px band with a 76%-opacity core).

drawLine and drawPath produce identical results, so this is the stroke rasterization itself, not an API-specific path. Filled paths look correct.

Context: we develop a desktop design tool (Figma-like). After moving to master for Impeller-on-Windows, all thin-stroke UI — 1.2px custom-painted toolbar icons, 0.5px snap-guide hairlines — reads 2–4× bolder than intended. The same code on macOS (Impeller/Metal) renders at expected weight, so this appears specific to the SDF rasterizer rather than Impeller generally.

Steps to reproduce

  1. Run the code sample below on Windows on master: flutter run -d windows --debug → banner reports OpenGLESSDF.
  2. Run again with --no-enable-impeller → Skia.
  3. Screenshot both (150% display scaling, DPR 1.5) and scan a vertical pixel column through the lines.
Code sample (complete main.dart)
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: TestPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: CustomPaint(size: Size.infinite, painter: LinePainter()),
    );
  }
}

/// Horizontal black lines at fixed logical y positions, alternating
/// drawLine vs drawPath at matching stroke widths, so a vertical pixel
/// scan of a screenshot can compare rendered thickness per API.
class LinePainter extends CustomPainter {
  static const rows = <(double y, double width, bool useLine, StrokeCap cap)>[
    (40, 0.5, true, StrokeCap.butt),
    (80, 0.5, false, StrokeCap.butt),
    (120, 1.0, true, StrokeCap.butt),
    (160, 1.0, false, StrokeCap.butt),
    (200, 1.19, true, StrokeCap.round),
    (240, 1.19, false, StrokeCap.round),
    (280, 2.0, true, StrokeCap.butt),
    (320, 2.0, false, StrokeCap.butt),
    (360, 4.0, true, StrokeCap.butt),
    (400, 4.0, false, StrokeCap.butt),
  ];

  @override
  void paint(Canvas canvas, Size size) {
    for (final (y, w, useLine, cap) in rows) {
      final paint = Paint()
        ..color = Colors.black
        ..style = PaintingStyle.stroke
        ..strokeWidth = w
        ..strokeCap = cap;
      final a = Offset(20, y);
      final b = Offset(size.width - 20, y);
      if (useLine) {
        canvas.drawLine(a, b, paint);
      } else {
        canvas.drawPath(Path()..moveTo(a.dx, a.dy)..lineTo(b.dx, b.dy), paint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Measured results

Luminance profiles from an OS-level screen capture (values 0–255, white background = 255), vertical scan through the lines, away from the caps. "Ink" = Σ(255 − lum)/255, i.e. total coverage in physical px. DPR 1.5.

strokeWidth (logical → physical) Skia profile Skia ink OpenGLESSDF profile SDF ink ratio
0.5 → 0.75 px [159,159] 0.75 [193,62,62,193] 2.00 2.7×
1.0 → 1.5 px [64,64] 1.50 [150,5,5,150] 2.78 1.9×
1.19 → 1.79 px [28,28] 1.78 [124,0,0,124] 3.03 1.7×
2.0 → 3.0 px [127,0,0,128] 3.00 [176,4,0,0,4,176] 4.59 1.5×
4.0 → 6.0 px [0,0,0,0,0,0] 6.00 [64,0,0,0,0,0,0,64] 7.50 1.25×

Two observations:

  • Skia's total coverage equals the stroke width exactly at every row (analytic coverage AA). OpenGLESSDF adds a near-constant +1.24 to +1.59 px of coverage regardless of width — consistent with a fixed-width smoothstep/falloff around the stroke edge rather than derivative-scaled coverage.
  • Sub-pixel strokes are not coverage-compensated: Skia renders the 0.75px line as 2 columns of ~38% gray (faint, reads "thin"); OpenGLESSDF renders a 76%-opacity 2px core plus fringes — 2.7× the ink, and much darker.

drawLine and drawPath rows are pixel-identical in both backends. The 1.19 rows use StrokeCap.round, the rest StrokeCap.butt — the inflation is the same across both. The effect is most visible on axis-aligned lines (fringe accumulates into uniform darker bands); diagonal/curved strokes read as softening.

Expected results

Thin strokes match Skia's weight/darkness (total coverage ≈ stroke width; sub-pixel strokes proportionally fainter), as Impeller/Metal on macOS does.

Actual results

Constant additive fringe at all widths; near-full-opacity core even for sub-pixel strokes. Hairline-heavy UIs (dividers, guides, stroked icons) visibly change weight between Skia/macOS and Windows-OpenGLESSDF.

Possibly related

Environment

[✓] Flutter (Channel master, 3.46.0-1.0.pre-364, on Microsoft Windows [Version 10.0.26200.8655], locale en-IL)
    • Framework revision 7400c96c37 (2026-07-01)
    • Engine revision 7400c96c37
    • Dart version 3.13.0 (build 3.13.0-256.0.dev)
[✓] Windows Version (Windows 11 or higher, 25H2, 2009)

GPU: Intel(R) Graphics (iGPU), driver 32.0.101.7082. Display at 150% scaling (DPR 1.5).

Renderer banner (default run): [IMPORTANT:flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc(126)] Using the Impeller rendering backend (OpenGLESSDF).

Side-by-side screenshots available — happy to attach.

Metadata

Metadata

Assignees

Labels

P0Critical issues such as a build break or regressione: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.fyi-windowsFor the attention of the Windows platform teamplatform-windowsBuilding on or for Windows specificallyteam-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