Skip to content

[Impeller] GetLocalCoverageLimit unconditionally clamps to root render target, causing incorrect subpass texture sizing and culling #195859

Closed

Description

@caimengnan-ops

Problem
Canvas::GetLocalCoverageLimit() in engine/src/flutter/impeller/display_list/canvas.cc ends with:
return maybe_coverage_limit->Intersection( Rect::MakeSize(render_target_.GetRenderTargetSize()));
This intersects the coverage limit with the root render target size. For the root pass this is a no-op (the pass texture IS the root RT). For subpasses (saveLayer) it's wrong: a subpass texture can extend beyond the root RT — shifted by a translate, or expanded by an image filter — and the root RT intersection clips off the valid portion outside the screen.

The line above already computes pass_texture ∩ clip_coverage, which is the correct bound.

Symptom
Scaled-down overview of a full-size page (e.g. launcher editing mode at 0.3×): the background fills correctly, but child elements beyond a certain distance from the screen center disappear. Also seen as edge artifacts with ImageFilter.blur on layers near the screen edge.

Suggested fix
Make the root RT intersection conditional:
std::optional<Rect> Canvas::GetLocalCoverageLimit(bool clamp_to_root = true) const { // ... existing pass_texture ∩ clip logic unchanged ... if (!clamp_to_root) return maybe_coverage_limit; return maybe_coverage_limit->Intersection( Rect::MakeSize(render_target_.GetRenderTargetSize())); }
Pass false from SaveLayer (texture sizing) and DrawDisplayList (cull rect). Root pass behavior unchanged.

We have been running with this fix internally and it resolves the reported symptoms.

Environment
Flutter main branch (also in 3.38.3 stable)
Impeller (Vulkan / OpenGLES)
File: impeller/display_list/canvas.cc, function GetLocalCoverageLimit()

Activity

  1. caimengnan-ops commented on Nov 3, 2026

    @caimengnan-ops
    ContributorAuthor

    Runnable repro with plain dart:ui (no plugins). A SaveLayer with a matrix image filter moves its content +300 px. Inside it, two circles are drawn at x=-200: one with drawCircle, one through drawPicture. Both should appear at x=100.

    import 'dart:typed_data';
    import 'dart:ui' as ui;
    import 'package:flutter_test/flutter_test.dart';
    
    void main() {
      test('drawPicture inside translated saveLayer is not culled', () async {
        const int w = 400, h = 200;
    
        final pr = ui.PictureRecorder();
        ui.Canvas(pr).drawCircle(const ui.Offset(-200, 150), 30,
            ui.Paint()..color = const ui.Color(0xFF00FF00));
        final inner = pr.endRecording();
    
        final rec = ui.PictureRecorder();
        final c = ui.Canvas(rec, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
        c.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
            ui.Paint()..color = const ui.Color(0xFFFFFFFF));
        final m = Float64List.fromList(
            <double>[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 300, 0, 0, 1]);
        c.saveLayer(null, ui.Paint()..imageFilter = ui.ImageFilter.matrix(m));
        c.drawCircle(const ui.Offset(-200, 50), 30,
            ui.Paint()..color = const ui.Color(0xFFFF0000)); // direct (control)
        c.drawPicture(inner); // via DrawDisplayList
        c.restore();
    
        final img = await rec.endRecording().toImage(w, h);
        final px = (await img.toByteData(format: ui.ImageByteFormat.rawRgba))!
            .buffer.asUint8List();
        List<int> at(int x, int y) {
          final i = (y * w + x) * 4;
          return [px[i], px[i + 1], px[i + 2]];
        }
    
        expect(at(100, 50), [255, 0, 0]);   // direct circle
        expect(at(100, 150), [0, 255, 0]);  // drawPicture circle
      });
    }

    Results on Flutter 3.38.3, Linux host:

    Backend direct circle drawPicture circle
    flutter test --enable-impeller visible missing (white)
    flutter test (Skia) visible visible

    The direct circle shows that the SaveLayer texture is large enough; only the nested DisplayList is culled. Fix: #195860

  2. saurabh-mirajkar commented on Nov 3, 2026

    @saurabh-mirajkar
    Contributor

    @caimengnan-ops Thanks for the detailed report. I reproduced this on Flutter 3.47.9 stable and current master 3.50.0 on macOS 27.2. The test fails with Impeller on both versions, while the same test passes under Skia. Labelling and routing it to the engine team for further investigation.

    Related: #195666

  3. added
    engineflutter/engine related. See also e: labels.
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    e: impellerImpeller rendering backend issues and features requests
    and removed
    in triagePresently being triaged by the triage team
    on Nov 3, 2026
  4. caimengnan-ops commented on Nov 7, 2026

    @caimengnan-ops
    ContributorAuthor

    A second case from review on
    #195860
    : the same translated SaveLayer, but the off-screen content is drawn inside a child saveLayer instead of a Picture. The child layer is sized against the root render target, so its off-screen part is dropped.

    import 'dart:typed_data';
    import 'dart:ui' as ui;
    import 'package:flutter_test/flutter_test.dart';
    
    void main() {
      test('child saveLayer inside translated saveLayer is not culled', () async {
        const int w = 400, h = 200;
    
        final rec = ui.PictureRecorder();
        final c = ui.Canvas(rec, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()));
        c.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
            ui.Paint()..color = const ui.Color(0xFFFFFFFF));
        final m = Float64List.fromList(
            <double>[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 300, 0, 0, 1]);
        c.saveLayer(null, ui.Paint()..imageFilter = ui.ImageFilter.matrix(m));
        c.drawCircle(const ui.Offset(-200, 50), 30,
            ui.Paint()..color = const ui.Color(0xFFFF0000)); // parent layer (control)
        c.saveLayer(null, ui.Paint()); // child layer
        c.drawCircle(const ui.Offset(-200, 150), 30,
            ui.Paint()..color = const ui.Color(0xFF00FF00));
        c.restore();
        c.restore();
    
        final img = await rec.endRecording().toImage(w, h);
        final px = (await img.toByteData(format: ui.ImageByteFormat.rawRgba))!
            .buffer.asUint8List();
        List<int> at(int x, int y) {
          final i = (y * w + x) * 4;
          return [px[i], px[i + 1], px[i + 2]];
        }
    
        expect(at(100, 50), [255, 0, 0]);   // circle in parent layer
        expect(at(100, 150), [0, 255, 0]);  // circle in child layer
      });
    }
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

    c: renderingUI glitches reported at the engine/skia or impeller rendering levele: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onteam-engineOwned by Engine team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions