d6cd3293ae
assets/avatars/catalog.json referenced 14 GLB files that were never in the repo, so the avatar system could not load a single avatar. And the loader's assembly path had never run against a real skeleton -- it called Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a way that dropped the skin binding. - scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art swaps in as a pure content change (catalog paths unchanged). - assets/avatars/*.glb: the generated pack (checked in). - avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound Skeleton3D that generate_scene() already produces, instead of rebuilding it. Attachments now mount via BoneAttachment3D to the catalog's mount_bone and are actually added to the avatar tree (they were parsed then dropped). Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin, and the Array[String] type mismatch on the accessory list. - smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully because the files don't exist" section with a real assembly check -- Skeleton3D present, full 24-bone rig, canonical bones resolve by name, visible body mesh, equipped items mounted to bones. 14/14 from a cold cache. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
297 lines
11 KiB
GDScript
297 lines
11 KiB
GDScript
extends SceneTree
|
|
## Generates the placeholder avatar asset pack (GLB files) referenced by
|
|
## assets/avatars/catalog.json.
|
|
##
|
|
## These are deliberately crude primitive stand-ins so the avatar loader /
|
|
## customization / in-world rendering can be built and tested now; real art
|
|
## swaps in later as a pure content change (catalog paths are unchanged).
|
|
##
|
|
## Run: godot --headless --path . --script res://scripts/tools/gen_placeholder_avatars.gd
|
|
## Output: res://assets/avatars/*.glb (13 attachments + 1 rigged base body)
|
|
|
|
const OUT_DIR := "res://assets/avatars/"
|
|
|
|
# Canonical rig — must match avatar_loader.gd CANONICAL_BONES. Each entry:
|
|
# name -> [parent_name, global_rest_position] (T-pose, Y-up, metres)
|
|
const BONES := {
|
|
"Root": ["", Vector3(0.00, 0.00, 0.00)],
|
|
"Hips": ["Root", Vector3(0.00, 0.95, 0.00)],
|
|
"Spine": ["Hips", Vector3(0.00, 1.10, 0.00)],
|
|
"Chest": ["Spine", Vector3(0.00, 1.30, 0.00)],
|
|
"Neck": ["Chest", Vector3(0.00, 1.48, 0.00)],
|
|
"Head": ["Neck", Vector3(0.00, 1.60, 0.00)],
|
|
"HeadTop": ["Head", Vector3(0.00, 1.78, 0.00)],
|
|
"Jaw": ["Head", Vector3(0.00, 1.56, 0.06)],
|
|
"LeftEye": ["Head", Vector3(0.04, 1.64, 0.09)],
|
|
"RightEye": ["Head", Vector3(-0.04, 1.64, 0.09)],
|
|
"LeftShoulder": ["Chest", Vector3(0.06, 1.42, 0.00)],
|
|
"LeftUpperArm": ["LeftShoulder", Vector3(0.18, 1.42, 0.00)],
|
|
"LeftLowerArm": ["LeftUpperArm", Vector3(0.44, 1.42, 0.00)],
|
|
"LeftHand": ["LeftLowerArm", Vector3(0.68, 1.42, 0.00)],
|
|
"RightShoulder": ["Chest", Vector3(-0.06, 1.42, 0.00)],
|
|
"RightUpperArm": ["RightShoulder", Vector3(-0.18, 1.42, 0.00)],
|
|
"RightLowerArm": ["RightUpperArm", Vector3(-0.44, 1.42, 0.00)],
|
|
"RightHand": ["RightLowerArm", Vector3(-0.68, 1.42, 0.00)],
|
|
"LeftUpperLeg": ["Hips", Vector3(0.11, 0.90, 0.00)],
|
|
"LeftLowerLeg": ["LeftUpperLeg", Vector3(0.11, 0.50, 0.00)],
|
|
"LeftFoot": ["LeftLowerLeg", Vector3(0.11, 0.08, 0.04)],
|
|
"RightUpperLeg": ["Hips", Vector3(-0.11, 0.90, 0.00)],
|
|
"RightLowerLeg": ["RightUpperLeg", Vector3(-0.11, 0.50, 0.00)],
|
|
"RightFoot": ["RightLowerLeg", Vector3(-0.11, 0.08, 0.04)],
|
|
}
|
|
|
|
# Order matters: parents before children.
|
|
const BONE_ORDER := [
|
|
"Root", "Hips", "Spine", "Chest", "Neck", "Head", "HeadTop", "Jaw",
|
|
"LeftEye", "RightEye",
|
|
"LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand",
|
|
"RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand",
|
|
"LeftUpperLeg", "LeftLowerLeg", "LeftFoot",
|
|
"RightUpperLeg", "RightLowerLeg", "RightFoot",
|
|
]
|
|
|
|
# Body segments: [boneA, boneB, radius] -> a capsule-ish box between the two,
|
|
# fully weighted to boneA. boneB "" means a small blob at boneA.
|
|
const SEGMENTS := [
|
|
["Hips", "Spine", 0.14],
|
|
["Spine", "Chest", 0.15],
|
|
["Chest", "Neck", 0.13],
|
|
["Neck", "Head", 0.05],
|
|
["Head", "", 0.11],
|
|
["LeftUpperArm", "LeftLowerArm", 0.055],
|
|
["LeftLowerArm", "LeftHand", 0.045],
|
|
["LeftHand", "", 0.05],
|
|
["RightUpperArm", "RightLowerArm", 0.055],
|
|
["RightLowerArm", "RightHand", 0.045],
|
|
["RightHand", "", 0.05],
|
|
["LeftUpperLeg", "LeftLowerLeg", 0.08],
|
|
["LeftLowerLeg", "LeftFoot", 0.06],
|
|
["LeftFoot", "", 0.06],
|
|
["RightUpperLeg", "RightLowerLeg", 0.08],
|
|
["RightLowerLeg", "RightFoot", 0.06],
|
|
["RightFoot", "", 0.06],
|
|
]
|
|
|
|
# Attachments: id -> [Mesh, local_offset_from_mount_bone, colour]
|
|
# Mesh is built in _attachment_mesh().
|
|
const ATTACHMENTS := {
|
|
"head_cyber_01": ["head", Vector3(0, 0.02, 0.0), Color(0.20, 0.85, 0.95)],
|
|
"hair_mohawk_01": ["mohawk", Vector3(0, 0.02, 0.0), Color(0.90, 0.20, 0.35)],
|
|
"hair_ponytail_01": ["ponytail", Vector3(0, 0.0, -0.08), Color(0.45, 0.30, 0.15)],
|
|
"face_glasses_01": ["glasses", Vector3(0, 0.06, 0.10), Color(0.10, 0.10, 0.12)],
|
|
"chest_bandit_01": ["torso", Vector3(0, 0.0, 0.0), Color(0.55, 0.20, 0.20)],
|
|
"back_backpack_01": ["box", Vector3(0, 0.0, -0.16), Color(0.30, 0.35, 0.25)],
|
|
"pants_cargo_01": ["hips", Vector3(0, -0.18, 0.0), Color(0.30, 0.35, 0.45)],
|
|
"left_hand_glove_01": ["blob", Vector3(0, 0.0, 0.0), Color(0.15, 0.15, 0.18)],
|
|
"right_hand_glove_01": ["blob", Vector3(0, 0.0, 0.0), Color(0.15, 0.15, 0.18)],
|
|
"left_foot_boot_01": ["boot", Vector3(0, -0.02, 0.03), Color(0.12, 0.10, 0.09)],
|
|
"right_foot_boot_01": ["boot", Vector3(0, -0.02, 0.03), Color(0.12, 0.10, 0.09)],
|
|
"accessory_belt_01": ["belt", Vector3(0, 0.98, 0.0), Color(0.20, 0.18, 0.12)],
|
|
"accessory_necklace_01": ["belt", Vector3(0, 1.34, 0.02), Color(0.85, 0.75, 0.20)],
|
|
}
|
|
|
|
var _fail := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
var abs_dir := ProjectSettings.globalize_path(OUT_DIR)
|
|
DirAccess.make_dir_recursive_absolute(abs_dir)
|
|
|
|
_gen_base_body()
|
|
for id in ATTACHMENTS:
|
|
var spec: Array = ATTACHMENTS[id]
|
|
_gen_attachment(id, spec[0], spec[1], spec[2])
|
|
|
|
print("\n%s" % ("DONE — all placeholder avatar GLBs written" if _fail == 0
|
|
else "FAILED (%d errors)" % _fail))
|
|
quit(_fail)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base body — rigged, skinned
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _global_rest(bone: String) -> Transform3D:
|
|
return Transform3D(Basis.IDENTITY, BONES[bone][1])
|
|
|
|
|
|
func _gen_base_body() -> void:
|
|
var root := Node3D.new()
|
|
root.name = "AvatarRoot"
|
|
|
|
var skel := Skeleton3D.new()
|
|
skel.name = "Skeleton3D"
|
|
root.add_child(skel)
|
|
|
|
var idx := {}
|
|
for bone in BONE_ORDER:
|
|
var i := skel.add_bone(bone)
|
|
idx[bone] = i
|
|
for bone in BONE_ORDER:
|
|
var parent: String = BONES[bone][0]
|
|
var g := _global_rest(bone)
|
|
if parent == "":
|
|
skel.set_bone_rest(idx[bone], g)
|
|
else:
|
|
skel.set_bone_parent(idx[bone], idx[parent])
|
|
skel.set_bone_rest(idx[bone], _global_rest(parent).affine_inverse() * g)
|
|
for bone in BONE_ORDER:
|
|
skel.set_bone_pose_position(idx[bone], skel.get_bone_rest(idx[bone]).origin)
|
|
|
|
# Skin: one bind per bone, bind pose = inverse global rest.
|
|
var skin := Skin.new()
|
|
for bone in BONE_ORDER:
|
|
skin.add_named_bind(bone, _global_rest(bone).affine_inverse())
|
|
|
|
# Build one ArrayMesh surface from all segments, CPU-skinned to bind indices.
|
|
var verts := PackedVector3Array()
|
|
var normals := PackedVector3Array()
|
|
var bones := PackedInt32Array()
|
|
var weights := PackedFloat32Array()
|
|
|
|
var bind_index := {}
|
|
for b in range(BONE_ORDER.size()):
|
|
bind_index[BONE_ORDER[b]] = b
|
|
|
|
for seg in SEGMENTS:
|
|
var a: String = seg[0]
|
|
var b: String = seg[1]
|
|
var r: float = seg[2]
|
|
var pa: Vector3 = BONES[a][1]
|
|
var pb: Vector3 = pa + Vector3(0, 0.12, 0) if b == "" else BONES[b][1]
|
|
_append_box_between(verts, normals, bones, weights, pa, pb, r, bind_index[a])
|
|
|
|
var arr := []
|
|
arr.resize(Mesh.ARRAY_MAX)
|
|
arr[Mesh.ARRAY_VERTEX] = verts
|
|
arr[Mesh.ARRAY_NORMAL] = normals
|
|
arr[Mesh.ARRAY_BONES] = bones
|
|
arr[Mesh.ARRAY_WEIGHTS] = weights
|
|
|
|
var mesh := ArrayMesh.new()
|
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.78, 0.66, 0.55)
|
|
mesh.surface_set_material(0, mat)
|
|
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = "Body"
|
|
mi.mesh = mesh
|
|
mi.skin = skin
|
|
skel.add_child(mi)
|
|
mi.skeleton = mi.get_path_to(skel)
|
|
|
|
_write_glb(root, "base_human_male_01")
|
|
root.free()
|
|
|
|
|
|
# Append an axis-aligned-ish box spanning pa..pb, all verts weighted 1.0 to bone_idx.
|
|
func _append_box_between(verts: PackedVector3Array, normals: PackedVector3Array,
|
|
bones: PackedInt32Array, weights: PackedFloat32Array,
|
|
pa: Vector3, pb: Vector3, r: float, bone_idx: int) -> void:
|
|
var axis := pb - pa
|
|
var len := axis.length()
|
|
if len < 0.001:
|
|
axis = Vector3(0, 0.1, 0)
|
|
len = 0.1
|
|
var dir := axis / len
|
|
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.95 else Vector3.FORWARD
|
|
var right := dir.cross(up).normalized()
|
|
up = right.cross(dir).normalized()
|
|
|
|
var corners: Array[Vector3] = [
|
|
-right * r - up * r, right * r - up * r,
|
|
right * r + up * r, -right * r + up * r,
|
|
]
|
|
# 8 corners: bottom ring at pa, top ring at pb
|
|
var pts: Array[Vector3] = []
|
|
for c in corners:
|
|
pts.append(pa + c)
|
|
for c in corners:
|
|
pts.append(pb + c)
|
|
|
|
var faces := [
|
|
[0, 1, 2, 3], [7, 6, 5, 4], # caps
|
|
[0, 4, 5, 1], [1, 5, 6, 2], # sides
|
|
[2, 6, 7, 3], [3, 7, 4, 0],
|
|
]
|
|
for f in faces:
|
|
var q: Array[Vector3] = [pts[f[0]], pts[f[1]], pts[f[2]], pts[f[3]]]
|
|
var n: Vector3 = (q[1] - q[0]).cross(q[2] - q[0]).normalized()
|
|
for tri in [[0, 1, 2], [0, 2, 3]]:
|
|
for vi in tri:
|
|
verts.append(q[vi])
|
|
normals.append(n)
|
|
bones.append_array([bone_idx, 0, 0, 0])
|
|
weights.append_array([1.0, 0.0, 0.0, 0.0])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Attachments — plain static meshes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _gen_attachment(id: String, kind: String, offset: Vector3, col: Color) -> void:
|
|
var root := Node3D.new()
|
|
root.name = "AttachmentRoot"
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = id
|
|
mi.mesh = _attachment_mesh(kind)
|
|
mi.position = offset
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = col
|
|
mi.material_override = mat
|
|
root.add_child(mi)
|
|
_write_glb(root, id)
|
|
root.free()
|
|
|
|
|
|
func _attachment_mesh(kind: String) -> Mesh:
|
|
match kind:
|
|
"head":
|
|
var m := SphereMesh.new(); m.radius = 0.13; m.height = 0.26; return m
|
|
"mohawk":
|
|
var m := PrismMesh.new(); m.size = Vector3(0.06, 0.16, 0.30); return m
|
|
"ponytail":
|
|
var m := CapsuleMesh.new(); m.radius = 0.04; m.height = 0.28; return m
|
|
"glasses":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.20, 0.05, 0.02); return m
|
|
"torso":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.34, 0.44, 0.22); return m
|
|
"box":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.26, 0.32, 0.14); return m
|
|
"hips":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.32, 0.30, 0.20); return m
|
|
"blob":
|
|
var m := SphereMesh.new(); m.radius = 0.06; m.height = 0.12; return m
|
|
"boot":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.10, 0.10, 0.20); return m
|
|
"belt":
|
|
var m := TorusMesh.new(); m.inner_radius = 0.12; m.outer_radius = 0.17; return m
|
|
_:
|
|
return BoxMesh.new()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GLB export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _write_glb(scene_root: Node, basename: String) -> void:
|
|
# append_from_scene needs the node in a tree.
|
|
get_root().add_child(scene_root)
|
|
var doc := GLTFDocument.new()
|
|
var state := GLTFState.new()
|
|
var err := doc.append_from_scene(scene_root, state)
|
|
if err != OK:
|
|
push_error("append_from_scene failed for %s: %s" % [basename, error_string(err)])
|
|
_fail += 1
|
|
get_root().remove_child(scene_root)
|
|
return
|
|
var out_path := ProjectSettings.globalize_path("%s%s.glb" % [OUT_DIR, basename])
|
|
err = doc.write_to_filesystem(state, out_path)
|
|
get_root().remove_child(scene_root)
|
|
if err != OK:
|
|
push_error("write_to_filesystem failed for %s: %s" % [basename, error_string(err)])
|
|
_fail += 1
|
|
return
|
|
print(" wrote %s.glb (%d bytes)" % [basename, FileAccess.get_file_as_bytes(out_path).size()])
|