Files
settled-reach/client/tools/garment_preview/lookbook_scene.gd
T
jpmschweitzerandClaude Fable 5 e562418d06 fix(assets): lookbook findings — 44 missing extracted albedos, manifest nesting, lookbook tool (T-1089)
The lookbook render pass load-checked all 264 garment GLBs and found four
garments (boots, cargo pants, jeans, tank top) shipped WITHOUT their
per-body Godot-extracted albedo textures — 44 GLBs would fail to load after
a clean import. Textures regenerated via forced reimport and committed.
Also: swimsuit_onepiece manifest entry was double-nested (lead's merge bug —
runtime unaffected, reads coverage.json); flattened. The parameterized
lookbook_scene tool (drives the production compositor, auto-framing, job
list) is committed for future catalogue renders. Grey head-template finding
filed as T-1095. Lookbook itself delivered to the desktop, not committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:04:36 +02:00

318 lines
10 KiB
GDScript

extends Node3D
## Wardrobe LOOKBOOK capture (T-1089).
##
## Drives the PRODUCTION character compositor (CharacterVisual.load_descriptor)
## to render one catwalk-style full-body portrait per garment. Each garment is
## the HERO item, dressed with neutral complements so it reads (a top over
## jeans+sneakers, footwear over tee+shorts, full-body pieces on their own).
## Every shot uses production-identical data: the same multi-region toon_garment
## shader, region masks, per-region tints and thrds logo the game uses at
## runtime — nothing is hand-composited here.
##
## One Godot process renders the WHOLE lookbook: it reads a JSON job list from
## the LOOKBOOK_JOBS env var and loops through every entry, reusing a single
## CharacterVisual (load_descriptor clears + rebuilds each time).
##
## Job schema (array of objects):
## {
## "garment_id": "tshirt_modern", # for logging only
## "out": "/abs/path/x.png", # 768x1024 PNG written here
## "body": "average_m", # body-type key
## "skin_tone": 3, # 0..8
## "head": "head_001", "hair": "buzzed", "eyebrow": "regular",
## "slots": {"torso": "tshirt_modern", "legs": "jeans_modern", ...},
## "tints": {"tshirt_modern": [[r,g,b,a], ...], ...},
## "logos": {"tshirt_modern": "thrds"},
## "camera": "full" | "feet",
## "walk_phase": 0.08, # 0..1 fraction of the Walk clip
## "yaw": 25.0 # 3/4 turn, degrees
## }
##
## NOT a committed asset-pipeline generator — a marketing/QA capture tool. The
## .gd/.tscn pair may be committed; the rendered PNGs and job JSON are not.
##
## Run:
## LOOKBOOK_JOBS=/tmp/jobs.json godot4 --path client \
## --rendering-driver opengl3 res://tools/garment_preview/lookbook_scene.tscn
const VIEWPORT := Vector2i(768, 1024)
const WALK_CLIP := "ual1/Walk"
const BODY_KEY_TO_ENUM := {
"thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
"thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
"heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
"heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
"child": CharacterVisualDescriptor.BodyType.CHILD,
}
var _cam: Camera3D = null
var _cv: CharacterVisual = null
var _vp: SubViewport = null
func _ready() -> void:
# Render into a fixed-size offscreen SubViewport so the captured image is
# always 768x1024 regardless of the on-screen window / display resolution.
_vp = SubViewport.new()
_vp.size = VIEWPORT
_vp.own_world_3d = true
_vp.transparent_bg = false
_vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_vp.msaa_3d = Viewport.MSAA_4X
add_child(_vp)
_setup_stage()
_run_all.call_deferred()
func _env(key: String, fallback: String) -> String:
var v := OS.get_environment(key)
return v if not v.is_empty() else fallback
func _setup_stage() -> void:
# Key + soft fill: a warm key from camera-left, a cool low-energy fill from
# camera-right so the toon regions keep gradient without a flat wash.
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-40, 28, 0)
key.light_energy = 1.25
key.light_color = Color(1.0, 0.98, 0.94)
_vp.add_child(key)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-18, -42, 0)
fill.light_energy = 0.45
fill.light_color = Color(0.86, 0.90, 1.0)
_vp.add_child(fill)
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.84, 0.85, 0.90)
env.ambient_light_energy = 0.85
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.20, 0.22, 0.26)
var we := WorldEnvironment.new()
we.environment = env
_vp.add_child(we)
_build_backdrop()
_cam = Camera3D.new()
_cam.fov = 42.0
_cam.keep_aspect = Camera3D.KEEP_HEIGHT # fov is the VERTICAL angle
_cam.current = true
_vp.add_child(_cam)
## Studio infinity-cove: a vertical gradient sweep behind + a matching floor,
## toned so the floor meets the backdrop near-seamlessly (catwalk look).
func _build_backdrop() -> void:
var grad := Gradient.new()
grad.set_color(0, Color(0.16, 0.17, 0.21))
grad.set_color(1, Color(0.30, 0.32, 0.37))
var gtex := GradientTexture2D.new()
gtex.gradient = grad
gtex.fill_from = Vector2(0, 0)
gtex.fill_to = Vector2(0, 1)
gtex.width = 8
gtex.height = 256
var back := MeshInstance3D.new()
var bmesh := QuadMesh.new()
bmesh.size = Vector2(12, 9)
back.mesh = bmesh
var bmat := StandardMaterial3D.new()
bmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
bmat.albedo_texture = gtex
back.material_override = bmat
back.position = Vector3(0, 2.0, -2.2)
_vp.add_child(back)
var floor := MeshInstance3D.new()
var fmesh := PlaneMesh.new()
fmesh.size = Vector2(12, 8)
floor.mesh = fmesh
var fmat := StandardMaterial3D.new()
fmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
fmat.albedo_color = Color(0.235, 0.255, 0.295)
floor.material_override = fmat
floor.position = Vector3(0, 0.0, -0.5)
_vp.add_child(floor)
func _run_all() -> void:
var jobs_path := _env("LOOKBOOK_JOBS", "")
if jobs_path.is_empty():
push_error("lookbook: LOOKBOOK_JOBS not set")
get_tree().quit(1)
return
var f := FileAccess.open(jobs_path, FileAccess.READ)
if f == null:
push_error("lookbook: cannot open %s" % jobs_path)
get_tree().quit(1)
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Array):
push_error("lookbook: job file is not a JSON array")
get_tree().quit(1)
return
_cv = CharacterVisual.new()
_vp.add_child(_cv)
var jobs: Array = parsed
var ok := 0
for i in jobs.size():
var job: Dictionary = jobs[i]
if await _render_job(job, i, jobs.size()):
ok += 1
print("lookbook: DONE — %d/%d rendered" % [ok, jobs.size()])
get_tree().quit(0)
func _render_job(job: Dictionary, idx: int, total: int) -> bool:
var gid := str(job.get("garment_id", "?"))
var body := str(job.get("body", "average_m"))
if not BODY_KEY_TO_ENUM.has(body):
push_error("lookbook: unknown body '%s' for %s" % [body, gid])
return false
var desc := CharacterVisualDescriptor.new()
desc.body_type = BODY_KEY_TO_ENUM[body]
desc.head_id = str(job.get("head", "head_001"))
desc.hair_id = str(job.get("hair", "buzzed"))
desc.eyebrow_id = str(job.get("eyebrow", "regular"))
desc.skin_tone = int(job.get("skin_tone", 3))
desc.clothing_slots = _to_str_dict(job.get("slots", {}))
desc.clothing_tints = _to_tint_map(job.get("tints", {}))
desc.clothing_logos = _to_str_dict(job.get("logos", {}))
_cv.load_descriptor(desc)
_cv.rotation_degrees.y = float(job.get("yaw", 25.0))
var skel := _cv.get_skeleton()
if skel == null:
push_error("lookbook: no skeleton for %s" % gid)
return false
# Pose: freeze the Walk clip at a phase with clear leg separation.
var ap := _cv.get_animation_player()
if ap != null and ap.has_animation(WALK_CLIP):
_cv.play_animation(WALK_CLIP)
ap.pause()
var length := ap.get_animation(WALK_CLIP).length
ap.seek(length * float(job.get("walk_phase", 0.08)), true)
await get_tree().process_frame
else:
push_warning("lookbook: %s missing, using rest pose" % WALK_CLIP)
if idx == 0 and ap != null:
print(" available clips: ", _all_clip_names(ap))
_frame_camera(str(job.get("camera", "full")))
# Let the posed skeleton + framing settle, then capture.
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var out := str(job.get("out", ""))
if out.is_empty():
push_error("lookbook: no out path for %s" % gid)
return false
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
var img := _vp.get_texture().get_image()
var err := img.save_png(out)
if err != OK:
push_error("lookbook: save failed (%d) for %s -> %s" % [err, gid, out])
return false
print("lookbook [%d/%d]: %s (%s, %s) -> %s" % [idx + 1, total, gid, body, job.get("camera", "full"), out])
return true
## Auto-frame from the true visible bounds so every body type + garment fills a
## consistent share of the portrait. Height is yaw-invariant, so the 3/4 turn
## does not change the framing.
func _frame_camera(mode: String) -> void:
var aabb := _visible_aabb(_cv)
var center := aabb.get_center()
var height: float = max(aabb.size.y, 0.2)
var fov_v := deg_to_rad(_cam.fov)
var fill := 0.86
if mode == "feet":
fill = 0.84
var visible_v := height / fill
var dist := (visible_v * 0.5) / tan(fov_v * 0.5)
if mode == "feet":
# Mild high angle to bring the shoes forward, but AIM AT THE TRUE CENTRE
# (never a lowered target) so the head cannot rotate out of frame even on
# tall / puffy-silhouette garments.
var cam_pos := Vector3(center.x, center.y + height * 0.03, center.z + dist)
_cam.position = cam_pos
_cam.look_at_from_position(cam_pos, center, Vector3.UP)
else:
var cam_pos := Vector3(center.x, center.y, center.z + dist)
_cam.position = cam_pos
_cam.look_at_from_position(cam_pos, center, Vector3.UP)
func _visible_aabb(root: Node) -> AABB:
var acc := AABB()
var first := true
var stack: Array[Node] = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
for c in n.get_children():
stack.append(c)
if n is MeshInstance3D:
var mi := n as MeshInstance3D
if not mi.visible or mi.mesh == null:
continue
if str(mi.name).begins_with("_outline_"):
continue
var world := mi.global_transform * mi.mesh.get_aabb()
if first:
acc = world
first = false
else:
acc = acc.merge(world)
return acc
func _all_clip_names(ap: AnimationPlayer) -> Array:
var names: Array = []
for lib_name in ap.get_animation_library_list():
var lib := ap.get_animation_library(lib_name)
for a in lib.get_animation_list():
names.append("%s/%s" % [lib_name, a])
return names
func _to_str_dict(v: Variant) -> Dictionary:
var out := {}
if v is Dictionary:
for k: String in v:
out[k] = str(v[k])
return out
## Convert {"item": [[r,g,b,a], ...]} to {"item": Array[Color]}.
func _to_tint_map(v: Variant) -> Dictionary:
var out := {}
if not (v is Dictionary):
return out
for item: String in v:
var colors: Array[Color] = []
var arr: Variant = v[item]
if arr is Array:
for c in arr:
if c is Array and (c as Array).size() >= 3:
var a: float = float(c[3]) if (c as Array).size() >= 4 else 1.0
colors.append(Color(float(c[0]), float(c[1]), float(c[2]), a))
out[item] = colors
return out