d6cd3293ae
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
555 lines
17 KiB
GDScript
555 lines
17 KiB
GDScript
## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting.
|
|
##
|
|
## Loads base body and attachment GLB assets, extracts skeletons and meshes,
|
|
## and re-targets attachment skins to the canonical skeleton by bone name.
|
|
##
|
|
## Per the avatar architecture spec (§6.2, §6.3):
|
|
## - The base body carries the canonical skeleton.
|
|
## - Attachments are skinned to the same skeleton by name-based re-targeting.
|
|
## - Static accessories are parented to their mount bone.
|
|
## - Body-part slots hide the corresponding base mesh.
|
|
|
|
class_name AvatarLoader
|
|
extends Node
|
|
|
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Maximum number of attachments allowed in the accessory slot.
|
|
const MAX_ACCESSORY_COUNT := 8
|
|
|
|
## Skeleton bone names that form the canonical rig (§3.1).
|
|
const CANONICAL_BONES := [
|
|
"Root",
|
|
"Hips",
|
|
"Spine",
|
|
"Chest",
|
|
"Neck",
|
|
"Head",
|
|
"HeadTop",
|
|
"Jaw",
|
|
"LeftEye",
|
|
"RightEye",
|
|
"LeftShoulder",
|
|
"LeftUpperArm",
|
|
"LeftLowerArm",
|
|
"LeftHand",
|
|
"RightShoulder",
|
|
"RightUpperArm",
|
|
"RightLowerArm",
|
|
"RightHand",
|
|
"LeftUpperLeg",
|
|
"LeftLowerLeg",
|
|
"LeftFoot",
|
|
"RightUpperLeg",
|
|
"RightLowerLeg",
|
|
"RightFoot",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Emitted when a base body has been loaded successfully.
|
|
signal base_body_loaded(avatar_node: Node3D)
|
|
|
|
## Emitted when an attachment has been loaded and merged.
|
|
signal attachment_loaded(slot_id: String, attachment_node: Node3D)
|
|
|
|
## Emitted on error during loading.
|
|
signal load_error(error_msg: String)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## The fully assembled avatar node (Skeleton3D + meshes + attachments).
|
|
var avatar_node: Node3D = null
|
|
|
|
## The Skeleton3D belonging to the avatar.
|
|
var skeleton: Skeleton3D = null
|
|
|
|
## Mapping of bone name -> bone index in the skeleton.
|
|
var _bone_index_map: Dictionary = {}
|
|
|
|
## Loaded base body meshes, keyed by slot id.
|
|
var _base_meshes: Dictionary = {}
|
|
|
|
## Loaded attachment nodes, keyed by slot id.
|
|
var _attachments: Dictionary = {}
|
|
|
|
## Whether the base body has been loaded.
|
|
var is_base_loaded: bool = false
|
|
|
|
## The avatar catalog reference (set by AvatarSystem).
|
|
var catalog: AvatarCatalog = null
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Initialize the loader with a reference to the avatar catalog.
|
|
func init_loader(cat: AvatarCatalog) -> void:
|
|
catalog = cat
|
|
|
|
|
|
## Load the base body asset identified by [param base_body_id].
|
|
## Returns the AvatarNode3D on success, or null on failure.
|
|
func load_base_body(base_body_id: String) -> Node3D:
|
|
if catalog == null:
|
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
|
return null
|
|
|
|
var asset_meta = catalog.resolve(base_body_id)
|
|
if asset_meta == null:
|
|
load_error.emit("Unknown base_body asset: %s" % base_body_id)
|
|
return null
|
|
|
|
if asset_meta.type != "base_body":
|
|
load_error.emit("Asset '%s' is not a base_body, got '%s'" % [
|
|
base_body_id, asset_meta.type])
|
|
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)
|
|
if avatar_node == null:
|
|
load_error.emit("Failed to parse GLB: %s" % path)
|
|
return null
|
|
|
|
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D
|
|
if skeleton == null:
|
|
load_error.emit("No Skeleton3D found in base body: %s" % path)
|
|
avatar_node.queue_free()
|
|
return null
|
|
|
|
# Build bone index map for fast lookups.
|
|
_bone_index_map.clear()
|
|
for i in skeleton.get_bone_count():
|
|
var bone_name: String = skeleton.get_bone_name(i)
|
|
if not bone_name.is_empty():
|
|
_bone_index_map[bone_name] = i
|
|
|
|
# Extract base body meshes and store them.
|
|
_extract_base_meshes(avatar_node)
|
|
|
|
is_base_loaded = true
|
|
base_body_loaded.emit(avatar_node)
|
|
return avatar_node
|
|
|
|
|
|
## Load and merge an attachment into the avatar.
|
|
## [param slot_id] is the slot to equip into.
|
|
## [param asset_id] is the catalog id of the attachment.
|
|
## Returns the attachment Node3D on success, or null on failure.
|
|
func load_attachment(slot_id: String, asset_id: String) -> Node3D:
|
|
if not is_base_loaded:
|
|
load_error.emit("Cannot load attachment: base body not loaded")
|
|
return null
|
|
|
|
if catalog == null:
|
|
load_error.emit("AvatarCatalog not set on AvatarLoader")
|
|
return null
|
|
|
|
var asset_meta = catalog.resolve(asset_id)
|
|
if asset_meta == null:
|
|
load_error.emit("Unknown attachment asset: %s" % asset_id)
|
|
return null
|
|
|
|
if asset_meta.type != "attachment":
|
|
load_error.emit("Asset '%s' is not an attachment, got '%s'" % [
|
|
asset_id, asset_meta.type])
|
|
return null
|
|
|
|
# Check slot exclusivity for accessory slot.
|
|
if slot_id == "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])
|
|
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)
|
|
if attachment_node == null:
|
|
load_error.emit("Failed to parse attachment GLB: %s" % path)
|
|
return null
|
|
|
|
# If the attachment carries its own skin, retarget it to the canonical
|
|
# skeleton; static props (the common case) are unaffected.
|
|
_retarget_skin(attachment_node)
|
|
|
|
# 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 the top-level mount node so unload can free the whole unit.
|
|
if slot_id == "accessory":
|
|
var list: Array = _attachments.get("accessory", [])
|
|
list.append(mount_node)
|
|
_attachments["accessory"] = list
|
|
else:
|
|
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
|
|
|
|
|
|
## Remove an attachment from the given slot.
|
|
func unload_attachment(slot_id: String) -> void:
|
|
if slot_id == "accessory":
|
|
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 is_instance_valid(node):
|
|
node.queue_free()
|
|
_attachments.erase(slot_id)
|
|
|
|
|
|
## Hide or show the base mesh for a given body-part slot.
|
|
func set_base_mesh_visible(slot_id: String, visible: bool) -> void:
|
|
var mesh = _base_meshes.get(slot_id)
|
|
if mesh and mesh is Node3D:
|
|
mesh.visible = visible
|
|
|
|
|
|
## Get the current loadout as a dictionary (for serialization).
|
|
func get_current_loadout() -> Dictionary:
|
|
var loadout: Dictionary = {
|
|
"base_body": "",
|
|
"slots": {},
|
|
}
|
|
|
|
# Determine base body id from catalog.
|
|
if is_base_loaded and catalog != null:
|
|
# Walk through catalog to find which base_body loaded.
|
|
for id in catalog.get_all_ids():
|
|
var meta = catalog.resolve(id)
|
|
if meta and meta.type == "base_body":
|
|
loadout.base_body = id
|
|
break
|
|
|
|
# Collect equipped attachments.
|
|
for slot_id in _attachments:
|
|
if slot_id == "accessory":
|
|
loadout.slots[slot_id] = _attachments[slot_id] as Array
|
|
else:
|
|
var node = _attachments[slot_id]
|
|
if node is Node3D:
|
|
# Find the asset id by matching the node's name.
|
|
var aid: String = node.name
|
|
loadout.slots[slot_id] = aid
|
|
|
|
return loadout
|
|
|
|
|
|
## Reset the avatar to an unloaded state.
|
|
func reset() -> void:
|
|
if avatar_node and avatar_node is Node:
|
|
avatar_node.queue_free()
|
|
|
|
avatar_node = null
|
|
skeleton = null
|
|
_bone_index_map.clear()
|
|
_base_meshes.clear()
|
|
_attachments.clear()
|
|
is_base_loaded = false
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal: GLB Parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## 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 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))
|
|
return null
|
|
|
|
var scene_root := gltf_document.generate_scene(gltf_state)
|
|
if not (scene_root is Node3D):
|
|
push_error("Failed to generate scene from GLB: %s" % path)
|
|
if scene_root:
|
|
scene_root.queue_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()
|
|
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:
|
|
|
|
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))
|
|
return null
|
|
|
|
var scene_root := gltf_document.generate_scene(gltf_state)
|
|
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()]
|
|
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
|
|
|
|
|
|
## 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():
|
|
_clear_owner_recursive(child)
|
|
|
|
|
|
## 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 skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
|
if skel == null:
|
|
return
|
|
|
|
for child in skel.get_children():
|
|
if child is MeshInstance3D:
|
|
# 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).
|
|
func _mesh_name_to_slot(mesh_name: String) -> String:
|
|
if mesh_name == "Body":
|
|
# Generic body mesh - covers torso, legs, arms.
|
|
return "torso"
|
|
if mesh_name == "head_Mesh":
|
|
return "head"
|
|
if mesh_name == "torso_Mesh":
|
|
return "torso"
|
|
if mesh_name == "legs_Mesh":
|
|
return "legs"
|
|
if mesh_name == "left_arm_Mesh":
|
|
return "left_arm"
|
|
if mesh_name == "right_arm_Mesh":
|
|
return "right_arm"
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal: Skin Retargeting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Retarget the skin weights of an attachment node to the canonical skeleton.
|
|
## Walks all MeshInstance3D children and remaps joint indices by bone name.
|
|
func _retarget_skin(attachment_node: Node3D) -> void:
|
|
if skeleton == null:
|
|
return
|
|
|
|
for child in attachment_node.get_children():
|
|
if child is MeshInstance3D:
|
|
var mesh_inst := child as MeshInstance3D
|
|
var mesh := mesh_inst.mesh
|
|
if mesh == null:
|
|
continue
|
|
|
|
# Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
|
|
# MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
|
|
# a completely separate node/property, not reachable here.
|
|
if mesh is ArrayMesh:
|
|
var arr_mesh := mesh as ArrayMesh
|
|
var surface_count := arr_mesh.get_surface_count()
|
|
|
|
for surface_idx in surface_count:
|
|
var arrays := arr_mesh.surface_get_arrays(surface_idx)
|
|
# 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
|
|
|
|
# Remap joint indices by bone name.
|
|
var new_joints := PackedInt32Array()
|
|
for i in range(0, joints_array.size(), 4):
|
|
for j in 4:
|
|
var old_joint := joints_array[i + j]
|
|
if old_joint < 0:
|
|
new_joints.append(0)
|
|
else:
|
|
var bone_name := skeleton.get_bone_name(old_joint)
|
|
var new_idx: int = _bone_index_map.get(bone_name, 0)
|
|
new_joints.append(new_idx)
|
|
|
|
arrays[Mesh.ARRAY_BONES] = new_joints
|
|
arr_mesh.surface_set_arrays(surface_idx, arrays)
|
|
|
|
|
|
## Parent an attachment node to its mount bone in the skeleton.
|
|
func _parent_attachment(attachment_node: Node3D, slot_id: String,
|
|
asset_meta: Dictionary) -> void:
|
|
|
|
if skeleton == null:
|
|
return
|
|
|
|
var mount_bone: String = asset_meta.get("mount_bone", "")
|
|
if mount_bone.is_empty():
|
|
# Try to infer from slot.
|
|
mount_bone = _slot_to_mount_bone(slot_id)
|
|
|
|
if mount_bone.is_empty():
|
|
return
|
|
|
|
var bone_idx := skeleton.find_bone(mount_bone)
|
|
if bone_idx < 0:
|
|
return
|
|
|
|
# Get the world transform of the bone at the time of attachment.
|
|
var bone_pose := skeleton.get_bone_pose(bone_idx)
|
|
var bone_transform := skeleton.global_transform * bone_pose
|
|
|
|
# Position the attachment at the bone's location.
|
|
attachment_node.global_transform = bone_transform
|
|
|
|
|
|
## Convert a slot id to its canonical mount bone name (§3.2).
|
|
func _slot_to_mount_bone(slot_id: String) -> String:
|
|
match slot_id:
|
|
"head": return "Head"
|
|
"hair": return "HeadTop"
|
|
"face": return "Jaw"
|
|
"torso", "chest", "back": return "Chest"
|
|
"left_arm": return "LeftUpperArm"
|
|
"right_arm": return "RightUpperArm"
|
|
"left_hand": return "LeftHand"
|
|
"right_hand": return "RightHand"
|
|
"legs": return "Hips"
|
|
"pants": return "Hips"
|
|
"left_foot": return "LeftFoot"
|
|
"right_foot": return "RightFoot"
|
|
"accessory": return "Root"
|
|
_: return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal: File Loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Load a file from the given path as a PackedByteArray.
|
|
func _load_file(path: String) -> PackedByteArray:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return PackedByteArray()
|
|
var data := file.get_buffer(file.get_length())
|
|
file.close()
|
|
return data
|