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(): reduce setCoords calls #9550

Closed
wants to merge 4 commits into from
Closed

perf(): reduce setCoords calls #9550

wants to merge 4 commits into from

Conversation

ShaMan123
Copy link
Contributor

@ShaMan123 ShaMan123 commented Dec 17, 2023

Description

After removing lineCoords we have 2 sets of coords
Corner coords (aCoords) used for object geometry (isOnScreen, intersection etc.) that belong to the parent plane, getCoords returning them in the scene plane.
Control coords (oCoords) used for control rendering/hit detection that belong to the viewport plane.

This distinction is very important and useful.
It means that viewport changes do not affect corner coords.
Also since they belong to the parent plane they need to be recalculated only if the object itself changes its own transform/size. This means that if an ancestor changes transform we do not need to recalculate the corner coords.
Since control coords exist only on the active object it seems important to separate use cases of setCoords to reduce computation.

I have found that fabric used to call setCoords before drawing the controls (at the end of the rendering cycle) instead of after a transform action. This led to a stale coords state between the 2 calls affecting apps using corner controls in the rendering cycle. It was always a frame behind. This is fixed now.

Looking into setCoords in general it seems we should decide when they are first set. It is ambiguous.
Does it happen when an object mounts the canvas? What about an object added to a group? We can check if the group has a canvas set, therefore it is mounted , therefore setCoords should be called.
It also means that object geometry should not be used until an object is mounted.

This PR is part of a performance effort I am looking into.
I have a ground breaking idea about a binary index that will reduce computation cost of isOnScreen exponentially.
I wish to contribute it to the repo but can't wait with it more than a number of days so if it can't merge soon I will depart from fabric (at least with this) or try to expose it as a standalone but that will be a shame.

Another change I have introduced is interactive including subTargetCheck when true.
To be able to select sub targets both need to be flagged as true.
subTargetCheck flags searchPossibleTargets to traverse the children of a group.
interactive flags to return the top most sub target found within searchPossibleTargets.
So essentially interactive = true contains subTargetCheck = true.
It is more straight forward as well.

In Action

Copy link
Contributor

github-actions bot commented Dec 17, 2023

Build Stats

file / KB (diff) bundled minified
fabric 914.791 (-0.642) 305.343 (-0.295)

Copy link
Contributor Author

@ShaMan123 ShaMan123 left a comment

Choose a reason for hiding this comment

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

I also made Group#interactive include subTargetCheck logically so now it is enough to

new Group(objects, { interactive: true })

This will not change anything else.

There is a decision to make and then I can complete this PR

Comment on lines -1404 to -1350
// hoverCursor should come from top-most subTarget,
// so we walk the array backwards
Copy link
Contributor Author

Choose a reason for hiding this comment

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

wrong

Copy link
Member

Choose a reason for hiding this comment

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

What is wrong exactly?

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 order of targets is [topMost, ..., bottomMost]?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorted in descending z index order, top most object first

@@ -190,6 +190,7 @@ export class SelectableCanvas<EventSpec extends CanvasEvents = CanvasEvents>

/**
* Keep track of the subTargets for Mouse Events
* Sorted in descending z index order, top most object first
Copy link
Contributor Author

Choose a reason for hiding this comment

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

right

@@ -101,6 +106,7 @@ export class ActiveSelection extends Group {
// the object will be in the objects array of both the ActiveSelection and the Group
// but referenced in the group's _activeObjects so that it won't be rendered twice.
this._enterGroup(object, removeParentTransform);
object.setCoords();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

needs a mounted decision

@@ -511,7 +511,6 @@ export class InteractiveFabricObject<
ctx.strokeStyle = options.cornerStrokeColor;
}
this._setLineDash(ctx, options.cornerDashArray);
this.setCoords();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

bad approach, leading to unnecessary computation and a stale state

.vscode/settings.json Outdated Show resolved Hide resolved
src/brushes/SprayBrush.ts Outdated Show resolved Hide resolved
if (isCollection(target) && target.subTargetCheck) {
if (
isCollection(target) &&
(target.subTargetCheck || target.interactive)
Copy link
Member

Choose a reason for hiding this comment

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

target interactive requires subtargetcheck, so we shouldn't be checking for both.

Copy link
Contributor Author

@ShaMan123 ShaMan123 Jan 8, 2024

Choose a reason for hiding this comment

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

I thought I wrote about this in the description, sorry, updated now.

Another change I have introduced is interactive including subTargetCheck.
To be able to select sub targets both need to be flagged as true.
subTargetCheck flags searchPossibleTargets to traverse the children of a group.
interactive flags to return the top most sub target found within searchPossibleTargets.
So essentially interactive = true contains subTargetCheck = true.
It is more straight forward as well.

Comment on lines -361 to -396
for (let i = 0; i < len; i++) {
const object = this._objects[i];
object.group || object.setCoords();
}
if (backgroundObject) {
backgroundObject.setCoords();
}
if (overlayObject) {
overlayObject.setCoords();
}
Copy link
Member

Choose a reason for hiding this comment

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

why deleting those? control coords need to be set when we change the vpt.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need control coords only for the active object

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And regarding corner coords, we need them on these objects only if they do not ignore the vpt

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 the code we do not even check if these objects are on screen and these objects are not selectable apart from imperatively calling setActiveObject (which I believe should call setCoords if it doesn't) thus making the setCoords call redundant

Copy link
Member

Choose a reason for hiding this comment

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

So this was here because before we were selecting by lineCoords, while now we select by aCoords that doesn't change by the zoom or pan.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exactly, the active object calls setCoords, the rest should not. Mind that setActiveObject call setCoords so it seals the gap.

Comment on lines 1692 to 1697
if (this._forceClearCache) {
this.initDimensions();
this.setCoords();
}
Copy link
Member

Choose a reason for hiding this comment

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

this whole thing the text has that can change the size just before rendering is wrong, is probably a patch that was done fore some reason, but this shouldn't exist. Render renders, period.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

suggestions?

Copy link
Member

Choose a reason for hiding this comment

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

i don't have a suggestion but is also evident that if there is a dependency between having the correct dimensions and being detected on screen, so for correctness this check and setCoords shoud be called before the onScreen early return, and also Object will do the isOnscreen check again.
This render method should take in account that if _forceClearCache is true, should execute the initDimension and then move to super.render. the precheck is not useful, is a double cost 99.9% of the time

@@ -248,6 +250,8 @@ export class LayoutManager {
left: object.left + offset.x,
top: object.top + offset.y,
});
// TODO: should we delete aCoords/oCoords here so they are not stale if subTargetCheck is false?
// or should we apply offset instead of recalculating aCoords?
Copy link
Member

Choose a reason for hiding this comment

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

an object without coords could create more damages than one with stale, different typings, always have to check if the coords are there.
We should document that if you have a group without subtargetCheck and you plan on using the group inner object coordinates, you have to update them when you use them.

Copy link
Contributor Author

@ShaMan123 ShaMan123 left a comment

Choose a reason for hiding this comment

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

rebased

@@ -216,6 +216,8 @@ export class LayoutManager {
target.setPositionByOrigin(nextCenter, CENTER, CENTER);
// invalidate
target.setCoords();
(target.subTargetCheck || target.interactive) &&
target.forEachObject((object) => object.setCoords());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

here is a good example of the benefit of separating setCoords to setCornerCoords and setControlCoords because here we need only setCornerCoords

Copy link
Contributor Author

@ShaMan123 ShaMan123 Jan 8, 2024

Choose a reason for hiding this comment

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

Do you have objections doing that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also this line is incorrect because it goes down 1 level but should recurse down

Comment on lines -486 to -500
setCoords() {
super.setCoords();
this._shouldSetNestedCoords() &&
this.forEachObject((object) => object.setCoords());
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jiayihu look here, this removes the perf hit you described

src/controls/polyControl.ts Outdated Show resolved Hide resolved
Copy link
Contributor Author

@ShaMan123 ShaMan123 left a comment

Choose a reason for hiding this comment

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

All in all this looks like an important PR

Copy link
Contributor Author

@ShaMan123 ShaMan123 left a comment

Choose a reason for hiding this comment

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

rebased and waiting for response before fixing some issues

this.setCursor(hoverCursor);
// hoverCursor should come from top-most subTarget
const hoverCursor =
(target as Group).subTargetCheck &&
Copy link
Contributor Author

Choose a reason for hiding this comment

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

should it be:

Suggested change
(target as Group).subTargetCheck &&
(target as Group).interactive &&

?

Copy link
Contributor Author

@ShaMan123 ShaMan123 left a comment

Choose a reason for hiding this comment

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

cleaned up a small bit

transform.target.isMoving = true;
this.setCursor(transform.target.moveCursor || this.moveCursor);
target.isMoving = true;
this.setCursor(target.moveCursor || this.moveCursor);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

doesn't belong here

@jiayihu
Copy link
Contributor

jiayihu commented Apr 8, 2024

Chipping in as requested to report that current setCoords performance is terrible for a few reasons:

  1. setCoords always set the control coords even though most of the time you don't need to show the controls. In fabric by default actually only the activeObject renders the controls
  2. obj.drawControls calls obj.setCoords internally, so whenever controls are renderered, their position is recalculated. With groups, this is aggravated by point 3
  3. Group setCoords calls it recursively for the children if subTargetCheck. Due to point 2, calling group.drawControls will call group.setCoords(), which in turn calls the children setCoords. But then when child.drawControls is called, this gets repeated. This results probably in factorial O(n!) time complexity.

As just an example, we recently implemented a Table, which is a Group of cells. Each cell is in turn a group of content. Each cell has 4 controls. Re-rendering a table with only 150 cells changes from 40s to 2.5 once setCoords is removed from drawControls:

Screenshot 2024-04-08 at 10 56 03 Screenshot 2024-04-08 at 10 56 58

@asturur
Copy link
Member

asturur commented Apr 8, 2024

my biggest issue with this PR and the reason why i left it here is because it says it is for performance but never gave a figure of the performance it was improving.

@asturur
Copy link
Member

asturur commented Apr 8, 2024

I also want to do some retrospect on how we got here.
SetCords was basically never called and was always left to the developer to don't mess up with it, how we end up that is called at draw control time i do not know.

if (overlayObject) {
overlayObject.setCoords();
}
this.viewportTransform = [...vpt];
Copy link
Member

Choose a reason for hiding this comment

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

this wasn't spread before, it shouldn't be spread now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are you sure?
When we init the canvas we spread to avoid mutation, why not here?

Copy link
Member

Choose a reason for hiding this comment

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

it is unrelated to what we are doing here. no?

@jiayihu
Copy link
Contributor

jiayihu commented Apr 8, 2024

never gave a figure of the performance it was improving.

I suggested @ShaMan123 we should add a benchmark test that simply renders a Group with 100 Groups, with 2 children per group. Then measure how much times it takes and how much this PR affects it. Also it will avoid future regressions.

SetCords was basically never called and was always left to the developer to don't mess up with it, how we end up that is called at draw control time i do not know.

Screenshot 2024-04-08 at 17 19 09

It was there since v5.3 but not on v3.6 (which we were using at my company before migrating to v6) but it's definitely suspicious to call setCoords during drawing. Maybe it was added to workaround stale coords without minding about performance.

@ShaMan123
Copy link
Contributor Author

@asturur this discussion can lead to how fabric uses isOnScreen and should one day. It is very wasteful. We are starting to look into it if you want in.

@asturur
Copy link
Member

asturur commented Apr 9, 2024

@asturur this discussion can lead to how fabric uses isOnScreen and should one day. It is very wasteful. We are starting to look into it if you want in.

never gave a figure of the performance it was improving.

I suggested @ShaMan123 we should add a benchmark test that simply renders a Group with 100 Groups, with 2 children per group. Then measure how much times it takes and how much this PR affects it. Also it will avoid future regressions.

SetCords was basically never called and was always left to the developer to don't mess up with it, how we end up that is called at draw control time i do not know.

Screenshot 2024-04-08 at 17 19 09

It was there since v5.3 but not on v3.6 (which we were using at my company before migrating to v6) but it's definitely suspicious to call setCoords during drawing. Maybe it was added to workaround stale coords without minding about performance.

Was added in 4.0 when the ability to call render controls with styleoverrides was added.
I think because you could then run multiple controls renders and we didn't want the controls to be offplace.
In that moment probably the same oCoords for interactivity where used for renders ( and maybe now too ) and we thought it was nicer if you didn't have to call that manually.
There weren't nested ocoords tho.
So i m sure that is the reason why that was there, but for sure now it can't be there at all.

@asturur
Copy link
Member

asturur commented Apr 9, 2024

@asturur this discussion can lead to how fabric uses isOnScreen and should one day. It is very wasteful. We are starting to look into it if you want in.

Just keep in mind isOnScreen was used to skip some rendering for first level objects. That was all, there aren't more intention behind it.
I don't see how is wasteful in theory it should check at worst 5 points in a rectangle i don't see how that is so bad. But please open a discussion on the same example as i m doing for the other changes. Keep it in draft until you want other to ask/comment

I m interested to see why is bad, that yes.

@jiayihu
Copy link
Contributor

jiayihu commented Apr 10, 2024

There weren't nested ocoords tho.

Bear in mind that it's not only about nested oCoords. It still does it for all the canvas objects, which is very expensive. Internally we have changed it to be done only for the needed objects, typically the activeObject.
So I kept the method call in obj.drawControls, which is not optimal during rendering of course, for exactly the reason of not having to call it manually. It'd be great if we can find a way to have both, i.e. call it not during rendering but not having to remember it.

@asturur
Copy link
Member

asturur commented Apr 10, 2024

If we are doing all objects 'setCoords' every render then we introduced a bug. That was never the intention, again setCoords was something that was called on setViewport and at the end of a transform.
At some point it was added during a drag operation but always on a single object.

Anyway this PR won't be breaking, so we can move forward with a release.
My request is to keep it strict to removing setCoords call and removing all the accessory changes ( like overlapping interactive with subtargetcheck and whatever is not a setCoord call )

@asturur
Copy link
Member

asturur commented Apr 10, 2024

i also see that calcOcoords with the nested cords for groups interactivity became way slower in the case of groups. so that is also probaly a cause of general slowness
We don't have a setCoords for the specific situation and a setCoords generic that updates all.

When doing setCoords on a group that isn't interactive, setting the oCoords of all the nested objects is a waste, and if the group is interactive but selected, is a waste anyway.

But setcoords is imperative, maybe having it going deeply nested was an error because most of the time aCoords do not change and that is all we need for targeting check.

But that is topic for a different discussion/pr

@ShaMan123
Copy link
Contributor Author

If we are doing all objects 'setCoords' every render then we introduced a bug. That was never the intention, again setCoords was something that was called on setViewport and at the end of a transform.
At some point it was added during a drag operation but always on a single object.

I was surprised to see that all the above are not as expected. Calling setCoords when drawing controls, not calling them otherwise

@asturur
Copy link
Member

asturur commented Apr 10, 2024

If we are doing all objects 'setCoords' every render then we introduced a bug. That was never the intention, again setCoords was something that was called on setViewport and at the end of a transform.
At some point it was added during a drag operation but always on a single object.

I was surprised to see that all the above are not as expected. Calling setCoords when drawing controls, not calling them otherwise

The gotcha always said, if something is misaligned doesn't work call setCoords.
Exactly because we never called if not when strictly necessary

@ShaMan123
Copy link
Contributor Author

If we are doing all objects 'setCoords' every render then we introduced a bug. That was never the intention, again setCoords was something that was called on setViewport and at the end of a transform.
At some point it was added during a drag operation but always on a single object.

I was surprised to see that all the above are not as expected. Calling setCoords when drawing controls, not calling them otherwise

The gotcha always said, if something is misaligned doesn't work call setCoords. Exactly because we never called if not when strictly necessary

That is what the gotcha says but the code does something entirely different so the gotcha is unrelated.
Fabric doesn't call setCoords after a transform action, which is the only place it 100% should.

@asturur
Copy link
Member

asturur commented Apr 10, 2024

@ShaMan123 is not unrelated, things changed pr after pr probably.
on 5.x setCoords was on finalizeCurrentTransform ( correct ), on scale method ( correct ), on adjustPosition ( correct ), on onComplete of animate ( maybe not correct ) , text editing ( correct ) and group layouting ( correct ) and on renderControls ( maybe not correct )

Also today is still on '_finalizeCurrentTransform' so that aspect is still there, not sure why you say it doesn't

@asturur
Copy link
Member

asturur commented Apr 10, 2024

A red flag i see in this PR is that we remove the nested call of group setCoords done with the subclass and then we comment in 2 places of added code that the call should be recursive, but we just removed the recursiveness from the code.
I don't think that change is a good one.

The only changes that are 100% correct here is the one from viewport and then moving the setCoord from the drawControl call earlier to the performAction could be correct without much doubts too.
But at that point you should remove the one from 'finalizeCurrentTransform'.

Where are the performance improvement coming from?
drawControl is removed but performAction compensate it
The only thing i see is removing the nesting from the group setCoords, but also then realizing is sitll necessary in many cases

@ShaMan123
Copy link
Contributor Author

ShaMan123 commented Apr 11, 2024

drawControl is removed but performAction compensate it

Only during a transform, if for other reasons you need to render the canvas this is better.

But at that point you should remove the one from 'finalizeCurrentTransform'.

agreed

Regarding the recursive setCoords call of group. Do we need it?
For geometry we need it.
For isOnScreen we do not because it doesn't check nested objects.
For controls we do not because setActiveObject will handle that.
Thoughts?

@ShaMan123
Copy link
Contributor Author

Regarding the recursive setCoords call of group. Do we need it?
For geometry we need it.

This makes my suggestion to delete aCoords/oCoords relevant. If they do not exist they could be recalculated on the fly from getCoords etc.

@jiayihu
Copy link
Contributor

jiayihu commented Apr 11, 2024

If they do not exist they could be recalculated on the fly from getCoords

Then you'd get worse performance having to recompute them everytime, for instance for _checkTarget->_pointIsInObjectSelectionArea

@ShaMan123
Copy link
Contributor Author

Why? We delete them only if an ancestors invalidates geometry

@asturur
Copy link
Member

asturur commented Apr 11, 2024

my suggestion is to take the no brainers from this PR and to think better what to do for the rest.

Do we need to have oCoords cached? at this point i do not know.
You need to have them cached only if you are running findTargetControl on non selected objects.
Fabric by default doesn't change the controls anymore if the object is not already selected, and if you select it you can call setCoords once and then keep updated during transforms.

But of course if you know you are using an override where you can click a control also when the object is not selected, then things change.

@ShaMan123
Copy link
Contributor Author

Do we need to have oCoords cached? at this point i do not know.

Great point!
Ok then, so the remaining question is about the nested setCoords. I think it is an important perf hit we should resolve so I shall reiterate on it after the non brainers.
I have an idea for hashing both the matrices and the coords.

@ShaMan123
Copy link
Contributor Author

closing in favor of #9793

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants