Skip to content

GLTF: Don't collapse non-joint leaf nodes when importing skeletons #104184

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

Merged
merged 1 commit into from
Jun 9, 2025

Conversation

aaronfranke
Copy link
Member

@aaronfranke aaronfranke commented Mar 15, 2025

Fixes issue #75917, fixes issue #92447, supersedes PR #92452, supersedes PR #98516.

This PR fixes an issue encountered by the Blender Project DogWalk team where non-joint leaf glTF nodes were getting always collapsed into the skeleton instead of being imported as nodes. The file they exported from Blender was correctly able to be imported back into Blender, but did not work correctly in Godot. This PR fixes that.

When I initially opened this PR I tried to keep the changes minimal, but I kept running into edge case after edge case, and I finally decided I needed to completely refactor this code to make it work.

In SkinTool, the existing code essentially dug down into the node hierarchy of a skeleton root and assigned everything inside as a joint, but this isn't what we want. The correct behavior is that only joint nodes and their ancestors up to the skeleton root should be generated as bones, along with the first level of leaf bones. If a leaf glTF node is not a joint, we should allow it to stay as a node, following the skeleton's bones.

However, this leads to a problem where we generate too many nodes, so that's why this PR needs to refactor the main node generation function to make this work. We need to only generate nodes when needed, only generate bone attachments when needed, only generate the correct amount of bones, and ensure nested skeleton leaf node trees stay together. All of this is required to be consistent with the behavior in Blender.

Here is a minimal reproduction asset: skinD_09_modified.zip. This file is the "skinD" example in Animation_Skin_09.gltf which can be found here https://github.com/KhronosGroup/glTF-Asset-Generator/tree/main/Output/Positive/Animation_Skin which showcases most of the edge cases. I've modified this file to have a non-joint leaf node in the skeleton so that it showcases the problem this PR fixes. In master, this file imports like this:

Screenshot 2025-03-15 at 8 55 57 AM

With this PR, the leaf node does not get collapsed, and becomes a node, with a parent attached to the bone:

Screenshot 2025-03-17 at 1 32 16 AM

With this PR, Godot's import of the file matches the correct behavior of how Blender imports the same file.

@fire
Copy link
Member

fire commented Mar 15, 2025

There are some github action errors. They look minor.

@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch from 67c44cd to 9b81722 Compare March 15, 2025 16:38
skeleton->joints.push_back(node_i);
} else {
non_joints.push_back(node_i);
// If a joint node has non-joint parents, we need to make them joints as well.
Copy link
Member

Choose a reason for hiding this comment

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

Since we're changing the algorithm for the node tree structure and that breaks the scene tree when doing inherited scenes. We've tended to do a forward upgrade import boolean.

Copy link
Member

Choose a reason for hiding this comment

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

It's been a long time since we've had one, but maybe @lyuma remember how we did it.

Copy link
Member Author

@aaronfranke aaronfranke Mar 15, 2025

Choose a reason for hiding this comment

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

The set of nodes generated with this PR should be a superset of the nodes generated in master.

Copy link
Member

Choose a reason for hiding this comment

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

For example inherited nodes will have a different node path. Let's say when we place a magical rod in the bone attachment. So I'm worried this will shift if the Paths are not exactly the same. I can't experiment on this due to a pending trip.

@fire
Copy link
Member

fire commented Mar 15, 2025

@fire
Copy link
Member

fire commented Mar 15, 2025

Also, GLTF has no concept of skeleton root. So in theory any bone parent node up to the root node can be a skeleton root.

https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#_skin_skeleton

@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch from 9b81722 to cb28fb3 Compare March 15, 2025 17:14
@aaronfranke aaronfranke marked this pull request as draft March 15, 2025 17:19
@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch 2 times, most recently from 981d788 to 3e0d4cf Compare March 16, 2025 20:39
@lyuma
Copy link
Contributor

lyuma commented Mar 17, 2025

If we opt to fix this, we should also fix mesh nodes. See how transformNode is both a node and a BoneAttachment + MeshInstance3D? Users also want those to become standalone nodes.

Our current behavior in Godot 4.x guarantees glTF compatibility, and Just Works without a sea of edge cases, but at the expense of usability for some users. As I understand it, though, your change is not fundamentally changing all aspects of the skeleton (these leaf nodes will still also be bones, but it adds a case where it is creating a bone attachment? it's a bit hard to follow what changed with the refactor)

Personally, I don't like all of the _end nodes creating BoneAttachments. That creates a gigantic mess on any model with hundreds of bones, and I am relieved to see those end nodes collapsed into the skeleton. I'm worried this sort of change will bring back messy skeletons, at least on FBX files (since Blender adds _end leaf bones by default).

We need to only generate nodes when needed, only generate bone attachments when needed, only generate the correct amount of bones, and ensure nested skeleton leaf node trees stay together.

I'm not sure I understand how you can know this. I guess if it came from Blender, you can make some assumptions since Blender always explicitly exports all skeleton bones as joints (this is how you know), but in general glTF has no way of communicating if a node is "when needed". The joints array isn't guaranteed to exist (and in the leaf bone case, I don't think Blender adds those to joints during .fbx export).

The correct behavior ...

Unless I'm mistaken, this isn't about correctness in the glTF spec: it is about matching the scene tree in Blender, right? I do like improving compatibility with Blender, but I'd be worried about introducing bugs with exports from other DCC applications (or even FBX, which also uses SkinTool)

I don't personally have the mental bandwidth to really dig into this problem, figure out which cases should be nodes and which should be bones, evaluate the edge cases, figure out animation bugs (remember that Godot stores bone and node transforms differently), and think about compatibility breakage, and then figure out how to rewrite all of my addons to support the new behavior (since having a mix of bones and nodes will break all of my skeleton modifier code). This is just too complex a problem for me to get into it for the foreseeable future... so I'd personally like to recuse myself from involvement in this PR.

The compatibility options can still be used where needed in projects that depend on the old behavior, so I understand the compatibility risk is minimal in this sense.

@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch from 3e0d4cf to 02c1134 Compare March 17, 2025 08:32
@aaronfranke
Copy link
Member Author

aaronfranke commented Mar 17, 2025

If we opt to fix this, we should also fix mesh nodes. See how transformNode is both a node and a BoneAttachment + MeshInstance3D? Users also want those to become standalone nodes.

What do you mean? The transformNode node is not a skinned mesh, so it must be attached to the bone. This is required to be compliant with the example file from Khronos.

Personally, I don't like all of the _end nodes creating BoneAttachments. .. at least on FBX files since Blender adds

This does not happen with this PR, at least not with FBX files exported from Blender. _end bones don't get attachments.

I'm not sure I understand how you can know this.
Unless I'm mistaken, this isn't about correctness in the glTF spec: it is about matching the scene tree in Blender, right?

Yes, it's matching the behavior in Blender. Well, glTF doesn't specify how Godot or Blender should import these, but the existence of joints is pretty clearly defining which glTF nodes are bones in a skeleton.

but in general glTF has no way of communicating if a node is "when needed"

If we don't do this, people expecting to have nodes attached to skeletons (ex: the Blender Project DogWalk team) have no way to do this... almost. If we did keep this behavior, there is still a workaround that does require the refactor in my PR but undoing the SkinTool changes. Currently Godot doesn't consider GLTFDocumentExtension classes when deciding if a bone should have a node generated, but this is a mistake. However, I don't think we should require users to do this. People should be able to attach a plain node in Blender without glTF extensions and use that in Godot directly.

The joints array isn't guaranteed to exist

It is guaranteed to exist: https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/skin.schema.json#L33

I don't personally have the mental bandwidth to really dig into this problem,

Honestly I completely understand that. It has taken me days to do this. In fact, I thought I had it, so I opened this PR, but then I found more edge cases, so I had to put this PR as draft and rework things like 3 more times before I got it right. The old test files in the repo @fire sent were immensely useful because they contained so many edge cases. I believe this PR now works correctly for all of those.

The compatibility options can still be used where needed in projects that depend on the old behavior, so I understand the compatibility risk is minimal in this sense.

Yes, and I've updated this PR to add a new number to the glTF compatibility option.

@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch 3 times, most recently from ce611cb to 22e1225 Compare March 19, 2025 08:32
@fire
Copy link
Member

fire commented Mar 28, 2025

What's the state of this?

@aaronfranke
Copy link
Member Author

@fire Probably all good now as far as I can tell, but given the impact and previous problems, we should do more testing first. Regardless I am confident this is better than the current master but ideally we find any other edge cases and fix them, if any more exist.

@fire fire moved this to Work in progress in Asset Pipeline Issue Triage Apr 13, 2025
@fire
Copy link
Member

fire commented Apr 13, 2025

Why is this still a draft pull request?

@fire fire moved this from Work in progress to Ready for review in Asset Pipeline Issue Triage Apr 13, 2025
@aaronfranke
Copy link
Member Author

aaronfranke commented May 25, 2025

@lyuma Yes, this PR has the correct behavior for round-tripping out of Godot and back into itself, and into Blender.

Correctly imported Godot scene from the test file in the PR description:

Screenshot 2025-05-25 at 11 51 18 AM

Correctly exported from Godot: exported.zip

The mesh on transformNode is given its own node on import, which is the correct behavior (remember that this is an edge case, we need to handle it to be spec-compliant but this is not possible to represent in either Godot or Blender), and then is given a unique name on export (not sure why 3 but again it's an edge case). The Skeleton3D's joint3 bone is a leaf bone, which is the correct behavior. It's interesting to see that the node order changed to be alphabetical, I'm not quite sure why, but well, either order is correct. The AnimationPlayer node should probably be excluded from export, but that's a separate issue from this PR (I tested that it happens on master).

Correctly imported Godot scene from the exported glTF file:

Screenshot 2025-05-25 at 11 52 00 AM

Correctly imported into Blender:

Screenshot 2025-05-25 at 12 00 05 PM

@fire
Copy link
Member

fire commented May 25, 2025

I think it's good to go, but will poke @lyuma for an approval.

@fire fire requested a review from a team June 1, 2025 20:05
@TokageItLab
Copy link
Member

TokageItLab commented Jun 1, 2025

I am concerned about the compatibility with the Import as Skeleton Bones option implemented by #88819. Since I remember it being like making all the Armature's child nodes (even if without weight) into bones.

image

@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch from 7153616 to 91b06bb Compare June 2, 2025 06:01
@aaronfranke
Copy link
Member Author

aaronfranke commented Jun 2, 2025

@TokageItLab Thanks, that was indeed not working. I've updated this PR to fix that option. The new parameter added to SkinTool::_determine_skeletons for whether to turn non-joint descendant nodes into bones or not needs to be true in the case of importing the whole scene as skeleton bones, and true in compat mode, but false if neither of those are the case.

if (p_state->get_import_as_skeleton_bones()) {
	err = SkinTool::_determine_skeletons(p_state->skins, p_state->nodes, p_state->skeletons, p_state->root_nodes, true);
} else {
	err = SkinTool::_determine_skeletons(p_state->skins, p_state->nodes, p_state->skeletons, Vector<GLTFNodeIndex>(), _naming_version < 2);
}

In a future compat breakage, we could consolidate the boolean into just checking if the root bone array is empty or not.

Copy link
Contributor

@lyuma lyuma left a comment

Choose a reason for hiding this comment

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

Seems to mostly work well for the test cases, so I think it looks good.

There's a special case function _does_skinned_mesh_require_placeholder_node which I think is unnecessary and incorrect. It works well after I remove it.

}
}

bool GLTFDocument::_does_skinned_mesh_require_placeholder_node(Ref<GLTFState> p_state, Ref<GLTFNode> p_gltf_node) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This function causes a regression in https://github.com/godotengine/godot-tests/tree/master/tests/gltf_skeleton_tests/gltf_tests: MeshNode becomes both a bone attachment, an empty and the mesh node is renamed to MeshNode2, even though the BoneAttachment is not needed.

image
MeshSkinnedToItselfAndOthers.glb

To fix it, I enitrely removed _does_skinned_mesh_require_placeholder_node (made it always return false), and it seemed to solve the rest of the corner cases. I tested with both the above gltf_skeleton_tests as well as the MRP from #67773 which is mentioned in the "Edge case" comment below, and I found it works correctly.

Copy link
Member Author

@aaronfranke aaronfranke Jun 8, 2025

Choose a reason for hiding this comment

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

Part of this function is an extracted version of the existing logic here

if (gltf_node->skin >= 0 && gltf_node->mesh >= 0 && !gltf_node->children.is_empty()) {

You added this in PR #72158 actually: https://github.com/godotengine/godot/pull/72158/files#diff-fdf33f6fcd2b129d540ebd0e70fd04982b68e279cebfd9e96036d2fd7561c31fR5643

The function checks for skinned meshes (existing check), checks for non-joint children (modified from the check for no children), and checks if child joints don't have their skeletons in the tree yet (new check, an edge case that becomes required to check for because of the modified second check). Replacing the function with return false; ignores part of the point of this PR, so that's the wrong move.

I think this explains it: The function is technically correct that we do need a placeholder in this case, but where we go wrong is by generating a new placeholder node. The subtlety here is that in this case, the Skeleton3D itself should be the placeholder. In some cases we need a new placeholder within a skeleton, in most cases we need to make a new Skeleton3D node that doesn't map to any glTF node, but in this case, these overlap, so we can feed two birds with one scone by using the Skeleton3D as the placeholder, inserting it into the tree immediately.

I just pushed this PR with this diff, which fixes the file you linked.

Screenshot 2025-06-08 at 2 14 26 PM

Additionally, this now means the generated tree matches what GLTFNode.get_scene_node_path expects with PR #104787 included (errors in the current master).

EDIT: It is also possible that user code generates a Skeleton3D node in a GLTFDocumentExtension, and this code also works for that case, so long as the user wants it named Skeleton3D or sets its name manually.

@TokageItLab TokageItLab modified the milestones: 4.x, 4.5 Jun 8, 2025
@TokageItLab TokageItLab moved this from Ready for review to Approved, Waiting for Production in Asset Pipeline Issue Triage Jun 8, 2025
@aaronfranke aaronfranke force-pushed the gltf-non-joint-leaf branch from 91b06bb to 4ad56f9 Compare June 8, 2025 21:16
Copy link
Contributor

@lyuma lyuma left a comment

Choose a reason for hiding this comment

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

It's hard for me to conceptualize enough of this code to be 100% certain, but you offer a reasonable explanation of the fix to the placeholder -> skinned mesh issue, and it seems to cover the test cases I have, so I'm satisfied.

I'll continue testing during beta, but I think it's good to merge. Nice work solving such a difficult round trip problem, Aaron.

@TokageItLab TokageItLab requested review from akien-mga and Repiteo June 9, 2025 16:17
@Repiteo Repiteo merged commit 3cc71ef into godotengine:master Jun 9, 2025
20 checks passed
@github-project-automation github-project-automation bot moved this from Approved, Waiting for Production to Done in Asset Pipeline Issue Triage Jun 9, 2025
@Repiteo
Copy link
Contributor

Repiteo commented Jun 9, 2025

Thanks!

@JonqsGames
Copy link
Contributor

Sorry but the simple test project on my PR #98516 is not working as expected. I think it even worse since now parenting looks mixed up. Can you confirm that this PR was meant to fix the leaf bone created by godot when mesh is parented to a bone ?

@aaronfranke
Copy link
Member Author

@JonqsGames Make sure you change the naming version to "Godot 4.5 or later" in the Import dock.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Development

Successfully merging this pull request may close these issues.

Importing GLTF add fake bones to the skeleton Blender asset import creates a new bone for every object parented to same bone
6 participants