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>
246 lines
7.7 KiB
GDScript
246 lines
7.7 KiB
GDScript
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)
|