// ───────────────────────────────────────────────────────────────────────────── // WL876_NpcPaletteBake.cs — 상인 NPC(Peasant Nolant)를 PC 와 같은 「팔레트 UV(도트) + 툰」 아트풍으로 굽기 (2026-10-15 PD #876) // // PD 「상인 NPC 의 크기를 플레이어 크기를 기준으로 더 크게 키우고 쉐이더나 텍스쳐를 개선해 캐릭터와 같은 아트 풍을 낼 수 있도록 // 수정해줘. (기존 캐릭터 디자인 변경했던 방법과 동일한 방법을 사용하면 됨)」 // // ■ 기존 방법 = WL-814x(PC) · WL-816b(무기) 굽기와 같다 // 면 평균색(원본 텍스처) → 이웃 면을 Lab 색차로 덩어리 병합 → k-means 팔레트 → 칸 H 원본 복원(±8° 클램프) → // 면 UV 를 팔레트 칸 한가운데로(면이 영구 단색) · 팔레트 32×32 · 칸 8 px · Point · 밉 OFF · 무압축 · // 재질 = PC 팔레트 재질 `M05_ToonP`(Shader Graphs/Toon · 외곽선) 사본 + _BaseMap 만 이 팔레트. // ■ 다른 점(스킨 메시) — 정점 복제 때 뼈 가중치·바인드포즈를 그대로 옮긴다(애니메이션 유지). // ■ 얼굴 — PC 와 같은 구성: 피부는 팔레트(머리 = `Skin_ToonP` 처럼 단색), 눈·눈썹·입은 **투명 얼굴 데칼** // (PC `face02_ToonS` = Unlit/Transparent 와 같은 셰이더) — 얼굴 칸(UV 왼쪽 위) 삼각형만 따로 떼어 데칼 메시로, // 원본 텍스처에서 피부와 색차가 큰 픽셀만 남긴 작은 텍스처(256²)를 입힌다. // 🔴 원본(Assets/Stylized NPC - Peasant Nolant/**)은 읽기만 한다 — 산출물은 전부 Assets/WL/Island/NPC/ 아래. // // 실행 = unity command run_script --file AgentScripts/WL876_NpcPaletteBake.cs --entry WL876NpcBake.Run // --args "[\"Yellow\", 40, 6, 16, 10, 24]" (색 · 몸 덩어리 · 모자 덩어리 · 팔레트 칸 · 데칼 색차 시작 · 끝) // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; public static class WL876NpcBake { const string SRC = "Assets/Stylized NPC - Peasant Nolant"; const string OUT = "Assets/WL/Island/NPC/"; const int CELL = 8; const float HUE_LIMIT = 8f; static readonly StringBuilder sb = new StringBuilder(); static void L(string s) { sb.AppendLine(s); } // ───────────────────────────── 색공간 (816b 와 같은 식) static float S2L(float c) { return c <= 0.04045f ? c / 12.92f : Mathf.Pow((c + 0.055f) / 1.055f, 2.4f); } struct Lab { public float L, a, b; } static Lab ToLab(Vector3 srgb) { var lin = new Vector3(S2L(srgb.x), S2L(srgb.y), S2L(srgb.z)); 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) }; } 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); } static void RgbToHsv(Vector3 c, out float h, out float s, out float v) { float r = c.x, g = c.y, b = c.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; } // ───────────────────────────── 텍스처 (디스크 원본 PNG · 무압축) class Tex { public int w, h; public Vector3[] px; } static Tex LoadPng(string path) { var tmp = new Texture2D(2, 2, TextureFormat.RGBA32, false); tmp.LoadImage(File.ReadAllBytes(path)); var p32 = tmp.GetPixels32(); var t = new Tex { w = tmp.width, h = tmp.height, px = new Vector3[p32.Length] }; for (int i = 0; i < p32.Length; i++) t.px[i] = new Vector3(p32[i].r / 255f, p32[i].g / 255f, p32[i].b / 255f); UnityEngine.Object.DestroyImmediate(tmp); 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.px[y * t.w + x]; } static Vector3 SampleBilinear(Tex t, float u, float v) { float x = u * t.w - 0.5f, y = v * t.h - 0.5f; int x0 = Mathf.Clamp(Mathf.FloorToInt(x), 0, t.w - 1), y0 = Mathf.Clamp(Mathf.FloorToInt(y), 0, t.h - 1); int x1 = Mathf.Min(x0 + 1, t.w - 1), y1 = Mathf.Min(y0 + 1, t.h - 1); float fx = Mathf.Clamp01(x - x0), fy = Mathf.Clamp01(y - y0); var a = Vector3.Lerp(t.px[y0 * t.w + x0], t.px[y0 * t.w + x1], fx); var b = Vector3.Lerp(t.px[y1 * t.w + x0], t.px[y1 * t.w + x1], fx); return Vector3.Lerp(a, b, fy); } 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++; } return cnt == 0 ? Sample(tex, (a.x + b.x + c.x) / 3f, (a.y + b.y + c.y) / 3f) : sum / cnt; } // ───────────────────────────── 덩어리 병합 (816b 와 같은 식) class Face { public int i0, i1, i2; public Vector3 col; public float area; } class Region { public Vector3 sum; public float area; public int count; public float hx, hy, sw; public Vector3 Col { get { return sum / Mathf.Max(area, 1e-9f); } } } static long Key(Vector3 v) { long x = (long)Mathf.RoundToInt(v.x * 100000f) & 0x1FFFFF; long y = (long)Mathf.RoundToInt(v.y * 100000f) & 0x1FFFFF; long z = (long)Mathf.RoundToInt(v.z * 100000f) & 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 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 void AccHue(Region r, Vector3 c, float area) { float h, s, v; RgbToHsv(c, 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 int Merge(int[] uf, Region[] reg, List> adj, int target) { 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; 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; } if (count <= 2) break; 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--; 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 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; } // ───────────────────────────── 메시 1개 분석 class MeshWork { public string name; public Mesh src; public Vector3[] vt; public Vector2[] uv; public int[] tr; public List faces; public int[] uf; public Region[] reg; public List roots; public int regions; public int[] assign; // roots 인덱스 → 팔레트 } static MeshWork Analyze(string name, Mesh src, Tex tex, int chunkTarget) { var w = new MeshWork { name = name, src = src, vt = src.vertices, uv = src.uv, tr = src.triangles }; w.faces = new List(w.tr.Length / 3); for (int t = 0; t < w.tr.Length; t += 3) { int a = w.tr[t], b = w.tr[t + 1], c = w.tr[t + 2]; w.faces.Add(new Face { i0 = a, i1 = b, i2 = c, area = Vector3.Cross(w.vt[b] - w.vt[a], w.vt[c] - w.vt[a]).magnitude * 0.5f, col = TriAvg(tex, w.uv[a], w.uv[b], w.uv[c]) }); } var map = new Dictionary(); var weld = new int[w.vt.Length]; for (int i = 0; i < w.vt.Length; i++) { long k = Key(w.vt[i]); int id; if (!map.TryGetValue(k, out id)) { id = map.Count; map[k] = id; } weld[i] = id; } var edgeMap = new Dictionary(); var adj = new List>(); for (int f = 0; f < w.faces.Count; f++) { var fa = w.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) adj.Add(new KeyValuePair(other, f)); } else edgeMap[k] = f; } } w.reg = new Region[w.faces.Count]; w.uf = new int[w.faces.Count]; for (int i = 0; i < w.faces.Count; i++) { w.uf[i] = i; var r = new Region { sum = w.faces[i].col * w.faces[i].area, area = w.faces[i].area, count = 1 }; AccHue(r, w.faces[i].col, w.faces[i].area); w.reg[i] = r; } w.regions = Merge(w.uf, w.reg, adj, chunkTarget); w.roots = new List(); var seen = new HashSet(); for (int i = 0; i < w.faces.Count; i++) { int r = Find(w.uf, i); if (seen.Add(r)) w.roots.Add(r); } return w; } // ───────────────────────────── 팔레트 메시 (스킨 정보 보존) static Mesh BuildPaletteMesh(MeshWork w, int G) { var src = w.src; var vt = w.vt; var nmv = src.normals; var tg = src.tangents; var bwt = src.boneWeights; var rootIndex = new Dictionary(); for (int i = 0; i < w.roots.Count; i++) rootIndex[w.roots[i]] = i; var map = new Dictionary(); var nv = new List(); var nn = new List(); var nt = new List(); var nuv = new List(); var nbw = new List(); var ntri = 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 == vt.Length) nn.Add(nmv[vi]); if (tg != null && tg.Length == vt.Length) nt.Add(tg[vi]); if (bwt != null && bwt.Length == vt.Length) nbw.Add(bwt[vi]); nuv.Add(new Vector2((pal % G + 0.5f) / G, (pal / G + 0.5f) / G)); return id; }; for (int f = 0; f < w.faces.Count; f++) { int pal = w.assign[rootIndex[Find(w.uf, f)]]; var fa = w.faces[f]; ntri.Add(get(fa.i0, pal)); ntri.Add(get(fa.i1, pal)); ntri.Add(get(fa.i2, pal)); } var mesh = new Mesh { name = "WL_Nolant_" + w.name + "_PAL" }; 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 (nbw.Count == nv.Count) mesh.boneWeights = nbw.ToArray(); mesh.bindposes = src.bindposes; mesh.bounds = src.bounds; return mesh; } // ───────────────────────────── 얼굴 데칼 (PC face02 처럼 투명 위 눈·눈썹·입) static Mesh BuildDecalMesh(MeshWork w, List faceTris, Rect uvRect, float offset) { var src = w.src; var vt = w.vt; var nmv = src.normals; var bwt = src.boneWeights; var map = new Dictionary(); var nv = new List(); var nn = new List(); var nuv = new List(); var nbw = new List(); var ntri = new List(); foreach (var f in faceTris) { var fa = w.faces[f]; foreach (var vi in new[] { fa.i0, fa.i1, fa.i2 }) { int id; if (!map.TryGetValue(vi, out id)) { id = nv.Count; map[vi] = id; var n = nmv != null && nmv.Length == vt.Length ? nmv[vi] : Vector3.zero; nv.Add(vt[vi] + n.normalized * offset); // 피부 위로 아주 조금 띄운다(같은 면 z-fighting 방지) nn.Add(n); if (bwt != null && bwt.Length == vt.Length) nbw.Add(bwt[vi]); var u = w.uv[vi]; nuv.Add(new Vector2((u.x - uvRect.xMin) / uvRect.width, (u.y - uvRect.yMin) / uvRect.height)); } ntri.Add(id); } } var mesh = new Mesh { name = "WL_Nolant_FaceDecal" }; mesh.SetVertices(nv); mesh.SetNormals(nn); mesh.SetUVs(0, nuv); mesh.SetTriangles(ntri, 0); if (nbw.Count == nv.Count) mesh.boneWeights = nbw.ToArray(); mesh.bindposes = src.bindposes; mesh.bounds = src.bounds; return mesh; } static void SaveMesh(Mesh m, string path) { if (AssetDatabase.LoadAssetAtPath(path) != null) AssetDatabase.DeleteAsset(path); AssetDatabase.CreateAsset(m, path); } static void ImportPalette(string path, int S) { 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 ImportDecal(string path) { AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); var ti = (TextureImporter)AssetImporter.GetAtPath(path); ti.textureType = TextureImporterType.Default; ti.alphaIsTransparency = true; ti.mipmapEnabled = true; ti.filterMode = FilterMode.Bilinear; ti.wrapMode = TextureWrapMode.Clamp; ti.sRGBTexture = true; ti.maxTextureSize = 256; var ps = ti.GetPlatformTextureSettings("Android"); ps.overridden = true; ps.maxTextureSize = 256; ps.format = TextureImporterFormat.ASTC_6x6; ti.SetPlatformTextureSettings(ps); ti.SaveAndReimport(); } // ───────────────────────────── 진입점 public static string Run(string color, int bodyChunks, int hatChunks, int paletteN, float decalLo, float decalHi) { sb.Length = 0; if (string.IsNullOrEmpty(color)) color = "Yellow"; string prefabPath = SRC + "/Prefabs/Peasant Nolant " + color + "(Free Version).prefab"; string texPath = SRC + "/Textures/Peasant Nolant " + color + ".png"; string variantPath = OUT + "WL_Merchant_Nolant_" + color + ".prefab"; var pf = AssetDatabase.LoadAssetAtPath(prefabPath); if (pf == null || !File.Exists(texPath)) return "원본 없음 — " + prefabPath + " / " + texPath; var baseMat = AssetDatabase.LoadAssetAtPath("Assets/WL/Look/Character/Materials/M05_ToonP.mat"); var faceMat = AssetDatabase.LoadAssetAtPath("Assets/WL/Look/Character/Materials/face02_ToonS.mat"); if (baseMat == null || faceMat == null) return "기준 재질(M05_ToonP · face02_ToonS) 없음"; var tex = LoadPng(texPath); L("=== WL #876 상인 NPC 팔레트 굽기 — " + color + " · 몸 덩어리 " + bodyChunks + " · 모자 " + hatChunks + " · 팔레트 " + paletteN + "칸 · 데칼 색차 " + decalLo + "~" + decalHi + " ==="); SkinnedMeshRenderer bodyR = null, hatR = null; foreach (var r in pf.GetComponentsInChildren(true)) { if (r.name == "Hat") hatR = r; else if (r.name == "Peasant Nolant") bodyR = r; } if (bodyR == null || hatR == null) return "렌더러(Peasant Nolant · Hat) 못 찾음"; var body = Analyze("Body", bodyR.sharedMesh, tex, bodyChunks); var hat = Analyze("Hat", hatR.sharedMesh, tex, hatChunks); L("덩어리: 몸 " + body.regions + "(삼각형 " + body.faces.Count + ") · 모자 " + hat.regions + "(삼각형 " + hat.faces.Count + ")"); // ── 두 메시 덩어리를 한 팔레트로(재질 1장 공유) var all = new List>(); foreach (var r in body.roots) all.Add(new KeyValuePair(body, r)); foreach (var r in hat.roots) all.Add(new KeyValuePair(hat, r)); int N = Mathf.Min(paletteN, all.Count); var pal = KMeans(all.Select(p => p.Key.reg[p.Value].Col).ToArray(), all.Select(p => p.Key.reg[p.Value].area).ToArray(), N); var asg = new int[all.Count]; for (int i = 0; i < all.Count; i++) { var lab = ToLab(all[i].Key.reg[all[i].Value].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; } } asg[i] = bj; } // 칸 H 원본 복원 + ±8° 클램프 · 채도는 부풀리지 않는다(816b 와 같은 규칙) 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 < all.Count; i++) { if (asg[i] != j) continue; var reg = all[i].Key.reg[all[i].Value]; hx += reg.hx; hy += reg.hy; sw += reg.sw; float h2, s2, v2; RgbToHsv(reg.Col, out h2, out s2, out v2); ssum += s2 * reg.area; aw += reg.area; } float ph, ps, pv; RgbToHsv(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 d8 = DHue(ph, hue); if (Mathf.Abs(d8) > HUE_LIMIT) hue = ph + Mathf.Sign(d8) * HUE_LIMIT; float satRestored = aw > 1e-6 ? (float)(ssum / aw) : ps; palS[j] = HsvToRgb(hue, Mathf.Min(ps, satRestored), pv); } body.assign = new int[body.roots.Count]; hat.assign = new int[hat.roots.Count]; { int bi = 0, hi = 0; for (int i = 0; i < all.Count; i++) { if (all[i].Key == body) body.assign[bi++] = asg[i]; else hat.assign[hi++] = asg[i]; } } int G = Mathf.NextPowerOfTwo(Mathf.CeilToInt(Mathf.Sqrt(N))); int S = G * CELL; Directory.CreateDirectory(OUT + "Meshes"); Directory.CreateDirectory(OUT + "Textures"); string palPath = OUT + "Textures/WLPaletteN_Nolant_" + color + ".png"; { 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(); File.WriteAllBytes(palPath, ptex.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(ptex); ImportPalette(palPath, S); } var sbPal = new StringBuilder(); for (int j = 0; j < N; j++) sbPal.Append(ColorUtility.ToHtmlStringRGB(new Color(palS[j].x, palS[j].y, palS[j].z)) + " "); L("팔레트 " + N + "칸(" + S + "×" + S + "): " + sbPal); var bodyPal = BuildPaletteMesh(body, G); var hatPal = BuildPaletteMesh(hat, G); SaveMesh(bodyPal, OUT + "Meshes/" + bodyPal.name + ".asset"); SaveMesh(hatPal, OUT + "Meshes/" + hatPal.name + ".asset"); L("메시: 몸 정점 " + body.src.vertexCount + "→" + bodyPal.vertexCount + " · 모자 " + hat.src.vertexCount + "→" + hatPal.vertexCount + " · 뼈 가중치 " + (bodyPal.boneWeights.Length == bodyPal.vertexCount) + "/" + (hatPal.boneWeights.Length == hatPal.vertexCount)); // ── 얼굴 데칼 — 얼굴 칸(UV 왼쪽 위 · 원본 텍스처의 얼굴 그림) 삼각형 var faceTris = new List(); for (int f = 0; f < body.faces.Count; f++) { var fa = body.faces[f]; bool inF = true; foreach (var vi in new[] { fa.i0, fa.i1, fa.i2 }) { var u = body.uv[vi]; if (!(u.x <= 0.18f && u.y >= 0.72f)) inF = false; } if (inF) faceTris.Add(f); } float umin = 1, umax = 0, vmin = 1, vmax = 0; foreach (var f in faceTris) { var fa = body.faces[f]; foreach (var vi in new[] { fa.i0, fa.i1, fa.i2 }) { var u = body.uv[vi]; umin = Mathf.Min(umin, u.x); umax = Mathf.Max(umax, u.x); vmin = Mathf.Min(vmin, u.y); vmax = Mathf.Max(vmax, u.y); } } var rect = Rect.MinMaxRect(Mathf.Max(0, umin - 0.004f), Mathf.Max(0, vmin - 0.004f), Mathf.Min(1, umax + 0.004f), Mathf.Min(1, vmax + 0.004f)); // 피부 기준색 = 얼굴 삼각형 면 평균에서 출발해 가까운 것만 다시 평균(2회) — 눈·눈썹이 섞이지 않게 Vector3 skin = Vector3.zero; float sa = 0; foreach (var f in faceTris) { skin += body.faces[f].col * body.faces[f].area; sa += body.faces[f].area; } skin /= Mathf.Max(sa, 1e-9f); const int D = 256; for (int it = 0; it < 2; it++) { var sl = ToLab(skin); Vector3 acc = Vector3.zero; int n = 0; for (int y = 0; y < D; y += 2) for (int x = 0; x < D; x += 2) { var c = Sample(tex, rect.xMin + (x + 0.5f) / D * rect.width, rect.yMin + (y + 0.5f) / D * rect.height); if (LabD(ToLab(c), sl) < 14f) { acc += c; n++; } } if (n > 0) skin = acc / n; } // 덮개(얼굴 삼각형이 덮는 텍셀) — 밖은 투명 var cover = new bool[D * D]; foreach (var f in faceTris) { var fa = body.faces[f]; Vector2 a = Px(body.uv[fa.i0], rect, D), b = Px(body.uv[fa.i1], rect, D), c = Px(body.uv[fa.i2], rect, D); int x0 = Mathf.Max(0, Mathf.FloorToInt(Mathf.Min(a.x, Mathf.Min(b.x, c.x))) - 1), x1 = Mathf.Min(D - 1, Mathf.CeilToInt(Mathf.Max(a.x, Mathf.Max(b.x, c.x))) + 1); int y0 = Mathf.Max(0, Mathf.FloorToInt(Mathf.Min(a.y, Mathf.Min(b.y, c.y))) - 1), y1 = Mathf.Min(D - 1, Mathf.CeilToInt(Mathf.Max(a.y, Mathf.Max(b.y, c.y))) + 1); for (int y = y0; y <= y1; y++) for (int x = x0; x <= x1; x++) if (Inside(new Vector2(x + 0.5f, y + 0.5f), a, b, c, 1.0f)) cover[y * D + x] = true; } var dtex = new Texture2D(D, D, TextureFormat.RGBA32, false); var dpx = new Color32[D * D]; var skinLab = ToLab(skin); int feat = 0; for (int y = 0; y < D; y++) for (int x = 0; x < D; x++) { var c = SampleBilinear(tex, rect.xMin + (x + 0.5f) / D * rect.width, rect.yMin + (y + 0.5f) / D * rect.height); float d = LabD(ToLab(c), skinLab); float al = cover[y * D + x] ? Mathf.Clamp01((d - decalLo) / Mathf.Max(0.01f, decalHi - decalLo)) : 0f; if (al > 0.5f) feat++; dpx[y * D + x] = new Color32((byte)Mathf.RoundToInt(c.x * 255f), (byte)Mathf.RoundToInt(c.y * 255f), (byte)Mathf.RoundToInt(c.z * 255f), (byte)Mathf.RoundToInt(al * 255f)); } dtex.SetPixels32(dpx); dtex.Apply(); string decalPath = OUT + "Textures/WL_Nolant_FaceDecal_" + color + ".png"; File.WriteAllBytes(decalPath, dtex.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(dtex); ImportDecal(decalPath); float offset = body.src.bounds.size.magnitude * 0.0012f; var decal = BuildDecalMesh(body, faceTris, rect, offset); SaveMesh(decal, OUT + "Meshes/" + decal.name + ".asset"); L("얼굴 데칼: 삼각형 " + faceTris.Count + " · UV 칸 " + rect.ToString("F3") + " · 피부 " + ColorUtility.ToHtmlStringRGB(new Color(skin.x, skin.y, skin.z)) + " · 남긴 텍셀 " + feat + "/" + (D * D) + " · 띄움 " + offset.ToString("F5") + "(메시 단위)"); // ── 재질 — PC 팔레트 재질(M05_ToonP) 사본 + 이 팔레트 · PC 얼굴 데칼 재질(face02_ToonS) 사본 + 이 데칼 string matPath = OUT + "WL_Nolant_" + color + "_ToonP.mat"; var m = AssetDatabase.LoadAssetAtPath(matPath); if (m == null) { m = new Material(baseMat); AssetDatabase.CreateAsset(m, matPath); } m.shader = baseMat.shader; m.CopyPropertiesFromMaterial(baseMat); var ptex2 = AssetDatabase.LoadAssetAtPath(palPath); if (m.HasProperty("_BaseMap")) m.SetTexture("_BaseMap", ptex2); if (m.HasProperty("_MainTex")) m.SetTexture("_MainTex", ptex2); m.enableInstancing = true; EditorUtility.SetDirty(m); string fmPath = OUT + "WL_Nolant_" + color + "_FaceDecal.mat"; var fm = AssetDatabase.LoadAssetAtPath(fmPath); if (fm == null) { fm = new Material(faceMat); AssetDatabase.CreateAsset(fm, fmPath); } fm.shader = faceMat.shader; fm.CopyPropertiesFromMaterial(faceMat); var dtex2 = AssetDatabase.LoadAssetAtPath(decalPath); if (fm.HasProperty("_MainTex")) fm.SetTexture("_MainTex", dtex2); if (fm.HasProperty("_BaseMap")) fm.SetTexture("_BaseMap", dtex2); EditorUtility.SetDirty(fm); AssetDatabase.SaveAssets(); L("재질: " + matPath + "(" + m.shader.name + ") · " + fmPath + "(" + fm.shader.name + ")"); // ── WL 프리팹 변형에 배선 — 몸·모자 = 팔레트 메시 + 툰 재질 · 얼굴 데칼 렌더러 추가(PC 의 Face 렌더러와 같은 구성) if (AssetDatabase.LoadAssetAtPath(variantPath) == null) return sb + "🔴 WL 프리팹 변형 없음 — 먼저 WL_NolantMerchantSetup 실행"; var root = PrefabUtility.LoadPrefabContents(variantPath); try { SkinnedMeshRenderer vb = null, vh = null, vd = null; foreach (var r in root.GetComponentsInChildren(true)) { if (r.name == "Peasant Nolant") vb = r; else if (r.name == "Hat") vh = r; else if (r.name == "Face_Decal") vd = r; } if (vb == null || vh == null) return sb + "🔴 변형 안 렌더러 못 찾음"; vb.sharedMesh = AssetDatabase.LoadAssetAtPath(OUT + "Meshes/" + bodyPal.name + ".asset"); vb.sharedMaterials = new[] { m }; vh.sharedMesh = AssetDatabase.LoadAssetAtPath(OUT + "Meshes/" + hatPal.name + ".asset"); vh.sharedMaterials = new[] { m }; if (vd == null) { var go = new GameObject("Face_Decal"); go.transform.SetParent(vb.transform.parent, false); go.transform.localPosition = vb.transform.localPosition; go.transform.localRotation = vb.transform.localRotation; go.transform.localScale = vb.transform.localScale; go.layer = vb.gameObject.layer; vd = go.AddComponent(); } vd.sharedMesh = AssetDatabase.LoadAssetAtPath(OUT + "Meshes/" + decal.name + ".asset"); vd.bones = vb.bones; vd.rootBone = vb.rootBone; vd.localBounds = vb.localBounds; vd.sharedMaterials = new[] { fm }; vd.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; vd.receiveShadows = false; vd.quality = vb.quality; vd.updateWhenOffscreen = false; PrefabUtility.SaveAsPrefabAsset(root, variantPath); L("프리팹 변형 배선: " + variantPath + " · 몸/모자 팔레트 메시 · 얼굴 데칼 렌더러(뼈 " + vd.bones.Length + ")"); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); File.WriteAllText("AgentScripts/WL876_BAKE.txt", sb.ToString(), new UTF8Encoding(true)); return sb.ToString(); } static Vector2 Px(Vector2 uv, Rect r, int D) { return new Vector2((uv.x - r.xMin) / r.width * D, (uv.y - r.yMin) / r.height * D); } static bool Inside(Vector2 p, Vector2 a, Vector2 b, Vector2 c, float pad) { // 무게중심 좌표 + 픽셀 반 칸 여유(경계 텍셀까지 덮는다) float d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y); if (Mathf.Abs(d) < 1e-9f) return false; float l1 = ((b.y - c.y) * (p.x - c.x) + (c.x - b.x) * (p.y - c.y)) / d; float l2 = ((c.y - a.y) * (p.x - c.x) + (a.x - c.x) * (p.y - c.y)) / d; float l3 = 1f - l1 - l2; float e = pad / Mathf.Max(1f, Mathf.Sqrt(Mathf.Abs(d))); return l1 >= -e && l2 >= -e && l3 >= -e; } }