WIP checkpoint: Implement avatar loader and customization UI in Godot
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
## 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)
|
||||
Reference in New Issue
Block a user