Add CharacterVisualDescriptor (11-variant BodyType enum, wire format encode/decode, body_type_key mapping) and CharacterVisual compositor (runtime 3D character assembler with slot architecture, BoneAttachment3D, clothing coverage, recolor mask loading, skin tone tinting, bone validation). Includes 64 unit tests and Blender utility scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
610 lines
22 KiB
GDScript
610 lines
22 KiB
GDScript
class_name CharacterVisual
|
||
extends Node3D
|
||
|
||
## Runtime character visual compositor.
|
||
## Accepts a CharacterVisualDescriptor and assembles the full 3D character from
|
||
## pre-authored asset files. No runtime deformation — selects and attaches meshes.
|
||
##
|
||
## All asset paths derived programmatically from BASE_PATH + descriptor fields:
|
||
## bodies/{body_type}/seg_{name}.glb (D-160: 21 segments per body type)
|
||
## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D)
|
||
## hair/{hair_id}.glb (BoneAttachment3D to Head bone)
|
||
## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type)
|
||
## accessories/{accessory_id}.glb (BoneAttachment3D per slot→bone map)
|
||
##
|
||
## Public API:
|
||
## load_descriptor(descriptor) — rebuild full character from descriptor
|
||
## set_facing(direction: Vector2|String) — rotate model to match facing direction
|
||
## get_skeleton() — returns the Skeleton3D for external use
|
||
## is_segment_visible(seg_name) — query body segment visibility
|
||
## get_active_coverage(item_id) — return coverage dict for a clothing item
|
||
## get_active_torso_variant() — "full" or "upper"
|
||
## has_slot_node(slot) — whether a named slot (hair, facial_hair) was loaded
|
||
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
|
||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||
##
|
||
## D-159 (11 body types), D-160 (21 segments), D-161 (head separate),
|
||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||
|
||
const BASE_PATH := "res://assets/characters/"
|
||
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
|
||
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
|
||
const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader"
|
||
const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader"
|
||
const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
|
||
|
||
## All body segment names in assembly order. D-160: 17 base + 2 swappable torso + 2 face.
|
||
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
|
||
const ALL_SEGMENTS: Array[String] = [
|
||
"head", "neck", "torso", "torso_upper",
|
||
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r",
|
||
"hand_l", "hand_r",
|
||
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r",
|
||
"foot_l", "foot_r",
|
||
"eyes", "eyebrows",
|
||
]
|
||
|
||
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
|
||
const NON_SKIN_SEGMENTS: Array[String] = ["eyes", "eyebrows"]
|
||
|
||
## Segments hidden by default; shown only when a clothing coverage rule requests it.
|
||
const HIDDEN_BY_DEFAULT: Array[String] = ["torso_upper"]
|
||
|
||
## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md).
|
||
## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H),
|
||
## lowercase for everything else. NOT Mixamo names (no "Hips", "LeftHand", "Spine2").
|
||
const SLOT_TO_BONE: Dictionary = {
|
||
"hat": "Head",
|
||
"goggles": "Head",
|
||
"mask": "Head",
|
||
"earring": "Head",
|
||
"backpack": "spine_03",
|
||
"belt": "pelvis",
|
||
"held_l": "hand_l",
|
||
"held_r": "hand_r",
|
||
}
|
||
|
||
## Skin tone palette: 9 tones, index 0–8 matching skin_tones/ texture filenames.
|
||
const SKIN_TONES: Array[Dictionary] = [
|
||
{"lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"},
|
||
{"lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"},
|
||
{"lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"},
|
||
{"lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"},
|
||
{"lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"},
|
||
{"lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"},
|
||
{"lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"},
|
||
{"lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"},
|
||
{"lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"},
|
||
]
|
||
|
||
const OUTLINE_WIDTH: float = 0.006
|
||
const OUTLINE_COLOR: Color = Color(0.08, 0.08, 0.12)
|
||
const OUTLINE_SIZE_THRESHOLD: float = 0.1 # skip tiny meshes (eyes, eyebrows)
|
||
|
||
# --- Internal state ---
|
||
|
||
var _skeleton: Skeleton3D = null
|
||
var _body_root: Node3D = null
|
||
|
||
var _body_meshes: Array[MeshInstance3D] = []
|
||
var _clothing_meshes: Array[MeshInstance3D] = []
|
||
var _bone_attachments: Array[BoneAttachment3D] = []
|
||
var _outline_nodes: Array[MeshInstance3D] = []
|
||
var _accessory_attachments: Array[BoneAttachment3D] = []
|
||
|
||
# Inspectable state for tests
|
||
var _active_torso_variant: String = "full"
|
||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||
|
||
var _toon_shader: Shader = null
|
||
var _toon_masked_shader: Shader = null
|
||
var _outline_shader: Shader = null
|
||
|
||
|
||
func _ready() -> void:
|
||
_load_shaders()
|
||
|
||
|
||
func _load_shaders() -> void:
|
||
_toon_shader = load(TOON_SHADER_PATH) as Shader
|
||
_toon_masked_shader = load(TOON_MASKED_SHADER_PATH) as Shader
|
||
_outline_shader = load(OUTLINE_SHADER_PATH) as Shader
|
||
if not _toon_shader or not _toon_masked_shader or not _outline_shader:
|
||
push_error("CharacterVisual: shader load failed — check %s" % BASE_PATH)
|
||
|
||
|
||
# =============================================================================
|
||
# Public API
|
||
# =============================================================================
|
||
|
||
## Rebuild the full character from a CharacterVisualDescriptor.
|
||
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
|
||
_clear()
|
||
_load_skeleton()
|
||
if _skeleton == null:
|
||
push_error("CharacterVisual: skeleton failed to load — aborting load_descriptor")
|
||
return
|
||
_load_body_segments(descriptor)
|
||
_load_head(descriptor)
|
||
_load_hair(descriptor)
|
||
_load_facial_hair(descriptor)
|
||
_load_eyebrows(descriptor)
|
||
_load_clothing(descriptor)
|
||
_load_accessories(descriptor)
|
||
_rebuild_outlines()
|
||
|
||
|
||
## Set character facing from a 2D direction vector or a named string direction.
|
||
## Vector2(0, 1) = south (toward camera), Vector2(1, 0) = east, etc.
|
||
## String values: "south", "north", "east", "west", "southwest", "northeast", etc.
|
||
func set_facing(direction: Variant) -> void:
|
||
if direction is Vector2:
|
||
# Convert 2D game-space direction to 3D Y rotation.
|
||
# atan2(x, y): Vector2(0,1)→0°, (1,0)→-90°, (0,-1)→180°, (-1,0)→90°
|
||
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
|
||
elif direction is String:
|
||
var angles: Dictionary = {
|
||
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0,
|
||
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.0,
|
||
}
|
||
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
|
||
|
||
|
||
## Alias kept for EntityRenderer compatibility.
|
||
func update_facing(direction: String) -> void:
|
||
set_facing(direction)
|
||
|
||
|
||
func get_skeleton() -> Skeleton3D:
|
||
return _skeleton
|
||
|
||
|
||
## Return whether a named body segment is currently visible.
|
||
func is_segment_visible(seg_name: String) -> bool:
|
||
for mi in _body_meshes:
|
||
if mi.get_meta("segment", "") == seg_name:
|
||
return mi.visible
|
||
return false
|
||
|
||
|
||
## Return the coverage.json data for a clothing item_id, or empty dict if unavailable.
|
||
func get_active_coverage(item_id: String) -> Dictionary:
|
||
return _active_coverages.get(item_id, {})
|
||
|
||
|
||
## Return the active torso variant: "full" or "upper".
|
||
func get_active_torso_variant() -> String:
|
||
return _active_torso_variant
|
||
|
||
|
||
## Return whether a named slot (hair, facial_hair, eyebrow) produced a scene node.
|
||
func has_slot_node(slot: String) -> bool:
|
||
return slot in _loaded_slots
|
||
|
||
|
||
## Return count of clothing MeshInstance3D nodes currently attached.
|
||
func get_clothing_node_count() -> int:
|
||
return _clothing_meshes.size()
|
||
|
||
|
||
## Return count of accessory BoneAttachment3D nodes currently attached.
|
||
func get_accessory_node_count() -> int:
|
||
return _accessory_attachments.size()
|
||
|
||
|
||
## Return the skin tone texture filename key for a given index (without .png extension).
|
||
func get_skin_tone_texture_name(index: int) -> String:
|
||
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — teardown
|
||
# =============================================================================
|
||
|
||
func _clear() -> void:
|
||
for att in _bone_attachments:
|
||
if is_instance_valid(att) and att.get_parent():
|
||
att.get_parent().remove_child(att)
|
||
att.free()
|
||
_bone_attachments.clear()
|
||
_accessory_attachments.clear()
|
||
_loaded_slots.clear()
|
||
|
||
for mi in _clothing_meshes:
|
||
if is_instance_valid(mi) and mi.get_parent():
|
||
mi.get_parent().remove_child(mi)
|
||
mi.free()
|
||
_clothing_meshes.clear()
|
||
|
||
_body_meshes.clear()
|
||
_active_coverages.clear()
|
||
_active_torso_variant = "full"
|
||
|
||
_rebuild_outlines() # clears outline nodes before removing skeleton
|
||
|
||
if is_instance_valid(_body_root) and _body_root.get_parent():
|
||
_body_root.get_parent().remove_child(_body_root)
|
||
_body_root.queue_free()
|
||
_body_root = null
|
||
_skeleton = null
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — skeleton
|
||
# =============================================================================
|
||
|
||
func _load_skeleton() -> void:
|
||
if not ResourceLoader.exists(SKELETON_PATH):
|
||
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
|
||
return
|
||
var scene := load(SKELETON_PATH) as PackedScene
|
||
if scene == null:
|
||
push_error("CharacterVisual: skeleton PackedScene is null")
|
||
return
|
||
_body_root = scene.instantiate()
|
||
add_child(_body_root)
|
||
_skeleton = _find_skeleton(_body_root)
|
||
if _skeleton == null:
|
||
push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH)
|
||
return
|
||
_validate_slot_bones()
|
||
|
||
|
||
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
|
||
## Catches bone name mismatches early rather than silently failing on accessory attach.
|
||
func _validate_slot_bones() -> void:
|
||
for slot: String in SLOT_TO_BONE:
|
||
var bone_name: String = SLOT_TO_BONE[slot]
|
||
if _skeleton.find_bone(bone_name) == -1:
|
||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — body segments (D-160)
|
||
# =============================================================================
|
||
|
||
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
|
||
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
|
||
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
|
||
|
||
for seg_name in ALL_SEGMENTS:
|
||
var seg_path := body_dir + "seg_%s.glb" % seg_name
|
||
if not ResourceLoader.exists(seg_path):
|
||
continue # segment not yet authored — skip gracefully
|
||
var seg_scene := load(seg_path) as PackedScene
|
||
if seg_scene == null:
|
||
continue
|
||
var inst := seg_scene.instantiate()
|
||
for mi in _collect_meshes(inst):
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
mi.set_meta("segment", seg_name)
|
||
mi.visible = seg_name not in HIDDEN_BY_DEFAULT
|
||
_apply_body_shader(mi, seg_name, tone)
|
||
_skeleton.add_child(mi)
|
||
_body_meshes.append(mi)
|
||
inst.queue_free()
|
||
|
||
|
||
func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
var is_skin := seg_name not in NON_SKIN_SEGMENTS
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
if not is_skin:
|
||
# Eyes/eyebrows: preserve the original embedded texture from the GLB.
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
if orig != null:
|
||
mi.set_surface_override_material(surf, orig)
|
||
continue
|
||
# Skin body segments: apply skin tone via toon_masked or flat toon fallback.
|
||
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
|
||
var skin_tex := load(tex_path) as Texture2D
|
||
var mat := ShaderMaterial.new()
|
||
if skin_tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", skin_tex)
|
||
mat.set_shader_parameter("tint_color", tone["lit"])
|
||
mat.set_shader_parameter("shadow_strength", 0.25)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tone["lit"])
|
||
mat.set_shader_parameter("shadow_color", tone["shadow"])
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
mi.set_surface_override_material(surf, mat)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
|
||
# =============================================================================
|
||
|
||
func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.head_id.is_empty():
|
||
return
|
||
var path := BASE_PATH + "heads/templates/%s.glb" % desc.head_id
|
||
if _attach_to_bone(path, "Head"):
|
||
_loaded_slots.append("head")
|
||
|
||
|
||
func _load_hair(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.hair_id.is_empty() or desc.hair_id == "bald":
|
||
return
|
||
var path := BASE_PATH + "hair/%s.glb" % desc.hair_id
|
||
if _attach_to_bone(path, "Head", desc.hair_tint):
|
||
_loaded_slots.append("hair")
|
||
|
||
|
||
func _load_facial_hair(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.facial_hair_id.is_empty():
|
||
return
|
||
var path := BASE_PATH + "facial_hair/%s.glb" % desc.facial_hair_id
|
||
if _attach_to_bone(path, "Head", desc.facial_hair_tint):
|
||
_loaded_slots.append("facial_hair")
|
||
|
||
|
||
func _load_eyebrows(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.eyebrow_id.is_empty():
|
||
return
|
||
# Convention: eyebrow_id uses the short visual name (e.g. "regular", "thick", "female"),
|
||
# NOT the full filename prefix. Path: eyebrows/{eyebrow_id}.glb → e.g. eyebrows/regular.glb
|
||
var path := BASE_PATH + "eyebrows/%s.glb" % desc.eyebrow_id
|
||
if _attach_to_bone(path, "Head", desc.eyebrow_tint):
|
||
_loaded_slots.append("eyebrow")
|
||
|
||
|
||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> bool:
|
||
if _skeleton == null:
|
||
return false
|
||
if not ResourceLoader.exists(path):
|
||
push_warning("CharacterVisual: asset not found (expected): %s" % path)
|
||
return false
|
||
var scene := load(path) as PackedScene
|
||
if scene == null:
|
||
return false
|
||
var bone_idx := _skeleton.find_bone(bone_name)
|
||
if bone_idx == -1:
|
||
push_error("CharacterVisual: bone '%s' not found in skeleton" % bone_name)
|
||
return false
|
||
var attachment := BoneAttachment3D.new()
|
||
attachment.bone_name = bone_name
|
||
attachment.bone_idx = bone_idx
|
||
_skeleton.add_child(attachment)
|
||
_bone_attachments.append(attachment)
|
||
|
||
# Load recolor mask sidecar: {asset_name}_mask.png alongside the GLB.
|
||
# Without this, toon_masked recolor_mask defaults to black → tint_color ignored.
|
||
var mask_path := path.get_basename() + "_mask.png"
|
||
var mask_tex: Texture2D = null
|
||
if ResourceLoader.exists(mask_path):
|
||
mask_tex = load(mask_path) as Texture2D
|
||
|
||
var inst := scene.instantiate()
|
||
attachment.add_child(inst)
|
||
for mi in _collect_meshes(inst):
|
||
_apply_tinted_shader(mi, tint, mask_tex)
|
||
return true
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
|
||
# =============================================================================
|
||
|
||
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||
if _skeleton == null or desc.clothing_slots.is_empty():
|
||
return
|
||
|
||
var hidden_segments: Array[String] = []
|
||
_active_torso_variant = "full"
|
||
|
||
for slot: String in desc.clothing_slots:
|
||
var item_id: String = desc.clothing_slots[slot]
|
||
var item_dir := BASE_PATH + "clothing/%s/" % item_id
|
||
var coverage := _read_coverage(item_dir + "coverage.json")
|
||
_active_coverages[item_id] = coverage
|
||
|
||
var glb_path := item_dir + "%s.glb" % desc.body_type_key()
|
||
|
||
# Accumulate segment coverage across all equipped clothing
|
||
if coverage.has("hides"):
|
||
for seg_name in coverage["hides"]:
|
||
if (seg_name as String) not in hidden_segments:
|
||
hidden_segments.append(seg_name)
|
||
# Only upgrade to "upper" — never downgrade back to "full" if a later item
|
||
# uses "full". Most-revealing torso variant wins across all equipped clothing.
|
||
if coverage.get("torso_variant") == "upper":
|
||
_active_torso_variant = "upper"
|
||
|
||
# Load recolor mask: shared across all body-type variants (same UV layout after Surface Deform).
|
||
var mask_path := item_dir + "reference_mask.png"
|
||
var mask_tex: Texture2D = null
|
||
if ResourceLoader.exists(mask_path):
|
||
mask_tex = load(mask_path) as Texture2D
|
||
|
||
# Load the body-type-fitted clothing GLB
|
||
if ResourceLoader.exists(glb_path):
|
||
var scene := load(glb_path) as PackedScene
|
||
if scene:
|
||
var inst := scene.instantiate()
|
||
var tints: Array = desc.clothing_tints.get(item_id, [])
|
||
for mi in _collect_meshes(inst):
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
_apply_clothing_shader(mi, tints, mask_tex)
|
||
_skeleton.add_child(mi)
|
||
_clothing_meshes.append(mi)
|
||
inst.queue_free()
|
||
|
||
# Apply segment hiding from coverage
|
||
for mi in _body_meshes:
|
||
var seg: String = mi.get_meta("segment", "")
|
||
if seg in hidden_segments:
|
||
mi.visible = false
|
||
|
||
# Apply torso variant selection
|
||
if _active_torso_variant == "upper":
|
||
for mi in _body_meshes:
|
||
match mi.get_meta("segment", ""):
|
||
"torso": mi.visible = false
|
||
"torso_upper": mi.visible = true
|
||
else:
|
||
for mi in _body_meshes:
|
||
if mi.get_meta("segment", "") == "torso_upper":
|
||
mi.visible = false
|
||
|
||
|
||
func _read_coverage(path: String) -> Dictionary:
|
||
if not FileAccess.file_exists(path):
|
||
return {}
|
||
var file := FileAccess.open(path, FileAccess.READ)
|
||
if file == null:
|
||
return {}
|
||
var parsed := JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if parsed is Dictionary:
|
||
return parsed
|
||
push_warning("CharacterVisual: coverage.json malformed at %s" % path)
|
||
return {}
|
||
|
||
|
||
func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2D = null) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
var tint: Color = tints[0] if not tints.is_empty() else Color(0.7, 0.65, 0.6)
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
var tex := _get_albedo_texture(orig)
|
||
var mat := ShaderMaterial.new()
|
||
if tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", tex)
|
||
mat.set_shader_parameter("tint_color", tint)
|
||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||
if mask_tex:
|
||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tint)
|
||
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
mi.set_surface_override_material(surf, mat)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — accessories (BoneAttachment3D per slot→bone)
|
||
# =============================================================================
|
||
|
||
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||
for slot: String in desc.accessory_slots:
|
||
var item_id: String = desc.accessory_slots[slot]
|
||
var bone_name: String = SLOT_TO_BONE.get(slot, "")
|
||
if bone_name.is_empty():
|
||
push_warning("CharacterVisual: unknown accessory slot '%s'" % slot)
|
||
continue
|
||
var path := BASE_PATH + "accessories/%s.glb" % item_id
|
||
var tint: Color = desc.accessory_tints.get(item_id, Color.WHITE)
|
||
if _attach_to_bone(path, bone_name, tint):
|
||
# Track accessory attachments separately for get_accessory_node_count()
|
||
_accessory_attachments.append(_bone_attachments.back())
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||
# =============================================================================
|
||
|
||
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
var tex := _get_albedo_texture(orig)
|
||
var mat := ShaderMaterial.new()
|
||
if tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", tex)
|
||
mat.set_shader_parameter("tint_color", tint)
|
||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||
if mask_tex:
|
||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tint)
|
||
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
mi.set_surface_override_material(surf, mat)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — outline pass (inverted hull, cull_front)
|
||
# =============================================================================
|
||
|
||
func _rebuild_outlines() -> void:
|
||
for node in _outline_nodes:
|
||
if is_instance_valid(node):
|
||
if node.get_parent():
|
||
node.get_parent().remove_child(node)
|
||
node.free()
|
||
_outline_nodes.clear()
|
||
|
||
if _outline_shader == null:
|
||
return
|
||
|
||
var outline_mat := ShaderMaterial.new()
|
||
outline_mat.shader = _outline_shader
|
||
outline_mat.set_shader_parameter("outline_color", OUTLINE_COLOR)
|
||
outline_mat.set_shader_parameter("outline_width", OUTLINE_WIDTH)
|
||
|
||
var all_meshes: Array[MeshInstance3D] = []
|
||
all_meshes.append_array(_body_meshes)
|
||
all_meshes.append_array(_clothing_meshes)
|
||
|
||
for mi in all_meshes:
|
||
if mi.mesh == null or not mi.visible:
|
||
continue
|
||
if mi.mesh.get_aabb().size.y < OUTLINE_SIZE_THRESHOLD:
|
||
continue
|
||
var outline_mi := mi.duplicate() as MeshInstance3D
|
||
outline_mi.name = "_outline_%s" % mi.name
|
||
outline_mi.material_override = outline_mat
|
||
mi.get_parent().add_child(outline_mi)
|
||
_outline_nodes.append(outline_mi)
|
||
|
||
|
||
# =============================================================================
|
||
# Static helpers
|
||
# =============================================================================
|
||
|
||
static func _find_skeleton(root: Node) -> Skeleton3D:
|
||
if root is Skeleton3D:
|
||
return root as Skeleton3D
|
||
for child in root.get_children():
|
||
var found := _find_skeleton(child)
|
||
if found:
|
||
return found
|
||
return null
|
||
|
||
|
||
static func _collect_meshes(root: Node) -> Array[MeshInstance3D]:
|
||
var result: Array[MeshInstance3D] = []
|
||
if root is MeshInstance3D:
|
||
result.append(root as MeshInstance3D)
|
||
for child in root.get_children():
|
||
result.append_array(_collect_meshes(child))
|
||
return result
|
||
|
||
|
||
static func _get_albedo_texture(mat: Material) -> Texture2D:
|
||
if mat == null:
|
||
return null
|
||
if mat is StandardMaterial3D:
|
||
return (mat as StandardMaterial3D).albedo_texture
|
||
if mat is BaseMaterial3D:
|
||
return (mat as BaseMaterial3D).albedo_texture
|
||
if mat is ShaderMaterial:
|
||
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo"]:
|
||
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
|
||
if val is Texture2D:
|
||
return val as Texture2D
|
||
return null
|