## AvatarVisualizer ## ## Builds the render tree from an AvatarLoadout. ## ## Responsibilities: ## - Create a root Node3D (or Node2D for 2D mode) as the avatar container. ## - For each slot in the loadout, call AvatarLoader.load_slot() and attach ## the resulting Skeleton3D / MeshInstance3D tree to the root. ## - Apply skin-tone overrides to all MeshInstance3D nodes. ## - Apply a single Skeleton3D root (the "body" skeleton) and retarget all ## limb skeletons to it using the bone-map from AvatarSlotRegistry. ## - Expose `get_root()` for the caller to attach to the scene tree. ## - Expose `clear()` to destroy the tree and free resources. ## ## Usage: ## var viz = AvatarVisualizer.new() ## viz.load_loadout(loadout) ## avatar_node.add_child(viz.get_root()) ## ## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates. ## The caller owns the root node's placement in the scene tree. class_name AvatarVisualizer extends Object ## Emitted when the visualizer has finished building the render tree. signal build_complete ## Emitted when the visualizer has been cleared. signal cleared # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## The root node that holds the avatar render tree. ## Set this to the desired parent before calling load_loadout(). var root: Node3D = null ## The Skeleton3D that serves as the body skeleton. ## All limb skeletons are retargeted to this skeleton's bone hierarchy. var body_skeleton: Skeleton3D = null ## The AvatarLoadout currently loaded (null if none). var current_loadout: AvatarLoadout = null ## Whether the visualizer is currently building (true during load_loadout). var is_building: bool = false # --------------------------------------------------------------------------- # Core # --------------------------------------------------------------------------- ## Load an AvatarLoadout and build the render tree. ## ## If [param root] is null, a new Node3D is created and assigned. ## If [param loadout] is null or invalid, the current tree is cleared. func load_loadout(loadout: AvatarLoadout, root_node: Node3D = null) -> void: # Clear any previous tree. clear() if loadout == null or not loadout.is_valid(): build_complete.emit() return current_loadout = loadout is_building = true # Create root if not provided. if root_node != null: root = root_node else: root = Node3D.new() root.name = "AvatarVisualizer" # Create the body skeleton. body_skeleton = Skeleton3D.new() body_skeleton.name = "AvatarBody" root.add_child(body_skeleton) # Build each slot's contribution. for slot_def in AvatarSlotRegistry.get_slot_definitions(): var slot_key: String = slot_def.slot_key var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key) if slot_item == null or slot_item.asset_id == "": # Slot is empty — create a placeholder. _add_placeholder_slot(slot_def) continue # Load the slot's asset. var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot( slot_item, slot_def, loadout.skin_tone, ) if slot_result == null: push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id]) _add_placeholder_slot(slot_def) continue # Attach the loaded tree to the root. _attach_slot_tree(slot_result, slot_def) # Retarget limb skeletons to the body skeleton. _retarget_limbs() # Apply skin-tone override to all meshes. _apply_skin_tone(loadout.skin_tone) is_building = false build_complete.emit() ## Clear the render tree and free resources. func clear() -> void: if root != null: root.queue_free() root = null body_skeleton = null current_loadout = null cleared.emit() ## Get the root node. func get_root() -> Node3D: return root ## Get a specific slot's Skeleton3D (if it has one). func get_slot_skeleton(slot_key: String) -> Skeleton3D: if body_skeleton == null: return null var bones := body_skeleton.get_bone_count() for i in range(bones): var bone_name: String = body_skeleton.get_bone_name(i) if bone_name == slot_key: return body_skeleton return null ## Get all MeshInstance3D nodes in the render tree. func get_all_mesh_instances() -> Array[MeshInstance3D]: var meshes: Array[MeshInstance3D] = [] if root == null: return meshes _collect_meshes(root, meshes) return meshes ## Get all Skeleton3D nodes in the render tree. func get_all_skeletons() -> Array[Skeleton3D]: var skeletons: Array[Skeleton3D] = [] if root == null: return skeletons _collect_skeletons(root, skeletons) return skeletons # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- ## Add a placeholder (empty) skeleton for a slot that has no asset. func _add_placeholder_slot(slot_def: AvatarSlotRegistry.SlotDefinition) -> void: if body_skeleton == null: return var bone_name: String = slot_def.slot_key var bone_id: int = body_skeleton.add_bone(bone_name) # Set the bone's rest pose to identity (placeholder). var rest := Transform3D() rest.origin = slot_def.default_offset body_skeleton.set_bone_rest(bone_id, rest) # Set parent to root (bone 0 is the root bone). body_skeleton.set_bone_parent(bone_id, 0) ## Attach a loaded slot's tree to the root. func _attach_slot_tree(result: AvatarLoader.SlotResult, slot_def: AvatarSlotRegistry.SlotDefinition) -> void: if result.skeleton == null: return # Add the skeleton as a child of the body skeleton. # The skeleton's bones will be merged into the body skeleton during retargeting. result.skeleton.name = "%s_skeleton" % slot_def.slot_key result.skeleton.visible = true root.add_child(result.skeleton) ## Retarget all limb skeletons to the body skeleton. func _retarget_limbs() -> void: if body_skeleton == null: return # Collect all child Skeleton3D nodes (limb skeletons). var limb_skeletons: Array[Skeleton3D] = [] for child in root.get_children(): if child is Skeleton3D and child != body_skeleton: limb_skeletons.append(child as Skeleton3D) # For each limb skeleton, merge its bones into the body skeleton. for limb in limb_skeletons: _merge_skeleton_into_body(limb) limb.queue_free() ## Merge a limb skeleton's bones into the body skeleton. func _merge_skeleton_into_body(limb: Skeleton3D) -> void: if limb == null or body_skeleton == null: return var limb_bone_count: int = limb.get_bone_count() var start_id: int = body_skeleton.get_bone_count() for i in range(limb_bone_count): var bone_name: String = limb.get_bone_name(i) var bone_id: int = body_skeleton.add_bone(bone_name) # Copy the rest transform. var rest: Transform3D = limb.get_bone_rest(i) body_skeleton.set_bone_rest(bone_id, rest) # Copy the parent. var parent_id: int = limb.get_bone_parent(i) if parent_id >= 0: # Map parent ID to body skeleton. if parent_id == 0: # Root of limb skeleton -> parent is the body root. body_skeleton.set_bone_parent(bone_id, 0) else: # Offset by start_id to map to body skeleton bones. body_skeleton.set_bone_parent(bone_id, start_id + parent_id) else: body_skeleton.set_bone_parent(bone_id, 0) # Copy custom bone data (if any). var custom_data_count: int = limb.get_bone_custom_data_count(i) for j in range(custom_data_count): var data: Variant = limb.get_bone_custom_data(i, j) body_skeleton.set_bone_custom_data(bone_id, j, data) # Copy mesh connections. var mesh_count: int = limb.get_bone_mesh_count(i) for m in range(mesh_count): var mesh: MeshInstance3D = limb.get_bone_mesh(i, m) if mesh != null: # Re-parent mesh to body skeleton. mesh.skeleton = body_skeleton mesh.skeleton_path = body_skeleton.get_path() body_skeleton.add_bone_mesh(bone_id, mesh) ## Apply skin-tone override to all MeshInstance3D nodes. func _apply_skin_tone(skin_tone: Color) -> void: if skin_tone == Color.TRANSPARENT: return var meshes: Array[MeshInstance3D] = get_all_mesh_instances() for mesh_instance in meshes: if mesh_instance.material_override == null: mesh_instance.material_override = StandardMaterial3D.new() var mat: StandardMaterial3D = mesh_instance.material_override mat.albedo_color = skin_tone ## Recursively collect all MeshInstance3D nodes. func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void: if node is MeshInstance3D: result.append(node as MeshInstance3D) for child in node.get_children(): _collect_meshes(child, result) ## Recursively collect all Skeleton3D nodes. func _collect_skeletons(node: Node, result: Array[Skeleton3D]) -> void: if node is Skeleton3D: result.append(node as Skeleton3D) for child in node.get_children(): _collect_skeletons(child, result)