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()])