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>
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
FAILED (Trellis clothing pipeline): Auto-rig a Trellis-generated clothing mesh
|
||||
onto a Quaternius character skeleton.
|
||||
|
||||
The weight transfer algorithm works correctly, but the Trellis-generated clothing
|
||||
meshes themselves are unsuitable for rigging: non-manifold geometry, inconsistent
|
||||
vertex density, and topology that does not deform well under skinning. The
|
||||
auto-rigging pipeline is technically sound but the INPUT meshes are the problem.
|
||||
|
||||
Conclusion: Trellis CANNOT generate riggable clothing. Use hand-authored or
|
||||
Quaternius-pack clothing instead.
|
||||
|
||||
This script is preserved as documentation of the approach and the robust weight
|
||||
transfer implementation (which may be reusable for other mesh sources).
|
||||
|
||||
Uses robust weight transfer with Laplacian inpainting for unmatched vertices,
|
||||
based on the SIGGRAPH Asia 2023 algorithm (MIT reference implementation).
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py \\
|
||||
-- body.gltf clothing.glb output.glb
|
||||
|
||||
Pipeline: Import -> Cleanup -> Scale/Position -> Weight Transfer -> Normalize -> Export
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import site
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Blender flatpak installs --user packages outside the default path
|
||||
sys.path.insert(0, site.getusersitepackages())
|
||||
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
import igl
|
||||
import scipy.sparse as sp
|
||||
import robust_laplacian
|
||||
|
||||
|
||||
# --- Parse args ---
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print("Usage: -- <body.gltf> <clothing.glb> <output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
body_path, clothing_path, output_path = argv[0], argv[1], argv[2]
|
||||
|
||||
print("=== Auto-rig clothing (robust weight transfer) ===")
|
||||
print(f" Body: {body_path}")
|
||||
print(f" Clothing: {clothing_path}")
|
||||
print(f" Output: {output_path}")
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
for col in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(col)
|
||||
|
||||
|
||||
def import_glb(path):
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for obj in objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def find_meshes(objects):
|
||||
return [obj for obj in objects if obj.type == 'MESH']
|
||||
|
||||
|
||||
def get_bounds(obj):
|
||||
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
mins = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
|
||||
maxs = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
|
||||
return mins, maxs
|
||||
|
||||
|
||||
def mesh_to_numpy(obj):
|
||||
"""Extract world-space vertices, faces, and normals as numpy arrays."""
|
||||
mesh = obj.data
|
||||
mesh.calc_loop_triangles()
|
||||
mw = obj.matrix_world
|
||||
|
||||
verts = np.array([mw @ v.co for v in mesh.vertices], dtype=np.float64)
|
||||
faces = np.array([[lt.vertices[i] for i in range(3)] for lt in mesh.loop_triangles], dtype=np.int64)
|
||||
normals = np.array([mw.to_3x3() @ v.normal for v in mesh.vertices], dtype=np.float64)
|
||||
|
||||
return verts, faces, normals
|
||||
|
||||
|
||||
def get_bone_weights(obj, bone_names):
|
||||
"""Extract per-vertex bone weights as a (n_verts x n_bones) matrix."""
|
||||
n_verts = len(obj.data.vertices)
|
||||
n_bones = len(bone_names)
|
||||
weights = np.zeros((n_verts, n_bones), dtype=np.float64)
|
||||
|
||||
# Map vertex group names to bone indices
|
||||
vg_to_bone = {}
|
||||
for vg in obj.vertex_groups:
|
||||
if vg.name in bone_names:
|
||||
vg_to_bone[vg.index] = bone_names.index(vg.name)
|
||||
|
||||
for v in obj.data.vertices:
|
||||
for g in v.groups:
|
||||
if g.group in vg_to_bone:
|
||||
weights[v.index, vg_to_bone[g.group]] = g.weight
|
||||
|
||||
return weights
|
||||
|
||||
|
||||
def set_bone_weights(obj, bone_names, weights):
|
||||
"""Set per-vertex bone weights from a (n_verts x n_bones) matrix."""
|
||||
# Create vertex groups
|
||||
for name in bone_names:
|
||||
if name not in obj.vertex_groups:
|
||||
obj.vertex_groups.new(name=name)
|
||||
|
||||
vg_map = {name: obj.vertex_groups[name] for name in bone_names if name in obj.vertex_groups}
|
||||
|
||||
for vi in range(len(obj.data.vertices)):
|
||||
for bi, name in enumerate(bone_names):
|
||||
w = weights[vi, bi]
|
||||
if w > 0.001:
|
||||
vg_map[name].add([vi], w, 'REPLACE')
|
||||
|
||||
|
||||
# --- Pipeline steps ---
|
||||
|
||||
def cleanup_trellis_mesh(obj):
|
||||
"""Fix common Trellis output issues."""
|
||||
print("\n Cleanup:")
|
||||
verts_before = len(obj.data.vertices)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=0.001)
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.mesh.select_loose()
|
||||
bpy.ops.mesh.delete(type='VERT')
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
print(f" {verts_before} → {len(obj.data.vertices)} verts")
|
||||
|
||||
|
||||
def scale_and_position(clothing_obj, body_obj, armature):
|
||||
"""Scale clothing to match body and center on spine."""
|
||||
print("\n Scale & position:")
|
||||
|
||||
body_mins, body_maxs = get_bounds(body_obj)
|
||||
cloth_mins, cloth_maxs = get_bounds(clothing_obj)
|
||||
body_size = body_maxs - body_mins
|
||||
cloth_size = cloth_maxs - cloth_mins
|
||||
|
||||
# Uniform scale based on body width
|
||||
torso_width = body_size.x * 0.85
|
||||
scale = torso_width / max(cloth_size.x, 0.001)
|
||||
clothing_obj.scale = (scale, scale, scale)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Center on spine_02 bone
|
||||
bone = armature.data.bones.get("spine_02")
|
||||
if bone:
|
||||
target = armature.matrix_world @ bone.head_local
|
||||
else:
|
||||
target = Vector(((body_mins.x + body_maxs.x) / 2, body_mins.y + body_size.y * 0.55, (body_mins.z + body_maxs.z) / 2))
|
||||
|
||||
cloth_mins2, cloth_maxs2 = get_bounds(clothing_obj)
|
||||
cloth_center = (cloth_mins2 + cloth_maxs2) / 2
|
||||
clothing_obj.location += target - cloth_center
|
||||
|
||||
# Apply transforms
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
clothing_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_obj
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
cloth_mins3, cloth_maxs3 = get_bounds(clothing_obj)
|
||||
print(f" Scale: {scale:.3f}, bounds: {cloth_mins3.y:.2f}–{cloth_maxs3.y:.2f}")
|
||||
|
||||
|
||||
def robust_weight_transfer(body_obj, clothing_obj, bone_names,
|
||||
dist_threshold=0.1, normal_threshold_deg=90.0):
|
||||
"""
|
||||
Transfer bone weights from body to clothing using closest-point matching
|
||||
with Laplacian inpainting for unmatched vertices.
|
||||
|
||||
Based on: "Robust Skin Weights Transfer via Weight Inpainting"
|
||||
(Abdrashitov et al., SIGGRAPH Asia 2023, MIT reference implementation)
|
||||
"""
|
||||
print("\n Robust weight transfer:")
|
||||
|
||||
# Extract mesh data
|
||||
src_verts, src_faces, src_normals = mesh_to_numpy(body_obj)
|
||||
tgt_verts, tgt_faces, tgt_normals = mesh_to_numpy(clothing_obj)
|
||||
|
||||
# Get source weights
|
||||
src_weights = get_bone_weights(body_obj, bone_names)
|
||||
|
||||
print(f" Source: {len(src_verts)} verts, {len(src_faces)} faces")
|
||||
print(f" Target: {len(tgt_verts)} verts, {len(tgt_faces)} faces")
|
||||
print(f" Bones: {len(bone_names)}")
|
||||
|
||||
# Step 1: Find closest point on source surface for each target vertex
|
||||
sqr_dist, face_idx, closest_pts = igl.point_mesh_squared_distance(
|
||||
tgt_verts, src_verts, src_faces
|
||||
)
|
||||
distances = np.sqrt(sqr_dist)
|
||||
|
||||
# Step 2: Compute barycentric coordinates for interpolation
|
||||
tgt_weights = np.zeros((len(tgt_verts), len(bone_names)), dtype=np.float64)
|
||||
matched = np.zeros(len(tgt_verts), dtype=bool)
|
||||
|
||||
normal_threshold = np.cos(np.radians(normal_threshold_deg))
|
||||
|
||||
for vi in range(len(tgt_verts)):
|
||||
if distances[vi] > dist_threshold:
|
||||
continue
|
||||
|
||||
fi = face_idx[vi]
|
||||
tri_verts = src_faces[fi]
|
||||
|
||||
# Check normal compatibility
|
||||
src_normal = np.mean(src_normals[tri_verts], axis=0)
|
||||
src_normal /= max(np.linalg.norm(src_normal), 1e-10)
|
||||
tgt_normal = tgt_normals[vi]
|
||||
tgt_normal /= max(np.linalg.norm(tgt_normal), 1e-10)
|
||||
dot = np.dot(src_normal, tgt_normal)
|
||||
|
||||
if dot < normal_threshold:
|
||||
continue
|
||||
|
||||
# Barycentric interpolation of weights
|
||||
p = closest_pts[vi]
|
||||
a, b, c = src_verts[tri_verts[0]], src_verts[tri_verts[1]], src_verts[tri_verts[2]]
|
||||
|
||||
# Compute barycentric coords
|
||||
v0, v1, v2 = b - a, c - a, p - a
|
||||
d00, d01, d11 = np.dot(v0, v0), np.dot(v0, v1), np.dot(v1, v1)
|
||||
d20, d21 = np.dot(v2, v0), np.dot(v2, v1)
|
||||
denom = d00 * d11 - d01 * d01
|
||||
if abs(denom) < 1e-10:
|
||||
continue
|
||||
bary_v = (d11 * d20 - d01 * d21) / denom
|
||||
bary_w = (d00 * d21 - d01 * d20) / denom
|
||||
bary_u = 1.0 - bary_v - bary_w
|
||||
|
||||
# Interpolate source weights
|
||||
tgt_weights[vi] = (bary_u * src_weights[tri_verts[0]] +
|
||||
bary_v * src_weights[tri_verts[1]] +
|
||||
bary_w * src_weights[tri_verts[2]])
|
||||
matched[vi] = True
|
||||
|
||||
n_matched = np.sum(matched)
|
||||
n_unmatched = len(tgt_verts) - n_matched
|
||||
print(f" Matched: {n_matched}/{len(tgt_verts)} ({100*n_matched/max(len(tgt_verts),1):.0f}%)")
|
||||
print(f" Unmatched: {n_unmatched} (will inpaint)")
|
||||
|
||||
# Step 3: Laplacian inpainting for unmatched vertices
|
||||
if n_unmatched > 0 and n_matched > 0:
|
||||
print(" Computing Laplacian inpainting...")
|
||||
L, M = robust_laplacian.mesh_laplacian(tgt_verts, tgt_faces)
|
||||
|
||||
# Solve per bone: minimize ||L @ w||^2 subject to matched vertices = known values
|
||||
unmatched_idx = np.where(~matched)[0]
|
||||
matched_idx = np.where(matched)[0]
|
||||
|
||||
for bi in range(len(bone_names)):
|
||||
known_weights = tgt_weights[matched_idx, bi]
|
||||
|
||||
# Build system: L[unmatched, unmatched] @ w_unknown = -L[unmatched, matched] @ w_known
|
||||
L_uu = L[np.ix_(unmatched_idx, unmatched_idx)]
|
||||
L_um = L[np.ix_(unmatched_idx, matched_idx)]
|
||||
|
||||
rhs = -L_um @ known_weights
|
||||
|
||||
if L_uu.shape[0] > 0:
|
||||
try:
|
||||
result = sp.linalg.spsolve(L_uu, rhs)
|
||||
tgt_weights[unmatched_idx, bi] = np.clip(result, 0.0, 1.0)
|
||||
except Exception:
|
||||
# Fallback: use nearest matched vertex weight
|
||||
for ui in unmatched_idx:
|
||||
dists_to_matched = np.linalg.norm(tgt_verts[matched_idx] - tgt_verts[ui], axis=1)
|
||||
nearest = matched_idx[np.argmin(dists_to_matched)]
|
||||
tgt_weights[ui, bi] = tgt_weights[nearest, bi]
|
||||
|
||||
print(" Inpainting complete")
|
||||
|
||||
# Step 4: Normalize weights per vertex
|
||||
row_sums = tgt_weights.sum(axis=1, keepdims=True)
|
||||
row_sums[row_sums < 1e-10] = 1.0 # avoid division by zero
|
||||
tgt_weights /= row_sums
|
||||
|
||||
# Step 5: Limit to 4 bones per vertex (game engine constraint)
|
||||
for vi in range(len(tgt_verts)):
|
||||
w = tgt_weights[vi]
|
||||
if np.count_nonzero(w > 0.001) > 4:
|
||||
top4 = np.argsort(w)[-4:]
|
||||
mask = np.zeros_like(w)
|
||||
mask[top4] = w[top4]
|
||||
mask /= max(mask.sum(), 1e-10)
|
||||
tgt_weights[vi] = mask
|
||||
|
||||
# Apply to clothing mesh
|
||||
set_bone_weights(clothing_obj, bone_names, tgt_weights)
|
||||
|
||||
active_bones = np.sum(tgt_weights.max(axis=0) > 0.01)
|
||||
print(f" Active bones: {active_bones}/{len(bone_names)}")
|
||||
|
||||
|
||||
def export_result(clothing_obj, armature, path):
|
||||
"""Export clothing + armature as GLB."""
|
||||
print(f"\n Exporting to {path}...")
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
obj.hide_set(True)
|
||||
clothing_obj.hide_set(False)
|
||||
armature.hide_set(False)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
clothing_obj.select_set(True)
|
||||
armature.select_set(True)
|
||||
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
print(f" Size: {os.path.getsize(path)} bytes")
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
clear_scene()
|
||||
|
||||
print("\nStep 1: Import body...")
|
||||
body_objects = import_glb(body_path)
|
||||
armature = find_armature(body_objects)
|
||||
body_meshes = find_meshes(body_objects)
|
||||
body_mesh = max(body_meshes, key=lambda m: len(m.data.vertices))
|
||||
bone_names = [b.name for b in armature.data.bones]
|
||||
print(f" Armature: {armature.name} ({len(bone_names)} bones)")
|
||||
print(f" Body mesh: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
|
||||
|
||||
print("\nStep 2: Import clothing...")
|
||||
clothing_objects = import_glb(clothing_path)
|
||||
clothing_meshes = find_meshes(clothing_objects)
|
||||
|
||||
if len(clothing_meshes) > 1:
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in clothing_meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = clothing_meshes[0]
|
||||
bpy.ops.object.join()
|
||||
|
||||
clothing_mesh = clothing_meshes[0]
|
||||
print(f" Clothing mesh: {clothing_mesh.name} ({len(clothing_mesh.data.vertices)} verts)")
|
||||
|
||||
print("\nStep 3: Cleanup...")
|
||||
cleanup_trellis_mesh(clothing_mesh)
|
||||
|
||||
print("\nStep 4: Scale & position...")
|
||||
scale_and_position(clothing_mesh, body_mesh, armature)
|
||||
|
||||
print("\nStep 5: Robust weight transfer...")
|
||||
# Parent to armature first
|
||||
clothing_mesh.parent = armature
|
||||
clothing_mesh.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = clothing_mesh.modifiers.new(name="Armature", type='ARMATURE')
|
||||
arm_mod.object = armature
|
||||
|
||||
robust_weight_transfer(body_mesh, clothing_mesh, bone_names,
|
||||
dist_threshold=0.15, normal_threshold_deg=120.0)
|
||||
|
||||
print("\nStep 6: Export...")
|
||||
export_result(clothing_mesh, armature, output_path)
|
||||
|
||||
print("\n=== Done ===")
|
||||
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
FAILED APPROACH: Create body type variants via pose-mode bone scaling.
|
||||
|
||||
Result: FAILED. Bone scaling produces broken meshes with vertex
|
||||
interpenetration, non-uniform distortion, and visual artifacts that
|
||||
are unacceptable even at gameplay zoom distance. The deformation is
|
||||
too crude for body type variation -- it stretches/compresses geometry
|
||||
rather than reshaping it.
|
||||
|
||||
This script is preserved as documentation of the failed approach.
|
||||
Do not use bone-scaling for body type generation in production.
|
||||
|
||||
Alternative: hand-authored body type meshes in Blender, or use
|
||||
Surface Deform (see fit_outfits_to_bodies.py) for clothing fitting.
|
||||
|
||||
Original intent:
|
||||
Create all 9 body type variants (4 adult x 2 genders + 1 child)
|
||||
from Quaternius base meshes by applying pose-mode bone scaling,
|
||||
baking to rest pose, and exporting.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/create_body_types.py \\
|
||||
-- <base_characters_dir/> <output_dir/>
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <base_characters_dir/> <output_dir/>")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT_DIR = argv[0]
|
||||
OUTPUT_DIR = argv[1]
|
||||
|
||||
if not os.path.isabs(INPUT_DIR):
|
||||
print(f"ERROR: input dir must be absolute: {INPUT_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isabs(OUTPUT_DIR):
|
||||
print(f"ERROR: output_dir must be absolute: {OUTPUT_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
MALE_GLTF = os.path.join(INPUT_DIR, "Superhero_Male_FullBody.gltf")
|
||||
FEMALE_GLTF = os.path.join(INPUT_DIR, "Superhero_Female_FullBody.gltf")
|
||||
|
||||
print("=== create_body_types.py (all 9 variants) ===")
|
||||
print(f" Input dir: {INPUT_DIR}")
|
||||
print(f" Output dir: {OUTPUT_DIR}")
|
||||
print(f" Male: {os.path.exists(MALE_GLTF)}")
|
||||
print(f" Female: {os.path.exists(FEMALE_GLTF)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body type definitions
|
||||
#
|
||||
# Each entry is a dict of bone_name -> (scale_x, scale_y, scale_z).
|
||||
# Scale values are in POSE BONE LOCAL space:
|
||||
# X = bone length axis (keep ~1.0 to preserve height/limb length)
|
||||
# Y, Z = cross-sectional axes (thickness/width of the deformed region)
|
||||
#
|
||||
# The Superhero Male baseline has very broad shoulders, thick limbs, deep chest.
|
||||
# We treat it as the Muscular archetype.
|
||||
#
|
||||
# Bones that drive silhouette at isometric zoom:
|
||||
# Shoulder width: clavicle_l/r (Y/Z scale pushes shoulders out)
|
||||
# Torso width: spine_01..03 (Y/Z broadens the trunk cross-section)
|
||||
# Hip width: pelvis (Y/Z widens the hip region)
|
||||
# Limb thickness: upperarm, lowerarm, thigh, calf (Y/Z = girth)
|
||||
# Neck: neck_01 (Y/Z for slim/heavy reads)
|
||||
#
|
||||
# NOTE: these bone names follow the Quaternius rig convention (snake_case).
|
||||
# If the rig ships with different names, the script prints a bone list and
|
||||
# falls back gracefully (scales that bone is skipped, model still exports).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Helper: symmetric bones (left/right pairs) share the same scale vector.
|
||||
# We expand them into explicit _l / _r entries at definition time.
|
||||
|
||||
def _sym(name, sx, sy, sz):
|
||||
"""Return entries for both _l and _r variants of a bone."""
|
||||
return {
|
||||
f"{name}_l": (sx, sy, sz),
|
||||
f"{name}_r": (sx, sy, sz),
|
||||
}
|
||||
|
||||
|
||||
def _body(spine=(1.0, 1.0, 1.0),
|
||||
pelvis=(1.0, 1.0, 1.0),
|
||||
clavicle=(1.0, 1.0, 1.0),
|
||||
upperarm=(1.0, 1.0, 1.0),
|
||||
lowerarm=(1.0, 1.0, 1.0),
|
||||
hand=(1.0, 1.0, 1.0),
|
||||
neck=(1.0, 1.0, 1.0),
|
||||
thigh=(1.0, 1.0, 1.0),
|
||||
calf=(1.0, 1.0, 1.0),
|
||||
foot=(1.0, 1.0, 1.0)):
|
||||
"""Build a full bone scale dict from region parameters."""
|
||||
d = {}
|
||||
d["spine_01"] = spine
|
||||
d["spine_02"] = spine
|
||||
d["spine_03"] = spine
|
||||
d["pelvis"] = pelvis
|
||||
d.update(_sym("clavicle", *clavicle))
|
||||
d.update(_sym("upperarm", *upperarm))
|
||||
d.update(_sym("lowerarm", *lowerarm))
|
||||
d.update(_sym("hand", *hand))
|
||||
d["neck_01"] = neck
|
||||
d.update(_sym("thigh", *thigh))
|
||||
d.update(_sym("calf", *calf))
|
||||
d.update(_sym("foot", *foot))
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The four body types as scale deltas from Blender rest pose.
|
||||
# All values are pose.bone.scale — applied multiplicatively over the rig's
|
||||
# existing rest pose (which IS the Superhero Male).
|
||||
#
|
||||
# Design rationale per type:
|
||||
#
|
||||
# MUSCULAR: near-identity on the Superhero source — very minor shoulder
|
||||
# pull-in to read as "athletic" rather than "comic book". The source
|
||||
# already communicates muscular; we just clean up the extreme silhouette.
|
||||
#
|
||||
# AVERAGE: bring shoulders and torso to closer to Regular proportions.
|
||||
# Slim limbs moderately. Still reads as fit/active at gameplay zoom —
|
||||
# the Settled Reach is a mobile industrial society, nobody is sedentary.
|
||||
#
|
||||
# THIN: ectomorph. Narrow everything. The goal is clear silhouette
|
||||
# differentiation at 32px effective height. Don't go so thin it reads
|
||||
# as "diseased" — this is a body TYPE, not a health state.
|
||||
#
|
||||
# HEAVY: Widen the torso, thicken the midsection, thicken the limbs.
|
||||
# Shoulders stay broad (not narrow — Heavy people can be broad).
|
||||
# The distinctive read is the wide trunk and thick upper arms.
|
||||
#
|
||||
# Scale axis notes:
|
||||
# For spine/pelvis/clavicle: Y is the lateral (side-to-side) axis in bone
|
||||
# local space. Z is forward-back depth. We primarily drive Y for width.
|
||||
# For limbs: Y and Z are both thickness axes. Scale them together for
|
||||
# uniform girth change.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BODY_TYPES = {
|
||||
"muscular": _body(
|
||||
# Superhero Male IS the muscular baseline.
|
||||
# Minor cleanup: pull the extreme shoulder width in just slightly
|
||||
# so it reads as "athletic human" not "action figure".
|
||||
spine = (1.0, 1.00, 1.00),
|
||||
pelvis = (1.0, 0.97, 0.97),
|
||||
clavicle = (1.0, 0.96, 0.96),
|
||||
upperarm = (1.0, 1.00, 1.00),
|
||||
lowerarm = (1.0, 1.00, 1.00),
|
||||
hand = (1.0, 1.00, 1.00),
|
||||
neck = (1.0, 1.00, 1.00),
|
||||
thigh = (1.0, 1.00, 1.00),
|
||||
calf = (1.0, 1.00, 1.00),
|
||||
foot = (1.0, 1.00, 1.00),
|
||||
),
|
||||
|
||||
"average": _body(
|
||||
# Narrower shoulders, slimmer trunk, less thick limbs.
|
||||
# Target: a working professional — fit but not a gym-goer.
|
||||
spine = (1.0, 0.88, 0.88),
|
||||
pelvis = (1.0, 0.88, 0.88),
|
||||
clavicle = (1.0, 0.84, 0.84),
|
||||
upperarm = (1.0, 0.82, 0.82),
|
||||
lowerarm = (1.0, 0.84, 0.84),
|
||||
hand = (1.0, 0.90, 0.90),
|
||||
neck = (1.0, 0.90, 0.90),
|
||||
thigh = (1.0, 0.84, 0.84),
|
||||
calf = (1.0, 0.86, 0.86),
|
||||
foot = (1.0, 0.94, 0.94),
|
||||
),
|
||||
|
||||
"thin": _body(
|
||||
# Ectomorph: clearly narrow silhouette.
|
||||
# Key read at isometric zoom: narrow shoulders, thinner limbs.
|
||||
# Don't collapse below ~0.70 — mesh may interpenetrate.
|
||||
spine = (1.0, 0.76, 0.76),
|
||||
pelvis = (1.0, 0.76, 0.76),
|
||||
clavicle = (1.0, 0.70, 0.70),
|
||||
upperarm = (1.0, 0.68, 0.68),
|
||||
lowerarm = (1.0, 0.70, 0.70),
|
||||
hand = (1.0, 0.80, 0.80),
|
||||
neck = (1.0, 0.80, 0.80),
|
||||
thigh = (1.0, 0.70, 0.70),
|
||||
calf = (1.0, 0.72, 0.72),
|
||||
foot = (1.0, 0.88, 0.88),
|
||||
),
|
||||
|
||||
"heavy": _body(
|
||||
# Wide trunk and midsection. Thick limbs.
|
||||
# Shoulders stay reasonably broad — Heavy doesn't mean narrow-shouldered.
|
||||
# Midsection (spine_01 = lower torso/belly region) gets extra girth.
|
||||
# Do not scale X (length) — we keep the same height.
|
||||
spine = (1.0, 1.18, 1.22), # spine_01/02/03 — widened trunk, deeper chest
|
||||
pelvis = (1.0, 1.14, 1.12),
|
||||
clavicle = (1.0, 1.06, 1.06), # shoulders still broad but not exaggerated
|
||||
upperarm = (1.0, 1.20, 1.20), # thick upper arms
|
||||
lowerarm = (1.0, 1.12, 1.12),
|
||||
hand = (1.0, 1.08, 1.08),
|
||||
neck = (1.0, 1.10, 1.10),
|
||||
thigh = (1.0, 1.22, 1.22), # thick thighs — key heavy silhouette read
|
||||
calf = (1.0, 1.14, 1.14),
|
||||
foot = (1.0, 1.04, 1.04),
|
||||
),
|
||||
}
|
||||
|
||||
# Heavy type needs the belly/midsection emphasis — spine_01 is the lower torso.
|
||||
# Override just spine_01 to be MORE pronounced than spine_02/03.
|
||||
BODY_TYPES["heavy"]["spine_01"] = (1.0, 1.26, 1.30)
|
||||
|
||||
# Female variants — same 4 types, adjusted for female Superhero base proportions.
|
||||
# The female Superhero has different baseline proportions (wider hips, narrower shoulders)
|
||||
# so the scale factors differ to hit the same target silhouettes.
|
||||
FEMALE_BODY_TYPES = {
|
||||
"muscular": _body(
|
||||
spine = (1.0, 0.98, 0.98),
|
||||
pelvis = (1.0, 0.95, 0.95),
|
||||
clavicle = (1.0, 0.98, 0.98),
|
||||
upperarm = (1.0, 0.98, 0.98),
|
||||
lowerarm = (1.0, 0.98, 0.98),
|
||||
hand = (1.0, 0.98, 0.98),
|
||||
neck = (1.0, 0.98, 0.98),
|
||||
thigh = (1.0, 0.98, 0.98),
|
||||
calf = (1.0, 0.98, 0.98),
|
||||
foot = (1.0, 0.98, 0.98),
|
||||
),
|
||||
"average": _body(
|
||||
spine = (1.0, 0.86, 0.86),
|
||||
pelvis = (1.0, 0.90, 0.88), # wider hips relative to torso
|
||||
clavicle = (1.0, 0.82, 0.82),
|
||||
upperarm = (1.0, 0.80, 0.80),
|
||||
lowerarm = (1.0, 0.82, 0.82),
|
||||
hand = (1.0, 0.88, 0.88),
|
||||
neck = (1.0, 0.88, 0.88),
|
||||
thigh = (1.0, 0.86, 0.86),
|
||||
calf = (1.0, 0.84, 0.84),
|
||||
foot = (1.0, 0.92, 0.92),
|
||||
),
|
||||
"thin": _body(
|
||||
spine = (1.0, 0.74, 0.74),
|
||||
pelvis = (1.0, 0.78, 0.76),
|
||||
clavicle = (1.0, 0.68, 0.68),
|
||||
upperarm = (1.0, 0.66, 0.66),
|
||||
lowerarm = (1.0, 0.68, 0.68),
|
||||
hand = (1.0, 0.78, 0.78),
|
||||
neck = (1.0, 0.78, 0.78),
|
||||
thigh = (1.0, 0.72, 0.72),
|
||||
calf = (1.0, 0.70, 0.70),
|
||||
foot = (1.0, 0.86, 0.86),
|
||||
),
|
||||
"heavy": _body(
|
||||
spine = (1.0, 1.16, 1.20),
|
||||
pelvis = (1.0, 1.18, 1.14), # wider hips
|
||||
clavicle = (1.0, 1.04, 1.04),
|
||||
upperarm = (1.0, 1.18, 1.18),
|
||||
lowerarm = (1.0, 1.10, 1.10),
|
||||
hand = (1.0, 1.06, 1.06),
|
||||
neck = (1.0, 1.08, 1.08),
|
||||
thigh = (1.0, 1.24, 1.24),
|
||||
calf = (1.0, 1.12, 1.12),
|
||||
foot = (1.0, 1.04, 1.04),
|
||||
),
|
||||
}
|
||||
FEMALE_BODY_TYPES["heavy"]["spine_01"] = (1.0, 1.24, 1.28)
|
||||
|
||||
# Child variant — uniform scale down + enlarged head for child proportions.
|
||||
# Applied as bone scale: shorten all limbs, enlarge head.
|
||||
CHILD_SCALES = _body(
|
||||
spine = (0.85, 0.82, 0.82), # shorter + narrower torso
|
||||
pelvis = (0.85, 0.82, 0.82),
|
||||
clavicle = (0.85, 0.78, 0.78), # narrower shoulders
|
||||
upperarm = (0.80, 0.78, 0.78), # shorter + thinner limbs
|
||||
lowerarm = (0.80, 0.78, 0.78),
|
||||
hand = (0.85, 0.82, 0.82),
|
||||
neck = (0.80, 0.85, 0.85), # shorter neck
|
||||
thigh = (0.80, 0.80, 0.80),
|
||||
calf = (0.80, 0.78, 0.78),
|
||||
foot = (0.85, 0.88, 0.88),
|
||||
)
|
||||
# Enlarge head for child proportions (larger head-to-body ratio)
|
||||
CHILD_SCALES["Head"] = (1.20, 1.20, 1.20)
|
||||
CHILD_SCALES["neck_01"] = (0.80, 0.90, 0.90)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blender helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
for c in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(c)
|
||||
|
||||
|
||||
def import_gltf(path):
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for obj in objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def find_meshes(objects):
|
||||
return [obj for obj in objects if obj.type == 'MESH']
|
||||
|
||||
|
||||
def print_bone_list(armature):
|
||||
print("\n Bone list (for reference):")
|
||||
for b in sorted(armature.data.bones, key=lambda x: x.name):
|
||||
print(f" {b.name}")
|
||||
|
||||
|
||||
def apply_bone_scales(armature, scale_dict):
|
||||
"""
|
||||
Apply scale values to pose bones.
|
||||
|
||||
Works in POSE mode — we set pose.bones[name].scale, then bake to
|
||||
rest pose via bpy.ops.pose.armature_apply(). The mesh gets its shape
|
||||
from the pose-to-rest bake.
|
||||
|
||||
Unknown bone names are silently skipped (rig naming may vary between
|
||||
Quaternius pack versions).
|
||||
"""
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
bpy.ops.pose.select_all(action='DESELECT')
|
||||
|
||||
applied = []
|
||||
skipped = []
|
||||
|
||||
for bone_name, (sx, sy, sz) in scale_dict.items():
|
||||
pb = armature.pose.bones.get(bone_name)
|
||||
if pb is None:
|
||||
skipped.append(bone_name)
|
||||
continue
|
||||
pb.scale = (sx, sy, sz)
|
||||
applied.append(bone_name)
|
||||
|
||||
if skipped:
|
||||
print(f" Skipped (bone not found): {skipped}")
|
||||
print(f" Applied scales to {len(applied)} pose bones")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def bake_pose_to_rest(armature, meshes):
|
||||
"""
|
||||
Apply the current pose as the new rest pose.
|
||||
|
||||
Steps:
|
||||
1. Select the armature and all skinned meshes.
|
||||
2. bpy.ops.pose.armature_apply() bakes pose -> rest on the armature.
|
||||
3. For each mesh, apply the Armature modifier so vertex positions are
|
||||
updated to match the new rest pose. This means the exported GLB
|
||||
is a standalone mesh (no live modifier), correct for a base body GLB.
|
||||
|
||||
After this operation, the pose is identity again on the new rest pose.
|
||||
"""
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.ops.pose.armature_apply(selected=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Apply armature modifier on each skinned mesh so the mesh vertices
|
||||
# are in the correct position in object space.
|
||||
for mesh_obj in meshes:
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
mesh_obj.select_set(True)
|
||||
for mod in mesh_obj.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
# Applying the modifier bakes deformed vertex positions.
|
||||
# We keep the armature modifier itself so the GLB exports
|
||||
# with skinning data intact — we apply a COPY of the modifier
|
||||
# result to mesh vertex positions via a shape key trick instead.
|
||||
#
|
||||
# Actually: we DO want skinning data in the output GLB so the
|
||||
# animation library can retarget. So we DON'T apply the modifier.
|
||||
# The mesh vertices are already correct because armature_apply()
|
||||
# updated the rest pose — the modifier in rest pose = identity.
|
||||
pass
|
||||
mesh_obj.select_set(False)
|
||||
|
||||
|
||||
def reset_pose_to_identity(armature):
|
||||
"""Reset all pose bone transforms to identity (for next variant)."""
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def export_variant(armature, meshes, output_path):
|
||||
"""Export armature + all skinned meshes as GLB."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False, # don't apply modifiers — keep skinning
|
||||
export_animations=False, # body mesh only, no animations
|
||||
export_skins=True, # keep skeleton + weights for animation retarget
|
||||
export_extras=False,
|
||||
)
|
||||
size_kb = os.path.getsize(output_path) // 1024
|
||||
print(f" Exported: {output_path} ({size_kb} KB)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duplicate the imported scene objects into isolated copies.
|
||||
#
|
||||
# We want to produce 4 independent exports from ONE import. The approach:
|
||||
# 1. Import once.
|
||||
# 2. For each variant: duplicate all objects, apply scales to the duplicate
|
||||
# armature, bake, export, then delete the duplicates.
|
||||
# This avoids re-importing (slow) and accumulation of transform errors.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def duplicate_objects(objects):
|
||||
"""Duplicate a list of objects and return the duplicates."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
bpy.ops.object.duplicate(linked=False)
|
||||
# The duplicated objects are now the selection
|
||||
dupes = list(bpy.context.selected_objects)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
return dupes
|
||||
|
||||
|
||||
def delete_objects(objects):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_variants(source_gltf, prefix, type_dict):
|
||||
"""Generate body type variants from a source gltf."""
|
||||
print(f"\n--- Importing {source_gltf} ---")
|
||||
clear_scene()
|
||||
|
||||
source_objects = import_gltf(source_gltf)
|
||||
source_armature = find_armature(source_objects)
|
||||
source_meshes = find_meshes(source_objects)
|
||||
|
||||
if source_armature is None:
|
||||
print("ERROR: No armature found. Skipping.")
|
||||
return []
|
||||
|
||||
print(f" Armature: {source_armature.name} ({len(source_armature.data.bones)} bones)")
|
||||
for m in source_meshes:
|
||||
print(f" Mesh: {m.name} ({len(m.data.vertices)} verts)")
|
||||
|
||||
if prefix == "male":
|
||||
print_bone_list(source_armature)
|
||||
|
||||
for obj in source_objects:
|
||||
obj.hide_set(True)
|
||||
obj.hide_render = True
|
||||
|
||||
results = []
|
||||
for variant_name, scale_dict in type_dict.items():
|
||||
print(f"\n --- {prefix}_{variant_name} ---")
|
||||
|
||||
for obj in source_objects:
|
||||
obj.hide_set(False)
|
||||
variant_objects = duplicate_objects(source_objects)
|
||||
for obj in source_objects:
|
||||
obj.hide_set(True)
|
||||
|
||||
variant_armature = find_armature(variant_objects)
|
||||
variant_meshes = find_meshes(variant_objects)
|
||||
|
||||
if variant_armature is None:
|
||||
print(f" ERROR: No armature in duplicate. Skipping.")
|
||||
delete_objects(variant_objects)
|
||||
continue
|
||||
|
||||
print(f" Applying bone scales...")
|
||||
apply_bone_scales(variant_armature, scale_dict)
|
||||
|
||||
print(f" Baking pose to rest pose...")
|
||||
bake_pose_to_rest(variant_armature, variant_meshes)
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, f"{prefix}_{variant_name}.glb")
|
||||
print(f" Exporting...")
|
||||
export_variant(variant_armature, variant_meshes, output_path)
|
||||
results.append(output_path)
|
||||
|
||||
delete_objects(variant_objects)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
all_outputs = []
|
||||
|
||||
# Male variants (4)
|
||||
if os.path.exists(MALE_GLTF):
|
||||
all_outputs += generate_variants(MALE_GLTF, "male", BODY_TYPES)
|
||||
else:
|
||||
print(f"WARNING: Male source not found: {MALE_GLTF}")
|
||||
|
||||
# Female variants (4)
|
||||
if os.path.exists(FEMALE_GLTF):
|
||||
all_outputs += generate_variants(FEMALE_GLTF, "female", FEMALE_BODY_TYPES)
|
||||
else:
|
||||
print(f"WARNING: Female source not found: {FEMALE_GLTF}")
|
||||
|
||||
# Child (1) — based on female average as starting point
|
||||
if os.path.exists(FEMALE_GLTF):
|
||||
print("\n--- Child variant ---")
|
||||
all_outputs += generate_variants(FEMALE_GLTF, "child", {"child": CHILD_SCALES})
|
||||
# Rename child_child.glb to child.glb
|
||||
child_src = os.path.join(OUTPUT_DIR, "child_child.glb")
|
||||
child_dst = os.path.join(OUTPUT_DIR, "child.glb")
|
||||
if os.path.exists(child_src):
|
||||
os.rename(child_src, child_dst)
|
||||
print(f" Renamed to child.glb")
|
||||
|
||||
print("\n=== Done ===")
|
||||
print(f"Output files in: {OUTPUT_DIR}")
|
||||
expected = [f"male_{t}.glb" for t in BODY_TYPES] + \
|
||||
[f"female_{t}.glb" for t in FEMALE_BODY_TYPES] + \
|
||||
["child.glb"]
|
||||
for name in expected:
|
||||
path = os.path.join(OUTPUT_DIR, name)
|
||||
status = "[OK]" if os.path.exists(path) else "[MISSING]"
|
||||
print(f" {status} {name}")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
OBSOLETE: Placeholder body type GLBs (workaround for failed bone-scaling approach).
|
||||
|
||||
This script was a stopgap to produce placeholder body type files with blue
|
||||
material tinting after create_body_types.py produced broken meshes. No longer
|
||||
needed -- the body type pipeline will use hand-authored meshes instead.
|
||||
|
||||
Preserved as documentation only.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py \\
|
||||
-- <base_characters_dir/> <output_dir/>
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <base_characters_dir/> <output_dir/>")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT_DIR = argv[0]
|
||||
OUTPUT_DIR = argv[1]
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
MALE_GLTF = os.path.join(INPUT_DIR, "Superhero_Male_FullBody.gltf")
|
||||
FEMALE_GLTF = os.path.join(INPUT_DIR, "Superhero_Female_FullBody.gltf")
|
||||
|
||||
PLACEHOLDER_COLOR = (0.2, 0.35, 0.65, 1.0) # muted blue
|
||||
|
||||
# muscular keeps original textures (it IS the Superhero base)
|
||||
# all others get blue placeholder material
|
||||
VARIANTS = [
|
||||
("male_thin", MALE_GLTF, True),
|
||||
("male_average", MALE_GLTF, True),
|
||||
("male_muscular", MALE_GLTF, False), # keep original
|
||||
("male_heavy", MALE_GLTF, True),
|
||||
("female_thin", FEMALE_GLTF, True),
|
||||
("female_average", FEMALE_GLTF, True),
|
||||
("female_muscular", FEMALE_GLTF, False), # keep original
|
||||
("female_heavy", FEMALE_GLTF, True),
|
||||
("child", FEMALE_GLTF, True),
|
||||
]
|
||||
|
||||
print("=== Create placeholder body types ===")
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
for c in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(c)
|
||||
|
||||
|
||||
def apply_placeholder_material(obj):
|
||||
"""Replace all materials with solid blue placeholder."""
|
||||
if obj.type != 'MESH':
|
||||
return
|
||||
# Create placeholder material
|
||||
mat = bpy.data.materials.new(name="placeholder_blue")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
if bsdf:
|
||||
bsdf.inputs["Base Color"].default_value = PLACEHOLDER_COLOR
|
||||
bsdf.inputs["Roughness"].default_value = 1.0
|
||||
bsdf.inputs["Metallic"].default_value = 0.0
|
||||
# Clear existing materials and assign placeholder
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(mat)
|
||||
|
||||
|
||||
for variant_name, source_gltf, is_placeholder in VARIANTS:
|
||||
print(f"\n {variant_name}...")
|
||||
clear_scene()
|
||||
|
||||
if not os.path.exists(source_gltf):
|
||||
print(f" SKIP: source not found: {source_gltf}")
|
||||
continue
|
||||
|
||||
bpy.ops.import_scene.gltf(filepath=source_gltf)
|
||||
objects = list(bpy.data.objects)
|
||||
|
||||
# Apply placeholder material to placeholder variants only
|
||||
if is_placeholder:
|
||||
for obj in objects:
|
||||
if obj.type == 'MESH':
|
||||
apply_placeholder_material(obj)
|
||||
|
||||
# Select all for export
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
if objects:
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, f"{variant_name}.glb")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
size_kb = os.path.getsize(output_path) // 1024
|
||||
print(f" Exported: {variant_name}.glb ({size_kb} KB)")
|
||||
|
||||
print("\n=== Done ===")
|
||||
for name, _, _ in VARIANTS:
|
||||
path = os.path.join(OUTPUT_DIR, f"{name}.glb")
|
||||
status = "[OK]" if os.path.exists(path) else "[MISSING]"
|
||||
print(f" {status} {name}.glb")
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
PARTIALLY VALIDATED: Fit Fantasy outfits to each of the 4 body types.
|
||||
|
||||
The outfits (Male_Peasant, Male_Ranger) were authored for the Quaternius Regular
|
||||
body proportions. The 4 body type GLBs have already had their rest poses baked
|
||||
to the variant shape (muscular/average/thin/heavy) by create_body_types.py.
|
||||
|
||||
Strategy: Data Transfer weight retarget + rest pose rebind
|
||||
---------------------------------------------------------------------------
|
||||
Each outfit GLTF contains:
|
||||
- Its own armature (original Regular rest pose)
|
||||
- Clothing meshes skinned to that armature (weights are already good)
|
||||
|
||||
Each body type GLB contains:
|
||||
- An armature whose rest pose IS the body variant shape
|
||||
- A body mesh deformed to that shape
|
||||
|
||||
To refit clothing to a body type:
|
||||
1. Import the body type GLB (target armature + target body mesh)
|
||||
2. Import the outfit GLTF (source armature + clothing meshes)
|
||||
3. For each clothing mesh:
|
||||
a. Apply a Surface Deform modifier binding it to the body mesh
|
||||
This captures the correspondence: clothing surface -> body surface
|
||||
b. Apply the modifier to bake deformed positions as the new rest shape
|
||||
c. Replace the armature modifier's target with the body type armature
|
||||
(bone names are identical across all variants)
|
||||
d. Re-transfer bone weights from the body type mesh via Data Transfer
|
||||
to correct any weight gaps caused by the shape difference
|
||||
4. Export: body type armature + all refitted clothing meshes as GLB
|
||||
|
||||
Why Surface Deform + weight retarget rather than shape-key tricks:
|
||||
- Surface Deform is Blender's canonical "stick this mesh to that surface"
|
||||
operator. It handles offset geometry (clothing sits proud of the body)
|
||||
correctly by tracking the delta from the surface.
|
||||
- After Apply, each clothing vertex is in its correct world position for
|
||||
this body type's rest pose.
|
||||
- We then re-run Data Transfer to pick up the body type's weight painting,
|
||||
which was authored for the body proportions (weight distribution differs
|
||||
between muscular vs thin, etc.).
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py \\
|
||||
-- <body_types_dir> <outfits_dir> <output_dir>
|
||||
|
||||
Where:
|
||||
body_types_dir absolute path to body-types/ (contains body_*.glb)
|
||||
outfits_dir absolute path to outfits-fantasy/full/ (contains *.gltf)
|
||||
output_dir absolute path to outfits-fitted/ (will be created)
|
||||
|
||||
Output:
|
||||
<output_dir>/muscular/Male_Peasant.glb
|
||||
<output_dir>/muscular/Male_Ranger.glb
|
||||
<output_dir>/average/Male_Peasant.glb
|
||||
... etc.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
if len(argv) < 3:
|
||||
print("Usage: -- <body_types_dir> <outfits_dir> <output_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
BODY_TYPES_DIR = argv[0]
|
||||
OUTFITS_DIR = argv[1]
|
||||
OUTPUT_DIR = argv[2]
|
||||
|
||||
for label, path in [("body_types_dir", BODY_TYPES_DIR),
|
||||
("outfits_dir", OUTFITS_DIR),
|
||||
("output_dir", OUTPUT_DIR)]:
|
||||
if not os.path.isabs(path):
|
||||
print(f"ERROR: {label} must be an absolute path: {path}")
|
||||
sys.exit(1)
|
||||
|
||||
BODY_TYPES = ["muscular", "average", "thin", "heavy"]
|
||||
|
||||
OUTFITS = ["Male_Peasant", "Male_Ranger"]
|
||||
|
||||
print("=== fit_outfits_to_bodies.py ===")
|
||||
print(f" Body types dir: {BODY_TYPES_DIR}")
|
||||
print(f" Outfits dir: {OUTFITS_DIR}")
|
||||
print(f" Output dir: {OUTPUT_DIR}")
|
||||
print(f" Body types: {BODY_TYPES}")
|
||||
print(f" Outfits: {OUTFITS}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for col in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(col)
|
||||
# Purge orphan data blocks to keep memory clean between iterations
|
||||
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
||||
|
||||
|
||||
def import_gltf(path):
|
||||
"""Import a GLTF/GLB file. Returns list of newly imported objects."""
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
new_objects = list(set(bpy.data.objects) - before)
|
||||
return new_objects
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for obj in objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def find_meshes(objects):
|
||||
return [obj for obj in objects if obj.type == 'MESH']
|
||||
|
||||
|
||||
def find_largest_mesh(objects):
|
||||
"""Return the mesh with the most vertices (the body surface, not debris)."""
|
||||
meshes = find_meshes(objects)
|
||||
if not meshes:
|
||||
return None
|
||||
return max(meshes, key=lambda m: len(m.data.vertices))
|
||||
|
||||
|
||||
def deselect_all():
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
|
||||
def set_active(obj):
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core fitting pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def unhide_object(obj):
|
||||
obj.hide_set(False)
|
||||
obj.hide_viewport = False
|
||||
obj.hide_render = False
|
||||
|
||||
|
||||
def ensure_visible(objects):
|
||||
for obj in objects:
|
||||
unhide_object(obj)
|
||||
|
||||
|
||||
def transfer_weights_data_transfer(source_body, clothing_mesh, body_armature):
|
||||
"""
|
||||
Transfer bone weights from source_body to clothing_mesh using
|
||||
Blender's built-in Data Transfer modifier.
|
||||
|
||||
This corrects weight distribution after vertex positions have been
|
||||
updated to match the target body shape.
|
||||
"""
|
||||
ensure_visible([source_body, clothing_mesh])
|
||||
|
||||
deselect_all()
|
||||
set_active(clothing_mesh)
|
||||
clothing_mesh.select_set(True)
|
||||
|
||||
dt = clothing_mesh.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
|
||||
dt.object = source_body
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.layers_vgroup_select_dst = 'NAME'
|
||||
|
||||
# Generate matching vertex groups on the clothing mesh
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
|
||||
# Apply to bake the weights
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
|
||||
|
||||
def bind_surface_deform(clothing_mesh, body_mesh):
|
||||
"""
|
||||
Add a Surface Deform modifier to clothing_mesh, bind it to body_mesh,
|
||||
then apply the modifier to bake the deformed vertex positions.
|
||||
|
||||
The clothing sits at an offset from the body surface. Surface Deform
|
||||
tracks that offset correctly, so after apply the clothing vertices sit
|
||||
in the same relative position to the body_mesh vertices — but now in
|
||||
the target body's rest pose space.
|
||||
|
||||
Returns True if binding succeeded, False otherwise.
|
||||
"""
|
||||
ensure_visible([clothing_mesh, body_mesh])
|
||||
|
||||
deselect_all()
|
||||
set_active(clothing_mesh)
|
||||
clothing_mesh.select_set(True)
|
||||
|
||||
sd = clothing_mesh.modifiers.new(name="SurfaceDeform", type='SURFACE_DEFORM')
|
||||
sd.target = body_mesh
|
||||
# Falloff controls how far from the surface vertices are still captured.
|
||||
# 4.0 is generous — some clothing pieces (hoods, boots) sit further from
|
||||
# the body than defaults would handle.
|
||||
sd.falloff = 4.0
|
||||
|
||||
# Bind: this is the critical step that computes surface correspondence
|
||||
bpy.ops.object.surfacedeform_bind(modifier=sd.name)
|
||||
|
||||
if not sd.is_bound:
|
||||
print(f" WARNING: Surface Deform bind failed for {clothing_mesh.name}.")
|
||||
print(f" Removing modifier and proceeding without shape refit.")
|
||||
clothing_mesh.modifiers.remove(sd)
|
||||
return False
|
||||
|
||||
# Apply to bake vertex positions to the target body's rest pose
|
||||
bpy.ops.object.modifier_apply(modifier=sd.name)
|
||||
return True
|
||||
|
||||
|
||||
def retarget_armature_modifier(clothing_mesh, old_armature, new_armature):
|
||||
"""
|
||||
Replace the armature modifier on a clothing mesh so it references the
|
||||
target body's armature instead of the original outfit armature.
|
||||
|
||||
The bone names are identical across all body type variants, so weights
|
||||
transfer by name automatically.
|
||||
"""
|
||||
for mod in clothing_mesh.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = new_armature
|
||||
|
||||
# Update the parent relationship
|
||||
clothing_mesh.parent = new_armature
|
||||
clothing_mesh.matrix_parent_inverse = new_armature.matrix_world.inverted()
|
||||
|
||||
|
||||
def normalize_weights(clothing_mesh):
|
||||
"""
|
||||
Normalize vertex group weights so they sum to 1.0 per vertex.
|
||||
Required after any weight modification to ensure correct deformation.
|
||||
"""
|
||||
ensure_visible([clothing_mesh])
|
||||
deselect_all()
|
||||
set_active(clothing_mesh)
|
||||
clothing_mesh.select_set(True)
|
||||
|
||||
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def export_fitted_outfit(body_armature, clothing_meshes, output_path):
|
||||
"""
|
||||
Export the refitted outfit as a GLB containing:
|
||||
- The body type armature (skeleton)
|
||||
- All clothing meshes (skinned to that armature)
|
||||
|
||||
The body mesh itself is NOT exported — only the skeleton and clothes.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
ensure_visible([body_armature] + clothing_meshes)
|
||||
deselect_all()
|
||||
|
||||
body_armature.select_set(True)
|
||||
for mesh in clothing_meshes:
|
||||
mesh.select_set(True)
|
||||
|
||||
set_active(body_armature)
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False, # keep modifier stack (Armature modifier for deform)
|
||||
export_animations=False,
|
||||
export_skins=True, # export skinning data (weights + skeleton)
|
||||
export_extras=False,
|
||||
)
|
||||
|
||||
size_kb = os.path.getsize(output_path) // 1024
|
||||
print(f" Exported: {output_path} ({size_kb} KB)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
total_jobs = len(BODY_TYPES) * len(OUTFITS)
|
||||
completed = 0
|
||||
failed = []
|
||||
|
||||
for body_type in BODY_TYPES:
|
||||
body_glb = os.path.join(BODY_TYPES_DIR, f"body_{body_type}.glb")
|
||||
if not os.path.exists(body_glb):
|
||||
print(f"\nERROR: Body GLB not found: {body_glb}")
|
||||
print(" Run create_body_types.py first.")
|
||||
sys.exit(1)
|
||||
|
||||
for outfit_name in OUTFITS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {body_type.upper()} / {outfit_name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
outfit_gltf = os.path.join(OUTFITS_DIR, f"{outfit_name}.gltf")
|
||||
output_path = os.path.join(OUTPUT_DIR, body_type, f"{outfit_name}.glb")
|
||||
|
||||
if not os.path.exists(outfit_gltf):
|
||||
print(f" ERROR: Outfit GLTF not found: {outfit_gltf}")
|
||||
failed.append(f"{body_type}/{outfit_name} (missing source)")
|
||||
continue
|
||||
|
||||
clear_scene()
|
||||
|
||||
# --- Step 1: Import body type GLB ---
|
||||
print(f"\n Step 1: Import body type ({body_type})...")
|
||||
body_objects = import_gltf(body_glb)
|
||||
body_armature = find_armature(body_objects)
|
||||
body_mesh = find_largest_mesh(body_objects)
|
||||
|
||||
if body_armature is None:
|
||||
print(f" ERROR: No armature in {body_glb}")
|
||||
failed.append(f"{body_type}/{outfit_name} (no body armature)")
|
||||
continue
|
||||
|
||||
if body_mesh is None:
|
||||
print(f" ERROR: No mesh in {body_glb}")
|
||||
failed.append(f"{body_type}/{outfit_name} (no body mesh)")
|
||||
continue
|
||||
|
||||
bone_names = [b.name for b in body_armature.data.bones]
|
||||
print(f" Armature: {body_armature.name} ({len(bone_names)} bones)")
|
||||
print(f" Body mesh: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
|
||||
|
||||
# --- Step 2: Import outfit GLTF ---
|
||||
print(f"\n Step 2: Import outfit ({outfit_name})...")
|
||||
outfit_objects = import_gltf(outfit_gltf)
|
||||
outfit_armature = find_armature(outfit_objects)
|
||||
clothing_meshes = find_meshes(outfit_objects)
|
||||
|
||||
if not clothing_meshes:
|
||||
print(f" ERROR: No meshes in {outfit_gltf}")
|
||||
failed.append(f"{body_type}/{outfit_name} (no clothing meshes)")
|
||||
continue
|
||||
|
||||
print(f" Outfit armature: {outfit_armature.name if outfit_armature else 'None'}")
|
||||
print(f" Clothing meshes: {[m.name for m in clothing_meshes]}")
|
||||
|
||||
# --- Step 3: Refit each clothing mesh to body type ---
|
||||
print(f"\n Step 3: Refit {len(clothing_meshes)} clothing mesh(es)...")
|
||||
|
||||
refitted = []
|
||||
for clothing_mesh in clothing_meshes:
|
||||
print(f"\n Mesh: {clothing_mesh.name} ({len(clothing_mesh.data.vertices)} verts)")
|
||||
|
||||
# 3a. Surface Deform: reposition vertices to match target body shape
|
||||
print(f" Surface Deform bind + apply...")
|
||||
sd_ok = bind_surface_deform(clothing_mesh, body_mesh)
|
||||
if not sd_ok:
|
||||
print(f" Skipping Surface Deform for {clothing_mesh.name} — "
|
||||
f"will retarget weights only.")
|
||||
|
||||
# 3b. Retarget armature modifier to body type armature
|
||||
print(f" Retargeting armature modifier...")
|
||||
retarget_armature_modifier(clothing_mesh, outfit_armature, body_armature)
|
||||
|
||||
# 3c. Re-transfer weights from body type mesh
|
||||
# This picks up the body type's weight distribution which
|
||||
# differs from the original Regular body (heavier limbs on heavy,
|
||||
# narrower deformation zones on thin, etc.)
|
||||
print(f" Data Transfer weights from body type mesh...")
|
||||
transfer_weights_data_transfer(body_mesh, clothing_mesh, body_armature)
|
||||
|
||||
# 3d. Normalize weights
|
||||
print(f" Normalizing weights...")
|
||||
normalize_weights(clothing_mesh)
|
||||
|
||||
refitted.append(clothing_mesh)
|
||||
|
||||
if not refitted:
|
||||
print(f" ERROR: No meshes were successfully refitted.")
|
||||
failed.append(f"{body_type}/{outfit_name} (all meshes failed)")
|
||||
continue
|
||||
|
||||
# --- Step 4: Export ---
|
||||
print(f"\n Step 4: Export to {output_path}...")
|
||||
try:
|
||||
export_fitted_outfit(body_armature, refitted, output_path)
|
||||
completed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: Export failed: {e}")
|
||||
failed.append(f"{body_type}/{outfit_name} (export error: {e})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" DONE")
|
||||
print(f"{'='*60}")
|
||||
print(f" Completed: {completed}/{total_jobs}")
|
||||
|
||||
if failed:
|
||||
print(f" Failed ({len(failed)}):")
|
||||
for f in failed:
|
||||
print(f" - {f}")
|
||||
else:
|
||||
print(f" All jobs succeeded.")
|
||||
|
||||
print(f"\n Output structure:")
|
||||
for body_type in BODY_TYPES:
|
||||
for outfit_name in OUTFITS:
|
||||
path = os.path.join(OUTPUT_DIR, body_type, f"{outfit_name}.glb")
|
||||
status = "[OK]" if os.path.exists(path) else "[MISSING]"
|
||||
print(f" {status} {body_type}/{outfit_name}.glb")
|
||||
|
||||
if completed < total_jobs:
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
VALIDATED: Segment a Quaternius body mesh into bone-group regions.
|
||||
|
||||
Each segment becomes a separate mesh, skinned to the same skeleton,
|
||||
exportable independently. Segments map to clothing coverage zones
|
||||
and can be selectively hidden at runtime.
|
||||
|
||||
Segments:
|
||||
head — Head bone
|
||||
neck — neck_01
|
||||
torso — spine_01, spine_02, spine_03, pelvis
|
||||
arm_upper_l — clavicle_l, upperarm_l
|
||||
arm_upper_r — clavicle_r, upperarm_r
|
||||
arm_lower_l — lowerarm_l
|
||||
arm_lower_r — lowerarm_r
|
||||
hand_l — hand_l, all finger bones _l
|
||||
hand_r — hand_r, all finger bones _r
|
||||
leg_upper_l — thigh_l
|
||||
leg_upper_r — thigh_r
|
||||
leg_lower_l — calf_l
|
||||
leg_lower_r — calf_r
|
||||
foot_l — foot_l, ball_l, ball_leaf_l
|
||||
foot_r — foot_r, ball_r, ball_leaf_r
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \
|
||||
spikes/quaternius-aesthetic/scripts/blender/segment_body.py \
|
||||
-- <input.gltf> <output_dir/>
|
||||
|
||||
Output:
|
||||
<output_dir>/seg_head.glb
|
||||
<output_dir>/seg_neck.glb
|
||||
<output_dir>/seg_torso.glb
|
||||
... etc
|
||||
<output_dir>/armature.glb (armature only, no body mesh)
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
if len(argv) < 2:
|
||||
print("Usage: -- <input.gltf> <output_dir/>")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT_PATH = argv[0]
|
||||
OUTPUT_DIR = argv[1]
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Bone-to-segment mapping. Each segment is a list of bone names.
|
||||
# A vertex belongs to the segment whose bones have the highest combined weight.
|
||||
SEGMENTS = {
|
||||
"head": ["Head"],
|
||||
"neck": ["neck_01"],
|
||||
"torso": ["spine_01", "spine_02", "spine_03", "pelvis", "root"],
|
||||
"arm_upper_l": ["clavicle_l", "upperarm_l"],
|
||||
"arm_upper_r": ["clavicle_r", "upperarm_r"],
|
||||
"arm_lower_l": ["lowerarm_l"],
|
||||
"arm_lower_r": ["lowerarm_r"],
|
||||
"hand_l": ["hand_l", "index_01_l", "index_02_l", "index_03_l", "index_04_leaf_l",
|
||||
"middle_01_l", "middle_02_l", "middle_03_l", "middle_04_leaf_l",
|
||||
"pinky_01_l", "pinky_02_l", "pinky_03_l", "pinky_04_leaf_l",
|
||||
"ring_01_l", "ring_02_l", "ring_03_l", "ring_04_leaf_l",
|
||||
"thumb_01_l", "thumb_02_l", "thumb_03_l", "thumb_04_leaf_l"],
|
||||
"hand_r": ["hand_r", "index_01_r", "index_02_r", "index_03_r", "index_04_leaf_r",
|
||||
"middle_01_r", "middle_02_r", "middle_03_r", "middle_04_leaf_r",
|
||||
"pinky_01_r", "pinky_02_r", "pinky_03_r", "pinky_04_leaf_r",
|
||||
"ring_01_r", "ring_02_r", "ring_03_r", "ring_04_leaf_r",
|
||||
"thumb_01_r", "thumb_02_r", "thumb_03_r", "thumb_04_leaf_r"],
|
||||
"leg_upper_l": ["thigh_l"],
|
||||
"leg_upper_r": ["thigh_r"],
|
||||
"leg_lower_l": ["calf_l"],
|
||||
"leg_lower_r": ["calf_r"],
|
||||
"foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
|
||||
"foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
|
||||
}
|
||||
|
||||
print("=== Segment body mesh ===")
|
||||
print(f" Input: {INPUT_PATH}")
|
||||
print(f" Output: {OUTPUT_DIR}")
|
||||
print(f" Segments: {len(SEGMENTS)}")
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
for c in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(c)
|
||||
|
||||
|
||||
def get_vertex_segment(mesh_obj, vert_index, vg_name_to_segment):
|
||||
"""Determine which segment a vertex belongs to based on highest combined bone weight."""
|
||||
segment_weights = {}
|
||||
for g in mesh_obj.data.vertices[vert_index].groups:
|
||||
vg = mesh_obj.vertex_groups[g.group]
|
||||
seg = vg_name_to_segment.get(vg.name)
|
||||
if seg:
|
||||
segment_weights[seg] = segment_weights.get(seg, 0.0) + g.weight
|
||||
|
||||
if not segment_weights:
|
||||
return "torso" # fallback for unweighted verts
|
||||
return max(segment_weights, key=segment_weights.get)
|
||||
|
||||
|
||||
def get_neighbor_verts(mesh_obj, vert_indices):
|
||||
"""Find all vertices connected to the given set by edges (1-ring border)."""
|
||||
idx_set = set(vert_indices)
|
||||
neighbors = set()
|
||||
for edge in mesh_obj.data.edges:
|
||||
v0, v1 = edge.vertices[0], edge.vertices[1]
|
||||
if v0 in idx_set and v1 not in idx_set:
|
||||
neighbors.add(v1)
|
||||
elif v1 in idx_set and v0 not in idx_set:
|
||||
neighbors.add(v0)
|
||||
return neighbors
|
||||
|
||||
|
||||
def extract_segment(mesh_obj, vert_indices, segment_name, armature):
|
||||
"""
|
||||
Create a new mesh from a subset of vertices plus a 1-ring border overlap.
|
||||
The overlap ensures adjacent segments share boundary geometry, eliminating
|
||||
visible seams at segment boundaries.
|
||||
"""
|
||||
if not vert_indices:
|
||||
return None
|
||||
|
||||
# Add 1-ring neighbor vertices as overlap border
|
||||
border = get_neighbor_verts(mesh_obj, vert_indices)
|
||||
keep_set = set(vert_indices) | border
|
||||
|
||||
# Duplicate the full mesh
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh_obj.select_set(True)
|
||||
mesh_obj.hide_set(False)
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.duplicate()
|
||||
seg_obj = bpy.context.active_object
|
||||
seg_obj.name = "seg_%s" % segment_name
|
||||
|
||||
# Select only the vertices NOT in this segment (+ border) and delete them
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
for v in seg_obj.data.vertices:
|
||||
v.select = v.index not in keep_set
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.delete(type='VERT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
remaining = len(seg_obj.data.vertices)
|
||||
if remaining == 0:
|
||||
bpy.data.objects.remove(seg_obj)
|
||||
return None
|
||||
|
||||
return seg_obj
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
clear_scene()
|
||||
|
||||
print("\nStep 1: Import...")
|
||||
bpy.ops.import_scene.gltf(filepath=INPUT_PATH)
|
||||
all_objects = list(bpy.data.objects)
|
||||
|
||||
armature = None
|
||||
meshes = []
|
||||
for obj in all_objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
armature = obj
|
||||
elif obj.type == 'MESH':
|
||||
meshes.append(obj)
|
||||
|
||||
if not armature:
|
||||
print("ERROR: no armature found")
|
||||
sys.exit(1)
|
||||
|
||||
body_mesh = max(meshes, key=lambda m: len(m.data.vertices))
|
||||
small_meshes = [m for m in meshes if m != body_mesh]
|
||||
|
||||
print(f" Armature: {armature.name} ({len(armature.data.bones)} bones)")
|
||||
print(f" Body mesh: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
|
||||
for m in small_meshes:
|
||||
print(f" Extra mesh: {m.name} ({len(m.data.vertices)} verts)")
|
||||
|
||||
# Build reverse map: bone name -> segment name
|
||||
vg_name_to_segment = {}
|
||||
for seg_name, bones in SEGMENTS.items():
|
||||
for bone in bones:
|
||||
vg_name_to_segment[bone] = seg_name
|
||||
|
||||
# Classify every vertex
|
||||
print("\nStep 2: Classifying vertices...")
|
||||
vert_segments = {} # segment_name -> [vert_indices]
|
||||
for v in body_mesh.data.vertices:
|
||||
seg = get_vertex_segment(body_mesh, v.index, vg_name_to_segment)
|
||||
if seg not in vert_segments:
|
||||
vert_segments[seg] = []
|
||||
vert_segments[seg].append(v.index)
|
||||
|
||||
for seg_name in sorted(vert_segments.keys()):
|
||||
print(f" {seg_name}: {len(vert_segments[seg_name])} verts")
|
||||
|
||||
# Extract each segment
|
||||
print("\nStep 3: Extracting segments...")
|
||||
segment_objects = {}
|
||||
|
||||
for seg_name in SEGMENTS:
|
||||
indices = vert_segments.get(seg_name, [])
|
||||
if not indices:
|
||||
print(f" {seg_name}: SKIP (no vertices)")
|
||||
continue
|
||||
|
||||
seg_obj = extract_segment(body_mesh, indices, seg_name, armature)
|
||||
if seg_obj:
|
||||
segment_objects[seg_name] = seg_obj
|
||||
print(f" {seg_name}: {len(seg_obj.data.vertices)} verts")
|
||||
else:
|
||||
print(f" {seg_name}: FAILED")
|
||||
|
||||
# Hide the original body mesh
|
||||
body_mesh.hide_set(True)
|
||||
|
||||
# Export each segment with the armature
|
||||
print("\nStep 4: Exporting segments...")
|
||||
for seg_name, seg_obj in segment_objects.items():
|
||||
# Hide all segments except this one
|
||||
for other_name, other_obj in segment_objects.items():
|
||||
other_obj.hide_set(other_name != seg_name)
|
||||
for m in small_meshes:
|
||||
m.hide_set(True)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
seg_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % seg_name)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
size_kb = os.path.getsize(output_path) // 1024
|
||||
print(f" seg_{seg_name}.glb ({size_kb} KB)")
|
||||
|
||||
# Export eyes/eyebrows as separate segments too
|
||||
print("\nStep 5: Exporting extra meshes...")
|
||||
for m in small_meshes:
|
||||
for other_obj in segment_objects.values():
|
||||
other_obj.hide_set(True)
|
||||
for other_m in small_meshes:
|
||||
other_m.hide_set(other_m != m)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
m.select_set(True)
|
||||
m.hide_set(False)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
clean_name = m.name.lower().replace(" ", "_")
|
||||
output_path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % clean_name)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
size_kb = os.path.getsize(output_path) // 1024
|
||||
print(f" seg_{clean_name}.glb ({size_kb} KB)")
|
||||
|
||||
# Export the armature alone (for animation loading)
|
||||
print("\nStep 6: Exporting armature...")
|
||||
for obj in segment_objects.values():
|
||||
obj.hide_set(True)
|
||||
for m in small_meshes:
|
||||
m.hide_set(True)
|
||||
body_mesh.hide_set(True)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
armature_path = os.path.join(OUTPUT_DIR, "armature.glb")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=armature_path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
print(f" armature.glb ({os.path.getsize(armature_path) // 1024} KB)")
|
||||
|
||||
print("\n=== Done ===")
|
||||
for seg_name in SEGMENTS:
|
||||
path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % seg_name)
|
||||
status = "[OK]" if os.path.exists(path) else "[MISSING]"
|
||||
print(f" {status} seg_{seg_name}.glb")
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
SUPERSEDED: Head-only separation from a Quaternius body mesh.
|
||||
|
||||
This script splits the body into head vs body based on Head bone weights.
|
||||
It has been superseded by segment_body.py, which performs full 15-region
|
||||
segmentation (including the head as one of those regions).
|
||||
|
||||
Use segment_body.py for all new work. This file is preserved as documentation.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/separate_head.py \\
|
||||
-- <input.gltf> <body_output.glb> <head_output.glb>
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
if len(argv) < 3:
|
||||
print("Usage: -- <input.gltf> <body_output.glb> <head_output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT_PATH = argv[0]
|
||||
BODY_OUTPUT = argv[1]
|
||||
HEAD_OUTPUT = argv[2]
|
||||
|
||||
HEAD_BONE = "Head"
|
||||
|
||||
print("=== Separate head from body ===")
|
||||
print(f" Input: {INPUT_PATH}")
|
||||
print(f" Body: {BODY_OUTPUT}")
|
||||
print(f" Head: {HEAD_OUTPUT}")
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
for c in list(bpy.data.collections):
|
||||
bpy.data.collections.remove(c)
|
||||
|
||||
|
||||
def get_dominant_bone(mesh_obj, vert_index):
|
||||
"""Get the name of the bone with highest weight for a vertex."""
|
||||
best_weight = 0.0
|
||||
best_group = ""
|
||||
for g in mesh_obj.data.vertices[vert_index].groups:
|
||||
if g.weight > best_weight:
|
||||
best_weight = g.weight
|
||||
vg = mesh_obj.vertex_groups[g.group]
|
||||
best_group = vg.name
|
||||
return best_group
|
||||
|
||||
|
||||
def separate_by_bone(mesh_obj, bone_name):
|
||||
"""
|
||||
Separate vertices weighted to bone_name into a new object.
|
||||
Returns (body_obj, head_obj).
|
||||
"""
|
||||
# Find which vertices belong to the head
|
||||
head_verts = set()
|
||||
for v in mesh_obj.data.vertices:
|
||||
dominant = get_dominant_bone(mesh_obj, v.index)
|
||||
if dominant == bone_name:
|
||||
head_verts.add(v.index)
|
||||
|
||||
print(f" Total verts: {len(mesh_obj.data.vertices)}")
|
||||
print(f" Head verts: {len(head_verts)}")
|
||||
print(f" Body verts: {len(mesh_obj.data.vertices) - len(head_verts)}")
|
||||
|
||||
if not head_verts:
|
||||
print(" WARNING: no vertices found for head bone")
|
||||
return mesh_obj, None
|
||||
|
||||
# Duplicate the mesh for the head version
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.duplicate()
|
||||
head_obj = bpy.context.active_object
|
||||
head_obj.name = "Head_Mesh"
|
||||
|
||||
# Edit the BODY copy — remove head vertices
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
for v in mesh_obj.data.vertices:
|
||||
v.select = v.index in head_verts
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.delete(type='VERT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Edit the HEAD copy — remove body vertices
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
head_obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = head_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Head verts in the duplicate have the same indices
|
||||
body_verts = set(range(len(head_obj.data.vertices))) - head_verts
|
||||
for v in head_obj.data.vertices:
|
||||
v.select = v.index in body_verts
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.delete(type='VERT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
print(f" Body mesh after split: {len(mesh_obj.data.vertices)} verts")
|
||||
print(f" Head mesh after split: {len(head_obj.data.vertices)} verts")
|
||||
|
||||
return mesh_obj, head_obj
|
||||
|
||||
|
||||
def export_glb(objects, path):
|
||||
"""Export selected objects as GLB."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
obj.hide_set(False)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=path,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
)
|
||||
print(f" Exported: {path} ({os.path.getsize(path) // 1024} KB)")
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
clear_scene()
|
||||
|
||||
print("\nStep 1: Import...")
|
||||
bpy.ops.import_scene.gltf(filepath=INPUT_PATH)
|
||||
all_objects = list(bpy.data.objects)
|
||||
|
||||
armature = None
|
||||
meshes = []
|
||||
other = []
|
||||
for obj in all_objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
armature = obj
|
||||
elif obj.type == 'MESH':
|
||||
meshes.append(obj)
|
||||
else:
|
||||
other.append(obj)
|
||||
|
||||
if not armature:
|
||||
print("ERROR: no armature found")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Armature: {armature.name}")
|
||||
for m in meshes:
|
||||
print(f" Mesh: {m.name} ({len(m.data.vertices)} verts)")
|
||||
|
||||
# Find the main body mesh (largest)
|
||||
body_mesh = max(meshes, key=lambda m: len(m.data.vertices))
|
||||
small_meshes = [m for m in meshes if m != body_mesh] # eyes, eyebrows
|
||||
|
||||
print(f"\nStep 2: Separate head from {body_mesh.name}...")
|
||||
body_only, head_only = separate_by_bone(body_mesh, HEAD_BONE)
|
||||
|
||||
if head_only is None:
|
||||
print("ERROR: head separation failed")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Export body (armature + body mesh + eyes/eyebrows — no head)
|
||||
print("\nStep 3: Export body (without head)...")
|
||||
export_objects = [armature, body_only] + small_meshes
|
||||
# Hide head for body export
|
||||
head_only.hide_set(True)
|
||||
export_glb(export_objects, BODY_OUTPUT)
|
||||
|
||||
# Step 4: Export head as standalone mesh (no armature, centered at origin)
|
||||
print("\nStep 4: Export head...")
|
||||
body_only.hide_set(True)
|
||||
for m in small_meshes:
|
||||
m.hide_set(True)
|
||||
head_only.hide_set(False)
|
||||
|
||||
# Remove armature modifier and unparent from skeleton
|
||||
bpy.context.view_layer.objects.active = head_only
|
||||
head_only.select_set(True)
|
||||
for mod in list(head_only.modifiers):
|
||||
if mod.type == 'ARMATURE':
|
||||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||||
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
||||
|
||||
# Find the Head bone position to use as the new origin
|
||||
head_bone = armature.data.bones.get(HEAD_BONE)
|
||||
if head_bone:
|
||||
head_bone_pos = armature.matrix_world @ head_bone.head_local
|
||||
print(f" Head bone position: {head_bone_pos}")
|
||||
# Set origin to head bone position, then move mesh to world origin
|
||||
bpy.context.scene.cursor.location = head_bone_pos
|
||||
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
|
||||
head_only.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
head_only.select_set(True)
|
||||
bpy.context.view_layer.objects.active = head_only
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=HEAD_OUTPUT,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=True,
|
||||
export_animations=False,
|
||||
export_skins=False,
|
||||
)
|
||||
print(f" Exported: {HEAD_OUTPUT} ({os.path.getsize(HEAD_OUTPUT) // 1024} KB)")
|
||||
|
||||
print("\n=== Done ===")
|
||||
@@ -0,0 +1,575 @@
|
||||
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
|
||||
@@ -0,0 +1,237 @@
|
||||
extends Node3D
|
||||
|
||||
## Automated screenshot cycle -- segmented bodies x outfits x angles x zoom levels.
|
||||
## Launch: godot --path . scenes/screenshot_cycle.tscn
|
||||
|
||||
const ANGLES := {
|
||||
"dramatic": Vector3(-30.0, 45.0, 0.0),
|
||||
"frontal": Vector3(-5.0, 45.0, 0.0),
|
||||
}
|
||||
|
||||
const ZOOMS := {
|
||||
"gameplay": 5.0,
|
||||
"heavy": 2.5,
|
||||
}
|
||||
|
||||
const ANIM_PATH := "res://models/quaternius/animations/UAL2_Standard.glb"
|
||||
const CAM_DIST: float = 20.0
|
||||
const PIVOT_Y_ZOOMED_OUT: float = 0.9
|
||||
const PIVOT_Y_ZOOMED_IN: float = 1.5
|
||||
const ZOOM_MIN: float = 1.5
|
||||
const ZOOM_MAX: float = 30.0
|
||||
|
||||
## Bodies to cycle through. Each uses the segmented architecture.
|
||||
const BODIES := [
|
||||
{
|
||||
"name": "male_segmented",
|
||||
"segmented": "res://models/quaternius/segmented-male/",
|
||||
},
|
||||
{
|
||||
"name": "female_segmented",
|
||||
"segmented": "res://models/quaternius/segmented-female/",
|
||||
},
|
||||
]
|
||||
|
||||
## Outfits directory -- scan for Male_*.gltf files.
|
||||
const OUTFITS_DIR := "res://models/quaternius/outfits-fantasy/full/"
|
||||
|
||||
var _camera: Camera3D
|
||||
var _character: CharacterVisual
|
||||
var _light: DirectionalLight3D
|
||||
var _world_env: WorldEnvironment
|
||||
var _shots: Array[Dictionary] = []
|
||||
var _shot_index: int = 0
|
||||
var _settle_frames: int = 0
|
||||
var _out_dir: String
|
||||
var _current_body: String = ""
|
||||
|
||||
func _ready() -> void:
|
||||
var git_output: Array = []
|
||||
OS.execute("git", ["rev-parse", "--show-toplevel"], git_output)
|
||||
var root := (git_output[0] as String).strip_edges()
|
||||
var timestamp := Time.get_datetime_string_from_system().replace(":", "").replace("T", "-").substr(0, 15)
|
||||
_out_dir = "%s/.tmp/quaternius-spike-%s" % [root, timestamp]
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
print("Output: %s" % _out_dir)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
_camera.near = 0.1
|
||||
_camera.far = 100.0
|
||||
_camera.current = true
|
||||
add_child(_camera)
|
||||
|
||||
_build_floor()
|
||||
|
||||
_light = DirectionalLight3D.new()
|
||||
_light.transform = Transform3D(
|
||||
Basis(Vector3(0.866, -0.354, 0.354), Vector3(0, 0.707, 0.707), Vector3(-0.5, -0.612, 0.612)),
|
||||
Vector3(5, 10, 5)
|
||||
)
|
||||
_light.light_energy = 0.8
|
||||
add_child(_light)
|
||||
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.2, 0.24)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.5, 0.5, 0.55)
|
||||
env.ambient_light_energy = 0.6
|
||||
_world_env = WorldEnvironment.new()
|
||||
_world_env.environment = env
|
||||
add_child(_world_env)
|
||||
|
||||
_build_shot_list()
|
||||
_load_body_for_shot(_shots[0])
|
||||
_setup_shot(_shots[0])
|
||||
print("Screenshot cycle: %d shots queued" % _shots.size())
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _shot_index >= _shots.size():
|
||||
print("Screenshot cycle complete: %d shots captured" % _shots.size())
|
||||
get_tree().quit()
|
||||
return
|
||||
|
||||
_settle_frames += 1
|
||||
if _settle_frames < 15:
|
||||
return
|
||||
|
||||
_capture_shot(_shots[_shot_index])
|
||||
_shot_index += 1
|
||||
|
||||
if _shot_index < _shots.size():
|
||||
_load_body_for_shot(_shots[_shot_index])
|
||||
_setup_shot(_shots[_shot_index])
|
||||
_settle_frames = 0
|
||||
|
||||
func _build_shot_list() -> void:
|
||||
# Scan available outfits
|
||||
var outfit_paths: Array[String] = []
|
||||
var dir := DirAccess.open(OUTFITS_DIR)
|
||||
if dir:
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if file_name.ends_with(".gltf") and file_name.begins_with("Male_"):
|
||||
outfit_paths.append(OUTFITS_DIR + file_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
outfit_paths.sort()
|
||||
|
||||
for body in BODIES:
|
||||
var body_name: String = body["name"]
|
||||
|
||||
# Bare body at both angles
|
||||
for angle_name in ANGLES:
|
||||
_shots.append({
|
||||
"body": body,
|
||||
"angle_name": angle_name,
|
||||
"angle": ANGLES[angle_name],
|
||||
"zoom_name": "gameplay",
|
||||
"zoom": ZOOMS["gameplay"],
|
||||
"outfit_name": "bare",
|
||||
"outfit_path": "",
|
||||
})
|
||||
|
||||
# Each outfit at dramatic gameplay + frontal heavy zoom
|
||||
for outfit_path in outfit_paths:
|
||||
var outfit_name := outfit_path.get_file().get_basename()
|
||||
_shots.append({
|
||||
"body": body,
|
||||
"angle_name": "dramatic",
|
||||
"angle": ANGLES["dramatic"],
|
||||
"zoom_name": "gameplay",
|
||||
"zoom": ZOOMS["gameplay"],
|
||||
"outfit_name": outfit_name,
|
||||
"outfit_path": outfit_path,
|
||||
})
|
||||
_shots.append({
|
||||
"body": body,
|
||||
"angle_name": "frontal",
|
||||
"angle": ANGLES["frontal"],
|
||||
"zoom_name": "heavy",
|
||||
"zoom": ZOOMS["heavy"],
|
||||
"outfit_name": outfit_name,
|
||||
"outfit_path": outfit_path,
|
||||
})
|
||||
|
||||
func _load_body_for_shot(shot: Dictionary) -> void:
|
||||
var body: Dictionary = shot["body"]
|
||||
if body["name"] == _current_body:
|
||||
return
|
||||
_current_body = body["name"]
|
||||
|
||||
if _character:
|
||||
remove_child(_character)
|
||||
_character.free()
|
||||
_character = null
|
||||
|
||||
_character = CharacterVisual.new()
|
||||
_character.set_shaders(
|
||||
preload("res://shaders/spike/toon.gdshader"),
|
||||
preload("res://shaders/spike/toon_masked.gdshader"),
|
||||
preload("res://shaders/spike/outline.gdshader"),
|
||||
)
|
||||
_character.position = Vector3(0.0, 0.04, 0.0)
|
||||
_character.rotation_degrees.y = 45.0
|
||||
add_child(_character)
|
||||
|
||||
if body.has("segmented"):
|
||||
_character.load_body(body["segmented"])
|
||||
else:
|
||||
_character.load_unsegmented_body(body["path"])
|
||||
|
||||
print(" Body loaded: %s (skeleton: %d bones)" % [body["name"], _character.get_bone_count()])
|
||||
|
||||
var anims := _character.load_animation_library(ANIM_PATH)
|
||||
for anim_name in anims:
|
||||
if "Idle" in anim_name:
|
||||
_character.play_animation(anim_name)
|
||||
break
|
||||
|
||||
func _setup_shot(shot: Dictionary) -> void:
|
||||
_character.detach_all_outfits()
|
||||
_character.set_body_visible(true)
|
||||
if shot["outfit_path"] != "":
|
||||
_character.attach_outfit(shot["outfit_path"])
|
||||
|
||||
_character.shader_mode = CharacterVisual.ShaderMode.TEXTURED
|
||||
_character.outline_enabled = true
|
||||
|
||||
var zoom := float(shot["zoom"])
|
||||
_camera.size = zoom
|
||||
var zoom_t := 1.0 - (zoom - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)
|
||||
var pivot_y := lerpf(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clampf(zoom_t, 0.0, 1.0))
|
||||
var pivot := Vector3(0.0, pivot_y, 0.0)
|
||||
var rot := shot["angle"] as Vector3
|
||||
_camera.rotation_degrees = rot
|
||||
var basis := Basis.from_euler(rot * (PI / 180.0))
|
||||
_camera.position = pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _capture_shot(shot: Dictionary) -> void:
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var body_name: String = (shot["body"] as Dictionary)["name"]
|
||||
var desc := "%02d-%s-%s-%s-%s" % [
|
||||
_shot_index + 1, body_name, shot["outfit_name"], shot["angle_name"], shot["zoom_name"]
|
||||
]
|
||||
var filename := "%s.png" % desc
|
||||
img.save_png(_out_dir.path_join(filename))
|
||||
print(" [%d/%d] %s" % [_shot_index + 1, _shots.size(), filename])
|
||||
|
||||
func _build_floor() -> void:
|
||||
const FLOOR_HALF_SIZE := 4
|
||||
for x in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
|
||||
for z in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (abs(x) + abs(z)) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
@@ -0,0 +1,359 @@
|
||||
extends Node3D
|
||||
|
||||
## Quaternius Aesthetic Validation Spike -- showcase harness.
|
||||
## All character visual logic lives in CharacterVisual.
|
||||
## This script handles camera, HUD, and input only.
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
@onready var body_select: OptionButton = $HUD/Panel/VBox/BodySelect
|
||||
@onready var skin_tone_select: OptionButton = $HUD/Panel/VBox/SkinToneSelect
|
||||
@onready var btn_outline: Button = $HUD/Panel/VBox/BtnOutline
|
||||
@onready var btn_outfit: Button = $HUD/Panel/VBox/BtnOutfit
|
||||
@onready var btn_anim: Button = $HUD/Panel/VBox/BtnAnim
|
||||
@onready var btn_trellis: Button = $HUD/Panel/VBox/BtnTrellis
|
||||
@onready var btn_tilt: Button = $HUD/Panel/VBox/BtnTilt
|
||||
|
||||
# --- Camera ---
|
||||
|
||||
var camera_pivot := Vector3(0.0, 0.9, 0.0)
|
||||
var camera_size_target: float = 5.0
|
||||
var camera_angle: int = 1 # start at dramatic
|
||||
var camera_target_rot := Vector3.ZERO
|
||||
var camera_current_rot := Vector3.ZERO
|
||||
var camera_orbit_y: float = 45.0
|
||||
|
||||
const PIVOT_Y_ZOOMED_OUT: float = 0.9 # full body -- center mass
|
||||
const PIVOT_Y_ZOOMED_IN: float = 1.5 # face -- upper head
|
||||
const ZOOM_MIN: float = 1.5
|
||||
const ZOOM_MAX: float = 30.0
|
||||
const ZOOM_SPEED: float = 0.8
|
||||
const PAN_SPEED: float = 8.0
|
||||
const TILT_SPEED: float = 3.0
|
||||
const ORBIT_STEP: float = 90.0
|
||||
const CAM_DIST: float = 20.0
|
||||
|
||||
const ANGLE_PRESETS := [
|
||||
Vector3(-90.0, 0.0, 0.0), # top-down
|
||||
Vector3(-30.0, 45.0, 0.0), # dramatic
|
||||
Vector3(-5.0, 45.0, 0.0), # frontal
|
||||
]
|
||||
const ANGLE_NAMES := ["top-down", "dramatic", "frontal"]
|
||||
|
||||
# --- Character ---
|
||||
|
||||
var character: CharacterVisual
|
||||
var outfit_paths: Array[String] = []
|
||||
var current_outfit_index: int = -1
|
||||
var current_anim_index: int = 0
|
||||
var anim_names: Array[String] = []
|
||||
|
||||
## Body entries: each is a dict with either "segmented" (dir path) or "path" (single GLB).
|
||||
var body_entries: Array[Dictionary] = []
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
const CHARACTER_Y_OFFSET: float = 0.04
|
||||
const CHARACTER_Y_ROTATION: float = 45.0
|
||||
const ANIM_DIR := "res://models/quaternius/animations/"
|
||||
|
||||
# --- Lifecycle ---
|
||||
|
||||
func _ready() -> void:
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = camera_size_target
|
||||
camera.near = 0.1
|
||||
camera.far = 100.0
|
||||
camera_target_rot = ANGLE_PRESETS[camera_angle]
|
||||
camera_target_rot.y = camera_orbit_y
|
||||
camera_current_rot = camera_target_rot
|
||||
_apply_camera()
|
||||
|
||||
_build_floor()
|
||||
_scan_bodies()
|
||||
|
||||
# Load the first body (segmented male by default)
|
||||
var first := body_entries[0] if body_entries.size() > 0 else {}
|
||||
_load_character(first)
|
||||
_scan_outfits()
|
||||
|
||||
body_select.item_selected.connect(_on_body_selected)
|
||||
_populate_skin_tones()
|
||||
skin_tone_select.item_selected.connect(_on_skin_tone_selected)
|
||||
btn_outline.pressed.connect(_toggle_outline)
|
||||
btn_outfit.pressed.connect(_cycle_outfit)
|
||||
btn_anim.pressed.connect(_toggle_animation)
|
||||
btn_trellis.pressed.connect(_toggle_trellis_head)
|
||||
btn_tilt.pressed.connect(_toggle_tilt)
|
||||
_update_hud()
|
||||
|
||||
print("Quaternius Aesthetic Spike ready!")
|
||||
|
||||
# --- Body scanning ---
|
||||
|
||||
func _scan_bodies() -> void:
|
||||
body_entries.append({
|
||||
"name": "Male Muscular (segmented)",
|
||||
"segmented": "res://models/quaternius/segmented-male/",
|
||||
})
|
||||
body_entries.append({
|
||||
"name": "Female Muscular (segmented)",
|
||||
"segmented": "res://models/quaternius/segmented-female/",
|
||||
})
|
||||
|
||||
body_select.clear()
|
||||
for entry in body_entries:
|
||||
body_select.add_item(entry["name"])
|
||||
print("Body types: %d" % body_entries.size())
|
||||
|
||||
func _on_body_selected(index: int) -> void:
|
||||
if index < 0 or index >= body_entries.size():
|
||||
return
|
||||
var entry: Dictionary = body_entries[index]
|
||||
print("Switching body: %s" % entry["name"])
|
||||
|
||||
character.queue_free()
|
||||
_load_character(entry)
|
||||
_scan_outfits()
|
||||
_update_hud()
|
||||
|
||||
# --- Skin tone ---
|
||||
|
||||
func _populate_skin_tones() -> void:
|
||||
skin_tone_select.clear()
|
||||
for i in range(CharacterVisual.SKIN_TONES.size()):
|
||||
var tone: Dictionary = CharacterVisual.SKIN_TONES[i]
|
||||
var swatch := _create_color_swatch(tone["lit"])
|
||||
skin_tone_select.add_icon_item(swatch, "")
|
||||
skin_tone_select.selected = character.skin_tone_index
|
||||
|
||||
func _on_skin_tone_selected(index: int) -> void:
|
||||
character.skin_tone_index = index
|
||||
_update_hud()
|
||||
|
||||
func _create_color_swatch(color: Color) -> ImageTexture:
|
||||
var img := Image.create(16, 16, false, Image.FORMAT_RGBA8)
|
||||
img.fill(color)
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
# --- Character setup ---
|
||||
|
||||
func _load_character(entry: Dictionary) -> void:
|
||||
## Create a new CharacterVisual from a body entry dictionary.
|
||||
## Handles both segmented and unsegmented body paths.
|
||||
character = CharacterVisual.new()
|
||||
character.set_shaders(
|
||||
preload("res://shaders/spike/toon.gdshader"),
|
||||
preload("res://shaders/spike/toon_masked.gdshader"),
|
||||
preload("res://shaders/spike/outline.gdshader"),
|
||||
)
|
||||
character.position = Vector3(0.0, CHARACTER_Y_OFFSET, 0.0)
|
||||
character.rotation_degrees.y = CHARACTER_Y_ROTATION
|
||||
add_child(character)
|
||||
|
||||
if entry.has("segmented"):
|
||||
character.load_body(entry["segmented"])
|
||||
else:
|
||||
character.load_unsegmented_body(entry.get("path", ""))
|
||||
|
||||
_load_all_animations()
|
||||
|
||||
var mesh_count := character.get_content_mesh_count()
|
||||
print("Character loaded: %d bones, %d meshes" % [character.get_bone_count(), mesh_count])
|
||||
|
||||
func _load_all_animations() -> void:
|
||||
## Scan the animations directory and load all GLB animation libraries.
|
||||
## Builds the anim_names list for cycling and plays the first idle.
|
||||
anim_names.clear()
|
||||
current_anim_index = 0
|
||||
var dir := DirAccess.open(ANIM_DIR)
|
||||
if dir == null:
|
||||
return
|
||||
var lib_idx := 0
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if file_name.ends_with(".glb"):
|
||||
var lib_name := "ual_%d" % lib_idx
|
||||
var anims := character.load_animation_library(ANIM_DIR + file_name, lib_name)
|
||||
for anim_name in anims:
|
||||
anim_names.append("%s/%s" % [lib_name, anim_name])
|
||||
lib_idx += 1
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# Play first idle
|
||||
for i in range(anim_names.size()):
|
||||
if "Idle" in anim_names[i]:
|
||||
current_anim_index = i
|
||||
character.get_animation_player().play(anim_names[i])
|
||||
break
|
||||
|
||||
# --- Camera ---
|
||||
|
||||
func _apply_camera() -> void:
|
||||
camera.rotation_degrees = camera_current_rot
|
||||
var basis := Basis.from_euler(camera_current_rot * (PI / 180.0))
|
||||
camera.position = camera_pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
camera.size = lerp(camera.size, camera_size_target, 8.0 * delta)
|
||||
camera_current_rot = camera_current_rot.lerp(camera_target_rot, TILT_SPEED * delta)
|
||||
|
||||
# Shift pivot toward face as zoom increases
|
||||
var zoom_t := 1.0 - (camera.size - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)
|
||||
camera_pivot.y = lerp(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clamp(zoom_t, 0.0, 1.0))
|
||||
_apply_camera()
|
||||
|
||||
camera_target_rot.y = camera_orbit_y
|
||||
|
||||
# WASD panning
|
||||
var input_dir := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W): input_dir.y += 1.0
|
||||
if Input.is_key_pressed(KEY_S): input_dir.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_A): input_dir.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D): input_dir.x += 1.0
|
||||
if input_dir.length() > 0:
|
||||
input_dir = input_dir.normalized()
|
||||
var cam_right := camera.global_basis.x
|
||||
var cam_up := camera.global_basis.y
|
||||
var pan_right := Vector3(cam_right.x, 0, cam_right.z).normalized()
|
||||
var pan_up := Vector3(cam_up.x, 0, cam_up.z).normalized()
|
||||
camera_pivot += (pan_right * input_dir.x + pan_up * input_dir.y) * PAN_SPEED * delta
|
||||
_apply_camera()
|
||||
|
||||
# --- Input ---
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
|
||||
camera_size_target = max(ZOOM_MIN, camera_size_target - ZOOM_SPEED)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
|
||||
camera_size_target = min(ZOOM_MAX, camera_size_target + ZOOM_SPEED)
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
match event.keycode:
|
||||
KEY_T: _toggle_tilt()
|
||||
KEY_Q: camera_orbit_y -= ORBIT_STEP
|
||||
KEY_E: camera_orbit_y += ORBIT_STEP
|
||||
KEY_2: _toggle_outline()
|
||||
KEY_3: _cycle_outfit()
|
||||
KEY_4: _toggle_animation()
|
||||
KEY_5: _cycle_animation()
|
||||
KEY_6: _toggle_trellis_head()
|
||||
KEY_F12: _take_screenshot()
|
||||
|
||||
func _toggle_tilt() -> void:
|
||||
camera_angle = (camera_angle + 1) % ANGLE_PRESETS.size()
|
||||
camera_target_rot = ANGLE_PRESETS[camera_angle]
|
||||
camera_target_rot.y = camera_orbit_y
|
||||
_update_hud()
|
||||
|
||||
func _toggle_outline() -> void:
|
||||
character.outline_enabled = not character.outline_enabled
|
||||
_update_hud()
|
||||
|
||||
func _toggle_animation() -> void:
|
||||
if character.is_playing():
|
||||
character.pause_animation()
|
||||
else:
|
||||
character.resume_animation()
|
||||
_update_hud()
|
||||
|
||||
func _cycle_animation() -> void:
|
||||
if anim_names.is_empty():
|
||||
return
|
||||
current_anim_index = (current_anim_index + 1) % anim_names.size()
|
||||
var anim_path := anim_names[current_anim_index]
|
||||
character.get_animation_player().play(anim_path)
|
||||
_update_hud()
|
||||
|
||||
var _trellis_head_on := false
|
||||
|
||||
func _toggle_trellis_head() -> void:
|
||||
if _trellis_head_on:
|
||||
character.detach_all_bone_attachments()
|
||||
_trellis_head_on = false
|
||||
print("Trellis head: OFF")
|
||||
else:
|
||||
character.attach_to_bone("res://models/generated/head.glb", "Head")
|
||||
_trellis_head_on = true
|
||||
print("Trellis head: ON")
|
||||
|
||||
func _cycle_outfit() -> void:
|
||||
character.detach_all_outfits()
|
||||
character.set_body_visible(true)
|
||||
current_outfit_index += 1
|
||||
if current_outfit_index >= outfit_paths.size():
|
||||
current_outfit_index = -1
|
||||
else:
|
||||
character.attach_outfit(outfit_paths[current_outfit_index])
|
||||
_update_hud()
|
||||
|
||||
# --- HUD ---
|
||||
|
||||
var _screenshot_count: int = 0
|
||||
|
||||
func _take_screenshot() -> void:
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var outfit_name := "bare"
|
||||
if current_outfit_index >= 0 and current_outfit_index < outfit_paths.size():
|
||||
outfit_name = outfit_paths[current_outfit_index].get_file().get_basename()
|
||||
var filename := "screenshots/%s_%s_%02d.png" % [ANGLE_NAMES[camera_angle], outfit_name, _screenshot_count]
|
||||
img.save_png("res://" + filename)
|
||||
_screenshot_count += 1
|
||||
print("Screenshot saved: %s" % filename)
|
||||
|
||||
func _update_hud() -> void:
|
||||
btn_outline.text = "[2] Outline: %s" % ("ON" if character.outline_enabled else "OFF")
|
||||
var outfit_name := "none"
|
||||
if current_outfit_index >= 0 and current_outfit_index < outfit_paths.size():
|
||||
outfit_name = outfit_paths[current_outfit_index].get_file().get_basename()
|
||||
btn_outfit.text = "[3] Outfit: %s" % outfit_name
|
||||
btn_anim.text = "[4] Pause/Play | [5] %s" % _current_anim_short_name()
|
||||
btn_trellis.text = "[6] Trellis head: %s" % ("ON" if _trellis_head_on else "OFF")
|
||||
btn_tilt.text = "[T] Camera: %s" % ANGLE_NAMES[camera_angle]
|
||||
|
||||
func _current_anim_short_name() -> String:
|
||||
if anim_names.is_empty():
|
||||
return "no anims"
|
||||
var full := anim_names[current_anim_index]
|
||||
# Strip library prefix (e.g. "ual_0/Idle_FoldArms" → "Idle_FoldArms")
|
||||
var slash := full.find("/")
|
||||
return full.substr(slash + 1) if slash >= 0 else full
|
||||
|
||||
# --- Scene setup ---
|
||||
|
||||
func _build_floor() -> void:
|
||||
const FLOOR_HALF_SIZE := 4
|
||||
for x in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
|
||||
for z in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (abs(x) + abs(z)) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
|
||||
func _scan_outfits() -> void:
|
||||
## Scan the original Fantasy outfit pack for Male outfits.
|
||||
outfit_paths.clear()
|
||||
var full_dir := "res://models/quaternius/outfits-fantasy/full/"
|
||||
var dir := DirAccess.open(full_dir)
|
||||
if dir:
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if file_name.ends_with(".gltf") and file_name.begins_with("Male_"):
|
||||
outfit_paths.append(full_dir + file_name)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
outfit_paths.sort()
|
||||
current_outfit_index = -1
|
||||
print("Available outfits: %d" % outfit_paths.size())
|
||||
@@ -0,0 +1,184 @@
|
||||
extends Node3D
|
||||
|
||||
## VALIDATED APPROACH (partially): Trellis head + Quaternius hairstyle stacked on Head bone.
|
||||
##
|
||||
## Result: the hairstyle meshes load and attach to the Head bone correctly.
|
||||
## BoneAttachment3D stacking (head + hair on same bone) works mechanically.
|
||||
## Visual alignment was not yet perfect -- hair positioning needs manual offset
|
||||
## tuning per hairstyle. The APPROACH is validated; the CONTENT needs work.
|
||||
|
||||
const ANIM_PATH := "res://models/quaternius/animations/UAL2_Standard.glb"
|
||||
const CAM_DIST: float = 20.0
|
||||
const PIVOT_Y_ZOOMED_OUT: float = 0.9
|
||||
const PIVOT_Y_ZOOMED_IN: float = 1.5
|
||||
const ZOOM_MIN: float = 1.5
|
||||
const ZOOM_MAX: float = 30.0
|
||||
|
||||
var _camera: Camera3D
|
||||
var _character: CharacterVisual
|
||||
var _out_dir: String
|
||||
var _shots: Array[Dictionary] = []
|
||||
var _shot_index: int = 0
|
||||
var _settle_frames: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
var git_output: Array = []
|
||||
OS.execute("git", ["rev-parse", "--show-toplevel"], git_output)
|
||||
var root := (git_output[0] as String).strip_edges()
|
||||
var timestamp := Time.get_datetime_string_from_system().replace(":", "").replace("T", "-").substr(0, 15)
|
||||
_out_dir = "%s/.tmp/hair-smoke-%s" % [root, timestamp]
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
print("Output: %s" % _out_dir)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
_camera.near = 0.1
|
||||
_camera.far = 100.0
|
||||
_camera.current = true
|
||||
add_child(_camera)
|
||||
|
||||
_build_floor()
|
||||
_build_lighting()
|
||||
|
||||
_character = CharacterVisual.new()
|
||||
_character.set_shaders(
|
||||
preload("res://shaders/spike/toon.gdshader"),
|
||||
preload("res://shaders/spike/toon_masked.gdshader"),
|
||||
preload("res://shaders/spike/outline.gdshader"),
|
||||
)
|
||||
_character.position = Vector3(0.0, 0.04, 0.0)
|
||||
_character.rotation_degrees.y = 45.0
|
||||
add_child(_character)
|
||||
|
||||
_character.load_unsegmented_body("res://models/quaternius/base-characters/Superhero_Male_FullBody.gltf")
|
||||
var anims := _character.load_animation_library(ANIM_PATH)
|
||||
for anim_name in anims:
|
||||
if "Idle" in anim_name:
|
||||
_character.play_animation(anim_name)
|
||||
break
|
||||
|
||||
# Scan available hairstyles
|
||||
var hair_dir := "res://models/quaternius/hairstyles/"
|
||||
var hairstyles: Array[String] = []
|
||||
var dir := DirAccess.open(hair_dir)
|
||||
if dir:
|
||||
dir.list_dir_begin()
|
||||
var f := dir.get_next()
|
||||
while f != "":
|
||||
if f.ends_with(".gltf") and f.begins_with("Hair_"):
|
||||
hairstyles.append(hair_dir + f)
|
||||
f = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
hairstyles.sort()
|
||||
print("Hairstyles found: %d" % hairstyles.size())
|
||||
|
||||
# Shots: Trellis head + each hairstyle, at frontal heavy zoom
|
||||
var angles := {
|
||||
"dramatic": Vector3(-30.0, 45.0, 0.0),
|
||||
"frontal": Vector3(-5.0, 45.0, 0.0),
|
||||
}
|
||||
|
||||
# Bald Trellis head only
|
||||
_shots.append({
|
||||
"name": "trellis-bald_frontal",
|
||||
"angle": angles["frontal"],
|
||||
"zoom": 2.5,
|
||||
"head": true,
|
||||
"hair": "",
|
||||
})
|
||||
|
||||
# Each hairstyle on the Trellis head
|
||||
for hair_path in hairstyles:
|
||||
var hair_name := hair_path.get_file().get_basename()
|
||||
_shots.append({
|
||||
"name": "%s_frontal" % hair_name,
|
||||
"angle": angles["frontal"],
|
||||
"zoom": 2.5,
|
||||
"head": true,
|
||||
"hair": hair_path,
|
||||
})
|
||||
_shots.append({
|
||||
"name": "%s_dramatic" % hair_name,
|
||||
"angle": angles["dramatic"],
|
||||
"zoom": 5.0,
|
||||
"head": true,
|
||||
"hair": hair_path,
|
||||
})
|
||||
|
||||
_setup_shot(_shots[0])
|
||||
print("Hair smoke test: %d shots" % _shots.size())
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _shot_index >= _shots.size():
|
||||
print("Hair smoke test complete")
|
||||
get_tree().quit()
|
||||
return
|
||||
|
||||
_settle_frames += 1
|
||||
if _settle_frames < 15:
|
||||
return
|
||||
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var filename := "%02d-%s.png" % [_shot_index + 1, _shots[_shot_index]["name"]]
|
||||
img.save_png(_out_dir.path_join(filename))
|
||||
print(" [%d/%d] %s" % [_shot_index + 1, _shots.size(), filename])
|
||||
|
||||
_shot_index += 1
|
||||
if _shot_index < _shots.size():
|
||||
_setup_shot(_shots[_shot_index])
|
||||
_settle_frames = 0
|
||||
|
||||
func _setup_shot(shot: Dictionary) -> void:
|
||||
_character.detach_all_bone_attachments()
|
||||
|
||||
if shot["head"]:
|
||||
_character.attach_to_bone("res://models/generated/head.glb", "Head")
|
||||
|
||||
var hair_path: String = shot["hair"]
|
||||
if hair_path != "":
|
||||
_character.attach_to_bone(hair_path, "Head")
|
||||
|
||||
var zoom := float(shot["zoom"])
|
||||
_camera.size = zoom
|
||||
var zoom_t := 1.0 - (zoom - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)
|
||||
var pivot_y := lerpf(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clampf(zoom_t, 0.0, 1.0))
|
||||
var pivot := Vector3(0.0, pivot_y, 0.0)
|
||||
var rot := shot["angle"] as Vector3
|
||||
_camera.rotation_degrees = rot
|
||||
var basis := Basis.from_euler(rot * (PI / 180.0))
|
||||
_camera.position = pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _build_floor() -> void:
|
||||
for x in range(-4, 5):
|
||||
for z in range(-4, 5):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (abs(x) + abs(z)) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.transform = Transform3D(
|
||||
Basis(Vector3(0.866, -0.354, 0.354), Vector3(0, 0.707, 0.707), Vector3(-0.5, -0.612, 0.612)),
|
||||
Vector3(5, 10, 5)
|
||||
)
|
||||
light.light_energy = 0.8
|
||||
add_child(light)
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.2, 0.24)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.5, 0.5, 0.55)
|
||||
env.ambient_light_energy = 0.6
|
||||
var world_env := WorldEnvironment.new()
|
||||
world_env.environment = env
|
||||
add_child(world_env)
|
||||
@@ -0,0 +1,171 @@
|
||||
extends Node3D
|
||||
|
||||
## VALIDATED APPROACH: Trellis-generated head attached to character via BoneAttachment3D.
|
||||
##
|
||||
## Result: head attaches correctly to the Head bone and follows animation.
|
||||
## The BoneAttachment3D system works for static meshes riding single bones.
|
||||
## This is the production path for per-character generated heads.
|
||||
##
|
||||
## Takes screenshots at multiple angles, then quits.
|
||||
|
||||
const ANIM_PATH := "res://models/quaternius/animations/UAL2_Standard.glb"
|
||||
const CAM_DIST: float = 20.0
|
||||
const PIVOT_Y_ZOOMED_OUT: float = 0.9
|
||||
const PIVOT_Y_ZOOMED_IN: float = 1.5
|
||||
const ZOOM_MIN: float = 1.5
|
||||
const ZOOM_MAX: float = 30.0
|
||||
|
||||
var _camera: Camera3D
|
||||
var _character: CharacterVisual
|
||||
var _out_dir: String
|
||||
var _shots: Array[Dictionary] = []
|
||||
var _shot_index: int = 0
|
||||
var _settle_frames: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
var git_output: Array = []
|
||||
OS.execute("git", ["rev-parse", "--show-toplevel"], git_output)
|
||||
var root := (git_output[0] as String).strip_edges()
|
||||
var timestamp := Time.get_datetime_string_from_system().replace(":", "").replace("T", "-").substr(0, 15)
|
||||
_out_dir = "%s/.tmp/head-smoke-%s" % [root, timestamp]
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
print("Output: %s" % _out_dir)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
_camera.near = 0.1
|
||||
_camera.far = 100.0
|
||||
_camera.current = true
|
||||
add_child(_camera)
|
||||
|
||||
_build_floor()
|
||||
_build_lighting()
|
||||
|
||||
# Character with original Superhero body
|
||||
_character = CharacterVisual.new()
|
||||
_character.set_shaders(
|
||||
preload("res://shaders/spike/toon.gdshader"),
|
||||
preload("res://shaders/spike/toon_masked.gdshader"),
|
||||
preload("res://shaders/spike/outline.gdshader"),
|
||||
)
|
||||
_character.position = Vector3(0.0, 0.04, 0.0)
|
||||
_character.rotation_degrees.y = 45.0
|
||||
add_child(_character)
|
||||
|
||||
_character.load_unsegmented_body("res://models/quaternius/base-characters/Superhero_Male_FullBody.gltf")
|
||||
var anims := _character.load_animation_library(ANIM_PATH)
|
||||
for anim_name in anims:
|
||||
if "Idle" in anim_name:
|
||||
_character.play_animation(anim_name)
|
||||
break
|
||||
|
||||
# Attach Trellis head
|
||||
var ok := _character.attach_to_bone("res://models/generated/head.glb", "Head")
|
||||
print("Head attached: %s" % ok)
|
||||
|
||||
# Build shots: with/without head, different angles and zooms
|
||||
var angles := {
|
||||
"dramatic": Vector3(-30.0, 45.0, 0.0),
|
||||
"frontal": Vector3(-5.0, 45.0, 0.0),
|
||||
}
|
||||
var zooms := {"gameplay": 5.0, "heavy": 2.5}
|
||||
|
||||
# With Trellis head
|
||||
for angle_name in angles:
|
||||
for zoom_name in zooms:
|
||||
_shots.append({
|
||||
"name": "head-on_%s_%s" % [angle_name, zoom_name],
|
||||
"angle": angles[angle_name],
|
||||
"zoom": zooms[zoom_name],
|
||||
"head": true,
|
||||
})
|
||||
|
||||
# Without head (baseline comparison)
|
||||
_shots.append({
|
||||
"name": "head-off_dramatic_gameplay",
|
||||
"angle": angles["dramatic"],
|
||||
"zoom": 5.0,
|
||||
"head": false,
|
||||
})
|
||||
_shots.append({
|
||||
"name": "head-off_frontal_heavy",
|
||||
"angle": angles["frontal"],
|
||||
"zoom": 2.5,
|
||||
"head": false,
|
||||
})
|
||||
|
||||
_setup_shot(_shots[0])
|
||||
print("Smoke test: %d shots" % _shots.size())
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _shot_index >= _shots.size():
|
||||
print("Smoke test complete")
|
||||
get_tree().quit()
|
||||
return
|
||||
|
||||
_settle_frames += 1
|
||||
if _settle_frames < 15:
|
||||
return
|
||||
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var filename := "%02d-%s.png" % [_shot_index + 1, _shots[_shot_index]["name"]]
|
||||
img.save_png(_out_dir.path_join(filename))
|
||||
print(" [%d/%d] %s" % [_shot_index + 1, _shots.size(), filename])
|
||||
|
||||
_shot_index += 1
|
||||
if _shot_index < _shots.size():
|
||||
_setup_shot(_shots[_shot_index])
|
||||
_settle_frames = 0
|
||||
|
||||
func _setup_shot(shot: Dictionary) -> void:
|
||||
if shot["head"]:
|
||||
if not _character.has_bone_attachments():
|
||||
_character.attach_to_bone("res://models/generated/head.glb", "Head")
|
||||
else:
|
||||
_character.detach_all_bone_attachments()
|
||||
|
||||
var zoom := float(shot["zoom"])
|
||||
_camera.size = zoom
|
||||
var zoom_t := 1.0 - (zoom - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)
|
||||
var pivot_y := lerpf(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clampf(zoom_t, 0.0, 1.0))
|
||||
var pivot := Vector3(0.0, pivot_y, 0.0)
|
||||
var rot := shot["angle"] as Vector3
|
||||
_camera.rotation_degrees = rot
|
||||
var basis := Basis.from_euler(rot * (PI / 180.0))
|
||||
_camera.position = pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _build_floor() -> void:
|
||||
for x in range(-4, 5):
|
||||
for z in range(-4, 5):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (abs(x) + abs(z)) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.transform = Transform3D(
|
||||
Basis(Vector3(0.866, -0.354, 0.354), Vector3(0, 0.707, 0.707), Vector3(-0.5, -0.612, 0.612)),
|
||||
Vector3(5, 10, 5)
|
||||
)
|
||||
light.light_energy = 0.8
|
||||
add_child(light)
|
||||
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.2, 0.24)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.5, 0.5, 0.55)
|
||||
env.ambient_light_energy = 0.6
|
||||
var world_env := WorldEnvironment.new()
|
||||
world_env.environment = env
|
||||
add_child(world_env)
|
||||
@@ -0,0 +1,245 @@
|
||||
extends Node3D
|
||||
|
||||
## FAILED APPROACH: runtime per-segment clothing scaling.
|
||||
##
|
||||
## Hypothesis: clothing authored on the Average body can be runtime-scaled
|
||||
## per bone segment to fit other body types, without pre-baked variants.
|
||||
##
|
||||
## Result: FAILED CATASTROPHICALLY. Bone scaling produces extreme mesh
|
||||
## distortion, vertex interpenetration, and visual artifacts. The deformation
|
||||
## is non-uniform in ways that break clothing geometry. This approach is
|
||||
## NOT viable for body type variation.
|
||||
##
|
||||
## Alternatives validated:
|
||||
## - Surface Deform in Blender (fit_outfits_to_bodies.py) -- partially validated
|
||||
## - Hand-authored body type meshes -- not yet tested
|
||||
##
|
||||
## This file is preserved as documentation of the failed approach.
|
||||
## Do not use this pattern in production code.
|
||||
|
||||
const ANIM_PATH := "res://models/quaternius/animations/UAL2_Standard.glb"
|
||||
const CAM_DIST: float = 20.0
|
||||
|
||||
# Body types and their segment scale factors relative to average.
|
||||
# These match the bone scale ratios from create_body_types.py (also FAILED).
|
||||
const SEGMENT_SCALES := {
|
||||
"thin": {
|
||||
"spine_01": Vector3(1.0, 0.86, 0.86),
|
||||
"spine_02": Vector3(1.0, 0.86, 0.86),
|
||||
"spine_03": Vector3(1.0, 0.86, 0.86),
|
||||
"pelvis": Vector3(1.0, 0.86, 0.86),
|
||||
"clavicle_l": Vector3(1.0, 0.83, 0.83),
|
||||
"clavicle_r": Vector3(1.0, 0.83, 0.83),
|
||||
"upperarm_l": Vector3(1.0, 0.83, 0.83),
|
||||
"upperarm_r": Vector3(1.0, 0.83, 0.83),
|
||||
"thigh_l": Vector3(1.0, 0.83, 0.83),
|
||||
"thigh_r": Vector3(1.0, 0.83, 0.83),
|
||||
},
|
||||
"average": {}, # identity -- clothing is authored for this
|
||||
"muscular": {
|
||||
"spine_01": Vector3(1.0, 1.14, 1.14),
|
||||
"spine_02": Vector3(1.0, 1.14, 1.14),
|
||||
"spine_03": Vector3(1.0, 1.14, 1.14),
|
||||
"pelvis": Vector3(1.0, 1.10, 1.10),
|
||||
"clavicle_l": Vector3(1.0, 1.14, 1.14),
|
||||
"clavicle_r": Vector3(1.0, 1.14, 1.14),
|
||||
"upperarm_l": Vector3(1.0, 1.22, 1.22),
|
||||
"upperarm_r": Vector3(1.0, 1.22, 1.22),
|
||||
"thigh_l": Vector3(1.0, 1.19, 1.19),
|
||||
"thigh_r": Vector3(1.0, 1.19, 1.19),
|
||||
},
|
||||
"heavy": {
|
||||
"spine_01": Vector3(1.0, 1.43, 1.48),
|
||||
"spine_02": Vector3(1.0, 1.34, 1.39),
|
||||
"spine_03": Vector3(1.0, 1.34, 1.39),
|
||||
"pelvis": Vector3(1.0, 1.30, 1.27),
|
||||
"clavicle_l": Vector3(1.0, 1.26, 1.26),
|
||||
"clavicle_r": Vector3(1.0, 1.26, 1.26),
|
||||
"upperarm_l": Vector3(1.0, 1.46, 1.46),
|
||||
"upperarm_r": Vector3(1.0, 1.46, 1.46),
|
||||
"thigh_l": Vector3(1.0, 1.45, 1.45),
|
||||
"thigh_r": Vector3(1.0, 1.45, 1.45),
|
||||
},
|
||||
}
|
||||
|
||||
## NOTE: These body-types/ paths reference GLBs produced by create_body_types.py,
|
||||
## which is also a FAILED approach. This smoke test may not run without those files.
|
||||
const BODY_PATHS := {
|
||||
"thin": "res://models/quaternius/body-types/male_thin.glb",
|
||||
"average": "res://models/quaternius/body-types/male_average.glb",
|
||||
"muscular": "res://models/quaternius/body-types/male_muscular.glb",
|
||||
"heavy": "res://models/quaternius/body-types/male_heavy.glb",
|
||||
}
|
||||
|
||||
const OUTFIT_PATH := "res://models/quaternius/outfits-fantasy/full/Male_Peasant.gltf"
|
||||
|
||||
var _camera: Camera3D
|
||||
var _characters: Array[CharacterVisual] = []
|
||||
var _out_dir: String
|
||||
var _shots: Array[Dictionary] = []
|
||||
var _shot_index: int = 0
|
||||
var _settle_frames: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
var git_output: Array = []
|
||||
OS.execute("git", ["rev-parse", "--show-toplevel"], git_output)
|
||||
var root := (git_output[0] as String).strip_edges()
|
||||
var timestamp := Time.get_datetime_string_from_system().replace(":", "").replace("T", "-").substr(0, 15)
|
||||
_out_dir = "%s/.tmp/runtime-scale-%s" % [root, timestamp]
|
||||
DirAccess.make_dir_recursive_absolute(_out_dir)
|
||||
print("Output: %s" % _out_dir)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
_camera.near = 0.1
|
||||
_camera.far = 100.0
|
||||
_camera.current = true
|
||||
add_child(_camera)
|
||||
|
||||
_build_floor()
|
||||
_build_lighting()
|
||||
|
||||
# Place 2 characters side by side (average vs heavy)
|
||||
var positions := [
|
||||
Vector3(-1.0, 0.04, 0.0),
|
||||
Vector3(1.0, 0.04, 0.0),
|
||||
]
|
||||
var types := ["average", "heavy"]
|
||||
|
||||
for i in range(2):
|
||||
var type_name: String = types[i]
|
||||
var cv := CharacterVisual.new()
|
||||
cv.set_shaders(
|
||||
preload("res://shaders/spike/toon.gdshader"),
|
||||
preload("res://shaders/spike/toon_masked.gdshader"),
|
||||
preload("res://shaders/spike/outline.gdshader"),
|
||||
)
|
||||
cv.position = positions[i]
|
||||
cv.rotation_degrees.y = 45.0
|
||||
add_child(cv)
|
||||
|
||||
cv.load_unsegmented_body(BODY_PATHS[type_name])
|
||||
|
||||
var anims := cv.load_animation_library(ANIM_PATH)
|
||||
for anim_name in anims:
|
||||
if "Idle" in anim_name:
|
||||
cv.play_animation(anim_name)
|
||||
break
|
||||
|
||||
cv.set_body_visible(false)
|
||||
cv.attach_outfit(OUTFIT_PATH)
|
||||
|
||||
# Apply per-segment bone scale to outfit meshes via pose bones
|
||||
if type_name != "average":
|
||||
_apply_segment_scales(cv, SEGMENT_SCALES[type_name])
|
||||
|
||||
_characters.append(cv)
|
||||
print(" Character %d: %s" % [i, type_name])
|
||||
|
||||
# Shots
|
||||
_shots.append({
|
||||
"name": "lineup_dramatic_gameplay",
|
||||
"angle": Vector3(-30.0, 45.0, 0.0),
|
||||
"zoom": 8.0,
|
||||
"pivot": Vector3(0.0, 0.9, 0.0),
|
||||
})
|
||||
_shots.append({
|
||||
"name": "lineup_frontal_gameplay",
|
||||
"angle": Vector3(-5.0, 45.0, 0.0),
|
||||
"zoom": 8.0,
|
||||
"pivot": Vector3(0.0, 0.9, 0.0),
|
||||
})
|
||||
_shots.append({
|
||||
"name": "lineup_dramatic_heavy_zoom",
|
||||
"angle": Vector3(-30.0, 45.0, 0.0),
|
||||
"zoom": 4.0,
|
||||
"pivot": Vector3(0.0, 1.0, 0.0),
|
||||
})
|
||||
for i in range(2):
|
||||
var type_name: String = types[i]
|
||||
_shots.append({
|
||||
"name": "%s_frontal_closeup" % type_name,
|
||||
"angle": Vector3(-5.0, 45.0, 0.0),
|
||||
"zoom": 3.0,
|
||||
"pivot": Vector3(positions[i].x, 1.2, positions[i].z),
|
||||
})
|
||||
|
||||
_setup_shot(_shots[0])
|
||||
print("Runtime scale test: %d shots" % _shots.size())
|
||||
|
||||
func _apply_segment_scales(cv: CharacterVisual, scales: Dictionary) -> void:
|
||||
## Apply per-segment bone scaling via the skeleton's pose bones.
|
||||
var skel := cv.get_skeleton()
|
||||
if skel == null:
|
||||
print("WARNING: no skeleton for segment scaling")
|
||||
return
|
||||
for bone_name in scales:
|
||||
var bone_idx := skel.find_bone(bone_name)
|
||||
if bone_idx == -1:
|
||||
continue
|
||||
var scale: Vector3 = scales[bone_name]
|
||||
skel.set_bone_pose_scale(bone_idx, scale)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _shot_index >= _shots.size():
|
||||
print("Runtime scale test complete")
|
||||
get_tree().quit()
|
||||
return
|
||||
|
||||
_settle_frames += 1
|
||||
if _settle_frames < 15:
|
||||
return
|
||||
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var filename := "%02d-%s.png" % [_shot_index + 1, _shots[_shot_index]["name"]]
|
||||
img.save_png(_out_dir.path_join(filename))
|
||||
print(" [%d/%d] %s" % [_shot_index + 1, _shots.size(), filename])
|
||||
|
||||
_shot_index += 1
|
||||
if _shot_index < _shots.size():
|
||||
_setup_shot(_shots[_shot_index])
|
||||
_settle_frames = 0
|
||||
|
||||
func _setup_shot(shot: Dictionary) -> void:
|
||||
var zoom := float(shot["zoom"])
|
||||
_camera.size = zoom
|
||||
var pivot := shot["pivot"] as Vector3
|
||||
var rot := shot["angle"] as Vector3
|
||||
_camera.rotation_degrees = rot
|
||||
var basis := Basis.from_euler(rot * (PI / 180.0))
|
||||
_camera.position = pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _build_floor() -> void:
|
||||
for x in range(-6, 7):
|
||||
for z in range(-4, 5):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (abs(x) + abs(z)) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.transform = Transform3D(
|
||||
Basis(Vector3(0.866, -0.354, 0.354), Vector3(0, 0.707, 0.707), Vector3(-0.5, -0.612, 0.612)),
|
||||
Vector3(5, 10, 5)
|
||||
)
|
||||
light.light_energy = 0.8
|
||||
add_child(light)
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.2, 0.24)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.5, 0.5, 0.55)
|
||||
env.ambient_light_energy = 0.6
|
||||
var world_env := WorldEnvironment.new()
|
||||
world_env.environment = env
|
||||
add_child(world_env)
|
||||
Reference in New Issue
Block a user