Files
modbison/scripts/avatar/avatar_loader.gd
T
daddy 43a3b888da
checkpoint / checkpoint (push) Successful in 1m45s
Avatar customization: skin tone, head swap, and hair now actually apply
Three dead controls on the customization screen:

- Skin tone slider did nothing: neither the visualizer nor AvatarSystem
  ever read loadout.skin_tone. Added AvatarLoader.apply_skin_tone() -- it
  retints the base body's skin surfaces (matched by the placeholder skin
  albedo, i.e. head + arms) -- and both build paths call it. Slider now
  maps across a deep-to-pale skin range.

- "Cyber Head" did nothing: its catalog entry was type "body_part", which
  load_attachment() rejects, and the placeholder body is one mesh so there
  was nothing to hide either. Retyped it as an attachment -> it mounts on
  the Head bone as a cyber visor.

- Mohawk / ponytail sat inside the head: the Head and HeadTop bones were
  well below the visual head (bone at 1.58, head crown at ~2.03). Moved
  Head to the head's centre and HeadTop just above the crown (mesh is
  rigidly bound, so it doesn't move), and retuned the hair/visor/glasses
  meshes and offsets to match.

Verified: avatar smoke 14/14 (source + pack), fresh-clone, 2p multiplayer,
and drove the real customization screen in rendered screenshots (equip
head+mohawk+mid skin, then ponytail+glasses+pale skin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 16:10:46 +00:00

563 lines
18 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
## 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)
# 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
## 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