Project_WL/AgentScripts/WL814k_Probe.cs

687 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
var look = AssetDatabase.LoadAssetAtPath<WLSpriteLookSettings>(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<SkinnedMeshRenderer>(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<Animator>();
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<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
int lv = bake != null ? bake.paletteLevels : 6;
var allowed = new HashSet<int>();
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<int>();
var colors = new HashSet<int>();
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<int> s)
{
var l = new List<int>(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<SkinnedMeshRenderer>(true);
var mrs = actor.GetComponentsInChildren<MeshRenderer>(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];
}
}
/// <summary>지면만 찍은 컷과 다른 픽셀 = 캐릭터 픽셀.</summary>
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 };
}
/// <summary>지면 — PD 가 「게임 화면처럼」 볼 수 있게 단색 평면 1장(커밋 0 · 임시 씬).</summary>
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<Collider>());
var mr = go.GetComponent<MeshRenderer>();
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<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
RenderSettings.fog = false;
RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource<Material>("Default-Skybox.mat");
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox;
RenderSettings.ambientIntensity = 1f;
DynamicGI.UpdateEnvironment();
var sun = new GameObject("Sun").AddComponent<Light>();
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<Light>();
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<GameObject>(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<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{ sm.updateWhenOffscreen = true; sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; }
var an = go.GetComponent<Animator>();
if (an != null) { an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.Rebind(); }
// 굽기와 같은 Toon 머티리얼로(같은 조건 비교)
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
if (bake != null && bake.useToonMaterials)
foreach (var rend in go.GetComponentsInChildren<Renderer>(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<Material>(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<Animator>();
if (an == null) return;
int h = Animator.StringToHash(state);
if (!an.HasState(0, h)) return;
an.Play(h, 0, nt);
an.Update(0f);
}
/// <summary>시트가 가진 프레임 f 와 **같은 시각**으로 3D 를 세운다(A/B 를 같은 포즈로 맞추기 위해).</summary>
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<Transform>(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<Camera>();
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];
}
}
}