// ───────────────────────────────────────────────────────────────────────────── // 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"); } } }