Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(): optimize perPixelTargetFind #8770

Merged
merged 34 commits into from
Mar 19, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8036930
perf(): `perPixelTargetFind`
ShaMan123 Mar 9, 2023
34b0e17
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
04efeec
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
6bbe23a
Update canvas.js
ShaMan123 Mar 9, 2023
d21ee1b
Revert "Update SelectableCanvas.ts"
ShaMan123 Mar 9, 2023
5f98acb
m
ShaMan123 Mar 9, 2023
04ae878
fix!
ShaMan123 Mar 9, 2023
a3a1123
cleanup and safegurad
ShaMan123 Mar 9, 2023
22ffc9f
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
2c60600
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
d7ee90a
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
a6bb89a
Update CHANGELOG.md
ShaMan123 Mar 9, 2023
815a743
clean
ShaMan123 Mar 9, 2023
a91831d
Update canvas.js
ShaMan123 Mar 9, 2023
096eadb
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
04d471f
Update SelectableCanvas.ts
ShaMan123 Mar 9, 2023
a97e51d
comment
ShaMan123 Mar 9, 2023
7caef9e
apply code review
ShaMan123 Mar 12, 2023
d5ed5bf
Merge branch 'master' into perf-pixel-detection
ShaMan123 Mar 12, 2023
03e8019
tests
ShaMan123 Mar 12, 2023
593f760
fixes
ShaMan123 Mar 12, 2023
a94ed32
Update SelectableCanvas.ts
ShaMan123 Mar 12, 2023
de87f9f
fix retina
ShaMan123 Mar 12, 2023
f8f02d2
Update isTransparent.ts
ShaMan123 Mar 12, 2023
a1bf0e3
better bounds logic
ShaMan123 Mar 12, 2023
3593d05
tests
ShaMan123 Mar 12, 2023
84a0a2b
dump cache opt
ShaMan123 Mar 12, 2023
a81cf72
revert uglyness
ShaMan123 Mar 12, 2023
ee441ba
Merge branch 'master' into perf-pixel-detection
asturur Mar 19, 2023
22fa5a3
perf-pixel-detection take 2 (#8790)
asturur Mar 19, 2023
a8c6251
no new point methods
asturur Mar 19, 2023
ae30ec4
add more tolerance
asturur Mar 19, 2023
c6794b7
less
asturur Mar 19, 2023
9d4b3e5
less
asturur Mar 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [next]

- perf(): optimize `perPixelTargetFind` [#8770](https://github.com/fabricjs/fabric.js/pull/8770)
- ci(): disallow circular deps [#8759](https://github.com/fabricjs/fabric.js/pull/8759)
- fix(): env WebGL import cycle [#8758](https://github.com/fabricjs/fabric.js/pull/8758)
- chore(TS): remove controls from prototype. BREAKING: controls aren't shared anymore [#8753](https://github.com/fabricjs/fabric.js/pull/8753)
Expand Down
78 changes: 56 additions & 22 deletions src/canvas/SelectableCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ export class SelectableCanvas<
declare upperCanvasEl: HTMLCanvasElement;
declare contextTop: CanvasRenderingContext2D;
declare wrapperEl: HTMLDivElement;
declare cacheCanvasEl: HTMLCanvasElement;
declare pixelFindCanvasEl: HTMLCanvasElement;
protected declare _isCurrentlyDrawing: boolean;
declare freeDrawingBrush?: BaseBrush;
declare _activeObject?: FabricObject;
Expand Down Expand Up @@ -667,10 +667,23 @@ export class SelectableCanvas<
* @return {Boolean}
*/
isTargetTransparent(target: FabricObject, x: number, y: number): boolean {
const ctx = this.contextCache;
const retina = this.getRetinaScaling();
const size = Math.max(2 * this.targetFindTolerance * retina, 10);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you max it out?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would rather have a method setTargetFindTolerance that creates this mini canvas, i m not even sure retina scaling is a requirement since the mouse pixels ( the so called css pixels ) are always without retina.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would rather have a method setTargetFindTolerance that creates this mini canvas,

will do

I wasn't sure about retina. What will happen in case of ay scaled and blurry image? Will the hit test return a false positive?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I removed the max value of 10

if (this.pixelFindCanvasEl.width < size) {
// TODO: handle in set
this.pixelFindCanvasEl.width = this.pixelFindCanvasEl.height = size;
} else {
this.clearContext(ctx);
}
// in case the target is the activeObject, we cannot execute this optimization
// because we need to draw controls too.
if (isFabricObjectCached(target) && target !== this._activeObject) {
// optimizatio: we can reuse the cache
if (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it happened that i thought a couple of times of this:

// in case the target is the activeObject, we cannot execute this optimization
    // because we need to draw controls too.

And then i asked myself, why do i have to draw the controls?
The controls can be found with the coordinates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absolutely
I had no idea what was that.
And I think active selection never caches, or at least it didn't in v5. Now I don't remember.

isFabricObjectCached(target) &&
target !== this._activeObject &&
!target.dirty
) {
// optimization: we can reuse the cache
const normalizedPointer = this._normalizePointer(target, new Point(x, y)),
targetRelativeX = Math.max(
target.cacheTranslationX + normalizedPointer.x * target.zoomX,
Expand All @@ -680,31 +693,46 @@ export class SelectableCanvas<
target.cacheTranslationY + normalizedPointer.y * target.zoomY,
0
);
// transform tolerance according to vpt
// TODO: use sendVectorToPlane
const tolerance = new Point()
.scalarAdd(this.targetFindTolerance)
.transform(invertTransform(this.viewportTransform), true);
const size = tolerance.scalarMultiply(2).max(new Point(1, 1));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i need to think about this. this put ourselves in the case of transforming the tolerance with the vpt when what we want is really just canvas pixels. So if there is a small text or a thin line and the zoom level makes it super thin, we were not composensating for this, if the line was so thinned by the zoom out that was nearly transparent, we would consider it transparent before.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Shaman can you share the app code used to test this so i can reproduce the testing case? i m curious about trying a couple of things.

Copy link
Contributor Author

@ShaMan123 ShaMan123 Mar 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method accepts a global pointer, living in the viewport, so 1 is the is perceived as 1 by the user, not by the canvas.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes 1 viewport pixel. 1 of what are you looking at in that moment and also 1 of how much you can move your mouse.
The need to use the max between scalarMultiply and 1, and the invert of the viewportTransform is something you added, i think it change the meaning of what tolerance does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I fixed it because previously it was checking tolerance in the canvas plane and not in the viewport. I will add a dedicated test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app... I will share a link?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i made mine, i had this time to work on it and i just played a bit
image

I made this canvas where i can see what is in the pixelFind canvas in realtime, as soon as i m done highlighting the difference i can push up the branch

// performance optimization:
// we draw the hit area to the dedicated canvas instead of using `getImageData` on the target's cache canvas
// since `size` is transformed according to vpt the image is drawn as if transformed as well so `targetFindTolerance` can be used as the tolerance value
ctx.drawImage(
target._cacheCanvas,
Math.floor(targetRelativeX - tolerance.x),
Math.floor(targetRelativeY - tolerance.y),
Math.ceil(size.x),
Math.ceil(size.y),
0,
0,
Math.ceil(size.x * retina),
Math.ceil(size.y * retina)
);
ShaMan123 marked this conversation as resolved.
Show resolved Hide resolved

return isTransparent(
target._cacheContext,
Math.round(targetRelativeX),
Math.round(targetRelativeY),
ctx,
tolerance.x,
tolerance.y,
this.targetFindTolerance
);
}

const ctx = this.contextCache,
originalColor = target.selectionBackgroundColor,
v = this.viewportTransform;

target.selectionBackgroundColor = '';

this.clearContext(ctx);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

who is clearing the context now between different checks? and where the selectionBackgroundColor handling code went?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will restore selection background...
The context must be scaled because we set dimensions of canvas as well and never re init retina, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure. i m building a little demo based on this concept so we can talk with examples.
A fun diversion from types/tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fun diversion from types/tests

cool

Since you merged the sandbox pr just run
npm run sandbox start -- -t next [path/to/folder or nothing]
if you provide a path the app will build there, if not it will build on the template. Which is fine as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the deployed version
https://codesandbox.io/p/sandbox/fabric-nextjs-sandbox-forked-k53evn?welcome=true&file=%2FREADME.md

Can you look at the sandbox github app and see it can operate in the repo? It should post links for us to reproduce with ease using the templates I built that are now merged into master

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app I posted is nothing really, just objects on canvas using the next template

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will look into this sandbox as soon as i can.
The goal is that for each PR there is a sandbox with the branch built?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct


ctx.save();
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
ctx.translate(-x + this.targetFindTolerance, -y + this.targetFindTolerance);
ctx.transform(...this.viewportTransform);
target.render(ctx);
ctx.restore();

target.selectionBackgroundColor = originalColor;

return isTransparent(ctx, x, y, this.targetFindTolerance);
return isTransparent(
ctx,
this.targetFindTolerance,
this.targetFindTolerance,
this.targetFindTolerance
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at this again shouldn't it be multiplied by retina?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my branch i completely removed retina from the logic of target transparent.
I don't think it should be involved at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it is needed for precision in case of blur

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

considering retina makes the cached/noncached case very similar and cut differences there.
But force us to continuosly round numbers

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That isn't too bad, is it?
I am thinking that this feature is used with text and text is heavily impacted by retina

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes i think it will win the smaller code at the end.
traget transparency isn't meant to be a pixel sniper effect. It has just to guess if is completely transparent or not.

);
ShaMan123 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -1195,7 +1223,6 @@ export class SelectableCanvas<
_setBackstoreDimension(prop: keyof TSize, value: number) {
super._setBackstoreDimension(prop, value);
this.upperCanvasEl[prop] = value;
this.cacheCanvasEl[prop] = value;
}

/**
Expand Down Expand Up @@ -1237,8 +1264,15 @@ export class SelectableCanvas<
}

protected _createCacheCanvas() {
this.cacheCanvasEl = this._createCanvasElement();
this.contextCache = this.cacheCanvasEl.getContext('2d')!;
this.pixelFindCanvasEl = this._createCanvasElement();
this.contextCache = this.pixelFindCanvasEl.getContext('2d', {
willReadFrequently: true,
})!;
const retina = this.getRetinaScaling();
this.contextCache.scale(retina, retina);
this.pixelFindCanvasEl.width = this.pixelFindCanvasEl.height = Math.ceil(
Math.max(2 * this.targetFindTolerance * retina, retina)
);
}

protected _initWrapperElement() {
Expand Down
40 changes: 12 additions & 28 deletions src/util/misc/isTransparent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { capValue } from './capValue';

/**
* Returns true if context has transparent pixel
* at specified location (taking tolerance into account)
Expand All @@ -13,39 +15,21 @@ export const isTransparent = (
y: number,
tolerance: number
): boolean => {
// If tolerance is > 0 adjust start coords to take into account.
// If moves off Canvas fix to 0
if (tolerance > 0) {
if (x > tolerance) {
x -= tolerance;
} else {
x = 0;
}
if (y > tolerance) {
y -= tolerance;
} else {
y = 0;
}
}

let _isTransparent = true;
tolerance = Math.round(Math.max(tolerance, 0));
const sx = Math.floor(Math.max(x - tolerance, 0));
const sy = Math.floor(Math.max(y - tolerance, 0));
const { data } = ctx.getImageData(
x,
y,
tolerance * 2 || 1,
tolerance * 2 || 1
sx,
sy,
capValue(1, tolerance * 2, ctx.canvas.width - sx),
capValue(1, tolerance * 2, ctx.canvas.height - sy)
);
const l = data.length;

// Split image data - for tolerance > 1, pixelDataSize = 4;
for (let i = 3; i < l; i += 4) {
for (let i = 3; i < data.length; i += 4) {
const alphaChannel = data[i];
if (alphaChannel > 0) {
// Stop if colour found
_isTransparent = false;
break;
return false;
}
}

return _isTransparent;
return true;
};
Loading