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>
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -204,22 +204,20 @@
|
||||
"logo_capable": false
|
||||
},
|
||||
"swimsuit_onepiece": {
|
||||
"swimsuit_onepiece": {
|
||||
"slot": "full_body",
|
||||
"style": "modern",
|
||||
"logo_capable": false,
|
||||
"regions": {
|
||||
"R": "straps_trim",
|
||||
"G": "body",
|
||||
"B": "side_blocks"
|
||||
},
|
||||
"default_tints": [
|
||||
"#f2f0e8",
|
||||
"#e34a33",
|
||||
"#1f7a8c",
|
||||
"#ffffff"
|
||||
]
|
||||
}
|
||||
"slot": "full_body",
|
||||
"style": "modern",
|
||||
"logo_capable": false,
|
||||
"regions": {
|
||||
"R": "straps_trim",
|
||||
"G": "body",
|
||||
"B": "side_blocks"
|
||||
},
|
||||
"default_tints": [
|
||||
"#f2f0e8",
|
||||
"#e34a33",
|
||||
"#1f7a8c",
|
||||
"#ffffff"
|
||||
]
|
||||
},
|
||||
"sneakers_modern": {
|
||||
"slot": "feet",
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c8rtlookbk0garm"]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/garment_preview/lookbook_scene.gd" id="1_look"]
|
||||
|
||||
[node name="Lookbook" type="Node3D"]
|
||||
script = ExtResource("1_look")
|
||||