From 8f4f7a47e901fae4a9df58e49986a90ecbb00bc2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 22 May 2026 14:44:16 +0300 Subject: [PATCH] fix(canvas): filter retained backing previews - Keep stale scene backings available during zoom for smooth interaction - Draw scaled backings with linear filtering to avoid blocky text artifacts --- .../src/canvas/renderer/retained-backing.ts | 13 ++-- .../render/canvas/retained-backing.test.ts | 72 +++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 9e302a4a0..3d25bfe82 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -103,6 +103,10 @@ function backingWorldCoverageContainsLiveViewport(r: SkiaRenderer): boolean { ) } +function backingZoomMatchesLiveViewport(r: SkiaRenderer): boolean { + return Math.abs((r.sceneBacking?.zoom ?? r.zoom) - r.zoom) <= 0.0001 +} + function backingCoverageContainsLiveViewport( r: SkiaRenderer, sceneVersion: number, @@ -110,7 +114,7 @@ function backingCoverageContainsLiveViewport( positionPreviewVersion: number ): boolean { if (!backingMetadataMatches(r, sceneVersion, positionPreviewVersion)) return false - const crispZoom = Math.abs((r.sceneBacking?.zoom ?? r.zoom) - r.zoom) <= 0.0001 + const crispZoom = backingZoomMatchesLiveViewport(r) if (allowStaleZoom && backingScreenCoverageContainsViewport(r)) return true return crispZoom && backingWorldCoverageContainsLiveViewport(r) } @@ -139,12 +143,13 @@ function drawSceneBacking( const x = r.panX - backing.panX * scale const y = r.panY - backing.panY * scale r.opacityPaint.setAlphaf(1) - canvas.drawImageRect( + canvas.drawImageRectOptions( backing.image, r.ck.LTRBRect(0, 0, backing.width * backing.dpr, backing.height * backing.dpr), r.ck.LTRBRect(x, y, x + backing.width * scale, y + backing.height * scale), - r.opacityPaint, - true + r.ck.FilterMode.Linear, + r.ck.MipmapMode.None, + r.opacityPaint ) return true } diff --git a/tests/engine/render/canvas/retained-backing.test.ts b/tests/engine/render/canvas/retained-backing.test.ts index b20090444..7a6a44606 100644 --- a/tests/engine/render/canvas/retained-backing.test.ts +++ b/tests/engine/render/canvas/retained-backing.test.ts @@ -17,7 +17,9 @@ function createRenderer(surfaceFactory: () => Surface | null) { top, right, bottom - ]) + ]), + FilterMode: { Linear: 'Linear' }, + MipmapMode: { None: 'None' } } as SkiaRenderer['ck'], surface: { makeSurface: mock(surfaceFactory) @@ -54,7 +56,8 @@ function createRenderer(surfaceFactory: () => Surface | null) { function createCanvas() { const canvas: Partial = { - drawImageRect: mock() + drawImageRect: mock(), + drawImageRectOptions: mock() } return canvas as Canvas } @@ -79,10 +82,71 @@ test('retained scene backing falls back when CanvasKit cannot create an offscree expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false) expect(r.surface.makeSurface).toHaveBeenCalled() - expect(canvas.drawImageRect).not.toHaveBeenCalled() + expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() expect(r.sceneBacking).toBeNull() }) +test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => { + const r = createRenderer(() => null) + r.zoom = 1 + r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY + r.sceneBacking = { + image: { delete: mock() } as CKImage, + pageId: 'page', + sceneVersion: 1, + positionPreviewVersion: 0, + panX: 0, + panY: 0, + zoom: 0.5, + width: 300, + height: 300, + dpr: 1, + worldX: 0, + worldY: 0, + worldWidth: 600, + worldHeight: 600 + } as NonNullable + const canvas = createCanvas() + const graph = createGraph() + + expect(renderSceneBacking(r, canvas, graph, 1)).toBe(true) + expect(canvas.drawImageRectOptions).toHaveBeenCalledWith( + r.sceneBacking.image, + expect.anything(), + expect.anything(), + r.ck.FilterMode.Linear, + r.ck.MipmapMode.None, + r.opacityPaint + ) +}) + +test('retained scene backing allows same-zoom previews while panning', () => { + const r = createRenderer(() => null) + r.zoom = 1 + r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY + r.sceneBacking = { + image: { delete: mock() } as CKImage, + pageId: 'page', + sceneVersion: 1, + positionPreviewVersion: 0, + panX: 0, + panY: 0, + zoom: 1, + width: 300, + height: 300, + dpr: 1, + worldX: 0, + worldY: 0, + worldWidth: 300, + worldHeight: 300 + } as NonNullable + const canvas = createCanvas() + const graph = createGraph() + + expect(renderSceneBacking(r, canvas, graph, 1)).toBe(true) + expect(canvas.drawImageRectOptions).toHaveBeenCalled() +}) + test('retained scene backing invalidates stale position-preview metadata', () => { const r = createRenderer(() => null) r.sceneBacking = { @@ -106,5 +170,5 @@ test('retained scene backing invalidates stale position-preview metadata', () => const graph = createGraph(2) expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false) - expect(canvas.drawImageRect).not.toHaveBeenCalled() + expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() })