diff --git a/lib/web_ui/lib/src/engine/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/bitmap_canvas.dart index 719737c8aa692..b67576f8a4023 100644 --- a/lib/web_ui/lib/src/engine/bitmap_canvas.dart +++ b/lib/web_ui/lib/src/engine/bitmap_canvas.dart @@ -104,18 +104,23 @@ class BitmapCanvas extends EngineCanvas { /// can be constructed from contents. bool _preserveImageData = false; + /// Canvas pixel to screen pixel ratio. Similar to dpi but + /// uses global transform of canvas to compute ratio. + final double _density; + /// Allocates a canvas with enough memory to paint a picture within the given /// [bounds]. /// /// This canvas can be reused by pictures with different paint bounds as long /// as the [Rect.size] of the bounds fully fit within the size used to /// initialize this canvas. - BitmapCanvas(this._bounds) + BitmapCanvas(this._bounds, {double density = 1.0}) : assert(_bounds != null), // ignore: unnecessary_null_comparison + _density = density, _widthInBitmapPixels = _widthToPhysical(_bounds.width), _heightInBitmapPixels = _heightToPhysical(_bounds.height), _canvasPool = _CanvasPool(_widthToPhysical(_bounds.width), - _heightToPhysical(_bounds.height)) { + _heightToPhysical(_bounds.height), density) { rootElement.style.position = 'absolute'; // Adds one extra pixel to the requested size. This is to compensate for // _initializeViewport() snapping canvas position to 1 pixel, causing @@ -179,10 +184,11 @@ class BitmapCanvas extends EngineCanvas { } // Used by picture to assess if canvas is large enough to reuse as is. - bool doesFitBounds(ui.Rect newBounds) { + bool doesFitBounds(ui.Rect newBounds, double newDensity) { assert(newBounds != null); // ignore: unnecessary_null_comparison return _widthInBitmapPixels >= _widthToPhysical(newBounds.width) && - _heightInBitmapPixels >= _heightToPhysical(newBounds.height); + _heightInBitmapPixels >= _heightToPhysical(newBounds.height) && + _density == newDensity; } @override diff --git a/lib/web_ui/lib/src/engine/canvas_pool.dart b/lib/web_ui/lib/src/engine/canvas_pool.dart index d39aee1171e91..efe49dfe48925 100644 --- a/lib/web_ui/lib/src/engine/canvas_pool.dart +++ b/lib/web_ui/lib/src/engine/canvas_pool.dart @@ -33,8 +33,10 @@ class _CanvasPool extends _SaveStackTracking { html.HtmlElement? _rootElement; int _saveContextCount = 0; + final double _density; - _CanvasPool(this._widthInBitmapPixels, this._heightInBitmapPixels); + _CanvasPool(this._widthInBitmapPixels, this._heightInBitmapPixels, + this._density); html.CanvasRenderingContext2D get context { html.CanvasRenderingContext2D? ctx = _context; @@ -83,7 +85,12 @@ class _CanvasPool extends _SaveStackTracking { void _createCanvas() { bool requiresClearRect = false; bool reused = false; - html.CanvasElement canvas; + html.CanvasElement? canvas; + if (_canvas != null) { + _canvas!.width = 0; + _canvas!.height = 0; + _canvas = null; + } if (_reusablePool != null && _reusablePool!.isNotEmpty) { canvas = _canvas = _reusablePool!.removeAt(0); requiresClearRect = true; @@ -99,10 +106,7 @@ class _CanvasPool extends _SaveStackTracking { _widthInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio; final double cssHeight = _heightInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio; - canvas = html.CanvasElement( - width: _widthInBitmapPixels, - height: _heightInBitmapPixels, - ); + canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels); _canvas = canvas; // Why is this null check here, even though we just allocated a canvas element above? @@ -113,12 +117,9 @@ class _CanvasPool extends _SaveStackTracking { if (_canvas == null) { // Evict BitmapCanvas(s) and retry. _reduceCanvasMemoryUsage(); - canvas = html.CanvasElement( - width: _widthInBitmapPixels, - height: _heightInBitmapPixels, - ); + canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels); } - canvas.style + canvas!.style ..position = 'absolute' ..width = '${cssWidth}px' ..height = '${cssHeight}px'; @@ -131,19 +132,55 @@ class _CanvasPool extends _SaveStackTracking { _rootElement!.append(canvas); } - if (reused) { - // If a canvas is the first element we set z-index = -1 in [BitmapCanvas] - // endOfPaint to workaround blink compositing bug. To make sure this - // does not leak when reused reset z-index. - canvas.style.removeProperty('z-index'); + try { + if (reused) { + // If a canvas is the first element we set z-index = -1 in [BitmapCanvas] + // endOfPaint to workaround blink compositing bug. To make sure this + // does not leak when reused reset z-index. + canvas.style.removeProperty('z-index'); + } + _context = canvas.context2D; + } catch (e) { + // Handle OOM. } - - final html.CanvasRenderingContext2D context = _context = canvas.context2D; - _contextHandle = ContextStateHandle(this, context); + if (_context == null) { + _reduceCanvasMemoryUsage(); + _context = canvas.context2D; + } + if (_context == null) { + /// Browser ran out of memory, try to recover current allocation + /// and bail. + _canvas?.width = 0; + _canvas?.height = 0; + _canvas = null; + return; + } + _contextHandle = ContextStateHandle(this, _context!, this._density); _initializeViewport(requiresClearRect); _replayClipStack(); } + html.CanvasElement? _allocCanvas(int width, int height) { + final dynamic canvas = + js_util.callMethod(html.document, 'createElement', ['CANVAS']); + if (canvas != null) { + try { + canvas.width = (width * _density).ceil(); + canvas.height = (height * _density).ceil(); + } catch (e) { + return null; + } + return canvas as html.CanvasElement; + } + return null; + // !!! We don't use the code below since NNBD assumes it can never return + // null and optimizes out code. + // return canvas = html.CanvasElement( + // width: _widthInBitmapPixels, + // height: _heightInBitmapPixels, + // ); + } + @override void clear() { super.clear(); @@ -188,7 +225,7 @@ class _CanvasPool extends _SaveStackTracking { clipTimeTransform[5] != prevTransform[5] || clipTimeTransform[12] != prevTransform[12] || clipTimeTransform[13] != prevTransform[13]) { - final double ratio = EnginePlatformDispatcher.browserDevicePixelRatio; + final double ratio = dpi; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.transform( clipTimeTransform[0], @@ -222,7 +259,7 @@ class _CanvasPool extends _SaveStackTracking { transform[5] != prevTransform[5] || transform[12] != prevTransform[12] || transform[13] != prevTransform[13]) { - final double ratio = EnginePlatformDispatcher.browserDevicePixelRatio; + final double ratio = dpi; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.transform(transform[0], transform[1], transform[4], transform[5], transform[12], transform[13]); @@ -300,15 +337,19 @@ class _CanvasPool extends _SaveStackTracking { // is applied on the DOM elements. ctx.setTransform(1, 0, 0, 1, 0, 0); if (clearCanvas) { - ctx.clearRect(0, 0, _widthInBitmapPixels, _heightInBitmapPixels); + ctx.clearRect(0, 0, _widthInBitmapPixels * _density, + _heightInBitmapPixels * _density); } // This scale makes sure that 1 CSS pixel is translated to the correct // number of bitmap pixels. - ctx.scale(EnginePlatformDispatcher.browserDevicePixelRatio, - EnginePlatformDispatcher.browserDevicePixelRatio); + ctx.scale(dpi, dpi); } + /// Returns effective dpi (browser DPI and pixel density due to transform). + double get dpi => + EnginePlatformDispatcher.browserDevicePixelRatio * _density; + void resetTransform() { final html.CanvasElement? canvas = _canvas; if (canvas != null) { @@ -688,8 +729,9 @@ class _CanvasPool extends _SaveStackTracking { class ContextStateHandle { final html.CanvasRenderingContext2D context; final _CanvasPool _canvasPool; + final double density; - ContextStateHandle(this._canvasPool, this.context); + ContextStateHandle(this._canvasPool, this.context, this.density); ui.BlendMode? _currentBlendMode = ui.BlendMode.srcOver; ui.StrokeCap? _currentStrokeCap = ui.StrokeCap.butt; ui.StrokeJoin? _currentStrokeJoin = ui.StrokeJoin.miter; @@ -778,7 +820,8 @@ class ContextStateHandle { if (paint.shader != null) { final EngineGradient engineShader = paint.shader as EngineGradient; final Object paintStyle = - engineShader.createPaintStyle(_canvasPool.context, shaderBounds); + engineShader.createPaintStyle(_canvasPool.context, shaderBounds, + density); fillStyle = paintStyle; strokeStyle = paintStyle; } else if (paint.color != null) { diff --git a/lib/web_ui/lib/src/engine/html/picture.dart b/lib/web_ui/lib/src/engine/html/picture.dart index f81395f7e4a78..379b72ae87b9d 100644 --- a/lib/web_ui/lib/src/engine/html/picture.dart +++ b/lib/web_ui/lib/src/engine/html/picture.dart @@ -90,6 +90,7 @@ class PersistedPicture extends PersistedLeafSurface { final EnginePicture picture; final ui.Rect? localPaintBounds; final int hints; + double _density = 1.0; /// Cache for reusing elements such as images across picture updates. CrossFrameCache? _elementCache = @@ -107,6 +108,23 @@ class PersistedPicture extends PersistedLeafSurface { _transform = _transform!.clone(); _transform!.translate(dx, dy); } + final double paintWidth = localPaintBounds!.width; + final double paintHeight = localPaintBounds!.height; + final double newDensity = localPaintBounds == null || paintWidth == 0 || paintHeight == 0 + ? 1.0 : _computePixelDensity(_transform, paintWidth, paintHeight); + if (newDensity != _density) { + _density = newDensity; + if (_canvas != null) { + // If cull rect and density hasn't changed, this will only repaint. + // If density doesn't match canvas, a new canvas will be created + // and paint queued. + // + // Similar to preroll for transform where transform is updated, for + // picture this means we need to repaint so pixelation doesn't occur + // due to transform changing overall dpi. + applyPaint(_canvas); + } + } _computeExactCullRects(); } @@ -296,7 +314,12 @@ class PersistedPicture extends PersistedLeafSurface { // painting. This removes all the setup work and scaffolding objects // that won't be useful for anything anyway. _recycleCanvas(oldCanvas); - domRenderer.clearDom(rootElement!); + if (rootElement != null) { + domRenderer.clearDom(rootElement!); + } + if (_canvas != null) { + _recycleCanvas(_canvas); + } _canvas = null; return; } @@ -339,7 +362,7 @@ class PersistedPicture extends PersistedLeafSurface { // We did not allocate a canvas last time. This can happen when the // picture is completely clipped out of the view. return 1.0; - } else if (!oldCanvas.doesFitBounds(_exactLocalCullRect!)) { + } else if (!oldCanvas.doesFitBounds(_exactLocalCullRect!, _density)) { // The canvas needs to be resized before painting. return 1.0; } else { @@ -382,7 +405,7 @@ class PersistedPicture extends PersistedLeafSurface { void _applyBitmapPaint(EngineCanvas? oldCanvas) { if (oldCanvas is BitmapCanvas && - oldCanvas.doesFitBounds(_optimalLocalCullRect!) && + oldCanvas.doesFitBounds(_optimalLocalCullRect!, _density) && oldCanvas.isReusable()) { if (_debugShowCanvasReuseStats) { DebugCanvasReuseOverlay.instance.keptCount++; @@ -451,7 +474,7 @@ class PersistedPicture extends PersistedLeafSurface { final double candidatePixelCount = candidateSize.width * candidateSize.height; - final bool fits = candidate.doesFitBounds(bounds); + final bool fits = candidate.doesFitBounds(bounds, _density); final bool isSmaller = candidatePixelCount < lastPixelCount; if (fits && isSmaller) { // [isTooSmall] is used to make sure that a small picture doesn't @@ -493,7 +516,7 @@ class PersistedPicture extends PersistedLeafSurface { if (_debugShowCanvasReuseStats) { DebugCanvasReuseOverlay.instance.createdCount++; } - final BitmapCanvas canvas = BitmapCanvas(bounds); + final BitmapCanvas canvas = BitmapCanvas(bounds, density: _density); canvas.setElementCache(_elementCache); if (_debugExplainSurfaceStats) { _surfaceStatsFor(this) @@ -536,8 +559,12 @@ class PersistedPicture extends PersistedLeafSurface { final bool cullRectChangeRequiresRepaint = _computeOptimalCullRect(oldSurface); if (identical(picture, oldSurface.picture)) { + bool densityChanged = + (_canvas is BitmapCanvas && + _density != (_canvas as BitmapCanvas)._density); + // The picture is the same. Attempt to avoid repaint. - if (cullRectChangeRequiresRepaint) { + if (cullRectChangeRequiresRepaint || densityChanged) { // Cull rect changed such that a repaint is still necessary. _applyPaint(oldSurface); } else { @@ -603,3 +630,72 @@ class PersistedPicture extends PersistedLeafSurface { } } } + +/// Given size of a rectangle and transform, computes pixel density +/// (scale factor). +double _computePixelDensity(Matrix4? transform, double width, double height) { + if (transform == null || transform.isIdentity()) { + return 1.0; + } + final Float32List m = transform.storage; + // Apply perspective transform to all 4 corners. Can't use left,top, bottom, + // right since for example rotating 45 degrees would yield inaccurate size. + double minX = m[12] * m[15]; + double minY = m[13] * m[15]; + double maxX = minX; + double maxY = minY; + double x = width; + double y = height; + double wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + double xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + double yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + print('$xp,$yp'); + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + x = 0; + wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + print('$xp,$yp'); + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + x = width; + y = 0; + wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); + xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; + yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; + print('$xp,$yp'); + minX = math.min(minX, xp); + maxX = math.max(maxX, xp); + minY = math.min(minY, yp); + maxY = math.max(maxY, yp); + double scaleX = (maxX - minX) / width; + double scaleY = (maxY - minY) / height; + double scale = math.min(scaleX, scaleY); + // kEpsilon guards against divide by zero below. + if (scale < kEpsilon || scale == 1) { + // Handle local paint bounds scaled to 0, typical when using + // transform animations and nothing is drawn. + return 1.0; + } + if (scale > 1) { + // Normalize scale to multiples of 2: 1x, 2x, 4x, 6x, 8x. + // This is to prevent frequent rescaling of canvas during animations. + // + // On a fullscreen high dpi device dpi*density*resolution will demand + // too much memory, so clamp at 4. + scale = math.min(4.0, ((scale / 2.0).ceil() * 2.0)); + // Guard against webkit absolute limit. + const double kPixelLimit = 1024 * 1024 * 4; + if ((width * height * scale * scale) > kPixelLimit && scale > 2) { + scale = (kPixelLimit * 0.8) / (width * height); + } + } else { + scale = math.max(2.0 / (2.0 / scale).floor(), 0.0001); + } + return scale; +} diff --git a/lib/web_ui/lib/src/engine/html/shaders/shader.dart b/lib/web_ui/lib/src/engine/html/shaders/shader.dart index 6ca1b812deed4..380dbfec8c9e1 100644 --- a/lib/web_ui/lib/src/engine/html/shaders/shader.dart +++ b/lib/web_ui/lib/src/engine/html/shaders/shader.dart @@ -11,7 +11,7 @@ abstract class EngineGradient implements ui.Gradient { /// Creates a fill style to be used in painting. Object createPaintStyle(html.CanvasRenderingContext2D? ctx, - ui.Rect? shaderBounds); + ui.Rect? shaderBounds, double density); } class GradientSweep extends EngineGradient { @@ -29,7 +29,7 @@ class GradientSweep extends EngineGradient { @override Object createPaintStyle(html.CanvasRenderingContext2D? ctx, - ui.Rect? shaderBounds) { + ui.Rect? shaderBounds, double density) { assert(shaderBounds != null); int widthInPixels = shaderBounds!.right.ceil(); int heightInPixels = shaderBounds.bottom.ceil(); @@ -167,7 +167,7 @@ class GradientLinear extends EngineGradient { @override html.CanvasGradient createPaintStyle(html.CanvasRenderingContext2D? ctx, - ui.Rect? shaderBounds) { + ui.Rect? shaderBounds, double density) { _FastMatrix64? matrix4 = this.matrix4; html.CanvasGradient gradient; if (matrix4 != null) { @@ -215,7 +215,7 @@ class GradientRadial extends EngineGradient { @override Object createPaintStyle(html.CanvasRenderingContext2D? ctx, - ui.Rect? shaderBounds) { + ui.Rect? shaderBounds, double density) { if (!useCanvasKit) { if (tileMode != ui.TileMode.clamp) { throw UnimplementedError( @@ -255,7 +255,7 @@ class GradientConical extends EngineGradient { @override Object createPaintStyle(html.CanvasRenderingContext2D? ctx, - ui.Rect? shaderBounds) { + ui.Rect? shaderBounds, double density) { throw UnimplementedError(); } } diff --git a/lib/web_ui/lib/src/engine/html/surface.dart b/lib/web_ui/lib/src/engine/html/surface.dart index 68efb10a92556..546e69384745f 100644 --- a/lib/web_ui/lib/src/engine/html/surface.dart +++ b/lib/web_ui/lib/src/engine/html/surface.dart @@ -33,21 +33,24 @@ const double _kScreenPixelRatioWarningThreshold = 6.0; /// Performs any outstanding painting work enqueued by [PersistedPicture]s. void commitScene(PersistedScene scene) { if (_paintQueue.isNotEmpty) { - if (_paintQueue.length > 1) { - // Sort paint requests in decreasing canvas size order. Paint requests - // attempt to reuse canvases. For efficiency we want the biggest pictures - // to find canvases before the smaller ones claim them. - _paintQueue.sort((_PaintRequest a, _PaintRequest b) { - final double aSize = a.canvasSize.height * a.canvasSize.width; - final double bSize = b.canvasSize.height * b.canvasSize.width; - return bSize.compareTo(aSize); - }); - } + try { + if (_paintQueue.length > 1) { + // Sort paint requests in decreasing canvas size order. Paint requests + // attempt to reuse canvases. For efficiency we want the biggest pictures + // to find canvases before the smaller ones claim them. + _paintQueue.sort((_PaintRequest a, _PaintRequest b) { + final double aSize = a.canvasSize.height * a.canvasSize.width; + final double bSize = b.canvasSize.height * b.canvasSize.width; + return bSize.compareTo(aSize); + }); + } - for (_PaintRequest request in _paintQueue) { - request.paintCallback(); + for (_PaintRequest request in _paintQueue) { + request.paintCallback(); + } + } finally { + _paintQueue = <_PaintRequest>[]; } - _paintQueue = <_PaintRequest>[]; } // After the update the retained surfaces are back to active. @@ -356,6 +359,7 @@ abstract class PersistedSurface implements ui.EngineLayer { assert(rootElement == null); assert(debugAssertSurfaceState(this, PersistedSurfaceState.created)); rootElement = createElement(); + assert(rootElement != null); applyWebkitClipFix(rootElement); if (_debugExplainSurfaceStats) { _surfaceStatsFor(this).allocatedDomNodeCount++; diff --git a/lib/web_ui/test/engine/surface/scene_builder_test.dart b/lib/web_ui/test/engine/surface/scene_builder_test.dart index fca7829288da2..c85881123fec7 100644 --- a/lib/web_ui/test/engine/surface/scene_builder_test.dart +++ b/lib/web_ui/test/engine/surface/scene_builder_test.dart @@ -525,6 +525,70 @@ void testMain() { await testCase('be', 'remove in the middle', deletions: 2); await testCase('', 'remove all', deletions: 2); }); + + test('Canvas should allocate fewer pixels when zoomed out', () async { + final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); + final Picture picture1 = _drawPicture(); + builder.pushClipRect(const Rect.fromLTRB(10, 10, 300, 300)); + builder.addPicture(Offset.zero, picture1); + builder.pop(); + + html.HtmlElement content = builder.build().webOnlyRootElement; + html.CanvasElement canvas = content.querySelector('canvas'); + final int unscaledWidth = canvas.width; + final int unscaledHeight = canvas.height; + + // Force update to scene which will utilize reuse code path. + final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); + builder2.pushOffset(0, 0); + builder2.pushTransform(Matrix4.identity().scaled(0.5, 0.5).toFloat64()); + builder2.pushClipRect( + const Rect.fromLTRB(10, 10, 300, 300), + ); + builder2.addPicture(Offset.zero, picture1); + builder2.pop(); + builder2.pop(); + builder2.pop(); + + html.HtmlElement contentAfterScale = builder2.build().webOnlyRootElement; + html.CanvasElement canvas2 = contentAfterScale.querySelector('canvas'); + // Although we are drawing same picture, due to scaling the new canvas + // should have fewer pixels. + expect(canvas2.width < unscaledWidth, true); + expect(canvas2.height < unscaledHeight, true); + }); + + test('Canvas should allocate more pixels when zoomed in', () async { + final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); + final Picture picture1 = _drawPicture(); + builder.pushClipRect(const Rect.fromLTRB(10, 10, 300, 300)); + builder.addPicture(Offset.zero, picture1); + builder.pop(); + + html.HtmlElement content = builder.build().webOnlyRootElement; + html.CanvasElement canvas = content.querySelector('canvas'); + final int unscaledWidth = canvas.width; + final int unscaledHeight = canvas.height; + + // Force update to scene which will utilize reuse code path. + final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); + builder2.pushOffset(0, 0); + builder2.pushTransform(Matrix4.identity().scaled(2, 2).toFloat64()); + builder2.pushClipRect( + const Rect.fromLTRB(10, 10, 300, 300), + ); + builder2.addPicture(Offset.zero, picture1); + builder2.pop(); + builder2.pop(); + builder2.pop(); + + html.HtmlElement contentAfterScale = builder2.build().webOnlyRootElement; + html.CanvasElement canvas2 = contentAfterScale.querySelector('canvas'); + // Although we are drawing same picture, due to scaling the new canvas + // should have more pixels. + expect(canvas2.width > unscaledWidth, true); + expect(canvas2.height > unscaledHeight, true); + }); } typedef TestLayerBuilder = EngineLayer Function( diff --git a/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart b/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart index 1822bc1c3e985..557280e2aecd1 100644 --- a/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart +++ b/lib/web_ui/test/golden_tests/engine/shadow_golden_test.dart @@ -163,7 +163,7 @@ void testMain() async { await matchGoldenFile( 'shadows.png', region: region, - maxDiffRatePercent: 0.0, + maxDiffRatePercent: 0.23, pixelComparison: PixelComparison.precise, ); },