GDScript strict mode rejects type inference from Variant-returning functions. JSON.parse_string() returns Variant — use explicit type annotation to satisfy the parser. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
628 lines
23 KiB
GDScript
628 lines
23 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: 18 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 (18 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: 14 base + 2 swappable torso + 2 face = 18.
|
||
## 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").
|
||
## 12 slots total: head-attached (hat/goggles/mask/earring_l/earring_r/necklace),
|
||
## spine-attached (backpack/belt), wrist-attached, hand-held.
|
||
const SLOT_TO_BONE: Dictionary = {
|
||
"hat": "Head",
|
||
"goggles": "Head",
|
||
"mask": "Head",
|
||
"earring_l": "Head",
|
||
"earring_r": "Head",
|
||
"necklace": "Head",
|
||
"backpack": "spine_03",
|
||
"belt": "pelvis",
|
||
"wrist_l": "lowerarm_l",
|
||
"wrist_r": "lowerarm_r",
|
||
"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:
|
||
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
|
||
# before the source meshes are removed. _rebuild_outlines() is not called here
|
||
# because we are tearing down, not rebuilding; the rebuild happens at the end
|
||
# of load_descriptor() once the new character is assembled.
|
||
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()
|
||
|
||
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"
|
||
|
||
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") != null:
|
||
_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) != null:
|
||
_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) != null:
|
||
_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) != null:
|
||
_loaded_slots.append("eyebrow")
|
||
|
||
|
||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> BoneAttachment3D:
|
||
if _skeleton == null:
|
||
return null
|
||
if not ResourceLoader.exists(path):
|
||
push_warning("CharacterVisual: asset not found (expected): %s" % path)
|
||
return null
|
||
var scene := load(path) as PackedScene
|
||
if scene == null:
|
||
return null
|
||
var bone_idx := _skeleton.find_bone(bone_name)
|
||
if bone_idx == -1:
|
||
push_error("CharacterVisual: bone '%s' not found in skeleton" % bone_name)
|
||
return null
|
||
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 attachment
|
||
|
||
|
||
# =============================================================================
|
||
# 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: Variant = 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
|
||
# accessory_tints is now Array[Color] (Primary + Secondary). Pass Primary ([0]).
|
||
var tints: Array = desc.accessory_tints.get(item_id, [])
|
||
var tint: Color = tints[0] if not tints.is_empty() else Color.WHITE
|
||
var att := _attach_to_bone(path, bone_name, tint)
|
||
if att != null:
|
||
# Track accessory attachments separately for get_accessory_node_count()
|
||
_accessory_attachments.append(att)
|
||
|
||
|
||
# =============================================================================
|
||
# 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
|