{"accessors":[{"bufferView":0,"componentType":5126,"count":6,"type":"VEC3","max":[0.25,0,0.2],"min":[-0.25,0,-0.2],"name":"Positions Accessor"},{"bufferView":1,"componentType":5125,"count":12,"type":"SCALAR","name":"Indices Accessor"},{"bufferView":2,"componentType":5126,"count":6,"type":"VEC4","name":"weights accessor"},{"bufferView":3,"componentType":5123,"count":6,"type":"VEC4","name":"joint indices accessor"},{"bufferView":4,"componentType":5126,"count":2,"type":"MAT4","name":"IBM"},{"bufferView":5,"componentType":5126,"count":6,"type":"VEC3","max":[0.85,0,0.2],"min":[0.35000002,0,-0.2],"name":"Positions Accessor"}],"asset":{"generator":"glTF-Asset-Generator Animation_Skin_05 + translation on node 3 (plane2)","version":"2.0"},"buffers":[{"uri":"data:application/octet-stream;base64,AACAvgAAAADNzEy+AACAPgAAAADNzEy+AACAvgAAAAAAAAAAAACAPgAAAAAAAAAAAACAvgAAAADNzEw+AACAPgAAAADNzEw+AAAAAAEAAAACAAAAAgAAAAEAAAADAAAAAgAAAAMAAAAEAAAABAAAAAMAAAAFAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAADNzEw+AACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAgAAAgD80M7M+AAAAAM3MTL6amVk/AAAAAM3MTL40M7M+AAAAAAAAAACamVk/AAAAAAAAAAA0M7M+AAAAAM3MTD6amVk/AAAAAM3MTD4=","byteLength":464}],"bufferViews":[{"buffer":0,"byteLength":72,"name":"Positions"},{"buffer":0,"byteOffset":72,"byteLength":48,"name":"Indices"},{"buffer":0,"byteOffset":120,"byteLength":96,"name":"weights buffer view"},{"buffer":0,"byteOffset":216,"byteLength":48,"name":"joint indices buffer view"},{"buffer":0,"byteOffset":264,"byteLength":128,"name":"Inverse Bind Matrix"},{"buffer":0,"byteOffset":392,"byteLength":72,"name":"Positions"}],"materials":[{"pbrMetallicRoughness":{"baseColorFactor":[0.8,0.8,0.8,1]},"doubleSided":true},{"pbrMetallicRoughness":{"baseColorFactor":[0,0,1,1]},"doubleSided":true}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"WEIGHTS_0":2,"JOINTS_0":3},"indices":1,"material":0}]},{"primitives":[{"attributes":{"POSITION":5,"WEIGHTS_0":2,"JOINTS_0":3},"indices":1,"material":1}]}],"nodes":[{"skin":0,"mesh":0,"name":"plane"},{"children":[2],"rotation":[-0.7071067,0,0,0.70710677],"translation":[0,-0.2,0],"name":"joint0"},{"rotation":[-0.25881904,0,0,0.9659258],"translation":[0,0,0.2],"name":"joint1"},{"skin":0,"mesh":1,"name":"plane2","translation":[1,0,0]}],"scene":0,"scenes":[{"nodes":[0,1,3]}],"skins":[{"inverseBindMatrices":4,"joints":[1,2],"name":"skinA"}]}
Description
While investigating #4611 (whose premise turned out to be wrong: the transform of a skinned mesh node is already ignored, see the closing comment there) I found three real defects in how glb skins are bound. All three were verified by running the actual
GlbParser+ renderer onNullGraphicsDeviceand comparing, per skinned mesh instance and bone, what the vertex shader applies (node.worldTransform * skinInstance.matrices[i]) against the glTF spec value (jointWorldTransform[i] * inverseBindMatrix[i]) computed independently from the source file.SkinsSkinInstances1. The skin is stored on the shared
Mesh, so a mesh reused by several nodes with different skins gets the last skin for all of themThis is the architectural problem reported in #5680 (
Mesh.skinprevents instantiating several skins on one mesh). The assessment there that the impact is limited does not hold: it breaks a Khronos sample asset and is a regression, see below.linkSkinsinglb-parser.jsdoesmesh.skin = skins[gltfNode.skin]for every node that references the mesh, so the last node wins.GlbContainerResource.instantiateRenderEntitythen creates the skin instance frommeshInstance.mesh.skininstead of the node's skin. RecursiveSkeletons has 1 mesh referenced by 84 nodes, each with its own skin: every one of the 84 mesh instances ends up sharing a singleSkinInstancebound to skin 83, so all tentacle segments collapse onto the last skeleton section.This is a regression from #3350 (v1.45.2, July 2021): before it,
instantiateRenderEntitypassedskins[gltfNode.skin]for each node to the skin instance cache; #3350 replaced that withmeshInstance.mesh.skinand addedlinkSkins. The legacy Model path (GlbContainerResource.createModel) still looks upskins[gltfNode.skin]per node and binds all 84 correctly.Mesh._initBoneAabbsalso sizes the bone AABBs fromthis.skin.boneNames, so a mesh shared by skins with different joint counts would get wrong culling bounds even after fixing the binding.2.
createSkindeduplicates skins by joined joint names onlyThe
glbSkinscache (added in #2912) keys onboneNames.join('#'). Two glTF skins with the same joint names become one engineSkincarrying the first skin's inverse bind matrices, so differing IBMs are silently dropped. Bone resolution is also by name (rootBone.findByName), so two skeletons that reuse joint names collapse onto the first one. Duplicate node names are legal in glTF. Animation_Skin_11 (two skinA instances, both using joints namedjoint0/joint1) renders the second plane driven by the first skeleton.3. A shared
SkinInstanceassumes all its mesh instances share one node transformSkinInstanceCacheshares oneSkinInstanceper (skin, rootBone).Renderer.updateCpuSkinMatricescomputesmatrices[i] = inv(node.world) * bone.world * IBM[i]once per frame using the first mesh instance's node and skips the rest via_skinUpdateIndex, but every mesh instance still uploads its ownmatrix_model. The inverse only cancels for the first one; the others render offset bynodeB.world * inv(nodeA.world). Which mesh instance is "first" depends on layer order, so removing or disabling one skinned part shifts the others, which is the symptom described in #5329 (possibly the same defect). Per spec the skinned node's transform must be ignored, so exporters can legitimately leave any transform there. No stock Khronos asset has two differently-transformed nodes on one skin; the spec-valid file below (Animation_Skin_05 withtranslation: [1,0,0]onplane2) reproduces it, withplane2offset by exactly 1 unit.Steps to Reproduce
RecursiveSkeletons.glbas a container asset and callinstantiateRenderEntity(). Expected: 84 skinned segments along 4 recursive skeletons. Actual: everything is bound to the last skin.Animation_Skin_11.gltf. Expected: each plane follows its own skeleton. Actual: both follow the first.plane2is offset by 1 on X.Animation_Skin_05_offset.gltf (self-contained, buffers embedded)
{"accessors":[{"bufferView":0,"componentType":5126,"count":6,"type":"VEC3","max":[0.25,0,0.2],"min":[-0.25,0,-0.2],"name":"Positions Accessor"},{"bufferView":1,"componentType":5125,"count":12,"type":"SCALAR","name":"Indices Accessor"},{"bufferView":2,"componentType":5126,"count":6,"type":"VEC4","name":"weights accessor"},{"bufferView":3,"componentType":5123,"count":6,"type":"VEC4","name":"joint indices accessor"},{"bufferView":4,"componentType":5126,"count":2,"type":"MAT4","name":"IBM"},{"bufferView":5,"componentType":5126,"count":6,"type":"VEC3","max":[0.85,0,0.2],"min":[0.35000002,0,-0.2],"name":"Positions Accessor"}],"asset":{"generator":"glTF-Asset-Generator Animation_Skin_05 + translation on node 3 (plane2)","version":"2.0"},"buffers":[{"uri":"data:application/octet-stream;base64,AACAvgAAAADNzEy+AACAPgAAAADNzEy+AACAvgAAAAAAAAAAAACAPgAAAAAAAAAAAACAvgAAAADNzEw+AACAPgAAAADNzEw+AAAAAAEAAAACAAAAAgAAAAEAAAADAAAAAgAAAAMAAAAEAAAABAAAAAMAAAAFAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAADNzEw+AACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAgAAAgD80M7M+AAAAAM3MTL6amVk/AAAAAM3MTL40M7M+AAAAAAAAAACamVk/AAAAAAAAAAA0M7M+AAAAAM3MTD6amVk/AAAAAM3MTD4=","byteLength":464}],"bufferViews":[{"buffer":0,"byteLength":72,"name":"Positions"},{"buffer":0,"byteOffset":72,"byteLength":48,"name":"Indices"},{"buffer":0,"byteOffset":120,"byteLength":96,"name":"weights buffer view"},{"buffer":0,"byteOffset":216,"byteLength":48,"name":"joint indices buffer view"},{"buffer":0,"byteOffset":264,"byteLength":128,"name":"Inverse Bind Matrix"},{"buffer":0,"byteOffset":392,"byteLength":72,"name":"Positions"}],"materials":[{"pbrMetallicRoughness":{"baseColorFactor":[0.8,0.8,0.8,1]},"doubleSided":true},{"pbrMetallicRoughness":{"baseColorFactor":[0,0,1,1]},"doubleSided":true}],"meshes":[{"primitives":[{"attributes":{"POSITION":0,"WEIGHTS_0":2,"JOINTS_0":3},"indices":1,"material":0}]},{"primitives":[{"attributes":{"POSITION":5,"WEIGHTS_0":2,"JOINTS_0":3},"indices":1,"material":1}]}],"nodes":[{"skin":0,"mesh":0,"name":"plane"},{"children":[2],"rotation":[-0.7071067,0,0,0.70710677],"translation":[0,-0.2,0],"name":"joint0"},{"rotation":[-0.25881904,0,0,0.9659258],"translation":[0,0,0.2],"name":"joint1"},{"skin":0,"mesh":1,"name":"plane2","translation":[1,0,0]}],"scene":0,"scenes":[{"nodes":[0,1,3]}],"skins":[{"inverseBindMatrices":4,"joints":[1,2],"name":"skinA"}]}Suggested fixes
instantiateRenderEntity(skins[gltfNode.skin], ascreateModeldoes) and treatmesh.skinonly as the default for the render-asset path; make bone AABBs per skin rather than per mesh.SkinInstancecache), and resolve bones by node identity rather than by name when instantiating from a glb.rootBone) instead ofmeshInstance.node, and upload the root bone's world transform asmatrix_modelfor skinned draws (the skinned AABB path inmesh-instance.jsmust use the same frame). Alternatively include the mesh node in the cache key, at the cost of the sharing Render component is sharing and properly releasing skin instances #2912 introduced.Engine
mainat 63b1937. The math is backend independent.