mirror of
https://github.com/igorski/bitmappery.git
synced 2026-06-16 19:25:38 +02:00
Improved brushing performance on zoomed in documents
This commit is contained in:
@@ -64,7 +64,7 @@ import {
|
||||
getCanvasInstance, setCanvasInstance,
|
||||
createSpriteForLayer, getSpriteForLayer, flushLayerSprites, flushCache as flushSpriteCache,
|
||||
} from "@/factories/sprite-factory";
|
||||
import { flushCache as flushBitmapCache } from "@/services/caches/bitmap-cache";
|
||||
import { flushCache as flushBitmapCache } from "@/rendering/cache/bitmap-cache";
|
||||
|
||||
/* internal non-reactive properties */
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"en-US": {
|
||||
"preferences": "Preferences",
|
||||
"lowMemoryMode": "Low memory mode",
|
||||
"lowMemoryExpl": "On devices with low memory (like phones), this mode is recommended to reduce the amount of allocated memory. It comes at the expense of brush history accuracy (e.g. undo will go back several strokes instead of one).",
|
||||
"lowMemoryExpl": "On devices with low memory (like phones), this mode is recommended to reduce the amount of allocated memory. It comes at the expense of brush history accuracy (e.g. undo will instantly go back several strokes instead of one at a time).",
|
||||
"wasmFilters": "Web Assembly filters",
|
||||
"wasmFiltersExpl": "Experimental feature. If application of layer filters is slow, you can enable this mode which offers a performance gain depending on your environment.",
|
||||
"wasmFiltersExpl": "Experimental feature. If application of layer filters is slow, you can enable this mode which can offer a performance gain depending on your environment.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ import { scaleRectangle } from "@/math/image-math";
|
||||
import { getRectangleForSelection, isSelectionClosed } from "@/math/selection-math";
|
||||
import { rotatePoints, translatePointerRotation } from "@/math/point-math";
|
||||
import { renderEffectsForLayer } from "@/services/render-service";
|
||||
import { flushLayerCache, clearCacheProperty } from "@/services/caches/bitmap-cache";
|
||||
import { clipContextToSelection } from "@/rendering/clipping";
|
||||
import { flushLayerCache, clearCacheProperty } from "@/rendering/cache/bitmap-cache";
|
||||
import {
|
||||
getTempCanvas, renderTempCanvas, disposeTempCanvas, translatePointers, createOverrideConfig
|
||||
} from "@/rendering/lowres";
|
||||
import BrushFactory from "@/factories/brush-factory";
|
||||
import { getSpriteForLayer } from "@/factories/sprite-factory";
|
||||
import { enqueueState } from "@/factories/history-state-factory";
|
||||
@@ -271,8 +275,7 @@ class LayerSprite extends sprite {
|
||||
// TODO: when rotated and mirrored, x and y are now in right coordinate space, but not at right point
|
||||
|
||||
// get the enqueued pointers which are to be rendered in this paint cycle
|
||||
let { pointers, last } = this._brush;
|
||||
pointers = JSON.parse( JSON.stringify( pointers.slice( pointers.length - ( pointers.length - last ) - 1 )));
|
||||
const pointers = translatePointers( this._brush );
|
||||
|
||||
if ( isCloneStamp ) {
|
||||
if ( this._brush.down ) {
|
||||
@@ -288,21 +291,13 @@ class LayerSprite extends sprite {
|
||||
let overrides = null;
|
||||
if ( this._brush.down ) {
|
||||
// live update on lower resolution canvas
|
||||
this.tempCanvas = this.tempCanvas || createCanvas(
|
||||
this.canvas.getWidth(), this.canvas.getHeight()
|
||||
);
|
||||
overrides = {
|
||||
scale : 1 / this.canvas.documentScale,
|
||||
zoom : this.canvas.zoomFactor,
|
||||
x : left,
|
||||
y : top,
|
||||
pointers,
|
||||
};
|
||||
this.tempCanvas = this.tempCanvas || getTempCanvas( this.canvas );
|
||||
overrides = createOverrideConfig( this.canvas, left, top, pointers );
|
||||
ctx.restore(); // restore previous context before switching to temp context
|
||||
ctx = this.tempCanvas.ctx;
|
||||
|
||||
if ( selectionPoints && this.tempCanvas ) {
|
||||
clipContextToSelection( ctx, selectionPoints, isFillMode, sX - left, sY - top, overrides.scale );
|
||||
clipContextToSelection( ctx, selectionPoints, isFillMode, sX - left, sY - top, overrides );
|
||||
}
|
||||
}
|
||||
renderBrushStroke( ctx, this._brush, this, overrides );
|
||||
@@ -478,8 +473,9 @@ class LayerSprite extends sprite {
|
||||
|
||||
handleRelease( x, y ) {
|
||||
if ( this._brush.down ) {
|
||||
// brushing was active, deactivate brushing and render
|
||||
// brushing was active, deactivate brushing and render the
|
||||
// high resolution version of the brushed path onto the Layer source
|
||||
disposeTempCanvas();
|
||||
this.tempCanvas = null;
|
||||
this._brush.down = false;
|
||||
this._brush.last = 0;
|
||||
@@ -524,12 +520,7 @@ class LayerSprite extends sprite {
|
||||
|
||||
// sprite is currently brushing, render low resolution temp contents onto screen
|
||||
if ( this.tempCanvas ) {
|
||||
const { cvs } = this.tempCanvas;
|
||||
const scale = this.canvas.documentScale;
|
||||
documentContext.drawImage(
|
||||
cvs, 0, 0, cvs.width, cvs.height,
|
||||
-viewport.left, -viewport.top, cvs.width * scale, cvs.height * scale
|
||||
);
|
||||
renderTempCanvas( this.canvas, documentContext );
|
||||
}
|
||||
|
||||
// render brush outline at pointer position
|
||||
@@ -606,17 +597,6 @@ function scaleViewport( viewport, scale ) {
|
||||
return scaled;
|
||||
}
|
||||
|
||||
function clipContextToSelection( ctx, selectionPoints, isFillMode, offsetX, offsetY, scale = 1 ) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
selectionPoints.forEach(( point, index ) => {
|
||||
ctx[ index === 0 ? "moveTo" : "lineTo" ]( ( point.x - offsetX ) * scale, ( point.y - offsetY ) * scale );
|
||||
});
|
||||
if ( !isFillMode ) {
|
||||
ctx.clip();
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE we use getSpriteForLayer() instead of passing the Sprite by reference
|
||||
// as it is possible the Sprite originally rendering the Layer has been disposed
|
||||
// and a new one has been created while traversing the change history
|
||||
|
||||
50
src/rendering/clipping.js
Normal file
50
src/rendering/clipping.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Igor Zinken 2021 - https://www.igorski.nl
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prepares a clipping path corresponding to given selections outline, transformed
|
||||
* appropriately to the destination coordinates. NOTE: don't forget to restore the ctx when done.
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx destination context to clip
|
||||
* @param {Array<{{ x: Number, y: Number }}>} selectionPoints all coordinates within the selection
|
||||
* @param {boolean} useFloodFill whether selection will be filled using .fill() (requires different clipping preparation)
|
||||
* @param {Number} offsetX destination offset to shift selection by (bounds relative to viewport)
|
||||
* @param {Number} offsetY destination offset to shift selection by (bounds relative to viewport)
|
||||
* @param {Object=} overrideConfig optional override Object when workin in lowres preview mode
|
||||
*/
|
||||
export const clipContextToSelection = ( ctx, selectionPoints, useFloodFill, offsetX, offsetY, overrideConfig = null ) => {
|
||||
let scale = 1;
|
||||
let vpX = 0;
|
||||
let vpY = 0;
|
||||
if ( overrideConfig ) {
|
||||
({ scale, vpX, vpY } = overrideConfig );
|
||||
}
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
selectionPoints.forEach(( point, index ) => {
|
||||
ctx[ index === 0 ? "moveTo" : "lineTo" ]( (( point.x - offsetX ) * scale ) - vpX, (( point.y - offsetY ) * scale ) - vpY );
|
||||
});
|
||||
if ( !useFloodFill ) {
|
||||
ctx.clip();
|
||||
}
|
||||
};
|
||||
127
src/rendering/lowres.js
Normal file
127
src/rendering/lowres.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Igor Zinken 2021 - https://www.igorski.nl
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import { createCanvas } from "@/utils/canvas-util";
|
||||
|
||||
let tempCanvas;
|
||||
|
||||
/**
|
||||
* Lazily create / retrieve a low resolution canvas which can be used to render
|
||||
* low resolution content as a quick live preview measure.
|
||||
* The canvas will match the unzoomed viewport size.
|
||||
*/
|
||||
export const getTempCanvas = zoomableCanvas => {
|
||||
const { width, height } = zoomableCanvas._viewport;
|
||||
if ( !tempCanvas ) {
|
||||
tempCanvas = createCanvas();
|
||||
}
|
||||
setDimensions( width, height );
|
||||
return tempCanvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the contents of the tempCanvas onto given destinationContext
|
||||
* using the scaling properties corresponding to given zoomableCanvas
|
||||
*/
|
||||
export const renderTempCanvas = ( zoomableCanvas, destinationContext ) => {
|
||||
const { cvs } = tempCanvas;
|
||||
const scale = zoomableCanvas.documentScale;
|
||||
destinationContext.drawImage(
|
||||
cvs, 0, 0, cvs.width, cvs.height,
|
||||
0, 0, cvs.width * scale, cvs.height * scale
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispose the current tempCanvas instance. It won't actually be removed, but
|
||||
* remains pooled for further use. It's size will however be reduced to
|
||||
* shrink its memory footprint.
|
||||
*/
|
||||
export const disposeTempCanvas = () => {
|
||||
if ( tempCanvas ) {
|
||||
setDimensions( 1, 1 );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate all pointers within a brush stroke for low res preview rendering.
|
||||
* This creates a deep copy of the pointers, leaving the original list unchanged.
|
||||
* This can be called in rendering iterations by supplying a positive value for
|
||||
* last (which indicates the offset of the last rendered pointer).
|
||||
*
|
||||
* @param {Object} brush
|
||||
* @return {Array<{{ x: Number, y:Number }}>}
|
||||
*/
|
||||
export const translatePointers = brush => {
|
||||
const { pointers, last } = brush;
|
||||
return JSON.parse( JSON.stringify( pointers.slice( pointers.length - ( pointers.length - last ) - 1 )));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create override configuration for a render operation, adapting its
|
||||
* source input (e.g. pointers list) to scaling and coordinate space
|
||||
* of low res preview.
|
||||
*
|
||||
* @param {ZoomableCanvas} zoomableCanvas
|
||||
* @param {Number} x layer X offset
|
||||
* @param {Number} y layer Y offset
|
||||
* @param {Array<{{ x: Number, y:Number }}>} pointers
|
||||
* @return {Object}
|
||||
*/
|
||||
export const createOverrideConfig = ( zoomableCanvas, x, y, pointers ) => ({
|
||||
scale : 1 / zoomableCanvas.documentScale,
|
||||
zoom : zoomableCanvas.zoomFactor,
|
||||
vpX : zoomableCanvas._viewport.left,
|
||||
vpY : zoomableCanvas._viewport.top,
|
||||
x,
|
||||
y,
|
||||
pointers,
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a low res override configuration onto given point.
|
||||
* NOTE: This will mutate the original instance. Only use on entries
|
||||
* of translatePointers()
|
||||
*
|
||||
* @param {Object} overrideConfig
|
||||
* @param {{ x: Number, y: Number }} point coordinate to transform
|
||||
*/
|
||||
export const applyOverrideConfig = ( overrideConfig, point ) => {
|
||||
const { x, y, vpX, vpY, scale } = overrideConfig;
|
||||
|
||||
// correct for low res scaling
|
||||
|
||||
point.x = ( point.x + x ) * scale;
|
||||
point.y = ( point.y + y ) * scale;
|
||||
|
||||
// correct for viewport offset
|
||||
|
||||
point.x -= vpX;
|
||||
point.y -= vpY;
|
||||
};
|
||||
|
||||
/* internal methods */
|
||||
|
||||
function setDimensions( width, height ) {
|
||||
tempCanvas.cvs.width = width;
|
||||
tempCanvas.cvs.height = height;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { isEqual as isTextEqual } from "@/factories/text-factory";
|
||||
import { createCanvas, cloneCanvas } from "@/utils/canvas-util";
|
||||
import { replaceLayerSource } from "@/utils/layer-util";
|
||||
import { fastRound, getRotatedSize, getRotationCenter } from "@/math/image-math";
|
||||
import { hasLayerCache, getLayerCache, setLayerCache } from "@/services/caches/bitmap-cache";
|
||||
import { hasLayerCache, getLayerCache, setLayerCache } from "@/rendering/cache/bitmap-cache";
|
||||
import { loadGoogleFont } from "@/services/font-service";
|
||||
import FilterWorker from "@/workers/filter.worker";
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import BrushTypes from "@/definitions/brush-types";
|
||||
import { createDrawable } from "@/factories/brush-factory";
|
||||
import { distanceBetween, angleBetween, pointBetween, translatePointerRotation } from "@/math/point-math";
|
||||
import { randomInRange } from "@/math/unit-math";
|
||||
import { applyOverrideConfig } from "@/rendering/lowres";
|
||||
import { createCanvas, resizeImage } from "@/utils/canvas-util";
|
||||
|
||||
const { cos, sin } = Math;
|
||||
@@ -50,16 +51,16 @@ export const renderCross = ( ctx, x, y, size ) => {
|
||||
* @param {CanvasRenderingContext2D} ctx to render on
|
||||
* @param {Object} brush properties
|
||||
* @param {zCanvas.sprite} sprite defining relative (on-screen) Layer coordinates
|
||||
* @param {Object=} optional override to use (defines alternate pointers and coordinate scaling)
|
||||
* @param {Object=} overrideConfig optional override to use (defines alternate pointers and coordinate scaling)
|
||||
*/
|
||||
export const renderBrushStroke = ( ctx, brush, sprite, optOverride ) => {
|
||||
export const renderBrushStroke = ( ctx, brush, sprite, overrideConfig ) => {
|
||||
let { pointers, radius, halfRadius, doubleRadius, options } = brush;
|
||||
let scale = 1;
|
||||
const { type } = options;
|
||||
|
||||
if ( optOverride ) {
|
||||
pointers = optOverride.pointers;
|
||||
scale = optOverride.zoom;
|
||||
if ( overrideConfig ) {
|
||||
pointers = overrideConfig.pointers;
|
||||
scale = overrideConfig.zoom;
|
||||
radius *= scale;
|
||||
halfRadius *= scale;
|
||||
doubleRadius *= scale;
|
||||
@@ -77,13 +78,13 @@ export const renderBrushStroke = ( ctx, brush, sprite, optOverride ) => {
|
||||
const prevPoint = pointers[ i - 1 ];
|
||||
const point = pointers[ i ];
|
||||
|
||||
if ( optOverride ) {
|
||||
// apply the configuration Object, when supplied
|
||||
|
||||
if ( overrideConfig ) {
|
||||
if ( isFirst ) {
|
||||
prevPoint.x = ( prevPoint.x + optOverride.x ) * optOverride.scale;
|
||||
prevPoint.y = ( prevPoint.y + optOverride.y ) * optOverride.scale;
|
||||
applyOverrideConfig( overrideConfig, prevPoint );
|
||||
}
|
||||
point.x = ( point.x + optOverride.x ) * optOverride.scale;
|
||||
point.y = ( point.y + optOverride.y ) * optOverride.scale;
|
||||
applyOverrideConfig( overrideConfig, point );
|
||||
}
|
||||
|
||||
// paint brush types
|
||||
@@ -153,7 +154,7 @@ export const renderBrushStroke = ( ctx, brush, sprite, optOverride ) => {
|
||||
}
|
||||
|
||||
// this one benefits from working with a large point queue
|
||||
// as such when supplying optOverride for live rendering, regular line is drawn instead
|
||||
// as such when supplying overrideConfig for live rendering, regular line is drawn instead
|
||||
|
||||
if ( type === BrushTypes.CONNECTED ) {
|
||||
if ( isFirst ) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getLayerCache, setLayerCache, hasLayerCache, clearCacheProperty,
|
||||
flushLayerCache, flushCache
|
||||
} from "@/services/caches/bitmap-cache";
|
||||
} from "@/rendering/cache/bitmap-cache";
|
||||
import LayerFactory from "@/factories/layer-factory";
|
||||
|
||||
jest.mock( "@/utils/canvas-util", () => ({}));
|
||||
Reference in New Issue
Block a user