// WL-814k 프로브 + 캡처 4장 (게임 코드 아님 · 에디트 모드 전용 · 임시 씬 저장 0 · 커밋 0) // probe: unity command run_script --file AgentScripts/WL814k_Probe.cs --entry WL814k_Probe.RunAll // cap : unity command run_script --file AgentScripts/WL814k_Probe.cs --entry WL814k_Probe.Capture // 🔴 원본 프리팹/머티리얼/애니메이션 수정 0. 씬 저장 0. using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Profiling; using UnityEngine.SceneManagement; using WL.Look.SpriteBake; public static class WL814k_Probe { const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k"; const string kTxt = "AgentScripts/WL814k_PROBE.txt"; const string kTxtCap = "AgentScripts/WL814k_CAPTURE.txt"; const string kRes = "Assets/WL/Look/Sprite/Resources/WL"; const string kPcPrefab = "Assets/Res_Addr/PC/Ai01.prefab"; const string kMobPrefab = "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab"; // 도트A 프리셋 실측 = orthoSize 3.4 · pixelHeight 240 · 내림각 43.609° const float kOrtho = 3.4f, kPitch = 43.609f; const int kLowH = 240, kLowW = 135, kHiW = 1080, kHiH = 1920; static StringBuilder s_sb; static int s_pass, s_fail; static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } static void Chk(string n, bool ok, string d) { if (ok) s_pass++; else s_fail++; L((ok ? " PASS " : " FAIL ") + n + " — " + d); } static void Write(string p) { File.WriteAllText(p, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814k] → " + p); } // ═════════════════════════════════════════════════════════════════════════ // 프로브 // ═════════════════════════════════════════════════════════════════════════ public static void RunAll() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814k 프로브 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion); try { A_Assets(); EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); B_AttachRestore(); C_FrameDir(); D_Gc(); E_Sheet(); F_C8(); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { SpriteBillboard.RestoreNow("probe-end"); WLSpriteLookSettings.RuntimeDisabled = false; EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " — " + s_pass + " pass / " + s_fail + " fail"); Write(kTxt); } // ── A. 에셋 · 메타 ────────────────────────────────────────────────────── static void A_Assets() { L(""); L("== A. 에셋 · 메타 =="); var bake = AssetDatabase.LoadAssetAtPath(kRes + "/WLSpriteBakeSettings.asset"); var look = AssetDatabase.LoadAssetAtPath(kRes + "/WLSpriteLookSettings.asset"); Chk("A1 굽기 SO", bake != null, bake == null ? "없음" : "내림각 " + bake.PitchDeg.ToString("0.000") + "° · 픽셀 " + bake.WorldPerPixel.ToString("0.00000") + " m"); Chk("A2 런타임 SO", look != null && look.sets != null && look.sets.Length == 2, look == null ? "없음" : "sets " + look.sets.Length); if (look == null) return; foreach (var s in look.sets) { Chk("A3 시트 " + (s == null ? "null" : s.name), s != null && s.IsUsable, s == null ? "null" : string.Format("{0} · 프레임 {1}×{2} · {3}열×{4}행 · 방향 {5} · 총 {6}칸 · 텍스처 {7}×{8}", s.actorNamePrefix, s.frameWidth, s.frameHeight, s.columns, s.rows, s.dirCount, s.TotalFrames, s.sheet == null ? 0 : s.sheet.width, s.sheet == null ? 0 : s.sheet.height)); if (s == null || s.sheet == null) continue; var ti = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(s.sheet)) as TextureImporter; Chk("A4 임포트 " + s.name, ti != null && ti.filterMode == FilterMode.Point && !ti.mipmapEnabled && ti.textureCompression == TextureImporterCompression.Uncompressed && ti.alphaIsTransparency, ti == null ? "임포터 없음" : "filter " + ti.filterMode + " · mipmap " + ti.mipmapEnabled + " · 압축 " + ti.textureCompression + " · alphaIsTransparency " + ti.alphaIsTransparency + " · maxSize " + ti.maxTextureSize + " · type " + ti.textureType); int need = s.TotalFrames; int cap = s.columns * s.rows; Chk("A5 격자 " + s.name, cap >= need && s.sheet.width == s.columns * s.frameWidth && s.sheet.height == s.rows * s.frameHeight, "칸 " + cap + " ≥ 필요 " + need + " · 텍스처 " + s.sheet.width + "×" + s.sheet.height); var act = new StringBuilder(); foreach (var a in s.actions) act.Append(a.key + "[" + a.startIndex + "+" + a.frameCount + "] "); Chk("A6 동작표 " + s.name, s.actions.Length >= 4, s.actions.Length + "개 · " + act.ToString().TrimEnd()); } } // ── B. 부착 → 원복 ────────────────────────────────────────────────────── static void B_AttachRestore() { L(""); L("== B. 부착 → 원복 (원본 파괴 0 · 렌더러 원값 복원) =="); var look = WLSpriteLookSettings.Instance; var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f); var set = SpriteBillboard.MatchSet(actor.name, look); Chk("B1 이름 매칭", set != null, "액터 '" + actor.name + "' → " + (set == null ? "매칭 실패" : set.name)); if (set == null) { UnityEngine.Object.DestroyImmediate(actor); return; } var smrs = actor.GetComponentsInChildren(true); int before = 0; foreach (var r in smrs) if (r.enabled) before++; int childBefore = CountAll(actor); var e = SpriteBillboard.TryAttach(actor, set); int after = 0; foreach (var r in smrs) if (r.enabled) after++; Chk("B2 원본 끔", e != null && after == 0 && before > 0, "SMR " + before + "개 → 켜져 있는 것 " + after + "개"); Chk("B3 쿼드 1개", e != null && e.quad != null, e == null ? "-" : e.quad.name + " · 부모 " + e.quad.transform.parent.name + " · 레이어 " + e.quad.layer); Chk("B4 파괴 0", CountAll(actor) == childBefore + 1, "노드 " + childBefore + " → " + CountAll(actor) + " (쿼드 +1)"); SpriteBillboard.Detach(actor); int restored = 0; foreach (var r in smrs) if (r != null && r.enabled) restored++; Chk("B5 원복", restored == before && CountAll(actor) == childBefore, "SMR 켜짐 " + restored + "/" + before + " · 노드 " + CountAll(actor) + "/" + childBefore); UnityEngine.Object.DestroyImmediate(actor); } // ── C. 프레임 · 방향 선택 ─────────────────────────────────────────────── static void C_FrameDir() { L(""); L("== C. 프레임 · 방향 선택 (Animator 폴링 · 원본 코드 수정 0) =="); var look = WLSpriteLookSettings.Instance; var cam = MakeCam(kLowW, kLowH); var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f); var set = SpriteBillboard.MatchSet(actor.name, look); var anim = actor.GetComponent(); anim.applyRootMotion = false; anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; anim.Rebind(); var e = SpriteBillboard.TryAttach(actor, set); // ⓐ 상태 해시 → 동작 int okState = 0, allState = 0; foreach (var a in set.actions) foreach (var n in a.stateNames) { allState++; var found = set.FindByStateHash(Animator.StringToHash(n)); if (found == a) okState++; } Chk("C1 상태→동작", okState == allState, okState + "/" + allState + "개 상태가 정확한 동작으로 간다"); // ⓑ normalizedTime → 프레임 var idle = set.FindByKey("idle"); var lines = new StringBuilder(); bool frameOk = true; for (int f = 0; f < idle.frameCount; f++) { float nt = (f + 0.5f) / idle.frameCount; anim.Play(Animator.StringToHash("idle"), 0, nt); anim.Update(0f); SpriteBillboard.UpdateAll(cam.cam); if (e.frame != f) frameOk = false; lines.Append("nt " + nt.ToString("0.00") + "→f" + e.frame + " "); } Chk("C2 시간→프레임", frameOk, lines.ToString().Trim()); // ⓑ-2 기본 상태가 아닌 동작도 폴링으로 맞는가 (에디트 모드 실측) lines.Length = 0; int hit = 0, tot = 0; foreach (var a in set.actions) { int h = Animator.StringToHash(a.stateNames[0]); if (!anim.HasState(0, h)) continue; tot++; anim.Play(h, 0, 0.5f); anim.Update(0f); SpriteBillboard.UpdateAll(cam.cam); bool ok = e.action != null && e.action.key == a.key; if (ok) hit++; lines.Append(a.key + "→" + (e.action == null ? "-" : e.action.key) + (ok ? " " : "✗ ")); } Chk("C2b 상태 폴링(에디트 모드)", hit == tot, hit + "/" + tot + " · " + lines.ToString().Trim()); // ⓒ 상대 yaw → 방향 lines.Length = 0; bool dirOk = true; anim.Play(Animator.StringToHash("idle"), 0, 0f); anim.Update(0f); for (int d = 0; d < set.dirCount; d++) { actor.transform.rotation = Quaternion.Euler(0f, d * (360f / set.dirCount), 0f); SpriteBillboard.UpdateAll(cam.cam); if (e.dir != d) dirOk = false; lines.Append(d * 45 + "°→d" + e.dir + " "); } Chk("C3 각도→방향", dirOk, lines.ToString().Trim()); // ⓓ 쿼드 크기 = 프레임 px × 픽셀 월드 크기 (스케일 보정 포함) actor.transform.rotation = Quaternion.identity; SpriteBillboard.UpdateAll(cam.cam); float expect = set.frameWidth * set.worldPerPixel * (0.7f / set.bakeScale); float got = e.quadTf.lossyScale.x; Chk("C4 쿼드 크기", Mathf.Abs(got - expect) < 0.002f, string.Format("월드 {0:0.0000} m (기대 {1:0.0000}) · 스케일 보정 {2:0.000}", got, expect, e.scaleK)); Chk("C5 쿼드 자세", Quaternion.Angle(e.quadTf.rotation, cam.cam.transform.rotation) < 0.01f, "카메라 정렬 오차 " + Quaternion.Angle(e.quadTf.rotation, cam.cam.transform.rotation).ToString("0.000") + "°"); SpriteBillboard.Detach(actor); UnityEngine.Object.DestroyImmediate(actor); cam.Dispose(); } // ── D. GC ─────────────────────────────────────────────────────────────── static void D_Gc() { L(""); L("== D. GC (프레임당 할당 0) =="); var look = WLSpriteLookSettings.Instance; var cam = MakeCam(kLowW, kLowH); var a1 = Spawn(kPcPrefab, Vector3.zero, 0.7f); var a2 = Spawn(kMobPrefab, new Vector3(1.5f, 0f, 0f), 0f); SpriteBillboard.TryAttach(a1, SpriteBillboard.MatchSet(a1.name, look)); SpriteBillboard.TryAttach(a2, SpriteBillboard.MatchSet(a2.name, look)); for (int i = 0; i < 200; i++) SpriteBillboard.UpdateAll(cam.cam); // 워밍업 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long m0 = Profiler.GetMonoUsedSizeLong(); int g0 = GC.CollectionCount(0); for (int i = 0; i < 200000; i++) SpriteBillboard.UpdateAll(cam.cam); long m1 = Profiler.GetMonoUsedSizeLong(); int g1 = GC.CollectionCount(0); Chk("D1 UpdateAll ×200k", m1 - m0 == 0 && g1 == g0, "mono Δ " + (m1 - m0) + " B · gen0 " + (g1 - g0) + "회"); SpriteBillboard.RestoreNow("gc-probe"); UnityEngine.Object.DestroyImmediate(a1); UnityEngine.Object.DestroyImmediate(a2); cam.Dispose(); } // ── E. 시트 내용 ──────────────────────────────────────────────────────── static void E_Sheet() { L(""); L("== E. 시트 내용 (도트화가 실제로 강제됐는가) =="); var look = WLSpriteLookSettings.Instance; foreach (var s in look.sets) { if (s == null || s.sheet == null) continue; var path = AssetDatabase.GetAssetPath(s.sheet); var bytes = File.ReadAllBytes(path); var t = new Texture2D(2, 2, TextureFormat.RGBA32, false, false); t.LoadImage(bytes); var px = t.GetPixels32(); // 기대 팔레트 = 채널당 levels 단계 · 여기에 외곽선 색(SO)만 예외로 허용한다 var bake = AssetDatabase.LoadAssetAtPath(kRes + "/WLSpriteBakeSettings.asset"); int lv = bake != null ? bake.paletteLevels : 6; var allowed = new HashSet(); for (int i = 0; i < lv; i++) allowed.Add(Mathf.RoundToInt((float)i / (lv - 1) * 255f)); Color32 oc = bake != null ? (Color32)bake.outlineColor : new Color32(14, 11, 17, 255); bool bands = bake == null || bake.paletteValueBands; int opaque = 0, semi = 0, outline = 0, offPalette = 0; var levels = new HashSet(); var colors = new HashSet(); for (int i = 0; i < px.Length; i++) { if (px[i].a == 0) continue; if (px[i].a == 255) opaque++; else semi++; levels.Add(bands ? Mathf.Max(px[i].r, Mathf.Max(px[i].g, px[i].b)) : px[i].r); if (!bands) { levels.Add(px[i].g); levels.Add(px[i].b); } colors.Add((px[i].r << 16) | (px[i].g << 8) | px[i].b); bool isOutline = px[i].r == oc.r && px[i].g == oc.g && px[i].b == oc.b; if (isOutline) { outline++; continue; } if (bands) { if (!allowed.Contains(Mathf.Max(px[i].r, Mathf.Max(px[i].g, px[i].b)))) offPalette++; } else if (!allowed.Contains(px[i].r) || !allowed.Contains(px[i].g) || !allowed.Contains(px[i].b)) offPalette++; } Chk("E1 알파 이진화 " + s.name, semi == 0, "불투명 " + opaque + " · 반투명 " + semi + "(0 이어야 한다)"); Chk("E2 팔레트 양자화 " + s.name, offPalette == 0, (bands ? "밝기 " : "채널 ") + lv + "단계 " + Join(allowed) + " 밖 픽셀 " + offPalette + "개 · 실제 " + levels.Count + "가지 " + Join(levels) + " (외곽선 색 " + oc.r + "," + oc.g + "," + oc.b + " 제외) · 색 " + colors.Count + "종"); Chk("E3 외곽선 " + s.name, outline > opaque * 0.05f, "어두운 테두리 픽셀 " + outline + " / 불투명 " + opaque + " (" + (100f * outline / Mathf.Max(1, opaque)).ToString("0.0") + " %)"); Chk("E4 PNG 크기 " + s.name, bytes.Length > 0, bytes.Length.ToString("N0") + " B (" + (bytes.Length / 1024.0).ToString("0.0") + " KB) · " + s.sheet.width + "×" + s.sheet.height + " · RGBA32 런타임 " + (s.sheet.width * s.sheet.height * 4 / 1024.0 / 1024.0).ToString("0.00") + " MB"); UnityEngine.Object.DestroyImmediate(t); } } static string Join(HashSet s) { var l = new List(s); l.Sort(); var sb = new StringBuilder("["); foreach (var v in l) sb.Append(v).Append(' '); return sb.ToString().TrimEnd() + "]"; } // ── F. C8 (SO off → 원본 3D 100 %) ───────────────────────────────────── static void F_C8() { L(""); L("== F. C8 롤백 (SO off → 원본 3D 100 %) =="); var look = WLSpriteLookSettings.Instance; var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f); var smrs = actor.GetComponentsInChildren(true); var mrs = actor.GetComponentsInChildren(true); int smrOn0 = 0; foreach (var r in smrs) if (r.enabled) smrOn0++; int mrOn0 = 0; foreach (var r in mrs) if (r.enabled) mrOn0++; int nodes0 = CountAll(actor); SpriteBillboard.TryAttach(actor, SpriteBillboard.MatchSet(actor.name, look)); WLSpriteLookSettings.RuntimeDisabled = true; Chk("F1 게이트", !WLSpriteLookSettings.Enabled && !SpriteBillboard.ShouldBeOn, "RuntimeDisabled → Enabled=false · ShouldBeOn=false"); int n = SpriteBillboard.RestoreNow("C8"); WLSpriteLookSettings.RuntimeDisabled = false; int smrOn1 = 0; foreach (var r in smrs) if (r != null && r.enabled) smrOn1++; int mrOn1 = 0; foreach (var r in mrs) if (r != null && r.enabled) mrOn1++; Chk("F2 렌더러 100 % 복원", smrOn1 == smrOn0 && mrOn1 == mrOn0, "SMR " + smrOn1 + "/" + smrOn0 + " · MR " + mrOn1 + "/" + mrOn0); Chk("F3 쿼드 0", CountAll(actor) == nodes0 && SpriteBillboard.ActiveCount == 0, "노드 " + CountAll(actor) + "/" + nodes0 + " · 대상 " + SpriteBillboard.ActiveCount); // 시트를 안 가진 액터는 아예 안 걸린다 var other = Spawn("Assets/Res_Addr/PC/Ai02.prefab", Vector3.zero, 0.7f); if (other != null) { Chk("F4 목록 밖 액터", SpriteBillboard.MatchSet(other.name, look) == null, "'" + other.name + "' → 매칭 0 = 원본 3D 그대로"); UnityEngine.Object.DestroyImmediate(other); } UnityEngine.Object.DestroyImmediate(actor); } // ═════════════════════════════════════════════════════════════════════════ // 캡처 4장 // ═════════════════════════════════════════════════════════════════════════ public static void Capture() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814k 캡처 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); Directory.CreateDirectory(kOutDir); try { EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); World(); var look = WLSpriteLookSettings.Instance; // 두 캐릭터를 나란히 (PC 왼쪽 · 몹 오른쪽) var pc = Spawn(kPcPrefab, new Vector3(-1.0f, 0f, 0f), 0.7f); var mob = Spawn(kMobPrefab, new Vector3(1.1f, 0f, 0f), 0f); // 🔴 구운 프레임과 **정확히 같은 시각**으로 3D 를 세운다(프레임 양자화 차이를 A/B 에서 빼기 위해) PoseFrame(pc, look, "attack", 2, 315f); PoseFrame(mob, look, "attack", 3, 45f); var ground = Ground(); var cam = MakeCam(kLowW, kLowH); cam.cam.transform.position = new Vector3(0f, 1.0f, 0f) - cam.cam.transform.forward * 40f; cam.cam.orthographicSize = kOrtho; // 지면만 찍은 기준 컷(실루엣을 차분으로 정확히 잰다) pc.SetActive(false); mob.SetActive(false); var bg = cam.Grab(); pc.SetActive(true); mob.SetActive(true); // ⓐ 3D 원본 var a = cam.Grab(); SavePoint(a, kLowW, kLowH, kOutDir + @"\k_1_3d_original.png"); L(" ⓐ 3D 원본 → k_1_3d_original.png"); // ⓑ 구운 스프라이트로 교체(같은 구도 · 같은 포즈 · 같은 방향) var ePc = SpriteBillboard.TryAttach(pc, SpriteBillboard.MatchSet(pc.name, look)); var eMob = SpriteBillboard.TryAttach(mob, SpriteBillboard.MatchSet(mob.name, look)); SpriteBillboard.UpdateAll(cam.cam); L(string.Format(" 폴링 결과 — PC {0} d{1} f{2} / 몹 {3} d{4} f{5}", ePc.action != null ? ePc.action.key : "-", ePc.dir, ePc.frame, eMob.action != null ? eMob.action.key : "-", eMob.dir, eMob.frame)); // 🔴 A/B 는 「같은 포즈」여야 하므로 프레임을 못 박는다(폴링 정확도는 프로브 C 가 따로 잰다) SpriteBillboard.SetManualFrame(pc, "attack", 7, 2); SpriteBillboard.SetManualFrame(mob, "attack", 1, 3); SpriteBillboard.UpdateAll(cam.cam); var b = cam.Grab(); SavePoint(b, kLowW, kLowH, kOutDir + @"\k_2_sprite_baked.png"); L(" ⓑ 스프라이트 교체 → k_2_sprite_baked.png"); // 정렬 실측 — 같은 구도에서 3D 실루엣과 스프라이트 실루엣이 몇 px 어긋나는가 var boxA = Box(a, bg, kLowW, kLowH, 0, kLowW / 2); var boxB = Box(b, bg, kLowW, kLowH, 0, kLowW / 2); L(string.Format(" 정렬 실측(PC 쪽 절반) — 3D x[{0}..{1}] y[{2}..{3}] / 스프라이트 x[{4}..{5}] y[{6}..{7}] → Δ좌 {8} · Δ우 {9} · Δ하 {10} · Δ상 {11} px", boxA[0], boxA[1], boxA[2], boxA[3], boxB[0], boxB[1], boxB[2], boxB[3], boxB[0] - boxA[0], boxB[1] - boxA[1], boxB[2] - boxA[2], boxB[3] - boxA[3])); L(string.Format(" 밝기 실측 — 3D 평균 {0:0.0} · 스프라이트 평균 {1:0.0} (0~255 · 캐릭터 픽셀만) · 캐릭터 픽셀 3D {2} / 스프라이트 {3} (외곽선 +{4})", MeanLum(a, bg), MeanLum(b, bg), CountDiff(a, bg), CountDiff(b, bg), CountDiff(b, bg) - CountDiff(a, bg))); // ⓒ 2배 클로즈업 — 위 3D / 아래 스프라이트 CloseUp(a, b, bg, kLowW, kLowH, kOutDir + @"\k_3_closeup_2x.png"); L(" ⓒ 클로즈업 → k_3_closeup_2x.png"); // ⓓ 방향 8종 한 장 SpriteBillboard.RestoreNow("capture-dir"); Directions(cam, pc, mob, ground, kOutDir + @"\k_4_directions.png"); L(" ⓓ 방향 8종 → k_4_directions.png"); cam.Dispose(); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { SpriteBillboard.RestoreNow("capture-end"); EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); } L(""); L("캡처 " + (s_fail == 0 ? "PASS" : "FAIL") + " — " + kOutDir); Write(kTxtCap); } // 방향 8종을 한 장에 — 위 줄 = 3D · 아래 줄 = 스프라이트 static void Directions(CamRig cam, GameObject pc, GameObject mob, GameObject ground, string file) { int cell = 64; // 셀 하나의 저해상도 픽셀 크기 var rig = MakeCam(cell, cell); rig.cam.transform.rotation = cam.cam.transform.rotation; rig.cam.orthographicSize = cell * (2f * kOrtho / kLowH) * 0.5f; mob.SetActive(false); pc.transform.position = Vector3.zero; int cols = 8, rows = 2; var sheet = new Color32[cols * cell * rows * cell]; var look = WLSpriteLookSettings.Instance; for (int pass = 0; pass < 2; pass++) { if (pass == 1) { var e = SpriteBillboard.TryAttach(pc, SpriteBillboard.MatchSet(pc.name, look)); } for (int d = 0; d < cols; d++) { Pose(pc, "idle", 0.0f, d * 45f); rig.cam.transform.position = new Vector3(0f, 0.6f, 0f) - rig.cam.transform.forward * 40f; if (pass == 1) SpriteBillboard.UpdateAll(rig.cam); var px = rig.Grab(); BlitInto(px, cell, cell, sheet, cols * cell, d * cell, (rows - 1 - pass) * cell); } } SpriteBillboard.RestoreNow("dir-capture"); mob.SetActive(true); SavePoint(sheet, cols * cell, rows * cell, file, 4); rig.Dispose(); } // 클로즈업 — 캐릭터 주변을 잘라 확대해 위/아래로 붙인다 static void CloseUp(Color32[] a, Color32[] b, Color32[] bg, int w, int h, string file) { var box = Box(a, bg, w, h, 0, w); int pad = 6; int x0 = Mathf.Max(0, box[0] - pad), x1 = Mathf.Min(w - 1, box[1] + pad); int y0 = Mathf.Max(0, box[2] - pad), y1 = Mathf.Min(h - 1, box[3] + pad); int cw = x1 - x0 + 1, ch = y1 - y0 + 1; int zoom = Mathf.Max(2, Mathf.Min(1080 / Mathf.Max(1, cw), 900 / Mathf.Max(1, ch))); int outW = cw * zoom, outH = ch * zoom * 2 + 8; var outPx = new Color32[outW * outH]; for (int i = 0; i < outPx.Length; i++) outPx[i] = new Color32(24, 26, 30, 255); // 아래 = 스프라이트 · 위 = 3D CropZoom(b, w, h, x0, y0, cw, ch, zoom, outPx, outW, 0, 0); CropZoom(a, w, h, x0, y0, cw, ch, zoom, outPx, outW, 0, ch * zoom + 8); L(string.Format(" 클로즈업 crop {0}×{1} px → ×{2} = {3}×{4} (위 3D · 아래 스프라이트)", cw, ch, zoom, outW, outH)); SaveRaw(outPx, outW, outH, file); } static void CropZoom(Color32[] src, int w, int h, int x0, int y0, int cw, int ch, int zoom, Color32[] dst, int dw, int dx, int dy) { for (int y = 0; y < ch * zoom; y++) for (int x = 0; x < cw * zoom; x++) { int sx = x0 + x / zoom, sy = y0 + y / zoom; if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue; int di = (dy + y) * dw + (dx + x); if (di < 0 || di >= dst.Length) continue; dst[di] = src[sy * w + sx]; } } /// 지면만 찍은 컷과 다른 픽셀 = 캐릭터 픽셀. static bool Diff(Color32[] px, Color32[] bg, int i) { if (bg == null) return px[i].a >= 8; return Mathf.Abs(px[i].r - bg[i].r) + Mathf.Abs(px[i].g - bg[i].g) + Mathf.Abs(px[i].b - bg[i].b) > 8 || Mathf.Abs(px[i].a - bg[i].a) > 8; } static float MeanLum(Color32[] px, Color32[] bg) { double sum = 0; int n = 0; for (int i = 0; i < px.Length; i++) { if (!Diff(px, bg, i)) continue; sum += 0.299 * px[i].r + 0.587 * px[i].g + 0.114 * px[i].b; n++; } return n == 0 ? 0f : (float)(sum / n); } static int CountDiff(Color32[] px, Color32[] bg) { int n = 0; for (int i = 0; i < px.Length; i++) if (Diff(px, bg, i)) n++; return n; } static int[] Box(Color32[] px, Color32[] bg, int w, int h, int xFrom, int xTo) { int minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1; for (int y = 0; y < h; y++) for (int x = xFrom; x < Mathf.Min(w, xTo); x++) { if (!Diff(px, bg, y * w + x)) continue; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } return new[] { minX, maxX, minY, maxY }; } /// 지면 — PD 가 「게임 화면처럼」 볼 수 있게 단색 평면 1장(커밋 0 · 임시 씬). static GameObject Ground() { var go = GameObject.CreatePrimitive(PrimitiveType.Plane); go.name = "[WL814k] Ground"; go.transform.position = Vector3.zero; go.transform.localScale = new Vector3(6f, 1f, 6f); UnityEngine.Object.DestroyImmediate(go.GetComponent()); var mr = go.GetComponent(); var sh = Shader.Find("Universal Render Pipeline/Lit"); var m = new Material(sh != null ? sh : Shader.Find("Standard")); m.hideFlags = HideFlags.HideAndDontSave; m.SetColor("_BaseColor", new Color(0.34f, 0.40f, 0.25f, 1f)); m.SetFloat("_Smoothness", 0f); mr.sharedMaterial = m; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; return go; } // ═════════════════════════════════════════════════════════════════════════ // 공통 // ═════════════════════════════════════════════════════════════════════════ static void World() { var bake = AssetDatabase.LoadAssetAtPath(kRes + "/WLSpriteBakeSettings.asset"); RenderSettings.fog = false; RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource("Default-Skybox.mat"); RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox; RenderSettings.ambientIntensity = 1f; DynamicGI.UpdateEnvironment(); var sun = new GameObject("Sun").AddComponent(); sun.type = LightType.Directional; sun.shadows = LightShadows.None; sun.color = bake != null ? bake.sunColor : Color.white; sun.intensity = bake != null ? bake.sunIntensity : 1.2f; sun.transform.rotation = Quaternion.Euler(bake != null ? bake.sunEuler : new Vector3(26.4f, 238.6f, 0f)); if (bake != null && bake.fillIntensity > 0f) { var fill = new GameObject("Fill").AddComponent(); fill.type = LightType.Directional; fill.shadows = LightShadows.None; fill.color = bake.fillColor; fill.intensity = bake.fillIntensity; fill.transform.rotation = Quaternion.Euler(bake.fillEuler); } } static GameObject Spawn(string prefabPath, Vector3 pos, float scale) { var src = AssetDatabase.LoadAssetAtPath(prefabPath); if (src == null) return null; var go = (GameObject)PrefabUtility.InstantiatePrefab(src); PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); go.name = Path.GetFileNameWithoutExtension(prefabPath) + "(Clone)"; go.transform.position = pos; go.transform.rotation = Quaternion.identity; if (scale > 0.0001f) go.transform.localScale = Vector3.one * scale; foreach (var c in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c.gameObject); foreach (var mb in go.GetComponentsInChildren(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb); foreach (var sm in go.GetComponentsInChildren(true)) { sm.updateWhenOffscreen = true; sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; } var an = go.GetComponent(); if (an != null) { an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.Rebind(); } // 굽기와 같은 Toon 머티리얼로(같은 조건 비교) var bake = AssetDatabase.LoadAssetAtPath(kRes + "/WLSpriteBakeSettings.asset"); if (bake != null && bake.useToonMaterials) foreach (var rend in go.GetComponentsInChildren(true)) { if (rend == null || rend is ParticleSystemRenderer) continue; var mats = rend.sharedMaterials; bool any = false; for (int i = 0; i < mats.Length; i++) { if (mats[i] == null) continue; var t = AssetDatabase.LoadAssetAtPath(bake.toonMaterialDir + "/" + mats[i].name + bake.toonMaterialSuffix + ".mat"); if (t != null) { mats[i] = t; any = true; } } if (any) rend.sharedMaterials = mats; } return go; } static void Pose(GameObject go, string state, float nt, float yaw) { go.transform.rotation = Quaternion.Euler(0f, yaw, 0f); var an = go.GetComponent(); if (an == null) return; int h = Animator.StringToHash(state); if (!an.HasState(0, h)) return; an.Play(h, 0, nt); an.Update(0f); } /// 시트가 가진 프레임 f 와 **같은 시각**으로 3D 를 세운다(A/B 를 같은 포즈로 맞추기 위해). static void PoseFrame(GameObject go, WLSpriteLookSettings look, string key, int frame, float yaw) { var set = SpriteBillboard.MatchSet(go.name, look); var a = set != null ? set.FindByKey(key) : null; if (a == null) { Pose(go, key, 0f, yaw); return; } float nt = a.loop ? (frame + 0.001f) / a.frameCount : (a.frameCount > 1 ? (float)frame / (a.frameCount - 1) : 0f); Pose(go, key, nt, yaw); } static int CountAll(GameObject go) { return go.GetComponentsInChildren(true).Length; } sealed class CamRig { public Camera cam; public RenderTexture rt; public Texture2D tex; public int w, h; public Color32[] Grab() { cam.targetTexture = rt; cam.Render(); cam.targetTexture = null; var active = RenderTexture.active; RenderTexture.active = rt; tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(false, false); RenderTexture.active = active; return tex.GetPixels32(); } public void Dispose() { RenderTexture.active = null; if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } if (tex != null) UnityEngine.Object.DestroyImmediate(tex); if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject); } } static CamRig MakeCam(int w, int h) { var go = new GameObject("[WL814k] CapCam"); var c = go.AddComponent(); c.orthographic = true; c.orthographicSize = h * (2f * kOrtho / kLowH) * 0.5f; c.nearClipPlane = 0.01f; c.farClipPlane = 200f; c.clearFlags = CameraClearFlags.SolidColor; c.backgroundColor = new Color(0.27f, 0.34f, 0.22f, 1f); // 지면 평면 밖은 이 색(지면이 화면을 덮는다) c.allowHDR = false; c.allowMSAA = false; c.useOcclusionCulling = false; go.transform.rotation = Quaternion.Euler(kPitch, 0f, 0f); go.transform.position = new Vector3(0f, 1.0f, 0f) - go.transform.forward * 40f; var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create(); var tex = new Texture2D(w, h, TextureFormat.RGBA32, false, false); return new CamRig { cam = c, rt = rt, tex = tex, w = w, h = h }; } // 저해상도 → Point 확대 저장(도트 모드가 화면에 내보내는 방식 그대로) static void SavePoint(Color32[] px, int w, int h, string file, int zoomOverride = 0) { int zoom = zoomOverride > 0 ? zoomOverride : Mathf.Max(1, kHiH / h); int ow = w * zoom, oh = h * zoom; var outPx = new Color32[ow * oh]; for (int y = 0; y < oh; y++) for (int x = 0; x < ow; x++) outPx[y * ow + x] = px[(y / zoom) * w + (x / zoom)]; SaveRaw(outPx, ow, oh, file); } static void SaveRaw(Color32[] px, int w, int h, string file) { var t = new Texture2D(w, h, TextureFormat.RGBA32, false, false); t.SetPixels32(px); t.Apply(false, false); File.WriteAllBytes(file, t.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(t); } static void BlitInto(Color32[] src, int sw, int sh, Color32[] dst, int dw, int dx, int dy) { for (int y = 0; y < sh; y++) for (int x = 0; x < sw; x++) { int di = (dy + y) * dw + (dx + x); if (di >= 0 && di < dst.Length) dst[di] = src[y * sw + x]; } } }