## 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 # --------------------------------------------------------------------------- # 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[String] = _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 # Retarget skin to the canonical skeleton. _retarget_skin(attachment_node) # Parent static accessories to their mount bone. _parent_attachment(attachment_node, slot_id, asset_meta) # Store reference. if slot_id == "accessory": var list: Array[String] = _attachments.get("accessory", []) list.append(asset_id) _attachments["accessory"] = list else: _attachments[slot_id] = attachment_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": var list: Array[String] = _attachments.get("accessory", []) for asset_id in list: var node = _attachments.get(asset_id) if node and node is Node: node.queue_free() _attachments.erase("accessory") else: var node = _attachments.get(slot_id) if node and node is 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. ## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints 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: push_error("Failed to generate scene from GLB: %s" % path) avatar.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) return avatar ## 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 scene_root == null: push_error("Failed to generate scene from attachment GLB: %s" % path) return null 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()) 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. for child in node.get_children(): if child is Node3D: _merge_node_into_avatar(child, avatar, body_meshes, attach_points, attachments_container) ## Extract base body meshes from the avatar and store them by slot. func _extract_base_meshes(avatar: Node3D) -> void: var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D if body_meshes == null: return for child in body_meshes.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 ## 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) var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array 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