Skip to content

Commit 1ad6765

Browse files
authored
[web] Fixes canvas pixelation and overallocation due to transforms. (flutter#22160)
1 parent 37d766c commit 1ad6765

File tree

7 files changed

+268
-55
lines changed

7 files changed

+268
-55
lines changed

lib/web_ui/lib/src/engine/bitmap_canvas.dart

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,23 @@ class BitmapCanvas extends EngineCanvas {
104104
/// can be constructed from contents.
105105
bool _preserveImageData = false;
106106

107+
/// Canvas pixel to screen pixel ratio. Similar to dpi but
108+
/// uses global transform of canvas to compute ratio.
109+
final double _density;
110+
107111
/// Allocates a canvas with enough memory to paint a picture within the given
108112
/// [bounds].
109113
///
110114
/// This canvas can be reused by pictures with different paint bounds as long
111115
/// as the [Rect.size] of the bounds fully fit within the size used to
112116
/// initialize this canvas.
113-
BitmapCanvas(this._bounds)
117+
BitmapCanvas(this._bounds, {double density = 1.0})
114118
: assert(_bounds != null), // ignore: unnecessary_null_comparison
119+
_density = density,
115120
_widthInBitmapPixels = _widthToPhysical(_bounds.width),
116121
_heightInBitmapPixels = _heightToPhysical(_bounds.height),
117122
_canvasPool = _CanvasPool(_widthToPhysical(_bounds.width),
118-
_heightToPhysical(_bounds.height)) {
123+
_heightToPhysical(_bounds.height), density) {
119124
rootElement.style.position = 'absolute';
120125
// Adds one extra pixel to the requested size. This is to compensate for
121126
// _initializeViewport() snapping canvas position to 1 pixel, causing
@@ -179,10 +184,11 @@ class BitmapCanvas extends EngineCanvas {
179184
}
180185

181186
// Used by picture to assess if canvas is large enough to reuse as is.
182-
bool doesFitBounds(ui.Rect newBounds) {
187+
bool doesFitBounds(ui.Rect newBounds, double newDensity) {
183188
assert(newBounds != null); // ignore: unnecessary_null_comparison
184189
return _widthInBitmapPixels >= _widthToPhysical(newBounds.width) &&
185-
_heightInBitmapPixels >= _heightToPhysical(newBounds.height);
190+
_heightInBitmapPixels >= _heightToPhysical(newBounds.height) &&
191+
_density == newDensity;
186192
}
187193

188194
@override

lib/web_ui/lib/src/engine/canvas_pool.dart

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ class _CanvasPool extends _SaveStackTracking {
3333

3434
html.HtmlElement? _rootElement;
3535
int _saveContextCount = 0;
36+
final double _density;
3637

37-
_CanvasPool(this._widthInBitmapPixels, this._heightInBitmapPixels);
38+
_CanvasPool(this._widthInBitmapPixels, this._heightInBitmapPixels,
39+
this._density);
3840

3941
html.CanvasRenderingContext2D get context {
4042
html.CanvasRenderingContext2D? ctx = _context;
@@ -83,7 +85,12 @@ class _CanvasPool extends _SaveStackTracking {
8385
void _createCanvas() {
8486
bool requiresClearRect = false;
8587
bool reused = false;
86-
html.CanvasElement canvas;
88+
html.CanvasElement? canvas;
89+
if (_canvas != null) {
90+
_canvas!.width = 0;
91+
_canvas!.height = 0;
92+
_canvas = null;
93+
}
8794
if (_reusablePool != null && _reusablePool!.isNotEmpty) {
8895
canvas = _canvas = _reusablePool!.removeAt(0);
8996
requiresClearRect = true;
@@ -99,10 +106,7 @@ class _CanvasPool extends _SaveStackTracking {
99106
_widthInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio;
100107
final double cssHeight =
101108
_heightInBitmapPixels / EnginePlatformDispatcher.browserDevicePixelRatio;
102-
canvas = html.CanvasElement(
103-
width: _widthInBitmapPixels,
104-
height: _heightInBitmapPixels,
105-
);
109+
canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels);
106110
_canvas = canvas;
107111

108112
// Why is this null check here, even though we just allocated a canvas element above?
@@ -113,12 +117,9 @@ class _CanvasPool extends _SaveStackTracking {
113117
if (_canvas == null) {
114118
// Evict BitmapCanvas(s) and retry.
115119
_reduceCanvasMemoryUsage();
116-
canvas = html.CanvasElement(
117-
width: _widthInBitmapPixels,
118-
height: _heightInBitmapPixels,
119-
);
120+
canvas = _allocCanvas(_widthInBitmapPixels, _heightInBitmapPixels);
120121
}
121-
canvas.style
122+
canvas!.style
122123
..position = 'absolute'
123124
..width = '${cssWidth}px'
124125
..height = '${cssHeight}px';
@@ -131,19 +132,55 @@ class _CanvasPool extends _SaveStackTracking {
131132
_rootElement!.append(canvas);
132133
}
133134

134-
if (reused) {
135-
// If a canvas is the first element we set z-index = -1 in [BitmapCanvas]
136-
// endOfPaint to workaround blink compositing bug. To make sure this
137-
// does not leak when reused reset z-index.
138-
canvas.style.removeProperty('z-index');
135+
try {
136+
if (reused) {
137+
// If a canvas is the first element we set z-index = -1 in [BitmapCanvas]
138+
// endOfPaint to workaround blink compositing bug. To make sure this
139+
// does not leak when reused reset z-index.
140+
canvas.style.removeProperty('z-index');
141+
}
142+
_context = canvas.context2D;
143+
} catch (e) {
144+
// Handle OOM.
139145
}
140-
141-
final html.CanvasRenderingContext2D context = _context = canvas.context2D;
142-
_contextHandle = ContextStateHandle(this, context);
146+
if (_context == null) {
147+
_reduceCanvasMemoryUsage();
148+
_context = canvas.context2D;
149+
}
150+
if (_context == null) {
151+
/// Browser ran out of memory, try to recover current allocation
152+
/// and bail.
153+
_canvas?.width = 0;
154+
_canvas?.height = 0;
155+
_canvas = null;
156+
return;
157+
}
158+
_contextHandle = ContextStateHandle(this, _context!, this._density);
143159
_initializeViewport(requiresClearRect);
144160
_replayClipStack();
145161
}
146162

163+
html.CanvasElement? _allocCanvas(int width, int height) {
164+
final dynamic canvas =
165+
js_util.callMethod(html.document, 'createElement', <dynamic>['CANVAS']);
166+
if (canvas != null) {
167+
try {
168+
canvas.width = (width * _density).ceil();
169+
canvas.height = (height * _density).ceil();
170+
} catch (e) {
171+
return null;
172+
}
173+
return canvas as html.CanvasElement;
174+
}
175+
return null;
176+
// !!! We don't use the code below since NNBD assumes it can never return
177+
// null and optimizes out code.
178+
// return canvas = html.CanvasElement(
179+
// width: _widthInBitmapPixels,
180+
// height: _heightInBitmapPixels,
181+
// );
182+
}
183+
147184
@override
148185
void clear() {
149186
super.clear();
@@ -188,7 +225,7 @@ class _CanvasPool extends _SaveStackTracking {
188225
clipTimeTransform[5] != prevTransform[5] ||
189226
clipTimeTransform[12] != prevTransform[12] ||
190227
clipTimeTransform[13] != prevTransform[13]) {
191-
final double ratio = EnginePlatformDispatcher.browserDevicePixelRatio;
228+
final double ratio = dpi;
192229
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
193230
ctx.transform(
194231
clipTimeTransform[0],
@@ -222,7 +259,7 @@ class _CanvasPool extends _SaveStackTracking {
222259
transform[5] != prevTransform[5] ||
223260
transform[12] != prevTransform[12] ||
224261
transform[13] != prevTransform[13]) {
225-
final double ratio = EnginePlatformDispatcher.browserDevicePixelRatio;
262+
final double ratio = dpi;
226263
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
227264
ctx.transform(transform[0], transform[1], transform[4], transform[5],
228265
transform[12], transform[13]);
@@ -300,15 +337,19 @@ class _CanvasPool extends _SaveStackTracking {
300337
// is applied on the DOM elements.
301338
ctx.setTransform(1, 0, 0, 1, 0, 0);
302339
if (clearCanvas) {
303-
ctx.clearRect(0, 0, _widthInBitmapPixels, _heightInBitmapPixels);
340+
ctx.clearRect(0, 0, _widthInBitmapPixels * _density,
341+
_heightInBitmapPixels * _density);
304342
}
305343

306344
// This scale makes sure that 1 CSS pixel is translated to the correct
307345
// number of bitmap pixels.
308-
ctx.scale(EnginePlatformDispatcher.browserDevicePixelRatio,
309-
EnginePlatformDispatcher.browserDevicePixelRatio);
346+
ctx.scale(dpi, dpi);
310347
}
311348

349+
/// Returns effective dpi (browser DPI and pixel density due to transform).
350+
double get dpi =>
351+
EnginePlatformDispatcher.browserDevicePixelRatio * _density;
352+
312353
void resetTransform() {
313354
final html.CanvasElement? canvas = _canvas;
314355
if (canvas != null) {
@@ -688,8 +729,9 @@ class _CanvasPool extends _SaveStackTracking {
688729
class ContextStateHandle {
689730
final html.CanvasRenderingContext2D context;
690731
final _CanvasPool _canvasPool;
732+
final double density;
691733

692-
ContextStateHandle(this._canvasPool, this.context);
734+
ContextStateHandle(this._canvasPool, this.context, this.density);
693735
ui.BlendMode? _currentBlendMode = ui.BlendMode.srcOver;
694736
ui.StrokeCap? _currentStrokeCap = ui.StrokeCap.butt;
695737
ui.StrokeJoin? _currentStrokeJoin = ui.StrokeJoin.miter;
@@ -778,7 +820,8 @@ class ContextStateHandle {
778820
if (paint.shader != null) {
779821
final EngineGradient engineShader = paint.shader as EngineGradient;
780822
final Object paintStyle =
781-
engineShader.createPaintStyle(_canvasPool.context, shaderBounds);
823+
engineShader.createPaintStyle(_canvasPool.context, shaderBounds,
824+
density);
782825
fillStyle = paintStyle;
783826
strokeStyle = paintStyle;
784827
} else if (paint.color != null) {

lib/web_ui/lib/src/engine/html/picture.dart

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class PersistedPicture extends PersistedLeafSurface {
9090
final EnginePicture picture;
9191
final ui.Rect? localPaintBounds;
9292
final int hints;
93+
double _density = 1.0;
9394

9495
/// Cache for reusing elements such as images across picture updates.
9596
CrossFrameCache<html.HtmlElement>? _elementCache =
@@ -107,6 +108,23 @@ class PersistedPicture extends PersistedLeafSurface {
107108
_transform = _transform!.clone();
108109
_transform!.translate(dx, dy);
109110
}
111+
final double paintWidth = localPaintBounds!.width;
112+
final double paintHeight = localPaintBounds!.height;
113+
final double newDensity = localPaintBounds == null || paintWidth == 0 || paintHeight == 0
114+
? 1.0 : _computePixelDensity(_transform, paintWidth, paintHeight);
115+
if (newDensity != _density) {
116+
_density = newDensity;
117+
if (_canvas != null) {
118+
// If cull rect and density hasn't changed, this will only repaint.
119+
// If density doesn't match canvas, a new canvas will be created
120+
// and paint queued.
121+
//
122+
// Similar to preroll for transform where transform is updated, for
123+
// picture this means we need to repaint so pixelation doesn't occur
124+
// due to transform changing overall dpi.
125+
applyPaint(_canvas);
126+
}
127+
}
110128
_computeExactCullRects();
111129
}
112130

@@ -296,7 +314,12 @@ class PersistedPicture extends PersistedLeafSurface {
296314
// painting. This removes all the setup work and scaffolding objects
297315
// that won't be useful for anything anyway.
298316
_recycleCanvas(oldCanvas);
299-
domRenderer.clearDom(rootElement!);
317+
if (rootElement != null) {
318+
domRenderer.clearDom(rootElement!);
319+
}
320+
if (_canvas != null) {
321+
_recycleCanvas(_canvas);
322+
}
300323
_canvas = null;
301324
return;
302325
}
@@ -339,7 +362,7 @@ class PersistedPicture extends PersistedLeafSurface {
339362
// We did not allocate a canvas last time. This can happen when the
340363
// picture is completely clipped out of the view.
341364
return 1.0;
342-
} else if (!oldCanvas.doesFitBounds(_exactLocalCullRect!)) {
365+
} else if (!oldCanvas.doesFitBounds(_exactLocalCullRect!, _density)) {
343366
// The canvas needs to be resized before painting.
344367
return 1.0;
345368
} else {
@@ -382,7 +405,7 @@ class PersistedPicture extends PersistedLeafSurface {
382405

383406
void _applyBitmapPaint(EngineCanvas? oldCanvas) {
384407
if (oldCanvas is BitmapCanvas &&
385-
oldCanvas.doesFitBounds(_optimalLocalCullRect!) &&
408+
oldCanvas.doesFitBounds(_optimalLocalCullRect!, _density) &&
386409
oldCanvas.isReusable()) {
387410
if (_debugShowCanvasReuseStats) {
388411
DebugCanvasReuseOverlay.instance.keptCount++;
@@ -451,7 +474,7 @@ class PersistedPicture extends PersistedLeafSurface {
451474
final double candidatePixelCount =
452475
candidateSize.width * candidateSize.height;
453476

454-
final bool fits = candidate.doesFitBounds(bounds);
477+
final bool fits = candidate.doesFitBounds(bounds, _density);
455478
final bool isSmaller = candidatePixelCount < lastPixelCount;
456479
if (fits && isSmaller) {
457480
// [isTooSmall] is used to make sure that a small picture doesn't
@@ -493,7 +516,7 @@ class PersistedPicture extends PersistedLeafSurface {
493516
if (_debugShowCanvasReuseStats) {
494517
DebugCanvasReuseOverlay.instance.createdCount++;
495518
}
496-
final BitmapCanvas canvas = BitmapCanvas(bounds);
519+
final BitmapCanvas canvas = BitmapCanvas(bounds, density: _density);
497520
canvas.setElementCache(_elementCache);
498521
if (_debugExplainSurfaceStats) {
499522
_surfaceStatsFor(this)
@@ -536,8 +559,12 @@ class PersistedPicture extends PersistedLeafSurface {
536559
final bool cullRectChangeRequiresRepaint =
537560
_computeOptimalCullRect(oldSurface);
538561
if (identical(picture, oldSurface.picture)) {
562+
bool densityChanged =
563+
(_canvas is BitmapCanvas &&
564+
_density != (_canvas as BitmapCanvas)._density);
565+
539566
// The picture is the same. Attempt to avoid repaint.
540-
if (cullRectChangeRequiresRepaint) {
567+
if (cullRectChangeRequiresRepaint || densityChanged) {
541568
// Cull rect changed such that a repaint is still necessary.
542569
_applyPaint(oldSurface);
543570
} else {
@@ -603,3 +630,72 @@ class PersistedPicture extends PersistedLeafSurface {
603630
}
604631
}
605632
}
633+
634+
/// Given size of a rectangle and transform, computes pixel density
635+
/// (scale factor).
636+
double _computePixelDensity(Matrix4? transform, double width, double height) {
637+
if (transform == null || transform.isIdentity()) {
638+
return 1.0;
639+
}
640+
final Float32List m = transform.storage;
641+
// Apply perspective transform to all 4 corners. Can't use left,top, bottom,
642+
// right since for example rotating 45 degrees would yield inaccurate size.
643+
double minX = m[12] * m[15];
644+
double minY = m[13] * m[15];
645+
double maxX = minX;
646+
double maxY = minY;
647+
double x = width;
648+
double y = height;
649+
double wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]);
650+
double xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp;
651+
double yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp;
652+
print('$xp,$yp');
653+
minX = math.min(minX, xp);
654+
maxX = math.max(maxX, xp);
655+
minY = math.min(minY, yp);
656+
maxY = math.max(maxY, yp);
657+
x = 0;
658+
wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]);
659+
xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp;
660+
yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp;
661+
print('$xp,$yp');
662+
minX = math.min(minX, xp);
663+
maxX = math.max(maxX, xp);
664+
minY = math.min(minY, yp);
665+
maxY = math.max(maxY, yp);
666+
x = width;
667+
y = 0;
668+
wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]);
669+
xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp;
670+
yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp;
671+
print('$xp,$yp');
672+
minX = math.min(minX, xp);
673+
maxX = math.max(maxX, xp);
674+
minY = math.min(minY, yp);
675+
maxY = math.max(maxY, yp);
676+
double scaleX = (maxX - minX) / width;
677+
double scaleY = (maxY - minY) / height;
678+
double scale = math.min(scaleX, scaleY);
679+
// kEpsilon guards against divide by zero below.
680+
if (scale < kEpsilon || scale == 1) {
681+
// Handle local paint bounds scaled to 0, typical when using
682+
// transform animations and nothing is drawn.
683+
return 1.0;
684+
}
685+
if (scale > 1) {
686+
// Normalize scale to multiples of 2: 1x, 2x, 4x, 6x, 8x.
687+
// This is to prevent frequent rescaling of canvas during animations.
688+
//
689+
// On a fullscreen high dpi device dpi*density*resolution will demand
690+
// too much memory, so clamp at 4.
691+
scale = math.min(4.0, ((scale / 2.0).ceil() * 2.0));
692+
// Guard against webkit absolute limit.
693+
const double kPixelLimit = 1024 * 1024 * 4;
694+
if ((width * height * scale * scale) > kPixelLimit && scale > 2) {
695+
scale = (kPixelLimit * 0.8) / (width * height);
696+
}
697+
} else {
698+
scale = math.max(2.0 / (2.0 / scale).floor(), 0.0001);
699+
}
700+
return scale;
701+
}

0 commit comments

Comments
 (0)