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