Skip to content

[Impeller] Do not clamp the local coverage limit to the root render target - #195860

Merged
auto-submit[bot] merged 5 commits into
flutter:masterfrom
caimengnan-ops:fix-cull-rect-clamp
Nov 11, 2026
Merged

auto-submit[bot] merged 5 commits into
flutter:masterfrom
caimengnan-ops:fix-cull-rect-clamp

Conversation

@caimengnan-ops

@caimengnan-ops caimengnan-ops commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Canvas::GetLocalCoverageLimit() intersected its result with the root render target. Inside a subpass this is too tight: content outside the root RT can still end up on-screen, for example when an ancestor SaveLayer has a matrix image filter that translates it. Both callers were affected:

  • DrawDisplayList uses the limit as its cull rect, so a nested DisplayList (e.g. a dart:ui Picture) drawn off-screen inside such a layer was culled.
  • SaveLayer uses the limit to size the subpass, so a child SaveLayer inside such a layer lost its off-screen content.

This PR removes the root RT intersection. The limit is now bounded only by the current pass texture and the clip. On the root pass the pass texture is the root RT, so the result there is unchanged. Subpass textures in the affected cases can be larger, because they now have to hold the content that the ancestor filter moves on-screen.

Tests:

  • DrawDisplayListInTranslatedSaveLayerIsNotCulled: a circle drawn through DrawDisplayList inside a translated SaveLayer.
  • NestedSaveLayerInTranslatedSaveLayerIsNotCulled: a circle drawn inside a child SaveLayer inside a translated SaveLayer.

Each test also draws a control circle directly into the parent layer. Before the fix only the control circle renders.

Both scenarios reproduce with plain dart:ui on Flutter 3.38.3 (flutter test --enable-impeller): the circle is missing on Impeller and present on Skia. See #195859.

Fixes #195859

@flutter-dashboard

Copy link
Copy Markdown

It looks like this pull request may not have tests. Please make sure to add tests or get an explicit test exemption before merging.

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. If both you and the PR author believe this PR qualifies for a test exemption, escalate to the tech lead for the changed area to review and apply the test-exempt label. All reviewers should feel empowered to ask for tests before escalating.

@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. e: impeller Impeller rendering backend issues and features requests labels Nov 3, 2026
@google-cla

google-cla Bot commented Nov 3, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Canvas::GetLocalCoverageLimit method to include an optional clamp_to_root parameter, allowing subpasses to bypass clamping to the root render target size. Corresponding updates were made to the method signature in the header file and at call sites in SaveLayer and DlDispatcherBase::drawDisplayList. A reviewer suggested that the clamping logic could be simplified further by removing the parameter entirely, as the intersection is a no-op on the root pass and potentially incorrect for subpasses.

Comment on lines 1826 to 1835
// For subpasses the root RT rect is irrelevant: the subpass texture can
// extend beyond the root RT (e.g. shifted by a translate, or expanded by
// an image filter). The pass_texture ∩ clip above is already the correct
// upper bound. On the root pass, pass_texture == root_RT, so this
// intersection is a no-op either way.
if (!clamp_to_root) {
return maybe_coverage_limit;
}
return maybe_coverage_limit->Intersection(
Rect::MakeSize(render_target_.GetRenderTargetSize()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the intersection with the root render target size is a no-op on the root pass (where pass_texture == root_RT) and incorrect on subpasses (where the subpass texture can extend beyond the root RT), we can completely remove the clamping logic and the clamp_to_root parameter.\n\nThis simplifies the API and avoids having to pass /*clamp_to_root=*/false at all the call sites (e.g., in SaveLayer and DrawDisplayList).\n\nIf we remove the clamping entirely, GetLocalCoverageLimit will naturally return the correct bounded limit for both root passes and subpasses.

Suggested change
// For subpasses the root RT rect is irrelevant: the subpass texture can
// extend beyond the root RT (e.g. shifted by a translate, or expanded by
// an image filter). The pass_texture ∩ clip above is already the correct
// upper bound. On the root pass, pass_texture == root_RT, so this
// intersection is a no-op either way.
if (!clamp_to_root) {
return maybe_coverage_limit;
}
return maybe_coverage_limit->Intersection(
Rect::MakeSize(render_target_.GetRenderTargetSize()));
return maybe_coverage_limit;
References
  1. Suggest simplification and refactoring to enhance readability and maintainability, and write what you need and no more. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, but I would rather not drop the clamp entirely. In SaveLayer, the coverage limit is not only an upper bound: ComputeSaveLayerCoverage returns it directly as the subpass size when the output floods or the bounds are unbounded. Unclamping it there would grow offscreen textures, and it is not needed for this bug.

I have narrowed the change so only DrawDisplayList (culling) uses the unclamped value, and SaveLayer is unchanged. I also replaced the tests with one that fails without the fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do need to drop the clamp entirely.

Your fix covers the DrawDisplayList case, where DrawDisplayList is used inside a SaveLayer. It removes the clamp so when DrawDisplayList has contents drawn off screen, it doesn't get culled because the SaveLayer could have a filter that moves the off-screen contents on-screen.

The other case where GetLocalCoverageLimit is used is in SaveLayer to get the subpass size. Your fix leaves this unchanged. But I think this case has the same issue. If SaveLayer is called inside a parent SaveLayer, the child SaveLayer's off-screen contents could be moved on-screen by a filter on the parent SaveLayer. If the clamp is kept for this case, the child SaveLayer's off-screeen contents would get incorrectly culled and wouldn't get drawn when the parent SaveLayer's filter moves it on-screen.

This can be reproed with a modified version of your DrawDisplayListInTranslatedSaveLayerIsNotCulled test:

TEST_P(AiksTest, NestedSaveLayerInTranslatedSaveLayerIsNotCulled) {
  DlPaint green;
  green.setColor(DlColor::kGreen());

  DisplayListBuilder builder;
  builder.Scale(GetContentScale().x, GetContentScale().y);

  DlPaint paint;
  paint.setImageFilter(DlImageFilter::MakeMatrix(
      DlMatrix::MakeTranslation({300, 0}), DlImageSampling::kLinear));
  builder.SaveLayer(std::nullopt, &paint);
  // Control: drawn directly into the parent layer.
  builder.DrawCircle(DlPoint(-100, 100), 80, green);
  DlPaint child_paint;
  builder.SaveLayer(std::nullopt, &child_paint);
  // Drawn inside the child SaveLayer; not visible when SaveLayer clamps.
  builder.DrawCircle(DlPoint(-100, 300), 80, green);
  builder.Restore();
  builder.Restore();

  ASSERT_TRUE(OpenPlaygroundHere(builder.Build()));
}

As you mention, removing the clamping can result in growing offscreen textures. But this is correct and necessary. The offscreen texture must be sized to contain contents that ends up on-screen by the SaveLayer's translation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, thanks. The child SaveLayer case reproduces with plain dart:ui on 3.38.3 (missing on Impeller, present on Skia); I added it to the issue. Done in 4244c8d: the clamp is removed entirely and NestedSaveLayerInTranslatedSaveLayerIsNotCulled is added.

@caimengnan-ops

Copy link
Copy Markdown
Contributor Author

I signed the CLA.

@caimengnan-ops caimengnan-ops changed the title [Impeller] Don't clamp subpass coverage limit to root render target [Impeller] Do not clamp DrawDisplayList cull rect to the root render target Nov 3, 2026
…target

DrawDisplayList culls a nested DisplayList against
Canvas::GetLocalCoverageLimit(), which always intersects with the root
render target. Inside a subpass this is too tight: content outside the
root RT can still end up on-screen, for example when an ancestor
SaveLayer has a matrix image filter that translates it. Such content is
culled and never drawn.

Add a clamp_to_root parameter (default true) and pass false only from
DrawDisplayList. SaveLayer keeps the clamped value, because it can be
returned directly as the subpass size and unclamping it would grow
offscreen textures. On the root pass the two values are identical.

Test: DrawDisplayListInTranslatedSaveLayerIsNotCulled.

Fixes flutter#195859
Comment on lines 1826 to 1835
// For subpasses the root RT rect is irrelevant: the subpass texture can
// extend beyond the root RT (e.g. shifted by a translate, or expanded by
// an image filter). The pass_texture ∩ clip above is already the correct
// upper bound. On the root pass, pass_texture == root_RT, so this
// intersection is a no-op either way.
if (!clamp_to_root) {
return maybe_coverage_limit;
}
return maybe_coverage_limit->Intersection(
Rect::MakeSize(render_target_.GetRenderTargetSize()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do need to drop the clamp entirely.

Your fix covers the DrawDisplayList case, where DrawDisplayList is used inside a SaveLayer. It removes the clamp so when DrawDisplayList has contents drawn off screen, it doesn't get culled because the SaveLayer could have a filter that moves the off-screen contents on-screen.

The other case where GetLocalCoverageLimit is used is in SaveLayer to get the subpass size. Your fix leaves this unchanged. But I think this case has the same issue. If SaveLayer is called inside a parent SaveLayer, the child SaveLayer's off-screen contents could be moved on-screen by a filter on the parent SaveLayer. If the clamp is kept for this case, the child SaveLayer's off-screeen contents would get incorrectly culled and wouldn't get drawn when the parent SaveLayer's filter moves it on-screen.

This can be reproed with a modified version of your DrawDisplayListInTranslatedSaveLayerIsNotCulled test:

TEST_P(AiksTest, NestedSaveLayerInTranslatedSaveLayerIsNotCulled) {
  DlPaint green;
  green.setColor(DlColor::kGreen());

  DisplayListBuilder builder;
  builder.Scale(GetContentScale().x, GetContentScale().y);

  DlPaint paint;
  paint.setImageFilter(DlImageFilter::MakeMatrix(
      DlMatrix::MakeTranslation({300, 0}), DlImageSampling::kLinear));
  builder.SaveLayer(std::nullopt, &paint);
  // Control: drawn directly into the parent layer.
  builder.DrawCircle(DlPoint(-100, 100), 80, green);
  DlPaint child_paint;
  builder.SaveLayer(std::nullopt, &child_paint);
  // Drawn inside the child SaveLayer; not visible when SaveLayer clamps.
  builder.DrawCircle(DlPoint(-100, 300), 80, green);
  builder.Restore();
  builder.Restore();

  ASSERT_TRUE(OpenPlaygroundHere(builder.Build()));
}

As you mention, removing the clamping can result in growing offscreen textures. But this is correct and necessary. The offscreen texture must be sized to contain contents that ends up on-screen by the SaveLayer's translation.

Per review: SaveLayer has the same problem as DrawDisplayList. A child
SaveLayer inside a parent SaveLayer whose image filter translates content
on-screen was sized against the root RT, so its off-screen content was
dropped. Remove the clamp_to_root parameter and the root RT intersection
entirely; the pass texture and clip already bound the result, and on the
root pass the pass texture is the root RT.

Add NestedSaveLayerInTranslatedSaveLayerIsNotCulled (from review).

@b-luk b-luk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes, @caimengnan-ops.

Please also update the title and description, now that the changes are not limited to DrawDisplayList.

A tip regarding reviews: After making changes, if the PR is ready for review again you should click to re-request a review. That lets reviewers know the PR is ready to be looked at again.

Comment on lines +2865 to +2867
// Renders two green circles side by side. Both are drawn at x=-100 inside a
// SaveLayer whose image filter translates them +300 on-screen. The left one
// is drawn directly; the right one is drawn through DrawDisplayList (a nested

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description is incorrect. The circles are drawn with one on top and one below, not side by side. "Left one" and "right one" should be "top one" and "bottom one".

Same for the description of the nested save layer test below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e508e3b, both test descriptions now say top/bottom.

Comment on lines 1822 to 1824
@@ -1823,8 +1823,12 @@ std::optional<Rect> Canvas::GetLocalCoverageLimit() const {
return std::nullopt;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is no longer needed. It was needed before, because we had to check that the optional had a value before making another Intersection call below. But that Intersection was removed. So we can just directly return maybe_coverage_limit (or return the result directly without a maybe_coverage_limit variable at all).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e508e3b. Rect::Intersection already returns nullopt when the rects do not overlap and a non-empty rect otherwise, so it now returns the intersection directly.

@caimengnan-ops caimengnan-ops changed the title [Impeller] Do not clamp DrawDisplayList cull rect to the root render target [Impeller] Do not clamp the local coverage limit to the root render target Nov 7, 2026
- GetLocalCoverageLimit: Rect::Intersection already returns nullopt when
  the rects do not overlap, and a non-empty result otherwise, so the
  has_value/IsEmpty check is no longer needed. Return it directly.
- The test circles are stacked vertically; describe them as top/bottom
  instead of left/right.
@caimengnan-ops

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All comments are addressed, and the title and description are updated.

@caimengnan-ops
caimengnan-ops requested a review from b-luk November 7, 2026 04:06
@b-luk b-luk added the CICD Run CI/CD label Nov 9, 2026

@b-luk b-luk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking mostly good to me. I had a nit about the added comment. And you'll need to merge from head to pick up changes to pass the "ci.yaml validation" check.

Comment on lines +1818 to +1822
// Do not intersect with the root render target. Inside a subpass, content
// outside the root RT can still end up on-screen (e.g. an ancestor image
// filter translates it), so both culling and nested subpass sizing must be
// bounded by the current pass texture and clip only. On the root pass the
// pass texture is the root RT, so the result is unchanged there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the updated code, I think this comment is a bit confusing. I'd remove the second half of it, just leaving:

  // Do not intersect with the root render target. Inside a subpass, content
  // outside the root RT can still end up on-screen (e.g. an ancestor image
  // filter translates it).

I think the rest just adds details that are confusing or not useful to someone trying to understand the code.

  • "so both culling and nested subpass sizing must be bounded by the current pass texture and clip only." describes how the two current callers use the result. The function itself shouldn't need to know that, and the comment will go stale if callers are added or changed.
  • "On the root pass the pass texture is the root RT, so the result is unchanged there" is saying that intersecting with the root render target would make no difference for the root pass case. But we're no longer doing this intersection in any case. So the comment only makes sense in the context of the PR and with knowledge of the old code. For someone just reading the new code, it's a confusing thing to note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 24e2988, thanks. I also merged master in dc568a4.

@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Nov 10, 2026
@caimengnan-ops

Copy link
Copy Markdown
Contributor Author

Thanks! Addressed the comment and merged master. Could you re-add the CICD label so the checks can run again?

@caimengnan-ops
caimengnan-ops requested a review from b-luk November 10, 2026 01:26
@b-luk b-luk added the CICD Run CI/CD label Nov 10, 2026
@caimengnan-ops

Copy link
Copy Markdown
Contributor Author

@b-luk The workflows for the latest commit are waiting for approval ("action_required"), so the checks have not started. Could you approve them when you get a chance? Thanks!

@flutter-dashboard

Copy link
Copy Markdown

Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change).

If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

Changes reported for pull request #195860 at sha dc568a4

@flutter-dashboard flutter-dashboard Bot added the will affect goldens Changes to golden files label Nov 10, 2026
@b-luk

b-luk commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

The changed golden is correct behavior.

The updated image shows a partially cropped white rectangle on the right side of the image. This white shape does not appear in the existing golden.

This white shape is the "Debug" banner text (rendered with Ahem rectangles). It was incorrectly culled with the existing behavior. The new golden correctly draws it as intended.

@b-luk b-luk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@b-luk
b-luk requested a review from gaaclarke November 10, 2026 22:47
@gaaclarke gaaclarke added the autosubmit Merge PR when tree becomes green via auto submit App label Nov 11, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Nov 11, 2026
Merged via the queue into flutter:master with commit fae5d0d Nov 11, 2026
28 of 29 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Nov 11, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Nov 12, 2026
flutter/flutter@b98afb5...9c427b5

2026-11-12 [email protected] Roll Skia from d67d0f22e3ec to 9aa2513a4ac9 (2 revisions) (flutter/flutter#196429)
2026-11-12 [email protected] [flutter_tools] Move CpuArch to flutter_tools_core and define HostPlatform by osName and cpuArch (flutter/flutter#195841)
2026-11-12 [email protected] Roll Skia from 23e7e63789b8 to d67d0f22e3ec (2 revisions) (flutter/flutter#196424)
2026-11-12 [email protected] Uber compactify (flutter/flutter#196392)
2026-11-12 [email protected] Roll Skia from 3d7e335da165 to 23e7e63789b8 (12 revisions) (flutter/flutter#196422)
2026-11-12 [email protected] Roll Dart SDK from 13af329ea4f2 to e5add7a25b32 (1 revision) (flutter/flutter#196413)
2026-11-12 [email protected] Don't terminate the shared EGL display when an engine is disposed (flutter/flutter#194520)
2026-11-11 [email protected] Adds manifest flag to turn on SDFs for Android. (flutter/flutter#196056)
2026-11-11 [email protected] Remove bringup from Linux_pixel_7pro integration_ui_keyboard_inset_jump (flutter/flutter#196334)
2026-11-11 [email protected] Uses system default locale for text rendering when preferred locale isn't supported (flutter/flutter#195997)
2026-11-11 [email protected] [android]: prewarms shaders when the context is created (flutter/flutter#196069)
2026-11-11 [email protected] iOS: Recomposite platform views only if their parameters change (flutter/flutter#196287)
2026-11-11 [email protected] [flutter_tools] Forward encoding in ErrorHandlingFile.readAsStringSync (flutter/flutter#196136)
2026-11-11 [email protected] Roll Skia from b89c24bbab8c to 3d7e335da165 (8 revisions) (flutter/flutter#196390)
2026-11-11 [email protected] Add Flutter Gardener instructions on how to disable a test (flutter/flutter#196312)
2026-11-11 [email protected] [Impeller] Enable malioc analysis for framebuffer_blend shaders (flutter/flutter#196343)
2026-11-11 [email protected] [flutter_tools] Migrate general.shard Android, asset, and build tests off globals (flutter/flutter#196125)
2026-11-11 [email protected] Roll Dart SDK from 8f15c4ca2d41 to 13af329ea4f2 (1 revision) (flutter/flutter#196386)
2026-11-11 [email protected] [Impeller] Do not clamp the local coverage limit to the root render target (flutter/flutter#195860)
2026-11-11 [email protected] [Impeller] Use separate uniform buffers on VMware SVGA3D (flutter/flutter#196022)
2026-11-11 [email protected] [flutter_tools] Introduce AndroidSdkProvider for lazy native_assets SDK lookup (flutter/flutter#196122)
2026-11-11 [email protected] Moves linux to ThinLTO. (flutter/flutter#196314)
2026-11-11 [email protected] [flutter_tools] Migrate bin/fuchsia_tester.dart off globals and runInContext (flutter/flutter#196074)
2026-11-11 [email protected] Changes the tolerances for frame_rate_main_test.dart. (flutter/flutter#196304)
2026-11-11 [email protected] Roll Packages from 08faf64 to 653628e (16 revisions) (flutter/flutter#196381)
2026-11-11 [email protected] Allow SliverCrossAxisGroup children to lay out with zero cross-axis extent instead of asserting (flutter/flutter#191287)
2026-11-11 [email protected] Roll Skia from cc4c54f752ba to b89c24bbab8c (4 revisions) (flutter/flutter#196375)
2026-11-11 [email protected] [Linux] Fix layer offset when compositing with glBlitFramebuffer (flutter/flutter#196189)
2026-11-11 [email protected] [flutter_tools] Inject CrashReportSender into SwiftPM migration (flutter/flutter#196315)

If this roll has caused a breakage, revert this CL and set the roller
to dry run mode using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC [email protected],[email protected] on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD e: impeller Impeller rendering backend issues and features requests engine flutter/engine related. See also e: labels. will affect goldens Changes to golden files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants