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>
238 lines
6.9 KiB
GDScript
238 lines
6.9 KiB
GDScript
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)
|