// ───────────────────────────────────────────────────────────────────────────── // 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) { Render(new Color(0.10f, 0.10f, 0.12f)); Save(Render(new Color(0.10f, 0.10f, 0.12f)), RAW + name + ".png"); } // 1장 버리고 찍는다: 메시를 갈아끼운 직후 첫 프레임은 스킨이 아직 안 올라온다 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 static Vector3 SrgbToLin(Vector3 v) { return new Vector3(S2L(v.x), S2L(v.y), S2L(v.z)); } // 🔴 SrgbAvg = 평균을 sRGB(보이는 밝기)에서 낸다. 선형광 평균은 텍스처의 어두운 선을 // 날려 버려 화면에서 머리칼/피부가 밝고 진해진다(1~3회차 실측: L +20 · S +0.11). public static bool SrgbAvg = true; // RestoreSat = 팔레트 칸의 채도를 「그 칸에 속한 덩어리들의 원본 채도」로 되돌린다. public static bool RestoreSat = true; // 부위 그룹별 채도/명도 보정 계수 (1 = 그대로). Hue 는 절대 건드리지 않는다. public static Dictionary SGain = new Dictionary(); public static Dictionary VGain = new Dictionary(); public static string[] palGroup = new string[0]; public static string Group(string meshName) { if (meshName == "Head") return "Skin"; if (meshName == "Hair05") return "Hair"; return "M05"; } static Vector3 ToWorkSrgb(Vector3 v) { return SrgbAvg ? v : LinToSrgb(v); } static Lab ToLabAuto(Vector3 v) { return ToLab(SrgbAvg ? SrgbToLin(v) : v); } 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] = SrgbAvg ? new Vector3(px[i].r / 255f, px[i].g / 255f, px[i].b / 255f) : 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 = ToLabAuto(regions[roots[i]].Col); float bd = float.MaxValue; int bj = 0; for (int j = 0; j < N; j++) { float d = LabD(lab, ToLabAuto(pal[j])); if (d < bd) { bd = d; bj = j; } } assign[i] = bj; } // 🔴 Hue 복원: 팔레트 칸의 H/S 를 그 칸 소속 덩어리들의 원본 H/S 로 되돌린다(채도·면적 가중) var palS = new Vector3[N]; palGroup = new string[N]; for (int j = 0; j < N; j++) { double hx = 0, hy = 0, sw = 0, ssum = 0, aw = 0; var garea = new double[M]; 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(ToWorkSrgb(reg.Col), out h, out s, out v); ssum += s * reg.area; aw += reg.area; garea[reg.mesh] += reg.area; } float ph, ps, pv; RgbToHsv(ToWorkSrgb(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 = (RestoreSat && aw > 1e-6) ? (float)(ssum / aw) : ps; // 🔴 부위별 채도·명도 보정 — Hue 는 손대지 않는다(발주 §1 「V 만 조절 · Hue ±8°」). // 3~4회차 실측에서 팔레트가 「지금」보다 진하고(S +0.10) 밝게(L +12~23) 나온 것을 되돌린다. int dom = 0; for (int m = 1; m < M; m++) if (garea[m] > garea[dom]) dom = m; string grp = Group(names[dom]); float sg, vg; sat *= SGain.TryGetValue(grp, out sg) ? sg : 1f; pv *= VGain.TryGetValue(grp, out vg) ? vg : 1f; palS[j] = HsvToRgb(hue, sat, pv); palGroup[j] = grp; } 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(ToWorkSrgb(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} · srcBW {3} newBW {4} bindposes {5} normals {6} tangents {7}", names[m], srcs[m].vertexCount, nm.vertexCount, srcs[m].boneWeights.Length, nm.boneWeights.Length, nm.bindposes.Length, nm.normals.Length, nm.tangents.Length)); } 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(ToWorkSrgb(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(ToLabAuto(a.Col), ToLabAuto(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(ToLabAuto).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] = ToLabAuto(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; // 🔴 레거시 boneWeights(4본) 로 옮긴다 — SetBoneWeights(bonesPerVertex, BoneWeight1[]) 로 만든 메시를 // .asset 으로 저장하면 SkinnedMeshRenderer 가 "mesh data size and vertex stride" 로 렌더를 멈춘다(실측). var bws = src.boneWeights; 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 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 (bws != null && bws.Length > 0) nbw.Add(bws[vi]); 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); mesh.bindposes = src.bindposes; if (nbw.Count == nv.Count) mesh.boneWeights = nbw.ToArray(); mesh.UploadMeshData(false); 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"); } } } // ───────────────────────────────────────────────────────────────────────────── // WLXIter3 — 3회차: 얼굴을 고친 상태에서 덩어리·팔레트·셰이드 전수 + 덩어리별 색단계 실측 // ───────────────────────────────────────────────────────────────────────────── public static class WLXIter3 { public static Texture2D F32, F64, FV1, H64, H32; public static void LoadTex() { F32 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_P32.png"); F64 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_P64.png"); FV1 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "face02_V1.png"); H64 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "Hair05_P64.png"); H32 = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "Hair05_P32.png"); } public 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; } } /// 덩어리 ID 를 색으로 찍는다(빛 0) — 「부위(덩어리)당 색 단계」를 재기 위한 지도. public static void ShootIdMap(WLXBake.Result res, string name) { int N = res.paletteUsed, G = Mathf.CeilToInt(Mathf.Sqrt(N)), S = G * 8; var t = new Texture2D(S, S, TextureFormat.RGBA32, false); t.filterMode = FilterMode.Point; t.wrapMode = TextureWrapMode.Clamp; var px = new Color32[S * S]; for (int i = 0; i < px.Length; i++) px[i] = new Color32(0, 0, 0, 255); for (int j = 0; j < N; j++) { int gx = j % G, gy = j / G; var c = new Color32((byte)(8 + j * 7), (byte)(200 - j * 5), (byte)(40 + j * 3), 255); for (int y = 0; y < 8; y++) for (int x = 0; x < 8; x++) px[(gy * 8 + y) * S + gx * 8 + x] = c; } t.SetPixels32(px); t.Apply(); var sh = Shader.Find("Unlit/Texture"); var keep = new List(); WLXCap.NoLook(); for (int i = 0; i < WLXCap.Smrs.Length; i++) { var r = WLXCap.Smrs[i]; Mesh nm; if (res.meshes.TryGetValue(r.name, out nm)) { r.sharedMesh = nm; var m = new Material(sh) { name = "TMP_id" }; m.SetTexture("_MainTex", t); r.sharedMaterials = Enumerable.Repeat(m, WLXCap.OrigMats(i).Length).ToArray(); } else { r.enabled = false; keep.Add(r); } } WLXCap.Shoot(name); foreach (var r in keep) r.enabled = true; UnityEngine.Object.DestroyImmediate(t); } static readonly int[] CH = { 20, 40, 80 }; static readonly int[] PN = { 10, 16, 24 }; public static void Run() { try { WLXCap.Setup(); WLXBake.SaveAssets = false; LoadTex(); WLXCap.L("tex loaded f32=" + (F32 != null) + " h64=" + (H64 != null)); foreach (var ch in CH) foreach (var pn in PN) { 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)); foreach (var sh in new[] { 3, 2 }) { ApplyPalette(res, F32, null); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, sh)); WLXCap.Shoot("i3_" + tag + "_s" + sh); WLXCap.NoLook(); WLXCap.RestoreMeshes(); } // 머리칼 텍스처안 ApplyPalette(res, F32, H64); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 3)); WLXCap.Shoot("i3_" + tag + "_s3_hairtex"); WLXCap.NoLook(); WLXCap.RestoreMeshes(); ShootIdMap(res, "idmap_" + tag); WLXCap.RestoreMats(); WLXCap.RestoreMeshes(); } } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_ITER3.txt"); } } } // ───────────────────────────────────────────────────────────────────────────── // WLXIter4 — 4회차: 팔레트 색을 고르는 「평균 공간」과 「채도 복원」이 화면 색조에 미치는 영향 // 3회차 실측에서 머리칼/피부가 원본보다 밝고(L +20) 진해졌다(S +0.11) → 원인 후보 2개를 A/B // ───────────────────────────────────────────────────────────────────────────── public static class WLXIter4 { public static void Run() { try { WLXCap.Setup(); WLXBake.SaveAssets = false; WLXIter3.LoadTex(); var combos = new[] { new { name = "srgbSat", srgb = true, sat = true }, new { name = "srgbNos", srgb = true, sat = false }, new { name = "linNos", srgb = false, sat = false }, }; foreach (var c in combos) { WLXBake.SrgbAvg = c.srgb; WLXBake.RestoreSat = c.sat; WLXBake.Bake(40, 16, "v_" + c.name); var res = WLXBake.Last; WLXCap.L(string.Format("{0}: regions {1} pal {2} dHue {3:F2}/{4:F2}", c.name, res.regionCount, res.paletteUsed, res.meanDHue, res.maxDHue)); foreach (var sh in new[] { 3, 2 }) { WLXIter3.ApplyPalette(res, WLXIter3.F32, null); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, sh)); WLXCap.Shoot("i4_" + c.name + "_s" + sh); WLXCap.NoLook(); WLXCap.RestoreMeshes(); } } WLXBake.SrgbAvg = true; WLXBake.RestoreSat = true; } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_ITER4.txt"); } } } // ───────────────────────────────────────────────────────────────────────────── // WLXIter5 — 5회차: 「지금」과 색조(채도·밝기)를 맞추는 보정 계수 탐색 (Hue 무변경) // ───────────────────────────────────────────────────────────────────────────── public static class WLXIter5 { static void SetGain(float sM, float sS, float sH, float vM, float vS, float vH) { WLXBake.SGain = new Dictionary { { "M05", sM }, { "Skin", sS }, { "Hair", sH } }; WLXBake.VGain = new Dictionary { { "M05", vM }, { "Skin", vS }, { "Hair", vH } }; } public static void Run() { try { WLXCap.Setup(); WLXBake.SaveAssets = false; WLXIter3.LoadTex(); WLXBake.SrgbAvg = true; WLXBake.RestoreSat = true; var sets = new[] { new { n = "g0none", s = new[]{1f,1f,1f}, v = new[]{1f,1f,1f} }, new { n = "g1soft", s = new[]{0.80f,0.86f,0.85f}, v = new[]{1.00f,0.93f,0.93f} }, new { n = "g2hard", s = new[]{0.72f,0.78f,0.78f}, v = new[]{1.00f,0.86f,0.88f} }, new { n = "g3sat", s = new[]{0.80f,0.86f,0.85f}, v = new[]{1f,1f,1f} }, }; foreach (var g in sets) { SetGain(g.s[0], g.s[1], g.s[2], g.v[0], g.v[1], g.v[2]); WLXBake.Bake(40, 16, "g_" + g.n); var res = WLXBake.Last; WLXCap.L(string.Format("{0}: dHue {1:F2}/{2:F2}", g.n, res.meanDHue, res.maxDHue)); WLXIter3.ApplyPalette(res, WLXIter3.F32, null); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 3)); WLXCap.Shoot("i5_" + g.n + "_s3"); WLXCap.NoLook(); WLXCap.RestoreMeshes(); } SetGain(1f, 1f, 1f, 1f, 1f, 1f); } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_ITER5.txt"); } } } // ───────────────────────────────────────────────────────────────────────────── // WLXFinal — 최종 채택본 굽기 + 에셋 배선 (덩어리 40 · 팔레트 16 · sRGB 평균 · g1soft 보정) // ───────────────────────────────────────────────────────────────────────────── public static class WLXFinal { const string MAT = "Assets/WL/Look/Character/Materials/"; const string TEX = "Assets/WL/Look/Character/Textures/"; const string SO = "Assets/WL/Look/Character/Resources/WL/WLCharacterPaletteSettings.asset"; public const int CHUNKS = 40, PAL = 16; public const float SHADES = 3f; static readonly StringBuilder sb = new StringBuilder(); static void L(string s) { sb.AppendLine(s); } public static void SetFinalGain() { WLXBake.SrgbAvg = true; WLXBake.RestoreSat = true; WLXBake.SGain = new Dictionary { { "M05", 0.80f }, { "Skin", 0.86f }, { "Hair", 0.85f } }; WLXBake.VGain = new Dictionary { { "M05", 1.00f }, { "Skin", 0.93f }, { "Hair", 0.93f } }; } public static string Build() { sb.Length = 0; try { SetFinalGain(); WLXBake.SaveAssets = true; L(WLXBake.Bake(CHUNKS, PAL, "FINAL")); var res = WLXBake.Last; var pal = AssetDatabase.LoadAssetAtPath(res.palPath); // 얼굴 점텍스처(1텍셀 = 1화면 px) — 814v V1 의 격자 1칸을 1텍셀로 string fp = WLXBake.MakePointTex(TEX + "face02_V1.png", 32, "face02_P32", true); var faceTex = AssetDatabase.LoadAssetAtPath(fp); L("face tex " + fp + " " + (faceTex != null ? faceTex.width + "x" + faceTex.height : "?")); // 머티리얼 복사본 var mM05 = MakeMat("M05_ToonS", "M05_ToonP", pal, true); var mSkin = MakeMat("Skin_ToonS", "Skin_ToonP", pal, true); var mHair = MakeMat("Hair05_ToonS", "Hair05_ToonP", pal, true); var mFace = MakeMat("face02_ToonS", "face02_ToonP", faceTex, false); // SO var so = AssetDatabase.LoadAssetAtPath(SO); if (so == null) { so = ScriptableObject.CreateInstance(); Directory.CreateDirectory(Path.GetDirectoryName(SO)); AssetDatabase.CreateAsset(so, SO); } var names = new[] { "Arm_m05", "Body_m05", "Leg_M05", "Head", "Hair05", "Face" }; var meshes = new Mesh[names.Length]; var mats = new Material[names.Length]; for (int i = 0; i < names.Length; i++) { if (names[i] != "Face") meshes[i] = AssetDatabase.LoadAssetAtPath(WLXBake.MESH_DIR + names[i] + "_PAL_FINAL.asset"); mats[i] = names[i] == "Head" ? mSkin : names[i] == "Hair05" ? mHair : names[i] == "Face" ? mFace : mM05; L(string.Format(" [{0}] {1,-9} mesh={2} mat={3}", i, names[i], meshes[i] != null ? meshes[i].name : "-", mats[i] != null ? mats[i].name : "-")); } so.enabled_ = 1; so.verboseLog = 0; so.rendererNames = names; so.paletteMeshes = meshes; so.paletteMaterials = mats; EditorUtility.SetDirty(so); // 임시 산출물 정리 (비교용으로 만든 팔레트/텍스처) foreach (var g in AssetDatabase.FindAssets("WLPalette_", new[] { "Assets/WL/Look/Character/Textures" })) { string p = AssetDatabase.GUIDToAssetPath(g); if (!p.EndsWith("WLPalette_FINAL.png")) { AssetDatabase.DeleteAsset(p); L(" del " + p); } } foreach (var f in new[] { "face02_P64.png", "Hair05_P32.png" }) if (AssetDatabase.LoadAssetAtPath(TEX + f) != null) { AssetDatabase.DeleteAsset(TEX + f); L(" del " + TEX + f); } foreach (var g in AssetDatabase.FindAssets("_PAL_", new[] { "Assets/WL/Look/Character/Meshes" })) { string p = AssetDatabase.GUIDToAssetPath(g); if (!p.Contains("_PAL_FINAL")) { AssetDatabase.DeleteAsset(p); L(" del " + p); } } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); L("DONE"); } catch (Exception e) { L("EXCEPTION " + e); } finally { File.WriteAllText("AgentScripts/WL814x_FINAL.txt", sb.ToString(), new UTF8Encoding(true)); } return sb.ToString(); } static Material MakeMat(string src, string dst, Texture2D tex, bool toon) { var s = AssetDatabase.LoadAssetAtPath(MAT + src + ".mat"); if (s == null) { L("!! no src mat " + src); return null; } var m = AssetDatabase.LoadAssetAtPath(MAT + dst + ".mat"); if (m == null) { m = new Material(s); AssetDatabase.CreateAsset(m, MAT + dst + ".mat"); } else { m.shader = s.shader; m.CopyPropertiesFromMaterial(s); } foreach (var pn in new[] { "_BaseMap", "_ShadowBaseMap", "_MainTex" }) if (m.HasProperty(pn)) m.SetTexture(pn, tex); if (toon && m.HasProperty("_Shades")) m.SetFloat("_Shades", SHADES); // 부위당 색 단계 ≤ 4 EditorUtility.SetDirty(m); L(" mat " + dst + " shader=" + m.shader.name + " tex=" + (tex != null ? tex.name : "-") + (toon ? " shades=" + SHADES : "")); return m; } /// 최종 검증 — 런타임 경로(WLCharacterLook.Apply)로 적용해서 찍는다 + 되돌리기 확인. public static void Verify() { try { WLXCap.Setup(); var lookCfg = WL.Look.Character.WLCharacterLookSettings.Instance; var palCfg = WL.Look.Character.WLCharacterPaletteSettings.Instance; WLXCap.L("palette SO = " + (palCfg != null ? "found enabled_=" + palCfg.enabled_ : "NULL")); // 🔴 워밍업: 세션에서 스킨 메시를 처음 갈아끼운 직후의 첫 Camera.Render() 는 // "mesh data size and vertex stride" 로 렌더가 멈춘다(에디터 한정 · 실측). // 한 번 갈아끼우고 버리는 렌더를 돌린 뒤 본 촬영을 한다. palCfg.enabled_ = 1; WL.Look.Character.WLCharacterLook.Apply(WLXCap.Root); for (int w = 0; w < 3; w++) WLXCap.Render(Color.black); WLXCap.RestoreMeshes(); WLXCap.RestoreMats(); for (int w = 0; w < 2; w++) WLXCap.Render(Color.black); // ① enabled_ = 0 → 814s+814t 상태 100 % 여야 한다 int keep = palCfg.enabled_; palCfg.enabled_ = 0; WL.Look.Character.WLCharacterLook.Apply(WLXCap.Root); WLXCap.L("off: " + WL.Look.Character.WLCharacterPalette.LastLog); for (int i = 0; i < WLXCap.Smrs.Length; i++) WLXCap.L(" off " + WLXCap.Smrs[i].name + " mesh=" + WLXCap.Smrs[i].sharedMesh.name + " mat=" + WLXCap.Smrs[i].sharedMaterial.name); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, true, 0)); WLXCap.Shoot("z_off_now"); WLXCap.NoLook(); // ② enabled_ = 1 → 팔레트 palCfg.enabled_ = 1; WL.Look.Character.WLCharacterLook.Apply(WLXCap.Root); // 🔴 스킨 메시를 갈아끼운 직후 에디터에서 곧바로 Camera.Render() 하면 // "mesh data size and vertex stride" 로 렌더가 멈춘다 → 에디터 틱을 한 번 돌린다. AssetDatabase.Refresh(); EditorApplication.QueuePlayerLoopUpdate(); UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); WLXCap.L("on: " + WL.Look.Character.WLCharacterPalette.LastLog); for (int i = 0; i < WLXCap.Smrs.Length; i++) { var r0 = WLXCap.Smrs[i]; var m0 = r0.sharedMaterial; WLXCap.L(" on " + r0.name + " mesh=" + r0.sharedMesh.name + " mat=" + m0.name + " tris=" + (r0.sharedMesh.triangles.Length / 3) + " verts=" + r0.sharedMesh.vertexCount + " en=" + r0.enabled + " vis=" + r0.isVisible + " shader=" + (m0.shader != null ? m0.shader.name : "NULL") + " base=" + (m0.HasProperty("_BaseMap") ? (m0.GetTexture("_BaseMap") != null ? m0.GetTexture("_BaseMap").name : "NULL") : "-") + " shadow=" + (m0.HasProperty("_ShadowBaseMap") ? (m0.GetTexture("_ShadowBaseMap") != null ? m0.GetTexture("_ShadowBaseMap").name : "NULL") : "-") + " shades=" + (m0.HasProperty("_Shades") ? m0.GetFloat("_Shades").ToString("F1") : "-") + " bnd=" + r0.bounds.size.ToString("F2") + " lay=" + r0.gameObject.layer); } WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 0)); WLXCap.Shoot("z_final"); WLXCap.NoLook(); // 림을 켠 종합안도 한 장(대조) WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, true, 0)); WLXCap.Shoot("z_final_rimon"); WLXCap.NoLook(); // 외곽선 없는 것(외곽선 1px 확인용) WLXCap.ApplyLook(WLXCap.LookOpt(false, true, true, false, 0)); WLXCap.Shoot("z_final_noout"); WLXCap.NoLook(); // 덩어리 지도 WLXFinal.SetFinalGain(); WLXBake.SaveAssets = false; WLXBake.Bake(CHUNKS, PAL, "idm"); WLXIter3.ShootIdMap(WLXBake.Last, "idmap_FINAL"); AssetDatabase.DeleteAsset(WLXBake.Last.palPath); palCfg.enabled_ = keep; } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_VERIFY.txt"); } } /// 회전 떨림 — 연속 8프레임(1°씩)의 픽셀 변화율. 814u 와 같은 축. public static void Jitter() { try { WLXCap.Setup(); var palCfg = WL.Look.Character.WLCharacterPaletteSettings.Instance; float yaw0 = WLXCap.Root.transform.eulerAngles.y; foreach (var on in new[] { 0, 1 }) { palCfg.enabled_ = on; WL.Look.Character.WLCharacterLook.Apply(WLXCap.Root); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, on == 0, 0)); var prev = (WLXCap.Shot)null; double sum = 0; int n = 0; double mx = 0; for (int f = 0; f < 8; f++) { WLXCap.Root.transform.rotation = Quaternion.Euler(0, yaw0 + f, 0); var s = WLXCap.Render(new Color(0.10f, 0.10f, 0.12f)); if (prev != null) { int diff = 0, tot = 0; for (int i = 0; i < s.px.Length; i++) { var a = prev.px[i]; var b = s.px[i]; bool bg = a.r == 25 && a.g == 25 && a.b == 30 && b.r == 25 && b.g == 25 && b.b == 30; if (bg) continue; tot++; if (Mathf.Abs(a.r - b.r) + Mathf.Abs(a.g - b.g) + Mathf.Abs(a.b - b.b) > 12) diff++; } double r = tot > 0 ? 100.0 * diff / tot : 0; sum += r; n++; if (r > mx) mx = r; } prev = s; } WLXCap.L(string.Format("회전 떨림 {0}: 평균 {1:F2} % · 최대 {2:F2} % (8프레임 · 1°/프레임)", on == 0 ? "지금" : "팔레트", sum / Mathf.Max(n, 1), mx)); WLXCap.NoLook(); } WLXCap.Root.transform.rotation = Quaternion.Euler(0, yaw0, 0); palCfg.enabled_ = 1; } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_JITTER.txt"); } } /// 저장된 메시 에셋이 스킨 데이터를 제대로 들고 있는지 확인(렌더 정지 원인 추적). public static string CheckMesh() { var sb2 = new StringBuilder(); foreach (var n in new[] { "Arm_m05", "Body_m05", "Leg_M05", "Head", "Hair05" }) { var p = WLXBake.MESH_DIR + n + "_PAL_FINAL.asset"; var m = AssetDatabase.LoadAssetAtPath(p); var src = AssetDatabase.LoadAllAssetsAtPath("Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx").OfType().FirstOrDefault(x => x.name == n); sb2.AppendLine(n + " : " + (m == null ? "NULL" : "verts " + m.vertexCount + " bw " + m.boneWeights.Length + " bind " + m.bindposes.Length + " attrs [" + string.Join(",", m.GetVertexAttributes().Select(a => a.attribute + ":" + a.format + "x" + a.dimension + "@" + a.stream)) + "]")); if (src != null) sb2.AppendLine(" src: attrs [" + string.Join(",", src.GetVertexAttributes().Select(a => a.attribute + ":" + a.format + "x" + a.dimension + "@" + a.stream)) + "]"); } File.WriteAllText("AgentScripts/WL814x_MESHCHK.txt", sb2.ToString(), new UTF8Encoding(true)); return sb2.ToString(); } /// 메모리 메시 vs 에셋 메시 — 렌더 정지의 원인이 「에셋 저장」인지 가린다. public static void Diag2() { try { WLXCap.Setup(); SetFinalGain(); WLXBake.SaveAssets = false; WLXBake.Bake(CHUNKS, PAL, "memchk"); var res = WLXBake.Last; WLXIter3.ApplyPalette(res, WLXIter3.F32, null); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 0)); WLXCap.Shoot("q_mem"); WLXCap.NoLook(); WLXCap.RestoreMeshes(); WLXCap.RestoreMats(); // 같은 팔레트 텍스처로 에셋 메시만 바꿔 끼운다 var palTex = AssetDatabase.LoadAssetAtPath(res.palPath); foreach (var r in WLXCap.Smrs) { var am = AssetDatabase.LoadAssetAtPath(WLXBake.MESH_DIR + r.name + "_PAL_FINAL.asset"); if (am == null) continue; r.sharedMesh = am; WLXCap.L("asset " + r.name + " verts " + am.vertexCount + " bw " + am.boneWeights.Length + " readable " + am.isReadable); } var finalPal = AssetDatabase.LoadAssetAtPath(WLXBake.TEX_DIR + "WLPalette_FINAL.png"); for (int i = 0; i < WLXCap.Smrs.Length; i++) { var mats = (Material[])WLXCap.OrigMats(i).Clone(); var tex = WLXCap.Smrs[i].name == "Face" ? WLXIter3.F32 : finalPal; for (int m = 0; m < mats.Length; m++) { var c = new Material(mats[m]) { name = "TMP2" }; foreach (var pn in new[] { "_BaseMap", "_ShadowBaseMap", "_MainTex" }) if (c.HasProperty(pn)) c.SetTexture(pn, tex); if (c.HasProperty("_Shades")) c.SetFloat("_Shades", 3f); mats[m] = c; } WLXCap.Smrs[i].sharedMaterials = mats; } WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 0)); WLXCap.Shoot("q_asset"); WLXCap.NoLook(); // 같은 런에서 런타임 경로(SO 머티리얼 + SO 메시)로도 찍어 본다 WLXCap.RestoreMeshes(); WLXCap.RestoreMats(); WL.Look.Character.WLCharacterLook.Apply(WLXCap.Root); WLXCap.L("runtime: " + WL.Look.Character.WLCharacterPalette.LastLog); WLXCap.ApplyLook(WLXCap.LookOpt(true, true, true, false, 0)); WLXCap.Shoot("q_runtime"); WLXCap.NoLook(); AssetDatabase.DeleteAsset(res.palPath); } catch (Exception e) { WLXCap.L("EXCEPTION " + e); } finally { WLXCap.Teardown(); WLXCap.Flush("WL814x_DIAG2.txt"); } } }