-
Notifications
You must be signed in to change notification settings - Fork 110
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
Optimize spring bones computations #1539
base: dev
Are you sure you want to change the base?
Conversation
/** | ||
* List of dependencies that need to be updated before this joint. | ||
*/ | ||
public get dependencies(): THREE.Object3D[] { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SHOULD] Existing VRMConstraints already have signatures get dependencies(): Set<THREE.Object3D>
. I want to align the interface unless it needs to be an array instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you explain why you moved this to the public property though? I don't think it's a bad idea, but it is still called only from VRMSpringBoneManager
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initially I moved it as I wanted to cache the list of dependencies, since it caused a lot of memory allocations in the hot-path (VMRSpringBoneManager.update
). The VRMSpringBoneJoint
itself seemed like a good place to keep the cached list as it could also invalidate it when needed. But after pre-computing the joint order, it was no longer in the hot-path, so I removed the caching again.
In the end the move wasn't needed. But I like the consistency with the VRMConstraints
, so I'll update the signature to return a Set<THREE.Object3D>
as well.
packages/three-vrm-springbone/src/utils/lowestCommonAncestor.ts
Outdated
Show resolved
Hide resolved
packages/three-vrm-springbone/src/utils/lowestCommonAncestor.ts
Outdated
Show resolved
Hide resolved
// it is required when the spring bone chain is sparse | ||
traverseChildrenUntilConditionMet(springBone.bone, (object) => { | ||
const lca = lowestCommonAncestor(ancestors); | ||
this._ancestors = []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm sure this this._ancestors
has to be an array since the 0th element should calculate ancestors' world matrices.
[SHOULD] However, the reason why the variable this._ancestors
is needed is kinda unintuitive. It would be very appreciated if you give them a proper doc comment to help future us.
I believe it goes like "A list of ancestors of the entire SpringBone joints. Their world matrices have to be calculated before we update the entire SpringBone system. The first element is ~~"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, the _ancestors
list is ordered such that their world matrices can be updated correctly. The first element is the lowest common ancestor for which we also need to make sure its ancestors have their world matrix updated.
The graph below is an example of a Scene graph and shows which nodes would be in the _ancestors
list in that case:
graph TD
S[Scene] --> AP[Object3D]
S[Scene] --> B[Object3D]
AP --> A[Object3D<br>ancestors#91;0#93;]
A --> C[Object3D<br>ancestors#91;1#93;]
A --> D[Object3D<br>ancestors#91;2#93;]
C --> S1[Springbone 1]
D --> S2[Springbone 2]
D --> S3[Springbone 3]
classDef ancestor fill:#AFA,stroke:gray
classDef lancestor fill:#4D4,stroke:black
classDef o3d fill:#EEE,stroke:gray
class C ancestor
class D ancestor
class A lancestor
class S o3d
class AP o3d
class B o3d
I've added a doc comment that should hopefully clarify things for future readers.
I reviewed the first two commits. I will review the third one tomorrow. |
7449a0d
to
a7d1201
Compare
a7d1201
to
856df6f
Compare
Spring bones tend to be quite performance intensive on the CPU side. I looked into ways to optimize them, while retaining their original behaviour. This PR consists of three commits each focussing on a different area.
See the following table which shows the average duration a call to
vrm.update
took. The changes are stacked, so theCollider shape offset
column contains all three optimizations.Pre-computing spring joint update order (a21e474)
The order in which spring bones need to be updated can be pre-computed making the actual
update
a lot more straightforward and performant. Whenever a spring bone is added or removed, the order is recomputed. The following things are precomputed:_sortedJoints
)_ancestors
)Computing stiffness and gravity in world space (2e14729)
The
VRMSpringBoneJoint.update
method converted all forces into center space. This is required for inertia, but for both stiffness and gravity this isn't strictly needed. It saves a couple of matrix multiplications by handling these in world space instead.Additionally some of the vector math could be simplified (e.g.
.add(v3.multiplyScalar(scalar))
->.addScaledVector(v3, scalar)
) slightly reducing the amount of operations required.Aligning collider position with collider shape offset (7449a0d)
The
VRMSpringBoneCollider
is aTHREE.Object3D
with an identity transform. The collision shapes contain anoffset
, which is used to compute the world position of the collider shape. But this is done each timecalculateCollision
is called, which can be many times per-frame, despite the actual world position of the collider not changing in between.By setting the position of
VRMSpringBoneCollider
to the offset, the world position can be retrieved from theworldMatrix
instead, ensuring it's only calculated once per collider per update. To further reduce the number of calculations done, the output vector (target
) is now only updated in case the distance is negative.This did require some changes to the unit tests, as they don't use
VRMSpringBoneCollider
but test against theVRMSpringBoneColliderShape
directly.Notes
For benchmarking and testing I created a setup where I loaded the VRM, called
vrm.update(FIXED_DELTA)
and compared the world positions and rotations of all joints to reference values based on running the same ondev
. So for the cases and VRM models tested above, the output after one update was identical (with EPSILON=0.0001).