Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage #53
@@ -0,0 +1,173 @@
|
||||
## AvatarCatalog - Asset catalog management.
|
||||
##
|
||||
## Manages the mapping of asset IDs to their metadata and resource paths.
|
||||
## Loads the catalog manifest from disk and provides lookup by ID and slot.
|
||||
##
|
||||
## The catalog is loaded from `res://assets/avatars/catalog.json` which follows
|
||||
## the format specified in avatar.md §7.2.
|
||||
|
||||
class_name AvatarCatalog
|
||||
extends RefCounted
|
||||
|
||||
|
||||
## Emitted when the catalog is loaded successfully.
|
||||
signal catalog_loaded
|
||||
|
||||
## Emitted when the catalog fails to load.
|
||||
signal catalog_load_failed(error_message: String)
|
||||
|
||||
## Catalog manifest structure:
|
||||
## { "assets": { "id": { "type", "slot", "path", "version", ... } } }
|
||||
var _catalog: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
var _catalog_path: String = "res://assets/avatars/catalog.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Load the catalog from disk. Call this before any lookups.
|
||||
func load_catalog(path: String = _catalog_path) -> Error:
|
||||
_catalog_path = path
|
||||
_catalog = {}
|
||||
|
||||
var file = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
var err = FileAccess.get_open_error()
|
||||
catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err])
|
||||
return err
|
||||
|
||||
var json_text: String = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json: JSONParseResult = JSON.parse_string(json_text)
|
||||
if json == null:
|
||||
catalog_load_failed.emit("Invalid JSON in catalog: %s" % path)
|
||||
return ERR_PARSE_ERROR
|
||||
|
||||
if not json is Dictionary or not json.has("assets"):
|
||||
catalog_load_failed.emit("Catalog missing 'assets' key")
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
_catalog = json["assets"]
|
||||
_loaded = true
|
||||
catalog_loaded.emit()
|
||||
return OK
|
||||
|
||||
|
||||
## Get the metadata for an asset by its ID.
|
||||
## Returns a Dictionary with keys: type, slot, path, version, etc.
|
||||
## Returns null if the asset ID is not found.
|
||||
func get_asset(id: String) -> Dictionary:
|
||||
if not _loaded:
|
||||
return {}
|
||||
return _catalog.get(id, {})
|
||||
|
||||
|
||||
## Check if an asset ID exists in the catalog.
|
||||
func has_asset(id: String) -> bool:
|
||||
if not _loaded:
|
||||
return false
|
||||
return _catalog.has(id)
|
||||
|
||||
|
||||
## Get all assets that belong to a given slot.
|
||||
## Returns an array of asset ID strings.
|
||||
func get_assets_by_slot(slot: String) -> Array[String]:
|
||||
if not _loaded:
|
||||
return []
|
||||
var result: Array[String] = []
|
||||
for id in _catalog:
|
||||
var asset: Dictionary = _catalog[id]
|
||||
if asset.get("slot") == slot:
|
||||
result.append(id)
|
||||
return result
|
||||
|
||||
|
||||
## Get all asset IDs in the catalog.
|
||||
func get_all_asset_ids() -> Array[String]:
|
||||
if not _loaded:
|
||||
return []
|
||||
return _catalog.keys()
|
||||
|
||||
|
||||
## Validate a loadout against the catalog.
|
||||
## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]).
|
||||
func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
|
||||
# Check base_body
|
||||
if loadout.base_body.is_empty():
|
||||
errors.append("Loadout missing base_body")
|
||||
elif not has_asset(loadout.base_body):
|
||||
errors.append("Unknown base_body asset: %s" % loadout.base_body)
|
||||
else:
|
||||
var base: Dictionary = get_asset(loadout.base_body)
|
||||
if base.get("type") != "base_body":
|
||||
errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body)
|
||||
|
||||
# Check each slot
|
||||
for slot_id in loadout.slots:
|
||||
var slot_value = loadout.slots[slot_id]
|
||||
|
||||
if slot_value == null:
|
||||
continue
|
||||
|
||||
if slot_id == "accessory":
|
||||
# Accessory is an array
|
||||
if not slot_value is Array:
|
||||
errors.append("Slot '%s' must be an array" % slot_id)
|
||||
continue
|
||||
for item_id in slot_value:
|
||||
if not has_asset(item_id):
|
||||
errors.append("Unknown accessory asset: %s" % item_id)
|
||||
else:
|
||||
var asset: Dictionary = get_asset(item_id)
|
||||
if asset.get("slot") != slot_id:
|
||||
errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [
|
||||
item_id, asset.get("slot"), slot_id
|
||||
])
|
||||
else:
|
||||
# Exclusive slot - single value
|
||||
if not slot_value is String:
|
||||
errors.append("Slot '%s' must be a string or null" % slot_id)
|
||||
continue
|
||||
if not has_asset(slot_value):
|
||||
errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value])
|
||||
else:
|
||||
var asset: Dictionary = get_asset(slot_value)
|
||||
if asset.get("slot") != slot_id:
|
||||
errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [
|
||||
slot_value, asset.get("slot"), slot_id
|
||||
])
|
||||
|
||||
return {
|
||||
"valid": errors.is_empty(),
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
|
||||
## Compute a hash of the loadout for change detection.
|
||||
## Returns a uint32 hash.
|
||||
static func compute_loadout_hash(loadout: AvatarLoadout) -> uint32:
|
||||
var hasher = HashingContext.new()
|
||||
hasher.start(HashingContext.HASH_SHA256)
|
||||
|
||||
hasher.update(loadout.base_body.to_utf8_buffer())
|
||||
for slot_id in loadout.slots:
|
||||
var slot_value = loadout.slots[slot_id]
|
||||
hasher.update(slot_id.to_utf8_buffer())
|
||||
if slot_value is Array:
|
||||
for item in slot_value:
|
||||
hasher.update(item.to_utf8_buffer())
|
||||
elif slot_value is String:
|
||||
hasher.update(slot_value.to_utf8_buffer())
|
||||
|
||||
var hash_bytes = hasher.finish()
|
||||
return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24)
|
||||
|
||||
|
||||
## Get the catalog path.
|
||||
func get_catalog_path() -> String:
|
||||
return _catalog_path
|
||||
@@ -0,0 +1,561 @@
|
||||
## 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.
|
||||
# Godot's GLTFState allows custom parsing.
|
||||
var gltf_document := GLTFDocument.new()
|
||||
var gltf_state := GLTFState.new()
|
||||
|
||||
var parse_result := gltf_document.parse(glb_data, gltf_state, "", "")
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % Error.parse_error(parse_result))
|
||||
avatar.queue_free()
|
||||
return null
|
||||
|
||||
# Export the scene from GLTFState.
|
||||
var root_nodes := gltf_state.get_nodes_as_resources()
|
||||
if root_nodes.is_empty():
|
||||
push_error("No root nodes in GLB: %s" % path)
|
||||
avatar.queue_free()
|
||||
return null
|
||||
|
||||
# Merge GLB nodes into our avatar structure.
|
||||
for node_resource in root_nodes:
|
||||
if node_resource is Node3D:
|
||||
_merge_node_into_avatar(node_resource, 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.parse(glb_data, gltf_state, "", "")
|
||||
if parse_result != OK:
|
||||
push_error("GLB parse error: %s" % Error.parse_error(parse_result))
|
||||
return null
|
||||
|
||||
var root_nodes := gltf_state.get_nodes_as_resources()
|
||||
if root_nodes.is_empty():
|
||||
push_error("No root nodes in attachment GLB: %s" % path)
|
||||
return null
|
||||
|
||||
var attachment := Node3D.new()
|
||||
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
|
||||
|
||||
for node_resource in root_nodes:
|
||||
if node_resource is Node3D:
|
||||
attachment.add_child(node_resource.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)
|
||||
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
|
||||
|
||||
# Handle MultiMeshInstance3D.
|
||||
if mesh is MultiMesh:
|
||||
# MultiMesh doesn't have skin weights per-vertex in the same way.
|
||||
continue
|
||||
|
||||
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_JOINTS] 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 := _bone_index_map.get(bone_name, 0)
|
||||
new_joints.append(new_idx)
|
||||
|
||||
arrays[Mesh.ARRAY_JOINTS] = 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
|
||||
@@ -0,0 +1,225 @@
|
||||
## AvatarLoadout - Serializable loadout model + validation.
|
||||
##
|
||||
## Represents a complete avatar composition: a base body plus equipped items
|
||||
## per slot. Validates slot exclusivity and references against a catalog.
|
||||
##
|
||||
## See docs/architecture/avatar.md §5 for the loadout JSON format.
|
||||
|
||||
extends RefCounted
|
||||
|
||||
|
||||
## Maximum number of accessories allowed in the accessory slot.
|
||||
const MAX_ACCESSORIES: int = 8
|
||||
|
||||
## The base body asset ID.
|
||||
var base_body: String = ""
|
||||
|
||||
## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry.
|
||||
## Exclusive slots hold a single asset ID (or null).
|
||||
## The "accessory" slot holds an array of asset IDs.
|
||||
var slots: Dictionary = {}
|
||||
|
||||
## Validation errors found during the last validate() call.
|
||||
var validation_errors: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
validation_errors = []
|
||||
|
||||
|
||||
## Create a loadout from a JSON string.
|
||||
static func from_json(json_string: String) -> AvatarLoadout:
|
||||
var loadout := AvatarLoadout.new()
|
||||
var data: Variant = JSON.parse_string(json_string)
|
||||
if data is not Dictionary:
|
||||
loadout.validation_errors.append("Loadout JSON root is not an object")
|
||||
return loadout
|
||||
|
||||
var root: Dictionary = data as Dictionary
|
||||
|
||||
# Parse base_body
|
||||
if root.has("base_body"):
|
||||
var bb = root["base_body"]
|
||||
if typeof(bb) == TYPE_STRING:
|
||||
loadout.base_body = bb as String
|
||||
else:
|
||||
loadout.validation_errors.append("base_body must be a string")
|
||||
|
||||
# Parse slots
|
||||
if root.has("slots"):
|
||||
var slots_data = root["slots"]
|
||||
if typeof(slots_data) == TYPE_DICTIONARY:
|
||||
loadout.slots = slots_data as Dictionary
|
||||
else:
|
||||
loadout.validation_errors.append("slots must be an object")
|
||||
else:
|
||||
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
|
||||
|
||||
return loadout
|
||||
|
||||
|
||||
## Serialize this loadout to a JSON string.
|
||||
func to_json() -> String:
|
||||
var data := {
|
||||
"base_body": base_body,
|
||||
"slots": {}
|
||||
}
|
||||
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
if slot_id == "accessory":
|
||||
data.slots[slot_id] = slots.get(slot_id, [])
|
||||
else:
|
||||
data.slots[slot_id] = slots.get(slot_id, null)
|
||||
|
||||
return JSON.stringify(data, " ")
|
||||
|
||||
|
||||
## Validate this loadout against the slot registry.
|
||||
## Returns true if valid; populates validation_errors if not.
|
||||
func validate() -> bool:
|
||||
validation_errors.clear()
|
||||
|
||||
# Check base_body
|
||||
if base_body.is_empty():
|
||||
validation_errors.append("base_body is required")
|
||||
|
||||
# Check each slot
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||
if slot_def == null:
|
||||
validation_errors.append("Unknown slot: %s" % slot_id)
|
||||
continue
|
||||
|
||||
var value = slots.get(slot_id)
|
||||
|
||||
if slot_def.exclusive:
|
||||
# Exclusive slots: must be null or a string
|
||||
if value != null and typeof(value) != TYPE_STRING:
|
||||
validation_errors.append(
|
||||
"Slot '%s' must be null or a string, got %s" % [
|
||||
slot_id, typeof(value)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Non-exclusive (accessory): must be null or an array
|
||||
if value != null and typeof(value) != TYPE_ARRAY:
|
||||
validation_errors.append(
|
||||
"Slot '%s' must be null or an array, got %s" % [
|
||||
slot_id, typeof(value)
|
||||
]
|
||||
)
|
||||
elif value is Array:
|
||||
if slot_id == "accessory":
|
||||
if value.size() > MAX_ACCESSORIES:
|
||||
validation_errors.append(
|
||||
"Accessory count (%d) exceeds maximum (%d)" % [
|
||||
value.size(), MAX_ACCESSORIES
|
||||
]
|
||||
)
|
||||
|
||||
# Check for duplicate accessories
|
||||
if slots.has("accessory") and slots["accessory"] is Array:
|
||||
var accessories = slots["accessory"] as Array
|
||||
var seen := {}
|
||||
for item in accessories:
|
||||
if item is String:
|
||||
if seen.has(item):
|
||||
validation_errors.append(
|
||||
"Duplicate accessory: %s" % item
|
||||
)
|
||||
seen[item] = true
|
||||
|
||||
return validation_errors.is_empty()
|
||||
|
||||
|
||||
## Check if a specific slot is equipped with a non-null item.
|
||||
func is_equipped(slot_id: String) -> bool:
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
return false
|
||||
|
||||
var value = slots.get(slot_id)
|
||||
if value == null:
|
||||
return false
|
||||
|
||||
if typeof(value) == TYPE_STRING:
|
||||
return not (value as String).is_empty()
|
||||
elif typeof(value) == TYPE_ARRAY:
|
||||
return (value as Array).size() > 0
|
||||
|
||||
return false
|
||||
|
||||
|
||||
## Get the equipped asset ID for an exclusive slot.
|
||||
## Returns empty string if not equipped or slot is not exclusive.
|
||||
func get_equipment(slot_id: String) -> String:
|
||||
var value = slots.get(slot_id)
|
||||
if value is String:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
## Set equipment for an exclusive slot.
|
||||
func set_equipment(slot_id: String, asset_id: String) -> void:
|
||||
if not AvatarSlotRegistry.has_slot(slot_id):
|
||||
push_error("Unknown slot: %s" % slot_id)
|
||||
return
|
||||
|
||||
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
|
||||
if slot_def and not slot_def.exclusive:
|
||||
push_error("Slot '%s' is not exclusive" % slot_id)
|
||||
return
|
||||
|
||||
if slots.has(slot_id):
|
||||
var current = slots[slot_id]
|
||||
if current is Array:
|
||||
# Convert array to single value
|
||||
slots[slot_id] = asset_id if asset_id.is_empty() else null
|
||||
else:
|
||||
slots[slot_id] = asset_id if not asset_id.is_empty() else null
|
||||
else:
|
||||
slots[slot_id] = asset_id if not asset_id.is_empty() else null
|
||||
|
||||
|
||||
## Add an accessory to the accessory slot.
|
||||
func add_accessory(asset_id: String) -> bool:
|
||||
var accessories = slots.get("accessory", []) as Array
|
||||
if accessories.size() >= MAX_ACCESSORIES:
|
||||
return false
|
||||
if asset_id.is_empty():
|
||||
return false
|
||||
if accessories.has(asset_id):
|
||||
return false
|
||||
accessories.append(asset_id)
|
||||
slots["accessory"] = accessories
|
||||
return true
|
||||
|
||||
|
||||
## Remove an accessory from the accessory slot.
|
||||
func remove_accessory(asset_id: String) -> bool:
|
||||
var accessories = slots.get("accessory", []) as Array
|
||||
if accessories.has(asset_id):
|
||||
accessories.erase(asset_id)
|
||||
slots["accessory"] = accessories
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Compute a hash of this loadout for change detection.
|
||||
## Mirrors the server's loadout_hash concept.
|
||||
func compute_hash() -> int:
|
||||
var hasher := HashingContext.new()
|
||||
hasher.start(HashingContext.HASH_SHA256)
|
||||
hasher.update(base_body.to_utf8_buffer())
|
||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||
hasher.update(slot_id.to_utf8_buffer())
|
||||
var value = slots.get(slot_id)
|
||||
if value is String:
|
||||
hasher.update(value.to_utf8_buffer())
|
||||
elif value is Array:
|
||||
var arr_str = JSON.stringify(value)
|
||||
hasher.update(arr_str.to_utf8_buffer())
|
||||
else:
|
||||
hasher.update("null".to_utf8_buffer())
|
||||
var digest = hasher.finish()
|
||||
# Return first 4 bytes as int for compact storage
|
||||
return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)
|
||||
@@ -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