Project_WL/AgentScripts/WL814x_Run.cs

1295 lines
66 KiB
C#
Raw 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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<Transform, int> 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<SkinnedMeshRenderer>(true).Where(r => r.enabled).ToArray();
var scam = Camera.main ?? UnityEngine.Object.FindObjectsByType<Camera>(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<Transform, int>();
foreach (var t in Root.GetComponentsInChildren<Transform>(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<Camera>(); 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<string, string> MeshTex = new Dictionary<string, string> {
{ "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<string, float> SGain = new Dictionary<string, float>();
public static Dictionary<string, float> VGain = new Dictionary<string, float>();
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<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) };
}
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<string, Tex> s_tex = new Dictionary<string, Tex>();
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<string, Mesh> meshes = new Dictionary<string, Mesh>();
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<GameObject>("Assets/Res_Addr/PC/LH_M05.prefab");
var smrs = prefab.GetComponentsInChildren<SkinnedMeshRenderer>(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<Face>();
var start = new int[M + 1];
var vtxOf = new List<Vector3[]>(); var triOf = new List<int[]>();
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<int>();
for (int i = start[m]; i < start[m + 1]; i++) seen.Add(Find(uf, i));
L(" " + names[m] + " : " + seen.Count);
}
var roots = new List<int>();
{ var seen = new HashSet<int>(); 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<int, int>();
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<Mesh>(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<KeyValuePair<int, int>> BuildAdjacency(List<Face> faces, int[] start, List<Vector3[]> vtx, int M)
{
var pairs = new List<KeyValuePair<int, int>>();
for (int m = 0; m < M; m++)
{
var vt = vtx[m];
var map = new Dictionary<long, int>(); 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<long, int>();
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<int, int>(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<KeyValuePair<int, int>> adj, int target, int M, List<Face> 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<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;
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<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 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<Face> faces, int f0, int f1, int[] uf, Dictionary<int, int> 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<long, int>();
var nv = new List<Vector3>(); var nn = new List<Vector3>(); var nt = new List<Vector4>();
var nuv = new List<Vector2>(); var ntri = new List<int>();
var nbw = new List<BoneWeight>();
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 > 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<Texture2D>(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 };
/// <summary>1회차 — 덩어리 20/40/80 × 팔레트 16/24/32 전수 비교 (얼굴은 지금 그대로).</summary>
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"); }
}
/// <summary>2회차 — 얼굴 점텍스처(32/64) · 머리칼 팔레트 vs 텍스처 · 셰이드 단수.</summary>
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<Texture2D>(WLXBake.TEX_DIR + "face02_P32.png");
var f64 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "face02_P64.png");
var fV1 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "face02_V1.png");
var h64 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "Hair05_P64.png");
var h32 = AssetDatabase.LoadAssetAtPath<Texture2D>(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<string, Texture2D> { { "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<string, Texture2D> { { "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<Texture2D>(WLXBake.TEX_DIR + "face02_P32.png");
F64 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "face02_P64.png");
FV1 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "face02_V1.png");
H64 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "Hair05_P64.png");
H32 = AssetDatabase.LoadAssetAtPath<Texture2D>(WLXBake.TEX_DIR + "Hair05_P32.png");
}
public static void ApplyPalette(WLXBake.Result res, Texture2D faceTex, Texture2D hairTex)
{
var pal = AssetDatabase.LoadAssetAtPath<Texture2D>(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;
}
}
/// <summary>덩어리 ID 를 색으로 찍는다(빛 0) — 「부위(덩어리)당 색 단계」를 재기 위한 지도.</summary>
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<SkinnedMeshRenderer>();
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<string, float> { { "M05", sM }, { "Skin", sS }, { "Hair", sH } };
WLXBake.VGain = new Dictionary<string, float> { { "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<string, float> { { "M05", 0.80f }, { "Skin", 0.86f }, { "Hair", 0.85f } };
WLXBake.VGain = new Dictionary<string, float> { { "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<Texture2D>(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<Texture2D>(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<WL.Look.Character.WLCharacterPaletteSettings>(SO);
if (so == null)
{
so = ScriptableObject.CreateInstance<WL.Look.Character.WLCharacterPaletteSettings>();
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<Mesh>(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<Texture2D>(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<Material>(MAT + src + ".mat");
if (s == null) { L("!! no src mat " + src); return null; }
var m = AssetDatabase.LoadAssetAtPath<Material>(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;
}
/// <summary>최종 검증 — 런타임 경로(WLCharacterLook.Apply)로 적용해서 찍는다 + 되돌리기 확인.</summary>
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"); }
}
/// <summary>회전 떨림 — 연속 8프레임(1°씩)의 픽셀 변화율. 814u 와 같은 축.</summary>
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"); }
}
/// <summary>저장된 메시 에셋이 스킨 데이터를 제대로 들고 있는지 확인(렌더 정지 원인 추적).</summary>
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<Mesh>(p);
var src = AssetDatabase.LoadAllAssetsAtPath("Assets/Suriyun/Characters/RedKnight/FBX/Characters/M05.fbx").OfType<Mesh>().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();
}
/// <summary>메모리 메시 vs 에셋 메시 — 렌더 정지의 원인이 「에셋 저장」인지 가린다.</summary>
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<Texture2D>(res.palPath);
foreach (var r in WLXCap.Smrs)
{
var am = AssetDatabase.LoadAssetAtPath<Mesh>(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<Texture2D>(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"); }
}
}