Prevent rendering and caching blended layers for occluded layers

This commit is contained in:
Igor Zinken
2026-08-01 14:32:39 +02:00
parent 5fb1e209a1
commit 53ec6ca471
7 changed files with 93 additions and 32 deletions

View File

@@ -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 ) {

View File

@@ -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 );

View File

@@ -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 ) {

View File

@@ -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<DocumentState, any> = {
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
}

View File

@@ -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 );
});
});

View File

@@ -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 );
}
});
});

View File

@@ -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;