Skip to content

[Impeller] MetalSDF mis-clips a gradient-shader + MaskFilter.blur draw when the canvas is rotated by 90°/180°/270° (shape vanishes near a window edge) - regression in 3.47 #191449

Closed

Description

@Ruzo

Steps to reproduce

Even though the original code was written by me way before AI, the issue causing this bug was discovered by Fable. So I'm letting Fable explain what it found:

  1. flutter create --platforms=macos --empty sdf_blur_repro, replace lib/main.dart with the file below.
  2. flutter run -d macos (Flutter 3.47.0 stable, which logs Using the Impeller rendering backend (MetalSDF)). This is a macOS Impeller bug, so it does not reproduce on web/DartPad — the sample compiles there but renders correctly.
  3. Look at the bottom row of arcs, or read the pixel probe the app prints after its first frame.

The page draws two rows of three 250 px arcs with one CustomPainter. Every arc is drawn on a canvas rotated by 1.5π about the arc centre (the common "put 0° at 12 o'clock" idiom). The only difference between columns is the Paint:

column Paint
A SweepGradient shader, no maskFilter
B SweepGradient shader + MaskFilter.blur(BlurStyle.normal, 1)
C solid color + MaskFilter.blur(BlurStyle.normal, 1)

The top row is flush with the top edge of the window, the bottom row is flush with the bottom edge.

Expected results

Six identical arcs (this is what flutter test / the software renderer produces, see expected_software_renderer.png).

Actual results

Bottom-row B is not drawn at all. Top-row B, and A/C in both rows, are fine.

Pixel probe output (one pixel sampled on the opaque part of each arc):

[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_metal_impeller.mm(53)] Using the Impeller rendering backend (MetalSDF).
=== pixel probe (window 782.0x600.0, dpr 1.0) ===
top    shaderOnly      @(242,125) rgba(180,99,56,255)  ok
top    shaderPlusBlur  @(508,125) rgba(179,98,56,255)  ok
top    solidPlusBlur   @(774,125) rgba(255,138,76,255)  ok
bottom shaderOnly      @(242,475) rgba(179,98,56,255)  ok
bottom shaderPlusBlur  @(508,475) rgba(14,10,11,255)  <-- NOT RENDERED
bottom solidPlusBlur   @(774,475) rgba(255,138,76,255)  ok

What I found while narrowing it down

The draw is not dropped; it is clipped by the window rect applied in the wrong orientation. Moving a single shader+blur arc (size S, top-left (px, py)) around an W×H window and measuring the lit pixels gives, in device pixels relative to the arc's own top-left:

canvas rotation visible part of the arc
rotate(1.5π) (270°) dx ≤ H − (py + S) → vanishes when it touches the bottom edge
rotate(0.5π) (90°) dy ≤ W − (px + S) → vanishes when it touches the right edge
rotate(π) (180°) dx ≥ S − (W − (px + S)) and dy ≥ S − (H − (py + S)) → vanishes at either edge
no rotation, rotate(2π), rotate(0.1π), rotate(0.25π), rotate(0.499π), scale(1.0001), skew(0.001, 0) fully visible

Measured example for 270° (S = 250, W×H = 800×600, row sweep down the window): top at y = 0/50/100 → fully visible; y = 150 → dx ≤ 200; y = 200 → dx ≤ 150; y = 250 → dx ≤ 100; y = 300 → dx ≤ 50; y = 350 → nothing.

So the clip is only wrong for rotations that are exact multiples of 90° (0.499999π also fails, 0.49π does not), and only for paints that combine a shader with a mask blur. Shader-only and solid-colour+blur paints are correct in every configuration. isComplex, full-circle vs 270° arcs, hard gradient stops and Colors.transparent stops make no difference.

In a real app this shows up as a timer progress ring (shader + 1 px blur, canvas rotated 1.5π so the timer starts at 12 o'clock) losing most of its arc after upgrading to 3.47 — see real_app_timer_ring.png: only the stub next to the thumb survives, which is exactly the intersection of the lit sweep with the dx ≤ bottom margin region. Same code renders correctly in flutter test and rendered correctly on 3.38.

Possibly related to the earlier #127013 ("MaskFilter not applied when using Shader", fixed in 3.11); 3.47 switched macOS to the new MetalSDF backend, where this is now a clipping error rather than an ignored blur.

Workaround: drop the maskFilter from the shader paint (or draw the blur with a solid-colour paint and the gradient without blur), or avoid rotating the canvas and offset the arc/gradient start angles instead.

Code sample

Code sample
// Minimal repro for flutter/flutter.
//
// Impeller MetalSDF (macOS, Flutter 3.47): a Paint that has BOTH a gradient
// shader AND a MaskFilter.blur is mis-clipped when the canvas is rotated by a
// multiple of 90°. The clip moves with the shape's distance to the window
// edge; a shape touching the bottom edge (for rotate(1.5π)) is not drawn at
// all. Shader-only and solid+blur paints are unaffected, as is any rotation
// that is not a multiple of 90°.
//
// Layout: two rows of three 250 px arcs, every arc drawn on a canvas rotated
// by 1.5π about its centre.
//   column A: gradient shader, no blur     (control)
//   column B: gradient shader + blur(1)    <-- bug column
//   column C: solid color + blur(1)        (control)
//   top row   : arcs flush with the TOP of the window
//   bottom row: arcs flush with the BOTTOM of the window
// Expected: six identical arcs. Actual on MetalSDF: bottom-row B is missing.
//
// Run as a macOS desktop app (`flutter run -d macos`); the bug is in the macOS
// Impeller backend, so it cannot reproduce on web/DartPad. After the first
// frame the app samples one pixel on every arc and prints the RGBA values.

import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';

const Color kAccent = Color(0xFFFF8A4C);
const Color kBackground = Color(0xFF0E0A0B);
const double kArcSize = 250;
const double kStrokeWidth = 8;
const double kGap = 16;
const double kRotation = 1.5 * math.pi;

void main() {
  runApp(
    const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: ReproPage(),
    ),
  );
}

enum PaintVariant {
  shaderOnly('A: shader, no blur'),
  shaderPlusBlur('B: shader + blur(1)'),
  solidPlusBlur('C: solid + blur(1)');

  const PaintVariant(this.label);
  final String label;
}

class ArcPainter extends CustomPainter {
  const ArcPainter(this.variant);
  final PaintVariant variant;

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final rect = Rect.fromCircle(
      center: center,
      radius: size.shortestSide / 2 - kStrokeWidth,
    );

    // Rotate about the arc centre (e.g. to move 0° to 12 o'clock).
    canvas.translate(center.dx, center.dy);
    canvas.rotate(kRotation);
    canvas.translate(-center.dx, -center.dy);

    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = kStrokeWidth
      ..strokeCap = StrokeCap.butt;

    switch (variant) {
      case PaintVariant.shaderOnly:
        paint.shader = _gradient(rect);
      case PaintVariant.shaderPlusBlur:
        paint
          ..shader = _gradient(rect)
          ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1);
      case PaintVariant.solidPlusBlur:
        paint
          ..color = kAccent
          ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1);
    }

    canvas.drawArc(rect, 0, 1.5 * math.pi, false, paint);
  }

  static Shader _gradient(Rect rect) => SweepGradient(
        colors: [kAccent.withValues(alpha: 0.05), kAccent, kAccent],
        stops: const [0.0, 0.375, 0.75],
      ).createShader(rect);

  @override
  bool shouldRepaint(ArcPainter oldDelegate) => oldDelegate.variant != variant;
}

class ArcRow extends StatelessWidget {
  const ArcRow({super.key, required this.labelsBelow});
  final bool labelsBelow;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (final (i, v) in PaintVariant.values.indexed) ...[
          if (i > 0) const SizedBox(width: kGap),
          Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              if (!labelsBelow) ArcLabel(variant: v),
              CustomPaint(
                size: const Size.square(kArcSize),
                painter: ArcPainter(v),
              ),
              if (labelsBelow) ArcLabel(variant: v),
            ],
          ),
        ],
      ],
    );
  }
}

class ArcLabel extends StatelessWidget {
  const ArcLabel({super.key, required this.variant});
  final PaintVariant variant;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Text(
        variant.label,
        style: const TextStyle(color: Colors.white70, fontSize: 11),
      ),
    );
  }
}

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

  @override
  State<ReproPage> createState() => _ReproPageState();
}

class _ReproPageState extends State<ReproPage> {
  final GlobalKey _boundaryKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    SchedulerBinding.instance.addPostFrameCallback((_) => _probe());
  }

  Future<void> _probe() async {
    // Wait one extra frame so the GPU-backed surface has definitely rendered.
    await SchedulerBinding.instance.endOfFrame;
    if (!mounted) return;
    final dpr = View.of(context).devicePixelRatio;
    final boundary = _boundaryKey.currentContext!.findRenderObject()!
        as RenderRepaintBoundary;
    final image = await boundary.toImage(pixelRatio: dpr);
    final bytes = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!;
    final size = boundary.size;

    final buffer = StringBuffer(
      '\n=== pixel probe (window ${size.width}x${size.height}, dpr $dpr) ===\n',
    );
    const radius = kArcSize / 2 - kStrokeWidth;
    final rowWidth = 3 * kArcSize + 2 * kGap;
    final left = (size.width - rowWidth) / 2;
    for (final (row, top) in [0.0, size.height - kArcSize].indexed) {
      for (final (col, variant) in PaintVariant.values.indexed) {
        final cx = left + col * (kArcSize + kGap) + kArcSize / 2;
        final cy = top + kArcSize / 2;
        // 90° into the sweep — inside the opaque part of every variant —
        // offset by the canvas rotation so we sample the same spot on screen.
        const angle = math.pi / 2 + kRotation;
        final x = ((cx + radius * math.cos(angle)) * dpr).round();
        final y = ((cy + radius * math.sin(angle)) * dpr).round();
        final offset = (y * image.width + x) * 4;
        final r = bytes.getUint8(offset);
        final g = bytes.getUint8(offset + 1);
        final b = bytes.getUint8(offset + 2);
        final a = bytes.getUint8(offset + 3);
        // Background is 0x0E0A0B; anything with r < 40 was not drawn.
        buffer.writeln(
          '${row == 0 ? "top   " : "bottom"} ${variant.name.padRight(15)} '
          '@($x,$y) rgba($r,$g,$b,$a)  ${r > 40 ? "ok" : "<-- NOT RENDERED"}',
        );
      }
    }
    debugPrint(buffer.toString());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: kBackground,
      body: RepaintBoundary(
        key: _boundaryKey,
        child: Container(
          color: kBackground,
          child: const Column(
            children: [
              // Top row: arcs flush with the top edge, labels below them.
              ArcRow(labelsBelow: true),
              Spacer(),
              // Bottom row: labels above, arcs flush with the bottom edge.
              ArcRow(labelsBelow: false),
            ],
          ),
        ),
      ),
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration Expected: Image

Actual:
Image

(The actual screenshot is the full macOS window of the repro app; bottom-row B is missing.)

Real-world symptom (timer ring, shader + blur(1), canvas rotated 1.5π):
Image

Logs

Logs
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_metal_impeller.mm(53)] Using the Impeller rendering backend (MetalSDF).

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.47.0, on macOS 26.5.2 25F84 darwin-arm64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
    ! Multiple adb binaries found. This can cause conflicts and device detection issues:
[✓] Xcode - develop for iOS and macOS (Xcode 26.6)
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
    ! Device emulator-5562 is offline.
[✓] Network resources

Activity

  1. suryaKommana2662 commented on Aug 21, 2026

    @suryaKommana2662
    Contributor

    @Ruzo Thanks for report. I was able to reproduce the reported behavior locally on macOS.

    Flutter 3.47.0:

    [IMPORTANT:flutter/shell/platform/embedder/embedder_surface_metal_impeller.mm(53)] Using the Impeller rendering backend (MetalSDF).
    
    === pixel probe (window 782.0x600.0, dpr 2.0) ===
    top    shaderOnly      @(484,250) rgba(178,97,54,255)  ok
    top    shaderPlusBlur  @(1016,250) rgba(179,98,56,255)  ok
    top    solidPlusBlur   @(1548,250) rgba(255,138,76,255)  ok
    bottom shaderOnly      @(484,950) rgba(178,96,54,255)  ok
    bottom shaderPlusBlur  @(1016,950) rgba(14,10,11,255)  <-- NOT RENDERED
    bottom solidPlusBlur   @(1548,950) rgba(255,138,76,255)  ok
    
    Image

    Flutter 3.44.8:

    === pixel probe (window 782.0x600.0, dpr 2.0) ===
    top    shaderOnly      @(484,250) rgba(179,97,55,255)  ok
    top    shaderPlusBlur  @(1016,250) rgba(179,97,55,255)  ok
    top    solidPlusBlur   @(1548,250) rgba(255,138,76,255)  ok
    bottom shaderOnly      @(484,950) rgba(179,97,55,255)  ok
    bottom shaderPlusBlur  @(1016,950) rgba(179,97,55,255)  ok
    bottom solidPlusBlur   @(1548,950) rgba(255,138,76,255)  ok
    
    Image

    Visually, I observed the reported missing B: shader + blur(1) arc in my local testing as well. I have attached screenshots from both Flutter versions for comparison.

    The 3.47.0 output shows the bottom shaderPlusBlur sample as NOT RENDERED, while the same probe reports ok on 3.44.8. Routing to the team-engine for further investigation.

  2. added
    c: regressionIt was better in the past than it is now
    engineflutter/engine related. See also e: labels.
    e: impellerImpeller rendering backend issues and features requests
    fyi-macosFor the attention of macOS platform team
    and removed
    in triagePresently being triaged by the triage team
    on Aug 21, 2026
  3. added
    P1High-priority issues at the top of the work list
    on Aug 24, 2026
  4. self-assigned this
    on Aug 26, 2026
  5. github-actions commented on Sep 17, 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 Sep 17, 2026
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 listc: regressionIt was better in the past than it is nowe: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.found in release: 3.47Found to occur in 3.47team-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