Files
settled-reach/spikes/quaternius-aesthetic/scripts/spike/smoke_test_hair.gd
T
jpmschweitzerandClaude Opus 4.6 c380ef8a84 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>
2026-03-19 13:29:22 +01:00

185 lines
5.6 KiB
GDScript

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)