// WL814u_Crawl.cs — 「3D 를 유지한 채 픽셀 떨림을 줄이는 대책」 실측 (에디터 전용 · 발주서 WL-814u 범위변경 ⓓ) // 🔴 씬 저장 0 · 에셋 수정 0 · ProjectSettings 0. // // 떨림 수치 정의 (근거를 남긴다) // 두 연속 프레임의 캐릭터 실루엣을 각자의 bbox 왼쪽아래로 맞춘 뒤(= 이동분을 뺀 뒤) 비교한다. // shapeFlip % = XOR(실루엣A, 실루엣B) / 두 면적 평균 ← 「모양이 몇 % 바뀌었나」 // colorΔ % = 둘 다 덮인 픽셀 중 색 차이>24 인 비율 ← 「색이 몇 % 흔들렸나」 // 이동분을 빼기 때문에 「캐릭터가 지나간다」는 정상 움직임은 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 WL814u_Crawl { const string SCENE = "Assets/WL/Look/Arena/Scenes/WL_ArenaProto.unity"; const string SHOT = "Screenshots_WL/WL814u/"; const string OUT = "AgentScripts/WL814u_CRAWL.txt"; const int LAYER = 31; static readonly StringBuilder sb = new StringBuilder(); static void L(string s) { sb.AppendLine(s); Console.WriteLine("[814uX] " + s); } class Shot { public Color32[] px; public int w, h; } class Frame { public bool[] m; public Shot c; public int x0, y0, x1, y1, area; } static GameObject root; static SkinnedMeshRenderer[] smrs; static Camera scam; static Camera cam, mcam; static Shot Render(Camera c, int w, int h) { var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); rt.antiAliasing = 1; rt.filterMode = FilterMode.Point; var pA = RenderTexture.active; c.targetTexture = rt; c.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(); c.targetTexture = null; RenderTexture.active = pA; var s = new Shot { px = t.GetPixels32(), w = w, h = h }; UnityEngine.Object.DestroyImmediate(t); rt.Release(); UnityEngine.Object.DestroyImmediate(rt); return s; } static Frame Grab(int w, int h) { mcam.backgroundColor = Color.black; var a = Render(mcam, w, h); mcam.backgroundColor = Color.white; var b = Render(mcam, w, h); var f = new Frame { m = new bool[w * h], c = a, x0 = w, y0 = h, x1 = -1, y1 = -1 }; for (int i = 0; i < f.m.Length; i++) { int d = Mathf.Max(Mathf.Abs(a.px[i].r - b.px[i].r), Mathf.Max(Mathf.Abs(a.px[i].g - b.px[i].g), Mathf.Abs(a.px[i].b - b.px[i].b))); if (d < 128) { f.m[i] = true; f.area++; int x = i % w, y = i / w; if (x < f.x0) f.x0 = x; if (x > f.x1) f.x1 = x; if (y < f.y0) f.y0 = y; if (y > f.y1) f.y1 = y; } } return f; } // bbox 왼쪽아래 기준으로 맞춘 뒤 비교 static void Compare(Frame A, Frame B, int w, int h, out float shapeFlip, out float colorD) { int dx = B.x0 - A.x0, dy = B.y0 - A.y0; int xor = 0, both = 0, chg = 0; for (int y = A.y0; y <= A.y1; y++) for (int x = A.x0; x <= A.x1; x++) { int bx = x + dx, by = y + dy; bool ma = A.m[y * w + x]; bool mb = (bx >= 0 && by >= 0 && bx < w && by < h) && B.m[by * w + bx]; if (ma != mb) xor++; else if (ma) { both++; var ca = A.c.px[y * w + x]; var cb = B.c.px[by * w + bx]; if (Mathf.Abs(ca.r - cb.r) + Mathf.Abs(ca.g - cb.g) + Mathf.Abs(ca.b - cb.b) > 24) chg++; } } shapeFlip = 200f * xor / Mathf.Max(1, A.area + B.area); colorD = 100f * chg / Mathf.Max(1, both); } static bool[] Dilate(bool[] m, int w, int h, int n) { var cur = (bool[])m.Clone(); for (int k = 0; k < n; k++) { var nx = (bool[])cur.Clone(); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if (cur[y * w + x]) { if (x > 0) nx[y * w + x - 1] = true; if (x < w - 1) nx[y * w + x + 1] = true; if (y > 0) nx[(y - 1) * w + x] = true; if (y < h - 1) nx[(y + 1) * w + x] = true; } cur = nx; } return cur; } static void Setup(float ortho, int w, int h) { if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject); if (mcam != null) UnityEngine.Object.DestroyImmediate(mcam.gameObject); var b = new Bounds(root.transform.position, Vector3.zero); foreach (var r in smrs) b.Encapsulate(r.bounds); cam = new GameObject("WL814u_X").AddComponent(); cam.CopyFrom(scam); cam.orthographic = true; cam.orthographicSize = ortho; cam.aspect = (float)w / h; cam.targetTexture = null; cam.transform.SetPositionAndRotation(b.center - scam.transform.forward * 50f, scam.transform.rotation); var d1 = cam.GetUniversalAdditionalCameraData(); if (d1 != null) { d1.renderPostProcessing = false; d1.SetRenderer(0); } mcam = new GameObject("WL814u_XM").AddComponent(); mcam.CopyFrom(cam); mcam.cullingMask = 1 << LAYER; mcam.clearFlags = CameraClearFlags.SolidColor; mcam.aspect = (float)w / h; var d2 = mcam.GetUniversalAdditionalCameraData(); if (d2 != null) { d2.renderPostProcessing = false; d2.SetRenderer(0); } for (int i = 0; i < 3; i++) Render(mcam, w, h); // 워밍업(첫 렌더는 텍스처 미로드) } // PixelCameraManager.PositionToGrid 와 같은 식: 카메라 축 기준 픽셀 격자에 반올림 static Vector3 ToGrid(Vector3 p, float pixelWorld) { var t = cam.transform; float rx = Vector3.Dot(p, t.right), ry = Vector3.Dot(p, t.up), rz = Vector3.Dot(p, t.forward); rx = Mathf.Round(rx / pixelWorld) * pixelWorld; ry = Mathf.Round(ry / pixelWorld) * pixelWorld; rz = Mathf.Round(rz / pixelWorld) * pixelWorld; return rx * t.right + ry * t.up + rz * t.forward; } 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)); } } // ⓓ-2 재측정 — 1차는 8° 만 돌려서 16방향이 경계를 한 번도 안 넘었다(0.00 % 는 측정 구간 탓). // 이번엔 90° 를 돌려 모든 양자화가 경계를 여러 번 넘게 한다. public static void Run2() { try { EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single); foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) if (go.name.Contains("LH_M05")) root = go; smrs = root.GetComponentsInChildren(true).Where(r => r.enabled).ToArray(); scam = Camera.main ?? UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).First(c => c.orthographic); var allT = root.GetComponentsInChildren(true); var orig = new Dictionary(); foreach (var t in allT) { orig[t] = t.gameObject.layer; t.gameObject.layer = LAYER; } var baseRot = root.transform.rotation; float yaw0 = scam.transform.eulerAngles.y + 180f; Setup(3.4f, 135, 240); const int N = 91; const float STEP = 1.0f; // 1°/프레임 × 90 = 90° 회전 L(""); L("== ⓓ-2(재측정) 회전 계단화 · 90° 회전 (1°/프레임 × 90) · 도트A 240줄 =="); L(" | 방향 수 | 각도 간격 | 떨림 0 인 프레임 | 모양 변화 평균 %/프레임 | 계단 순간 최대 % | 계단 횟수/90° |"); foreach (int dirs in new[] { 0, 32, 16, 8 }) { float q = dirs == 0 ? 0f : 360f / dirs; Frame prev = null; var sf = new List(); int zero = 0, steps = 0; float mx = 0; for (int i = 0; i < N; i++) { float y = yaw0 + i * STEP; if (q > 0f) y = Mathf.Round(y / q) * q; root.transform.rotation = Quaternion.Euler(0, y, 0); var f = Grab(135, 240); if (prev != null) { Compare(prev, f, 135, 240, out float s, out float c); sf.Add(s); if (s < 0.01f) zero++; if (s > 0.5f) steps++; if (s > mx) mx = s; } prev = f; } L(string.Format(" | {0} | {1} | **{2}/90** | **{3:F2} %** | {4:F1} % | {5} |", dirs == 0 ? "양자화 없음" : dirs.ToString(), dirs == 0 ? "-" : q.ToString("F1") + "°", zero, sf.Average(), mx, steps)); } root.transform.rotation = baseRot; foreach (var kv in orig) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value; if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject); if (mcam != null) UnityEngine.Object.DestroyImmediate(mcam.gameObject); } catch (Exception e) { L("EXCEPTION " + e); } finally { File.AppendAllText(OUT, sb.ToString(), new UTF8Encoding(false)); } } static void Body() { EditorSceneManager.OpenScene(SCENE, OpenSceneMode.Single); foreach (var go in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) if (go.name.Contains("LH_M05")) root = go; smrs = root.GetComponentsInChildren(true).Where(r => r.enabled).ToArray(); scam = Camera.main ?? UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).First(c => c.orthographic); var all = root.GetComponentsInChildren(true); var orig = new Dictionary(); foreach (var t in all) { orig[t] = t.gameObject.layer; t.gameObject.layer = LAYER; } float camYaw = scam.transform.eulerAngles.y; var basePos = root.transform.position; var baseRot = root.transform.rotation; root.transform.rotation = Quaternion.Euler(0, camYaw + 180f, 0); float yaw0 = camYaw + 180f; const int N = 33; // 33 프레임 = 32 비교 const float STEP = 0.25f; // 프레임당 0.25° 회전 (걷다가 방향 트는 속도 수준) try { // ── 1) 해상도별 회전 떨림 ──────────────────────────────────── L("== ⓓ-1 해상도별 회전 떨림 (캐릭터 yaw 0.25°/프레임 × 32회 · 이동분 제거 후 비교) =="); L(" | 설정 | 캐릭터 키 | 모양 변화 %/프레임 | 색 변화 %/프레임 | 1° 환산 모양 % |"); var res = new (string name, float ortho, int w, int h)[] { ("도트A 240줄 (ortho 3.4)", 3.4f, 135, 240), ("360줄 (ortho 3.4)", 3.4f, 203, 360), ("480줄 (ortho 3.4)", 3.4f, 270, 480), ("현재 1920줄 (ortho 10)", 10f, 1080, 1920), }; foreach (var r in res) { Setup(r.ortho, r.w, r.h); var (sf, cd, hgt) = RotSweep(r.w, r.h, yaw0, STEP, N, 0f); L(string.Format(" | {0} | {1} px | **{2:F2} %** | **{3:F1} %** | {4:F1} % |", r.name, hgt, sf, cd, sf / STEP)); } // ── 2) 회전 양자화 ────────────────────────────────────────── L(""); L("== ⓓ-2 회전 계단화 (yaw 를 N방향으로 반올림 · 도트A 240줄) =="); L(" | 방향 수 | 각도 간격 | 모양 변화 평균 %/프레임 | 그중 최대(계단 순간) % | 계단이 생긴 프레임 수/32 |"); Setup(3.4f, 135, 240); foreach (int dirs in new[] { 0, 32, 16, 8 }) { float q = dirs == 0 ? 0f : 360f / dirs; var (sf, cd, _) = RotSweep(135, 240, yaw0, STEP, N, q, out float mx, out int steps); L(string.Format(" | {0} | {1} | **{2:F2} %** | {3:F2} % | {4} |", dirs == 0 ? "양자화 없음(연속)" : dirs.ToString(), dirs == 0 ? "-" : (q.ToString("F1") + "°"), sf, mx, steps)); } // ── 3) 이동 + 픽셀 격자 스냅 ──────────────────────────────── L(""); L("== ⓓ-3 이동 시 떨림 · 픽셀 격자 스냅(VoxelGridAdjuster 방식) 유/무 (도트A 240줄) =="); Setup(3.4f, 135, 240); float pixelWorld = 2f * 3.4f / 240f; L(string.Format(" 픽셀 1개 = {0:F5} m · 프레임당 이동 0.37 px (일부러 격자에 안 맞는 값)", pixelWorld)); L(" | 스냅 | 모양 변화 평균 %/프레임 | 최대 % |"); foreach (bool snap in new[] { false, true }) { root.transform.rotation = Quaternion.Euler(0, yaw0, 0); Frame prev = null; var sfs = new List(); for (int i = 0; i < N; i++) { var p = basePos + cam.transform.right * (0.37f * pixelWorld * i); root.transform.position = snap ? ToGrid(p, pixelWorld) : p; var f = Grab(135, 240); if (prev != null) { Compare(prev, f, 135, 240, out float s, out float c); sfs.Add(s); } prev = f; } root.transform.position = basePos; L(string.Format(" | {0} | **{1:F2} %** | {2:F2} % |", snap ? "켬(격자 스냅)" : "끔", sfs.Average(), sfs.Max())); } // ── 4) 외곽선 두께와 떨림 ─────────────────────────────────── L(""); L("== ⓓ-4 외곽선 두께 vs 떨림 (회전 중 · 도트A 240줄 · 외곽선 = 실루엣을 N px 부풀린 띠) =="); Setup(3.4f, 135, 240); L(" | 외곽선 두께 | 띠 면적(px) | 띠가 프레임마다 바뀌는 비율 % | 캐릭터 대비 띠 비중 % |"); var frames = new List(); for (int i = 0; i < 17; i++) { root.transform.rotation = Quaternion.Euler(0, yaw0 + i * STEP, 0); frames.Add(Grab(135, 240)); } foreach (int th in new[] { 1, 2, 3 }) { var bandFlip = new List(); var bandArea = new List(); var ratio = new List(); for (int i = 1; i < frames.Count; i++) { var A = frames[i - 1]; var B = frames[i]; var dA = Dilate(A.m, 135, 240, th); var dB = Dilate(B.m, 135, 240, th); int dx = B.x0 - A.x0, dy = B.y0 - A.y0; int xor = 0, ar = 0; for (int y = 1; y < 239; y++) for (int x = 1; x < 134; x++) { bool ba = dA[y * 135 + x] && !A.m[y * 135 + x]; int bxx = x + dx, byy = y + dy; bool bb = (bxx > 0 && byy > 0 && bxx < 134 && byy < 239) && dB[byy * 135 + bxx] && !B.m[byy * 135 + bxx]; if (ba) ar++; if (ba != bb) xor++; } bandFlip.Add(100f * xor / Mathf.Max(1, ar)); bandArea.Add(ar); ratio.Add(100f * ar / A.area); } L(string.Format(" | {0} px | {1:F0} | **{2:F1} %** | {3:F0} % |", th, bandArea.Average(), bandFlip.Average(), ratio.Average())); } // ── 5) 연속 프레임 비교 이미지 ────────────────────────────── L(""); L("== 캡처 =="); Strip("u_6_crawl_rotation.png", 3.4f, 135, 240, yaw0, STEP, 0f, 16f, "회전 8프레임: 위=양자화 없음 / 아래=16방향 계단화"); StripMove("u_7_crawl_move_snap.png", 3.4f, 135, 240, yaw0, basePos); } finally { root.transform.rotation = baseRot; root.transform.position = basePos; foreach (var kv in orig) if (kv.Key != null) kv.Key.gameObject.layer = kv.Value; if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject); if (mcam != null) UnityEngine.Object.DestroyImmediate(mcam.gameObject); } } static (float, float, int) RotSweep(int w, int h, float yaw0, float step, int n, float q) { return RotSweep(w, h, yaw0, step, n, q, out _, out _); } static (float, float, int) RotSweep(int w, int h, float yaw0, float step, int n, float q, out float maxFlip, out int stepCount) { Frame prev = null; var sf = new List(); var cd = new List(); int hgt = 0; maxFlip = 0; stepCount = 0; for (int i = 0; i < n; i++) { float y = yaw0 + i * step; if (q > 0f) y = Mathf.Round(y / q) * q; root.transform.rotation = Quaternion.Euler(0, y, 0); var f = Grab(w, h); hgt = f.y1 - f.y0 + 1; if (prev != null) { Compare(prev, f, w, h, out float s, out float c); sf.Add(s); cd.Add(c); if (s > maxFlip) maxFlip = s; if (s > 0.5f) stepCount++; } prev = f; } return (sf.Average(), cd.Average(), hgt); } // 회전 8프레임 × 2행(양자화 없음 / 양자화) static void Strip(string file, float ortho, int w, int h, float yaw0, float step, float q1, float dirs2, string note) { Setup(ortho, w, h); int zoom = 6, crop = 48; var rows = new List(); foreach (float dirs in new[] { 0f, dirs2 }) { float q = dirs == 0f ? 0f : 360f / dirs; var row = new List(); for (int i = 0; i < 8; i++) { float y = yaw0 + i * step * 4f; // 8프레임에 8° 회전 if (q > 0f) y = Mathf.Round(y / q) * q; root.transform.rotation = Quaternion.Euler(0, y, 0); row.Add(Render(cam, w, h)); } rows.Add(row.ToArray()); } // 합성 int cw = crop, chh = crop, gap = 4; int ow = (cw * zoom + gap) * 8 - gap, oh = (chh * zoom + gap) * rows.Count - gap; var o = new Shot { w = ow, h = oh, px = new Color32[ow * oh] }; for (int i = 0; i < o.px.Length; i++) o.px[i] = new Color32(24, 24, 28, 255); int cx = w / 2 - crop / 2, cy = h / 2 - crop / 2; for (int r = 0; r < rows.Count; r++) for (int k = 0; k < 8; k++) for (int y = 0; y < chh * zoom; y++) for (int x = 0; x < cw * zoom; x++) { int sx = cx + x / zoom, sy = cy + y / zoom; if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue; int dxp = k * (cw * zoom + gap) + x; int dyp = (rows.Count - 1 - r) * (chh * zoom + gap) + y; o.px[dyp * ow + dxp] = rows[r][k].px[sy * w + sx]; } SaveShot(o, file); L(" " + file + " — " + note); } static void StripMove(string file, float ortho, int w, int h, float yaw0, Vector3 basePos) { Setup(ortho, w, h); root.transform.rotation = Quaternion.Euler(0, yaw0, 0); float pixelWorld = 2f * ortho / h; int zoom = 6, crop = 48, gap = 4; var rows = new List(); foreach (bool snap in new[] { false, true }) { var row = new List(); for (int i = 0; i < 8; i++) { var p = basePos + cam.transform.right * (0.37f * pixelWorld * i); root.transform.position = snap ? ToGrid(p, pixelWorld) : p; row.Add(Render(cam, w, h)); } rows.Add(row.ToArray()); } root.transform.position = basePos; int ow = (crop * zoom + gap) * 8 - gap, oh = (crop * zoom + gap) * 2 - gap; var o = new Shot { w = ow, h = oh, px = new Color32[ow * oh] }; for (int i = 0; i < o.px.Length; i++) o.px[i] = new Color32(24, 24, 28, 255); int cx = w / 2 - crop / 2, cy = h / 2 - crop / 2; for (int r = 0; r < 2; r++) for (int k = 0; k < 8; k++) for (int y = 0; y < crop * zoom; y++) for (int x = 0; x < crop * zoom; x++) { int sx = cx + x / zoom, sy = cy + y / zoom; if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue; o.px[((1 - r) * (crop * zoom + gap) + y) * ow + k * (crop * zoom + gap) + x] = rows[r][k].px[sy * w + sx]; } SaveShot(o, file); L(" " + file + " — 이동 8프레임: 위=스냅 없음 / 아래=픽셀 격자 스냅"); } static void SaveShot(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); } }