[rmodels] Fix glTF skinning when joints have non-joint parent nodes#5876
Open
cseelhoff wants to merge 1 commit into
Open
[rmodels] Fix glTF skinning when joints have non-joint parent nodes#5876cseelhoff wants to merge 1 commit into
cseelhoff wants to merge 1 commit into
Conversation
Some glTF exporters (notably wow.export, but also various other DCC pipelines) place skin joints under intermediate non-joint transform nodes that carry part of the bind-pose offset. raylib's existing LoadBoneInfoGLTF and LoadModelAnimationsGLTF only inspected a joint's immediate parent and only sampled joint-local TRS, so any transform stored on an intermediate non-joint ancestor was silently dropped, producing exploded or stretched meshes at runtime. Two surgical changes: LoadBoneInfoGLTF: walk the parent chain past any non-joint ancestors when looking up parentIndex, instead of comparing only against node.parent. Joints whose direct parent is a non-joint were previously treated as skeleton roots. LoadModelAnimationsGLTF: precompute a per-joint extOffset matrix that bakes in the static TRS contribution of any intermediate non-joint nodes between the joint and its nearest joint ancestor. Apply it to each frame's joint TRS before BuildPoseFromParentJoints so the per-frame world transforms match the bind-pose world transforms (LoadGLTF already used cgltf_node_transform_world for bindPose, so this aligns the two code paths). The replaced root-only worldTransform adjustment is a strict subset of the new per-joint extOffset machinery, so it has been removed. Spec-compliant files (the six skeletal-skinning .glb examples shipped with raylib) render bit-identically before and after; previously broken files (e.g. wow.export's babyoctopus.gltf) now match the reference rendering from f3d, the Khronos sample viewer, and three.js.
Owner
|
@cseelhoff Please, could you provide some affected model for testing? |
Author
Contributor
|
Just started to test it. |
Contributor
|
The fix worked for the cow. I will try the rest. |
Contributor
|
Works nice, great work. |
kimkulling
reviewed
May 22, 2026
|
|
||
| for (unsigned int j = 0; j < skin.joints_count; j++) | ||
| cgltf_node *ancestor = node.parent; | ||
| while (ancestor != NULL && parentIndex == -1) |
Contributor
There was a problem hiding this comment.
Consider adding an inline function for this with a meaningful name.
kimkulling
suggested changes
May 22, 2026
| break; | ||
| if (skin.joints[j] == ancestor) { parentIndex = (int)j; break; } | ||
| } | ||
| if (parentIndex == -1) ancestor = ancestor->parent; |
| // store bone offsets on dummy parent nodes rather than on the | ||
| // joints themselves. Depends only on the skin, not the animation. | ||
| int jointCount = (int)skin.joints_count; | ||
| Matrix *extOffset = (Matrix *)RL_MALLOC(jointCount*sizeof(Matrix)); |
Contributor
There was a problem hiding this comment.
Check against NULL is missing.
| bool isJoint = false; | ||
| for (int jj = 0; jj < jointCount; jj++) | ||
| { | ||
| if (skin.joints[jj] == n) { isJoint = true; break; } |
| if (skin.joints[jj] == n) { isJoint = true; break; } | ||
| } | ||
| if (isJoint) break; | ||
| Matrix nm = MatrixMultiply(MatrixMultiply( |
Contributor
There was a problem hiding this comment.
Formatting or try to write this more readable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[rmodels] Fix glTF skinning when joints have non-joint parent nodes
Summary
Two surgical fixes in
src/rmodels.cthat correct glTF skinning for fileswhere the skin's joints are nested under intermediate non-joint transform
nodes that carry part of the bind-pose offset. Common in exports from
wow.export, and seen in some Blender/Maya/Houdini pipelines that wrap an
armature in a parent "rig" or "offset" empty.
Both spec-compliant files (every skeletal
.glbshipped with raylib'sexamples) and previously broken files now render correctly.
The bug
A glTF skin lists which nodes are joints. The skeleton hierarchy that
raylib reconstructs from that list assumes each joint's parent in
skin.joints[]equals its node-tree parent. Many real-world filesviolate this: a joint can have a non-joint ancestor (a plain transform
node) sitting between it and the next joint up the chain. That
intermediate node's TRS is part of the model's bind pose and must be
applied to the joint's animation frames; otherwise the per-frame world
transform diverges from the bind world transform, the inverse-bind
multiplication in
UpdateModelAnimationBoneMatricesproducesnon-identity skin matrices at rest, and the mesh explodes.
LoadGLTFalready handles this correctly when buildingbindPosebecause it calls
cgltf_node_transform_world(joint)per joint, whichwalks the full ancestor chain. The two animation-side functions did not.
Repro file: babyoctopus.gltf from a wow.export of a World of Warcraft
model. Stock raylib renders a stretched/inverted mesh; f3d, the Khronos
sample viewer, and three.js all render it correctly. Screenshots
attached.
The fix
1.
LoadBoneInfoGLTFReplace the single-step parent comparison with a loop that walks
node.parent->parent->parent...until it either hits a node listed inskin.joints[]or runs off the scene root. Joints whose direct parentis a non-joint were previously assigned
parent = -1(treated asskeleton roots), corrupting the chain used by
BuildPoseFromParentJoints.2.
LoadModelAnimationsGLTFPrecompute once per skin a
Matrix extOffset[k]per joint that bakes inthe composed TRS of every intermediate non-joint node between joint
kand its nearest joint ancestor. Then, in the per-frame loop, compose the
joint's local TRS into a matrix, multiply by
extOffset[k], andMatrixDecomposeback to TRS before storing inkeyframePoses. Theresult is the joint's transform expressed relative to its skeleton
parent, which is what
BuildPoseFromParentJointsexpects.This also subsumes the previous root-only
worldTransformadjustmentblock — the new
extOffset[0]covers it for the root joint andcorrectly extends the same logic to every joint. The dead variables and
the root patch-up are removed.
Diff size
+49 / -29 lines in one file. No new files, no API changes, no behaviour
changes for files that don't have intermediate non-joint nodes.
Validation
Built on Linux with cmake + gcc, no warnings.
Regression sweep across every skeletal
.glbin raylib's examples(
robot.glb,greenman.glb,raylib_logo_3d.glb,old_car_new.glb,plane.glb,shaders/robot.glb): pixel-identical output before/afterunder fixed-camera screenshot comparison. Any noise was sub-0.0002 and
traced to HUD framerate-counter antialiasing, not 3D output.
Visual verification of the broken file (babyoctopus.gltf): now matches
f3d, the Khronos sample viewer, and three.js.
Known limitation (pre-existing, not addressed by this PR)
If a non-joint ancestor uses a 4×4
matrixinstead of TRS(
node->has_matrix == 1in cgltf), this patch'sextOffsetbuilderreads
n->scale/n->rotation/n->translationdirectly and ignoresmatrix. This matches the rest ofrmodels.c(nohas_matrixreferences; joint TRS reader behaves the same way). Properly handling
matrix-form nodes would be a separate, file-wide change.
Checklist
rmodels.c