298 lines
16 KiB
C#
298 lines
16 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// WL814u_Probe.cs — 「도트 일러스트급 캐릭터」 한계 실측 (에디터 전용 · 발주서 WL-814u §1)
|
||
//
|
||
// 🔴 씬 저장 0 · 프리팹/머티리얼/에셋 수정 0 · ProjectSettings 0.
|
||
// 씬은 읽기 전용으로 열고 메모리에서만 만진다(레이어 임시 변경 → 원복 · 저장 안 함).
|
||
//
|
||
// 측정
|
||
// ① 화면에서 캐릭터가 차지하는 픽셀 (현재 카메라 ortho 10 · 1080×1920, 그리고 도트A ortho 3.4 · 135×240)
|
||
// — 전체 키 / 머리(Neck_M 위) / 얼굴(face02 데칼의 실제 그려진 영역) / 눈 1개
|
||
// ② 부위별 마스크 안에 실제로 찍히는 고유 색 수
|
||
// ③ face02 · Hair05 의 UV 점유 영역 → 화면 실효 해상도(텍셀당 화면 픽셀)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
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 WL814u_Probe
|
||
{
|
||
const string SCENE = "Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity";
|
||
const string OUT = "AgentScripts/WL814u_PROBE.txt";
|
||
const int LAYER = 31;
|
||
|
||
// face02.png(2048²) 에서 알파>32 덩어리 5개 (파이썬 실측 · png 좌표 · row0 = 위)
|
||
// eyeL x[578..854] y[948..1130] / eyeR x[1190..1466] / browL x[663..827] y[817..923] / browR x[1200..1364] / mouth x[991..1057] y[1420..1433]
|
||
const int FACE_TEX = 2048;
|
||
const int DRAWN_X0 = 578, DRAWN_X1 = 1466, DRAWN_Y0 = 817, DRAWN_Y1 = 1433; // 889 × 617
|
||
const int EYE_W = 277, EYE_H = 183, BROW_W = 165, BROW_H = 107, MOUTH_W = 67, MOUTH_H = 14;
|
||
|
||
static readonly StringBuilder sb = new StringBuilder();
|
||
static void L(string s) { sb.AppendLine(s); Console.WriteLine("[814u] " + s); }
|
||
|
||
public static void Run()
|
||
{
|
||
try { Body(); }
|
||
catch (Exception e) { L("EXCEPTION " + e); }
|
||
finally
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(OUT));
|
||
File.WriteAllText(OUT, sb.ToString(), new UTF8Encoding(true));
|
||
Console.WriteLine("[814u] wrote " + OUT);
|
||
}
|
||
}
|
||
|
||
// ── 유틸 ────────────────────────────────────────────────────────────────
|
||
static GameObject FindRoot()
|
||
{
|
||
foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects())
|
||
if (go.name.Contains("LH_M05") || go.name.StartsWith("PC_")) return go;
|
||
return null;
|
||
}
|
||
|
||
class Shot
|
||
{
|
||
public Color32[] px; public int w, h;
|
||
public Color32 At(int x, int y) { return px[y * w + x]; }
|
||
}
|
||
|
||
static Shot Render(Camera cam, int w, int h, Color bg)
|
||
{
|
||
var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||
rt.antiAliasing = 1; rt.filterMode = FilterMode.Point;
|
||
var prevT = cam.targetTexture; var prevActive = RenderTexture.active;
|
||
var prevCF = cam.clearFlags; var prevBG = 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 = prevT; RenderTexture.active = prevActive;
|
||
cam.clearFlags = prevCF; cam.backgroundColor = prevBG;
|
||
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[] Mask(Camera cam, int w, int h, out int x0, out int y0, out int x1, out int y1, out int count)
|
||
{
|
||
var b = Render(cam, w, h, Color.black);
|
||
var wht = Render(cam, w, h, Color.white);
|
||
var m = new bool[w * h];
|
||
x0 = w; y0 = h; x1 = -1; y1 = -1; count = 0;
|
||
for (int y = 0; y < h; y++)
|
||
for (int x = 0; x < w; x++)
|
||
{
|
||
int i = y * w + x;
|
||
int d = Mathf.Max(Mathf.Abs(b.px[i].r - wht.px[i].r), Mathf.Max(Mathf.Abs(b.px[i].g - wht.px[i].g), Mathf.Abs(b.px[i].b - wht.px[i].b)));
|
||
if (d < 128) { m[i] = true; count++; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
||
}
|
||
return m;
|
||
}
|
||
|
||
static int UniqueColors(Shot s, bool[] m)
|
||
{
|
||
var set = new HashSet<int>();
|
||
for (int i = 0; i < m.Length; i++) if (m[i]) set.Add((s.px[i].r << 16) | (s.px[i].g << 8) | s.px[i].b);
|
||
return set.Count;
|
||
}
|
||
|
||
// ── 본체 ────────────────────────────────────────────────────────────────
|
||
static void Body()
|
||
{
|
||
EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single);
|
||
L("scene = " + SCENE);
|
||
var root = FindRoot();
|
||
if (root == null) { L("!! PC root not found"); return; }
|
||
L("root = " + root.name + " pos=" + root.transform.position.ToString("F4") + " rot=" + root.transform.eulerAngles.ToString("F2") + " scale=" + root.transform.localScale.ToString("F4"));
|
||
|
||
var smrs = root.GetComponentsInChildren<SkinnedMeshRenderer>(true).Where(r => r.enabled && r.gameObject.activeInHierarchy).ToArray();
|
||
L("");
|
||
L("== SMR 전수 ==");
|
||
foreach (var r in smrs)
|
||
{
|
||
var mat = r.sharedMaterial;
|
||
var tex = mat != null && mat.HasProperty("_BaseMap") ? mat.GetTexture("_BaseMap") : (mat != null ? mat.mainTexture : null);
|
||
L(string.Format(" {0} | sub={1} | mat={2} | shader={3} | tex={4} {5}", r.name, r.sharedMesh != null ? r.sharedMesh.subMeshCount : -1,
|
||
mat != null ? mat.name : "null", mat != null ? mat.shader.name : "null",
|
||
tex != null ? tex.name : "null", tex != null ? ("(" + tex.width + "x" + tex.height + ")") : ""));
|
||
}
|
||
|
||
// 서브메시 인덱스 = SMR 컴포넌트가 mesh 의 어느 서브메시를 그리는가 (Suriyun 리그는 SMR 1개 = 서브메시 1개)
|
||
// face / hair / body / skin 구분
|
||
Func<SkinnedMeshRenderer, string> texName = r =>
|
||
{
|
||
var m = r.sharedMaterial; if (m == null) return "";
|
||
var t = m.HasProperty("_BaseMap") ? m.GetTexture("_BaseMap") : m.mainTexture;
|
||
return t != null ? t.name.ToLowerInvariant() : m.name.ToLowerInvariant();
|
||
};
|
||
var faceR = smrs.Where(r => texName(r).Contains("face")).ToArray();
|
||
var hairR = smrs.Where(r => texName(r).Contains("hair")).ToArray();
|
||
var skinR = smrs.Where(r => texName(r).Contains("skin")).ToArray();
|
||
var bodyR = smrs.Where(r => texName(r).Contains("m05")).ToArray();
|
||
L(string.Format(" 분류: face={0} hair={1} skin={2} body={3}", faceR.Length, hairR.Length, skinR.Length, bodyR.Length));
|
||
|
||
// 본
|
||
L("");
|
||
L("== 본(머리 관련) ==");
|
||
var bones = root.GetComponentsInChildren<Transform>(true);
|
||
Transform neck = null, head = null;
|
||
foreach (var b in bones)
|
||
{
|
||
if (b.name == "Neck_M" || b.name == "Head_M" || b.name == "HeadEnd_M" || b.name.Contains("Jaw") || b.name.StartsWith("Eye"))
|
||
{
|
||
L(" " + b.name + " world=" + b.position.ToString("F4"));
|
||
if (b.name == "Neck_M") neck = b;
|
||
if (b.name == "Head_M") head = b;
|
||
}
|
||
}
|
||
|
||
// UV 점유 (변형 없는 sharedMesh 의 UV — UV 는 스키닝으로 안 변한다)
|
||
L("");
|
||
L("== UV 점유 (텍스처 2048 기준) ==");
|
||
foreach (var r in smrs)
|
||
{
|
||
var mesh = r.sharedMesh; if (mesh == null) continue;
|
||
var uv = mesh.uv; if (uv == null || uv.Length == 0) continue;
|
||
// 이 SMR 이 그리는 서브메시만
|
||
var used = new HashSet<int>();
|
||
for (int s = 0; s < mesh.subMeshCount; s++) { }
|
||
var tris = mesh.triangles; // 전체 (SMR 이 서브메시 1개만 그려도 mesh 는 공유 · 아래에서 서브메시별로 다시 계산)
|
||
for (int s = 0; s < mesh.subMeshCount; s++)
|
||
{
|
||
var t2 = mesh.GetTriangles(s);
|
||
float u0 = 9, u1 = -9, v0 = 9, v1 = -9;
|
||
foreach (var i in t2) { var q = uv[i]; if (q.x < u0) u0 = q.x; if (q.x > u1) u1 = q.x; if (q.y < v0) v0 = q.y; if (q.y > v1) v1 = q.y; }
|
||
L(string.Format(" {0} sub{1}: u[{2:F4}..{3:F4}] v[{4:F4}..{5:F4}] → {6:F0} × {7:F0} 텍셀 (tri {8})",
|
||
r.name, s, u0, u1, v0, v1, (u1 - u0) * 2048, (v1 - v0) * 2048, t2.Length / 3));
|
||
}
|
||
break; // 메시는 6 SMR 이 공유 — 한 번만 출력
|
||
}
|
||
|
||
// 카메라
|
||
Camera scam = Camera.main;
|
||
if (scam == null) scam = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsInactive.Exclude, FindObjectsSortMode.None).FirstOrDefault(c => c.orthographic);
|
||
if (scam == null) { L("!! camera not found"); return; }
|
||
L("");
|
||
L("== 씬 카메라 == " + scam.name + " ortho=" + scam.orthographic + " size=" + scam.orthographicSize +
|
||
" pos=" + scam.transform.position.ToString("F4") + " euler=" + scam.transform.eulerAngles.ToString("F3"));
|
||
|
||
// 레이어 임시 변경 (씬 저장 안 함)
|
||
var origLayer = new Dictionary<Transform, int>();
|
||
foreach (var t in bones) { origLayer[t] = t.gameObject.layer; t.gameObject.layer = LAYER; }
|
||
|
||
try
|
||
{
|
||
Measure("A. 현재 카메라 (ortho 10 · 1080×1920)", scam, 10f, 1080, 1920, root, smrs, faceR, hairR, skinR, bodyR, neck);
|
||
Measure("B. 도트A (ortho 3.4 · 135×240)", scam, 3.4f, 135, 240, root, smrs, faceR, hairR, skinR, bodyR, neck);
|
||
}
|
||
finally
|
||
{
|
||
foreach (var kv in origLayer) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value;
|
||
}
|
||
}
|
||
|
||
static void Measure(string title, Camera scam, float ortho, int W, int H, GameObject root,
|
||
SkinnedMeshRenderer[] all, SkinnedMeshRenderer[] faceR, SkinnedMeshRenderer[] hairR,
|
||
SkinnedMeshRenderer[] skinR, SkinnedMeshRenderer[] bodyR, Transform neck)
|
||
{
|
||
L("");
|
||
L("════════ " + title + " ════════");
|
||
|
||
// 캐릭터만 찍는 임시 카메라
|
||
var go = new GameObject("WL814u_Cam");
|
||
var cam = go.AddComponent<Camera>();
|
||
cam.CopyFrom(scam);
|
||
cam.orthographic = true; cam.orthographicSize = ortho;
|
||
cam.cullingMask = 1 << LAYER;
|
||
cam.targetTexture = null;
|
||
cam.transform.SetPositionAndRotation(scam.transform.position, scam.transform.rotation);
|
||
var ucd = cam.GetUniversalAdditionalCameraData();
|
||
if (ucd != null) { ucd.renderPostProcessing = false; ucd.renderShadows = true; ucd.SetRenderer(0); }
|
||
|
||
// 캐릭터를 화면 중앙에 (직교라 시선방향 이동은 화면을 안 바꾼다 → 좌우/상하만 맞춘다)
|
||
var b = new Bounds(root.transform.position, Vector3.zero);
|
||
foreach (var r in all) b.Encapsulate(r.bounds);
|
||
var center = b.center;
|
||
cam.transform.position = center - cam.transform.forward * 50f;
|
||
|
||
// 전체 씬(배경 포함) 컬러 — 부위 색 수 세기용
|
||
var fullCam = new GameObject("WL814u_FullCam").AddComponent<Camera>();
|
||
fullCam.CopyFrom(scam);
|
||
fullCam.orthographic = true; fullCam.orthographicSize = ortho;
|
||
fullCam.transform.SetPositionAndRotation(cam.transform.position, cam.transform.rotation);
|
||
fullCam.targetTexture = null;
|
||
var fd = fullCam.GetUniversalAdditionalCameraData(); if (fd != null) { fd.renderPostProcessing = false; fd.SetRenderer(0); }
|
||
var full = Render(fullCam, W, H, Color.black);
|
||
|
||
float pxPerM = H / (2f * ortho);
|
||
L(string.Format(" 화면 {0}×{1} · ortho {2} · 1 m = {3:F3} px · 1 px = {4:F5} m", W, H, ortho, pxPerM, 1f / pxPerM));
|
||
|
||
// 전체 실루엣
|
||
int x0, y0, x1, y1, cnt;
|
||
SetOnly(all, all);
|
||
var mAll = Mask(cam, W, H, out x0, out y0, out x1, out y1, out cnt);
|
||
int hAll = y1 - y0 + 1, wAll = x1 - x0 + 1;
|
||
L(string.Format(" 🔴 캐릭터 전체 실루엣: 키 {0} px · 폭 {1} px · 면적 {2} px (bbox x[{3}..{4}] y[{5}..{6}])", hAll, wAll, cnt, x0, x1, y0, y1));
|
||
L(string.Format(" 화면 세로의 {0:F1} % · 고유 색 {1}", 100f * hAll / H, UniqueColors(full, mAll)));
|
||
|
||
// 머리 = Neck_M 스크린 y 위쪽
|
||
if (neck != null)
|
||
{
|
||
var sp = cam.WorldToScreenPoint(neck.position);
|
||
int neckY = Mathf.RoundToInt(sp.y);
|
||
int headPx = y1 - neckY + 1;
|
||
// 머리 폭 = 목 위쪽 행들의 최대 폭
|
||
int hx0 = W, hx1 = -1, hcnt = 0;
|
||
for (int y = Mathf.Max(0, neckY); y <= y1; y++)
|
||
for (int x = 0; x < W; x++) if (mAll[y * W + x]) { hcnt++; if (x < hx0) hx0 = x; if (x > hx1) hx1 = x; }
|
||
L(string.Format(" 🔴 머리(Neck_M {0} px ~ 정수리 {1} px): 높이 {2} px · 폭 {3} px · 면적 {4} px · 머리:키 = 1:{5:F2}",
|
||
neckY, y1, headPx, hx1 - hx0 + 1, hcnt, (float)hAll / Mathf.Max(1, headPx)));
|
||
}
|
||
|
||
// 부위별
|
||
foreach (var pair in new[] {
|
||
new KeyValuePair<string, SkinnedMeshRenderer[]>("face02(얼굴 데칼)", faceR),
|
||
new KeyValuePair<string, SkinnedMeshRenderer[]>("Hair05(머리칼)", hairR),
|
||
new KeyValuePair<string, SkinnedMeshRenderer[]>("skin(피부)", skinR),
|
||
new KeyValuePair<string, SkinnedMeshRenderer[]>("M05(몸/갑옷)", bodyR) })
|
||
{
|
||
if (pair.Value.Length == 0) continue;
|
||
SetOnly(all, pair.Value);
|
||
int fx0, fy0, fx1, fy1, fc;
|
||
var m = Mask(cam, W, H, out fx0, out fy0, out fx1, out fy1, out fc);
|
||
if (fc == 0) { L(" " + pair.Key + ": 화면에 0 px (가려짐)"); continue; }
|
||
L(string.Format(" {0}: {1} × {2} px · 면적 {3} px · 고유 색 {4} (bbox x[{5}..{6}] y[{7}..{8}])",
|
||
pair.Key, fx1 - fx0 + 1, fy1 - fy0 + 1, fc, UniqueColors(full, m), fx0, fx1, fy0, fy1));
|
||
|
||
if (pair.Value == faceR)
|
||
{
|
||
float sx = (float)(fx1 - fx0 + 1) / (DRAWN_X1 - DRAWN_X0 + 1);
|
||
float sy = (float)(fy1 - fy0 + 1) / (DRAWN_Y1 - DRAWN_Y0 + 1);
|
||
L(string.Format(" 🔴 얼굴 실효 해상도: 텍셀 1개 = 화면 {0:F4} × {1:F4} px (= 텍스처 {2}×{3} 텍셀이 화면 {4}×{5} px 로 찍힌다)",
|
||
sx, sy, DRAWN_X1 - DRAWN_X0 + 1, DRAWN_Y1 - DRAWN_Y0 + 1, fx1 - fx0 + 1, fy1 - fy0 + 1));
|
||
L(string.Format(" 🔴 눈 1개 = 화면 {0:F2} × {1:F2} px · 눈썹 1개 = {2:F2} × {3:F2} px · 입 = {4:F2} × {5:F2} px",
|
||
EYE_W * sx, EYE_H * sy, BROW_W * sx, BROW_H * sy, MOUTH_W * sx, MOUTH_H * sy));
|
||
L(string.Format(" 텍스처 2048 기준 축소율 = 1/{0:F0} (가로) · 1/{1:F0} (세로)", 1f / sx, 1f / sy));
|
||
}
|
||
}
|
||
SetOnly(all, all);
|
||
|
||
UnityEngine.Object.DestroyImmediate(go);
|
||
UnityEngine.Object.DestroyImmediate(fullCam.gameObject);
|
||
}
|
||
|
||
static void SetOnly(SkinnedMeshRenderer[] all, SkinnedMeshRenderer[] on)
|
||
{
|
||
var set = new HashSet<SkinnedMeshRenderer>(on);
|
||
foreach (var r in all) r.enabled = set.Contains(r);
|
||
}
|
||
}
|