Implement action queue for deferred paint rendering, use low resolution paint preview during paint

This commit is contained in:
Igor Zinken
2021-01-17 23:35:14 +01:00
committed by GitHub
parent 4017872468
commit cc10c2dcaa
8 changed files with 290 additions and 165 deletions

View File

@@ -79,7 +79,7 @@ export default {
return [
{ text: this.$t( "line" ), value: BrushTypes.LINE },
{ text: this.$t( "pen" ), value: BrushTypes.PEN },
// { text: this.$t( "curvedPen" ), value: BrushTypes.CURVED_PEN },
{ text: this.$t( "curvedPen" ), value: BrushTypes.CURVED_PEN },
{ text: this.$t( "calligraphic" ), value: BrushTypes.CALLIGRAPHIC },
{ text: this.$t( "paintBrush" ), value: BrushTypes.PAINT_BRUSH },
{ text: this.$t( "sprayCan" ), value: BrushTypes.SPRAY }

View File

@@ -76,7 +76,7 @@ class InteractionPane extends sprite {
zCanvas.getHeight() / zCanvas.zoomFactor
);
if ( mode === MODE_SELECTION ) {
if ( document && mode === MODE_SELECTION ) {
if ( !document.selection ) {
this.setSelection( [] );
}

View File

@@ -94,14 +94,15 @@ class LayerSprite extends sprite {
cacheBrush( color = "rgba(255,0,0,1)", toolOptions = { radius: 5, strokes: 1 } ) {
this._brush = BrushFactory.create({
color,
radius: toolOptions.size,
pointer: this._brush.pointer,
options: toolOptions
radius : toolOptions.size,
pointers : this._brush.pointers,
options : toolOptions
});
}
storeBrushPointer( x, y ) {
this._brush.pointer = { x: x - this._bounds.left, y: y - this._bounds.top };
this._brush.down = true;
this._brush.pointers.push({ x: x - this._bounds.left, y: y - this._bounds.top });
}
cacheEffects() {
@@ -128,6 +129,10 @@ class LayerSprite extends sprite {
this.setInteractive( this.layer === activeLayer );
}
resetSelection() {
this._selection = null;
}
handleActiveTool( tool, toolOptions, activeDocument ) {
this.isDragging = false;
this._isPaintMode = false;
@@ -194,10 +199,11 @@ class LayerSprite extends sprite {
this.preparePendingPaintState();
}
const { left, top } = this._bounds;
// translate pointer to translated space, when layer is rotated or mirrored
const { mirrorX, mirrorY, rotation } = this.layer.effects;
const rotCenterX = this._bounds.left + this._bounds.width / 2;
const rotCenterY = this._bounds.top + this._bounds.height / 2;
const rotCenterX = left + this._bounds.width / 2;
const rotCenterY = top + this._bounds.height / 2;
if ( this.isRotated() ) {
({ x, y } = translatePointerRotation( x, y, rotCenterX, rotCenterY, rotation ));
@@ -208,7 +214,7 @@ class LayerSprite extends sprite {
const isFillMode = this._toolType === ToolTypes.FILL;
// get the drawing context
const ctx = ( drawOnMask ? this.layer.mask : this.layer.source ).getContext( "2d" );
let ctx = ( drawOnMask ? this.layer.mask : this.layer.source ).getContext( "2d" );
const { width, height } = ctx.canvas;
ctx.save();
@@ -228,11 +234,11 @@ class LayerSprite extends sprite {
y -= this.layer.y;
// if there is an active selection, painting will be constrained within
if ( this._selection ) {
let selectionPoints = this._selection;
let sX = this._bounds.left;
let sY = this._bounds.top;
let selectionPoints = this._selection;
let sX, sY;
if ( selectionPoints ) {
sX = left;
sY = top;
if ( this.isRotated() ) {
selectionPoints = rotatePoints( selectionPoints, rotCenterX, rotCenterY, rotation );
const rect = getRectangleForSelection( selectionPoints );
@@ -242,13 +248,7 @@ class LayerSprite extends sprite {
sX = 0;//pts.x;
sY = 0;//pts.y;
}
ctx.beginPath();
selectionPoints.forEach(( point, index ) => {
ctx[ index === 0 ? "moveTo" : "lineTo" ]( point.x - sX, point.y - sY );
});
if ( !isFillMode ) {
ctx.clip();
}
clipContextToSelection( ctx, selectionPoints, isFillMode, sX, sY );
}
// transform destination context in case the current layer is rotated or mirrored
@@ -257,7 +257,8 @@ class LayerSprite extends sprite {
ctx.rotate( rotation );
ctx.translate( -x, -y );
if ( isFillMode ) {
if ( isFillMode )
{
ctx.fillStyle = this.canvas?.store.getters.activeColor;
if ( this._selection ) {
ctx.fill();
@@ -265,22 +266,54 @@ class LayerSprite extends sprite {
} else {
ctx.fillRect( 0, 0, width, height );
}
} else {
}
else {
// TODO: when rotated and mirrored, x and y are now in right coordinate space, but not at right point
const destination = { x, y };
// 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 )));
if ( isCloneStamp ) {
renderClonedStroke(
ctx, this,
getSpriteForLayer({ id: this._toolOptions.sourceLayerId }), // TODO: fugly!!
this._brush, destination,
);
if ( this._brush.down ) {
renderClonedStroke(
ctx, this._brush, this, getSpriteForLayer({ id: this._toolOptions.sourceLayerId }), pointers
);
// clone operation is direct-to-Layer-source
this.setBitmap( ctx.canvas );
}
} else {
renderBrushStroke( this, this._brush, ctx, destination );
// brush operations are done on a lower resolution canvas during live update
// upon release, this will be rendered to the Layer source (see handleRelease())
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,
};
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 );
}
}
renderBrushStroke( ctx, this._brush, this, overrides );
}
}
ctx.restore();
this.resetFilterAndRecache();
// when brushing, defer recache of filters to handleRelease()
if ( !this._brush.down ) {
this.resetFilterAndRecache();
}
}
/**
@@ -423,20 +456,36 @@ class LayerSprite extends sprite {
}
}
// brush tool active (either draws/erases onto IMAGE_GRAPHIC layer source or on the mask bitmap)
if ( !!this._brush.pointer ) {
this.paint( x, y );
this.storeBrushPointer( x, y ); // update so next stroke starts from current position
// brush mode and brushing is active
if ( this._brush.down ) {
// enqueue current pointer position, painting of all enqueued pointers will be deferred
// to the update()-hook, this prevents multiple renders on each move event
this.storeBrushPointer( x, y );
}
}
handleRelease( x, y ) {
this._brush.pointer = null;
if ( this._brush.down ) {
// brushing was active, deactivate brushing and render
// high resolution version of the brushed path onto the Layer source
this.tempCanvas = null;
this._brush.down = false;
this._brush.last = 0;
this.paint( x, y );
this._brush.pointers = []; // pointers have been rendered, reset
}
if ( this._isPaintMode ) {
this.forceMoveListener(); // keeps the move listener active
}
}
update() {
if ( this._brush.down ) {
this.paint( this._pointerX, this._pointerY );
this._brush.last = this._brush.pointers.length;
}
}
draw( documentContext, viewport ) {
const scaleDocument = this.isScaled();
@@ -457,6 +506,16 @@ class LayerSprite extends sprite {
// invoke base class behaviour to render bitmap
super.draw( documentContext, viewport );
// 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
);
}
// render brush outline at pointer position
if ( this._isPaintMode ) {
const drawBrushOutline = this._toolType !== ToolTypes.CLONE || !!this._toolOptions.coords;
@@ -470,7 +529,7 @@ class LayerSprite extends sprite {
ty = ( coords.y - viewport.top ) + ( this._pointerY - relSource.y );
}
// when no source coordinate is set, or when applying the clone stamp, we show a cross to mark the origin
if ( !coords || this._brush.pointer ) {
if ( !coords || this._brush.down ) {
renderCross( documentContext, tx, ty, this._brush.radius / this.canvas.zoomFactor );
}
}
@@ -531,6 +590,17 @@ 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

View File

@@ -21,7 +21,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const BrushFactory = {
create({ radius = 10, color = "rgba(255,0,0,1)", pointer = null, options = {} } = {} ) {
create({ radius = 10, color = "rgba(255,0,0,1)", pointers = [], options = {} } = {} ) {
const [r, g, b, a] = color.split( "," );
const colors = [
color,
@@ -31,17 +31,18 @@ const BrushFactory = {
return {
radius,
colors,
pointer,
pointers,
options, // provided by tool-module
halfRadius : radius * 0.5,
doubleRadius : radius * 2
doubleRadius : radius * 2,
down : false
};
}
};
export default BrushFactory;
export const createDrawable = ( brush, ctx, x, y ) => {
const gradient = ctx.createRadialGradient( x, y, brush.halfRadius, x, y, brush.radius );
export const createDrawable = ( brush, ctx, x, y, scale = 1 ) => {
const gradient = ctx.createRadialGradient( x, y, brush.halfRadius * scale, x, y, brush.radius * scale );
gradient.addColorStop( 0, brush.colors[ 0 ]);
gradient.addColorStop( 0.5, brush.colors[ 1 ]);

View File

@@ -197,8 +197,9 @@ export default {
commit( "showNotification", { message: translate( "selectionCopied" ) });
dispatch( "clearSelection" );
},
clearSelection() {
clearSelection({ getters }) {
getCanvasInstance()?.interactionPane.resetSelection();
getSpriteForLayer( getters.activeLayer )?.resetSelection();
},
pasteSelection({ commit, getters, dispatch, state }) {
const selection = state.selectionContent;

View File

@@ -10,10 +10,9 @@
.select,
.vue-slider {
display: inline-block;
width: 50%;
width: 50% !important;
}
.vue-slider {
width: 50% !important;
vertical-align: middle;
}
input, textarea {

View File

@@ -43,110 +43,150 @@ export const renderCross = ( ctx, x, y, size ) => {
ctx.restore();
};
export const renderBrushStroke = ( sprite, brush, ctx, destinationPoint ) => {
const { pointer, radius, halfRadius, doubleRadius, options } = brush;
/**
* Render a series of registered pointer offset into a single brush stroke
* @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)
*/
export const renderBrushStroke = ( ctx, brush, sprite, optOverride ) => {
let { pointers, radius, halfRadius, doubleRadius, options } = brush;
let scale = 1;
const { type } = options;
// effects complicate proceedings https://github.com/igorski/bitmappery/issues/2
const { rotation, mirrorX, mirrorY } = sprite.layer.effects;
if ( rotation || mirrorX || mirrorY ) {
ctx.fillStyle = createDrawable( brush, ctx, destinationPoint.x, destinationPoint.y )
ctx.fillRect( destinationPoint.x - radius, destinationPoint.y - radius, doubleRadius, doubleRadius );
if ( optOverride ) {
pointers = optOverride.pointers;
scale = optOverride.zoom;
radius *= scale;
halfRadius *= scale;
doubleRadius *= scale;
}
if ( pointers.length < 2 ) {
return;
}
ctx.save();
ctx.lineJoin = ctx.lineCap = "round";
// paint brush types
for ( let i = 1; i < pointers.length; ++i ) {
const isFirst = i === 1;
const prevPoint = pointers[ i - 1 ];
const point = pointers[ i ];
if ( type === BrushTypes.PAINT_BRUSH ) {
const dist = distanceBetween( pointer, destinationPoint );
const angle = angleBetween( pointer, destinationPoint );
const incr = brush.radius * 0.25;
const sin = Math.sin( angle );
const cos = Math.cos( angle );
let x, y, size, doubleSize;
for ( let i = 0; i < dist; i += incr ) {
x = pointer.x + ( sin * i );
y = pointer.y + ( cos * i );
ctx.fillStyle = createDrawable( brush, ctx, x, y );
ctx.fillRect( x - radius, y - radius, doubleRadius, doubleRadius );
if ( optOverride ) {
if ( isFirst ) {
prevPoint.x = ( prevPoint.x + optOverride.x ) * optOverride.scale;
prevPoint.y = ( prevPoint.y + optOverride.y ) * optOverride.scale;
}
point.x = ( point.x + optOverride.x ) * optOverride.scale;
point.y = ( point.y + optOverride.y ) * optOverride.scale;
}
return ctx.restore();
}
if ( type === BrushTypes.SPRAY ) {
ctx.fillStyle = brush.colors[ 0 ];
for ( let i = doubleRadius; i--; ) {
const angle = randomInRange( 0, TWO_PI );
const size = randomInRange( 1, 3 );
ctx.fillRect(
destinationPoint.x + randomInRange( -halfRadius, halfRadius ) * cos( angle ),
destinationPoint.y + randomInRange( -halfRadius, halfRadius ) * sin( angle ),
size, size
);
// paint brush types
if ( type === BrushTypes.PAINT_BRUSH ) {
const dist = distanceBetween( prevPoint, point );
const angle = angleBetween( prevPoint, point );
const incr = radius * 0.25;
const sin = Math.sin( angle );
const cos = Math.cos( angle );
let x, y, size, doubleSize;
for ( let j = 0; j < dist; j += incr ) {
x = prevPoint.x + ( sin * j );
y = prevPoint.y + ( cos * j );
ctx.fillStyle = createDrawable( brush, ctx, x, y, scale );
ctx.fillRect( x - radius, y - radius, doubleRadius, doubleRadius );
}
continue;
}
return ctx.restore();
}
// line types
if ( type === BrushTypes.SPRAY ) {
ctx.fillStyle = brush.colors[ 0 ];
let j = doubleRadius;
while ( j-- > 0 ) {
const angle = randomInRange( 0, TWO_PI );
const size = randomInRange( 1, 3 );
ctx.fillRect(
point.x + randomInRange( -halfRadius, halfRadius ) * cos( angle ),
point.y + randomInRange( -halfRadius, halfRadius ) * sin( angle ),
size, size
);
}
continue;
}
ctx.lineWidth = brush.radius;
ctx.strokeStyle = brush.colors[ 0 ];
// line types
if ( type === BrushTypes.LINE ) {
ctx.beginPath();
ctx.moveTo( pointer.x, pointer.y );
ctx.lineTo( destinationPoint.x, destinationPoint.y );
ctx.stroke();
return ctx.restore();
}
ctx.strokeStyle = brush.colors[ 0 ];
if ( type === BrushTypes.CALLIGRAPHIC ) {
ctx.beginPath();
const min = ( brush.radius * 0.25 ) * 0.66666;
const max = ( brush.radius * 0.25 ) * 1.33333;
[ -max, -min, 0, min, max ].forEach( offset => {
ctx.moveTo( pointer.x + offset, pointer.y + offset );
ctx.lineTo( destinationPoint.x + offset, destinationPoint.y + offset );
if ( type === BrushTypes.LINE ) {
if ( isFirst ) {
ctx.lineWidth = radius;
ctx.beginPath();
ctx.moveTo( prevPoint.x, prevPoint.y );
}
ctx.lineTo( point.x, point.y );
ctx.stroke();
});
return ctx.restore();
}
// multi stroke line types
let dX = 0;
let dY = 0;
for ( let i = 0; i < options.strokes; ++i ) {
switch ( type ) {
default:
case BrushTypes.PEN:
ctx.beginPath();
ctx.lineWidth = ( brush.radius * 0.2 ) * randomInRange( 0.5, 1 );
ctx.moveTo( pointer.x - dX, pointer.y - dY );
ctx.lineTo( destinationPoint.x - dX, destinationPoint.y - dY );
ctx.stroke();
break;
// TODO: this one benefits from working with a large point queue
case BrushTypes.CURVED_PEN:
ctx.beginPath();
ctx.moveTo( pointer.x, pointer.y );
const midPoint = pointBetween( pointer, destinationPoint );
ctx.quadraticCurveTo( pointer.x, pointer.y, midPoint.x, midPoint.y );
ctx.lineTo( destinationPoint.x, destinationPoint.y );
ctx.stroke();
break;
continue;
}
if ( type === BrushTypes.CALLIGRAPHIC ) {
if ( isFirst ) {
ctx.lineWidth = halfRadius;
ctx.beginPath();
}
const min = ( radius * 0.25 ) * 0.66666;
const max = ( radius * 0.25 ) * 1.33333;
[ -max, -min, 0, min, max ].forEach( offset => {
ctx.moveTo( prevPoint.x + offset, prevPoint.y + offset );
ctx.lineTo( point.x + offset, point.y + offset );
ctx.stroke();
});
continue;
}
// this one benefits from working with a large point queue
if ( type === BrushTypes.CURVED_PEN ) {
if ( isFirst ) {
ctx.lineWidth = radius;
ctx.beginPath();
ctx.moveTo( prevPoint.x, prevPoint.y );
}
const midPoint = pointBetween( prevPoint, point );
ctx.quadraticCurveTo( prevPoint.x, prevPoint.y, midPoint.x, midPoint.y );
if ( i === pointers.length - 1 ) {
ctx.lineTo( point.x, point.y );
ctx.stroke();
}
continue;
}
// multi stroke line type
let dX = 0
let dY = 0;
for ( let j = 0; j < options.strokes; ++j ) {
switch ( type ) {
default:
case BrushTypes.PEN:
if ( isFirst && j === 0 ) {
ctx.beginPath();
ctx.lineWidth = ( radius * 0.2 ) * randomInRange( 0.5, 1 );
}
ctx.moveTo( prevPoint.x - dX, prevPoint.y - dY );
ctx.lineTo( point.x - dX, point.y - dY );
ctx.stroke();
break;
}
dX += randomInRange( 0, ctx.lineWidth );
dY += randomInRange( 0, ctx.lineWidth );
}
dX += randomInRange( 0, ctx.lineWidth );
dY += randomInRange( 0, ctx.lineWidth );
}
ctx.restore();
};
@@ -155,47 +195,59 @@ export const renderBrushStroke = ( sprite, brush, ctx, destinationPoint ) => {
* Masks the contents of given source using given brushCvs, and renders the result onto given destContext.
* Used by clone stamp tool.
*
* @param {CanvasRenderingContext2D} destContext
* @param {zCanvas.sprite} sprite
* @param {zCanvas.sprite} sourceSprite containing the bitmap to mask (see getBitmap())
* @param {CanvasRenderingContext2D} destContext context to render on
* @param {Object} brush operation to use
* @param {{ x: number, y:number }} destinationPoint coordinate relative to given destContext
* @param {zCanvas.sprite} sprite containg the relative (on-screen) Layer coordinates
* @param {zCanvas.sprite} sourceSprite containing the bitmap to mask (see getBitmap())
* @param {Array<{{ x: Number, y: Number }}>=} optPointers optional Array of alternative coordinates
*/
export const renderClonedStroke = ( destContext, sprite, sourceSprite, brush, destinationPoint ) => {
const maskRadius = brush.radius;
export const renderClonedStroke = ( destContext, brush, sprite, sourceSprite, optPointers ) => {
if ( !sourceSprite ) {
return;
}
const { coords, opacity } = sprite._toolOptions;
const source = sourceSprite.getBitmap();
const relSource = sprite._cloneStartCoords ?? sprite._dragStartEventCoordinates;
const { radius, doubleRadius, options } = brush;
const { type } = options;
const pointers = optPointers || brush.pointers;
const sourceX = ( coords.x - sourceSprite.getX()) - maskRadius;
const sourceY = ( coords.y - sourceSprite.getY()) - maskRadius;
const xDelta = sprite._dragStartOffset.x + (( destinationPoint.x - sprite._bounds.left ) - relSource.x );
const yDelta = sprite._dragStartOffset.y + (( destinationPoint.y - sprite._bounds.top ) - relSource.y );
const source = sourceSprite.getBitmap();
const sourceX = ( coords.x - sourceSprite.getX()) - radius;
const sourceY = ( coords.y - sourceSprite.getY()) - radius;
const relSource = sprite._cloneStartCoords || sprite._dragStartEventCoordinates;
// prepare temporary canvas (match size with brush)
const { cvs, ctx } = tempCanvas;
cvs.width = brush.doubleRadius;
cvs.height = brush.doubleRadius;
cvs.width = doubleRadius;
cvs.height = doubleRadius;
ctx.globalAlpha = opacity;
for ( let i = 0; i < pointers.length; ++i ) {
const destinationPoint = pointers[ i ];
// draw source bitmap data onto temporary canvas
ctx.drawImage(
source, sourceX + xDelta, sourceY + yDelta, maskRadius, maskRadius,
0, 0, maskRadius, maskRadius
);
const xDelta = sprite._dragStartOffset.x + (( destinationPoint.x - sprite._bounds.left ) - relSource.x );
const yDelta = sprite._dragStartOffset.y + (( destinationPoint.y - sprite._bounds.top ) - relSource.y );
// draw the brush above the bitmap, keeping only the overlapping area
ctx.globalCompositeOperation = "destination-in";
// note we use the brush.pointer as the destination too
renderBrushStroke( sprite, brush, ctx, brush.pointer );
// draw source bitmap data onto temporary canvas
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = opacity;
// draw the masked result onto the destination canvas
destContext.drawImage(
cvs, 0, 0, maskRadius, maskRadius,
destinationPoint.x - maskRadius, destinationPoint.y - maskRadius, maskRadius, maskRadius
);
ctx.clearRect( 0, 0, cvs.width, cvs.height );
ctx.drawImage(
source, sourceX + xDelta, sourceY + yDelta, radius, radius, 0, 0, radius, radius
);
// draw the brush above the bitmap, keeping only the overlapping area
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = createDrawable( brush, ctx, 0, 0 );
ctx.fillRect( 0, 0, radius, radius );//point.x - radius, point.y - radius, doubleRadius, doubleRadius );
// draw the masked result onto the destination canvas
destContext.drawImage(
cvs, 0, 0, radius, radius,
destinationPoint.x - radius, destinationPoint.y - radius, radius, radius
);
}
};
export const resizeLayerContent = async ( layer, ratioX, ratioY ) => {

View File

@@ -12,7 +12,8 @@ describe( "Brush factory", () => {
"rgba(255,0,0,0)"
],
options: {},
pointer: null,
pointers: [],
down: false,
});
});
@@ -20,7 +21,7 @@ describe( "Brush factory", () => {
expect( BrushFactory.create({
radius: 20,
color: "rgba(128,123,686,.75)",
pointer: { x: 10, y: 20 },
pointers: [{ x: 10, y: 20 }],
options: { size: 10 }
})).toEqual({
radius: 20,
@@ -31,8 +32,9 @@ describe( "Brush factory", () => {
"rgba(128,123,686,0.375)",
"rgba(128,123,686,0)"
],
pointer: { x: 10, y: 20 },
options: { size: 10 }
pointers: [{ x: 10, y: 20 }],
options: { size: 10 },
down: false,
});
});
});