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

cstegel added 5 commits June 9, 2023 12:00
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.

cstegel added 8 commits June 13, 2023 11:45
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
cstegel added 4 commits June 16, 2023 17:11
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.

cstegel added 3 commits July 26, 2023 10:25
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 added this to the r161 milestone 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
@mrdoob mrdoob modified the milestones: r165, r166 May 31, 2024
@cstegel
Copy link
Contributor Author

cstegel commented Jun 14, 2024

Happy belated birthday to this PR 🎂

@mrdoob mrdoob modified the milestones: r166, r167 Jun 28, 2024
@sunag sunag mentioned this pull request Jul 13, 2024
3 tasks
@mrdoob mrdoob modified the milestones: r167, r168 Jul 25, 2024
@Thebluedaredevil
Copy link

cstegel--- y'all gotta get this into the glTF format---- destroy that pesky fbx format---- hahahahh--- seriously though, your work on this aspect of glTF is just pure AWESOMENESS--- I'd love to be able to use my 6-20 boneaffect limit skinweights

@cstegel
Copy link
Contributor Author

cstegel commented Jul 30, 2024

cstegel--- y'all gotta get this into the glTF format---- destroy that pesky fbx format---- hahahahh--- seriously though, your work on this aspect of glTF is just pure AWESOMENESS--- I'd love to be able to use my 6-20 boneaffect limit skinweights

Thanks for the appreciation, @Thebluedaredevil :)

The glTF format actually already supports this, but three.js doesn't correctly load those kinds of glTF files. The glTF spec does not have a limit on skinning weight / skinning index buffers. It allows buffers to be created which support multiples of 4 bone influences. Many glTF loaders / renderers -- like three.js -- only look at the first buffer and ignore the rest. When you export glTF files from a tool like Blender, you can choose how many bone influences to include which determines how many glTF buffers are made:

Screenshot 2024-07-30 at 2 42 50 PM

Based on the discussion here and the existence of #28863, I think the three.js maintainers want to rework how extensions like this can be added so I doubt this would go in any time soon.

@mrdoob mrdoob modified the milestones: r168, r169 Aug 30, 2024
@mrdoob mrdoob modified the milestones: r169, r170 Sep 26, 2024
@mrdoob mrdoob modified the milestones: r170, r171 Oct 31, 2024
@mrdoob mrdoob modified the milestones: r171, r172 Nov 29, 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.

9 participants