Files
settled-reach/spikes/quaternius-aesthetic/scripts/spike/character_visual.gd
T
jpmschweitzerandClaude Opus 4.6 c380ef8a84 feat(assets): add Quaternius aesthetic validation spike
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>
2026-03-19 13:29:22 +01:00

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