diff --git a/AgentScripts/WL814x_DIAG.txt b/AgentScripts/WL814x_DIAG.txt new file mode 100644 index 000000000..8028ffe9e --- /dev/null +++ b/AgentScripts/WL814x_DIAG.txt @@ -0,0 +1,19 @@ +setup: char bounds (0.648, 1.217, 0.907) · 화면 키 117 px · 포즈 yaw 240 +SO: enabled_=1 mode=3 +ApplyMode(3): mode 3 · 렌더러 6 · 슬롯 6 + slot Arm_m05 = M05_ToonF + slot Body_m05 = M05_ToonF + slot Face = face02_ToonF + slot Hair05 = Hair05_ToonF + slot Head = Skin_ToonF + slot Leg_M05 = M05_ToonF +mask Arm_m05 = 335 px +mask Body_m05 = 975 px +mask Face = 47 px +mask Hair05 = 1154 px +mask Head = 1047 px +mask Leg_M05 = 729 px +mask all = 2996 px +mask body3 = 1756 px +c0 Ⓐ136 Ⓑ12mat/md0.12 Ⓒ0.55 Ⓓ5 +DONE diff --git a/AgentScripts/WL814x_ITER1.txt b/AgentScripts/WL814x_ITER1.txt new file mode 100644 index 000000000..59fbd9cf1 --- /dev/null +++ b/AgentScripts/WL814x_ITER1.txt @@ -0,0 +1,18 @@ +setup: bounds (0.648, 1.217, 0.907) / screen H 117 px / pose yaw 240 +mask Arm_m05 = 335 px +mask Body_m05 = 975 px +mask Face = 50 px +mask Hair05 = 1154 px +mask Head = 1047 px +mask Leg_M05 = 729 px +mask all = 2996 px +mask body3 = 1756 px +c20p16: regions 21 pal 16 dHue 1.06/4.58 verts 2520->2573 +c20p24: regions 21 pal 21 dHue 1.06/4.58 verts 2520->2573 +c20p32: regions 21 pal 21 dHue 1.06/4.58 verts 2520->2573 +c40p16: regions 40 pal 16 dHue 1.06/5.15 verts 2520->2733 +c40p24: regions 40 pal 24 dHue 0.53/3.58 verts 2520->2781 +c40p32: regions 40 pal 32 dHue 0.50/2.37 verts 2520->2781 +c80p16: regions 80 pal 16 dHue 1.16/16.86 verts 2520->3145 +c80p24: regions 80 pal 24 dHue 0.67/16.86 verts 2520->3163 +c80p32: regions 80 pal 32 dHue 0.54/16.86 verts 2520->3164 diff --git a/AgentScripts/WL814x_ITER2.txt b/AgentScripts/WL814x_ITER2.txt new file mode 100644 index 000000000..729764c47 --- /dev/null +++ b/AgentScripts/WL814x_ITER2.txt @@ -0,0 +1,3 @@ +setup: bounds (0.648, 1.217, 0.907) / screen H 117 px / pose yaw 240 +face P32=True P64=True V1=True hair P64=True +best: regions 40 pal 16 dHue 1.06/5.15 verts 2520->2733 diff --git a/AgentScripts/WL814x_PROBE.txt b/AgentScripts/WL814x_PROBE.txt new file mode 100644 index 000000000..c0f7f421b --- /dev/null +++ b/AgentScripts/WL814x_PROBE.txt @@ -0,0 +1,47 @@ +=== LH_M05.prefab 렌더러 === +[R] Arm_m05 type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Arm_m05 path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=468 sub=1 tris=752 blendshapes=0 bones=63 bindposes=63 isReadable=True + uv0 range x[0.395,0.661] y[0.650,0.974] uv2=False colors=False tangents=True normals=True + mat[0] M05_ToonS shader=Shader Graphs/Toon path=Assets/WL/Look/Character/Materials/M05_ToonS.mat base=Assets/WL/Look/Character/Textures/M05_WL.png shadowbase=Assets/WL/Look/Character/Textures/M05_WL.png +[R] Body_m05 type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Body_m05 path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=604 sub=1 tris=984 blendshapes=0 bones=40 bindposes=40 isReadable=True + uv0 range x[0.002,0.998] y[0.002,0.919] uv2=False colors=False tangents=True normals=True + mat[0] M05_ToonS shader=Shader Graphs/Toon path=Assets/WL/Look/Character/Materials/M05_ToonS.mat base=Assets/WL/Look/Character/Textures/M05_WL.png shadowbase=Assets/WL/Look/Character/Textures/M05_WL.png +[R] Face type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Face path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=121 sub=1 tris=212 blendshapes=0 bones=1 bindposes=1 isReadable=True + uv0 range x[0.030,0.970] y[0.273,0.766] uv2=False colors=False tangents=True normals=True + mat[0] face02_ToonS shader=Unlit/Transparent path=Assets/WL/Look/Character/Materials/face02_ToonS.mat base=Assets/WL/Look/Character/Textures/face02_WL.png shadowbase=- +[R] Hair05 type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Hair05 path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=611 sub=1 tris=914 blendshapes=0 bones=1 bindposes=1 isReadable=True + uv0 range x[0.002,0.998] y[0.002,0.995] uv2=False colors=False tangents=True normals=True + mat[0] Hair05_ToonS shader=Shader Graphs/Toon path=Assets/WL/Look/Character/Materials/Hair05_ToonS.mat base=Assets/WL/Look/Character/Textures/Hair05_WL.png shadowbase=Assets/WL/Look/Character/Textures/Hair05_WL.png +[R] Head type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Head path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=339 sub=1 tris=582 blendshapes=0 bones=1 bindposes=1 isReadable=True + uv0 range x[0.643,0.998] y[0.057,0.603] uv2=False colors=False tangents=True normals=True + mat[0] Skin_ToonS shader=Shader Graphs/Toon path=Assets/WL/Look/Character/Materials/Skin_ToonS.mat base=Assets/WL/Look/Character/Textures/Skin_WL.png shadowbase=Assets/WL/Look/Character/Textures/Skin_WL.png +[R] Leg_M05 type=SkinnedMeshRenderer enabled=True activeSelf=True + mesh=Leg_M05 path=Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx verts=498 sub=1 tris=844 blendshapes=0 bones=29 bindposes=29 isReadable=True + uv0 range x[0.002,0.514] y[0.400,0.816] uv2=False colors=False tangents=True normals=True + mat[0] M05_ToonS shader=Shader Graphs/Toon path=Assets/WL/Look/Character/Materials/M05_ToonS.mat base=Assets/WL/Look/Character/Textures/M05_WL.png shadowbase=Assets/WL/Look/Character/Textures/M05_WL.png +[R] Sword_M05 type=MeshRenderer enabled=True activeSelf=False + mesh=Sword_M05 path=Assets/Suriyun/Characters/RedKnight/FBX/Weapon/Sword_M05.fbx verts=188 sub=1 tris=140 blendshapes=0 bones=0 bindposes=0 isReadable=True + uv0 range x[0.009,0.423] y[0.006,0.988] uv2=False colors=False tangents=True normals=True + mat[0] SwordM05 shader=Toon/Toon path=Assets/Suriyun/Characters/RedKnight/Materials/Weapon/SwordM05.mat base=- shadowbase=- + +=== 원본 텍스처 임포트 === +Assets/WL/Look/Character/Textures/M05_WL.png : maxSize=512 filter=Bilinear mip=True sRGB=True readable=True npot=ToNearest actual=512x512 fmt=ASTC_6x6 +Assets/WL/Look/Character/Textures/Skin_WL.png : maxSize=512 filter=Bilinear mip=False sRGB=True readable=True npot=None actual=512x512 fmt=ASTC_6x6 +Assets/WL/Look/Character/Textures/Hair05_WL.png : maxSize=512 filter=Bilinear mip=True sRGB=True readable=True npot=ToNearest actual=512x512 fmt=ASTC_6x6 +Assets/WL/Look/Character/Textures/face02_WL.png : maxSize=512 filter=Bilinear mip=True sRGB=True readable=True npot=ToNearest actual=512x512 fmt=ASTC_6x6 +Assets/WL/Look/Character/Textures/face02_V1.png : maxSize=512 filter=Point mip=True sRGB=True readable=True npot=None actual=512x512 fmt=RGBA32 +Assets/WL/Look/Character/Textures/Hair05_V1.png : maxSize=512 filter=Point mip=True sRGB=True readable=True npot=None actual=512x512 fmt=RGB24 + +=== SO 현재값 === +enabled_=1 mode=3 n=4 + [0] orig=M05 m1=M05_Toon m2=M05_ToonS m3=M05_ToonF + [1] orig=skin m1=Skin_Toon m2=Skin_ToonS m3=Skin_ToonF + [2] orig=Hair05 m1=Hair05_Toon m2=Hair05_ToonS m3=Hair05_ToonF + [3] orig=face02 m1=face02 m2=face02_ToonS m3=face02_ToonF + +=== 아레나 씬 카메라/캐릭터 === +scene=Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity diff --git a/AgentScripts/WL814x_Probe.cs b/AgentScripts/WL814x_Probe.cs new file mode 100644 index 000000000..73e8d50e5 --- /dev/null +++ b/AgentScripts/WL814x_Probe.cs @@ -0,0 +1,93 @@ +// WL-814x 프로브 1 — 메시·머티리얼·텍스처 실측 (팔레트 아틀라스 UV 설계 근거) +// run_script --file AgentScripts/WL814x_Probe.cs --entry WL814x_Probe.Run + +public static class WL814x_Probe +{ + const string Out = @"E:\NerdNavis\WL_wt\gameplay\AgentScripts\WL814x_PROBE.txt"; + + public static string Run() + { + var sb = new System.Text.StringBuilder(); + var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/PC/LH_M05.prefab"); + if (prefab == null) return "no prefab"; + + sb.AppendLine("=== LH_M05.prefab 렌더러 ==="); + var rs = prefab.GetComponentsInChildren(true); + foreach (var r in rs) + { + var smr = r as UnityEngine.SkinnedMeshRenderer; + var mf = r.GetComponent(); + UnityEngine.Mesh m = smr != null ? smr.sharedMesh : (mf != null ? mf.sharedMesh : null); + sb.AppendLine("[R] " + r.name + " type=" + r.GetType().Name + " enabled=" + r.enabled + " activeSelf=" + r.gameObject.activeSelf); + if (m != null) + { + string path = UnityEditor.AssetDatabase.GetAssetPath(m); + sb.AppendLine(" mesh=" + m.name + " path=" + path + " verts=" + m.vertexCount + " sub=" + m.subMeshCount + + " tris=" + (m.triangles.Length / 3) + " blendshapes=" + m.blendShapeCount + + " bones=" + (smr != null && smr.bones != null ? smr.bones.Length : 0) + + " bindposes=" + m.bindposes.Length + " isReadable=" + m.isReadable); + var uv = m.uv; + if (uv != null && uv.Length > 0) + { + float mnx = 9e9f, mxx = -9e9f, mny = 9e9f, mxy = -9e9f; + for (int i = 0; i < uv.Length; i++) { if (uv[i].x < mnx) mnx = uv[i].x; if (uv[i].x > mxx) mxx = uv[i].x; if (uv[i].y < mny) mny = uv[i].y; if (uv[i].y > mxy) mxy = uv[i].y; } + sb.AppendLine(" uv0 range x[" + mnx.ToString("F3") + "," + mxx.ToString("F3") + "] y[" + mny.ToString("F3") + "," + mxy.ToString("F3") + "]" + + " uv2=" + (m.uv2 != null && m.uv2.Length > 0) + " colors=" + (m.colors != null && m.colors.Length > 0) + + " tangents=" + (m.tangents != null && m.tangents.Length > 0) + " normals=" + (m.normals != null && m.normals.Length > 0)); + } + } + var mats = r.sharedMaterials; + for (int i = 0; i < mats.Length; i++) + { + var mt = mats[i]; + if (mt == null) { sb.AppendLine(" mat[" + i + "] = null"); continue; } + var tex = mt.HasProperty("_BaseMap") ? mt.GetTexture("_BaseMap") : (mt.HasProperty("_MainTex") ? mt.GetTexture("_MainTex") : null); + var stex = mt.HasProperty("_ShadowBaseMap") ? mt.GetTexture("_ShadowBaseMap") : null; + sb.AppendLine(" mat[" + i + "] " + mt.name + " shader=" + mt.shader.name + + " path=" + UnityEditor.AssetDatabase.GetAssetPath(mt) + + " base=" + (tex != null ? UnityEditor.AssetDatabase.GetAssetPath(tex) : "-") + + " shadowbase=" + (stex != null ? UnityEditor.AssetDatabase.GetAssetPath(stex) : "-")); + } + } + + sb.AppendLine(); + sb.AppendLine("=== 원본 텍스처 임포트 ==="); + string[] texPaths = { + "Assets/WL/Look/Character/Textures/M05_WL.png", + "Assets/WL/Look/Character/Textures/Skin_WL.png", + "Assets/WL/Look/Character/Textures/Hair05_WL.png", + "Assets/WL/Look/Character/Textures/face02_WL.png", + "Assets/WL/Look/Character/Textures/face02_V1.png", + "Assets/WL/Look/Character/Textures/Hair05_V1.png", + }; + foreach (var p in texPaths) + { + var ti = UnityEditor.AssetImporter.GetAtPath(p) as UnityEditor.TextureImporter; + var t = UnityEditor.AssetDatabase.LoadAssetAtPath(p); + if (ti == null) { sb.AppendLine(p + " : 없음"); continue; } + sb.AppendLine(p + " : maxSize=" + ti.maxTextureSize + " filter=" + ti.filterMode + " mip=" + ti.mipmapEnabled + + " sRGB=" + ti.sRGBTexture + " readable=" + ti.isReadable + " npot=" + ti.npotScale + + " actual=" + (t != null ? t.width + "x" + t.height : "?") + " fmt=" + (t != null ? t.format.ToString() : "?")); + } + + sb.AppendLine(); + sb.AppendLine("=== SO 현재값 ==="); + var so = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/WL/Look/Character/Resources/WL/WLCharacterLookSettings.asset"); + if (so != null) + { + sb.AppendLine("enabled_=" + so.enabled_ + " mode=" + so.mode + " n=" + (so.originals != null ? so.originals.Length : 0)); + for (int i = 0; i < (so.originals != null ? so.originals.Length : 0); i++) + sb.AppendLine(" [" + i + "] orig=" + (so.originals[i] != null ? so.originals[i].name : "-") + + " m1=" + (so.mode1 != null && i < so.mode1.Length && so.mode1[i] != null ? so.mode1[i].name : "-") + + " m2=" + (so.mode2 != null && i < so.mode2.Length && so.mode2[i] != null ? so.mode2[i].name : "-") + + " m3=" + (so.mode3 != null && i < so.mode3.Length && so.mode3[i] != null ? so.mode3[i].name : "-")); + } + + sb.AppendLine(); + sb.AppendLine("=== 아레나 씬 카메라/캐릭터 ==="); + sb.AppendLine("scene=Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity"); + + System.IO.File.WriteAllText(Out, sb.ToString()); + return "written " + Out + " (" + sb.Length + " chars)"; + } +} diff --git a/AgentScripts/WL814x_Run.cs b/AgentScripts/WL814x_Run.cs new file mode 100644 index 000000000..16d703a1c --- /dev/null +++ b/AgentScripts/WL814x_Run.cs @@ -0,0 +1,783 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WL814x_Run.cs — WL-814x 팔레트 아틀라스 UV R&D 하니스 (에디터 전용) +// WLXCap : 캡처/측정 (카메라 조건 = 814v 와 동일 · 레이어 31 단독 · 직교 10 · 1080×1920 · 포스트 off) +// WLXBake : 메시 UV 를 팔레트 칸 중심으로 굽는다 (원본 FBX/PNG 수정 0 · 메시 복사본) +// WLXIter : 반복 회차 드라이버 +// 🔴 씬 저장 0 · 원본 에셋 수정 0 · ProjectSettings 0 +// ───────────────────────────────────────────────────────────────────────────── +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Unity.Collections; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering.Universal; +using WL.Look.Arena; +using WL.Look.Character; + +public static class WLXCap +{ + public const string SCENE = "Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity"; + public const string RAW = "Screenshots_WL/WL814x/raw/"; + const int LAYER = 31; + public const int W = 1080, H = 1920; + + static readonly StringBuilder sb = new StringBuilder(); + public static void L(string s) { sb.AppendLine(s); } + public static void Flush(string file) { Directory.CreateDirectory("AgentScripts"); File.WriteAllText("AgentScripts/" + file, sb.ToString(), new UTF8Encoding(true)); } + + public static GameObject Root; + public static SkinnedMeshRenderer[] Smrs; + public static Camera Cam; + static GameObject s_camGo; + static Dictionary s_layers; + static Material[][] s_origMats; + static Mesh[] s_origMeshes; + + public static void Setup() + { + EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single); + Root = null; + foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) + if (go.name.Contains("LH_M05")) { Root = go; break; } + Smrs = Root.GetComponentsInChildren(true).Where(r => r.enabled).ToArray(); + + var scam = Camera.main ?? UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).First(c => c.orthographic); + float camYaw = scam.transform.eulerAngles.y; + Root.transform.rotation = Quaternion.Euler(0, camYaw + 180f, 0); // 814u/814v 와 같은 포즈 + + s_layers = new Dictionary(); + foreach (var t in Root.GetComponentsInChildren(true)) { s_layers[t] = t.gameObject.layer; t.gameObject.layer = LAYER; } + + s_origMats = Smrs.Select(r => r.sharedMaterials).ToArray(); + s_origMeshes = Smrs.Select(r => r.sharedMesh).ToArray(); + + var b = new Bounds(Root.transform.position, Vector3.zero); + foreach (var r in Smrs) b.Encapsulate(r.bounds); + + s_camGo = new GameObject("WLXCap"); + Cam = s_camGo.AddComponent(); Cam.CopyFrom(scam); + Cam.orthographic = true; Cam.orthographicSize = 10f; Cam.aspect = (float)W / H; + Cam.targetTexture = null; Cam.cullingMask = 1 << LAYER; + Cam.transform.SetPositionAndRotation(b.center - scam.transform.forward * 50f, scam.transform.rotation); + var ucd = Cam.GetUniversalAdditionalCameraData(); if (ucd != null) { ucd.renderPostProcessing = false; ucd.SetRenderer(0); } + for (int i = 0; i < 3; i++) Render(Color.black); + L("setup: bounds " + b.size.ToString("F3") + " / screen H " + (b.size.y / 20f * H).ToString("F0") + " px / pose yaw " + (camYaw + 180f).ToString("F0")); + } + + public static void Teardown() + { + WLReferenceLook.Restore(); + if (Smrs != null) + for (int i = 0; i < Smrs.Length; i++) { Smrs[i].sharedMaterials = s_origMats[i]; Smrs[i].sharedMesh = s_origMeshes[i]; Smrs[i].enabled = true; } + if (s_layers != null) foreach (var kv in s_layers) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value; + if (s_camGo != null) UnityEngine.Object.DestroyImmediate(s_camGo); + s_camGo = null; + } + + public class Shot { public Color32[] px; public int w, h; } + + public static Shot Render(Color bg) + { + var rt = new RenderTexture(W, H, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + rt.antiAliasing = 1; rt.filterMode = FilterMode.Point; + var pA = RenderTexture.active; var pC = Cam.clearFlags; var pB = Cam.backgroundColor; + Cam.clearFlags = CameraClearFlags.SolidColor; Cam.backgroundColor = bg; Cam.targetTexture = rt; Cam.Render(); + RenderTexture.active = rt; + var t = new Texture2D(W, H, TextureFormat.RGBA32, false); t.ReadPixels(new Rect(0, 0, W, H), 0, 0); t.Apply(); + Cam.targetTexture = null; RenderTexture.active = pA; Cam.clearFlags = pC; Cam.backgroundColor = pB; + var s = new Shot { px = t.GetPixels32(), w = W, h = H }; + UnityEngine.Object.DestroyImmediate(t); rt.Release(); UnityEngine.Object.DestroyImmediate(rt); + return s; + } + + public static void Save(Shot s, string path) + { + var t = new Texture2D(s.w, s.h, TextureFormat.RGBA32, false); t.SetPixels32(s.px); t.Apply(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, t.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(t); + } + + public static void Shoot(string name) { Save(Render(new Color(0.10f, 0.10f, 0.12f)), RAW + name + ".png"); } + + public static void SaveMask(string name, params SkinnedMeshRenderer[] on) + { + foreach (var r in Smrs) r.enabled = on.Contains(r); + var b = Render(Color.black); var w = Render(Color.white); + var t = new Texture2D(W, H, TextureFormat.RGBA32, false); + var px = new Color32[W * H]; int cnt = 0; + for (int i = 0; i < px.Length; i++) + { + int d = Mathf.Max(Mathf.Abs(b.px[i].r - w.px[i].r), Mathf.Max(Mathf.Abs(b.px[i].g - w.px[i].g), Mathf.Abs(b.px[i].b - w.px[i].b))); + bool hit = d < 128; if (hit) cnt++; + px[i] = hit ? new Color32(255, 255, 255, 255) : new Color32(0, 0, 0, 255); + } + t.SetPixels32(px); t.Apply(); + Directory.CreateDirectory(RAW); File.WriteAllBytes(RAW + "mask_" + name + ".png", t.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(t); + foreach (var r in Smrs) r.enabled = true; + L("mask " + name + " = " + cnt + " px"); + } + + public static void SaveAllMasks() + { + WLReferenceLook.Restore(); + foreach (var r in Smrs) SaveMask(r.name, r); + SaveMask("all", Smrs); + SaveMask("body3", Smrs.Where(r => r.name == "Body_m05" || r.name == "Arm_m05" || r.name == "Leg_M05").ToArray()); + } + + public static WLReferenceLook.Opt LookOpt(bool outline, bool contrast, bool mood, bool rim, int charShades) + { + var cfg = WLReferenceLookSettings.Instance; + var o = WLReferenceLook.Opt.FromSettings(cfg); + o.outline = outline; o.contrast = contrast; o.mood = mood; o.rim = rim; + if (charShades > 0) o.charShades = charShades; + return o; + } + public static void ApplyLook(WLReferenceLook.Opt o) { WLReferenceLook.Apply(o, WLReferenceLookSettings.Instance); } + public static void NoLook() { WLReferenceLook.Restore(); } + + public static void RestoreMats() { for (int i = 0; i < Smrs.Length; i++) Smrs[i].sharedMaterials = s_origMats[i]; } + public static void RestoreMeshes() { for (int i = 0; i < Smrs.Length; i++) Smrs[i].sharedMesh = s_origMeshes[i]; } + public static Material[] OrigMats(int i) { return s_origMats[i]; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// WLXBake — 팔레트 아틀라스 UV 굽기 +// ① 면마다 원본 텍스처의 실제 샘플 영역 평균색(선형광) +// ② 인접 면을 덩어리로 병합(Ward · 색거리²×면적항) +// ③ 덩어리 색을 팔레트 N색으로 k-means(Lab) → 🔴 Hue 는 원본 그대로 복원 +// ④ 면의 UV 를 팔레트 칸 중심으로 (정점은 (원정점,팔레트칸) 쌍으로만 분리 = 정점 증가 최소) +// ───────────────────────────────────────────────────────────────────────────── +public static class WLXBake +{ + public const string MESH_DIR = "Assets/WL/Look/Character/Meshes/"; + public const string TEX_DIR = "Assets/WL/Look/Character/Textures/"; + const int CELL = 8; + public static bool SaveAssets = true; + + static readonly StringBuilder sb = new StringBuilder(); + static void L(string s) { sb.AppendLine(s); } + + static readonly Dictionary MeshTex = new Dictionary { + { "Arm_m05", TEX_DIR + "M05_WL.png" }, + { "Body_m05", TEX_DIR + "M05_WL.png" }, + { "Leg_M05", TEX_DIR + "M05_WL.png" }, + { "Head", TEX_DIR + "Skin_WL.png" }, + { "Hair05", TEX_DIR + "Hair05_WL.png"}, + }; + + static float S2L(float c) { return c <= 0.04045f ? c / 12.92f : Mathf.Pow((c + 0.055f) / 1.055f, 2.4f); } + static float L2S(float c) { return c <= 0.0031308f ? c * 12.92f : 1.055f * Mathf.Pow(c, 1f / 2.4f) - 0.055f; } + public static Vector3 LinToSrgb(Vector3 v) { return new Vector3(L2S(v.x), L2S(v.y), L2S(v.z)); } + + public struct Lab { public float L, a, b; } + public static Lab ToLab(Vector3 lin) + { + float x = 0.4124f * lin.x + 0.3576f * lin.y + 0.1805f * lin.z; + float y = 0.2126f * lin.x + 0.7152f * lin.y + 0.0722f * lin.z; + float z = 0.0193f * lin.x + 0.1192f * lin.y + 0.9505f * lin.z; + x /= 0.95047f; z /= 1.08883f; + Func f = t => t > 0.008856f ? Mathf.Pow(t, 1f / 3f) : (7.787f * t + 16f / 116f); + float fx = f(x), fy = f(y), fz = f(z); + return new Lab { L = 116f * fy - 16f, a = 500f * (fx - fy), b = 200f * (fy - fz) }; + } + public static float LabD(Lab p, Lab q) { float dl = p.L - q.L, da = p.a - q.a, db = p.b - q.b; return Mathf.Sqrt(dl * dl + da * da + db * db); } + + public static void RgbToHsv(Vector3 srgb, out float h, out float s, out float v) + { + float r = srgb.x, g = srgb.y, b = srgb.z; + float mx = Mathf.Max(r, Mathf.Max(g, b)), mn = Mathf.Min(r, Mathf.Min(g, b)), d = mx - mn; + h = 0f; + if (d > 1e-6f) + { + if (mx == r) h = ((g - b) / d + 6f) % 6f; + else if (mx == g) h = (b - r) / d + 2f; + else h = (r - g) / d + 4f; + } + h *= 60f; s = mx > 1e-6f ? d / mx : 0f; v = mx; + } + static Vector3 HsvToRgb(float h, float s, float v) + { + var c = Color.HSVToRGB(((h % 360f) + 360f) % 360f / 360f, Mathf.Clamp01(s), Mathf.Clamp01(v), false); + return new Vector3(c.r, c.g, c.b); + } + static float DHue(float a, float b) { return (b - a + 540f) % 360f - 180f; } + + class Tex { public int w, h; public Vector3[] lin; } + static readonly Dictionary s_tex = new Dictionary(); + static Tex LoadTex(string path) + { + Tex t; + if (s_tex.TryGetValue(path, out t)) return t; + var tmp = new Texture2D(2, 2, TextureFormat.RGBA32, false); + tmp.LoadImage(File.ReadAllBytes(path)); + var px = tmp.GetPixels32(); + t = new Tex { w = tmp.width, h = tmp.height, lin = new Vector3[px.Length] }; + for (int i = 0; i < px.Length; i++) + t.lin[i] = new Vector3(S2L(px[i].r / 255f), S2L(px[i].g / 255f), S2L(px[i].b / 255f)); + UnityEngine.Object.DestroyImmediate(tmp); + s_tex[path] = t; + return t; + } + static Vector3 Sample(Tex t, float u, float v) + { + int x = Mathf.Clamp(Mathf.FloorToInt(u * t.w), 0, t.w - 1); + int y = Mathf.Clamp(Mathf.FloorToInt(v * t.h), 0, t.h - 1); + return t.lin[y * t.w + x]; + } + + class Face { public int mesh, i0, i1, i2; public Vector3 col; public float area; } + class Region + { + public Vector3 sum; public float area; public int count, mesh; + public float hx, hy, sw; + public Vector3 Col { get { return sum / Mathf.Max(area, 1e-9f); } } + } + + public class Result + { + public Dictionary meshes = new Dictionary(); + public Vector3[] palette; + public int regionCount, paletteUsed; + public float meanDHue, maxDHue; + public int vertsBefore, vertsAfter; + public string tag; + public string palPath; + } + public static Result Last; + + public static string Bake(int chunkTarget, int paletteN, string tag) + { + sb.Length = 0; + L("=== bake chunks=" + chunkTarget + " palette=" + paletteN + " tag=" + tag + " ==="); + var prefab = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/PC/LH_M05.prefab"); + var smrs = prefab.GetComponentsInChildren(true).Where(r => MeshTex.ContainsKey(r.name)).ToArray(); + var names = smrs.Select(r => r.name).ToArray(); + var srcs = smrs.Select(r => r.sharedMesh).ToArray(); + int M = srcs.Length; + + var faces = new List(); + var start = new int[M + 1]; + var vtxOf = new List(); var triOf = new List(); + for (int m = 0; m < M; m++) + { + start[m] = faces.Count; + var mesh = srcs[m]; var vt = mesh.vertices; var uv = mesh.uv; var tr = mesh.triangles; + vtxOf.Add(vt); triOf.Add(tr); + var tex = LoadTex(MeshTex[names[m]]); + for (int t = 0; t < tr.Length; t += 3) + { + int a = tr[t], b = tr[t + 1], c = tr[t + 2]; + faces.Add(new Face + { + mesh = m, i0 = a, i1 = b, i2 = c, + area = Vector3.Cross(vt[b] - vt[a], vt[c] - vt[a]).magnitude * 0.5f, + col = TriAvg(tex, uv[a], uv[b], uv[c]) + }); + } + } + start[M] = faces.Count; + L("faces " + faces.Count); + + var adj = BuildAdjacency(faces, start, vtxOf, M); + var regions = new Region[faces.Count]; + var uf = new int[faces.Count]; + for (int i = 0; i < faces.Count; i++) + { + uf[i] = i; + var r = new Region { sum = faces[i].col * faces[i].area, area = faces[i].area, count = 1, mesh = faces[i].mesh }; + AccHue(r, faces[i].col, faces[i].area); + regions[i] = r; + } + int regionCount = Merge(uf, regions, adj, chunkTarget, M, faces); + L("regions " + regionCount + " (target " + chunkTarget + ")"); + for (int m = 0; m < M; m++) + { + var seen = new HashSet(); + for (int i = start[m]; i < start[m + 1]; i++) seen.Add(Find(uf, i)); + L(" " + names[m] + " : " + seen.Count); + } + + var roots = new List(); + { var seen = new HashSet(); for (int i = 0; i < faces.Count; i++) { int r = Find(uf, i); if (seen.Add(r)) roots.Add(r); } } + int N = Mathf.Min(paletteN, roots.Count); + var pal = KMeans(roots.Select(r => regions[r].Col).ToArray(), roots.Select(r => regions[r].area).ToArray(), N); + var assign = new int[roots.Count]; + for (int i = 0; i < roots.Count; i++) + { + var lab = ToLab(regions[roots[i]].Col); + float bd = float.MaxValue; int bj = 0; + for (int j = 0; j < N; j++) { float d = LabD(lab, ToLab(pal[j])); if (d < bd) { bd = d; bj = j; } } + assign[i] = bj; + } + // 🔴 Hue 복원: 팔레트 칸의 H/S 를 그 칸 소속 덩어리들의 원본 H/S 로 되돌린다(채도·면적 가중) + var palS = new Vector3[N]; + for (int j = 0; j < N; j++) + { + double hx = 0, hy = 0, sw = 0, ssum = 0, aw = 0; + for (int i = 0; i < roots.Count; i++) + { + if (assign[i] != j) continue; + var reg = regions[roots[i]]; + hx += reg.hx; hy += reg.hy; sw += reg.sw; + float h, s, v; RgbToHsv(LinToSrgb(reg.Col), out h, out s, out v); + ssum += s * reg.area; aw += reg.area; + } + float ph, ps, pv; RgbToHsv(LinToSrgb(pal[j]), out ph, out ps, out pv); + float hue = sw > 1e-6 ? (float)((Math.Atan2(hy, hx) * 180.0 / Math.PI + 360.0) % 360.0) : ph; + float sat = aw > 1e-6 ? (float)(ssum / aw) : ps; + palS[j] = HsvToRgb(hue, sat, pv); + } + double dsum = 0, dw = 0; float dmax = 0; + for (int i = 0; i < roots.Count; i++) + { + var reg = regions[roots[i]]; + float h0, s0, v0, h1, s1, v1; + RgbToHsv(LinToSrgb(reg.Col), out h0, out s0, out v0); + RgbToHsv(palS[assign[i]], out h1, out s1, out v1); + if (s0 < 0.08f) continue; + float d = Mathf.Abs(DHue(h0, h1)); + dsum += d * reg.area; dw += reg.area; if (d > dmax) dmax = d; + } + float meanD = dw > 1e-6 ? (float)(dsum / dw) : 0f; + L(string.Format("palette {0} · dHue mean {1:F2} max {2:F2}", N, meanD, dmax)); + + int G = Mathf.CeilToInt(Mathf.Sqrt(N)); + int S = G * CELL; + string palPath = TEX_DIR + "WLPalette_" + tag + ".png"; + WritePalettePng(palS, N, G, S, palPath); + + var rootIndex = new Dictionary(); + for (int i = 0; i < roots.Count; i++) rootIndex[roots[i]] = i; + var res = new Result { palette = palS, regionCount = regionCount, paletteUsed = N, meanDHue = meanD, maxDHue = dmax, tag = tag, palPath = palPath }; + if (SaveAssets) Directory.CreateDirectory(MESH_DIR); + for (int m = 0; m < M; m++) + { + var nm = BuildMesh(srcs[m], faces, start[m], start[m + 1], uf, rootIndex, assign, G); + nm.name = names[m] + "_PAL_" + tag; + res.vertsBefore += srcs[m].vertexCount; res.vertsAfter += nm.vertexCount; + if (SaveAssets) + { + string p = MESH_DIR + nm.name + ".asset"; + if (AssetDatabase.LoadAssetAtPath(p) != null) AssetDatabase.DeleteAsset(p); + AssetDatabase.CreateAsset(nm, p); + } + res.meshes[names[m]] = nm; + L(string.Format(" mesh {0,-9} verts {1,4} -> {2,4}", names[m], srcs[m].vertexCount, nm.vertexCount)); + } + if (SaveAssets) AssetDatabase.SaveAssets(); + Last = res; + File.WriteAllText("AgentScripts/WL814x_BAKE_" + tag + ".txt", sb.ToString(), new UTF8Encoding(true)); + return sb.ToString(); + } + + static void WritePalettePng(Vector3[] palS, int N, int G, int S, string path) + { + var ptex = new Texture2D(S, S, TextureFormat.RGBA32, false); + var ppx = new Color32[S * S]; + for (int i = 0; i < ppx.Length; i++) ppx[i] = new Color32(255, 0, 255, 255); + for (int j = 0; j < N; j++) + { + int gx = j % G, gy = j / G; + var c = new Color32((byte)Mathf.RoundToInt(Mathf.Clamp01(palS[j].x) * 255f), + (byte)Mathf.RoundToInt(Mathf.Clamp01(palS[j].y) * 255f), + (byte)Mathf.RoundToInt(Mathf.Clamp01(palS[j].z) * 255f), 255); + for (int y = 0; y < CELL; y++) for (int x = 0; x < CELL; x++) ppx[(gy * CELL + y) * S + gx * CELL + x] = c; + } + ptex.SetPixels32(ppx); ptex.Apply(); + Directory.CreateDirectory(TEX_DIR); + File.WriteAllBytes(path, ptex.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(ptex); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); + var ti = (TextureImporter)AssetImporter.GetAtPath(path); + ti.textureType = TextureImporterType.Default; + ti.mipmapEnabled = false; // 🔴 밉이 있으면 이웃 칸이 섞인다 + ti.filterMode = FilterMode.Point; + ti.wrapMode = TextureWrapMode.Clamp; + ti.sRGBTexture = true; + ti.npotScale = TextureImporterNPOTScale.None; + ti.maxTextureSize = Mathf.Max(32, Mathf.NextPowerOfTwo(S)); + ti.textureCompression = TextureImporterCompression.Uncompressed; + ti.SaveAndReimport(); + } + + static void AccHue(Region r, Vector3 lin, float area) + { + float h, s, v; RgbToHsv(LinToSrgb(lin), out h, out s, out v); + float w = s * area; + r.hx += w * Mathf.Cos(h * Mathf.Deg2Rad); r.hy += w * Mathf.Sin(h * Mathf.Deg2Rad); r.sw += w; + } + + static Vector3 TriAvg(Tex tex, Vector2 a, Vector2 b, Vector2 c) + { + float au = a.x * tex.w, av = a.y * tex.h, bu = b.x * tex.w, bv = b.y * tex.h, cu = c.x * tex.w, cv = c.y * tex.h; + float areaTexels = Mathf.Abs((bu - au) * (cv - av) - (cu - au) * (bv - av)) * 0.5f; + int n = Mathf.Clamp(Mathf.CeilToInt(Mathf.Sqrt(areaTexels)) * 2, 8, 64); + Vector3 sum = Vector3.zero; int cnt = 0; + for (int i = 0; i < n; i++) + for (int j = 0; j < n - i; j++) + { + float w0 = (i + 1f / 3f) / n, w1 = (j + 1f / 3f) / n; + if (1f - w0 - w1 < 0f) continue; + var uv = a * (1f - w0 - w1) + b * w0 + c * w1; + sum += Sample(tex, uv.x, uv.y); cnt++; + } + if (cnt == 0) return Sample(tex, (a.x + b.x + c.x) / 3f, (a.y + b.y + c.y) / 3f); + return sum / cnt; + } + + static List> BuildAdjacency(List faces, int[] start, List vtx, int M) + { + var pairs = new List>(); + for (int m = 0; m < M; m++) + { + var vt = vtx[m]; + var map = new Dictionary(); var weld = new int[vt.Length]; + for (int i = 0; i < vt.Length; i++) + { + long k = Key(vt[i]); int id; + if (!map.TryGetValue(k, out id)) { id = map.Count; map[k] = id; } + weld[i] = id; + } + var edgeMap = new Dictionary(); + for (int f = start[m]; f < start[m + 1]; f++) + { + var fa = faces[f]; + int[] ii = { weld[fa.i0], weld[fa.i1], weld[fa.i2] }; + for (int e = 0; e < 3; e++) + { + int p = ii[e], q = ii[(e + 1) % 3]; + long k = p < q ? ((long)p << 32) | (uint)q : ((long)q << 32) | (uint)p; + int other; + if (edgeMap.TryGetValue(k, out other)) { if (other != f) pairs.Add(new KeyValuePair(other, f)); } + else edgeMap[k] = f; + } + } + } + return pairs; + } + static long Key(Vector3 v) + { + long x = (long)Mathf.RoundToInt(v.x * 10000f) & 0x1FFFFF; + long y = (long)Mathf.RoundToInt(v.y * 10000f) & 0x1FFFFF; + long z = (long)Mathf.RoundToInt(v.z * 10000f) & 0x1FFFFF; + return (x << 42) | (y << 21) | z; + } + static int Find(int[] uf, int i) { while (uf[i] != i) { uf[i] = uf[uf[i]]; i = uf[i]; } return i; } + + static int Merge(int[] uf, Region[] reg, List> adj, int target, int M, List faces) + { + var ver = new int[uf.Length]; + var pq = new SortedSet<(float cost, int a, int b, int va, int vb)>(Comparer<(float, int, int, int, int)>.Create((p, q) => + { + int c = p.Item1.CompareTo(q.Item1); if (c != 0) return c; + c = p.Item2.CompareTo(q.Item2); if (c != 0) return c; + c = p.Item3.CompareTo(q.Item3); if (c != 0) return c; + c = p.Item4.CompareTo(q.Item4); if (c != 0) return c; + return p.Item5.CompareTo(q.Item5); + })); + var nb = new Dictionary>(); + foreach (var e in adj) + { + HashSet sa, sbb; + if (!nb.TryGetValue(e.Key, out sa)) { sa = new HashSet(); nb[e.Key] = sa; } + if (!nb.TryGetValue(e.Value, out sbb)) { sbb = new HashSet(); nb[e.Value] = sbb; } + sa.Add(e.Value); sbb.Add(e.Key); + } + foreach (var kv in nb) foreach (var o in kv.Value) if (kv.Key < o) pq.Add((Cost(reg[kv.Key], reg[o]), kv.Key, o, 0, 0)); + + int count = uf.Length; + var perMesh = new int[M]; + for (int i = 0; i < uf.Length; i++) perMesh[faces[i].mesh]++; + + while (count > target && pq.Count > 0) + { + var e = pq.Min; pq.Remove(e); + int a = Find(uf, e.a), b = Find(uf, e.b); + if (a == b) continue; + int lo = Mathf.Min(a, b), hi = Mathf.Max(a, b); + if (ver[a] != e.va || ver[b] != e.vb) { pq.Add((Cost(reg[a], reg[b]), lo, hi, ver[lo], ver[hi])); continue; } + int mm = reg[a].mesh; + if (perMesh[mm] <= 2) continue; + uf[b] = a; + reg[a].sum += reg[b].sum; reg[a].area += reg[b].area; reg[a].count += reg[b].count; + reg[a].hx += reg[b].hx; reg[a].hy += reg[b].hy; reg[a].sw += reg[b].sw; + ver[a]++; count--; perMesh[mm]--; + HashSet sb2; + if (nb.TryGetValue(b, out sb2)) + { + HashSet sa2; + if (!nb.TryGetValue(a, out sa2)) { sa2 = new HashSet(); nb[a] = sa2; } + foreach (var o in sb2) + { + int r = Find(uf, o); + if (r == a) continue; + sa2.Add(r); + HashSet sr; + if (nb.TryGetValue(r, out sr)) sr.Add(a); + int l2 = Mathf.Min(a, r), h2 = Mathf.Max(a, r); + pq.Add((Cost(reg[a], reg[r]), l2, h2, ver[l2], ver[h2])); + } + } + } + return count; + } + + static float Cost(Region a, Region b) + { + float d = LabD(ToLab(a.Col), ToLab(b.Col)); + float w = (a.area * b.area) / Mathf.Max(a.area + b.area, 1e-9f); + return d * d * w; + } + + static Vector3[] KMeans(Vector3[] cols, float[] wts, int k) + { + int n = cols.Length; + var labs = cols.Select(ToLab).ToArray(); + var cent = new Lab[k]; var centRgb = new Vector3[k]; + var rnd = new System.Random(12345); + var d2 = new float[n]; + int first = 0; float bw = -1; + for (int i = 0; i < n; i++) if (wts[i] > bw) { bw = wts[i]; first = i; } + cent[0] = labs[first]; centRgb[0] = cols[first]; + for (int j = 1; j < k; j++) + { + double tot = 0; + for (int i = 0; i < n; i++) + { + float best = float.MaxValue; + for (int q = 0; q < j; q++) { float d = LabD(labs[i], cent[q]); if (d < best) best = d; } + d2[i] = best * best * wts[i]; tot += d2[i]; + } + double pick = rnd.NextDouble() * tot, acc = 0; int sel = n - 1; + for (int i = 0; i < n; i++) { acc += d2[i]; if (acc >= pick) { sel = i; break; } } + cent[j] = labs[sel]; centRgb[j] = cols[sel]; + } + var asg = new int[n]; + for (int it = 0; it < 80; it++) + { + bool ch = false; + for (int i = 0; i < n; i++) + { + float bd = float.MaxValue; int bj = 0; + for (int j = 0; j < k; j++) { float d = LabD(labs[i], cent[j]); if (d < bd) { bd = d; bj = j; } } + if (asg[i] != bj) { asg[i] = bj; ch = true; } + } + var sumRgb = new Vector3[k]; var sw = new double[k]; + for (int i = 0; i < n; i++) { sumRgb[asg[i]] += cols[i] * wts[i]; sw[asg[i]] += wts[i]; } + for (int j = 0; j < k; j++) if (sw[j] > 1e-9) { centRgb[j] = sumRgb[j] / (float)sw[j]; cent[j] = ToLab(centRgb[j]); } + if (!ch) break; + } + return centRgb; + } + + static Mesh BuildMesh(Mesh src, List faces, int f0, int f1, int[] uf, Dictionary rootIndex, int[] assign, int G) + { + var vt = src.vertices; var nmv = src.normals; var tg = src.tangents; + var bpv = src.GetBonesPerVertex(); var bw = src.GetAllBoneWeights(); + var bwStart = new int[vt.Length]; + { int acc = 0; for (int i = 0; i < vt.Length; i++) { bwStart[i] = acc; acc += (bpv.Length > 0 ? bpv[i] : 0); } } + + var map = new Dictionary(); + var nv = new List(); var nn = new List(); var nt = new List(); + var nuv = new List(); var ntri = new List(); + var nbpv = new List(); var nbw = new List(); + + Func get = (vi, pal) => + { + long k = ((long)vi << 16) | (uint)pal; int id; + if (map.TryGetValue(k, out id)) return id; + id = nv.Count; map[k] = id; + nv.Add(vt[vi]); + if (nmv != null && nmv.Length > 0) nn.Add(nmv[vi]); + if (tg != null && tg.Length > 0) nt.Add(tg[vi]); + nuv.Add(new Vector2((pal % G + 0.5f) / G, (pal / G + 0.5f) / G)); + if (bpv.Length > 0) { nbpv.Add(bpv[vi]); for (int q = 0; q < bpv[vi]; q++) nbw.Add(bw[bwStart[vi] + q]); } + return id; + }; + + for (int f = f0; f < f1; f++) + { + int pal = assign[rootIndex[Find(uf, f)]]; + var fa = faces[f]; + ntri.Add(get(fa.i0, pal)); ntri.Add(get(fa.i1, pal)); ntri.Add(get(fa.i2, pal)); + } + + var mesh = new Mesh(); + mesh.indexFormat = nv.Count > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16; + mesh.SetVertices(nv); + if (nn.Count == nv.Count) mesh.SetNormals(nn); + if (nt.Count == nv.Count) mesh.SetTangents(nt); + mesh.SetUVs(0, nuv); + mesh.SetTriangles(ntri, 0); + if (nbpv.Count == nv.Count) + { + var a1 = new NativeArray(nbpv.ToArray(), Allocator.Temp); + var a2 = new NativeArray(nbw.ToArray(), Allocator.Temp); + mesh.SetBoneWeights(a1, a2); a1.Dispose(); a2.Dispose(); + } + mesh.bindposes = src.bindposes; + mesh.RecalculateBounds(); + return mesh; + } + + // ── 얼굴/머리칼 「점 텍스처」: 814v 안의 격자 1칸 = 1텍셀 로 축소해 화면 1px 이 정확히 1칸을 읽게 한다. + public static string MakePointTex(string srcPng, int outSize, string outName, bool alpha) + { + var tmp = new Texture2D(2, 2, TextureFormat.RGBA32, false); + tmp.LoadImage(File.ReadAllBytes(srcPng)); + int f = tmp.width / outSize; + var src = tmp.GetPixels32(); + var dst = new Color32[outSize * outSize]; + for (int y = 0; y < outSize; y++) + for (int x = 0; x < outSize; x++) + dst[y * outSize + x] = src[(y * f + f / 2) * tmp.width + (x * f + f / 2)]; // 칸 중심 텍셀 = 뭉개지 않는다 + var o = new Texture2D(outSize, outSize, TextureFormat.RGBA32, false); + o.SetPixels32(dst); o.Apply(); + string p = TEX_DIR + outName + ".png"; + File.WriteAllBytes(p, o.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(o); UnityEngine.Object.DestroyImmediate(tmp); + AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); + var ti = (TextureImporter)AssetImporter.GetAtPath(p); + ti.textureType = TextureImporterType.Default; + ti.alphaSource = alpha ? TextureImporterAlphaSource.FromInput : TextureImporterAlphaSource.None; + ti.alphaIsTransparency = alpha; + ti.mipmapEnabled = true; + ti.filterMode = FilterMode.Point; + ti.wrapMode = TextureWrapMode.Clamp; + ti.sRGBTexture = true; + ti.npotScale = TextureImporterNPOTScale.None; + ti.maxTextureSize = Mathf.Max(32, outSize); + ti.textureCompression = TextureImporterCompression.Uncompressed; + ti.SaveAndReimport(); + return p; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// WLXIter — 반복 회차 드라이버 +// ───────────────────────────────────────────────────────────────────────────── +public static class WLXIter +{ + static void ApplyPalette(WLXBake.Result res, Texture2D faceTex, Texture2D hairTex) + { + var pal = AssetDatabase.LoadAssetAtPath(res.palPath); + for (int i = 0; i < WLXCap.Smrs.Length; i++) + { + var r = WLXCap.Smrs[i]; + var mats = (Material[])WLXCap.OrigMats(i).Clone(); + Texture2D tex = null; + Mesh nm; + bool usePal = res.meshes.TryGetValue(r.name, out nm); + if (r.name == "Hair05" && hairTex != null) { usePal = false; tex = hairTex; } + if (r.name == "Face") { usePal = false; tex = faceTex; } + if (usePal) { r.sharedMesh = nm; tex = pal; } + if (tex == null) { r.sharedMaterials = mats; continue; } + for (int m = 0; m < mats.Length; m++) + { + if (mats[m] == null) continue; + var c = new Material(mats[m]) { name = "TMP_" + mats[m].name }; + foreach (var pn in new[] { "_BaseMap", "_ShadowBaseMap", "_MainTex" }) if (c.HasProperty(pn)) c.SetTexture(pn, tex); + mats[m] = c; + } + r.sharedMaterials = mats; + } + } + + static readonly int[] CHUNKS = { 20, 40, 80 }; + static readonly int[] PALS = { 16, 24, 32 }; + + /// 1회차 — 덩어리 20/40/80 × 팔레트 16/24/32 전수 비교 (얼굴은 지금 그대로). + public static void Iter1() + { + try + { + WLXCap.Setup(); + WLXCap.SaveAllMasks(); + WLXBake.SaveAssets = false; + var cfg = WLCharacterLookSettings.Instance; + + // 기준 = main 현재 상태(mode3 + 814t 종합안) + WLCharacterLook.ApplyMode(WLXCap.Root, 3, cfg); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, true, 0)); + WLXCap.Shoot("i1_a_base_now"); WLXCap.NoLook(); + + foreach (var ch in CHUNKS) + foreach (var pn in PALS) + { + string tag = "c" + ch + "p" + pn; + WLXBake.Bake(ch, pn, tag); + var res = WLXBake.Last; + WLXCap.L(string.Format("{0}: regions {1} pal {2} dHue {3:F2}/{4:F2} verts {5}->{6}", + tag, res.regionCount, res.paletteUsed, res.meanDHue, res.maxDHue, res.vertsBefore, res.vertsAfter)); + ApplyPalette(res, null, null); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 3)); + WLXCap.Shoot("i1_" + tag); + WLXCap.NoLook(); + WLXCap.RestoreMeshes(); + } + } + catch (Exception e) { WLXCap.L("EXCEPTION " + e); } + finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_ITER1.txt"); } + } + + /// 2회차 — 얼굴 점텍스처(32/64) · 머리칼 팔레트 vs 텍스처 · 셰이드 단수. + public static void Iter2(int chunks, int palN) + { + try + { + WLXCap.Setup(); + WLXBake.SaveAssets = false; + // 얼굴·머리칼 점텍스처 생성(원본 수정 0 · 814v V1 을 격자 1칸 = 1텍셀로 축소) + WLXBake.MakePointTex(WLXBake.TEX_DIR + "face02_V1.png", 32, "face02_P32", true); + WLXBake.MakePointTex(WLXBake.TEX_DIR + "face02_V1.png", 64, "face02_P64", true); + WLXBake.MakePointTex(WLXBake.TEX_DIR + "Hair05_V1.png", 64, "Hair05_P64", false); + WLXBake.MakePointTex(WLXBake.TEX_DIR + "Hair05_V1.png", 32, "Hair05_P32", false); + AssetDatabase.Refresh(); + var f32 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_P32.png"); + var f64 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_P64.png"); + var fV1 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_V1.png"); + var h64 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "Hair05_P64.png"); + var h32 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "Hair05_P32.png"); + WLXCap.L("face P32=" + (f32 != null) + " P64=" + (f64 != null) + " V1=" + (fV1 != null) + " hair P64=" + (h64 != null)); + + WLXBake.Bake(chunks, palN, "best"); + var res = WLXBake.Last; + WLXCap.L(string.Format("best: regions {0} pal {1} dHue {2:F2}/{3:F2} verts {4}->{5}", + res.regionCount, res.paletteUsed, res.meanDHue, res.maxDHue, res.vertsBefore, res.vertsAfter)); + + var faces = new Dictionary { { "fV1", fV1 }, { "fP64", f64 }, { "fP32", f32 } }; + foreach (var kv in faces) + { + ApplyPalette(res, kv.Value, null); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 3)); + WLXCap.Shoot("i2_" + kv.Key); WLXCap.NoLook(); WLXCap.RestoreMeshes(); + } + // 머리칼: 팔레트(=위) vs 텍스처 + foreach (var kv in new Dictionary { { "hairP64", h64 }, { "hairP32", h32 } }) + { + ApplyPalette(res, f32, kv.Value); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 3)); + WLXCap.Shoot("i2_" + kv.Key); WLXCap.NoLook(); WLXCap.RestoreMeshes(); + } + // 셰이드 단수 비교 (얼굴 P32 · 머리 팔레트) + foreach (var sh in new[] { 7, 4, 3, 2 }) + { + ApplyPalette(res, f32, null); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, sh == 7 ? 0 : sh)); + WLXCap.Shoot("i2_shades" + sh); WLXCap.NoLook(); WLXCap.RestoreMeshes(); + } + // 림 켠 것(레퍼런스 종합안 유지) 대조 + ApplyPalette(res, f32, null); + WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, true, 3)); + WLXCap.Shoot("i2_rimon"); WLXCap.NoLook(); WLXCap.RestoreMeshes(); + } + catch (Exception e) { WLXCap.L("EXCEPTION " + e); } + finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_ITER2.txt"); } + } +} diff --git a/AgentScripts/WL814x_metrics.py b/AgentScripts/WL814x_metrics.py new file mode 100644 index 000000000..b543f7ae0 --- /dev/null +++ b/AgentScripts/WL814x_metrics.py @@ -0,0 +1,180 @@ +# WL-814x 측정기 — 렌더 PNG + 마스크 PNG 를 읽어 합격선 6항목을 잰다. +# python AgentScripts/WL814x_metrics.py [파일접두어필터] +# +# 정의(보고서와 동일) +# · 서로 다른 색 비율 = 고유색 수 / 캐릭터 영역 픽셀 수 × 100 +# · 부위당 색 단계 = 그 부위의 색을 RGB 거리 16 이내로 묶었을 때, 면적 90 % 를 덮는 군집 수 +# · 진한 점(눈) = 얼굴 데칼 마스크 안에서 L < 피부평균L × 0.75 인 픽셀 수 (814v 와 동일) +# · Hue 오차 = 부위별 채도가중 원형평균 Hue 의 기준 대비 차 (부호 있는 각도) +import sys, os, glob, json +import numpy as np +from PIL import Image + +RAW = sys.argv[1] if len(sys.argv) > 1 else "Screenshots_WL/WL814x/raw" +FILT = sys.argv[2] if len(sys.argv) > 2 else "" +PARTS = ["all", "body3", "Body_m05", "Arm_m05", "Leg_M05", "Head", "Hair05", "Face"] + + +def load(p): + return np.asarray(Image.open(p).convert("RGB"), dtype=np.uint8) + + +def masks(): + m = {} + for p in PARTS: + f = os.path.join(RAW, "mask_%s.png" % p) + if os.path.exists(f): + m[p] = load(f)[:, :, 0] > 127 + return m + + +def lum(px): + return 0.299 * px[:, 0] + 0.587 * px[:, 1] + 0.114 * px[:, 2] + + +def hsv(px): + a = px.astype(np.float32) + mx = a.max(1); mn = a.min(1); d = mx - mn + h = np.zeros(len(a), np.float32) + nz = d > 1e-6 + r, g, b = a[:, 0], a[:, 1], a[:, 2] + i = nz & (mx == r); h[i] = ((g[i] - b[i]) / d[i]) % 6 + i = nz & (mx == g) & ~(mx == r); h[i] = (b[i] - r[i]) / d[i] + 2 + i = nz & (mx == b) & ~(mx == r) & ~(mx == g); h[i] = (r[i] - g[i]) / d[i] + 4 + h *= 60.0 + s = np.where(mx > 1e-6, d / np.maximum(mx, 1e-6), 0) + return h, s, mx + + +def mean_hue(px): + h, s, v = hsv(px) + w = np.where(s > 0.08, s, 0.0) + if w.sum() < 1e-6: + return float("nan") + x = (w * np.cos(np.radians(h))).sum(); y = (w * np.sin(np.radians(h))).sum() + return (np.degrees(np.arctan2(y, x)) + 360.0) % 360.0 + + +def dhue(a, b): + d = (b - a + 180.0) % 360.0 - 180.0 + return d + + +def steps(px, thr=16.0, cover=0.90): + """색을 RGB 거리 thr 이내로 묶어 면적 cover 를 덮는 군집 수.""" + if len(px) == 0: + return 0, 0 + key = (px[:, 0].astype(np.int32) << 16) | (px[:, 1].astype(np.int32) << 8) | px[:, 2].astype(np.int32) + u, cnt = np.unique(key, return_counts=True) + order = np.argsort(-cnt) + u, cnt = u[order], cnt[order] + cols = np.stack([(u >> 16) & 255, (u >> 8) & 255, u & 255], 1).astype(np.float32) + cent = []; wts = [] + for i in range(len(u)): + c = cols[i]; w = cnt[i] + best = -1; bd = 1e9 + for j, cc in enumerate(cent): + d = np.linalg.norm(cc - c) + if d < bd: + bd = d; best = j + if best >= 0 and bd < thr: + n = wts[best] + w + cent[best] = (cent[best] * wts[best] + c * w) / n + wts[best] = n + else: + cent.append(c.copy()); wts.append(float(w)) + if len(cent) > 400: + break + wts = np.array(wts); o = np.argsort(-wts); wts = wts[o] + tot = len(px); acc = 0; k = 0 + for w in wts: + acc += w; k += 1 + if acc >= cover * tot: + break + return k, len(u) + + +def measure(img, msk, ref_hue=None, skinL=None): + px = img[msk] + n = len(px) + if n == 0: + return None + key = (px[:, 0].astype(np.int32) << 16) | (px[:, 1].astype(np.int32) << 8) | px[:, 2].astype(np.int32) + uc = len(np.unique(key)) + st, _ = steps(px) + L = lum(px) + r = dict(px=n, colors=uc, ratio=100.0 * uc / n, steps=st, + lmin=float(L.min()), lmax=float(L.max()), hue=mean_hue(px)) + if ref_hue is not None and not np.isnan(r["hue"]) and not np.isnan(ref_hue): + r["dhue"] = float(dhue(ref_hue, r["hue"])) + if skinL: + r["dark"] = int((L < skinL * 0.75).sum()) + return r + + +def main(): + M = masks() + files = sorted(glob.glob(os.path.join(RAW, "*.png"))) + files = [f for f in files if not os.path.basename(f).startswith("mask_")] + if FILT: + files = [f for f in files if os.path.basename(f).startswith(FILT)] + ref = {} + rows = [] + for f in files: + img = load(f) + name = os.path.splitext(os.path.basename(f))[0] + # 피부 밝기 기준 = Head 마스크에서 Face 를 뺀 영역 평균 L + skinL = None + if "Head" in M and "Face" in M: + hm = M["Head"] & ~M["Face"] + if hm.sum() > 0: + skinL = float(lum(img[hm]).mean()) + rec = {"name": name, "skinL": skinL} + for p in PARTS: + if p not in M: + continue + r = measure(img, M[p], ref.get(p), skinL if p == "Face" else None) + if r is None: + continue + if p not in ref and not np.isnan(r["hue"]): + ref[p] = r["hue"] + # 눈 크기(얼굴 왼쪽 절반의 진한 점 바운딩박스) + if p == "Face" and skinL: + ys, xs = np.where(M[p]) + x0, x1 = xs.min(), xs.max() + half = (x0 + x1) // 2 + sub = M[p].copy(); sub[:, half:] = False + idx = np.where(sub) + if len(idx[0]): + Ls = lum(img[sub]) + d = Ls < skinL * 0.75 + if d.sum() > 0: + yy = idx[0][d]; xx = idx[1][d] + r["eye"] = "%dx%d" % (xx.max() - xx.min() + 1, yy.max() - yy.min() + 1) + rec[p] = r + rows.append(rec) + + hdr = "%-28s %6s %6s %5s | %s | %s | %s" % ( + "render", "전체색", "비율%", "단계", + "색:몸통/피부/머리칼/얼굴", "단계:몸통/피부/머리칼", "얼굴 진한점/눈") + print(hdr); print("-" * 118) + for r in rows: + a = r.get("all"); b3 = r.get("body3"); hd = r.get("Head"); hr = r.get("Hair05"); fc = r.get("Face") + def c(x): return x["colors"] if x else 0 + def s(x): return x["steps"] if x else 0 + print("%-28s %6d %6.1f %5d | %5d/%4d/%4d/%4d | %6d/%3d/%3d | %4s / %s" % ( + r["name"], a["colors"], a["ratio"], a["steps"], + c(b3), c(hd), c(hr), c(fc), s(b3), s(hd), s(hr), + fc.get("dark", "-") if fc else "-", fc.get("eye", "-") if fc else "-")) + print() + print("%-28s %8s %8s %8s %8s" % ("render", "dH몸통", "dH피부", "dH머리칼", "dH얼굴")) + for r in rows: + def g(p): + x = r.get(p) + return ("%+.1f" % x["dhue"]) if (x and "dhue" in x) else "ref" + print("%-28s %8s %8s %8s %8s" % (r["name"], g("body3"), g("Head"), g("Hair05"), g("Face"))) + with open(os.path.join(RAW, "..", "metrics.json"), "w", encoding="utf-8") as fp: + json.dump(rows, fp, ensure_ascii=False, indent=1, default=float) + + +main()