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

Allow >4 bones to skin a vertex #26137

Open
cstegel opened this issue May 25, 2023 · 12 comments
Open

Allow >4 bones to skin a vertex #26137

cstegel opened this issue May 25, 2023 · 12 comments

Comments

@cstegel
Copy link
Contributor

cstegel commented May 25, 2023

Description

Currently, ThreeJS limits how many bones can skin a vertex to at most 4. When a loader sees more than 4 it arbitrarily removes some of them and emits a warning. This results in broken rigs.

Some file formats and mesh/rig authoring tools allow vertices to be skinned by more than 4 bones. I have some meshes with this need so I would like to add support to ThreeJS for more than 4 bone weights per vertex.

I have started modifying ThreeJS to add this support and was wondering if it would likely be accepted?

How bone skinning currently works

Vertex shaders receive per-vertex skinning weights + indexes:

attribute vec4 skinIndex;
attribute vec4 skinWeight;

skinIndex is used to retrieve the bone matrices and then skinWeight weights them to get the final vertex.

The skinIndex and skinWeight vertex buffers are directly created by loaders at load time and attached to BufferGeometry.

How I propose supporting >4 weights per vertex

Instead of sending bone indexes and weights to the vertex shader as vertex buffer data, it would be uploaded as a data texture and a single int per-vertex for the starting point in the bone-index-weight-pair texture.

attribute int bonePairTexStartIndex;
uniform sampler2D boneIndexWeightPairs;

The shading code would read from the bone pair texture until it saw an index of -1 or the maximum number of vertices, MAX_BONES_PER_VERT, is reached:

        #define MAX_BONES_PER_VERT 32;

        ...
        vec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );
	vec4 skinned = vec4( 0.0 );
	vec4 skinnedNormal = vec4( 0.0 );
        for (int i = 0; i < MAX_BONES_PER_VERT; i = i + 1) {
		int bonePairTexIndex = bonePairTexStartIndex + i;
		vec2 boneIndexWeight = 
			texelFetch(boneIndexWeightPairs, 
			           ivec2(bonePairTexIndex % boneTexWidth,
			           bonePairTexIndex / boneTexWidth),
			           0).xy;
		int boneIndex = int(boneIndexWeight.x);
		if (boneIndex < 0) break;
		mat4 boneMatrix = getBoneMatrix(boneIndex);
		skinned += (boneMatrix * skinVertex).xyz * boneIndexWeight.y;
		skinnedNormal += 
			normalize(mat3(bindMatrixInverse)
			        * mat3(boneMatrix)
			        * mat3(bindMatrix) * objectNormal)
			* boneIndexWeight.y;
	}

Instead of loading skinning index/weight data directly to a vertex buffer on BufferGeometry, loaders should create a data texture if a flag tells it to. It could also detect if any vertex has more than 4 bone weights and make this decision dynamically but it's easier to keep it as a configuration flag. A new texture on SkinnedMesh seems like the right place for this to live.

This change in the shaders would be controlled by a new #define so that the existing behavior is maintained when the extra bones aren't needed.

Alternatives

More shader attributes can be added to support additional bone weights but there's a limit on the amount of vertex data. MAX_VERTEX_ATTRIBS for WebGL is 16. The more attributes are used, the less likely a browser is to support the shader. Putting this data in a texture doesn't run into a similar limit although it might make performance worse.

Additional context

Unity supports unlimited blend weights as a parameter: https://docs.unity3d.com/2019.2/Documentation/Manual/class-QualitySettings.html#BlendWeights

Someone previously had an example of FBX deleting weights when there were more than 4 per vertex: https://discourse.threejs.org/t/is-the-limit-of-4-skinning-weights-per-vertex-a-hard-limit-of-webgl/801

Their related Github issue: #12127

@RemusMar
Copy link
Contributor

Currently, ThreeJS limits how many bones can skin a vertex to at most 4. When a loader sees more than 4 it arbitrarily removes some of them and emits a warning. This results in broken rigs.

Well, that's not quite true.
Yes, most (if not all) of the WebGL based engines have this limit (ThreeJS, BabylonJS, PlayCanvas, Unity3D, etc).
The main reason is that if you have high detailed skinned meshes (over 50,000 polys) and a bunch of bones you'll need a HUGE processing power to animate them if you raise that limit to 6 or 8 for example.
On the other hand, the exporters/loaders do NOT arbitrarily remove anything.
They find the first 4 bones with the highest weights and then normalize those weights.
That gives the best result in >90% of the cases.
But yes, sometimes the 5th and the 6th bone has the same weight as the 4th one and you'll end up with an altered animation.
Anyway, I'm not saying that raising that limit to 6 or 8 is a bad idea, but I'm not sure it's a GOOD idea.
At least for now ...

@mrdoob
Copy link
Owner

mrdoob commented May 30, 2023

I have started modifying ThreeJS to add this support and was wondering if it would likely be accepted?

Would be great if you can submit a PR with the changes and include an example with a model that uses more than 4 bones. Then we'll be able to measure performance and consider the maintenance costs.

@cstegel
Copy link
Contributor Author

cstegel commented May 30, 2023

@RemusMar Thanks for elaborating on how the loaders pick which bones to remove. I'm not that familiar with the loader code yet so I wasn't fully aware of how it decided to remove them. I agree that 4 bones per vertex is usually enough and more is a special case. I'm also aware that this will be less performant, but to @mrdoob's comment, we don't know how much of a difference this is yet. Unity indicates support for it but I haven't tried it.

I want to make this a toggle in the shader so that the current shader setup is the default. If someone desires more than 4 bones per vertex then it would switch the shader implementation. This way, the performance difference is only seen when the feature is enabled.

I'll comment here once I having a working PR for this.

@RemusMar
Copy link
Contributor

I agree that 4 bones per vertex is usually enough and more is a special case.

Hi Cory,
I don't know about Blender, but in 3DS Max (and Maya) there is a very useful setting for the Skin Modifier:
Bone Affect Limit.
By default, that value is 20 (not suitable for WebGL and browser based apps).
Before exporting (to GLTF) all you have to do is to set that value to 4 and 3DS Max will recompute and normalize everything before exporting.
So the exporters and loaders don't have to do anything here.
3DSMax

@cstegel
Copy link
Contributor Author

cstegel commented May 30, 2023

Hi Remus,

That's good to know that authoring tools should have controls to limit the bone influence but unfortunately there are still some cases where the company I work at wants to use models with >4 bones per vertex. I've advocated for keeping things under 4 and this will be the majority of models, but there are still some cases where they want higher.

For some context, the company was previously using a custom WebGL-based renderer with the same shader approach to >4 bone influences as I've described. That renderer is being abandoned in favor of using Unity (not running on web). In some environments, Unity is undesirable so a 2nd rendering system is needed. Three.js fits well but the one feature missing is the ability to handle a larger number of bone weights.

@cstegel
Copy link
Contributor Author

cstegel commented Jun 1, 2023

I found out that Babylon.js has support for >4 bone influences when I tried previewing a .glb with >4 bone influences in various renderers using VS Code's glTF Tools extension.

In the animations below, notice the jagged artifacts above the upper lip in Three.js that are not present in Babylon.js. This model was exported with max 16 bone influences although I don't know what the real max number was. When I use a copy of the mesh that was exported with max 4 bone influences, the animation has the same artifacts regardless of the renderer.

Babylon.js (5.6.1) Three.js (r140)
babylon-joints16-animation 3js-joints16-animation

Babylon's docs don't make this clear, but it seems to do this by using an extra vertex buffer for weights and indexes that holds 4 additional influences

Babylon's docs for their mesh class don't mention a max number for mesh bone influences other than it defaults to 4. There's also a seemingly-outdated doc about not supporting more than 4.

@RemusMar
Copy link
Contributor

RemusMar commented Jun 1, 2023

Cory,

babylon.js supports up to 4 bones influences per vertex.
It's using this formula:
Matrices weights: 4 floats to weight bones matrices
Matrices indices: 4 floats to index bones matrices
finalMatrix = worldMatrix * (bonesMatrices[index0] * weight0 + bonesMatrices[index1] * weight1 + bonesMatrices[index2] * weight2 + bonesMatrices[index3] * weight3);

In your 1st GIF, the first 4 weights are normalized and in the 2nd one they are not.
That's why the result in the 2nd one is wrong.
I get the feeling the GLTF loader used in Three.js is buggy.

@cstegel
Copy link
Contributor Author

cstegel commented Jun 2, 2023

Hi Remus,

Thanks for your response! I dug deeper into the Three.js's GLTFLoader class and the Babylon.js codebase to see what's going on.

GLTFLoader is normalizing the first weights attribute buffer (the first 4 weights). Currently, only the glTF WEIGHTS_0 attribute is used as the Three.js skinWeights vertex buffer.

I inspected the ordering of weights in the glTF WEIGHTS_n buffers across each vertex of the skinned mesh I've exported from Blender. It orders the weights from highest to lowest influence.

For example, here are the 16 weights for an individual vertex in my mesh across the 4 glTF buffers:

x: 0.2424, y: 0.1997, z: 0.1924, w: 0.05653   // WEIGHTS_0 @ v = 2345
x: 0.0563, y: 0.0545, z: 0.0309, w: 0.02831   // WEIGHTS_1 @ v = 2345
x: 0.0281, y: 0.0224, z: 0.0214, w: 0.02091   // WEIGHTS_2 @ v = 2345
x: 0.0202, y: 0.0155, z: 0.0098, w: 0         // WEIGHTS_3 @ v = 2345

Notice that it is ordered by descending weight and if only the first 4 are used (and renormalized to 100%) then 30% of the original influence is gone. This is what creates the artifacts.

Since this mesh's weights are ordered from highest to lowest, GLTFLoader's current behavior of only normalizing the first 4 weights is CORRECT, yet it produces the artifacts shown in my previous comment. This is NOT due to a lack of normalization.

The code that I've seen in Babylon.js (1, 2, 3) indicates they are optionally using 1 extra vertex buffer to support up to 8 weights instead of 4. This explains why the animation artifacts are not easily visible when it loads my model exported for 16 weights. For the example vertex above, it would only lose 14% of the original influence instead of 30%. Most of the vertices don't have this much of a difference so the final artifacts in Babylon.js for this mesh/animation are minor.

When I have Babylon.js load a model that I've exported from Blender which limits the weights to 4 (a single buffer), it has the same artifacts as Three.js. Again, this shows that the artifacts are due to missing bone weights and not a lack of normalization.

Finally, I've implemented the skinning-weight-texture approach that I started this issue with and the artifacts completely disappear. See the new animations below:

Three.js single-buffer vertex skinning (current behavior) Three.js skinning texture (WIP changes, all 16 bone weights)
3js-joints16-broken 3js-joints16-fixed

I am currently working on an example which shows the difference in behavior and cleaning up the code for review.

Regards,
Cory

@RemusMar
Copy link
Contributor

RemusMar commented Jun 4, 2023

Notice that it is ordered by descending weight and if only the first 4 are used (and renormalized to 100%) then 30% of the original influence is gone. This is what creates the artifacts.

Cory,
0.7 for 4 bones and 0.3 lost is way too much!
Are you sure the skinning was done properly for that 3D model?

@cstegel
Copy link
Contributor Author

cstegel commented Jun 5, 2023

Hi Remus,

I don't have much experience with creating rigs to know if the model could be skinned differently with less than four bones while still achieving the same quality of animation. This model was given to me by an artist as a sharable example to show the kind of artifacts that come up when bones are limited. The impression I got from them was that the 4 bone limit is always challenging and limits the expressiveness of the models they create. They frequently encounter this limit when creating highly expressive models.

This can be "fixed" for models by spending more artist time on rearranging and re-weighting bones, but some models will still have a trade off between expressiveness or < 4 bone influences. The choice depends on the use-case. For example, video games might want more expressiveness during cutscenes but otherwise limit to 4 bones during gameplay that has hundreds of models on screen. A V-tuber tracked-avatar system that renders a single model would want higher expressiveness.

Regards,
Cory

cstegel added a commit to cstegel/three.js that referenced this issue Jun 8, 2023
Related issue: mrdoob#26137 (mrdoob#26137)

This PR adds an alternative skinning approach to the existing bone index/weight vertex buffers which uses a vertex shader texture to support an arbitrary amount of bone influences per vertex. The current vertex buffer skinning approach limits meshes to <= 4 bone influences per vertex.

For example, a mesh with the following bone weights/indices in vertex buffers...

```
        WEIGHTS_0        JOINTS_0
        ---------------  --------
[index] 0   1   2   3    0 1 2 3
    v0: 0   1.0 0   0    0 1 2 3
    v1: 0.1 0.7 0.1 0.1  0 1 2 3
```

becomes this array/texture of (index, weight) pairs:

```
        v0                v1
        --------          -----------------------------------
[index] |   0  |     1    |  2        3        4        5   |    6
  data: (1, 1.0) (-1, -1) (1, 0.7) (0, 0.1) (2, 0.1) (3, 0.1) (-1, -1)
                   ⬆                        ⬆   ⬆
              sentinel value         bone index  weight
```

and this vertex buffer:

```
    weights texture start index
    ---------------------------
v0: 0
v1: 2
```

*I have worked on this change as part of my job at Google since my org has projects which would like Three.js to have this feature.*

**What changed:**

1. If a model has more than 1 bone weight buffer (>4 weights per vertex) then a bone weight texture is created and the shader behavior is changed to use it instead of vertex buffer skinning.
2. Vertex weight/index buffers are sorted by weight -- across all buffers -- before normalization in the buffers that will be used.
    - This allows skin texture creation to only include non-zero weights.
    - This "fixes" some artifacts for vertex buffer skinning when models have >4 weights and the higher weights are not in the first 4 weights.
    - This also means that loading skinned meshes takes longer because the per-vertex weights are sorted in addition to being normalized.

**What did not change:**

1. The vertex buffer skinning approach is still the default for models that have at most 4 bone weights per vertex.

**What needs decisions:**

1. Default behavior and how to control which skinning method to use
    - Currently defaults to the old buffer method if a model has <= 4 weights otherwise the texture method is used.
    - Loader/mesh options have controls for **always** or **never** using the texture approach.
3. Do we still want to keep the old method?
    - The code would be simpler without it and I haven't seen a big performance impact.
    - Main tradeoff seems to be model load time (creating the skinning texture from the vertex buffers)
    - I'm unsure if other parts of three.js or clients require/assume the presence of the skinning buffer

**What is still being worked on**

1. Tests
2. Documentation
4. Adding support to loaders of file formats other than glTF.

**Examples**

1. webgl_animation_skinning_many_bone_influences.html - Shows the difference between < 4 and >= 4 bone skinning for a model that was created with 16 bone skinning.
    - Notice:
        - The artifacts on the upper lip (left image, only 4 weights)
        - The difference in how much the nose gets pulled up/down with the mouth movement.

![frontal-close-up-4-vs-16-bones](https://github.com/mrdoob/three.js/assets/3453535/249a5233-97bc-4576-bdd9-fed9d071fb40)

5. webgl_animation_skinning_performance.html - loads many skinned meshes playing animations with a toggle between the old and new behavior to see if there are performance differences.

![perf-test](https://github.com/mrdoob/three.js/assets/3453535/7173d049-b846-4643-b297-47bbf1a1e9c3)

**Performance**

All performance numbers are from a 2019 MacBook Pro running Chrome 114.0.5735.106.

**Framerate**

`webgl_animation_skinning_performance.html` renders at the same 23 fps for both the vertex buffer and vertex texture skinning methods. The Chrome profiling image below shows that GPU code is executing for about 25% of the frame (11ms / 45ms) and vertex skinning is only a portion of that so this benchmark scene doesn't do the best job of stressing the vertex skinning:

<img width="2231" alt="perf-test-profile" src="https://github.com/mrdoob/three.js/assets/3453535/fe629821-e8f8-42fb-adc8-7de5ab4ea00a">

That being said, the soldier model still seems like a realistic asset that someone would use which is why I used it in the benchmarking scene. If there are other animated models with higher vertex counts that would increase GPU vertex shader runtime differences, I am happy to try them.

**Note:** Texture skinning could have beneficial performance for models with <= 4 weights per vertex because it strips out weights that are zero. For the `Soldier.glb` model, this removed 38% of the weights.

**Model Loading**

method | `Soldier.glb` (7434 vertices, 4 weights) runtime| `HeadWithMax16Joints.glb` (2474 vertices, 16 weights) runtime
--------|---------------------------|---------------
`SkinnedMesh.normalizeSkinWeights (dev)` | 5 ms | N/A
`SkinnedMesh.normalizeSkinWeights (this PR)` | 12 ms  | 21 ms
`SkinnedMesh.createBoneIndexWeightsTexture` | 7 ms | 8 ms
`GLTFLoader.load` (buffer skinning) | 190-380 ms |  N/A
`GLTFLoader.load` (texture skinning) | 190-380 ms | 240-290 ms

The new implementation of `SkinnedMesh.normalizeSkinWeights()` is slower because it sorts weights in addition to normalizing them. It could be made faster by sorting each vertex's buffer data in-place instead of copying to separate arrays and then copying them back.

The total load time and variance of the load time was so large that the additional processing in `normalizeSkinWeights` and `createBoneIndexWeightsTexture` did not have a noticeable effect.
@cstegel
Copy link
Contributor Author

cstegel commented Jun 8, 2023

I've created a draft PR for this now: #26222

@cstegel
Copy link
Contributor Author

cstegel commented Jun 14, 2023

PR is no longer a draft. I am now waiting on reviews.

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

No branches or pull requests

4 participants