## 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 ## The skin albedo baked into the placeholder base body by ## gen_placeholder_avatars.gd. apply_skin_tone() finds the mesh surfaces ## painted this colour (head + arms) and retints them. const PLACEHOLDER_SKIN_ALBEDO := Color(0.96, 0.80, 0.19) ## 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 avatar_node = _instance_glb_as_avatar(path) if avatar_node == null: load_error.emit("Failed to load base body scene: %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 attachment_node = _instance_glb_as_attachment(path, slot_id) if attachment_node == null: load_error.emit("Failed to load attachment scene: %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) # Check if this attachment has multi-bone children (e.g. Attach_LeftUpperLeg, Attach_Hips). var multi_bone_children: Array[Node] = [] for child in attachment_node.get_children(): var cname := child.name if cname.begins_with("Attach_") or cname.begins_with("attach_"): var target_bone := cname.substr(7) if skeleton.find_bone(target_bone) != -1: multi_bone_children.append(child) var mount_node: Node3D if not multi_bone_children.is_empty(): var created_bas: Array[BoneAttachment3D] = [] for child in multi_bone_children: var target_bone := child.name.substr(7) var ba := BoneAttachment3D.new() ba.name = "Mount_%s_%s" % [slot_id, target_bone] ba.bone_name = target_bone skeleton.add_child(ba) attachment_node.remove_child(child) ba.add_child(child) created_bas.append(ba) skeleton.add_child(attachment_node) attachment_node.set_meta("bone_attachments", created_bas) attachment_node.tree_exiting.connect(func(): for ba in created_bas: if is_instance_valid(ba) and not ba.is_queued_for_deletion(): ba.queue_free() ) mount_node = attachment_node else: # Single mount bone via a BoneAttachment3D so it follows the skeleton. # Falls back to a plain skeleton child if the mount bone is missing. var mount_bone: String = asset_meta.get("mount_bone", "") if mount_bone.is_empty(): mount_bone = _slot_to_mount_bone(slot_id) 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): if node.has_meta("bone_attachments"): for ba in node.get_meta("bone_attachments"): if is_instance_valid(ba): ba.queue_free() node.queue_free() _attachments.erase("accessory") else: var node = _attachments.get(slot_id) if is_instance_valid(node): if node.has_meta("bone_attachments"): for ba in node.get_meta("bone_attachments"): if is_instance_valid(ba): ba.queue_free() 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 ## Retint the base body's skin surfaces (the ones the generator painted ## PLACEHOLDER_SKIN_ALBEDO -- head + arms) to [param color]. A no-op if the ## colour is unset (transparent). Real segmented bodies will do this per mesh. func apply_skin_tone(color: Color) -> void: if color.a <= 0.0: return if avatar_node == null or not is_instance_valid(avatar_node): return var skel := avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D if skel == null: return for child in skel.get_children(): if not (child is MeshInstance3D): continue var mi := child as MeshInstance3D if mi.mesh == null: continue for s in mi.mesh.get_surface_count(): var mat := mi.get_active_material(s) if mat is BaseMaterial3D and \ (mat as BaseMaterial3D).albedo_color.is_equal_approx(PLACEHOLDER_SKIN_ALBEDO): var tinted := StandardMaterial3D.new() tinted.albedo_color = color tinted.roughness = 0.9 mi.set_surface_override_material(s, tinted) ## 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 # --------------------------------------------------------------------------- ## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar ## structure: ## Avatar (Node3D) ## Skeleton3D -- the canonical rig + skin-bound body meshes ## BodyMeshes (Node3D) -- unused container, kept for compatibility ## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment) ## ## 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: Node = packed.instantiate() if not (scene_root is Node3D): 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 base body scene: %s" % path) scene_root.free() return null var avatar := Node3D.new() avatar.name = "Avatar" 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.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 ## 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: Node = packed.instantiate() if not (scene_root is Node3D): push_error("Attachment scene root is not a Node3D: %s" % path) scene_root.free() return null 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.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