Skip to content

[rmodels] Fix glTF skinning when joints have non-joint parent nodes#5876

Open
cseelhoff wants to merge 1 commit into
raysan5:masterfrom
cseelhoff:fix-gltf-skinning-nonjoint-parents
Open

[rmodels] Fix glTF skinning when joints have non-joint parent nodes#5876
cseelhoff wants to merge 1 commit into
raysan5:masterfrom
cseelhoff:fix-gltf-skinning-nonjoint-parents

Conversation

@cseelhoff
Copy link
Copy Markdown

[rmodels] Fix glTF skinning when joints have non-joint parent nodes

Summary

Two surgical fixes in src/rmodels.c that correct glTF skinning for files
where 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 .glb shipped with raylib's
examples) 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 files
violate 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 UpdateModelAnimationBoneMatrices produces
non-identity skin matrices at rest, and the mesh explodes.

LoadGLTF already handles this correctly when building bindPose
because it calls cgltf_node_transform_world(joint) per joint, which
walks 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. LoadBoneInfoGLTF

Replace the single-step parent comparison with a loop that walks
node.parent->parent->parent... until it either hits a node listed in
skin.joints[] or runs off the scene root. Joints whose direct parent
is a non-joint were previously assigned parent = -1 (treated as
skeleton roots), corrupting the chain used by BuildPoseFromParentJoints.

2. LoadModelAnimationsGLTF

Precompute once per skin a Matrix extOffset[k] per joint that bakes in
the composed TRS of every intermediate non-joint node between joint k
and its nearest joint ancestor. Then, in the per-frame loop, compose the
joint's local TRS into a matrix, multiply by extOffset[k], and
MatrixDecompose back to TRS before storing in keyframePoses. The
result is the joint's transform expressed relative to its skeleton
parent, which is what BuildPoseFromParentJoints expects.

This also subsumes the previous root-only worldTransform adjustment
block — the new extOffset[0] covers it for the root joint and
correctly 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 .glb in raylib's examples
(robot.glb, greenman.glb, raylib_logo_3d.glb, old_car_new.glb,
plane.glb, shaders/robot.glb): pixel-identical output before/after
under 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 matrix instead of TRS
(node->has_matrix == 1 in cgltf), this patch's extOffset builder
reads n->scale/n->rotation/n->translation directly and ignores
matrix. This matches the rest of rmodels.c (no has_matrix
references; joint TRS reader behaves the same way). Properly handling
matrix-form nodes would be a separate, file-wide change.

Checklist

  • Fixes a real bug observed in third-party files
  • Two existing functions changed; no new APIs
  • No behavioural change for files that previously rendered correctly
  • Builds clean (Linux, gcc, no new warnings)
  • Code style matches surrounding rmodels.c
babyoctopus gltf_stock babyoctopus gltf_patch

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.
@raysan5
Copy link
Copy Markdown
Owner

raysan5 commented May 20, 2026

@cseelhoff Please, could you provide some affected model for testing?

@cseelhoff
Copy link
Copy Markdown
Author

cow.zip

@kimkulling
Copy link
Copy Markdown
Contributor

Just started to test it.

@kimkulling
Copy link
Copy Markdown
Contributor

The fix worked for the cow. I will try the rest.

@kimkulling
Copy link
Copy Markdown
Contributor

Works nice, great work.

Comment thread src/rmodels.c

for (unsigned int j = 0; j < skin.joints_count; j++)
cgltf_node *ancestor = node.parent;
while (ancestor != NULL && parentIndex == -1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding an inline function for this with a meaningful name.

Comment thread src/rmodels.c
break;
if (skin.joints[j] == ancestor) { parentIndex = (int)j; break; }
}
if (parentIndex == -1) ancestor = ancestor->parent;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line break.

Comment thread src/rmodels.c
// 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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check against NULL is missing.

Comment thread src/rmodels.c
bool isJoint = false;
for (int jj = 0; jj < jointCount; jj++)
{
if (skin.joints[jj] == n) { isJoint = true; break; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting

Comment thread src/rmodels.c
if (skin.joints[jj] == n) { isJoint = true; break; }
}
if (isJoint) break;
Matrix nm = MatrixMultiply(MatrixMultiply(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting or try to write this more readable.

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.

3 participants