Fix: avatars are capsules in the exe -- use load().instantiate(), not runtime GLTFDocument
checkpoint / checkpoint (push) Successful in 1m47s
checkpoint / checkpoint (push) Successful in 1m47s
Playtest log had the smoking gun: WARNING: player.gd: no avatar to show; keeping the placeholder capsule ERROR: Parameter "data.tree" is null. at: get_tree avatar_loader.gd read raw .glb bytes and ran GLTFDocument.append_from_buffer + generate_scene() at runtime. In a release export that path produced NO Skeleton3D (works fine in the editor / headless-from-source), so load_base_body() returned null and the player fell back to the capsule. The queue_free() calls in that path also errored (loader runs detached from any tree). Fix: load(path).instantiate() the Godot-imported PackedScene (the .scn that actually ships in the pack, with the skeleton + skin set up by the import pipeline). The raw-bytes GLTFDocument path stays available for future user-uploaded models but isn't used for the bundled catalog. - run_checkpoint_tests.py: also runs the avatar smoke against the EXPORTED pack, not just source -- that's what catches release-only resource differences. Verified: 14/14 against export/game.pck. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
@@ -119,14 +119,9 @@ func load_base_body(base_body_id: String) -> Node3D:
|
||||
return null
|
||||
|
||||
var path: String = asset_meta.path
|
||||
var glb_data: PackedByteArray = _load_file(path)
|
||||
if glb_data.is_empty():
|
||||
load_error.emit("Failed to read GLB file: %s" % path)
|
||||
return null
|
||||
|
||||
avatar_node = _parse_glb_as_avatar(glb_data, path)
|
||||
avatar_node = _instance_glb_as_avatar(path)
|
||||
if avatar_node == null:
|
||||
load_error.emit("Failed to parse GLB: %s" % path)
|
||||
load_error.emit("Failed to load base body scene: %s" % path)
|
||||
return null
|
||||
|
||||
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||
@@ -182,14 +177,9 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||
return null
|
||||
|
||||
var path: String = asset_meta.path
|
||||
var glb_data: PackedByteArray = _load_file(path)
|
||||
if glb_data.is_empty():
|
||||
load_error.emit("Failed to read GLB file: %s" % path)
|
||||
return null
|
||||
|
||||
var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id)
|
||||
var attachment_node = _instance_glb_as_attachment(path, slot_id)
|
||||
if attachment_node == null:
|
||||
load_error.emit("Failed to parse attachment GLB: %s" % path)
|
||||
load_error.emit("Failed to load attachment scene: %s" % path)
|
||||
return null
|
||||
|
||||
# If the attachment carries its own skin, retarget it to the canonical
|
||||
@@ -296,50 +286,44 @@ func reset() -> void:
|
||||
# Internal: GLB Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Parse a GLB byte array into an avatar Node3D structure:
|
||||
## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar
|
||||
## structure:
|
||||
## Avatar (Node3D)
|
||||
## Skeleton3D -- the canonical rig, with skinned body meshes as children
|
||||
## BodyMeshes (Node3D) -- references to the skinned body meshes, for slot hiding
|
||||
## Skeleton3D -- the canonical rig + skin-bound body meshes
|
||||
## BodyMeshes (Node3D) -- unused container, kept for compatibility
|
||||
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
|
||||
##
|
||||
## Rather than hand-copying the GLB skeleton bone-by-bone (which loses the skin
|
||||
## binding), this uses GLTFDocument.generate_scene() -- which already produces a
|
||||
## correctly skin-bound Skeleton3D + MeshInstance3D tree -- and lifts that
|
||||
## skeleton into our container structure intact.
|
||||
func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D:
|
||||
var gltf_document := GLTFDocument.new()
|
||||
var gltf_state := GLTFState.new()
|
||||
|
||||
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % error_string(parse_result))
|
||||
## Uses load().instantiate() rather than the runtime GLTFDocument path: the
|
||||
## imported .scn is the one that actually ends up in an exported build, with
|
||||
## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
|
||||
## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
|
||||
## release build: no Skeleton3D produced, so the player fell back to a capsule.)
|
||||
func _instance_glb_as_avatar(path: String) -> Node3D:
|
||||
var packed = load(path)
|
||||
if not (packed is PackedScene):
|
||||
push_error("Base body is not an importable scene: %s" % path)
|
||||
return null
|
||||
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
var scene_root: Node = packed.instantiate()
|
||||
if not (scene_root is Node3D):
|
||||
push_error("Failed to generate scene from GLB: %s" % path)
|
||||
if scene_root:
|
||||
scene_root.queue_free()
|
||||
push_error("Base body scene root is not a Node3D: %s" % path)
|
||||
scene_root.free()
|
||||
return null
|
||||
|
||||
var src_skel := _find_first_skeleton(scene_root)
|
||||
if src_skel == null:
|
||||
push_error("No Skeleton3D in generated scene for base body: %s" % path)
|
||||
scene_root.queue_free()
|
||||
push_error("No Skeleton3D in base body scene: %s" % path)
|
||||
scene_root.free()
|
||||
return null
|
||||
|
||||
var avatar := Node3D.new()
|
||||
avatar.name = "Avatar"
|
||||
|
||||
# Reparent the real (skin-bound) skeleton into our structure, renamed so
|
||||
# downstream get_node("Skeleton3D") lookups work regardless of the GLB's
|
||||
# own naming.
|
||||
src_skel.get_parent().remove_child(src_skel)
|
||||
src_skel.name = "Skeleton3D"
|
||||
src_skel.transform = Transform3D.IDENTITY
|
||||
_clear_owner_recursive(src_skel)
|
||||
avatar.add_child(src_skel)
|
||||
scene_root.queue_free()
|
||||
scene_root.free()
|
||||
|
||||
var body_meshes := Node3D.new()
|
||||
body_meshes.name = "BodyMeshes"
|
||||
@@ -363,34 +347,26 @@ func _find_first_skeleton(node: Node) -> Skeleton3D:
|
||||
return null
|
||||
|
||||
|
||||
## Parse a GLB byte array into an attachment Node3D.
|
||||
func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
|
||||
slot_id: String) -> Node3D:
|
||||
|
||||
var gltf_document := GLTFDocument.new()
|
||||
var gltf_state := GLTFState.new()
|
||||
|
||||
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % error_string(parse_result))
|
||||
## Instantiate an attachment .glb (imported PackedScene) into a single named
|
||||
## Node3D the caller can mount to a bone as one unit.
|
||||
func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
|
||||
var packed = load(path)
|
||||
if not (packed is PackedScene):
|
||||
push_error("Attachment is not an importable scene: %s" % path)
|
||||
return null
|
||||
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
var scene_root: Node = packed.instantiate()
|
||||
if not (scene_root is Node3D):
|
||||
push_error("Failed to generate scene from attachment GLB: %s" % path)
|
||||
if scene_root:
|
||||
scene_root.queue_free()
|
||||
push_error("Attachment scene root is not a Node3D: %s" % path)
|
||||
scene_root.free()
|
||||
return null
|
||||
|
||||
# Wrap the generated attachment geometry in a single named Node3D so the
|
||||
# caller can mount it to a bone as one unit.
|
||||
var attachment := Node3D.new()
|
||||
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
|
||||
for child in scene_root.get_children().duplicate():
|
||||
scene_root.remove_child(child)
|
||||
_clear_owner_recursive(child)
|
||||
attachment.add_child(child)
|
||||
scene_root.queue_free()
|
||||
scene_root.free()
|
||||
return attachment
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +191,23 @@ def main():
|
||||
r = run([godot_bin, "--headless", "--path", str(ROOT),
|
||||
"--script", "res://scripts/verify_export_contents.gd"], env=godot_env)
|
||||
results["export packaging"] = r.returncode == 0
|
||||
|
||||
# Run a key smoke against the EXPORTED pack, not just source --
|
||||
# release-build resources differ (e.g. the runtime GLTFDocument
|
||||
# path produced no skeleton in a real export; load().instantiate()
|
||||
# of the imported .scn does). This runs the avatar smoke on
|
||||
# exactly what ships.
|
||||
exported_pck = ROOT / "export" / "_ci_check.pck"
|
||||
exported_pck.parent.mkdir(exist_ok=True)
|
||||
ep = run([godot_bin, "--headless", "--path", str(ROOT),
|
||||
"--export-pack", "Linux", str(exported_pck)], env=godot_env)
|
||||
if ep.returncode == 0 and exported_pck.exists():
|
||||
r = run([godot_bin, "--headless", "--main-pack", str(exported_pck),
|
||||
"--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env)
|
||||
results["avatar smoke (exported pack)"] = r.returncode == 0
|
||||
exported_pck.unlink(missing_ok=True)
|
||||
else:
|
||||
results["avatar smoke (exported pack)"] = False
|
||||
finally:
|
||||
server_proc.terminate()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user