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:
2026-03-19 13:29:22 +01:00
co-authored by Claude Opus 4.6
parent 63b7d69535
commit c380ef8a84
298 changed files with 70853 additions and 0 deletions
@@ -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)