// ───────────────────────────────────────────────────────────────────────────── // WL814s_Posterize.cs — 캐릭터 텍스처 복사 + 색 평탄화(양자화) · 에디터 전용 // // PD 지시 #814 · 발주서 WL-814s §2 / §3 // // 🔴 원본 png(`Assets/Suriyun/**`)는 **바이트 하나도 건드리지 않는다**. // · 복사본을 `Assets/WL/Look/Character/Textures/` 에 만들고 // · 축소는 **복사본의 임포트 설정 maxTextureSize** 로만 한다(픽셀 리샘플 0 = 되돌리기 쉬움) // · 평탄화는 복사본을 읽어 **새 png** 로 저장한다(원본 덮어쓰기 0) // // 평탄화 = 채널당 색 단계를 levels 개로 반올림. 알파는 **손대지 않는다** // (얼굴 face02 · 머리 가장자리가 깨지지 않게 · 발주 §3). // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; public static class WL814s_Posterize { public const string DST = "Assets/WL/Look/Character/Textures/"; // 원본 4장(+검은 참고). name → 원본 경로 public static readonly string[,] SRC = new string[,] { { "M05", "Assets/Suriyun/Characters/RedKnight/Texture/M05/M05.png" }, { "Skin", "Assets/Suriyun/Characters/RedKnight/Texture/Skin&Hair&Face/Face/Skin.png" }, { "Hair05", "Assets/Suriyun/Characters/RedKnight/Texture/M05/Hair05.png" }, { "face02", "Assets/Suriyun/Characters/RedKnight/Texture/Skin&Hair&Face/Face/face02.png" }, }; static void Ensure(string folder) { folder = folder.TrimEnd('/'); if (AssetDatabase.IsValidFolder(folder)) return; int i = folder.LastIndexOf('/'); Ensure(folder.Substring(0, i)); AssetDatabase.CreateFolder(folder.Substring(0, i), folder.Substring(i + 1)); } static Texture2D LoadRaw(string path, out int w, out int h) { byte[] bytes = File.ReadAllBytes(path); var t = new Texture2D(2, 2, TextureFormat.RGBA32, false); t.LoadImage(bytes); w = t.width; h = t.height; return t; } // ═════════════════════════════════════════════════════════════════════ // ① 복사본 만들기 (바이트 그대로) + 임포트 설정 // ═════════════════════════════════════════════════════════════════════ public static string CopyAll() { var sb = new StringBuilder(); Ensure(DST); for (int i = 0; i < SRC.GetLength(0); i++) { string name = SRC[i, 0], src = SRC[i, 1]; string dst = DST + name + "_WL.png"; File.Copy(src, dst, true); sb.AppendLine("COPY " + src + " -> " + dst + " (" + new FileInfo(dst).Length + " bytes · 원본과 동일 = " + (new FileInfo(src).Length == new FileInfo(dst).Length) + ")"); } AssetDatabase.Refresh(); // 원본 임포터 설정을 복사본에 이식 for (int i = 0; i < SRC.GetLength(0); i++) { string name = SRC[i, 0], src = SRC[i, 1]; string dst = DST + name + "_WL.png"; var si = AssetImporter.GetAtPath(src) as TextureImporter; var di = AssetImporter.GetAtPath(dst) as TextureImporter; if (si == null || di == null) { sb.AppendLine("IMPORTER MISS " + name); continue; } di.textureType = si.textureType; di.sRGBTexture = si.sRGBTexture; di.alphaSource = si.alphaSource; di.alphaIsTransparency = si.alphaIsTransparency; di.mipmapEnabled = si.mipmapEnabled; di.wrapMode = si.wrapMode; di.filterMode = si.filterMode; di.anisoLevel = si.anisoLevel; di.npotScale = si.npotScale; di.maxTextureSize = si.maxTextureSize; di.textureCompression = si.textureCompression; di.SaveAndReimport(); sb.AppendLine("IMPORT " + name + "_WL type=" + di.textureType + " sRGB=" + di.sRGBTexture + " alphaSrc=" + di.alphaSource + " alphaIsTransparency=" + di.alphaIsTransparency + " mip=" + di.mipmapEnabled + " max=" + di.maxTextureSize); } AssetDatabase.SaveAssets(); return sb.ToString(); } // ═════════════════════════════════════════════════════════════════════ // ② 평탄화 — 복사본을 읽어 levels 단계로 양자화한 새 png // ═════════════════════════════════════════════════════════════════════ public static string MakeFlat(int levels) { var sb = new StringBuilder(); Ensure(DST); for (int i = 0; i < SRC.GetLength(0); i++) { string name = SRC[i, 0]; string src = DST + name + "_WL.png"; if (!File.Exists(src)) { sb.AppendLine("SKIP(no copy) " + src); continue; } int w, h; var t = LoadRaw(src, out w, out h); var px = t.GetPixels32(); float step = 255f / (levels - 1); int uniqBefore = CountUnique(px); int alphaChanged = 0; for (int p = 0; p < px.Length; p++) { var c = px[p]; c.r = (byte)Mathf.Clamp(Mathf.RoundToInt(Mathf.Round(c.r / step) * step), 0, 255); c.g = (byte)Mathf.Clamp(Mathf.RoundToInt(Mathf.Round(c.g / step) * step), 0, 255); c.b = (byte)Mathf.Clamp(Mathf.RoundToInt(Mathf.Round(c.b / step) * step), 0, 255); // 알파는 그대로 (얼굴·머리 가장자리 보존) px[p] = c; } var outT = new Texture2D(w, h, TextureFormat.RGBA32, false); outT.SetPixels32(px); outT.Apply(false); string dst = DST + name + "_WL_Q" + levels + ".png"; File.WriteAllBytes(dst, outT.EncodeToPNG()); int uniqAfter = CountUnique(px); sb.AppendLine("FLAT " + name + " levels=" + levels + " " + w + "x" + h + " 고유색 " + uniqBefore + " -> " + uniqAfter + " · 알파변경 " + alphaChanged + " · " + dst); UnityEngine.Object.DestroyImmediate(t); UnityEngine.Object.DestroyImmediate(outT); } AssetDatabase.Refresh(); // 임포트 설정을 복사본과 같게 for (int i = 0; i < SRC.GetLength(0); i++) { string name = SRC[i, 0]; var si = AssetImporter.GetAtPath(DST + name + "_WL.png") as TextureImporter; var di = AssetImporter.GetAtPath(DST + name + "_WL_Q" + levels + ".png") as TextureImporter; if (si == null || di == null) continue; di.textureType = si.textureType; di.sRGBTexture = si.sRGBTexture; di.alphaSource = si.alphaSource; di.alphaIsTransparency = si.alphaIsTransparency; di.mipmapEnabled = si.mipmapEnabled; di.wrapMode = si.wrapMode; di.filterMode = si.filterMode; di.npotScale = si.npotScale; di.maxTextureSize = si.maxTextureSize; di.textureCompression = si.textureCompression; di.SaveAndReimport(); } AssetDatabase.SaveAssets(); return sb.ToString(); } static int CountUnique(Color32[] px) { var set = new HashSet(); int stride = px.Length > 1048576 ? 4 : 1; // 4M 픽셀 이상이면 1/4 샘플링(메모리) for (int i = 0; i < px.Length; i += stride) { var c = px[i]; if (c.a == 0) continue; set.Add((c.r << 16) | (c.g << 8) | c.b); } return set.Count; } // ═════════════════════════════════════════════════════════════════════ // ③ 축소 — 복사본/평탄화본의 maxTextureSize 만 바꾼다 // ═════════════════════════════════════════════════════════════════════ public static string SetMaxSize(string suffix, int maxSize) { var sb = new StringBuilder(); for (int i = 0; i < SRC.GetLength(0); i++) { string p = DST + SRC[i, 0] + suffix + ".png"; var im = AssetImporter.GetAtPath(p) as TextureImporter; if (im == null) { sb.AppendLine("MISS " + p); continue; } im.maxTextureSize = maxSize; im.SaveAndReimport(); var t = AssetDatabase.LoadAssetAtPath(p); long mem = t != null ? UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(t) : 0; sb.AppendLine("MAXSIZE " + p + " -> " + maxSize + " · 실제 " + (t != null ? t.width + "x" + t.height : "?") + " · " + (t != null ? t.format.ToString() : "?") + " · " + (mem / 1024f / 1024f).ToString("F3") + " MB"); } AssetDatabase.SaveAssets(); return sb.ToString(); } /// 원본 4장의 런타임 메모리(현재 임포트 상태)를 잰다. public static string MeasureOriginals() { var sb = new StringBuilder(); float tot = 0; for (int i = 0; i < SRC.GetLength(0); i++) { var t = AssetDatabase.LoadAssetAtPath(SRC[i, 1]); if (t == null) { sb.AppendLine("MISS " + SRC[i, 1]); continue; } long mem = UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(t); tot += mem / 1024f / 1024f; sb.AppendLine("ORIG " + SRC[i, 0] + " " + t.width + "x" + t.height + " " + t.format + " mip=" + (t.mipmapCount > 1) + " · " + (mem / 1024f / 1024f).ToString("F3") + " MB"); } sb.AppendLine("ORIG TOTAL " + tot.ToString("F3") + " MB"); return sb.ToString(); } public static string MeasureSet(string suffix) { var sb = new StringBuilder(); float tot = 0; for (int i = 0; i < SRC.GetLength(0); i++) { string p = DST + SRC[i, 0] + suffix + ".png"; var t = AssetDatabase.LoadAssetAtPath(p); if (t == null) { sb.AppendLine("MISS " + p); continue; } long mem = UnityEngine.Profiling.Profiler.GetRuntimeMemorySizeLong(t); tot += mem / 1024f / 1024f; sb.AppendLine("SET" + suffix + " " + SRC[i, 0] + " " + t.width + "x" + t.height + " " + t.format + " · " + (mem / 1024f / 1024f).ToString("F3") + " MB"); } sb.AppendLine("SET" + suffix + " TOTAL " + tot.ToString("F3") + " MB"); return sb.ToString(); } public static void Run() { var sb = new StringBuilder(); sb.Append(MeasureOriginals()); sb.Append(CopyAll()); Debug.Log("[WL814s posterize]\n" + sb); } // 디스패처 — unity command run_script --entry WL814s_Posterize.Cmd --args " …" public static string Cmd(string[] args) { if (args == null || args.Length == 0) return MeasureOriginals(); string c = args[0]; try { if (c == "measure") return MeasureOriginals(); if (c == "copy") return MeasureOriginals() + CopyAll(); if (c == "flat") return MakeFlat(int.Parse(args[1])); if (c == "maxsize") return SetMaxSize(args[1] == "-" ? "_WL" : args[1], int.Parse(args[2])); if (c == "set") return MeasureSet(args[1] == "-" ? "_WL" : args[1]); return "UNKNOWN " + c; } catch (Exception e) { return "EXCEPTION " + e.GetType().Name + " " + e.Message + "\n" + e.StackTrace; } } }