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>
318 lines
10 KiB
GDScript
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
|