From 8a4d25aa96f86079b4b0d287cf21cb831c64ba3b Mon Sep 17 00:00:00 2001 From: swrring Date: Sat, 12 Sep 2026 23:49:17 +0900 Subject: [PATCH] =?UTF-8?q?[WL-814u]=20=ED=94=BD=EC=85=80=20=EB=96=A8?= =?UTF-8?q?=EB=A6=BC=20=EC=99=84=ED=99=94=20=EB=8C=80=EC=B1=85=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1(=EA=B2=A9=EC=9E=90=20=EC=8A=A4=EB=83=85=C2=B7?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=20=EC=96=91=EC=9E=90=ED=99=94=C2=B7=ED=95=B4?= =?UTF-8?q?=EC=83=81=EB=8F=84=C2=B7=EC=99=B8=EA=B3=BD=EC=84=A0=20=EB=91=90?= =?UTF-8?q?=EA=BB=98)=20(#814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- AgentScripts/WL814u_CRAWL.txt | 36 +++ AgentScripts/WL814u_Crawl.cs | 403 ++++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 AgentScripts/WL814u_CRAWL.txt create mode 100644 AgentScripts/WL814u_Crawl.cs diff --git a/AgentScripts/WL814u_CRAWL.txt b/AgentScripts/WL814u_CRAWL.txt new file mode 100644 index 000000000..443394ffc --- /dev/null +++ b/AgentScripts/WL814u_CRAWL.txt @@ -0,0 +1,36 @@ +== ⓓ-1 해상도별 회전 떨림 (캐릭터 yaw 0.25°/프레임 × 32회 · 이동분 제거 후 비교) == + | 설정 | 캐릭터 키 | 모양 변화 %/프레임 | 색 변화 %/프레임 | 1° 환산 모양 % | + | 도트A 240줄 (ortho 3.4) | 37 px | **0.31 %** | **2.8 %** | 1.2 % | + | 360줄 (ortho 3.4) | 55 px | **0.29 %** | **2.8 %** | 1.2 % | + | 480줄 (ortho 3.4) | 73 px | **0.28 %** | **2.5 %** | 1.1 % | + | 현재 1920줄 (ortho 10) | 100 px | **0.77 %** | **5.1 %** | 3.1 % | + +== ⓓ-2 회전 계단화 (yaw 를 N방향으로 반올림 · 도트A 240줄) == + | 방향 수 | 각도 간격 | 모양 변화 평균 %/프레임 | 그중 최대(계단 순간) % | 계단이 생긴 프레임 수/32 | + | 양자화 없음(연속) | - | **0.23 %** | 0.73 % | 1 | + | 32 | 11.3° | **0.57 %** | 18.38 % | 1 | + | 16 | 22.5° | **0.00 %** | 0.00 % | 0 | + | 8 | 45.0° | **1.04 %** | 33.42 % | 1 | + +== ⓓ-3 이동 시 떨림 · 픽셀 격자 스냅(VoxelGridAdjuster 방식) 유/무 (도트A 240줄) == + 픽셀 1개 = 0.02833 m · 프레임당 이동 0.37 px (일부러 격자에 안 맞는 값) + | 스냅 | 모양 변화 평균 %/프레임 | 최대 % | + | 끔 | **11.09 %** | 15.84 % | + | 켬(격자 스냅) | **0.00 %** | 0.00 % | + +== ⓓ-4 외곽선 두께 vs 떨림 (회전 중 · 도트A 240줄 · 외곽선 = 실루엣을 N px 부풀린 띠) == + | 외곽선 두께 | 띠 면적(px) | 띠가 프레임마다 바뀌는 비율 % | 캐릭터 대비 띠 비중 % | + | 1 px | 109 | **2.1 %** | 27 % | + | 2 px | 211 | **1.3 %** | 52 % | + | 3 px | 310 | **1.0 %** | 76 % | + +== 캡처 == + u_6_crawl_rotation.png — 회전 8프레임: 위=양자화 없음 / 아래=16방향 계단화 + u_7_crawl_move_snap.png — 이동 8프레임: 위=스냅 없음 / 아래=픽셀 격자 스냅 + +== ⓓ-2(재측정) 회전 계단화 · 90° 회전 (1°/프레임 × 90) · 도트A 240줄 == + | 방향 수 | 각도 간격 | 떨림 0 인 프레임 | 모양 변화 평균 %/프레임 | 계단 순간 최대 % | 계단 횟수/90° | + | 양자화 없음 | - | **0/90** | **2.89 %** | 22.6 % | 81 | + | 32 | 11.3° | **82/90** | **1.55 %** | 28.1 % | 8 | + | 16 | 22.5° | **86/90** | **1.31 %** | 47.2 % | 4 | + | 8 | 45.0° | **88/90** | **1.04 %** | 59.3 % | 2 | diff --git a/AgentScripts/WL814u_Crawl.cs b/AgentScripts/WL814u_Crawl.cs new file mode 100644 index 000000000..4efd97aff --- /dev/null +++ b/AgentScripts/WL814u_Crawl.cs @@ -0,0 +1,403 @@ +// 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); + } +}