diff --git a/client/assets/characters/shaders/toon_masked.gdshader b/client/assets/characters/shaders/toon_masked.gdshader index 51be10d98..9a9865c21 100644 --- a/client/assets/characters/shaders/toon_masked.gdshader +++ b/client/assets/characters/shaders/toon_masked.gdshader @@ -1,5 +1,5 @@ shader_type spatial; -render_mode unshaded, cull_disabled; +render_mode unshaded; // Original Trellis atlas texture uniform sampler2D albedo_tex : source_color; @@ -28,4 +28,5 @@ void fragment() { vec3 color = base * mix(1.0 - shadow_strength, 1.0, toon); ALBEDO = color; + ALPHA = original.a * tint_color.a; } diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index d21144d3a..abed05fae 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -54,13 +54,19 @@ 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": "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", } @@ -204,6 +210,17 @@ func get_skin_tone_texture_name(index: int) -> String: # ============================================================================= 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) @@ -222,8 +239,6 @@ func _clear() -> void: _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() @@ -324,7 +339,7 @@ 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"): + if _attach_to_bone(path, "Head") != null: _loaded_slots.append("head") @@ -332,7 +347,7 @@ 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): + if _attach_to_bone(path, "Head", desc.hair_tint) != null: _loaded_slots.append("hair") @@ -340,7 +355,7 @@ 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): + if _attach_to_bone(path, "Head", desc.facial_hair_tint) != null: _loaded_slots.append("facial_hair") @@ -350,23 +365,23 @@ func _load_eyebrows(desc: CharacterVisualDescriptor) -> void: # 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): + 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) -> bool: +func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> BoneAttachment3D: if _skeleton == null: - return false + return null if not ResourceLoader.exists(path): push_warning("CharacterVisual: asset not found (expected): %s" % path) - return false + return null var scene := load(path) as PackedScene if scene == null: - return false + 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 false + return null var attachment := BoneAttachment3D.new() attachment.bone_name = bone_name attachment.bone_idx = bone_idx @@ -384,7 +399,7 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) attachment.add_child(inst) for mi in _collect_meshes(inst): _apply_tinted_shader(mi, tint, mask_tex) - return true + return attachment # ============================================================================= @@ -504,9 +519,10 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void: 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): + var att := _attach_to_bone(path, bone_name, tint) + if att != null: # Track accessory attachments separately for get_accessory_node_count() - _accessory_attachments.append(_bone_attachments.back()) + _accessory_attachments.append(att) # ============================================================================= diff --git a/client/scripts/rendering/character_visual_descriptor.gd b/client/scripts/rendering/character_visual_descriptor.gd index de5c84da7..d8e44abea 100644 --- a/client/scripts/rendering/character_visual_descriptor.gd +++ b/client/scripts/rendering/character_visual_descriptor.gd @@ -129,7 +129,7 @@ static func from_dict(data: Dictionary) -> CharacterVisualDescriptor: desc.facial_hair_tint = _decode_color(data.get("facial_hair_tint"), Color.WHITE) desc.eyebrow_id = data.get("eyebrow_id", "") desc.eyebrow_tint = _decode_color(data.get("eyebrow_tint"), Color.WHITE) - desc.skin_tone = data.get("skin_tone", 0) + desc.skin_tone = clampi(data.get("skin_tone", 0), 0, 8) desc.clothing_slots = data.get("clothing_slots", {}) desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {})) desc.accessory_slots = data.get("accessory_slots", {}) diff --git a/client/tests/test_character_visual_sprint28.gd b/client/tests/test_character_visual_sprint28.gd index 169fcfb78..f88101243 100644 --- a/client/tests/test_character_visual_sprint28.gd +++ b/client/tests/test_character_visual_sprint28.gd @@ -15,7 +15,7 @@ ## 5. Facing rotation (set_facing / rotation change) ## 6. Edge cases: empty hair_id, no clothing, no accessories ## -## Spec: D-159 (body types), D-160 (15+2+2 segments), D-161 (head on Head bone), +## Spec: D-159 (body types), D-160 (17 bone-group regions), D-161 (head on Head bone), ## D-162 (clothing pre-baked per body type), D-164 (fork skeleton) ## Ticket: #704 class_name TestCharacterVisualSprint28 @@ -158,10 +158,11 @@ func test_reload_descriptor_clears_previous_nodes() -> void: if count_after_first == 0: push_warning("TestCharacterVisualSprint28: node count 0 — assets may not be present yet") return - # Second load must not double the children (would indicate no cleanup) - assert_bool(count_after_second <= count_after_first * 2).override_failure_message( - "load_descriptor must clear previous node tree before building new one — children doubled, suggesting stale nodes were kept" - ).is_true() + # Second load must produce exactly the same child count — no more, no less. + # _clear() frees and rebuilds; any deviation indicates stale nodes accumulating. + assert_int(count_after_second).override_failure_message( + "load_descriptor must produce identical child count on reload — stale nodes detected if higher, missing cleanup if lower" + ).is_equal(count_after_first) # ============================================================================= diff --git a/decisions/architecture.md b/decisions/architecture.md index 3cc987d81..55f39f15a 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -540,9 +540,9 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Dissent:** None - **Cross-reference:** [D-149](#d-149-3d-characters-rendered-live-in-scene--not-pre-rendered-sprites), [D-150](#d-150-character-outline--inverted-hull-method) -### D-160: Body meshes must be segmented into 15 bone-group regions +### D-160: Body meshes must be segmented into 17 bone-group regions - **Date:** 2026-03-19 -- **Decision:** Character body meshes are segmented into 15 bone-group regions (head, neck, torso, arm_upper_l/r, arm_lower_l/r, hand_l/r, leg_upper_l/r, leg_lower_l/r, foot_l/r) plus eyes and eyebrows. Each segment is a separate skinned GLB on the shared 65-bone skeleton. Segments have a 1-ring vertex overlap at boundaries to eliminate visible seams during animation. Segments can be individually hidden when clothing covers them. +- **Decision:** Character body meshes are segmented into 17 bone-group regions (head, neck, torso, arm_upper_l/r, arm_lower_l/r, hand_l/r, leg_upper_l/r, leg_lower_l/r, foot_l/r, eyes, eyebrows). Each segment is a separate skinned GLB on the shared 65-bone skeleton. Segments have a 1-ring vertex overlap at boundaries to eliminate visible seams during animation. Segments can be individually hidden when clothing covers them. - **Rationale:** Validated in the Quaternius aesthetic spike. Monolithic body meshes block the compositor: hiding the body to show clothing also removes the head and limbs. Segmentation enables per-slot visibility, head separation for Trellis-generated faces, and limb loss mechanics. The 1-ring vertex overlap was validated as seamless at gameplay zoom. - **Raised by:** Jeroen + Tyre, during spike validation. - **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton) (body type enum), [D-149](#d-149-3d-characters-rendered-live-in-scene--not-pre-rendered-sprites) @@ -552,7 +552,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Decision:** Character heads are never baked into body or clothing meshes. The head is always a separate mesh attached to the Head bone via BoneAttachment3D. Trellis generates unique head shapes per character. Hair attaches to the same bone as a separate swappable mesh (enables hairdresser mechanic). The neck stays with the body mesh as a separate segment. - **Rationale:** Validated in the Quaternius spike. The Quaternius pack bakes the head into the body mesh — this blocks clothing display (hiding body = losing head) and prevents per-character face variation. Separating the head unlocks: Trellis-generated faces, idle look-around animation, helmet/hood equipment, hair as a swappable accessory. - **Raised by:** Jeroen, during spike validation. -- **Cross-reference:** [D-160](#d-160-body-meshes-must-be-segmented-into-15-bone-group-regions), [D-163](#d-163-trellis-generates-unique-heads-per-character-via-boneattachment3d) +- **Cross-reference:** [D-160](#d-160-body-meshes-must-be-segmented-into-17-bone-group-regions), [D-163](#d-163-trellis-generates-unique-heads-per-character-via-boneattachment3d) ### D-162: Clothing is pre-baked per body type via Blender Surface Deform - **Date:** 2026-03-19 @@ -573,7 +573,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Decision:** The Quaternius 65-bone skeleton and Universal Animation Library are adopted as the character rig foundation. All body meshes are replaced with hand-authored meshes on the same skeleton. The CC0 license permits unrestricted forking. Quaternius Source tier ($5/mo Patreon, one month) is recommended for .blend source files to support the Blender batch scripting pipeline. - **Rationale:** The spike validated the skeleton and animation library as clean, compatible, and well-structured. The body meshes are rejected: Superhero proportions contradict the life-sim aesthetic (D-153), only 2 body types exist in the standard tier (Superhero Male/Female), and procedural body type generation via bone scaling failed. The skeleton is the expensive part to create from scratch — keeping it and replacing the meshes is the correct split. - **Raised by:** Tyre + Araminta, confirmed by Jeroen. -- **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton), [D-160](#d-160-body-meshes-must-be-segmented-into-15-bone-group-regions) +- **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton), [D-160](#d-160-body-meshes-must-be-segmented-into-17-bone-group-regions) --- diff --git a/tooling/blender_list_animations.py b/tooling/blender_list_animations.py index 28b0294d0..ab4f17bcb 100644 --- a/tooling/blender_list_animations.py +++ b/tooling/blender_list_animations.py @@ -16,6 +16,10 @@ if __name__ == "__main__": sys.exit(1) args = argv[argv.index("--") + 1:] + if len(args) < 1: + print("ERROR: Provide a GLB/GLTF path.") + sys.exit(1) + input_path = args[0] bpy.ops.wm.read_factory_settings(use_empty=True) diff --git a/tooling/blender_list_bones.py b/tooling/blender_list_bones.py index 6fad939df..7351fb4fe 100644 --- a/tooling/blender_list_bones.py +++ b/tooling/blender_list_bones.py @@ -1,10 +1,37 @@ +""" +blender_list_bones.py +Usage: tooling/blender --background --python tooling/blender_list_bones.py -- + +Lists all bone names in a GLB/GLTF armature, sorted alphabetically. +Used to verify bone naming after the January 2026 Quaternius naming update. +""" + import sys import bpy -bpy.ops.wm.read_factory_settings(use_empty=True) -bpy.ops.import_scene.gltf(filepath=sys.argv[sys.argv.index("--") + 1]) -for obj in bpy.context.scene.objects: - if obj.type == 'ARMATURE': - for b in sorted(obj.data.bones, key=lambda x: x.name): - print("BONE: " + b.name) - break + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + print("Usage: tooling/blender --background --python tooling/blender_list_bones.py -- ") + sys.exit(1) + + args = argv[argv.index("--") + 1:] + if len(args) < 1: + print("ERROR: Provide a GLB/GLTF path.") + sys.exit(1) + + input_path = args[0] + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=input_path) + + for obj in bpy.context.scene.objects: + if obj.type == 'ARMATURE': + print(f"ARMATURE: {obj.name} — {len(obj.data.bones)} bones") + for b in sorted(obj.data.bones, key=lambda x: x.name): + print("BONE: " + b.name) + break + else: + print("ERROR: No armature found in the imported file.") + sys.exit(1)