// WL814v_Capture.cs — 안별 임포트 설정 + 화면 실측 + 비교 캡처 (에디터 전용) // 🔴 씬 저장 0 · 머티리얼/프리팹/URP 에셋 수정 0 (씬 인스턴스에 임시 Material 만 꽂았다 버린다) · ProjectSettings 0 · 원본 png 수정 0 using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Rendering.Universal; public static class WL814v_Capture { const string SCENE = "Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity"; const string TEX = "Assets/WL/Look/Character/Textures/"; const string SHOT = "Screenshots_WL/WL814v/"; const string OUT = "AgentScripts/WL814v_CAPTURE.txt"; const int LAYER = 31; const int W = 1080, H = 1920; static readonly string[] VS = { "V1", "V2", "V3", "V4" }; static readonly StringBuilder sb = new StringBuilder(); static void L(string s) { sb.AppendLine(s); Console.WriteLine("[814vC] " + s); } public static void Import() { try { foreach (var v in VS) foreach (var f in new[] { "face02_" + v + ".png", "Hair05_" + v + ".png" }) { var p = TEX + f; AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); var ti = (TextureImporter)AssetImporter.GetAtPath(p); if (ti == null) { L("!! importer null " + p); continue; } ti.textureType = TextureImporterType.Default; ti.alphaSource = TextureImporterAlphaSource.FromInput; ti.alphaIsTransparency = true; ti.mipmapEnabled = true; // 1/40 축소 → 밉 없으면 심하게 반짝인다 ti.filterMode = FilterMode.Point; // 도트: 보간 금지 ti.wrapMode = TextureWrapMode.Clamp; ti.sRGBTexture = true; ti.maxTextureSize = 512; // 원본 face02_WL/Hair05_WL 의 실효 크기와 동일 조건 ti.textureCompression = TextureImporterCompression.Uncompressed; // 블록압축은 하드 엣지를 뭉갠다 ti.SaveAndReimport(); var t = AssetDatabase.LoadAssetAtPath(p); L(string.Format("import {0} → {1}×{2} · {3} · mip {4} · {5}", f, t.width, t.height, t.format, t.mipmapCount, t.filterMode)); } } catch (Exception e) { L("EXCEPTION " + e); } finally { Directory.CreateDirectory(Path.GetDirectoryName(OUT)); File.WriteAllText(OUT, sb.ToString(), new UTF8Encoding(true)); } } class Shot { public Color32[] px; public int w, h; } static Camera cam; 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; } static bool[] MaskOf(SkinnedMeshRenderer[] all, SkinnedMeshRenderer[] on, out int cnt, out RectInt bb) { foreach (var r in all) r.enabled = on.Contains(r); var b = Render(Color.black); var w = Render(Color.white); var m = new bool[W * H]; cnt = 0; int x0 = W, y0 = H, x1 = -1, y1 = -1; for (int i = 0; i < m.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))); if (d < 128) { m[i] = true; cnt++; int x = i % W, y = i / W; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } } foreach (var r in all) r.enabled = true; bb = (x1 < 0) ? new RectInt(0, 0, 0, 0) : new RectInt(x0, y0, x1 - x0 + 1, y1 - y0 + 1); return m; } static float Lum(Color32 c) { return 0.299f * c.r + 0.587f * c.g + 0.114f * c.b; } static void Hsv(Color32 c, out float h, out float s, out float v) { float r = c.r, g = c.g, b = c.b; float mx = Mathf.Max(r, Mathf.Max(g, b)), mn = Mathf.Min(r, Mathf.Min(g, b)); float d = mx - mn; h = 0; 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 float CMeanHue(List hs, List ws) { double x = 0, y = 0; for (int i = 0; i < hs.Count; i++) { x += ws[i] * Math.Cos(hs[i] * Math.PI / 180.0); y += ws[i] * Math.Sin(hs[i] * Math.PI / 180.0); } return (float)(((Math.Atan2(y, x) * 180.0 / Math.PI) + 360.0) % 360.0); } class Met { public int px, colors; public float ratio, lmin, lmax, hue, darkPx, eyeW, eyeH; } static Met Measure(Shot s, bool[] mask, RectInt bb, float skinL) { var m = new Met(); var set = new HashSet(); var hs = new List(); var ws = new List(); m.lmin = 999; m.lmax = -1; int dx0 = W, dy0 = H, dx1 = -1, dy1 = -1; int half = bb.x + bb.width / 2; for (int i = 0; i < mask.Length; i++) { if (!mask[i]) continue; var c = s.px[i]; m.px++; set.Add((c.r << 16) | (c.g << 8) | c.b); float l = Lum(c); if (l < m.lmin) m.lmin = l; if (l > m.lmax) m.lmax = l; float h, sa, v; Hsv(c, out h, out sa, out v); if (sa > 0.08f) { hs.Add(h); ws.Add(sa); } if (skinL > 0 && l < skinL * 0.75f) { m.darkPx++; int x = i % W, y = i / W; if (x < half) { if (x < dx0) dx0 = x; if (x > dx1) dx1 = x; if (y < dy0) dy0 = y; if (y > dy1) dy1 = y; } } } m.colors = set.Count; m.ratio = m.px > 0 ? 100f * set.Count / m.px : 0; m.hue = hs.Count > 0 ? CMeanHue(hs, ws) : -1; m.eyeW = dx1 >= dx0 ? dx1 - dx0 + 1 : 0; m.eyeH = dy1 >= dy0 ? dy1 - dy0 + 1 : 0; return m; } static Shot Crop(Shot s, RectInt r, int zoom) { var o = new Shot { w = r.width * zoom, h = r.height * zoom, px = new Color32[r.width * zoom * r.height * zoom] }; for (int y = 0; y < o.h; y++) for (int x = 0; x < o.w; x++) { int sx = r.x + x / zoom, sy = r.y + y / zoom; o.px[y * o.w + x] = (sx >= 0 && sy >= 0 && sx < s.w && sy < s.h) ? s.px[sy * s.w + sx] : new Color32(24, 24, 28, 255); } return o; } static Shot HStack(List a, int gap) { int h = a.Max(s => s.h), w = a.Sum(s => s.w) + gap * (a.Count - 1); var o = new Shot { w = w, h = h, px = new Color32[w * h] }; for (int i = 0; i < o.px.Length; i++) o.px[i] = new Color32(24, 24, 28, 255); int xo = 0; foreach (var s in a) { for (int y = 0; y < s.h; y++) for (int x = 0; x < s.w; x++) o.px[y * w + xo + x] = s.px[y * s.w + x]; xo += s.w + gap; } return o; } static Shot VStack(List a, int gap) { int w = a.Max(s => s.w), h = a.Sum(s => s.h) + gap * (a.Count - 1); var o = new Shot { w = w, h = h, px = new Color32[w * h] }; for (int i = 0; i < o.px.Length; i++) o.px[i] = new Color32(24, 24, 28, 255); int yo = 0; for (int k = a.Count - 1; k >= 0; k--) { var s = a[k]; for (int y = 0; y < s.h; y++) for (int x = 0; x < s.w; x++) o.px[(yo + y) * w + x] = s.px[y * s.w + x]; yo += s.h + gap; } return o; } static void Save(Shot s, string file) { var t = new Texture2D(s.w, s.h, TextureFormat.RGBA32, false); t.SetPixels32(s.px); t.Apply(); Directory.CreateDirectory(SHOT); File.WriteAllBytes(SHOT + file, t.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(t); L(" saved " + SHOT + file + " (" + s.w + "×" + s.h + ")"); } public static void Capture() { try { Body(); } catch (Exception e) { L("EXCEPTION " + e); } finally { Directory.CreateDirectory(Path.GetDirectoryName(OUT)); File.WriteAllText(OUT, sb.ToString(), new UTF8Encoding(true)); } } static void Body() { EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single); GameObject root = null; foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) if (go.name.Contains("LH_M05")) { root = go; break; } var smrs = root.GetComponentsInChildren(true).Where(r => r.enabled).ToArray(); var faceSMR = smrs.First(r => r.name == "Face"); var hairSMR = smrs.First(r => r.name == "Hair05"); var headSMR = smrs.First(r => r.name == "Head"); var scam = Camera.main ?? UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).First(c => c.orthographic); float camYaw = scam.transform.eulerAngles.y; root.transform.rotation = Quaternion.Euler(0, camYaw + 180f, 0); L(string.Format("포즈 = 카메라 정면 yaw {0:F0}° · ortho 10 · {1}×{2} (814u 동일)", camYaw + 180f, W, H)); var all = root.GetComponentsInChildren(true); var origLayer = new Dictionary(); foreach (var t in all) { origLayer[t] = t.gameObject.layer; t.gameObject.layer = LAYER; } var b = new Bounds(root.transform.position, Vector3.zero); foreach (var r in smrs) b.Encapsulate(r.bounds); var go2 = new GameObject("WL814v_Cap"); cam = go2.AddComponent(); cam.CopyFrom(scam); cam.orthographic = true; cam.orthographicSize = 10f; cam.aspect = (float)W / H; cam.targetTexture = null; cam.cullingMask = 1 << LAYER; cam.transform.SetPositionAndRotation(b.center - scam.transform.forward * 50f, scam.transform.rotation); var ucd = cam.GetUniversalAdditionalCameraData(); if (ucd != null) { ucd.renderPostProcessing = false; ucd.SetRenderer(0); } for (int i = 0; i < 3; i++) Render(Color.black); // 배치 워밍업 var faceOrig = faceSMR.sharedMaterial; var hairOrig = hairSMR.sharedMaterial; try { int cF, cH, cHead, cAll; RectInt bbF, bbH, bbHead, bbAll; var mFace = MaskOf(smrs, new[] { faceSMR }, out cF, out bbF); var mHair = MaskOf(smrs, new[] { hairSMR }, out cH, out bbH); var mHead = MaskOf(smrs, new[] { headSMR }, out cHead, out bbHead); var mAll = MaskOf(smrs, smrs, out cAll, out bbAll); L(string.Format("마스크: 얼굴데칼 {0} px {1} · 머리칼 {2} px · 머리(피부) {3} px · 전체 {4} px {5}", cF, bbF, cH, cHead, cAll, bbAll)); var baseShot = Render(new Color(0.10f, 0.10f, 0.12f)); // 피부 밝기 기준 = Head 마스크의 평균 L float skinL = 0; int nh = 0; for (int i = 0; i < mHead.Length; i++) if (mHead[i] && !mFace[i]) { skinL += Lum(baseShot.px[i]); nh++; } skinL = nh > 0 ? skinL / nh : 128f; L(string.Format("피부(Head) 평균 밝기 L = {0:F0} → 「진하게 읽히는 점」 문턱 = L < {1:F0}", skinL, skinL * 0.75f)); var m0f = Measure(baseShot, mFace, bbF, skinL); var m0h = Measure(baseShot, mHair, bbH, 0); L(""); L("== 얼굴 안별 화면 실측 (얼굴 데칼 " + cF + " px) =="); L(string.Format(" {0,-8} 고유색 {1,4} · 서로다른색 {2,5:F0} % · 밝기 {3,3:F0}~{4,3:F0}(폭 {5,3:F0}) · Hue {6,5:F1}° · ΔHue {7,6} · 진한점 {8,3:F0} px · 눈1개 진한부분 {9}×{10} px", "원본", m0f.colors, m0f.ratio, m0f.lmin, m0f.lmax, m0f.lmax - m0f.lmin, m0f.hue, "-", m0f.darkPx, m0f.eyeW, m0f.eyeH)); var faceShots = new List(); faceShots.Add(baseShot); foreach (var v in VS) { var tx = AssetDatabase.LoadAssetAtPath(TEX + "face02_" + v + ".png"); if (tx == null) { L(" !! 없음 face02_" + v); continue; } var fm = new Material(faceOrig) { name = "TMP_face_" + v }; if (fm.HasProperty("_MainTex")) fm.SetTexture("_MainTex", tx); if (fm.HasProperty("_BaseMap")) fm.SetTexture("_BaseMap", tx); if (fm.HasProperty("_ShadowBaseMap")) fm.SetTexture("_ShadowBaseMap", tx); faceSMR.sharedMaterial = fm; var s = Render(new Color(0.10f, 0.10f, 0.12f)); faceShots.Add(s); var m = Measure(s, mFace, bbF, skinL); L(string.Format(" {0,-8} 고유색 {1,4} · 서로다른색 {2,5:F0} % · 밝기 {3,3:F0}~{4,3:F0}(폭 {5,3:F0}) · Hue {6,5:F1}° · ΔHue {7,6:+0.0;-0.0} · 진한점 {8,3:F0} px · 눈1개 진한부분 {9}×{10} px", "face " + v, m.colors, m.ratio, m.lmin, m.lmax, m.lmax - m.lmin, m.hue, Mathf.DeltaAngle(m0f.hue, m.hue), m.darkPx, m.eyeW, m.eyeH)); faceSMR.sharedMaterial = faceOrig; } L(""); L("== 머리칼 안별 화면 실측 (머리칼 " + cH + " px) =="); L(string.Format(" {0,-8} 고유색 {1,4} · 서로다른색 {2,5:F0} % · 밝기 {3,3:F0}~{4,3:F0}(폭 {5,3:F0}) · Hue {6,5:F1}° · ΔHue {7,6}", "원본", m0h.colors, m0h.ratio, m0h.lmin, m0h.lmax, m0h.lmax - m0h.lmin, m0h.hue, "-")); var hairShots = new List(); hairShots.Add(baseShot); foreach (var v in VS) { var tx = AssetDatabase.LoadAssetAtPath(TEX + "Hair05_" + v + ".png"); if (tx == null) { L(" !! 없음 Hair05_" + v); continue; } var hm = new Material(hairOrig) { name = "TMP_hair_" + v }; if (hm.HasProperty("_MainTex")) hm.SetTexture("_MainTex", tx); if (hm.HasProperty("_BaseMap")) hm.SetTexture("_BaseMap", tx); if (hm.HasProperty("_ShadowBaseMap")) hm.SetTexture("_ShadowBaseMap", tx); hairSMR.sharedMaterial = hm; var s = Render(new Color(0.10f, 0.10f, 0.12f)); hairShots.Add(s); var m = Measure(s, mHair, bbH, 0); L(string.Format(" {0,-8} 고유색 {1,4} · 서로다른색 {2,5:F0} % · 밝기 {3,3:F0}~{4,3:F0}(폭 {5,3:F0}) · Hue {6,5:F1}° · ΔHue {7,6:+0.0;-0.0}", "hair " + v, m.colors, m.ratio, m.lmin, m.lmax, m.lmax - m.lmin, m.hue, Mathf.DeltaAngle(m0h.hue, m.hue))); hairSMR.sharedMaterial = hairOrig; } // ── 캡처 ── var headCrop = new RectInt(bbAll.x - 4, bbAll.y + bbAll.height - 58, bbAll.width + 8, 58); L(""); Save(VStack(new List { HStack(faceShots.Select(s => Crop(s, headCrop, 1)).ToList(), 4), HStack(faceShots.Select(s => Crop(s, headCrop, 12)).ToList(), 12) }, 16), "v_face_grid.png"); Save(VStack(new List { HStack(hairShots.Select(s => Crop(s, headCrop, 1)).ToList(), 4), HStack(hairShots.Select(s => Crop(s, headCrop, 4)).ToList(), 8) }, 16), "v_hair_grid.png"); // 최적 조합 = BEST_FACE / BEST_HAIR (인자로 바꿀 수 있게 상수) { var ftx = AssetDatabase.LoadAssetAtPath(TEX + "face02_" + BEST_FACE + ".png"); var htx = AssetDatabase.LoadAssetAtPath(TEX + "Hair05_" + BEST_HAIR + ".png"); var fm = new Material(faceOrig) { name = "TMP_bestF" }; var hm = new Material(hairOrig) { name = "TMP_bestH" }; foreach (var pn in new[] { "_MainTex", "_BaseMap", "_ShadowBaseMap" }) { if (fm.HasProperty(pn)) fm.SetTexture(pn, ftx); if (hm.HasProperty(pn)) hm.SetTexture(pn, htx); } faceSMR.sharedMaterial = fm; hairSMR.sharedMaterial = hm; var best = Render(new Color(0.10f, 0.10f, 0.12f)); var full = new RectInt(bbAll.x - 6, bbAll.y - 6, bbAll.width + 12, bbAll.height + 12); Save(VStack(new List { HStack(new List{ Crop(baseShot, full, 1), Crop(best, full, 1) }, 6), HStack(new List{ Crop(baseShot, full, 6), Crop(best, full, 6) }, 12) }, 16), "v_best_full.png"); var mf = Measure(best, mFace, bbF, skinL); var mh = Measure(best, mHair, bbH, 0); var ma = Measure(best, mAll, bbAll, 0); var ma0 = Measure(baseShot, mAll, bbAll, 0); L(""); L(string.Format("== 추천 조합 얼굴 {0} + 머리 {1} ==", BEST_FACE, BEST_HAIR)); L(string.Format(" 캐릭터 전체: 고유색 {0} → {1} · 서로다른색 {2:F0} % → {3:F0} %", ma0.colors, ma.colors, ma0.ratio, ma.ratio)); L(string.Format(" 얼굴 진한점 {0:F0} → {1:F0} px · 눈1개 {2}×{3} px · 대비폭 {4:F0} → {5:F0}", m0f.darkPx, mf.darkPx, mf.eyeW, mf.eyeH, m0f.lmax - m0f.lmin, mf.lmax - mf.lmin)); L(string.Format(" 머리칼 고유색 {0} → {1} · ΔHue {2:+0.0;-0.0}°", m0h.colors, mh.colors, Mathf.DeltaAngle(m0h.hue, mh.hue))); faceSMR.sharedMaterial = faceOrig; hairSMR.sharedMaterial = hairOrig; } } finally { faceSMR.sharedMaterial = faceOrig; hairSMR.sharedMaterial = hairOrig; foreach (var kv in origLayer) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value; UnityEngine.Object.DestroyImmediate(go2); } } // ── 올리브 해결 확인: 814u 시제품 vs 이번 안, 화면에서 직접 비교 ── public static void Olive() { try { OliveBody(); } catch (Exception e) { L("EXCEPTION " + e); } finally { File.WriteAllText("AgentScripts/WL814v_OLIVE.txt", sb.ToString(), new UTF8Encoding(true)); } } static void OliveBody() { EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single); GameObject root = null; foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) if (go.name.Contains("LH_M05")) { root = go; break; } var smrs = root.GetComponentsInChildren(true).Where(r => r.enabled).ToArray(); var hairSMR = smrs.First(r => r.name == "Hair05"); var scam = Camera.main ?? UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).First(c => c.orthographic); root.transform.rotation = Quaternion.Euler(0, scam.transform.eulerAngles.y + 180f, 0); var all = root.GetComponentsInChildren(true); var origLayer = new Dictionary(); foreach (var t in all) { origLayer[t] = t.gameObject.layer; t.gameObject.layer = LAYER; } var b = new Bounds(root.transform.position, Vector3.zero); foreach (var r in smrs) b.Encapsulate(r.bounds); var go2 = new GameObject("WL814v_Olive"); cam = go2.AddComponent(); cam.CopyFrom(scam); cam.orthographic = true; cam.orthographicSize = 10f; cam.aspect = (float)W / H; cam.targetTexture = null; cam.cullingMask = 1 << LAYER; cam.transform.SetPositionAndRotation(b.center - scam.transform.forward * 50f, scam.transform.rotation); var ucd = cam.GetUniversalAdditionalCameraData(); if (ucd != null) { ucd.renderPostProcessing = false; ucd.SetRenderer(0); } for (int i = 0; i < 3; i++) Render(Color.black); var hairOrig = hairSMR.sharedMaterial; try { int cH; RectInt bbH; var mHair = MaskOf(smrs, new[] { hairSMR }, out cH, out bbH); L(""); L("== 🔴 올리브 해결 확인 (머리칼 " + cH + " px · 같은 프레임·포즈) =="); var shots = new List(); var names = new List { "원본", "814u 시제품", "814v V1", "814v V2", "814v V3", "814v V4" }; var files = new List { null, "Hair05_Pixel", "Hair05_V1", "Hair05_V2", "Hair05_V3", "Hair05_V4" }; float h0 = 0; for (int k = 0; k < files.Count; k++) { if (files[k] != null) { var tx = AssetDatabase.LoadAssetAtPath(TEX + files[k] + ".png"); var hm = new Material(hairOrig) { name = "TMP_" + files[k] }; foreach (var pn in new[] { "_MainTex", "_BaseMap", "_ShadowBaseMap" }) if (hm.HasProperty(pn)) hm.SetTexture(pn, tx); hairSMR.sharedMaterial = hm; } var s = Render(new Color(0.10f, 0.10f, 0.12f)); shots.Add(s); var hs = new List(); var ws = new List(); int olive = 0, oliveS = 0, n = 0; for (int i = 0; i < mHair.Length; i++) { if (!mHair[i]) continue; float h, sa, v; Hsv(s.px[i], out h, out sa, out v); n++; if (sa > 0.08f) { hs.Add(h); ws.Add(sa); if (h >= 45f && h <= 110f) { olive++; if (sa > 0.25f && v < 210f) oliveS++; } } } float hm2 = CMeanHue(hs, ws); if (k == 0) h0 = hm2; L(string.Format(" {0,-12} Hue {1,5:F1}° · ΔHue {2,6:+0.0;-0.0;0.0}° · 🔴 짙은 올리브(H45~110°·S>0.25·V<210) {3,4} px = {4,5:F1} % · (넓은 기준 H45~110° {5,5:F1} %)", names[k], hm2, Mathf.DeltaAngle(h0, hm2), oliveS, 100f * oliveS / Mathf.Max(n, 1), 100f * olive / Mathf.Max(n, 1))); hairSMR.sharedMaterial = hairOrig; } var crop = new RectInt(bbH.x - 3, bbH.y - 3, bbH.width + 6, bbH.height + 6); Save(HStack(shots.Select(s => Crop(s, crop, 4)).ToList(), 8), "v_olive_fix.png"); } finally { hairSMR.sharedMaterial = hairOrig; foreach (var kv in origLayer) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value; UnityEngine.Object.DestroyImmediate(go2); } } public static string BEST_FACE = "V1", BEST_HAIR = "V1"; }