PC3 Phase 1.2: placeholder avatar asset pack + make the loader actually assemble
assets/avatars/catalog.json referenced 14 GLB files that were never in the repo, so the avatar system could not load a single avatar. And the loader's assembly path had never run against a real skeleton -- it called Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a way that dropped the skin binding. - scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art swaps in as a pure content change (catalog paths unchanged). - assets/avatars/*.glb: the generated pack (checked in). - avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound Skeleton3D that generate_scene() already produces, instead of rebuilding it. Attachments now mount via BoneAttachment3D to the catalog's mount_bone and are actually added to the avatar tree (they were parsed then dropped). Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin, and the Array[String] type mismatch on the accessory list. - smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully because the files don't exist" section with a real assembly check -- Skeleton3D present, full 24-bone rig, canonical bones resolve by name, visible body mesh, equipped items mounted to bones. 14/14 from a cold cache. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
+116
-128
@@ -175,7 +175,7 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||
|
||||
# Check slot exclusivity for accessory slot.
|
||||
if slot_id == "accessory":
|
||||
var current_accessories: Array[String] = _attachments.get("accessory", [])
|
||||
var current_accessories: Array = _attachments.get("accessory", [])
|
||||
if current_accessories.size() >= MAX_ACCESSORY_COUNT:
|
||||
load_error.emit("Accessory slot full (%d/%d)" % [
|
||||
MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT])
|
||||
@@ -192,19 +192,37 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||
load_error.emit("Failed to parse attachment GLB: %s" % path)
|
||||
return null
|
||||
|
||||
# Retarget skin to the canonical skeleton.
|
||||
# If the attachment carries its own skin, retarget it to the canonical
|
||||
# skeleton; static props (the common case) are unaffected.
|
||||
_retarget_skin(attachment_node)
|
||||
|
||||
# Parent static accessories to their mount bone.
|
||||
_parent_attachment(attachment_node, slot_id, asset_meta)
|
||||
# Mount to the appropriate bone via a BoneAttachment3D so it follows the
|
||||
# skeleton. Falls back to a plain skeleton child if the mount bone is
|
||||
# missing (a bad catalog entry) rather than dropping the attachment.
|
||||
var mount_bone: String = asset_meta.get("mount_bone", "")
|
||||
if mount_bone.is_empty():
|
||||
mount_bone = _slot_to_mount_bone(slot_id)
|
||||
var mount_node: Node3D
|
||||
if not mount_bone.is_empty() and skeleton.find_bone(mount_bone) != -1:
|
||||
var ba := BoneAttachment3D.new()
|
||||
ba.name = "Mount_%s" % slot_id if slot_id != "accessory" else "Mount_accessory_%s" % asset_id
|
||||
ba.bone_name = mount_bone
|
||||
skeleton.add_child(ba)
|
||||
ba.add_child(attachment_node)
|
||||
mount_node = ba
|
||||
else:
|
||||
skeleton.add_child(attachment_node)
|
||||
mount_node = attachment_node
|
||||
|
||||
# Store reference.
|
||||
# Store the top-level mount node so unload can free the whole unit.
|
||||
if slot_id == "accessory":
|
||||
var list: Array[String] = _attachments.get("accessory", [])
|
||||
list.append(asset_id)
|
||||
var list: Array = _attachments.get("accessory", [])
|
||||
list.append(mount_node)
|
||||
_attachments["accessory"] = list
|
||||
else:
|
||||
_attachments[slot_id] = attachment_node
|
||||
if _attachments.has(slot_id) and is_instance_valid(_attachments[slot_id]):
|
||||
_attachments[slot_id].queue_free()
|
||||
_attachments[slot_id] = mount_node
|
||||
|
||||
attachment_loaded.emit(slot_id, attachment_node)
|
||||
return attachment_node
|
||||
@@ -213,15 +231,13 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
||||
## Remove an attachment from the given slot.
|
||||
func unload_attachment(slot_id: String) -> void:
|
||||
if slot_id == "accessory":
|
||||
var list: Array[String] = _attachments.get("accessory", [])
|
||||
for asset_id in list:
|
||||
var node = _attachments.get(asset_id)
|
||||
if node and node is Node:
|
||||
for node in _attachments.get("accessory", []):
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
_attachments.erase("accessory")
|
||||
else:
|
||||
var node = _attachments.get(slot_id)
|
||||
if node and node is Node:
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
_attachments.erase(slot_id)
|
||||
|
||||
@@ -280,58 +296,73 @@ func reset() -> void:
|
||||
# Internal: GLB Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Parse a GLB byte array into an avatar Node3D structure.
|
||||
## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints
|
||||
## Parse a GLB byte array into an avatar Node3D structure:
|
||||
## Avatar (Node3D)
|
||||
## Skeleton3D -- the canonical rig, with skinned body meshes as children
|
||||
## BodyMeshes (Node3D) -- references to the skinned body meshes, for slot hiding
|
||||
## 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 avatar := Node3D.new()
|
||||
avatar.name = "Avatar"
|
||||
|
||||
# Create the skeleton container.
|
||||
var skeleton_node := Skeleton3D.new()
|
||||
skeleton_node.name = "Skeleton3D"
|
||||
skeleton_node.position = Vector3(0, 0, 0)
|
||||
avatar.add_child(skeleton_node)
|
||||
|
||||
# Create attachment points container.
|
||||
var attach_points := Node3D.new()
|
||||
attach_points.name = "AttachmentPoints"
|
||||
avatar.add_child(attach_points)
|
||||
|
||||
# Create body meshes container.
|
||||
var body_meshes := Node3D.new()
|
||||
body_meshes.name = "BodyMeshes"
|
||||
avatar.add_child(body_meshes)
|
||||
|
||||
# Create attachments container.
|
||||
var attachments_container := Node3D.new()
|
||||
attachments_container.name = "Attachments"
|
||||
avatar.add_child(attachments_container)
|
||||
|
||||
# Parse the GLB and extract skeleton + meshes.
|
||||
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))
|
||||
avatar.queue_free()
|
||||
return null
|
||||
|
||||
# Generate the actual scene tree from the parsed GLTF state.
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
if scene_root == null:
|
||||
if not (scene_root is Node3D):
|
||||
push_error("Failed to generate scene from GLB: %s" % path)
|
||||
avatar.queue_free()
|
||||
if scene_root:
|
||||
scene_root.queue_free()
|
||||
return null
|
||||
|
||||
# Merge GLB nodes into our avatar structure (recurses into children).
|
||||
if scene_root is Node3D:
|
||||
_merge_node_into_avatar(scene_root, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
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()
|
||||
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()
|
||||
|
||||
var body_meshes := Node3D.new()
|
||||
body_meshes.name = "BodyMeshes"
|
||||
avatar.add_child(body_meshes)
|
||||
|
||||
var attachments_container := Node3D.new()
|
||||
attachments_container.name = "Attachments"
|
||||
avatar.add_child(attachments_container)
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
## Depth-first search for the first Skeleton3D under [param node].
|
||||
func _find_first_skeleton(node: Node) -> Skeleton3D:
|
||||
if node is Skeleton3D:
|
||||
return node as Skeleton3D
|
||||
for child in node.get_children():
|
||||
var found := _find_first_skeleton(child)
|
||||
if found:
|
||||
return found
|
||||
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:
|
||||
@@ -345,99 +376,52 @@ func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
|
||||
return null
|
||||
|
||||
var scene_root := gltf_document.generate_scene(gltf_state)
|
||||
if scene_root == null:
|
||||
if not (scene_root is Node3D):
|
||||
push_error("Failed to generate scene from attachment GLB: %s" % path)
|
||||
if scene_root:
|
||||
scene_root.queue_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()]
|
||||
|
||||
if scene_root is Node3D:
|
||||
attachment.add_child(scene_root.duplicate())
|
||||
|
||||
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()
|
||||
return attachment
|
||||
|
||||
|
||||
## Merge a GLB node tree into the avatar's container structure.
|
||||
func _merge_node_into_avatar(node: Node3D, avatar: Node3D,
|
||||
body_meshes: Node3D, attach_points: Node3D,
|
||||
attachments_container: Node3D) -> void:
|
||||
|
||||
var node_name := node.name
|
||||
|
||||
# Identify skeleton nodes.
|
||||
if node is Skeleton3D:
|
||||
var src_skel := node as Skeleton3D
|
||||
var dst_skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||
if dst_skel:
|
||||
# Copy bones from source skeleton to destination.
|
||||
for i in src_skel.get_bone_count():
|
||||
var bone_name := src_skel.get_bone_name(i)
|
||||
var bone_pose := src_skel.get_bone_pose(i)
|
||||
var parent_idx := src_skel.get_bone_parent(i)
|
||||
if dst_skel.has_bone(bone_name):
|
||||
# Bone already exists (canonical).
|
||||
dst_skel.set_bone_pose(i, bone_pose)
|
||||
if parent_idx >= 0:
|
||||
var parent_name := src_skel.get_bone_name(parent_idx)
|
||||
var dst_parent_idx := dst_skel.find_bone(parent_name)
|
||||
if dst_parent_idx >= 0:
|
||||
dst_skel.set_bone_parent(i, dst_parent_idx)
|
||||
else:
|
||||
# Add new bone.
|
||||
var new_idx := dst_skel.add_bone(bone_name)
|
||||
dst_skel.set_bone_pose(new_idx, bone_pose)
|
||||
if parent_idx >= 0:
|
||||
var parent_name := src_skel.get_bone_name(parent_idx)
|
||||
var dst_parent_idx := dst_skel.find_bone(parent_name)
|
||||
if dst_parent_idx >= 0:
|
||||
dst_skel.set_bone_parent(new_idx, dst_parent_idx)
|
||||
# Skinned meshes are commonly children of the skeleton node in a GLB
|
||||
# export, not siblings — fall through to the recursion below instead
|
||||
# of returning, so they still get merged in.
|
||||
for child in src_skel.get_children():
|
||||
if child is Node3D:
|
||||
_merge_node_into_avatar(child, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
return
|
||||
|
||||
# Identify attachment point nodes (empty nodes named attach_*).
|
||||
if node_name.begins_with("attach_"):
|
||||
var cloned := node.duplicate()
|
||||
cloned.name = node_name
|
||||
attach_points.add_child(cloned)
|
||||
return
|
||||
|
||||
# Identify mesh nodes.
|
||||
if node is MeshInstance3D:
|
||||
var mesh_inst := node as MeshInstance3D
|
||||
var cloned := mesh_inst.duplicate() as MeshInstance3D
|
||||
# Determine which container to place it in.
|
||||
if node_name == "Body" or node_name.ends_with("_Mesh"):
|
||||
body_meshes.add_child(cloned)
|
||||
else:
|
||||
attachments_container.add_child(cloned)
|
||||
return
|
||||
|
||||
# For other nodes, recurse into children.
|
||||
## generate_scene() stamps every node with the GLB root as `owner`; that
|
||||
## becomes inconsistent (and noisy) once we reparent the node elsewhere.
|
||||
func _clear_owner_recursive(node: Node) -> void:
|
||||
node.owner = null
|
||||
for child in node.get_children():
|
||||
if child is Node3D:
|
||||
_merge_node_into_avatar(child, avatar, body_meshes,
|
||||
attach_points, attachments_container)
|
||||
_clear_owner_recursive(child)
|
||||
|
||||
|
||||
## Extract base body meshes from the avatar and store them by slot.
|
||||
## Index the base body's skinned meshes by slot so equipping a body-part
|
||||
## attachment can hide the corresponding base mesh. generate_scene() parents
|
||||
## skinned meshes under the Skeleton3D, so scan there.
|
||||
func _extract_base_meshes(avatar: Node3D) -> void:
|
||||
var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D
|
||||
if body_meshes == null:
|
||||
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
||||
if skel == null:
|
||||
return
|
||||
|
||||
for child in body_meshes.get_children():
|
||||
for child in skel.get_children():
|
||||
if child is MeshInstance3D:
|
||||
var mesh_name := child.name
|
||||
var slot_id := _mesh_name_to_slot(mesh_name)
|
||||
if slot_id != "":
|
||||
_base_meshes[slot_id] = child
|
||||
# The single "Body" placeholder mesh is registered only under
|
||||
# "torso" -- mapping it to every body-part slot would make it
|
||||
# vanish the moment any one body-part item is equipped. A real
|
||||
# multi-mesh body registers each mesh under its own slot.
|
||||
if child.name == "Body":
|
||||
_base_meshes["torso"] = child
|
||||
else:
|
||||
var slot_id := _mesh_name_to_slot(child.name)
|
||||
if slot_id != "":
|
||||
_base_meshes[slot_id] = child
|
||||
|
||||
|
||||
## Map a mesh name to a slot id based on naming conventions (§4.3).
|
||||
@@ -484,8 +468,12 @@ func _retarget_skin(attachment_node: Node3D) -> void:
|
||||
|
||||
for surface_idx in surface_count:
|
||||
var arrays := arr_mesh.surface_get_arrays(surface_idx)
|
||||
var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array
|
||||
var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array
|
||||
# Static props (the common attachment) carry no bone/weight
|
||||
# arrays at all -- nothing to retarget.
|
||||
if arrays[Mesh.ARRAY_BONES] == null or arrays[Mesh.ARRAY_WEIGHTS] == null:
|
||||
continue
|
||||
var joints_array: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var weights_array: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
|
||||
if joints_array.is_empty():
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user