diff --git a/src/components/document-canvas/document-canvas.vue b/src/components/document-canvas/document-canvas.vue index 62e1078..0659cc6 100644 --- a/src/components/document-canvas/document-canvas.vue +++ b/src/components/document-canvas/document-canvas.vue @@ -92,7 +92,7 @@ import { scale } from "@/math/unit-math"; import { unblockedWait, rafCallback } from "@/utils/debounce-util"; import { createGroupSnapshot, getAlignableObjects } from "@/utils/document-util"; import { isMobile } from "@/utils/environment-util"; -import { hasBlend } from "@/utils/layer-util"; +import { hasBlend, isOccluded } from "@/utils/layer-util"; import { getPreviousTile } from "@/utils/timeline-util"; import { fitInWindow } from "@/utils/zoom-util"; import Scrollbars from "./scrollbars/scrollbars.vue"; @@ -549,7 +549,7 @@ export default { // @todo can we do this in the loop above? let blendLayer = -1; this.layers?.forEach(( layer: Layer, index: number ) => { - if ( !this.isVisible( layer )) { + if ( !this.isVisible( layer, true )) { return; } const renderer = getRendererForLayer( layer ); @@ -628,8 +628,8 @@ export default { getCanvasInstance()?.interactionPane.stopOutsideSelection(); } }, - isVisible( layer: Layer ): boolean { - if ( !layer.visible ) { + isVisible( layer: Layer, checkOcclusion = false ): boolean { + if ( !layer.visible || ( checkOcclusion && isOccluded( layer, this.activeDocument ))) { return false; } if ( this.hasTimeline ) { diff --git a/src/rendering/actors/layer-renderer.ts b/src/rendering/actors/layer-renderer.ts index 71c4fee..7e9af10 100644 --- a/src/rendering/actors/layer-renderer.ts +++ b/src/rendering/actors/layer-renderer.ts @@ -25,7 +25,6 @@ import type { Point, Rectangle, Size } from "zcanvas"; import type ZoomableCanvas from "./zoomable-canvas"; import ZoomableSprite from "./zoomable-sprite"; import type { Viewport, TransformedDrawBounds } from "zcanvas"; -import { BlendModes } from "@/definitions/blend-modes"; import type { Document } from "@/model/types/document"; import type { Layer } from "@/model/types/layer"; import type { Selection } from "@/model/types/selection"; @@ -47,7 +46,10 @@ import { snapToGuide } from "@/rendering/operations/snapping"; import { applyTransformation } from "@/rendering/operations/transforming"; import { flushLayerCache, clearCacheProperty } from "@/rendering/cache/bitmap-cache"; import { createLayerThumbnail } from "@/rendering/cache/thumbnail-cache"; -import { cacheBlendedLayer, flushBlendedLayerCache, getBlendCache, getBlendableLayers, isBlendCached, pauseBlendCaching, useBlendCaching } from "@/rendering/cache/blended-layer-cache"; +import { + cacheBlendedLayer, flushBlendedLayerCache, + getBlendCache, getBlendableLayers, isBlendCached, pauseBlendCaching, useBlendCaching +} from "@/rendering/cache/blended-layer-cache"; import { renderBrushOutline } from "@/rendering/cursors/brush"; import { getDrawableCanvas, renderDrawableCanvas, disposeDrawableCanvas, sliceBrushPointers, createOverrideConfig @@ -204,9 +206,11 @@ export default class LayerRenderer extends ZoomableSprite { await renderEffectsForLayer( this.layer ); this._pendingEffectsRender = false; this.canvas?.setLock( false ); - this.invalidateBlendCache( true ); // now layer effects are cached, invalidate any existing blend cache - if ( this.layer.visible && !!this.canvas ) { - createLayerThumbnail( this.layer, this.canvas.getActiveDocument(), true ); + if ( this.layer.visible) { + this.invalidateBlendCache(); // now layer effects are cached, invalidate any existing blend cache + if ( !!this.canvas ) { + createLayerThumbnail( this.layer, this.canvas.getActiveDocument(), true ); + } } }); } @@ -216,9 +220,9 @@ export default class LayerRenderer extends ZoomableSprite { this.cacheEffects(); // sync mask and source changes with the renderers Bitmap } - invalidateBlendCache( full = false ): void { + invalidateBlendCache(): void { if ( hasBlend( this.layer ) || isBlendCached( this.layerIndex )) { - flushBlendedLayerCache( full ); + flushBlendedLayerCache( true ); } } @@ -670,9 +674,9 @@ export default class LayerRenderer extends ZoomableSprite { } if ( hasBlend( this.layer )) { let bitmap = getBlendCache( layerIndex ); - const document = this.canvas.getActiveDocument(); + const activeDocument = this.canvas.getActiveDocument(); if ( !bitmap ) { - bitmap = createSyncSnapshot( document, getBlendableLayers()); + bitmap = createSyncSnapshot( activeDocument, getBlendableLayers()); cacheBlendedLayer( layerIndex, bitmap ); } const pixelRatio = getPixelRatio(); @@ -697,7 +701,7 @@ export default class LayerRenderer extends ZoomableSprite { const isPainting = this.isPainting(); const isErasing = isPainting && this._toolType === ToolTypes.ERASER; const isDrawingOnMask = isPainting && isMaskable( this.layer, this.getStore() ); - const applyBlending = enabled && blendMode !== BlendModes.NORMAL && !isDrawingOnMask; + const applyBlending = hasBlend( this.layer ) && !isDrawingOnMask; if ( applyBlending ) { drawContext = getBlendContext( documentContext.canvas ); diff --git a/src/rendering/cache/blended-layer-cache.ts b/src/rendering/cache/blended-layer-cache.ts index f41c9eb..f6e6139 100644 --- a/src/rendering/cache/blended-layer-cache.ts +++ b/src/rendering/cache/blended-layer-cache.ts @@ -25,7 +25,7 @@ import { type Layer } from "@/model/types/layer"; interface BlendedLayerCache { enabled: boolean; // whether blend caching is enabled for the current Document paused: boolean; // whether blend caching is paused (for instance during mutations of layers inside the blended content) - index: number; // index of layer containing the blended content + index: number; // index of highest layer containing the blended content bitmap?: HTMLCanvasElement; // cached Bitmap blendableLayers?: number[]; // indices of all layers to render in the blend (up to and including the layer defined at the cache index) }; @@ -64,6 +64,14 @@ export const isBlendCached = ( index: number ): boolean => { return index < blendCache.index; }; +/** + * Whether the Layer at provided index affects the state of the blend cache + * (similar to isBlendCached() but also includes the highest layer that has the cached blend mode) + */ +export const affectsBlendCache = ( index: number ): boolean => { + return index <= blendCache.index; +}; + export const pauseBlendCaching = ( index: number, paused: boolean ): void => { const isCached = blendCache.index === index || isBlendCached( index ); if ( !isCached ) { diff --git a/src/store/modules/document-module.ts b/src/store/modules/document-module.ts index 90c4f7d..bf610d4 100644 --- a/src/store/modules/document-module.ts +++ b/src/store/modules/document-module.ts @@ -29,7 +29,7 @@ import type { Transform } from "@/model/types/transform"; import DocumentFactory from "@/model/factories/document-factory"; import LayerFactory from "@/model/factories/layer-factory"; import { createRendererForLayer, flushLayerRenderers, runRendererFn, getRendererForLayer } from "@/model/factories/renderer-factory"; -import { flushBlendedLayerCache } from "@/rendering/cache/blended-layer-cache"; +import { affectsBlendCache, flushBlendedLayerCache } from "@/rendering/cache/blended-layer-cache"; import { createLayerThumbnail, flushThumbnailCache, flushThumbnailForLayer } from "@/rendering/cache/thumbnail-cache"; import { flushTileCache } from "@/rendering/cache/tile-cache"; import { getCanvasInstance } from "@/services/canvas-service"; @@ -216,7 +216,7 @@ const DocumentModule: Module = { const renderer = getRendererForLayer( layer ); if ( renderer ) { renderer.layer = layer; - const flushBlendCache = !!opts.filters; + const flushBlendCache = ( !!opts.filters || typeof opts.visible === "boolean" ) && affectsBlendCache( index ); if ( flushBlendCache ) { flushBlendedLayerCache( true ); // direct to prevent rendering errors on undo } diff --git a/tests/unit/rendering/actors/layer-renderer.spec.ts b/tests/unit/rendering/actors/layer-renderer.spec.ts index b1aacbe..9e84ff4 100644 --- a/tests/unit/rendering/actors/layer-renderer.spec.ts +++ b/tests/unit/rendering/actors/layer-renderer.spec.ts @@ -21,7 +21,7 @@ vi.mock( "@/rendering/cache/blended-layer-cache", () => ({ isBlendCached: vi.fn(() => mockIsBlendCached ), useBlendCaching: vi.fn(() => mockUseBlendCaching ), pauseBlendCaching: vi.fn(( ...args ) => mockPauseBlendCaching( ...args )), - flushBlendedLayerCache: vi.fn(() => mockFlushBlendedLayerCache() ), + flushBlendedLayerCache: vi.fn(( ...args ) => mockFlushBlendedLayerCache( ...args )), })); const mockCreateLayerThumbnail = vi.fn(); @@ -325,7 +325,7 @@ describe( "LayerRenderer", () => { expect( canvas.setLock ).toHaveBeenCalledTimes( 3 ); }); - it( "should request a full invalidation of the the blend cache upon render completion", async () => { + it( "should request an invalidation of the the blend cache upon render completion", async () => { const layerRenderer = createLayerRenderer( LayerFactory.create({ filters: FiltersFactory.create({ blendMode: BlendModes.DARKEN }) })); @@ -333,7 +333,7 @@ describe( "LayerRenderer", () => { await mockAsyncRender(); - expect( invalidateSpy ).toHaveBeenCalledWith( true ); + expect( invalidateSpy ).toHaveBeenCalled(); }); it( "should request a render of the thumbnail upon render completion", async () => { @@ -349,7 +349,7 @@ describe( "LayerRenderer", () => { describe( "when invalidating the blend cache", () => { it( "should not flush the blended layer cache when the layer does not have a blend filter", () => { const layerRenderer = createLayerRenderer( LayerFactory.create({ - filters: FiltersFactory.create({ blendMode: BlendModes.NORMAL }) + filters: FiltersFactory.create({ enabled: true, blendMode: BlendModes.NORMAL }) })); layerRenderer.invalidateBlendCache(); @@ -357,26 +357,26 @@ describe( "LayerRenderer", () => { expect( mockFlushBlendedLayerCache ).not.toHaveBeenCalled(); }); - it( "should flush the blended layer cache when the layer has a blend filter", () => { + it( "should fully flush the blended layer cache when the layer has a blend filter", () => { const layerRenderer = createLayerRenderer( LayerFactory.create({ - filters: FiltersFactory.create({ blendMode: BlendModes.DARKEN }) + filters: FiltersFactory.create({ enabled: true, blendMode: BlendModes.DARKEN }) })); layerRenderer.invalidateBlendCache(); - expect( mockFlushBlendedLayerCache ).toHaveBeenCalled(); + expect( mockFlushBlendedLayerCache ).toHaveBeenCalledWith( true ); }); - it( "should flush the blended layer cache when the layer does not have a blend filter, but is part of the blended layer cache", () => { + it( "should fully flush the blended layer cache when the layer does not have a blend filter, but is part of the blended layer cache", () => { mockIsBlendCached = true; const layerRenderer = createLayerRenderer( LayerFactory.create({ - filters: FiltersFactory.create({ blendMode: BlendModes.NORMAL }) + filters: FiltersFactory.create({ enabled: true, blendMode: BlendModes.NORMAL }) })); layerRenderer.invalidateBlendCache(); - expect( mockFlushBlendedLayerCache ).toHaveBeenCalled(); + expect( mockFlushBlendedLayerCache ).toHaveBeenCalledWith( true ); }); }); diff --git a/tests/unit/rendering/cache/blended-layer-cache.spec.ts b/tests/unit/rendering/cache/blended-layer-cache.spec.ts index 7f7d874..ce8313a 100644 --- a/tests/unit/rendering/cache/blended-layer-cache.spec.ts +++ b/tests/unit/rendering/cache/blended-layer-cache.spec.ts @@ -4,7 +4,7 @@ mockZCanvas(); import LayerFactory from "@/model/factories/layer-factory"; import { - cacheBlendedLayer, flushBlendedLayerCache, getBlendCache, getBlendableLayers, + affectsBlendCache, cacheBlendedLayer, flushBlendedLayerCache, getBlendCache, getBlendableLayers, isBlendCached, pauseBlendCaching, setBlendCaching, useBlendCaching, } from "@/rendering/cache/blended-layer-cache"; @@ -196,4 +196,14 @@ describe( "Blended layer cache", () => { expect( getBlendCache( 1 )).toBeUndefined(); }); }); + + it( "should know when changes to the appearance of a Layer at a specific index affect the state of the blend cache", () => { + cacheBlendedLayer( 4, cachedBitmap ); + + expect( affectsBlendCache( 5 )).toBe( false ); + + for ( let i = 0; i <= 4; ++i ) { + expect( affectsBlendCache( i )).toBe( true ); + } + }); }); \ No newline at end of file diff --git a/tests/unit/store/modules/document-module.spec.ts b/tests/unit/store/modules/document-module.spec.ts index d551004..e4a3524 100644 --- a/tests/unit/store/modules/document-module.spec.ts +++ b/tests/unit/store/modules/document-module.spec.ts @@ -19,9 +19,11 @@ vi.mock( "@/model/factories/renderer-factory", () => ({ createRendererForLayer: vi.fn(( ...args: any[]) => mockUpdateFn?.( "createRendererForLayer", ...args )), })); const mockFlushBlendedLayerCache = vi.fn(); +let mockAffectsBlendCache = false; vi.mock( "@/rendering/cache/blended-layer-cache", async ( importOriginal ) => { return { ...await importOriginal(), + affectsBlendCache: vi.fn(() => mockAffectsBlendCache ), flushBlendedLayerCache: vi.fn(( ...args: any[] ) => mockFlushBlendedLayerCache( ...args )), } }); @@ -53,6 +55,7 @@ vi.mock( "@/utils/layer-util", async ( importOriginal ) => ({ describe( "Vuex document module", () => { afterEach(() => { vi.resetAllMocks(); + mockAffectsBlendCache = false; }); describe( "getters", () => { @@ -667,10 +670,12 @@ describe( "Vuex document module", () => { expect( resetAndRecacheSpy ).toHaveBeenCalled(); }); - it( "should not flush the blended layer cache when no filter properties were updated", () => { + it( "should not flush the blended layer cache when filter or visibility properties were updated for a Layer not affecting the cache", () => { const index = 0; - const opts = { name: "layer1 updated" }; + const opts = { filters: { gamma: 1 }, visible: false }; const layerRenderer = new LayerRenderer( layer1 ); + + mockAffectsBlendCache = false; mockUpdateFn = vi.fn( fn => { if ( fn === "getRendererForLayer" ) return layerRenderer; @@ -681,11 +686,13 @@ describe( "Vuex document module", () => { expect( mockFlushBlendedLayerCache ).not.toHaveBeenCalled(); }); - it( "should flush the blended layer cache fully when filter properties are updated to ensure correct rendering on history state changes", () => { + it( "should fully flush the blended layer cache when filter properties were updated for a Layer affecting the cache", () => { const index = 0; const opts = { filters: { gamma: 1 } }; const layerRenderer = new LayerRenderer( layer1 ); - + + mockAffectsBlendCache = true; + mockUpdateFn = vi.fn( fn => { if ( fn === "getRendererForLayer" ) return layerRenderer; return true; @@ -695,6 +702,38 @@ describe( "Vuex document module", () => { expect( mockFlushBlendedLayerCache ).toHaveBeenCalledWith( true ); }); + it( "should fully flush the blended layer cache when visibility was updated for a Layer affecting the cache", () => { + const index = 0; + const opts = { visible: false }; + const layerRenderer = new LayerRenderer( layer1 ); + + mockAffectsBlendCache = true; + + mockUpdateFn = vi.fn( fn => { + if ( fn === "getRendererForLayer" ) return layerRenderer; + return true; + }); + mutations.updateLayer( state, { index, opts }); + + expect( mockFlushBlendedLayerCache ).toHaveBeenCalledWith( true ); + }); + + it( "should not flush the blended layer cache when no filter or visibility properties were updated for a Layer affecting the cache", () => { + const index = 0; + const opts = { name: "layer1 updated" }; + const layerRenderer = new LayerRenderer( layer1 ); + + mockAffectsBlendCache = true; + + mockUpdateFn = vi.fn( fn => { + if ( fn === "getRendererForLayer" ) return layerRenderer; + return true; + }); + mutations.updateLayer( state, { index, opts }); + + expect( mockFlushBlendedLayerCache ).not.toHaveBeenCalled(); + }); + describe( "when requesting to also recreate the renderer for the specific Layer", () => { it( "should not do anything related to renderer lifecycle when no recreation request was provided", () => { const index = 0;