Complete character pipeline spike validating the Quaternius rig as foundation for The Settled Reach's 3D character system. Validated: - 65-bone skeleton + Universal Animation Library as rig foundation - Body segmentation into 15 bone-group regions with 1-ring vertex overlap - Trellis-generated heads via BoneAttachment3D - Skin tone texture generation pipeline (9 variants from source) - Toon shader + inverted hull outline at gameplay zoom - CharacterVisual class as compositor prototype Failed (documented): - Trellis clothing auto-rigging (sculptures, not garments) - Bone scaling for body type variants (catastrophic joint deformation) - Runtime per-segment clothing scaling (same failure as Blender-side) Includes: Blender pipeline scripts (segmentation, auto-rigging, Surface Deform fitting), Godot showcase with interactive controls, automated screenshot cycle, smoke tests, team reviews, and VERDICT.md. D-158 through D-164 locked. Q-060 through Q-062 opened. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
576 lines
20 KiB
GDScript
576 lines
20 KiB
GDScript
class_name CharacterVisual
|
|
extends Node3D
|
|
|
|
## Manages the visual representation of a single character:
|
|
## segmented body mesh, outfit meshes, toon shader, outline pass, and animation.
|
|
##
|
|
## Architecture (validated in Sprint 28):
|
|
## The body is segmented into 15 bone-group regions (head, neck, torso, arms,
|
|
## hands, legs, feet) plus eyes/eyebrows. Each segment is a separate skinned
|
|
## GLB sharing the same 65-bone skeleton. Segments can be individually hidden
|
|
## when clothing covers them.
|
|
##
|
|
## Static meshes (Trellis-generated heads, hairstyles, hats) attach to bones
|
|
## via BoneAttachment3D. Outfits are skinned GLBs that share the skeleton.
|
|
##
|
|
## Usage:
|
|
## var cv = CharacterVisual.new()
|
|
## parent.add_child(cv)
|
|
## cv.load_body("res://models/quaternius/segmented/", ALL_SEGMENTS)
|
|
## cv.load_animation_library("res://models/quaternius/animations/UAL2_Standard.glb")
|
|
## cv.play_animation("Idle_FoldArms")
|
|
## cv.attach_outfit("res://models/quaternius/outfits-fantasy/parts/Male_Ranger_Body.gltf")
|
|
##
|
|
## The class owns the scene subtree rooted at the imported body. All shader and
|
|
## outline state is managed internally. The caller never touches materials directly.
|
|
|
|
signal outfit_changed(outfit_name: String)
|
|
signal shader_mode_changed(mode: int)
|
|
signal outline_changed(enabled: bool)
|
|
|
|
enum ShaderMode { TEXTURED, FLAT_TOON }
|
|
|
|
## Camera angle presets for use with this character system.
|
|
## Frontal (0 deg tilt) is the default for mugshot / character editor UI.
|
|
## Gameplay uses dramatic (-30 deg) or isometric (-45 deg).
|
|
const CAMERA_ANGLE_FRONTAL := Vector3(-5.0, 45.0, 0.0)
|
|
const CAMERA_ANGLE_DRAMATIC := Vector3(-30.0, 45.0, 0.0)
|
|
const CAMERA_ANGLE_TOPDOWN := Vector3(-90.0, 0.0, 0.0)
|
|
|
|
## All body segment names in loading order.
|
|
const ALL_SEGMENTS: Array[String] = [
|
|
"head", "neck", "torso",
|
|
"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",
|
|
"eyebrows", "eyes",
|
|
]
|
|
|
|
# --- Public state (read via getters, write via setters) ---
|
|
|
|
var shader_mode: int = ShaderMode.TEXTURED:
|
|
set(value):
|
|
if shader_mode == value:
|
|
return
|
|
shader_mode = value
|
|
_refresh_visuals()
|
|
shader_mode_changed.emit(shader_mode)
|
|
|
|
var outline_enabled: bool = true:
|
|
set(value):
|
|
if outline_enabled == value:
|
|
return
|
|
outline_enabled = value
|
|
_rebuild_outlines()
|
|
outline_changed.emit(outline_enabled)
|
|
|
|
var outline_width: float = 0.006
|
|
var outline_color: Color = Color(0.08, 0.08, 0.12)
|
|
|
|
## Skin tone palette — 9 tones from pale to deep, each with lit and shadow color.
|
|
const SKIN_TONES := [
|
|
{"name": "Pale cool", "lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"},
|
|
{"name": "Pale warm", "lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"},
|
|
{"name": "Light olive", "lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"},
|
|
{"name": "Medium golden", "lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"},
|
|
{"name": "Olive warm", "lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"},
|
|
{"name": "Medium brown", "lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"},
|
|
{"name": "Deep brown", "lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"},
|
|
{"name": "Very deep warm", "lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"},
|
|
{"name": "Very deep cool", "lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"},
|
|
]
|
|
|
|
var skin_tone_index: int = 2: # default: Light olive
|
|
set(value):
|
|
skin_tone_index = value % SKIN_TONES.size()
|
|
# Only reapply toon shaders, don't touch outlines
|
|
for mi in _content_meshes:
|
|
_apply_toon(mi)
|
|
|
|
# --- Internal state ---
|
|
|
|
var _body_root: Node3D = null
|
|
var _skeleton: Skeleton3D = null
|
|
var _anim_player: AnimationPlayer = null
|
|
var _content_meshes: Array[MeshInstance3D] = []
|
|
var _body_meshes: Array[MeshInstance3D] = []
|
|
var _outline_nodes: Array[Node3D] = []
|
|
var _outfit_meshes: Array[MeshInstance3D] = []
|
|
|
|
# Shaders -- set these before loading the body, or call set_shaders().
|
|
var _toon_shader: Shader
|
|
var _toon_masked_shader: Shader
|
|
var _outline_shader: Shader
|
|
|
|
# --- Initialization ---
|
|
|
|
func set_shaders(toon: Shader, toon_masked: Shader, outline: Shader) -> void:
|
|
_toon_shader = toon
|
|
_toon_masked_shader = toon_masked
|
|
_outline_shader = outline
|
|
|
|
# --- Body loading ---
|
|
|
|
func load_body(segment_dir: String, segment_names: Array[String] = ALL_SEGMENTS.duplicate()) -> bool:
|
|
## Load a body from individual segment GLBs. Each segment is a separate skinned
|
|
## mesh that attaches to the shared skeleton.
|
|
##
|
|
## segment_dir: directory path like "res://models/quaternius/segmented/"
|
|
## segment_names: list of segment names to load (default: all 17 segments)
|
|
##
|
|
## The directory must contain an armature.glb (skeleton-only) and seg_<name>.glb
|
|
## files for each segment.
|
|
|
|
# Load the armature first (provides the skeleton)
|
|
var armature_scene: PackedScene = load(segment_dir.path_join("armature.glb"))
|
|
if armature_scene == null:
|
|
# Fall back: load skeleton from first segment
|
|
var first := load(segment_dir.path_join("seg_%s.glb" % segment_names[0]))
|
|
if first == null:
|
|
push_error("CharacterVisual: no armature or segments found in '%s'" % segment_dir)
|
|
return false
|
|
_body_root = first.instantiate()
|
|
add_child(_body_root)
|
|
_skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D
|
|
for mi in _collect_meshes(_body_root):
|
|
_register_mesh(mi)
|
|
_body_meshes.append(mi)
|
|
mi.set_meta("segment", segment_names[0])
|
|
segment_names = segment_names.slice(1)
|
|
else:
|
|
_body_root = armature_scene.instantiate()
|
|
add_child(_body_root)
|
|
_skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D
|
|
|
|
if _skeleton == null:
|
|
push_error("CharacterVisual: no skeleton found")
|
|
return false
|
|
|
|
# Load each segment and attach its meshes to our skeleton
|
|
for seg_name in segment_names:
|
|
var seg_path := segment_dir.path_join("seg_%s.glb" % seg_name)
|
|
var seg_scene: PackedScene = load(seg_path)
|
|
if seg_scene == null:
|
|
print(" Segment not found: %s" % seg_path)
|
|
continue
|
|
var seg_inst := seg_scene.instantiate()
|
|
var seg_meshes := _collect_meshes(seg_inst)
|
|
for mi in seg_meshes:
|
|
mi.get_parent().remove_child(mi)
|
|
mi.owner = null
|
|
mi.set_meta("segment", seg_name)
|
|
# Apply shader + fallback material to ALL surfaces BEFORE adding to tree
|
|
# to prevent null material errors from the renderer
|
|
_register_mesh(mi)
|
|
_ensure_all_materials(mi)
|
|
_skeleton.add_child(mi)
|
|
_body_meshes.append(mi)
|
|
seg_inst.queue_free()
|
|
|
|
_refresh_visuals()
|
|
return true
|
|
|
|
|
|
func load_unsegmented_body(gltf_path: String) -> bool:
|
|
## Load a single full-body GLB (unsegmented). Used for comparison with the
|
|
## original Quaternius base characters. Not the primary architecture.
|
|
var scene: PackedScene = load(gltf_path)
|
|
if scene == null:
|
|
push_error("CharacterVisual: could not load body '%s'" % gltf_path)
|
|
return false
|
|
|
|
_body_root = scene.instantiate()
|
|
add_child(_body_root)
|
|
|
|
_skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D
|
|
|
|
for mi in _collect_meshes(_body_root):
|
|
_register_mesh(mi)
|
|
_body_meshes.append(mi)
|
|
|
|
_refresh_visuals()
|
|
return true
|
|
|
|
|
|
func set_segment_visible(segment_name: String, visible: bool) -> void:
|
|
## Show/hide a specific body segment by name.
|
|
for mi in _body_meshes:
|
|
if mi.has_meta("segment") and mi.get_meta("segment") == segment_name:
|
|
mi.visible = visible
|
|
_rebuild_outlines()
|
|
|
|
func set_body_visible(show: bool) -> void:
|
|
## Hide/show all body meshes. When hiding, keeps small meshes (eyes, eyebrows)
|
|
## visible so the face remains present when outfits do not include a head.
|
|
## On segmented bodies, delegates to per-segment visibility.
|
|
const SMALL_MESH_THRESHOLD := 0.1 # AABB height below which meshes are kept visible
|
|
for mi in _body_meshes:
|
|
if not show and mi.mesh and mi.mesh.get_aabb().size.y < SMALL_MESH_THRESHOLD:
|
|
mi.visible = true # keep eyes/eyebrows
|
|
else:
|
|
mi.visible = show
|
|
_rebuild_outlines()
|
|
|
|
func get_skeleton() -> Skeleton3D:
|
|
return _skeleton
|
|
|
|
func get_bone_count() -> int:
|
|
return _skeleton.get_bone_count() if _skeleton else 0
|
|
|
|
func get_content_mesh_count() -> int:
|
|
return _content_meshes.size()
|
|
|
|
func get_animation_player() -> AnimationPlayer:
|
|
return _anim_player
|
|
|
|
func has_bone_attachments() -> bool:
|
|
return not _bone_attachments.is_empty()
|
|
|
|
# --- Animation ---
|
|
|
|
func load_animation_library(glb_path: String, library_name: String = "ual") -> PackedStringArray:
|
|
## Load animations from a GLB and attach them to this character.
|
|
## Returns the list of animation names available.
|
|
var scene: PackedScene = load(glb_path)
|
|
if scene == null:
|
|
push_error("CharacterVisual: could not load animation library '%s'" % glb_path)
|
|
return PackedStringArray()
|
|
|
|
var anim_instance := scene.instantiate()
|
|
var source_player := _find_typed(anim_instance, "AnimationPlayer") as AnimationPlayer
|
|
if source_player == null:
|
|
anim_instance.queue_free()
|
|
push_error("CharacterVisual: no AnimationPlayer in '%s'" % glb_path)
|
|
return PackedStringArray()
|
|
|
|
# Ensure we have an AnimationPlayer on our character
|
|
_anim_player = _find_typed(_body_root, "AnimationPlayer") as AnimationPlayer
|
|
if _anim_player == null:
|
|
_anim_player = AnimationPlayer.new()
|
|
_anim_player.name = "AnimationPlayer"
|
|
_body_root.add_child(_anim_player)
|
|
|
|
# Remap and copy all animations
|
|
var lib := AnimationLibrary.new()
|
|
var names := PackedStringArray()
|
|
for anim_name in source_player.get_animation_list():
|
|
var source_anim := source_player.get_animation(anim_name)
|
|
var remapped := _remap_animation(source_anim, anim_instance, _body_root)
|
|
lib.add_animation(anim_name, remapped)
|
|
names.append(anim_name)
|
|
|
|
_anim_player.add_animation_library(library_name, lib)
|
|
anim_instance.queue_free()
|
|
return names
|
|
|
|
func play_animation(anim_name: String, library_name: String = "ual") -> void:
|
|
if _anim_player:
|
|
_anim_player.play("%s/%s" % [library_name, anim_name])
|
|
|
|
func pause_animation() -> void:
|
|
if _anim_player:
|
|
_anim_player.pause()
|
|
|
|
func resume_animation() -> void:
|
|
if _anim_player:
|
|
_anim_player.play()
|
|
|
|
func is_playing() -> bool:
|
|
return _anim_player.is_playing() if _anim_player else false
|
|
|
|
func get_current_animation() -> String:
|
|
return _anim_player.current_animation if _anim_player else ""
|
|
|
|
# --- Bone attachments (heads, hair, accessories) ---
|
|
|
|
var _bone_attachments: Array[Node3D] = []
|
|
|
|
func attach_to_bone(gltf_path: String, bone_name: String, offset := Vector3.ZERO) -> bool:
|
|
## Load a static mesh and parent it to a bone via BoneAttachment3D.
|
|
## No skinning needed -- the mesh rides the bone transform.
|
|
## Used for Trellis-generated heads, hairstyles, hats, held items.
|
|
if _skeleton == null:
|
|
push_error("CharacterVisual: no skeleton for bone attachment")
|
|
return false
|
|
|
|
var bone_idx := _skeleton.find_bone(bone_name)
|
|
if bone_idx == -1:
|
|
push_error("CharacterVisual: bone '%s' not found" % bone_name)
|
|
return false
|
|
|
|
var scene: PackedScene = load(gltf_path)
|
|
if scene == null:
|
|
push_error("CharacterVisual: could not load '%s'" % gltf_path)
|
|
return false
|
|
|
|
var instance := scene.instantiate()
|
|
|
|
var attachment := BoneAttachment3D.new()
|
|
attachment.bone_name = bone_name
|
|
attachment.bone_idx = bone_idx
|
|
_skeleton.add_child(attachment)
|
|
|
|
instance.position = offset
|
|
attachment.add_child(instance)
|
|
|
|
# Apply toon shader to all meshes in the attachment
|
|
for mi in _collect_meshes(instance):
|
|
_save_originals(mi)
|
|
_apply_toon(mi)
|
|
_content_meshes.append(mi)
|
|
|
|
_bone_attachments.append(attachment)
|
|
_rebuild_outlines()
|
|
return true
|
|
|
|
func detach_all_bone_attachments() -> void:
|
|
for att in _bone_attachments:
|
|
for mi in _collect_meshes(att):
|
|
_content_meshes.erase(mi)
|
|
if att.get_parent():
|
|
att.get_parent().remove_child(att)
|
|
att.free()
|
|
_bone_attachments.clear()
|
|
_rebuild_outlines()
|
|
|
|
# --- Outfits ---
|
|
|
|
func attach_outfit(gltf_path: String) -> bool:
|
|
## Load an outfit GLB/GLTF and attach its meshes to this character's skeleton.
|
|
## Returns true if at least one mesh was attached.
|
|
if _skeleton == null:
|
|
push_error("CharacterVisual: no skeleton to attach outfit to")
|
|
return false
|
|
|
|
var scene: PackedScene = load(gltf_path)
|
|
if scene == null:
|
|
push_error("CharacterVisual: could not load outfit '%s'" % gltf_path)
|
|
return false
|
|
|
|
var outfit_instance := scene.instantiate()
|
|
var meshes := _collect_meshes(outfit_instance)
|
|
if meshes.is_empty():
|
|
outfit_instance.queue_free()
|
|
return false
|
|
|
|
for mi in meshes:
|
|
mi.get_parent().remove_child(mi)
|
|
mi.owner = null
|
|
_register_mesh(mi)
|
|
_ensure_all_materials(mi)
|
|
_skeleton.add_child(mi)
|
|
_outfit_meshes.append(mi)
|
|
|
|
outfit_instance.queue_free()
|
|
_rebuild_outlines()
|
|
outfit_changed.emit(gltf_path.get_file().get_basename())
|
|
return true
|
|
|
|
func detach_all_outfits() -> void:
|
|
for mi in _outfit_meshes:
|
|
_unregister_mesh(mi)
|
|
if mi.get_parent():
|
|
mi.get_parent().remove_child(mi)
|
|
mi.free()
|
|
_outfit_meshes.clear()
|
|
_rebuild_outlines()
|
|
outfit_changed.emit("none")
|
|
|
|
func get_outfit_count() -> int:
|
|
return _outfit_meshes.size()
|
|
|
|
# --- Visuals core ---
|
|
|
|
func _refresh_visuals() -> void:
|
|
## Reapply toon shader to all content meshes. Does NOT rebuild outlines —
|
|
## outline material is independent of toon shader state. Call _rebuild_outlines()
|
|
## separately when outline state changes or meshes are added/removed.
|
|
for mi in _content_meshes:
|
|
_apply_toon(mi)
|
|
|
|
func _register_mesh(mi: MeshInstance3D) -> void:
|
|
_save_originals(mi)
|
|
_apply_toon(mi)
|
|
_content_meshes.append(mi)
|
|
|
|
func _unregister_mesh(mi: MeshInstance3D) -> void:
|
|
_content_meshes.erase(mi)
|
|
|
|
# --- Toon shader ---
|
|
|
|
func _save_originals(mi: MeshInstance3D) -> void:
|
|
if mi.mesh == null or mi.has_meta("_orig_mats"):
|
|
return
|
|
var mats: Array[Material] = []
|
|
for s in range(mi.mesh.get_surface_count()):
|
|
var mat := mi.get_active_material(s)
|
|
if mat == null:
|
|
mat = mi.mesh.surface_get_material(s)
|
|
mats.append(mat)
|
|
mi.set_meta("_orig_mats", mats)
|
|
|
|
func _apply_toon(mi: MeshInstance3D) -> void:
|
|
if mi.mesh == null:
|
|
return
|
|
|
|
# Body segments that show skin get skin tone textures.
|
|
# Eyes, eyebrows, and the icosphere (light probe) keep their original materials.
|
|
const NON_SKIN_SEGMENTS := ["eyes", "eyebrows", "icosphere"]
|
|
var segment_name: String = mi.get_meta("segment", "") as String
|
|
var is_skin := segment_name != "" and segment_name not in NON_SKIN_SEGMENTS
|
|
var tone: Dictionary = SKIN_TONES[skin_tone_index]
|
|
|
|
var originals: Array = mi.get_meta("_orig_mats", []) as Array
|
|
for surf_idx in range(mi.mesh.get_surface_count()):
|
|
var original: Material = originals[surf_idx] if surf_idx < originals.size() else null
|
|
|
|
if is_skin:
|
|
# Body segments: load skin tone texture variant
|
|
var skin_tex_path := "res://models/quaternius/segmented-male/T_Skin_%s.png" % tone["tex"]
|
|
var skin_tex: Texture2D = load(skin_tex_path) as Texture2D
|
|
if skin_tex:
|
|
var mat := ShaderMaterial.new()
|
|
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)
|
|
mi.set_surface_override_material(surf_idx, mat)
|
|
else:
|
|
# Fallback: flat toon if texture not found
|
|
var mat := ShaderMaterial.new()
|
|
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_idx, mat)
|
|
else:
|
|
# Non-body meshes (outfits, accessories)
|
|
var tex := _get_albedo_texture(original)
|
|
var col := _get_albedo_color(original)
|
|
if tex:
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = _toon_masked_shader
|
|
mat.set_shader_parameter("albedo_tex", tex)
|
|
mat.set_shader_parameter("tint_color", Color(0.8, 0.7, 0.6))
|
|
mat.set_shader_parameter("shadow_strength", 0.2)
|
|
mi.set_surface_override_material(surf_idx, mat)
|
|
else:
|
|
var base := col if col != Color.BLACK else Color(0.6, 0.5, 0.45)
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = _toon_shader
|
|
mat.set_shader_parameter("base_color", base)
|
|
mat.set_shader_parameter("shadow_color", base.darkened(0.4))
|
|
mat.set_shader_parameter("shadow_threshold", 0.4)
|
|
mi.set_surface_override_material(surf_idx, mat)
|
|
|
|
# --- Outline ---
|
|
|
|
func _rebuild_outlines() -> void:
|
|
# Remove existing outline nodes immediately (not deferred) to prevent
|
|
# the renderer from touching them with stale/null materials
|
|
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 not outline_enabled:
|
|
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)
|
|
|
|
const OUTLINE_SIZE_THRESHOLD := 0.1 # skip tiny meshes (eyes/eyebrows)
|
|
for mi in _content_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
|
|
_apply_material_recursive(outline_mi, outline_mat)
|
|
_ensure_all_materials(outline_mi)
|
|
mi.get_parent().add_child(outline_mi)
|
|
_outline_nodes.append(outline_mi)
|
|
|
|
# --- Helpers ---
|
|
|
|
static func _apply_material_recursive(node: Node, mat: Material) -> void:
|
|
if node is MeshInstance3D:
|
|
(node as MeshInstance3D).material_override = mat
|
|
for child in node.get_children():
|
|
_apply_material_recursive(child, mat)
|
|
|
|
static func _get_albedo_color(mat: Material) -> Color:
|
|
if mat is StandardMaterial3D:
|
|
return (mat as StandardMaterial3D).albedo_color
|
|
if mat is BaseMaterial3D:
|
|
return (mat as BaseMaterial3D).albedo_color
|
|
return Color.BLACK
|
|
|
|
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 in ["albedo_tex", "albedo_texture", "texture_albedo", "base_color_texture"]:
|
|
var val = (mat as ShaderMaterial).get_shader_parameter(p)
|
|
if val is Texture2D:
|
|
return val as Texture2D
|
|
return null
|
|
|
|
static func _find_typed(root: Node, type_name: String) -> Node:
|
|
if root.get_class() == type_name:
|
|
return root
|
|
for child in root.get_children():
|
|
var found := _find_typed(child, type_name)
|
|
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
|
|
|
|
func _ensure_all_materials(node: Node) -> void:
|
|
## Ensure every MeshInstance3D in the subtree has a material on every surface.
|
|
## Prevents "material is null" errors from the GL renderer.
|
|
if node is MeshInstance3D:
|
|
var mi := node as MeshInstance3D
|
|
if mi.mesh:
|
|
for surf_idx in range(mi.mesh.get_surface_count()):
|
|
if mi.get_active_material(surf_idx) == null:
|
|
if mi.material_override == null:
|
|
var fallback := StandardMaterial3D.new()
|
|
fallback.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
fallback.albedo_color = Color(0.5, 0.5, 0.5)
|
|
mi.material_override = fallback
|
|
break # material_override covers all surfaces
|
|
for child in node.get_children():
|
|
_ensure_all_materials(child)
|
|
|
|
func _remap_animation(source: Animation, source_root: Node, target_root: Node) -> Animation:
|
|
var anim := source.duplicate() as Animation
|
|
var src_skel := _find_typed(source_root, "Skeleton3D")
|
|
var tgt_skel := _find_typed(target_root, "Skeleton3D")
|
|
if src_skel == null or tgt_skel == null:
|
|
return anim
|
|
var src_path := str(source_root.get_path_to(src_skel))
|
|
var tgt_path := str(target_root.get_path_to(tgt_skel))
|
|
for i in range(anim.get_track_count()):
|
|
var track := str(anim.track_get_path(i))
|
|
anim.track_set_path(i, NodePath(track.replace(src_path, tgt_path)))
|
|
return anim
|