-
-
Notifications
You must be signed in to change notification settings - Fork 35.9k
Fix performance of BatchedMesh removals and insertions #31468
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
base: dev
Are you sure you want to change the base?
Fix performance of BatchedMesh removals and insertions #31468
Conversation
📦 Bundle sizeFull ESM build, minified and gzipped.
🌳 Bundle size after tree-shakingMinimal build including a renderer, camera, empty scene, and dependencies.
|
45c957f
to
9c652bc
Compare
Note that this doesn't address the following: The variation of the second test which uses // this is slow:
for (let boxGeometryId of boxGeometryIds){
batchedMesh.deleteGeometry( boxGeometryId );
} instead of // this is quicker:
for (let boxInstanceId of boxInstanceIds){
batchedMesh.deleteInstance( boxInstanceId );
}
batchedMesh.setInstanceCount(1)
batchedMesh.setInstanceCount(n)
for (let boxGeometryId of boxGeometryIds){
batchedMesh.deleteGeometry( boxGeometryId );
} to remove the geometries (and all instances) is still slow (roughly 5s). Background is that deleteGeometry always loops over all instances to find (and remove) instances of this particular geometry. By manually removing the instances first and then pruning the used instances via setInstanceCount, this can be worked around. A proper fix would probably need to keep track which instances are associated with which geometry, to avoid doing always linear searches. |
Can you please provide full context for these timings? I'm seeing that this is taking less than 1.5ms on my machine using the 50,000 instances count cited in #31465: const mesh = new THREE.BatchedMesh( 100000, 5000, 10000 );
const gid = mesh.addGeometry( new THREE.SphereGeometry() );
for ( let i = 0; i < 50_000; i ++ ) {
const id = mesh.addInstance( gid );
// ...
}
console.time( 'delete geometry' );
mesh.deleteGeometry( gid );
console.timeEnd( 'delete geometry' ); |
I'll provide more details later. However, note the following: Removing the single geometry in test1 is fast (and the code snippet that you showed resembles test1, given that it uses a single geometry. |
Can you explain your use case for deleting and then reinserting 50,000 instances, as well? Is this something you're actually doing in your code or just a large example number? What issues is it causing your project? |
Sorry if my description was misleading. Yes, I meant one instance for each geometry, so 50000 instances in total.
In general, this is just a (much) simplified example and I just had to put a reasonably large number to see the effect here as well. The rough summary is this: we use BatchedMeshes to visualize large finite element models. We have to decompose it into many small regions (and we have had cases where this ended up in 200k regions), each region corresponding to a geometry and thus, we had to batch them to keep the number of draw calls small. We first noticed it when we did a full refresh of the scene, but kept the BatchMesh. We wanted to keep it, but arguably you'd be better off by starting from scratch. However, we are also doing partial updates of the scene, in which case there are really good reasons to keep the BatchMesh. And dependening on what the user is requesting, it can result in many new/modified geometries. (Maybe one could overwrite geometries, but this isn't very clean and it doesn't align well with our code structure.) (As a side-note, the BatchedMesh was really a key ingredient to make this possible at all, i.e. thanks for developing it!) |
Thanks for the explanation. It's always helpful to know the full context - I'm just thinking through the problem and whether there are other suitable solutions. I don't love that extra flags are added since it makes things a bit more difficult (they now need to be supported in toJSON, as well, for example) and depending on the order the user performs operations this would still be slow - deleting and then adding an instance immediately over and over again with a lot of unused instances instead of deleting them all and adding the new ones would cause the sort flag to be always be true and trigger the bad behavior: // fast since sort happens once
for ( let i = 0; i < 50_000; i ++ ) mesh.deleteInstance( i );
for ( let i = 0; i < 50_000; i ++ ) mesh.addInstance( geometryId );
// slow if there are already a lot of unused instance ids
for ( let i = 0; i < 50_000; i ++ ) {
mesh.deleteInstance( i );
mesh.addInstance( geometryId );
} I'm thinking it might be most simple to keep the "available ids" arrays always in a sorted order by just inserting the id correctly in the first place. It won't be as fast but doing a naive insert is more than an order of magnitude faster on my machine. The "sort" calls can then just be deleted: deleteInstance( instanceId ) {
// ...
// keep the ids list always sorted
let index = 0;
const ids = this._availableInstanceIds;
while ( index < ids.length && ids[ index ] <= instanceId ) {
index ++;
}
ids.splice( index, 0, instanceId );
return this;
} This brings me down from total time of delete and add instances from ~13000ms to ~870ms. It, of course, also shifts a lot of the time spent to the delete function rather than the add function. Using something like a binary search brings it down further but I'd like to avoid adding something like that if a simpler solution is "good enough". Here's a rough table of timings for my machine using 50,000 instances:
My preference for now would probably be to use the naive sorted insert if it's good enough for your use case. Otherwise maybe the binary search insert is suitable. But generally keeping the array sorted will allow for doing this delete / add work over multiple frames or calling the functions in any order without worrying about triggering the sort path unnecessarily. |
Thank you for the detailed investigation. I wasn't aware of the json export and in particular, that there is BatchMesh specific code outside of this file (Object3D.js/ObjectLoader.js). But I guess you could still make this compatible with the lazy sort. I also agree with your finding that the scenario "deleting and then adding an instance immediately over and over again with a lot of unused instances" is not solved by my approach. Furthermore, I do understand that you don't want to make it overly complicated for maintenance reasons. Note that in your approach "array always sorted", a binary search brings down the complexity of the find to log n, however, the insert operation using splice is still order n. I tend to think this would only be a clean solution only if we used a data structure that guaranteed that arbitrary inserts (and removals) are order log n; I'm thinking here of a binary heap. But let us maybe also take a step back, what is the reason that these arrays need to be sorted? |
I'm aware that you most probably wouldn't want to add a dependency, but this would demonstrate the performance when using a heap: |
Related issue: #31465
Description
BatchedMesh keeps track of the deleted ids in _availableInstanceIds or _availableGeometryIds, respectively.
For every (!) call of addInstance/addGeometry these arrays are sorted.
This implies if you delete n entities, and add n entities again, you have at least a complexity of n^2 (even ignoring the log n for now).
As a user, I would rather expect a complexity of roughly n log n for this scenario.
To avoid this, two internal flags _availableInstanceIdsSorted and _availableGeometryIdsSorted are introduced, which keep track whether the sorting was already done and thus can be skipped.
For the two test cases described in the issue, this reduced the runtime from roughly 10s for each of them to 10 ms (test1) or 150ms (test2) on my laptop.
This should reduce the complexity in the scenario described above to n log n (as you'll perform a single sort plus the actual removal and insertion).