From 37babcdaf13fee7a2f4664ef3c32fa2229160b99 Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Mon, 6 Apr 2026 10:23:59 -0700 Subject: [PATCH 1/8] [web] Implement stepped image downscaling for CanvasKit and Skwasm This change implements a high-quality stepped downscaling strategy (similar to mipmap generation) to resolve rendering artifacts (aliasing) caused by scaling down images by large factors (scale < 0.5) on the web. Key changes: - Added `ImageDownscaler` with a ref-counted cache for downscaled images linked to the image's source box. - Integrated the downscaler into `drawImageRect` for both CanvasKit and Skwasm renderers. - Conditioned the trigger on `FilterQuality.medium` or higher and scaling factor < 0.5 in both dimensions. - Handled cache eviction automatically when the source `CkImage` or `SkwasmImage` is fully disposed. - Added unit tests for the downscaling algorithm and caching disposal behavior. This fixes the jagged edges and pixelated artifacts seen when heavily downscaling large images in the web engine. --- .../flutter/lib/web_ui/lib/src/engine.dart | 1 + .../lib/src/engine/canvaskit/canvas.dart | 38 ++++ .../lib/src/engine/canvaskit/image.dart | 5 +- .../lib/src/engine/image_downscaler.dart | 171 ++++++++++++++++++ .../src/engine/skwasm/skwasm_impl/canvas.dart | 52 ++++++ .../src/engine/skwasm/skwasm_impl/image.dart | 5 +- .../web_ui/test/ui/image_downscaler_test.dart | 124 +++++++++++++ .../lib/web_ui/test/ui/image_golden_test.dart | 39 ++++ 8 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart create mode 100644 engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index d232f89703829..622fe8dd511da 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -61,6 +61,7 @@ export 'engine/frame_service.dart'; export 'engine/frame_timing_recorder.dart'; export 'engine/html_image_element_codec.dart'; export 'engine/image_decoder.dart'; +export 'engine/image_downscaler.dart'; export 'engine/image_format_detector.dart'; export 'engine/initialization.dart'; export 'engine/js_interop/js_app.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index 07c5f16c32dc0..d87f78b2e7417 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -146,6 +146,44 @@ class CkCanvas implements LayerCanvas { void drawImageRect(ui.Image image, ui.Rect src, ui.Rect dst, ui.Paint paint) { assert(rectIsValid(src)); assert(rectIsValid(dst)); + + if (shouldIterativelyDownscale(src, dst, paint)) { + final int targetWidth = dst.width.toInt(); + final int targetHeight = dst.height.toInt(); + + final ui.Image downscaledImage = getOrCreateDownscaledImage( + box: (image as CkImage).box, + originalImage: image, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + final SkCanvas tempSkCanvas = (canvas as CkCanvas).skCanvas; + final SkPaint skPaint = CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); + tempSkCanvas.drawImageRectOptions( + (img as CkImage).skImage, + toSkRect(s), + toSkRect(d), + canvasKit.FilterMode.Linear, + canvasKit.MipmapMode.None, + skPaint, + ); + skPaint.delete(); + }, + ); + + final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); + skCanvas.drawImageRectOptions( + (downscaledImage as CkImage).skImage, + toSkRect(ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble())), + toSkRect(dst), + toSkFilterMode(paint.filterQuality), + toSkMipmapMode(paint.filterQuality), + skPaint, + ); + skPaint.delete(); + return; + } + final ui.FilterQuality filterQuality = paint.filterQuality; final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); if (filterQuality == ui.FilterQuality.high) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart index 423163c8a4005..cf26270e9c336 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -405,7 +405,10 @@ class CkImage implements ui.Image, StackTraceDebugger { skImage, this, 'SkImage', - onDisposed: (CkImage image) => ui.Image.onDispose?.call(image), + onDisposed: (CkImage image) { + ui.Image.onDispose?.call(image); + DownscaledImageCache.instance.disposeForBox(image.box); + }, ); _init(); ui.Image.onCreate?.call(this); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart new file mode 100644 index 0000000000000..53685bd799617 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -0,0 +1,171 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:ui/ui.dart' as ui; + +/// A callback type used to draw a portion of an image onto a canvas. +/// +/// This is used to abstract the drawing operation so it can be implemented +/// differently for CanvasKit and Skwasm. +typedef RawDrawImageRect = + void Function(ui.Canvas canvas, ui.Image image, ui.Rect src, ui.Rect dst); + +/// Determines whether an image draw operation should use iterative downscaling. +/// +/// Iterative downscaling is used when both dimensions are being scaled down +/// to less than half of their source size, and the filter quality is at least +/// [ui.FilterQuality.medium]. +bool shouldIterativelyDownscale(ui.Rect src, ui.Rect dst, ui.Paint paint) { + return (dst.width < src.width / 2 && dst.height < src.height / 2) && + paint.filterQuality.index >= ui.FilterQuality.medium.index; +} + +/// A cache for downscaled images. +/// +/// This cache is used to avoid repeatedly downscaling the same image to the +/// same target size. +class DownscaledImageCache { + DownscaledImageCache._(); + + /// The singleton instance of the cache. + static final DownscaledImageCache instance = DownscaledImageCache._(); + + // The key is the ref-counting box of the image (CkCountedRef or CountedRef). + // We use the box as the key so that cloned images (which share the same box) + // can use the same cached downscaled image. + final Map> _cache = {}; + + /// Gets a cached downscaled image for the given [box] and target size. + ui.Image? get(Object box, int width, int height) { + return _cache[box]?[(width, height)]; + } + + /// Puts a downscaled image into the cache for the given [box] and target size. + void put(Object box, int width, int height, ui.Image image) { + final Map<(int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); + final ui.Image? oldImage = sizes[(width, height)]; + if (oldImage != null && oldImage != image) { + oldImage.dispose(); + } + sizes[(width, height)] = image; + } + + /// Disposes all cached downscaled images for the given [box]. + void disposeForBox(Object box) { + final Map<(int, int), ui.Image>? sizes = _cache.remove(box); + if (sizes != null) { + for (final ui.Image image in sizes.values) { + image.dispose(); + } + } + } +} + +/// Retrieves a downscaled image from the cache or creates it if it doesn't exist. +/// +/// The [box] is the ref-counting box of the original image (e.g., `CkCountedRef` +/// or `CountedRef`). We use the box as the key so that cloned images (which +/// share the same box) can use the same cached downscaled image. +ui.Image getOrCreateDownscaledImage({ + required Object box, + required ui.Image originalImage, + required int targetWidth, + required int targetHeight, + required RawDrawImageRect rawDraw, +}) { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final ui.Image? cached = cache.get(box, targetWidth, targetHeight); + if (cached != null) { + return cached; + } + + final ui.Image downscaled = createSteppedDownscaledImage( + originalImage: originalImage, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: rawDraw, + ); + + cache.put(box, targetWidth, targetHeight, downscaled); + return downscaled; +} + +/// Creates a high-quality downscaled image by repeatedly drawing the image at +/// half scale. +/// +/// This avoids aliasing artifacts that occur when downscaling an image by a +/// large factor in a single step due to Skia not using mipmaps on the web. +@visibleForTesting +ui.Image createSteppedDownscaledImage({ + required ui.Image originalImage, + required int targetWidth, + required int targetHeight, + required RawDrawImageRect rawDraw, +}) { + assert(targetWidth < originalImage.width / 2 && targetHeight < originalImage.height / 2); + var currentImage = originalImage; + int currentWidth = originalImage.width; + int currentHeight = originalImage.height; + + // We use FilterQuality.medium for the stepped downscaling. + // FilterQuality.medium enables mipmapping/bilinear filtering. + final List intermediateImages = []; + + while (currentWidth > targetWidth * 2) { + final int nextWidth = currentWidth ~/ 2; + final int nextHeight = currentHeight ~/ 2; + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + rawDraw( + canvas, + currentImage, + ui.Rect.fromLTWH(0, 0, currentWidth.toDouble(), currentHeight.toDouble()), + ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()), + ); + + final ui.Picture picture = recorder.endRecording(); + final ui.Image nextImage = picture.toImageSync(nextWidth, nextHeight); + picture.dispose(); + + intermediateImages.add(nextImage); + + currentImage = nextImage; + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + // Final step to the exact target size if needed. + if (currentWidth != targetWidth || currentHeight != targetHeight) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + rawDraw( + canvas, + currentImage, + ui.Rect.fromLTWH(0, 0, currentWidth.toDouble(), currentHeight.toDouble()), + ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), + ); + + final ui.Picture picture = recorder.endRecording(); + final ui.Image finalImage = picture.toImageSync(targetWidth, targetHeight); + picture.dispose(); + + for (final img in intermediateImages) { + img.dispose(); + } + + return finalImage; + } else { + // If we reached the target size exactly in the loop, the last image + // in intermediateImages is the result. All others can be disposed. + final ui.Image result = intermediateImages.removeLast(); + for (final img in intermediateImages) { + img.dispose(); + } + return result; + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index d78323cf8369c..82feebe8fc42b 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -243,6 +243,58 @@ class SkwasmCanvas implements LayerCanvas { @override void drawImageRect(ui.Image image, ui.Rect src, ui.Rect dst, ui.Paint paint) { + if (shouldIterativelyDownscale(src, dst, paint)) { + final int targetWidth = dst.width.toInt(); + final int targetHeight = dst.height.toInt(); + + final ui.Image downscaledImage = getOrCreateDownscaledImage( + box: (image as SkwasmImage).box, + originalImage: image, + targetWidth: targetWidth, + targetHeight: targetHeight, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + final CanvasHandle tempCanvasHandle = (canvas as SkwasmCanvas)._handle; + withStackScope((StackScope scope) { + final Pointer sourceRect = scope.convertRectToNative(s); + final Pointer destRect = scope.convertRectToNative(d); + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + final PaintHandle paintHandle = (tempPaint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + canvasDrawImageRect( + tempCanvasHandle, + (img as SkwasmImage).handle, + sourceRect, + destRect, + paintHandle, + ui.FilterQuality.medium.index, + ); + paintDispose(paintHandle); + }); + }, + ); + + withStackScope((StackScope scope) { + final Pointer sourceRect = scope.convertRectToNative( + ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), + ); + final Pointer destRect = scope.convertRectToNative(dst); + final PaintHandle paintHandle = (paint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + canvasDrawImageRect( + _handle, + (downscaledImage as SkwasmImage).handle, + sourceRect, + destRect, + paintHandle, + paint.filterQuality.index, + ); + paintDispose(paintHandle); + }); + return; + } + withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(src); final Pointer destRect = scope.convertRectToNative(dst); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart index 410f4e7668f71..b766a902b6394 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/image.dart @@ -17,7 +17,10 @@ class SkwasmImage implements ui.Image, StackTraceDebugger { this, 'SkImage', onDispose: (ImageHandle h) => imageDispose(h), - onDisposed: (SkwasmImage image) => ui.Image.onDispose?.call(image), + onDisposed: (SkwasmImage image) { + ui.Image.onDispose?.call(image); + DownscaledImageCache.instance.disposeForBox(image.box); + }, ); _init(); ui.Image.onCreate?.call(this); diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart new file mode 100644 index 0000000000000..88b26a4a5fdf5 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart @@ -0,0 +1,124 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/image_downscaler.dart'; +import 'package:ui/ui.dart' as ui; + +import '../common/test_initialization.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + setUpUnitTests(withImplicitView: true); + + group('DownscaledImageCache', () { + test('put and get', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image = MockImage(10, 10); + + cache.put(box, 5, 5, image); + expect(cache.get(box, 5, 5), equals(image)); + expect(cache.get(box, 10, 10), isNull); + }); + + test('disposeForBox', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image1 = MockImage(10, 10); + final image2 = MockImage(20, 20); + + cache.put(box, 5, 5, image1); + cache.put(box, 10, 10, image2); + + expect(cache.get(box, 5, 5), equals(image1)); + expect(cache.get(box, 10, 10), equals(image2)); + + cache.disposeForBox(box); + + expect(cache.get(box, 5, 5), isNull); + expect(cache.get(box, 10, 10), isNull); + expect(image1.disposed, isTrue); + expect(image2.disposed, isTrue); + }); + + test('overwrite value disposes old value', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + final image1 = MockImage(5, 5); + final image2 = MockImage(5, 5); + + cache.put(box, 5, 5, image1); + cache.put(box, 5, 5, image2); + + expect(cache.get(box, 5, 5), equals(image2)); + expect(image1.disposed, isTrue); + + cache.disposeForBox(box); + }); + }); + + group('createSteppedDownscaledImage', () { + test('calls rawDraw correct number of times', () { + final originalImage = MockImage(100, 100); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + targetWidth: 20, + targetHeight: 20, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + // Steps: 100 -> 50 -> 25 -> 20 (3 calls). + expect(drawCalls, equals(3)); + expect(targetSizes, equals([(50, 50), (25, 25), (20, 20)])); + }); + }); +} + +class MockImage implements ui.Image { + MockImage(this.width, this.height); + + @override + final int width; + @override + final int height; + + bool disposed = false; + + @override + void dispose() { + disposed = true; + } + + @override + bool get debugDisposed => disposed; + + @override + ui.Image clone() => this; + + @override + bool isCloneOf(ui.Image other) => other == this; + + @override + ui.ColorSpace get colorSpace => ui.ColorSpace.sRGB; + + @override + Future toByteData({ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba}) async => + null; + + @override + List? debugGetOpenHandleStackTraces() => null; +} diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart index 10aa970f2c34e..3cdfea259f213 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/image_golden_test.dart @@ -553,4 +553,43 @@ Future testMain() async { return info.image; }); } + + test('drawImageRect_downscale_text', () async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, const ui.Rect.fromLTWH(0, 0, 200, 200)); + + final paint = ui.Paint() + ..color = const ui.Color(0xFF00FF00) + ..strokeWidth = 1.0; + for (double i = 0; i < 200; i += 20) { + canvas.drawLine(ui.Offset(i, 0), ui.Offset(i, 200), paint); + canvas.drawLine(ui.Offset(0, i), ui.Offset(200, i), paint); + } + + final builder = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: 30)); + builder.pushStyle(ui.TextStyle(color: const ui.Color(0xFF000000))); + builder.addText('Flutter Web'); + final ui.Paragraph paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: 200)); + canvas.drawParagraph(paragraph, const ui.Offset(10, 80)); + + final ui.Image image = recorder.endRecording().toImageSync(200, 200); + + final recorder2 = ui.PictureRecorder(); + final canvas2 = ui.Canvas(recorder2, const ui.Rect.fromLTWH(0, 0, 100, 100)); + + canvas2.drawImageRect( + image, + const ui.Rect.fromLTWH(0, 0, 200, 200), + const ui.Rect.fromLTWH(10, 10, 50, 50), + ui.Paint()..filterQuality = ui.FilterQuality.medium, + ); + + await drawPictureUsingCurrentRenderer(recorder2.endRecording()); + + await matchGoldenFile( + 'canvas_drawImageRect_downscale_text.png', + region: const ui.Rect.fromLTWH(0, 0, 100, 100), + ); + }); } From 633907dc476eb54485bc99ff5ed3d9ace4139d75 Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Tue, 7 Apr 2026 16:26:19 -0700 Subject: [PATCH 2/8] Use src rect in downscaling logic --- .../lib/src/engine/canvaskit/canvas.dart | 1 + .../lib/src/engine/image_downscaler.dart | 94 +++++++++---------- .../src/engine/skwasm/skwasm_impl/canvas.dart | 1 + .../web_ui/test/ui/image_downscaler_test.dart | 88 ++++++++++++++--- 4 files changed, 124 insertions(+), 60 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index d87f78b2e7417..2cb5d1011cb50 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -154,6 +154,7 @@ class CkCanvas implements LayerCanvas { final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as CkImage).box, originalImage: image, + src: src, targetWidth: targetWidth, targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart index 53685bd799617..02c0e2132f5de 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -19,6 +19,8 @@ typedef RawDrawImageRect = /// [ui.FilterQuality.medium]. bool shouldIterativelyDownscale(ui.Rect src, ui.Rect dst, ui.Paint paint) { return (dst.width < src.width / 2 && dst.height < src.height / 2) && + dst.width >= 1 && + dst.height >= 1 && paint.filterQuality.index >= ui.FilterQuality.medium.index; } @@ -35,26 +37,26 @@ class DownscaledImageCache { // The key is the ref-counting box of the image (CkCountedRef or CountedRef). // We use the box as the key so that cloned images (which share the same box) // can use the same cached downscaled image. - final Map> _cache = {}; + final Map> _cache = {}; - /// Gets a cached downscaled image for the given [box] and target size. - ui.Image? get(Object box, int width, int height) { - return _cache[box]?[(width, height)]; + /// Gets a cached downscaled image for the given [box], source rect, and target size. + ui.Image? get(Object box, ui.Rect src, int width, int height) { + return _cache[box]?[(src, width, height)]; } - /// Puts a downscaled image into the cache for the given [box] and target size. - void put(Object box, int width, int height, ui.Image image) { - final Map<(int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); - final ui.Image? oldImage = sizes[(width, height)]; + /// Puts a downscaled image into the cache for the given [box], source rect, and target size. + void put(Object box, ui.Rect src, int width, int height, ui.Image image) { + final Map<(ui.Rect, int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); + final ui.Image? oldImage = sizes[(src, width, height)]; if (oldImage != null && oldImage != image) { oldImage.dispose(); } - sizes[(width, height)] = image; + sizes[(src, width, height)] = image; } /// Disposes all cached downscaled images for the given [box]. void disposeForBox(Object box) { - final Map<(int, int), ui.Image>? sizes = _cache.remove(box); + final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache.remove(box); if (sizes != null) { for (final ui.Image image in sizes.values) { image.dispose(); @@ -71,24 +73,26 @@ class DownscaledImageCache { ui.Image getOrCreateDownscaledImage({ required Object box, required ui.Image originalImage, + required ui.Rect src, required int targetWidth, required int targetHeight, required RawDrawImageRect rawDraw, }) { final DownscaledImageCache cache = DownscaledImageCache.instance; - final ui.Image? cached = cache.get(box, targetWidth, targetHeight); + final ui.Image? cached = cache.get(box, src, targetWidth, targetHeight); if (cached != null) { return cached; } final ui.Image downscaled = createSteppedDownscaledImage( originalImage: originalImage, + src: src, targetWidth: targetWidth, targetHeight: targetHeight, rawDraw: rawDraw, ); - cache.put(box, targetWidth, targetHeight, downscaled); + cache.put(box, src, targetWidth, targetHeight, downscaled); return downscaled; } @@ -100,22 +104,20 @@ ui.Image getOrCreateDownscaledImage({ @visibleForTesting ui.Image createSteppedDownscaledImage({ required ui.Image originalImage, + required ui.Rect src, required int targetWidth, required int targetHeight, required RawDrawImageRect rawDraw, }) { - assert(targetWidth < originalImage.width / 2 && targetHeight < originalImage.height / 2); + assert(targetWidth < src.width / 2 && targetHeight < src.height / 2); var currentImage = originalImage; - int currentWidth = originalImage.width; - int currentHeight = originalImage.height; + var currentSrc = src; - // We use FilterQuality.medium for the stepped downscaling. - // FilterQuality.medium enables mipmapping/bilinear filtering. final List intermediateImages = []; - while (currentWidth > targetWidth * 2) { - final int nextWidth = currentWidth ~/ 2; - final int nextHeight = currentHeight ~/ 2; + while (currentSrc.width > targetWidth * 2) { + final int nextWidth = currentSrc.width ~/ 2; + final int nextHeight = currentSrc.height ~/ 2; final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); @@ -123,7 +125,7 @@ ui.Image createSteppedDownscaledImage({ rawDraw( canvas, currentImage, - ui.Rect.fromLTWH(0, 0, currentWidth.toDouble(), currentHeight.toDouble()), + currentSrc, ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()), ); @@ -134,38 +136,36 @@ ui.Image createSteppedDownscaledImage({ intermediateImages.add(nextImage); currentImage = nextImage; - currentWidth = nextWidth; - currentHeight = nextHeight; + currentSrc = ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()); } - // Final step to the exact target size if needed. - if (currentWidth != targetWidth || currentHeight != targetHeight) { - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder); - - rawDraw( - canvas, - currentImage, - ui.Rect.fromLTWH(0, 0, currentWidth.toDouble(), currentHeight.toDouble()), - ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), - ); - - final ui.Picture picture = recorder.endRecording(); - final ui.Image finalImage = picture.toImageSync(targetWidth, targetHeight); - picture.dispose(); - - for (final img in intermediateImages) { - img.dispose(); - } - - return finalImage; - } else { - // If we reached the target size exactly in the loop, the last image - // in intermediateImages is the result. All others can be disposed. + // Optimization: If we reached the target size exactly in the loop, we can + // return the last intermediate image directly. + if (currentSrc.width.toInt() == targetWidth && currentSrc.height.toInt() == targetHeight) { final ui.Image result = intermediateImages.removeLast(); for (final img in intermediateImages) { img.dispose(); } return result; } + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + rawDraw( + canvas, + currentImage, + currentSrc, + ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), + ); + + final ui.Picture picture = recorder.endRecording(); + final ui.Image finalImage = picture.toImageSync(targetWidth, targetHeight); + picture.dispose(); + + for (final img in intermediateImages) { + img.dispose(); + } + + return finalImage; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index 82feebe8fc42b..43e346d97a520 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -250,6 +250,7 @@ class SkwasmCanvas implements LayerCanvas { final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as SkwasmImage).box, originalImage: image, + src: src, targetWidth: targetWidth, targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart index 88b26a4a5fdf5..7c7a5ad353248 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart @@ -6,7 +6,7 @@ import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; -import 'package:ui/src/engine/image_downscaler.dart'; +import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import '../common/test_initialization.dart'; @@ -23,10 +23,11 @@ void testMain() { final DownscaledImageCache cache = DownscaledImageCache.instance; final box = Object(); final image = MockImage(10, 10); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); - cache.put(box, 5, 5, image); - expect(cache.get(box, 5, 5), equals(image)); - expect(cache.get(box, 10, 10), isNull); + cache.put(box, src, 5, 5, image); + expect(cache.get(box, src, 5, 5), equals(image)); + expect(cache.get(box, src, 10, 10), isNull); }); test('disposeForBox', () { @@ -34,17 +35,18 @@ void testMain() { final box = Object(); final image1 = MockImage(10, 10); final image2 = MockImage(20, 20); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); - cache.put(box, 5, 5, image1); - cache.put(box, 10, 10, image2); + cache.put(box, src, 5, 5, image1); + cache.put(box, src, 10, 10, image2); - expect(cache.get(box, 5, 5), equals(image1)); - expect(cache.get(box, 10, 10), equals(image2)); + expect(cache.get(box, src, 5, 5), equals(image1)); + expect(cache.get(box, src, 10, 10), equals(image2)); cache.disposeForBox(box); - expect(cache.get(box, 5, 5), isNull); - expect(cache.get(box, 10, 10), isNull); + expect(cache.get(box, src, 5, 5), isNull); + expect(cache.get(box, src, 10, 10), isNull); expect(image1.disposed, isTrue); expect(image2.disposed, isTrue); }); @@ -54,17 +56,55 @@ void testMain() { final box = Object(); final image1 = MockImage(5, 5); final image2 = MockImage(5, 5); + const src = ui.Rect.fromLTRB(0, 0, 10, 10); - cache.put(box, 5, 5, image1); - cache.put(box, 5, 5, image2); + cache.put(box, src, 5, 5, image1); + cache.put(box, src, 5, 5, image2); - expect(cache.get(box, 5, 5), equals(image2)); + expect(cache.get(box, src, 5, 5), equals(image2)); expect(image1.disposed, isTrue); cache.disposeForBox(box); }); }); + group('shouldIterativelyDownscale', () { + test('returns true for large downscaling with medium quality', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); + + test('returns false when target width is less than 1', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 0.5, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false when target height is less than 1', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 0.5); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false when not downscaling enough', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 60, 60); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + + test('returns false for low filter quality', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 100); + const dst = ui.Rect.fromLTWH(0, 0, 20, 20); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.low; + expect(shouldIterativelyDownscale(src, dst, paint), isFalse); + }); + }); + group('createSteppedDownscaledImage', () { test('calls rawDraw correct number of times', () { final originalImage = MockImage(100, 100); @@ -73,6 +113,7 @@ void testMain() { createSteppedDownscaledImage( originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 100, 100), targetWidth: 20, targetHeight: 20, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { @@ -85,6 +126,27 @@ void testMain() { expect(drawCalls, equals(3)); expect(targetSizes, equals([(50, 50), (25, 25), (20, 20)])); }); + + test('uses src region in first step', () { + final originalImage = MockImage(100, 100); + const src = ui.Rect.fromLTRB(10, 10, 90, 90); + final List srcRects = []; + final List<(int, int)> dstSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: src, + targetWidth: 20, + targetHeight: 20, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + srcRects.add(s); + dstSizes.add((d.width.toInt(), d.height.toInt())); + }, + ); + + expect(srcRects, equals([src, const ui.Rect.fromLTRB(0, 0, 40, 40)])); + expect(dstSizes, equals([(40, 40), (20, 20)])); + }); }); } From d7b3d69cca4698e67660448a7a6c05cbd5db8051 Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Tue, 7 Apr 2026 16:54:09 -0700 Subject: [PATCH 3/8] Handle extreme aspect ratios. Limit cache size --- .../lib/src/engine/image_downscaler.dart | 56 +++++--- .../src/engine/skwasm/skwasm_impl/canvas.dart | 4 +- .../web_ui/test/ui/image_downscaler_test.dart | 121 ++++++++++++++++++ 3 files changed, 164 insertions(+), 17 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart index 02c0e2132f5de..e1ed6d505f481 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; + import 'package:meta/meta.dart'; import 'package:ui/ui.dart' as ui; @@ -39,19 +41,44 @@ class DownscaledImageCache { // can use the same cached downscaled image. final Map> _cache = {}; + /// The maximum number of downscaled variants to cache per image. + static const int _maxVariantsPerImage = 10; + /// Gets a cached downscaled image for the given [box], source rect, and target size. ui.Image? get(Object box, ui.Rect src, int width, int height) { - return _cache[box]?[(src, width, height)]; + final Map<(ui.Rect, int, int), ui.Image>? sizes = _cache[box]; + if (sizes == null) { + return null; + } + final key = (src, width, height); + final ui.Image? image = sizes[key]; + if (image != null) { + // Promote to most recent (insertion order). + sizes.remove(key); + sizes[key] = image; + } + return image; } /// Puts a downscaled image into the cache for the given [box], source rect, and target size. void put(Object box, ui.Rect src, int width, int height, ui.Image image) { final Map<(ui.Rect, int, int), ui.Image> sizes = _cache.putIfAbsent(box, () => {}); - final ui.Image? oldImage = sizes[(src, width, height)]; + final key = (src, width, height); + + // Remove if exists to refresh insertion order. + final ui.Image? oldImage = sizes.remove(key); if (oldImage != null && oldImage != image) { oldImage.dispose(); } - sizes[(src, width, height)] = image; + + sizes[key] = image; + + // Limit size. + if (sizes.length > _maxVariantsPerImage) { + final (ui.Rect, int, int) firstKey = sizes.keys.first; + final ui.Image? firstImage = sizes.remove(firstKey); + firstImage?.dispose(); + } } /// Disposes all cached downscaled images for the given [box]. @@ -113,11 +140,11 @@ ui.Image createSteppedDownscaledImage({ var currentImage = originalImage; var currentSrc = src; - final List intermediateImages = []; + ui.Image? previousIntermediate; - while (currentSrc.width > targetWidth * 2) { - final int nextWidth = currentSrc.width ~/ 2; - final int nextHeight = currentSrc.height ~/ 2; + while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { + final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); + final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); @@ -133,7 +160,10 @@ ui.Image createSteppedDownscaledImage({ final ui.Image nextImage = picture.toImageSync(nextWidth, nextHeight); picture.dispose(); - intermediateImages.add(nextImage); + if (previousIntermediate != null) { + previousIntermediate.dispose(); + } + previousIntermediate = nextImage; currentImage = nextImage; currentSrc = ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()); @@ -142,11 +172,7 @@ ui.Image createSteppedDownscaledImage({ // Optimization: If we reached the target size exactly in the loop, we can // return the last intermediate image directly. if (currentSrc.width.toInt() == targetWidth && currentSrc.height.toInt() == targetHeight) { - final ui.Image result = intermediateImages.removeLast(); - for (final img in intermediateImages) { - img.dispose(); - } - return result; + return currentImage; } final recorder = ui.PictureRecorder(); @@ -163,8 +189,8 @@ ui.Image createSteppedDownscaledImage({ final ui.Image finalImage = picture.toImageSync(targetWidth, targetHeight); picture.dispose(); - for (final img in intermediateImages) { - img.dispose(); + if (currentImage != originalImage) { + currentImage.dispose(); } return finalImage; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index 43e346d97a520..2f11f930b535c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -258,7 +258,7 @@ class SkwasmCanvas implements LayerCanvas { withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(s); final Pointer destRect = scope.convertRectToNative(d); - final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; final PaintHandle paintHandle = (tempPaint as SkwasmPaint).toRawPaint( defaultBlurTileMode: ui.TileMode.clamp, ); @@ -268,7 +268,7 @@ class SkwasmCanvas implements LayerCanvas { sourceRect, destRect, paintHandle, - ui.FilterQuality.medium.index, + ui.FilterQuality.low.index, ); paintDispose(paintHandle); }); diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart index 7c7a5ad353248..13199378c9c34 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart @@ -66,6 +66,84 @@ void testMain() { cache.disposeForBox(box); }); + + test('limits number of variants per image', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + // Add 10 variants. + for (var i = 0; i < 10; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + // All 10 should be there. + for (var i = 0; i < 10; i++) { + expect(cache.get(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5), isNotNull); + } + + // Add one more. + final image11 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5, image11); + + // The first one (i=0) should have been evicted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), isNull); + + // The rest should still be there. + for (var i = 1; i < 10; i++) { + expect(cache.get(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5), isNotNull); + } + + // The new one should be there. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5), equals(image11)); + + cache.disposeForBox(box); + }); + + test('evicted variant is disposed', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + final image0 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5, image0); + + // Add 10 more variants to evict the first one. + for (var i = 1; i <= 10; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), isNull); + expect(image0.disposed, isTrue); + + cache.disposeForBox(box); + }); + + test('get promotes item to most recent', () { + final DownscaledImageCache cache = DownscaledImageCache.instance; + final box = Object(); + + final image0 = MockImage(5, 5); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5, image0); + + // Add 8 more variants. + for (var i = 1; i <= 8; i++) { + cache.put(box, ui.Rect.fromLTWH(0, 0, i.toDouble() + 10, 10), 5, 5, MockImage(5, 5)); + } + + // Access the first one to promote it. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), equals(image0)); + + // Add 2 more variants. + cache.put(box, const ui.Rect.fromLTWH(0, 0, 19, 10), 5, 5, MockImage(5, 5)); + cache.put(box, const ui.Rect.fromLTWH(0, 0, 20, 10), 5, 5, MockImage(5, 5)); + + // The first one should NOT be evicted because it was promoted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 10, 10), 5, 5), equals(image0)); + + // The second one (i=1) should have been evicted. + expect(cache.get(box, const ui.Rect.fromLTWH(0, 0, 11, 10), 5, 5), isNull); + + cache.disposeForBox(box); + }); }); group('shouldIterativelyDownscale', () { @@ -147,6 +225,49 @@ void testMain() { expect(srcRects, equals([src, const ui.Rect.fromLTRB(0, 0, 40, 40)])); expect(dstSizes, equals([(40, 40), (20, 20)])); }); + + test('handles extreme aspect ratio without skipping steps', () { + final originalImage = MockImage(100, 1000); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 100, 1000), + targetWidth: 10, + targetHeight: 10, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + expect(drawCalls, equals(7)); + expect( + targetSizes, + equals([(50, 500), (25, 250), (12, 125), (10, 62), (10, 31), (10, 15), (10, 10)]), + ); + }); + + test('ensures dimensions do not drop below 1', () { + final originalImage = MockImage(3, 100); + var drawCalls = 0; + final List<(int, int)> targetSizes = []; + + createSteppedDownscaledImage( + originalImage: originalImage, + src: const ui.Rect.fromLTRB(0, 0, 3, 100), + targetWidth: 1, + targetHeight: 10, + rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect src, ui.Rect dst) { + drawCalls++; + targetSizes.add((dst.width.toInt(), dst.height.toInt())); + }, + ); + + expect(drawCalls, equals(4)); + expect(targetSizes, equals([(1, 50), (1, 25), (1, 12), (1, 10)])); + }); }); } From e8d4713a8c39e8f6ac1100c30d72838a53d069fc Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Wed, 8 Apr 2026 09:50:45 -0700 Subject: [PATCH 4/8] Add suggestions from Gemini review --- .../web_ui/lib/src/engine/image_downscaler.dart | 17 +++++++---------- .../web_ui/test/ui/image_downscaler_test.dart | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart index e1ed6d505f481..5c6d0fafc5fce 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -20,7 +20,7 @@ typedef RawDrawImageRect = /// to less than half of their source size, and the filter quality is at least /// [ui.FilterQuality.medium]. bool shouldIterativelyDownscale(ui.Rect src, ui.Rect dst, ui.Paint paint) { - return (dst.width < src.width / 2 && dst.height < src.height / 2) && + return (dst.width < src.width / 2 || dst.height < src.height / 2) && dst.width >= 1 && dst.height >= 1 && paint.filterQuality.index >= ui.FilterQuality.medium.index; @@ -36,9 +36,9 @@ class DownscaledImageCache { /// The singleton instance of the cache. static final DownscaledImageCache instance = DownscaledImageCache._(); - // The key is the ref-counting box of the image (CkCountedRef or CountedRef). - // We use the box as the key so that cloned images (which share the same box) - // can use the same cached downscaled image. + /// The key is the ref-counting box of the image (CkCountedRef or CountedRef). + /// We use the box as the key so that cloned images (which share the same box) + /// can use the same cached downscaled image. final Map> _cache = {}; /// The maximum number of downscaled variants to cache per image. @@ -136,12 +136,10 @@ ui.Image createSteppedDownscaledImage({ required int targetHeight, required RawDrawImageRect rawDraw, }) { - assert(targetWidth < src.width / 2 && targetHeight < src.height / 2); + assert(targetWidth < src.width / 2 || targetHeight < src.height / 2); var currentImage = originalImage; var currentSrc = src; - ui.Image? previousIntermediate; - while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); @@ -160,10 +158,9 @@ ui.Image createSteppedDownscaledImage({ final ui.Image nextImage = picture.toImageSync(nextWidth, nextHeight); picture.dispose(); - if (previousIntermediate != null) { - previousIntermediate.dispose(); + if (currentImage != originalImage) { + currentImage.dispose(); } - previousIntermediate = nextImage; currentImage = nextImage; currentSrc = ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()); diff --git a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart index 13199378c9c34..e3a049ab6c101 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/image_downscaler_test.dart @@ -181,6 +181,20 @@ void testMain() { final paint = ui.Paint()..filterQuality = ui.FilterQuality.low; expect(shouldIterativelyDownscale(src, dst, paint), isFalse); }); + + test('returns true when only width is downscaled significantly', () { + const src = ui.Rect.fromLTWH(0, 0, 1000, 100); + const dst = ui.Rect.fromLTWH(0, 0, 100, 100); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); + + test('returns true when only height is downscaled significantly', () { + const src = ui.Rect.fromLTWH(0, 0, 100, 1000); + const dst = ui.Rect.fromLTWH(0, 0, 100, 100); + final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium; + expect(shouldIterativelyDownscale(src, dst, paint), isTrue); + }); }); group('createSteppedDownscaledImage', () { From 9a64505c1c12cafd4503a97073c31f1655b97a69 Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Wed, 8 Apr 2026 14:03:18 -0700 Subject: [PATCH 5/8] Move temporary paint out of loop --- .../lib/web_ui/lib/src/engine/canvaskit/canvas.dart | 8 +++++--- .../lib/src/engine/skwasm/skwasm_impl/canvas.dart | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index 2cb5d1011cb50..541d9eb329d58 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -151,6 +151,8 @@ class CkCanvas implements LayerCanvas { final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); + final SkPaint downscalingPaint = CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); + final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as CkImage).box, originalImage: image, @@ -159,19 +161,19 @@ class CkCanvas implements LayerCanvas { targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { final SkCanvas tempSkCanvas = (canvas as CkCanvas).skCanvas; - final SkPaint skPaint = CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); tempSkCanvas.drawImageRectOptions( (img as CkImage).skImage, toSkRect(s), toSkRect(d), canvasKit.FilterMode.Linear, canvasKit.MipmapMode.None, - skPaint, + downscalingPaint, ); - skPaint.delete(); }, ); + downscalingPaint.delete(); + final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); skCanvas.drawImageRectOptions( (downscaledImage as CkImage).skImage, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index 2f11f930b535c..575a76ed77284 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -247,6 +247,11 @@ class SkwasmCanvas implements LayerCanvas { final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; + final PaintHandle paintHandle = (tempPaint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as SkwasmImage).box, originalImage: image, @@ -258,10 +263,6 @@ class SkwasmCanvas implements LayerCanvas { withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(s); final Pointer destRect = scope.convertRectToNative(d); - final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; - final PaintHandle paintHandle = (tempPaint as SkwasmPaint).toRawPaint( - defaultBlurTileMode: ui.TileMode.clamp, - ); canvasDrawImageRect( tempCanvasHandle, (img as SkwasmImage).handle, @@ -270,11 +271,12 @@ class SkwasmCanvas implements LayerCanvas { paintHandle, ui.FilterQuality.low.index, ); - paintDispose(paintHandle); }); }, ); + paintDispose(paintHandle); + withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative( ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), From d6081f1f5f54eea5076ab895ec118a53aa924b8b Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Wed, 8 Apr 2026 14:19:32 -0700 Subject: [PATCH 6/8] Only create paint object if needed --- .../web_ui/lib/src/engine/canvaskit/canvas.dart | 7 ++++--- .../src/engine/skwasm/skwasm_impl/canvas.dart | 17 +++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index 541d9eb329d58..b4503f4cb7346 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -151,7 +151,7 @@ class CkCanvas implements LayerCanvas { final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); - final SkPaint downscalingPaint = CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); + SkPaint? downscalingPaint; final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as CkImage).box, @@ -160,6 +160,7 @@ class CkCanvas implements LayerCanvas { targetWidth: targetWidth, targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { + downscalingPaint ??= CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); final SkCanvas tempSkCanvas = (canvas as CkCanvas).skCanvas; tempSkCanvas.drawImageRectOptions( (img as CkImage).skImage, @@ -167,12 +168,12 @@ class CkCanvas implements LayerCanvas { toSkRect(d), canvasKit.FilterMode.Linear, canvasKit.MipmapMode.None, - downscalingPaint, + downscalingPaint!, ); }, ); - downscalingPaint.delete(); + downscalingPaint?.delete(); final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); skCanvas.drawImageRectOptions( diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index 575a76ed77284..468aca0068078 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -247,10 +247,7 @@ class SkwasmCanvas implements LayerCanvas { final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); - final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; - final PaintHandle paintHandle = (tempPaint as SkwasmPaint).toRawPaint( - defaultBlurTileMode: ui.TileMode.clamp, - ); + PaintHandle? downscalingPaintHandle; final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as SkwasmImage).box, @@ -260,6 +257,12 @@ class SkwasmCanvas implements LayerCanvas { targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { final CanvasHandle tempCanvasHandle = (canvas as SkwasmCanvas)._handle; + if (downscalingPaintHandle == null) { + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; + downscalingPaintHandle = (tempPaint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + } withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(s); final Pointer destRect = scope.convertRectToNative(d); @@ -268,14 +271,16 @@ class SkwasmCanvas implements LayerCanvas { (img as SkwasmImage).handle, sourceRect, destRect, - paintHandle, + downscalingPaintHandle!, ui.FilterQuality.low.index, ); }); }, ); - paintDispose(paintHandle); + if (downscalingPaintHandle != null) { + paintDispose(downscalingPaintHandle!); + } withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative( From f04b3d748197d35f8ffbad7cec63d4ad6bdb664c Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Wed, 8 Apr 2026 15:41:02 -0700 Subject: [PATCH 7/8] Refactor image drawing code into helper function --- .../lib/src/engine/image_downscaler.dart | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart index 5c6d0fafc5fce..6342d17faaee5 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -140,23 +140,35 @@ ui.Image createSteppedDownscaledImage({ var currentImage = originalImage; var currentSrc = src; - while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { - final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); - final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); - + ui.Image drawImageScaled({ + required RawDrawImageRect rawDraw, + required ui.Image image, + required ui.Rect src, + required int width, + required int height, + }) { final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); - rawDraw( - canvas, - currentImage, - currentSrc, - ui.Rect.fromLTWH(0, 0, nextWidth.toDouble(), nextHeight.toDouble()), - ); + rawDraw(canvas, image, src, ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble())); final ui.Picture picture = recorder.endRecording(); - final ui.Image nextImage = picture.toImageSync(nextWidth, nextHeight); + final ui.Image result = picture.toImageSync(width, height); picture.dispose(); + return result; + } + + while (currentSrc.width > targetWidth * 2 || currentSrc.height > targetHeight * 2) { + final int nextWidth = math.max(1, math.max(targetWidth, currentSrc.width ~/ 2)); + final int nextHeight = math.max(1, math.max(targetHeight, currentSrc.height ~/ 2)); + + final ui.Image nextImage = drawImageScaled( + rawDraw: rawDraw, + image: currentImage, + src: currentSrc, + width: nextWidth, + height: nextHeight, + ); if (currentImage != originalImage) { currentImage.dispose(); @@ -172,20 +184,14 @@ ui.Image createSteppedDownscaledImage({ return currentImage; } - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder); - - rawDraw( - canvas, - currentImage, - currentSrc, - ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()), + final ui.Image finalImage = drawImageScaled( + rawDraw: rawDraw, + image: currentImage, + src: currentSrc, + width: targetWidth, + height: targetHeight, ); - final ui.Picture picture = recorder.endRecording(); - final ui.Image finalImage = picture.toImageSync(targetWidth, targetHeight); - picture.dispose(); - if (currentImage != originalImage) { currentImage.dispose(); } From 2266eb1fa9e0cc08b61067a06a28a6c7babc93aa Mon Sep 17 00:00:00 2001 From: Harry Terkelsen Date: Tue, 14 Apr 2026 10:37:19 -0700 Subject: [PATCH 8/8] Address comments --- .../lib/src/engine/canvaskit/canvas.dart | 16 ++++++---- .../lib/src/engine/image_downscaler.dart | 1 + .../src/engine/skwasm/skwasm_impl/canvas.dart | 29 ++++++++++--------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart index b4503f4cb7346..0f86e291114cd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart @@ -12,6 +12,11 @@ import 'package:ui/ui.dart' as ui; /// every time we need it. final SkClipOp _clipOpIntersect = canvasKit.ClipOp.Intersect; +SkPaint? _sharedDownscalingPaint; +SkPaint _getDownscalingPaint() { + return _sharedDownscalingPaint ??= CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); +} + /// A Dart wrapper around Skia's [SkCanvas]. /// /// This is intentionally not memory-managing the underlying [SkCanvas]. See @@ -148,11 +153,13 @@ class CkCanvas implements LayerCanvas { assert(rectIsValid(dst)); if (shouldIterativelyDownscale(src, dst, paint)) { + // Use iterative downscaling to avoid aliasing artifacts when downscaling + // by a large factor (scale < 0.5). This is a workaround for a Skia bug + // where mipmaps are not used for downscaling on the web. + // See: https://g-issues.skia.org/issues/500117356 final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); - SkPaint? downscalingPaint; - final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as CkImage).box, originalImage: image, @@ -160,7 +167,6 @@ class CkCanvas implements LayerCanvas { targetWidth: targetWidth, targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { - downscalingPaint ??= CkPaint().toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); final SkCanvas tempSkCanvas = (canvas as CkCanvas).skCanvas; tempSkCanvas.drawImageRectOptions( (img as CkImage).skImage, @@ -168,13 +174,11 @@ class CkCanvas implements LayerCanvas { toSkRect(d), canvasKit.FilterMode.Linear, canvasKit.MipmapMode.None, - downscalingPaint!, + _getDownscalingPaint(), ); }, ); - downscalingPaint?.delete(); - final SkPaint skPaint = (paint as CkPaint).toSkPaint(defaultBlurTileMode: ui.TileMode.clamp); skCanvas.drawImageRectOptions( (downscaledImage as CkImage).skImage, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart index 6342d17faaee5..05b75911fc4fd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_downscaler.dart @@ -128,6 +128,7 @@ ui.Image getOrCreateDownscaledImage({ /// /// This avoids aliasing artifacts that occur when downscaling an image by a /// large factor in a single step due to Skia not using mipmaps on the web. +/// See also: https://g-issues.skia.org/issues/500117356 @visibleForTesting ui.Image createSteppedDownscaledImage({ required ui.Image originalImage, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart index 468aca0068078..26d2eae5db292 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart @@ -9,6 +9,17 @@ import 'package:ui/src/engine.dart'; import 'package:ui/src/engine/skwasm/skwasm_impl.dart'; import 'package:ui/ui.dart' as ui; +PaintHandle? _sharedDownscalingPaintHandle; +PaintHandle _getDownscalingPaintHandle() { + if (_sharedDownscalingPaintHandle == null) { + final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; + _sharedDownscalingPaintHandle = (tempPaint as SkwasmPaint).toRawPaint( + defaultBlurTileMode: ui.TileMode.clamp, + ); + } + return _sharedDownscalingPaintHandle!; +} + class SkwasmCanvas implements LayerCanvas { factory SkwasmCanvas(SkwasmPictureRecorder recorder, ui.Rect cullRect) => SkwasmCanvas.fromHandle( withStackScope( @@ -244,11 +255,13 @@ class SkwasmCanvas implements LayerCanvas { @override void drawImageRect(ui.Image image, ui.Rect src, ui.Rect dst, ui.Paint paint) { if (shouldIterativelyDownscale(src, dst, paint)) { + // Use iterative downscaling to avoid aliasing artifacts when downscaling + // by a large factor (scale < 0.5). This is a workaround for a Skia bug + // where mipmaps are not used for downscaling on the web. + // See: https://g-issues.skia.org/issues/500117356 final int targetWidth = dst.width.toInt(); final int targetHeight = dst.height.toInt(); - PaintHandle? downscalingPaintHandle; - final ui.Image downscaledImage = getOrCreateDownscaledImage( box: (image as SkwasmImage).box, originalImage: image, @@ -257,12 +270,6 @@ class SkwasmCanvas implements LayerCanvas { targetHeight: targetHeight, rawDraw: (ui.Canvas canvas, ui.Image img, ui.Rect s, ui.Rect d) { final CanvasHandle tempCanvasHandle = (canvas as SkwasmCanvas)._handle; - if (downscalingPaintHandle == null) { - final tempPaint = ui.Paint()..filterQuality = ui.FilterQuality.low; - downscalingPaintHandle = (tempPaint as SkwasmPaint).toRawPaint( - defaultBlurTileMode: ui.TileMode.clamp, - ); - } withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative(s); final Pointer destRect = scope.convertRectToNative(d); @@ -271,17 +278,13 @@ class SkwasmCanvas implements LayerCanvas { (img as SkwasmImage).handle, sourceRect, destRect, - downscalingPaintHandle!, + _getDownscalingPaintHandle(), ui.FilterQuality.low.index, ); }); }, ); - if (downscalingPaintHandle != null) { - paintDispose(downscalingPaintHandle!); - } - withStackScope((StackScope scope) { final Pointer sourceRect = scope.convertRectToNative( ui.Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble()),