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

Add support for skinning >4 bones per vertex with a bone weight texture #26222

Open
wants to merge 29 commits into
base: dev
Choose a base branch
from

Conversation

cstegel
Copy link
Contributor

@cstegel cstegel commented Jun 8, 2023

Overview

Related issue: #26137

High-fidelity skeletal animations often need a large number of bone weights per vertex. The current vertex buffer skinning approach limits meshes to <= 4 bone influences per vertex.

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.

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.
  2. 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

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

  1. 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

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:

perf-test-profile

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.

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.
@github-actions
Copy link

github-actions bot commented Jun 8, 2023

📦 Bundle size

Full ESM build, minified and gzipped.

Filesize dev Filesize PR Diff
672.8 kB (166.7 kB) 676.5 kB (167.7 kB) +3.73 kB

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Filesize dev Filesize PR Diff
452.5 kB (109.3 kB) 454.7 kB (109.9 kB) +2.23 kB

This also adds them to examples/files.json and updates one of the titles

The screenshots were generated with the following command after fine-tuning
them to produce a good screenshot:

```
npm run make-screenshot \
	webgl_animation_skinning_many_bone_influences \
	webgl_animation_skinning_performance
```
One of the MacOS e2e tests was failing on the new
webgl_animation_performance example page which might be due to rendering time.

I'm making this change to see if it fixes things in CI. The E2E test works fine
on my 2019 MacBook pro.
@WestLangley
Copy link
Collaborator

Friendly tip. MeshPhysicalMaterial requires an environment map. I used RoomEnvironment here, but it is your choice. Scene lights are not required. I set material color to white, metalness 0, roughness 0. You also need to set tone mapping and exposure.

Screenshot 2023-06-09 at 7 23 12 PM

Directional light was also removed from the "many bones" example
because it wasn't needed.

The env map would make more of a difference if the material had lower roughness
but it looks too bright when extra lights are added like in the "performance"
example even though tone mapping is on.
@cstegel
Copy link
Contributor Author

cstegel commented Jun 13, 2023

Friendly tip. MeshPhysicalMaterial requires an environment map. I used RoomEnvironment here, but it is your choice. Scene lights are not required. I set material color to white, metalness 0, roughness 0. You also need to set tone mapping and exposure.

Screenshot 2023-06-09 at 7 23 12 PM

Thanks @WestLangley! I've added an environment map and tone mapping now, but I kept the same color and material properties because the smooth material appears too bright in the "performance" scene even with tone mapping. This makes the skinning difference hard to see. Reinhard tone mapping fixed it from being too bright but it doesn't look good (the colors are dull) so I stuck with ACESFilmicToneMapping and the default exposure.

This is done by moving the weight buffer normalization and texture creation
into the SkinnedMesh constructor. All relevant loaders now have an option
that is given to the SkinnedMesh constructor which controls whether or not a
weight texture is created.
…isuals

The visual artifacts are easier to see when there's a directional light in
the scene.
@cstegel
Copy link
Contributor Author

cstegel commented Jun 14, 2023

I do not plan on making more changes until comments are received so I am changing this from a draft PR to a normal PR.

Unit tests, documentation, and full loader support (FBX, Object, Collada, MMD, GLTF) have been added. I did not try loading a >4 bone weight model in those formats because all I have is a .glb, but the existing examples continued to load fine.

@cstegel cstegel marked this pull request as ready for review June 14, 2023 22:48
This new example takes a long time to load and can have a low framerate so the
exact state of the animations when the screenshot is taken will be slightly
different each time. This causes the screenshot test to be flaky.
// constructor parameters will be undefined.
if ( geometry ) {

this.normalizeSkinWeights();
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 a change in convention. Previously, some of the loaders would trigger normalizeSkinWeights() after SkinnedMesh construction but now it is always done inside the constructor. This is required before the bone weights texture is created and is one less thing that has to be duplicated in loader code.

@@ -175,27 +278,83 @@ class SkinnedMesh extends Mesh {

normalizeSkinWeights() {
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 a change in behavior. It fixes a bug when loading models with >4 skin weights and is also necessary for computing the skin weight texture.

Previously, only the first 4 weights were used instead of the highest 4 among all the weights. Now, the weights are sorted across all buffers before normalizing to the first N that will actually be used.


int bonePairTexIndex = bonePairTexStartIndex + ii;

vec2 boneIndexWeight =
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 could avoid one texture lookup per vertex if the normals could be skinned in the same loop as the vertices. I tried to do this but couldn't get it working. They run at different stages of the vertex shader and I didn't understand it well enough to know how to merge them.

See:

1. mrdoob#26392
2. mrdoob#26267

The default behavior of light intensities was changed. Some examples had the
light intensity values changed from 1 to 3 to preserve the old behavior.

This is necessary after merging dev into this branch.

The screenshots still visually look the same but I am committing them as well
in case there were any subtle changes due to this change in lighting behavior.
I did not change any behavior related to this screenshot test.

One E2E test run said it differed in 2.5% of pixels but the other
test runs were fine. I'm updating the screenshot to hopefully
make this test less flaky.
@mrdoob mrdoob modified the milestones: r156, r157 Aug 31, 2023
@cstegel
Copy link
Contributor Author

cstegel commented Sep 12, 2023

@mrdoob Did you get a chance to look at this? I'm happy to make revisions and would love to get this merged.

@mrdoob mrdoob modified the milestones: r157, r158 Sep 28, 2023
@sunag
Copy link
Collaborator

sunag commented Oct 13, 2023

About WebGPURenderer, I'm thinking in work on an update for the new architecture to support these implementations in Nodes without having to change NodeMaterial codes, I think updates like this could be just adding a new class to the renderer. e.g:

const renderer = new WebGPURenderer();
renderer.skinningClass = SkinningExtendedNode; // SkinningNode as default

// Add other properties for MorphNode, InstanceNode, etc...

What do you think about this? /cc @mrdoob @Mugen87 @LeviPesin

@Mugen87
Copy link
Collaborator

Mugen87 commented Oct 13, 2023

I'm not confident yet to say how important more than four bones weights per vertex is as a feature. Hence, I'm not feeling super well with so many core changes at the moment.

Implementing something like this as an addon than can be opted-in sounds preferable to me. At least it's worth to explore this option.

@LeviPesin
Copy link
Contributor

renderer.skinningClass = SkinningExtendedNode

Maybe not assigning class, but rather a node, so introducing something like skinningNode? It would be great to have some API for managing skinning, lighting models, etc...

@mrdoob mrdoob modified the milestones: r158, r159 Oct 27, 2023
@TheCodeTherapy
Copy link

@mrdoob Did you get a chance to look at this?

@GwilymIO
Copy link

🙏 would be great to see this in! 🥰

@mrdoob mrdoob modified the milestones: r159, r160 Nov 30, 2023
@mrdoob mrdoob modified the milestones: r160, r161 Dec 22, 2023
@mrdoob mrdoob modified the milestones: r161, r162 Jan 31, 2024
@mrdoob mrdoob modified the milestones: r162, r163 Feb 29, 2024
@mrdoob mrdoob modified the milestones: r163, r164 Mar 29, 2024
cstegel and others added 2 commits April 3, 2024 10:23
I accidentally added spaces instead of tabs during a previous merge commit that required manual resolving.
@mrdoob mrdoob modified the milestones: r164, r165 Apr 25, 2024
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.

None yet

8 participants