Project_WL/AgentScripts/WL876_NpcPaletteBake.cs

590 lines
34 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<float, float> 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<KeyValuePair<int, int>> 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<int, HashSet<int>>();
foreach (var e in adj)
{
HashSet<int> sa, sbb;
if (!nb.TryGetValue(e.Key, out sa)) { sa = new HashSet<int>(); nb[e.Key] = sa; }
if (!nb.TryGetValue(e.Value, out sbb)) { sbb = new HashSet<int>(); 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<int> sb2;
if (nb.TryGetValue(b, out sb2))
{
HashSet<int> sa2;
if (!nb.TryGetValue(a, out sa2)) { sa2 = new HashSet<int>(); nb[a] = sa2; }
foreach (var o in sb2)
{
int r = Find(uf, o); if (r == a) continue;
sa2.Add(r);
HashSet<int> 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<Face> faces; public int[] uf; public Region[] reg; public List<int> 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<Face>(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<long, int>(); 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<long, int>(); var adj = new List<KeyValuePair<int, int>>();
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<int, int>(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<int>();
var seen = new HashSet<int>();
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<int, int>(); for (int i = 0; i < w.roots.Count; i++) rootIndex[w.roots[i]] = i;
var map = new Dictionary<long, int>();
var nv = new List<Vector3>(); var nn = new List<Vector3>(); var nt = new List<Vector4>(); var nuv = new List<Vector2>(); var nbw = new List<BoneWeight>(); var ntri = new List<int>();
Func<int, int, int> 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<int> 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<int, int>();
var nv = new List<Vector3>(); var nn = new List<Vector3>(); var nuv = new List<Vector2>(); var nbw = new List<BoneWeight>(); var ntri = new List<int>();
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<Mesh>(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<GameObject>(prefabPath);
if (pf == null || !File.Exists(texPath)) return "원본 없음 — " + prefabPath + " / " + texPath;
var baseMat = AssetDatabase.LoadAssetAtPath<Material>("Assets/WL/Look/Character/Materials/M05_ToonP.mat");
var faceMat = AssetDatabase.LoadAssetAtPath<Material>("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<SkinnedMeshRenderer>(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<KeyValuePair<MeshWork, int>>();
foreach (var r in body.roots) all.Add(new KeyValuePair<MeshWork, int>(body, r));
foreach (var r in hat.roots) all.Add(new KeyValuePair<MeshWork, int>(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<int>();
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<Material>(matPath);
if (m == null) { m = new Material(baseMat); AssetDatabase.CreateAsset(m, matPath); }
m.shader = baseMat.shader; m.CopyPropertiesFromMaterial(baseMat);
var ptex2 = AssetDatabase.LoadAssetAtPath<Texture2D>(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<Material>(fmPath);
if (fm == null) { fm = new Material(faceMat); AssetDatabase.CreateAsset(fm, fmPath); }
fm.shader = faceMat.shader; fm.CopyPropertiesFromMaterial(faceMat);
var dtex2 = AssetDatabase.LoadAssetAtPath<Texture2D>(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<GameObject>(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<SkinnedMeshRenderer>(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<Mesh>(OUT + "Meshes/" + bodyPal.name + ".asset"); vb.sharedMaterials = new[] { m };
vh.sharedMesh = AssetDatabase.LoadAssetAtPath<Mesh>(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<SkinnedMeshRenderer>();
}
vd.sharedMesh = AssetDatabase.LoadAssetAtPath<Mesh>(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;
}
}