diff --git a/AgentScripts/WL814k_BAKE.txt b/AgentScripts/WL814k_BAKE.txt new file mode 100644 index 000000000..9be28072d --- /dev/null +++ b/AgentScripts/WL814k_BAKE.txt @@ -0,0 +1,35 @@ +WL-814k 굽기 · 2026-09-12 18:38:16 · Unity 6000.3.23f1 + +──────── Assets/Res_Addr/PC/Ai01.prefab (루트 스케일 0.7) +Toon 머티리얼 교체 = 12슬롯 (인스턴스에만 · 원본 .mat 무변경) +레이어0 상태 17개: hit, die, idle, run, casting, skill2, attack, attack2, dodge, land, skill3, skill4, skill5, stun, casting_end, lying, getup + 동작 idle ← 상태 [idle/land/getup] · 클립 Idle (2.000s · 4프레임 · loop True) + 동작 run ← 상태 [run/dodge] · 클립 Run (0.533s · 6프레임 · loop True) + 동작 attack ← 상태 [attack/attack2/skill2/skill3/skill4/skill5/casting/casting_end] · 클립 Atk2_wing (1.500s · 6프레임 · loop True) + 동작 hit ← 상태 [hit/stun] · 클립 LightHit (1.000s · 2프레임 · loop True) + 동작 die ← 상태 [die/lying] · 클립 death_F3441 (2.100s · 6프레임 · loop False) +실루엣 실측(프로브 192px) — x [73..119] y [79..129] · 캐릭터 높이 51 px · 폭 47 px → 프레임 56×56 · 발밑 픽셀 (28.5, 19.5) +OK — 프레임 56×56 px · 방향 8 · 총 192칸 · 시트 896×672 · PNG 165,716 B (161.8 KB) · 1.80초 · 캐릭터 51 px + PNG = Assets/WL/Look/Sprite/Baked/Ai01.png + SO = Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset + 동작표 = idle(시작 0·4프레임·2.0fps·상태 [idle/land/getup]·클립 Idle) run(시작 32·6프레임·11.3fps·상태 [run/dodge]·클립 Run) attack(시작 80·6프레임·4.0fps·상태 [attack/attack2/skill2/skill3/skill4/skill5/casting/casting_end]·클립 Atk2_wing) hit(시작 128·2프레임·2.0fps·상태 [hit/stun]·클립 LightHit) die(시작 144·6프레임·2.9fps·상태 [die/lying]·클립 death_F3441) + 발밑 픽셀 (28.5, 19.5) · 픽셀 1개 0.02833 m · 베이크 스케일 0.700 + +──────── Assets/Res_Addr/Mobs/Mob/Batty_A.prefab (프리팹 스케일 그대로) +Toon 머티리얼 교체 = 1슬롯 (인스턴스에만 · 원본 .mat 무변경) +레이어0 상태 8개: run, idle, attack, hit, die, stun, casting, casting_end + 동작 idle ← 상태 [idle] · 클립 Idle (0.958s · 4프레임 · loop True) + 동작 run ← 상태 [run] · 클립 Move (0.958s · 6프레임 · loop True) + 동작 attack ← 상태 [attack/casting/casting_end] · 클립 Attack (0.583s · 6프레임 · loop True) + 동작 hit ← 상태 [hit/stun] · 클립 Damage (0.583s · 2프레임 · loop True) + 동작 die ← 상태 [die] · 클립 Die (1.000s · 6프레임 · loop False) +실루엣 실측(프로브 192px) — x [75..117] y [84..127] · 캐릭터 높이 44 px · 폭 43 px → 프레임 48×48 · 발밑 픽셀 (24.5, 14.5) +OK — 프레임 48×48 px · 방향 8 · 총 192칸 · 시트 768×576 · PNG 107,778 B (105.3 KB) · 1.06초 · 캐릭터 44 px + PNG = Assets/WL/Look/Sprite/Baked/Batty_A.png + SO = Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset + 동작표 = idle(시작 0·4프레임·4.2fps·상태 [idle]·클립 Idle) run(시작 32·6프레임·6.3fps·상태 [run]·클립 Move) attack(시작 80·6프레임·10.3fps·상태 [attack/casting/casting_end]·클립 Attack) hit(시작 128·2프레임·3.4fps·상태 [hit/stun]·클립 Damage) die(시작 144·6프레임·6.0fps·상태 [die]·클립 Die) + 발밑 픽셀 (24.5, 14.5) · 픽셀 1개 0.02833 m · 베이크 스케일 1.500 + +런타임 SO 연결 — sets 2개 (Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset) + +합계 굽기 시간 = 2.87초 / 2종 → 1종당 1.43초 diff --git a/AgentScripts/WL814k_Bake.cs b/AgentScripts/WL814k_Bake.cs new file mode 100644 index 000000000..b0dfd2fc3 --- /dev/null +++ b/AgentScripts/WL814k_Bake.cs @@ -0,0 +1,133 @@ +// WL-814k 베이크 드라이버 (에디트 모드 전용 · 게임 코드 아님) +// 에셋 만들기 : unity command run_script --file AgentScripts/WL814k_Bake.cs --entry WL814k_Bake.MakeAssets +// 시범 2종 굽기: unity command run_script --file AgentScripts/WL814k_Bake.cs --entry WL814k_Bake.BakeDemo +// 임의 프리팹 : WL814k_Bake.Targets 를 바꾸거나 에디터 창(WL ▸ 도트 스프라이트 굽기)을 쓴다. +// +// 🔴 이 드라이버에만 「어떤 프리팹을 구울지」가 적혀 있다. 툴(Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs) +// 에는 캐릭터 이름이 0개다 — PD 가 캐릭터 에셋을 바꿔도 툴은 그대로 쓴다. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using WL.Look.SpriteBake; +using WL.Look.SpriteBake.EditorTools; + +public static class WL814k_Bake +{ + const string kResDir = "Assets/WL/Look/Sprite/Resources/WL"; + const string kOutDir = "Assets/WL/Look/Sprite/Baked"; + const string kTxt = "AgentScripts/WL814k_BAKE.txt"; + + // 🔴 1차 시범 = PC 1종 + 잡몹 1종 (전수 전환은 PD 승인 뒤) + // 두 번째 값 = 인게임 실제 루트 스케일(0 이면 프리팹 자체 값). + // PC 는 ClassConfig.f_Scale 0.7 로 스폰된다(814b·발주서 실측) → 그 크기로 구워야 + // 스프라이트 1px 이 화면 1px 과 정확히 맞는다. 몹은 프리팹 스케일 그대로(Batty_A = 1.5). + public static readonly string[] Targets = { "Assets/Res_Addr/PC/Ai01.prefab", "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab" }; + public static readonly float[] TargetScales = { 0.7f, 0f }; + + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + // ═════════════════════════════════════════════════════════════════════════ + // ① 설정 에셋 2개 만들기 + // ═════════════════════════════════════════════════════════════════════════ + public static void MakeAssets() + { + s_sb = new StringBuilder(); + L("WL-814k 설정 에셋 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + Directory.CreateDirectory(kResDir); + Directory.CreateDirectory(kOutDir); + + var bake = AssetDatabase.LoadAssetAtPath(kResDir + "/WLSpriteBakeSettings.asset"); + if (bake == null) + { + bake = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(bake, kResDir + "/WLSpriteBakeSettings.asset"); + L("만듦 " + kResDir + "/WLSpriteBakeSettings.asset"); + } + else L("있음 " + kResDir + "/WLSpriteBakeSettings.asset"); + + var look = AssetDatabase.LoadAssetAtPath(kResDir + "/WLSpriteLookSettings.asset"); + if (look == null) + { + look = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(look, kResDir + "/WLSpriteLookSettings.asset"); + L("만듦 " + kResDir + "/WLSpriteLookSettings.asset"); + } + else L("있음 " + kResDir + "/WLSpriteLookSettings.asset"); + + AssetDatabase.SaveAssetIfDirty(bake); + AssetDatabase.SaveAssetIfDirty(look); + WLSpriteBakeSettings.ClearCache(); + WLSpriteLookSettings.ClearCache(); + + L(string.Format("굽기 기본값 — 내림각 {0:0.000}° · 픽셀 {1:0.00000} m · 방향 {2} · 프레임 {3}(자동 {4}) · 팔레트 {5}단계 · 알파컷 {6}", + bake.PitchDeg, bake.WorldPerPixel, bake.dirCount, bake.frameSize, bake.autoFrameSize, bake.paletteLevels, bake.alphaCutoff)); + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + } + + // ═════════════════════════════════════════════════════════════════════════ + // ② 시범 2종 굽기 + SO 목록 연결 + // ═════════════════════════════════════════════════════════════════════════ + public static void BakeDemo() { BakeList(Targets, TargetScales); } + + public static void BakeList(string[] paths, float[] scales) + { + s_sb = new StringBuilder(); + L("WL-814k 굽기 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion); + + var sets = new List(); + double total = 0; + for (int ti = 0; ti < paths.Length; ti++) + { + var p = paths[ti]; + float scale = (scales != null && ti < scales.Length) ? scales[ti] : 0f; + L(""); + L("──────── " + p + (scale > 0f ? " (루트 스케일 " + scale + ")" : " (프리팹 스케일 그대로)")); + // 🔴 굽기마다 설정 에셋을 다시 읽는다 — PNG 임포트가 AssetDatabase 를 돌려 + // 참조만 들고 있던 SO 가 언로드돼 null 이 되는 것을 실측했다(첫 시도 실패 원인). + var cfg = AssetDatabase.LoadAssetAtPath(kResDir + "/WLSpriteBakeSettings.asset"); + if (cfg == null) { L("FAIL — 굽기 설정 에셋 없음. MakeAssets 먼저."); break; } + var r = WLSpriteBaker.Bake(p, cfg, kOutDir, null, scale); + total += r.seconds; + L(r.log.ToString().TrimEnd()); + foreach (var n in r.notes) L(" · 「미확인/주의」 " + n); + if (!r.ok) { L("FAIL"); continue; } + L(string.Format("OK — 프레임 {0}×{0} px · 방향 {1} · 총 {2}칸 · 시트 {3}×{4} · PNG {5:N0} B ({6:N1} KB) · {7:0.00}초 · 캐릭터 {8:0} px", + r.frameSize, r.dirCount, r.totalFrames, r.sheetW, r.sheetH, r.pngBytes, r.pngBytes / 1024.0, r.seconds, r.charPixelHeight)); + L(" PNG = " + r.pngPath); + L(" SO = " + r.setPath); + var set = AssetDatabase.LoadAssetAtPath(r.setPath); + if (set != null) + { + sets.Add(set); + var sb2 = new StringBuilder(); + foreach (var a in set.actions) + sb2.Append(string.Format("{0}(시작 {1}·{2}프레임·{3:0.0}fps·상태 [{4}]·클립 {5}) ", + a.key, a.startIndex, a.frameCount, a.fps, string.Join("/", a.stateNames), a.clipName)); + L(" 동작표 = " + sb2.ToString().TrimEnd()); + L(string.Format(" 발밑 픽셀 ({0:0.0}, {1:0.0}) · 픽셀 1개 {2:0.00000} m · 베이크 스케일 {3:0.000}", + set.footPixelX, set.footPixelY, set.worldPerPixel, set.bakeScale)); + } + } + + // 런타임 SO 에 시트 목록 연결 + var look = AssetDatabase.LoadAssetAtPath(kResDir + "/WLSpriteLookSettings.asset"); + if (look != null) + { + look.sets = sets.ToArray(); + EditorUtility.SetDirty(look); + AssetDatabase.SaveAssetIfDirty(look); + L(""); + L("런타임 SO 연결 — sets " + sets.Count + "개 (" + kResDir + "/WLSpriteLookSettings.asset)"); + } + L(""); + L(string.Format("합계 굽기 시간 = {0:0.00}초 / {1}종 → 1종당 {2:0.00}초", total, paths.Length, total / Math.Max(1, paths.Length))); + Write(); + } + + static void Write() { File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814k] → " + kTxt); } +} diff --git a/AgentScripts/WL814k_CAPTURE.txt b/AgentScripts/WL814k_CAPTURE.txt new file mode 100644 index 000000000..0aa9ff41c --- /dev/null +++ b/AgentScripts/WL814k_CAPTURE.txt @@ -0,0 +1,11 @@ +WL-814k 캡처 · 2026-09-12 18:43:29 + ⓐ 3D 원본 → k_1_3d_original.png + 폴링 결과 — PC idle d7 f0 / 몹 idle d1 f0 + ⓑ 스프라이트 교체 → k_2_sprite_baked.png + 정렬 실측(PC 쪽 절반) — 3D x[21..41] y[87..120] / 스프라이트 x[19..40] y[85..121] → Δ좌 -2 · Δ우 -1 · Δ하 -2 · Δ상 1 px + 밝기 실측 — 3D 평균 63.1 · 스프라이트 평균 48.4 (0~255 · 캐릭터 픽셀만) · 캐릭터 픽셀 3D 466 / 스프라이트 619 (외곽선 +153) + 클로즈업 crop 116×46 px → ×9 = 1044×836 (위 3D · 아래 스프라이트) + ⓒ 클로즈업 → k_3_closeup_2x.png + ⓓ 방향 8종 → k_4_directions.png + +캡처 PASS — E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k diff --git a/AgentScripts/WL814k_DIAG3.txt b/AgentScripts/WL814k_DIAG3.txt new file mode 100644 index 000000000..d1699e794 --- /dev/null +++ b/AgentScripts/WL814k_DIAG3.txt @@ -0,0 +1,43 @@ +WL-814k 진단 ③ · 18:24:55 + Animator 살아있나 = True + 샘플링 없음 덮인 426 · x[74..118] y[94..136] + Idle_f0 덮인 445 · x[75..114] y[96..136] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Idle_f1 덮인 436 · x[76..114] y[95..135] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Idle_f2 덮인 439 · x[76..113] y[94..135] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Idle_f3 덮인 441 · x[76..114] y[95..136] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Run_f0 덮인 503 · x[74..119] y[91..144] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Run_f1 덮인 496 · x[75..118] y[107..156] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Run_f2 덮인 502 · x[73..118] y[130..176] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Run_f3 덮인 503 · x[74..118] y[140..189] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Atk2_wing_f0 덮인 513 · x[71..112] y[88..132] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Atk2_wing_f1 덮인 447 · x[74..104] y[89..139] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Atk2_wing_f2 덮인 483 · x[68..109] y[91..131] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + Atk2_wing_f3 덮인 524 · x[69..104] y[83..133] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + LightHit_f0 덮인 448 · x[75..114] y[95..135] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + LightHit_f1 덮인 365 · x[75..113] y[81..113] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + LightHit_f2 덮인 449 · x[79..110] y[83..131] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + LightHit_f3 덮인 443 · x[75..114] y[87..127] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + death_F3441_f0 덮인 453 · x[78..114] y[78..121] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + death_F3441_f1 덮인 351 · x[91..109] y[0..37] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + death_F3441_f2 덮인 338 · x[86..104] y[0..35] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) + death_F3441_f3 덮인 374 · x[83..105] y[0..32] + 루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000) diff --git a/AgentScripts/WL814k_DIAG4.txt b/AgentScripts/WL814k_DIAG4.txt new file mode 100644 index 000000000..7eb71c6c7 --- /dev/null +++ b/AgentScripts/WL814k_DIAG4.txt @@ -0,0 +1,27 @@ +WL-814k 진단 ④ · 18:25:49 + +== Assets/Res_Addr/PC/Ai01.prefab == + 직속 자식: blen Group Mesh Root_M + 클립 Run · 위치 커브가 걸린 경로 0개: + f0 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.001, 0.990, 0.016) + f1 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.001, 0.845, 0.691) + f2 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.004, 0.990, 1.362) + f3 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.002, 0.845, 2.044) + 클립 death_F3441 · 위치 커브가 걸린 경로 0개: + f0 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.021, 0.238, -0.078) + f1 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.097, -0.496, -2.949) + f2 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.066, -0.456, -2.963) + f3 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.077, -0.433, -3.045) + +== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab == + 직속 자식: Batty root + 클립 Move · 위치 커브가 걸린 경로 8개: root/Center, root/Center/spine, root/Center/spine/wing_l1, root/Center/spine/wing_l1/wing_l2, root/Center/spine/wing_l1/wing_l2/wing_l3, root/Center/spine/wing_r1, root/Center/spine/wing_r1/wing_r2, root/Center/spine/wing_r1/wing_r2/wing_r3 + f0 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f1 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f2 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f3 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + 클립 Die · 위치 커브가 걸린 경로 8개: root/Center, root/Center/spine, root/Center/spine/wing_l1, root/Center/spine/wing_l1/wing_l2, root/Center/spine/wing_l1/wing_l2/wing_l3, root/Center/spine/wing_r1, root/Center/spine/wing_r1/wing_r2, root/Center/spine/wing_r1/wing_r2/wing_r3 + f0 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f1 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f2 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) + f3 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000) diff --git a/AgentScripts/WL814k_DIAG5.txt b/AgentScripts/WL814k_DIAG5.txt new file mode 100644 index 000000000..d56a02595 --- /dev/null +++ b/AgentScripts/WL814k_DIAG5.txt @@ -0,0 +1,45 @@ +WL-814k 진단 ⑤ · 18:27:44 + +== Assets/Res_Addr/PC/Ai01.prefab (anim.Play + anim.Update 경로) == + idle_f0 덮인 413 · x[86..110] y[92..136] + idle_f1 덮인 405 · x[86..110] y[92..135] + idle_f2 덮인 410 · x[86..110] y[92..135] + idle_f3 덮인 410 · x[86..110] y[92..136] + run_f0 덮인 474 · x[85..108] y[92..144] + run_f1 덮인 488 · x[85..111] y[91..139] + run_f2 덮인 486 · x[86..112] y[97..144] + run_f3 덮인 490 · x[86..110] y[91..139] + attack_f0 덮인 503 · x[75..109] y[86..132] + attack_f1 덮인 402 · x[80..101] y[89..134] + attack_f2 덮인 460 · x[78..109] y[88..131] + attack_f3 덮인 535 · x[75..103] y[83..133] + hit_f0 덮인 412 · x[86..110] y[91..135] + hit_f1 덮인 349 · x[86..110] y[89..120] + hit_f2 덮인 490 · x[86..111] y[91..141] + hit_f3 덮인 416 · x[86..110] y[92..136] + die_f0 덮인 457 · x[86..107] y[73..121] + die_f1 덮인 512 · x[77..123] y[86..116] + die_f2 덮인 518 · x[79..125] y[81..117] + die_f3 덮인 515 · x[79..123] y[78..114] + +== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab (anim.Play + anim.Update 경로) == + idle_f0 덮인 231 · x[84..108] y[98..112] + idle_f1 덮인 221 · x[84..108] y[97..111] + idle_f2 덮인 231 · x[84..108] y[96..110] + idle_f3 덮인 234 · x[84..108] y[97..112] + run_f0 덮인 256 · x[84..108] y[101..117] + run_f1 덮인 254 · x[84..108] y[99..115] + run_f2 덮인 250 · x[84..108] y[98..113] + run_f3 덮인 254 · x[84..108] y[100..116] + attack_f0 덮인 223 · x[84..108] y[97..111] + attack_f1 덮인 260 · x[84..108] y[106..122] + attack_f2 덮인 256 · x[84..108] y[102..118] + attack_f3 덮인 236 · x[84..108] y[96..111] + hit_f0 덮인 223 · x[84..108] y[97..111] + hit_f1 덮인 195 · x[84..108] y[90..102] + hit_f2 덮인 213 · x[84..108] y[95..109] + hit_f3 덮인 233 · x[84..108] y[99..113] + die_f0 덮인 223 · x[84..108] y[97..111] + die_f1 덮인 215 · x[84..108] y[99..112] + die_f2 덮인 266 · x[84..108] y[87..103] + die_f3 덮인 251 · x[84..108] y[87..101] diff --git a/AgentScripts/WL814k_DIAG6.txt b/AgentScripts/WL814k_DIAG6.txt new file mode 100644 index 000000000..529522475 --- /dev/null +++ b/AgentScripts/WL814k_DIAG6.txt @@ -0,0 +1,13 @@ +WL-814k 진단 ⑥ · 18:44:46 + 3D f0 → 상태 해시 1203776827(attack=1203776827) nt 0.000 + 3D f1 → 상태 해시 1203776827(attack=1203776827) nt 0.167 + 3D f2 → 상태 해시 1203776827(attack=1203776827) nt 0.334 + 3D f3 → 상태 해시 1203776827(attack=1203776827) nt 0.500 + 3D f4 → 상태 해시 1203776827(attack=1203776827) nt 0.667 + 3D f5 → 상태 해시 1203776827(attack=1203776827) nt 0.834 + 스프라이트 f0 → 인덱스 122 (시작 80 + 7*6 + 0) + 스프라이트 f1 → 인덱스 123 (시작 80 + 7*6 + 1) + 스프라이트 f2 → 인덱스 124 (시작 80 + 7*6 + 2) + 스프라이트 f3 → 인덱스 125 (시작 80 + 7*6 + 3) + 스프라이트 f4 → 인덱스 126 (시작 80 + 7*6 + 4) + 스프라이트 f5 → 인덱스 127 (시작 80 + 7*6 + 5) diff --git a/AgentScripts/WL814k_Diag3.cs b/AgentScripts/WL814k_Diag3.cs new file mode 100644 index 000000000..3ec0e4de7 --- /dev/null +++ b/AgentScripts/WL814k_Diag3.cs @@ -0,0 +1,138 @@ +// WL-814k 진단 ③ — AnimationMode 샘플링이 실루엣을 어떻게 바꾸는지 프레임별 실측 +// run: unity command run_script --file AgentScripts/WL814k_Diag3.cs --entry WL814k_Diag3.Run + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +public static class WL814k_Diag3 +{ + const string kTxt = "AgentScripts/WL814k_DIAG3.txt"; + static string OutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + Directory.CreateDirectory(OutDir); + L("WL-814k 진단 ③ · " + DateTime.Now.ToString("HH:mm:ss")); + Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Idle", "Run", "Atk2_wing", "LightHit", "death_F3441" }); + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + static void Test(string prefabPath, string[] clipNames) + { + GameObject pivot = null, inst = null, camGo = null; + RenderTexture rt = null; Texture2D tex = null; + bool am = false; + try + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.212f, 0.227f, 0.259f, 1f); + RenderSettings.fog = false; RenderSettings.skybox = null; + var sun = new GameObject("Sun").AddComponent(); + sun.type = LightType.Directional; sun.intensity = 1.2f; sun.shadows = LightShadows.None; + sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f); + + var src = AssetDatabase.LoadAssetAtPath(prefabPath); + pivot = new GameObject("Pivot"); + inst = (GameObject)PrefabUtility.InstantiatePrefab(src); + PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + inst.transform.SetParent(pivot.transform, false); + inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity; + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c.gameObject); + foreach (var mb in inst.GetComponentsInChildren(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb); + var anim = inst.GetComponent(); + L(" Animator 살아있나 = " + (anim != null)); + + float wpp = 3.4f * 2f / 240f; + int P = 192; + camGo = new GameObject("Cam"); + var cam = camGo.AddComponent(); + cam.orthographic = true; cam.orthographicSize = P * wpp * 0.5f; + cam.nearClipPlane = 0.01f; cam.farClipPlane = 200f; + cam.clearFlags = CameraClearFlags.SolidColor; cam.allowHDR = false; cam.allowMSAA = false; + camGo.transform.rotation = Quaternion.Euler(43.609f, 0f, 0f); + camGo.transform.position = -camGo.transform.right * (wpp * 0.5f) - camGo.transform.up * (wpp * 0.5f) - camGo.transform.forward * 50f; + + rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create(); + tex = new Texture2D(P, P, TextureFormat.RGBA32, false, false); + + var rac = anim.runtimeAnimatorController; + var black = new Color32[P * P]; var white = new Color32[P * P]; + + // ⓐ 샘플링 없이 + Box(cam, rt, tex, P, black, white, "샘플링 없음", null); + + AnimationMode.StartAnimationMode(); am = true; + foreach (var name in clipNames) + { + AnimationClip clip = null; + foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; } + if (clip == null) { L(" 클립 " + name + " 없음"); continue; } + for (int f = 0; f < 4; f++) + { + float t = clip.length * f / 4f; + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, clip, t); + AnimationMode.EndSampling(); + inst.transform.localPosition = Vector3.zero; + inst.transform.localRotation = Quaternion.identity; + string tag = name + "_f" + f; + Box(cam, rt, tex, P, black, white, tag, (name == "Idle" && f == 0) ? OutDir + "\\am_" + tag + ".png" : null); + L(" 루트 localPos " + inst.transform.localPosition.ToString("F3") + " · lossyScale " + inst.transform.lossyScale.ToString("F3")); + } + } + AnimationMode.StopAnimationMode(); am = false; + } + catch (Exception ex) { L("EXCEPTION " + ex); } + finally + { + if (am && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); + if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } + if (tex != null) UnityEngine.Object.DestroyImmediate(tex); + if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo); + if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + } + + static void Box(Camera cam, RenderTexture rt, Texture2D tex, int P, Color32[] black, Color32[] white, string tag, string savePng) + { + Grab(cam, rt, tex, P, Color.black, black, savePng == null ? null : savePng.Replace(".png", "_black.png")); + Grab(cam, rt, tex, P, Color.white, white, savePng == null ? null : savePng.Replace(".png", "_white.png")); + int cov = 0, minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1; + for (int y = 0; y < P; y++) + for (int x = 0; x < P; x++) + { + int i = y * P + x; + float c = 1f - (((white[i].r - black[i].r) + (white[i].g - black[i].g) + (white[i].b - black[i].b)) / 765f); + if (c < 0.5f) continue; + cov++; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + L(string.Format(" {0,-18} 덮인 {1,6} · x[{2}..{3}] y[{4}..{5}]", tag, cov, minX, maxX, minY, maxY)); + } + + static void Grab(Camera cam, RenderTexture rt, Texture2D tex, int P, Color bg, Color32[] outPx, string file) + { + cam.backgroundColor = bg; + cam.targetTexture = rt; cam.Render(); cam.targetTexture = null; + var active = RenderTexture.active; RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, P, P), 0, 0); tex.Apply(false, false); + RenderTexture.active = active; + if (file != null) File.WriteAllBytes(file, tex.EncodeToPNG()); + Array.Copy(tex.GetPixels32(), outPx, outPx.Length); + } +} diff --git a/AgentScripts/WL814k_Diag4.cs b/AgentScripts/WL814k_Diag4.cs new file mode 100644 index 000000000..177dd1f47 --- /dev/null +++ b/AgentScripts/WL814k_Diag4.cs @@ -0,0 +1,86 @@ +// WL-814k 진단 ④ — 루트 모션이 어느 트랜스폼에 실리는지 실측 +// run: unity command run_script --file AgentScripts/WL814k_Diag4.cs --entry WL814k_Diag4.Run + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +public static class WL814k_Diag4 +{ + const string kTxt = "AgentScripts/WL814k_DIAG4.txt"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + L("WL-814k 진단 ④ · " + DateTime.Now.ToString("HH:mm:ss")); + Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Run", "death_F3441" }); + Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "Move", "Die" }); + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + static void Test(string prefabPath, string[] clipNames) + { + GameObject pivot = null, inst = null; + bool am = false; + try + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + var src = AssetDatabase.LoadAssetAtPath(prefabPath); + pivot = new GameObject("Pivot"); + inst = (GameObject)PrefabUtility.InstantiatePrefab(src); + PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + inst.transform.SetParent(pivot.transform, false); + inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity; + var anim = inst.GetComponent(); + var rac = anim.runtimeAnimatorController; + + L(""); + L("== " + prefabPath + " =="); + var kids = new StringBuilder(); + for (int i = 0; i < inst.transform.childCount; i++) kids.Append(inst.transform.GetChild(i).name + " "); + L(" 직속 자식: " + kids); + + AnimationMode.StartAnimationMode(); am = true; + foreach (var name in clipNames) + { + AnimationClip clip = null; + foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; } + if (clip == null) continue; + var binds = AnimationUtility.GetCurveBindings(clip); + var paths = new System.Collections.Generic.HashSet(); + foreach (var b in binds) if (b.propertyName.StartsWith("m_LocalPosition")) paths.Add(b.path == "" ? "" : b.path); + L(" 클립 " + name + " · 위치 커브가 걸린 경로 " + paths.Count + "개: " + string.Join(", ", new System.Collections.Generic.List(paths).ToArray())); + + for (int f = 0; f < 4; f++) + { + float t = clip.length * f / 4f; + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, clip, t); + AnimationMode.EndSampling(); + var sb = new StringBuilder(); + sb.Append(string.Format(" f{0} rootLocal {1}", f, inst.transform.localPosition.ToString("F3"))); + for (int i = 0; i < inst.transform.childCount; i++) + { + var ch = inst.transform.GetChild(i); + sb.Append(" | " + ch.name + " " + ch.localPosition.ToString("F3")); + } + L(sb.ToString()); + } + } + AnimationMode.StopAnimationMode(); am = false; + } + catch (Exception ex) { L("EXCEPTION " + ex); } + finally + { + if (am && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); + if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + } +} diff --git a/AgentScripts/WL814k_Diag5.cs b/AgentScripts/WL814k_Diag5.cs new file mode 100644 index 000000000..fbe6e1691 --- /dev/null +++ b/AgentScripts/WL814k_Diag5.cs @@ -0,0 +1,134 @@ +// WL-814k 진단 ⑤ — 「진짜 Animator 로 구동」 경로가 에디트 모드에서 되는지 실측 +// (applyRootMotion=false 인 게임과 똑같은 포즈를 얻을 수 있으면 그게 정답 경로) +// run: unity command run_script --file AgentScripts/WL814k_Diag5.cs --entry WL814k_Diag5.Run + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +public static class WL814k_Diag5 +{ + const string kTxt = "AgentScripts/WL814k_DIAG5.txt"; + static string OutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + Directory.CreateDirectory(OutDir); + L("WL-814k 진단 ⑤ · " + DateTime.Now.ToString("HH:mm:ss")); + Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "idle", "run", "attack", "hit", "die" }); + Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "idle", "run", "attack", "hit", "die" }); + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + static void Test(string prefabPath, string[] states) + { + GameObject pivot = null, inst = null, camGo = null; + RenderTexture rt = null; Texture2D tex = null; + try + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.212f, 0.227f, 0.259f, 1f); + RenderSettings.fog = false; RenderSettings.skybox = null; + var sun = new GameObject("Sun").AddComponent(); + sun.type = LightType.Directional; sun.intensity = 1.2f; sun.shadows = LightShadows.None; + sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f); + + var src = AssetDatabase.LoadAssetAtPath(prefabPath); + pivot = new GameObject("Pivot"); + inst = (GameObject)PrefabUtility.InstantiatePrefab(src); + PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + inst.transform.SetParent(pivot.transform, false); + inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity; + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c.gameObject); + foreach (var mb in inst.GetComponentsInChildren(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb); + foreach (var sm in inst.GetComponentsInChildren(true)) if (sm != null) sm.updateWhenOffscreen = true; + + var anim = inst.GetComponent(); + anim.applyRootMotion = false; + anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; + anim.Rebind(); + + float wpp = 3.4f * 2f / 240f; + int P = 192; + camGo = new GameObject("Cam"); + var cam = camGo.AddComponent(); + cam.orthographic = true; cam.orthographicSize = P * wpp * 0.5f; + cam.nearClipPlane = 0.01f; cam.farClipPlane = 200f; + cam.clearFlags = CameraClearFlags.SolidColor; cam.allowHDR = false; cam.allowMSAA = false; + camGo.transform.rotation = Quaternion.Euler(43.609f, 0f, 0f); + camGo.transform.position = -camGo.transform.right * (wpp * 0.5f) - camGo.transform.up * (wpp * 0.5f) - camGo.transform.forward * 50f; + + rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create(); + tex = new Texture2D(P, P, TextureFormat.RGBA32, false, false); + var black = new Color32[P * P]; var white = new Color32[P * P]; + + L(""); + L("== " + prefabPath + " (anim.Play + anim.Update 경로) =="); + foreach (var st in states) + { + int hash = Animator.StringToHash(st); + if (!anim.HasState(0, hash)) { L(" 상태 " + st + " 없음"); continue; } + for (int f = 0; f < 4; f++) + { + float nt = f / 4f; + anim.Play(hash, 0, nt); + anim.Update(0f); + inst.transform.localPosition = Vector3.zero; + inst.transform.localRotation = Quaternion.identity; + string tag = st + "_f" + f; + Box(cam, rt, tex, P, black, white, tag, (f == 0) ? OutDir + "\\play_" + Path.GetFileNameWithoutExtension(prefabPath) + "_" + tag + ".png" : null); + } + } + } + catch (Exception ex) { L("EXCEPTION " + ex); } + finally + { + if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } + if (tex != null) UnityEngine.Object.DestroyImmediate(tex); + if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo); + if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + } + + static void Box(Camera cam, RenderTexture rt, Texture2D tex, int P, Color32[] black, Color32[] white, string tag, string savePng) + { + Grab(cam, rt, tex, P, Color.black, black, null); + Grab(cam, rt, tex, P, Color.white, white, savePng); + int cov = 0, minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1; + for (int y = 0; y < P; y++) + for (int x = 0; x < P; x++) + { + int i = y * P + x; + float c = 1f - (((white[i].r - black[i].r) + (white[i].g - black[i].g) + (white[i].b - black[i].b)) / 765f); + if (c < 0.5f) continue; + cov++; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + L(string.Format(" {0,-14} 덮인 {1,6} · x[{2}..{3}] y[{4}..{5}]", tag, cov, minX, maxX, minY, maxY)); + } + + static void Grab(Camera cam, RenderTexture rt, Texture2D tex, int P, Color bg, Color32[] outPx, string file) + { + cam.backgroundColor = bg; + cam.targetTexture = rt; cam.Render(); cam.targetTexture = null; + var active = RenderTexture.active; RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, P, P), 0, 0); tex.Apply(false, false); + RenderTexture.active = active; + if (file != null) File.WriteAllBytes(file, tex.EncodeToPNG()); + Array.Copy(tex.GetPixels32(), outPx, outPx.Length); + } +} diff --git a/AgentScripts/WL814k_Diag6.cs b/AgentScripts/WL814k_Diag6.cs new file mode 100644 index 000000000..06d17afb7 --- /dev/null +++ b/AgentScripts/WL814k_Diag6.cs @@ -0,0 +1,178 @@ +// WL-814k 진단 ⑥ — 3D 프레임 k 와 스프라이트 프레임 k 가 정말 같은 그림인가(격자 비교) +// run: unity command run_script --file AgentScripts/WL814k_Diag6.cs --entry WL814k_Diag6.Run + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using WL.Look.SpriteBake; + +public static class WL814k_Diag6 +{ + const string kOut = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag"; + const string kTxt = "AgentScripts/WL814k_DIAG6.txt"; + const float kOrtho = 3.4f, kPitch = 43.609f; + const int kLowH = 240; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + Directory.CreateDirectory(kOut); + L("WL-814k 진단 ⑥ · " + DateTime.Now.ToString("HH:mm:ss")); + try + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + var sun = new GameObject("Sun").AddComponent(); + sun.type = LightType.Directional; sun.shadows = LightShadows.None; + sun.color = new Color(1f, 0.92f, 0.703f, 1f); sun.intensity = 1.2f; + sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f); + var fill = new GameObject("Fill").AddComponent(); + fill.type = LightType.Directional; fill.shadows = LightShadows.None; + fill.color = new Color(0.596f, 0.703f, 0.839f, 1f); fill.intensity = 0.3f; + fill.transform.rotation = Quaternion.Euler(36.1f, 116.1f, 0f); + RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource("Default-Skybox.mat"); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox; + RenderSettings.ambientIntensity = 1f; RenderSettings.fog = false; + DynamicGI.UpdateEnvironment(); + + var look = WLSpriteLookSettings.Instance; + var actor = Spawn("Assets/Res_Addr/PC/Ai01.prefab", 0.7f); + var set = SpriteBillboard.MatchSet(actor.name, look); + var act = set.FindByKey("attack"); + var anim = actor.GetComponent(); + + int cell = 64, cols = act.frameCount, rows = 2, dir = 7; + var rig = MakeCam(cell, cell); + var sheet = new Color32[cols * cell * rows * cell]; + + // 위 줄 = 3D (매 컷 직전에 포즈를 다시 찍는다) + for (int f = 0; f < cols; f++) + { + actor.transform.rotation = Quaternion.Euler(0f, dir * 45f, 0f); + anim.Play(Animator.StringToHash("attack"), 0, (f + 0.001f) / act.frameCount); + anim.Update(0f); + var st = anim.GetCurrentAnimatorStateInfo(0); + L(string.Format(" 3D f{0} → 상태 해시 {1}(attack={2}) nt {3:0.000}", f, st.shortNameHash, Animator.StringToHash("attack"), st.normalizedTime)); + BlitInto(rig.Grab(), cell, cell, sheet, cols * cell, f * cell, 1 * cell); + } + + // 아래 줄 = 스프라이트 + var e = SpriteBillboard.TryAttach(actor, set); + for (int f = 0; f < cols; f++) + { + actor.transform.rotation = Quaternion.Euler(0f, dir * 45f, 0f); + SpriteBillboard.SetManualFrame(actor, "attack", dir, f); + SpriteBillboard.UpdateAll(rig.cam); + L(string.Format(" 스프라이트 f{0} → 인덱스 {1} (시작 {2} + {3}*{4} + {5})", f, + act.startIndex + dir * act.frameCount + f, act.startIndex, dir, act.frameCount, f)); + BlitInto(rig.Grab(), cell, cell, sheet, cols * cell, f * cell, 0 * cell); + } + SpriteBillboard.RestoreNow("diag6"); + + Save(sheet, cols * cell, rows * cell, kOut + @"\frames_attack_dir7.png", 6); + rig.Dispose(); + } + catch (Exception ex) { L("EXCEPTION " + ex); } + finally + { + SpriteBillboard.RestoreNow("diag6-end"); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + } + + static GameObject Spawn(string p, float scale) + { + var src = AssetDatabase.LoadAssetAtPath(p); + var go = (GameObject)PrefabUtility.InstantiatePrefab(src); + PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + go.name = Path.GetFileNameWithoutExtension(p) + "(Clone)"; + go.transform.position = Vector3.zero; + 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; + var an = go.GetComponent(); + an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.Rebind(); + var bake = WLSpriteBakeSettings.Instance; + 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; + } + + sealed class Rig + { + 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 a = RenderTexture.active; RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(false, false); + RenderTexture.active = a; + 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 Rig MakeCam(int w, int h) + { + var go = new GameObject("Cam"); + 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.34f, 0.40f, 0.25f, 1f); + c.allowHDR = false; c.allowMSAA = false; + go.transform.rotation = Quaternion.Euler(kPitch, 0f, 0f); + go.transform.position = new Vector3(0f, 0.6f, 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 Rig { cam = c, rt = rt, tex = tex, w = w, h = h }; + } + + 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]; + } + } + + static void Save(Color32[] px, int w, int h, string file, int zoom) + { + int ow = w * zoom, oh = h * zoom; + var o = new Color32[ow * oh]; + for (int y = 0; y < oh; y++) for (int x = 0; x < ow; x++) o[y * ow + x] = px[(y / zoom) * w + (x / zoom)]; + var t = new Texture2D(ow, oh, TextureFormat.RGBA32, false, false); + t.SetPixels32(o); t.Apply(false, false); + File.WriteAllBytes(file, t.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(t); + } +} diff --git a/AgentScripts/WL814k_NAMING.txt b/AgentScripts/WL814k_NAMING.txt new file mode 100644 index 000000000..f755c17ff --- /dev/null +++ b/AgentScripts/WL814k_NAMING.txt @@ -0,0 +1,18 @@ +WL-814k 이름 규약 표 · 2026-09-12 18:50:03 + 후보 idle = [idle / Idle / wait / IdleA / IdleB] (+보기만: land / getup) + 후보 run = [run / Run / move / Move / walk] (+보기만: dodge) + 후보 attack = [attack / Attack / atk / ATK0 / ATK1] (+보기만: ATK2 / ATK3 / ATK4 / Shoot / attack2 / skill2 / skill3 / skill4 / skill5 / casting / casting_end) + 후보 hit = [hit / Hit / damage / Damage] (+보기만: stun / Swoon) + 후보 die = [die / Die / death / Die0 / Die1] (+보기만: lying / Die2) + +== 개별 프리팹 == + Ai01.prefab 컨트롤러 pcanim_mage 상태 17개 · 매칭 5/5 · idle→idle run→run attack→attack hit→hit die→die + Batty_A.prefab 컨트롤러 Batty_Animations_Check 상태 8개 · 매칭 5/5 · idle→idle run→run attack→attack hit→hit die→die + F06.prefab 컨트롤러 Animations_check 상태 32개 · 매칭 5/5 · idle→IdleA run→Run attack→ATK0 hit→Damage die→Die0 + M05.prefab 컨트롤러 Animations_check 상태 32개 · 매칭 5/5 · idle→IdleA run→Run attack→ATK0 hit→Damage die→Die0 + +== Res_Addr/Mobs 전수 (확장 견적 근거) == + Animator+Controller 보유 프리팹 = 299 · 동작 5종 전부 매칭 = 288 (96.3 %) + 매칭 4/5 동작 = 11종 + 매칭 5/5 동작 = 288종 + 못 찾은 동작 'hit' = 11종 diff --git a/AgentScripts/WL814k_Naming.cs b/AgentScripts/WL814k_Naming.cs new file mode 100644 index 000000000..7c5718920 --- /dev/null +++ b/AgentScripts/WL814k_Naming.cs @@ -0,0 +1,109 @@ +// WL-814k 이름 규약 표 — 「굽기 SO 의 상태 이름 후보」가 어떤 캐릭터에 얼마나 걸리는지 실측 (읽기만 · 굽기 0) +// run: unity command run_script --file AgentScripts/WL814k_Naming.cs --entry WL814k_Naming.Run +// 🔴 여기서도 굽지 않는다 — 1차 시범은 PC 1종 + 잡몹 1종(발주서). 이 표는 「확장하면 뭐가 걸리나」의 근거다. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using WL.Look.SpriteBake; + +public static class WL814k_Naming +{ + const string kTxt = "AgentScripts/WL814k_NAMING.txt"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + L("WL-814k 이름 규약 표 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + var cfg = AssetDatabase.LoadAssetAtPath("Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset"); + if (cfg == null) { L("FAIL — 굽기 SO 없음"); File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); return; } + + foreach (var a in cfg.actions) + L(" 후보 " + a.key.PadRight(7) + " = [" + string.Join(" / ", a.stateNames) + "] (+보기만: " + string.Join(" / ", a.alsoMapStateNames) + ")"); + + // ── ① 시범 2종 + PD 가 방금 임포트한 치비 팩(Little Heroes) ───────────── + L(""); + L("== 개별 프리팹 =="); + foreach (var p in new[] + { + "Assets/Res_Addr/PC/Ai01.prefab", + "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", + "Assets/Suriyun/Characters/Nana/Prefab/F06.prefab", + "Assets/Suriyun/Characters/RedKnight/Prefab/M05/M05.prefab", + }) + One(p, cfg); + + // ── ② 몹 전수 — 확장했을 때 몇 %가 그냥 걸리는지 ─────────────────────── + L(""); + L("== Res_Addr/Mobs 전수 (확장 견적 근거) =="); + var guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" }); + int total = 0, full = 0; + var hist = new Dictionary(); + var missing = new Dictionary(); + foreach (var g in guids) + { + var path = AssetDatabase.GUIDToAssetPath(g); + var go = AssetDatabase.LoadAssetAtPath(path); + if (go == null) continue; + var anim = go.GetComponentInChildren(true); + if (anim == null || anim.runtimeAnimatorController == null) continue; + total++; + var states = States(anim); + int hit = 0; + foreach (var a in cfg.actions) + { + if (Match(a, states) != null) hit++; + else { int c; missing.TryGetValue(a.key, out c); missing[a.key] = c + 1; } + } + if (hit == cfg.actions.Length) full++; + int n; hist.TryGetValue(hit, out n); hist[hit] = n + 1; + } + L(" Animator+Controller 보유 프리팹 = " + total + " · 동작 5종 전부 매칭 = " + full + " (" + (100f * full / Mathf.Max(1, total)).ToString("0.0") + " %)"); + var keys = new List(hist.Keys); keys.Sort(); + foreach (var k in keys) L(" 매칭 " + k + "/5 동작 = " + hist[k] + "종"); + foreach (var kv in missing) L(" 못 찾은 동작 '" + kv.Key + "' = " + kv.Value + "종"); + + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + static void One(string path, WLSpriteBakeSettings cfg) + { + var go = AssetDatabase.LoadAssetAtPath(path); + if (go == null) { L(" " + path + " → 없음"); return; } + var anim = go.GetComponentInChildren(true); + if (anim == null || anim.runtimeAnimatorController == null) { L(" " + path + " → Animator/Controller 없음"); return; } + var states = States(anim); + var sb = new StringBuilder(); + int hit = 0; + foreach (var a in cfg.actions) + { + var m = Match(a, states); + if (m != null) hit++; + sb.Append(a.key + "→" + (m ?? "✗") + " "); + } + L(string.Format(" {0,-56} 컨트롤러 {1,-24} 상태 {2,2}개 · 매칭 {3}/{4} · {5}", + Path.GetFileName(path), anim.runtimeAnimatorController.name, states.Count, hit, cfg.actions.Length, sb.ToString().Trim())); + } + + static Dictionary States(Animator anim) + { + var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController; + if (ac != null && ac.layers.Length > 0 && ac.layers[0].stateMachine != null) + foreach (var ch in ac.layers[0].stateMachine.states) + if (ch.state.motion != null && !d.ContainsKey(ch.state.name)) d[ch.state.name] = ch.state.name; + return d; + } + + static string Match(WLSpriteBakeAction a, Dictionary states) + { + foreach (var n in a.stateNames) { string real; if (states.TryGetValue(n, out real)) return real; } + return null; + } +} diff --git a/AgentScripts/WL814k_PROBE.txt b/AgentScripts/WL814k_PROBE.txt new file mode 100644 index 000000000..34e151ce3 --- /dev/null +++ b/AgentScripts/WL814k_PROBE.txt @@ -0,0 +1,49 @@ +WL-814k 프로브 · 2026-09-12 18:43:19 · Unity 6000.3.23f1 + +== A. 에셋 · 메타 == + PASS A1 굽기 SO — 내림각 43.609° · 픽셀 0.02833 m + PASS A2 런타임 SO — sets 2 + PASS A3 시트 Ai01_SpriteSet — Ai01 · 프레임 56×56 · 16열×12행 · 방향 8 · 총 192칸 · 텍스처 896×672 + PASS A4 임포트 Ai01_SpriteSet — filter Point · mipmap False · 압축 Uncompressed · alphaIsTransparency True · maxSize 1024 · type Default + PASS A5 격자 Ai01_SpriteSet — 칸 192 ≥ 필요 192 · 텍스처 896×672 + PASS A6 동작표 Ai01_SpriteSet — 5개 · idle[0+4] run[32+6] attack[80+6] hit[128+2] die[144+6] + PASS A3 시트 Batty_A_SpriteSet — Batty_A · 프레임 48×48 · 16열×12행 · 방향 8 · 총 192칸 · 텍스처 768×576 + PASS A4 임포트 Batty_A_SpriteSet — filter Point · mipmap False · 압축 Uncompressed · alphaIsTransparency True · maxSize 1024 · type Default + PASS A5 격자 Batty_A_SpriteSet — 칸 192 ≥ 필요 192 · 텍스처 768×576 + PASS A6 동작표 Batty_A_SpriteSet — 5개 · idle[0+4] run[32+6] attack[80+6] hit[128+2] die[144+6] + +== B. 부착 → 원복 (원본 파괴 0 · 렌더러 원값 복원) == + PASS B1 이름 매칭 — 액터 'Ai01(Clone)' → Ai01_SpriteSet + PASS B2 원본 끔 — SMR 11개 → 켜져 있는 것 0개 + PASS B3 쿼드 1개 — [WL814k] SpriteQuad · 부모 Ai01(Clone) · 레이어 3 + PASS B4 파괴 0 — 노드 223 → 224 (쿼드 +1) + PASS B5 원복 — SMR 켜짐 11/11 · 노드 223/223 + +== C. 프레임 · 방향 선택 (Animator 폴링 · 원본 코드 수정 0) == + PASS C1 상태→동작 — 17/17개 상태가 정확한 동작으로 간다 + PASS C2 시간→프레임 — nt 0.13→f0 nt 0.38→f1 nt 0.63→f2 nt 0.88→f3 + PASS C2b 상태 폴링(에디트 모드) — 5/5 · idle→idle run→run attack→attack hit→hit die→die + PASS C3 각도→방향 — 0°→d0 45°→d1 90°→d2 135°→d3 180°→d4 225°→d5 270°→d6 315°→d7 + PASS C4 쿼드 크기 — 월드 1.5867 m (기대 1.5867) · 스케일 보정 1.000 + PASS C5 쿼드 자세 — 카메라 정렬 오차 0.000° + +== D. GC (프레임당 할당 0) == + PASS D1 UpdateAll ×200k — mono Δ 0 B · gen0 0회 + +== E. 시트 내용 (도트화가 실제로 강제됐는가) == + PASS E1 알파 이진화 Ai01_SpriteSet — 불투명 63548 · 반투명 0(0 이어야 한다) + PASS E2 팔레트 양자화 Ai01_SpriteSet — 밝기 6단계 [0 51 102 153 204 255] 밖 픽셀 0개 · 실제 7가지 [0 17 51 102 153 204 255] (외곽선 색 14,11,17 제외) · 색 1819종 + PASS E3 외곽선 Ai01_SpriteSet — 어두운 테두리 픽셀 22159 / 불투명 63548 (34.9 %) + PASS E4 PNG 크기 Ai01_SpriteSet — 165,716 B (161.8 KB) · 896×672 · RGBA32 런타임 2.30 MB + PASS E1 알파 이진화 Batty_A_SpriteSet — 불투명 53848 · 반투명 0(0 이어야 한다) + PASS E2 팔레트 양자화 Batty_A_SpriteSet — 밝기 6단계 [0 51 102 153 204 255] 밖 픽셀 0개 · 실제 6가지 [17 51 102 153 204 255] (외곽선 색 14,11,17 제외) · 색 398종 + PASS E3 외곽선 Batty_A_SpriteSet — 어두운 테두리 픽셀 12096 / 불투명 53848 (22.5 %) + PASS E4 PNG 크기 Batty_A_SpriteSet — 107,778 B (105.3 KB) · 768×576 · RGBA32 런타임 1.69 MB + +== F. C8 롤백 (SO off → 원본 3D 100 %) == + PASS F1 게이트 — RuntimeDisabled → Enabled=false · ShouldBeOn=false + PASS F2 렌더러 100 % 복원 — SMR 11/11 · MR 2/2 + PASS F3 쿼드 0 — 노드 223/223 · 대상 0 + PASS F4 목록 밖 액터 — 'Ai02(Clone)' → 매칭 0 = 원본 3D 그대로 + +RESULT PASS — 34 pass / 0 fail diff --git a/AgentScripts/WL814k_Probe.cs b/AgentScripts/WL814k_Probe.cs new file mode 100644 index 000000000..c9834527b --- /dev/null +++ b/AgentScripts/WL814k_Probe.cs @@ -0,0 +1,686 @@ +// 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]; + } + } +} diff --git a/AgentScripts/WL814k_SURVEY.txt b/AgentScripts/WL814k_SURVEY.txt new file mode 100644 index 000000000..ffe94ba7b --- /dev/null +++ b/AgentScripts/WL814k_SURVEY.txt @@ -0,0 +1,147 @@ +WL-814k 실측 조사 · 2026-09-12 18:04:40 · Unity 6000.3.23f1 + +== ① 카메라 각 (WLLookModeSettings 프리셋 실측) == + [0] 기본 pixelRig=0 h=80 d=3 up=3 ortho=3.8 pxH=480 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m + [1] 도트A pixelRig=1 h=80 d=3 up=3 ortho=3.4 pxH=240 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m + [2] 도트B(넓게) pixelRig=1 h=80 d=3 up=3 ortho=4.6 pxH=240 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m + [3] 도트C(굵게) pixelRig=1 h=80 d=3 up=3 ortho=4.6 pxH=180 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m + 도트A: orthoSize 3.4 · pixelHeight 240 → 월드 1 m = 35.294 px · 픽셀 1개 = 0.02833 m + +== ② 프리팹 Assets/Res_Addr/PC/Ai01.prefab == + Animator = Ai01 + avatar = AiAvatar · isHuman=True · isValid=True + applyRootMotion = False · cullingMode = CullUpdateTransforms + controller = pcanim_mage (Assets/Res_Addr/Animations/pcanim_mage.controller) + clips = 26 + · LightHit len 1.000s fps 30 frames 30 loop False legacy False hasMotion True hasRoot True empty False + · death_F3441 len 2.100s fps 30 frames 63 loop False legacy False hasMotion False hasRoot True empty False + · Idle len 2.000s fps 30 frames 60 loop True legacy False hasMotion True hasRoot True empty False + · Run len 0.533s fps 30 frames 16 loop True legacy False hasMotion True hasRoot True empty False + · CastspellB_wing len 1.208s fps 24 frames 29 loop True legacy False hasMotion False hasRoot True empty False + · RangeAttack1 len 2.100s fps 30 frames 63 loop False legacy False hasMotion True hasRoot True empty False + · Atk2_wing len 1.500s fps 30 frames 45 loop True legacy False hasMotion False hasRoot True empty False + · Atk_A1_wing len 1.167s fps 24 frames 28 loop True legacy False hasMotion False hasRoot True empty False + · DashForward len 1.333s fps 30 frames 40 loop False legacy False hasMotion True hasRoot True empty False + · Land len 0.500s fps 30 frames 15 loop False legacy False hasMotion True hasRoot True empty False + · RangeAttack2 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False + · SpecialAttack1 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False + · SpecialAttack2 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False + · Stunned len 2.000s fps 30 frames 60 loop True legacy False hasMotion True hasRoot True empty False + · CastspellC_wing len 2.667s fps 24 frames 64 loop False legacy False hasMotion False hasRoot True empty False + · LyingFront len 1.333s fps 30 frames 40 loop True legacy False hasMotion False hasRoot True empty False + · LyingFront_WakeUp len 0.667s fps 30 frames 20 loop False legacy False hasMotion False hasRoot True empty False + · Face_SmileA len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_Ang1 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_SmileB len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_Ang3 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_Ang2 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_SadA len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_SadB len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_Surprise len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + · Face_EyeClose len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False + layers = 2 + layer[0] Base Layer · states 17 · default=idle + hit|LightHit|hash 1523721793|full -62788552 + die|death_F3441|hash 964231127|full -1611975250 + idle|Idle|hash -601574123|full -178524345 + run|Run|hash 1349952704|full -152736071 + casting|CastspellB_wing|hash -786711984|full 1053661652 + skill2|RangeAttack1|hash -722835594|full -673593035 + attack|Atk2_wing|hash 1203776827|full 1156809592 + attack2|Atk_A1_wing|hash -1415997692|full 1146074240 + dodge|DashForward|hash -1854944626|full 337872140 + land|Land|hash -1476340264|full -2122346614 + skill3|RangeAttack2|hash -1544726560|full -1595999837 + skill4|SpecialAttack1|hash 1032438339|full 1052404736 + skill5|SpecialAttack2|hash 1250882261|full 1237146774 + stun|Stunned|hash -210448114|full -636777636 + casting_end|CastspellC_wing|hash 380729335|full -1453968558 + lying|LyingFront|hash -478560198|full 1714786232 + getup|LyingFront_WakeUp|hash -1333958798|full 892414192 + layer[1] Emotion · states 11 · default=New State + New State|-|hash -499022955|full 833042322 + SmileA (Emotion)|Face_SmileA|hash 371072321|full -1047046492 + ANG1 (Emotion) 0|Face_Ang1|hash 1042426016|full -374643899 + Indifferent (Emotion)|-|hash 1209094866|full -995248490 + SmileB (Emotion)|Face_SmileB|hash -1879278784|full 1484075173 + ANG3 (Emotion)|Face_Ang3|hash 199512751|full 749628860 + ANG2 (Emotion)|Face_Ang2|hash -898986641|full -316624260 + SadA (Emotion)|Face_SadA|hash -774383980|full -157423225 + SadB (Emotion)|Face_SadB|hash 1211478165|full 1870630790 + Surprise (Emotion)|Face_Surprise|hash -1812836920|full 1375012336 + EyeClose (Emotion)|Face_EyeClose|hash -302154122|full 804927054 + SkinnedMeshRenderer = 11 · MeshRenderer = 2 + 삼각형 합계 = 16645 · 머티리얼 10종 + Ai_hair [Toon/Toon] + Ai_cloth [Toon/Toon] + Ai_skin [Toon/Toon] + bikini10 [Toon/Toon] + Ai_eyeline [Toon/Toon] + Ai_eyeL [Unlit/Transparent] + Ai_eyeR [Unlit/Transparent] + Ai_face [Toon/Toon] + Ai_Lash [Toon/Toon] + cheek [Unlit/Transparent] + bones[0] = 41 + transform 노드 = 223 + root localScale = (1.0000, 1.0000, 1.0000) + root comp: UnityEngine.Transform + root comp: UnityEngine.Animator + root comp: WizardActor + root comp: UnityEngine.AI.NavMeshAgent + root comp: UnityEngine.Rigidbody + root comp: UnityEngine.CapsuleCollider + root comp: SkinnedMeshAfterImage + 바운드(스케일 1) center (0.0000, 0.7372, -0.0195) size (1.3047, 1.7017, 1.0770) · 높이 1.7017 m + 바운드(스케일 0.7) 높이 1.1912 m · 폭 0.9133 m · 깊이 0.7539 m + +== ② 프리팹 Assets/Res_Addr/Mobs/Mob/Batty_A.prefab == + Animator = Batty_A + avatar = BattyAvatar · isHuman=False · isValid=True + applyRootMotion = False · cullingMode = CullUpdateTransforms + controller = Batty_Animations_Check (Assets/ResWork/Suriyun/Monster Pack Darkness/Animations/Batty/Batty_Animations_Check.controller) + clips = 8 + · Move len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False + · Idle len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False + · Attack len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False + · Damage len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False + · Die len 1.000s fps 24 frames 24 loop False legacy False hasMotion False hasRoot False empty False + · stun len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False + · casting len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False + · casting_end len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False + layers = 1 + layer[0] Base Layer · states 8 · default=idle + run|Move|hash 1349952704|full -152736071 + idle|Idle|hash -601574123|full -178524345 + attack|Attack|hash 1203776827|full 1156809592 + hit|Damage|hash 1523721793|full -62788552 + die|Die|hash 964231127|full -1611975250 + stun|stun|hash -210448114|full -636777636 + casting|casting|hash -786711984|full 1053661652 + casting_end|casting_end|hash 380729335|full -1453968558 + SkinnedMeshRenderer = 1 · MeshRenderer = 0 + 삼각형 합계 = 896 · 머티리얼 1종 + T_Batty_A [MK/Toon/URP/Standard/Simple] + bones[0] = 8 + transform 노드 = 11 + root localScale = (1.5000, 1.5000, 1.5000) + root comp: UnityEngine.Transform + root comp: UnityEngine.Animator + root comp: MobActor + root comp: UnityEngine.BoxCollider + root comp: NoSeeLongDMob + root comp: UnityEngine.AI.NavMeshAgent + root comp: UnityEngine.Rigidbody + 바운드(스케일 1) center (0.0000, 0.3453, -0.0006) size (0.9355, 0.5372, 0.3566) · 높이 0.5372 m + 바운드(스케일 0.7) 높이 0.2507 m · 폭 0.4366 m · 깊이 0.1664 m + +== ③ 캐릭터 프리팹 수 (확장 견적 근거) == + Res_Addr/PC 프리팹 = 46 + Res_Addr/Mobs 프리팹 = 299 + 그중 Animator+Controller 보유 = 299 · 서로 다른 컨트롤러 79종 + +== ④ 814c Toon 머티리얼 == + Assets/WL/Look/Env/Materials = 258개 + 'Ai_*' → 6개: Ai_cloth_Toon.mat, Ai_eyeline_Toon.mat, Ai_face_Toon.mat, Ai_hair_Toon.mat, Ai_Lash_Toon.mat, Ai_skin_Toon.mat + 'Batty*' → 0개: + WLEnvLookSettings = 있음 diff --git a/AgentScripts/WL814k_SURVEY2.txt b/AgentScripts/WL814k_SURVEY2.txt new file mode 100644 index 000000000..d8b3ec2d3 --- /dev/null +++ b/AgentScripts/WL814k_SURVEY2.txt @@ -0,0 +1,38 @@ +WL-814k 실측 ② · 2026-09-12 18:06:23 + +== 방향광 (Res_Addr/Map/WL_Nature.prefab + Ingame 씬) == + [map] Sun type=Directional color=RGBA(1.000, 0.920, 0.703, 1.000) intensity=1.2 shadows=Soft euler=(26.40, 238.60, 0.00) + [map] Sun_Ambient type=Directional color=RGBA(0.596, 0.703, 0.839, 1.000) intensity=0.3 shadows=None euler=(36.10, 116.10, 0.00) + Assets/Scenes/Ingame.unity · Light(dir) 블록 없음 · 파일 21830 B + Assets/Scenes/Title.unity · Light(dir) 블록 있음 · 파일 54278 B + RenderSettings.ambientMode(현재 씬) = Skybox · ambientLight = RGBA(0.212, 0.227, 0.259, 1.000) · ambientIntensity = 1 · sun = null + +== Toon 매칭 Assets/Res_Addr/PC/Ai01.prefab == + Ai_hair [Toon/Toon] → Toon 있음 + Ai_cloth [Toon/Toon] → Toon 있음 + Ai_skin [Toon/Toon] → Toon 있음 + bikini10 [Toon/Toon] → Toon 있음 + Ai_eyeline [Toon/Toon] → Toon 있음 + cheek [Unlit/Transparent] → Toon 없음 + Ai_eyeL [Unlit/Transparent] → Toon 없음 + Ai_eyeR [Unlit/Transparent] → Toon 없음 + Ai_face [Toon/Toon] → Toon 있음 + Ai_Lash [Toon/Toon] → Toon 있음 + +== Toon 매칭 Assets/Res_Addr/Mobs/Mob/Batty_A.prefab == + T_Batty_A [MK/Toon/URP/Standard/Simple] → Toon 있음 + +== 샘플링 경로 (AnimationMode.SampleAnimationClip) == + Ai01 · clip Idle · probe joint31 · Δpos 0.0000 · Δrot 0.00° → 변화 없음(확인필요) + Batty_A · clip Attack · probe wing_l1 · Δpos 0.0000 · Δrot 49.40° → 샘플링 OK + +== 렌더 파이프라인 == + defaultRenderPipeline = URP-Performant (Assets/Settings/URP-Performant.asset) + currentRenderPipeline = URP-Balanced + QualitySettings.renderPipeline = URP-Balanced + SystemInfo.graphicsDeviceType = Direct3D11 · supportsComputeShaders = True + Application.isBatchMode = True + Shader.Find("Sprites/Default") = OK + Shader.Find("Unlit/Transparent") = OK + Shader.Find("Universal Render Pipeline/Unlit") = OK + Shader.Find("Universal Render Pipeline/2D/Sprite-Unlit-Default") = OK diff --git a/AgentScripts/WL814k_SURVEY3.txt b/AgentScripts/WL814k_SURVEY3.txt new file mode 100644 index 000000000..2f09358b0 --- /dev/null +++ b/AgentScripts/WL814k_SURVEY3.txt @@ -0,0 +1,15 @@ +WL-814k 실측 ③ 샘플링 · 2026-09-12 18:07:00 + +== Assets/Res_Addr/PC/Ai01.prefab == + Idle len 2.00s · AnimationMode Δbounds 2.3995 m · SampleAnimation Δbounds 2.3995 m + Run len 0.53s · AnimationMode Δbounds 2.7951 m · SampleAnimation Δbounds 4.2027 m + Atk2_wing len 1.50s · AnimationMode Δbounds 2.6478 m · SampleAnimation Δbounds 3.8505 m + LightHit len 1.00s · AnimationMode Δbounds 3.1135 m · SampleAnimation Δbounds 3.1256 m + death_F3441 len 2.10s · AnimationMode Δbounds 2.7651 m · SampleAnimation Δbounds 5.0011 m + +== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab == + Idle len 0.96s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m + Move len 0.96s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m + Attack len 0.58s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m + Damage len 0.58s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m + Die len 1.00s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m diff --git a/AgentScripts/WL814k_Survey.cs b/AgentScripts/WL814k_Survey.cs new file mode 100644 index 000000000..73f1629c6 --- /dev/null +++ b/AgentScripts/WL814k_Survey.cs @@ -0,0 +1,205 @@ +// WL-814k 실측 조사 (게임 코드 아님 · 에디트 모드 전용 · 에셋 수정 0 · 읽기만) +// run: unity command run_script --file AgentScripts/WL814k_Survey.cs --entry WL814k_Survey.Run +// 🔴 원본 프리팹/머티리얼/애니메이션 수정 0. AssetDatabase.SaveAssets 호출 0. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +public static class WL814k_Survey +{ + const string kTxt = "AgentScripts/WL814k_SURVEY.txt"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + L("WL-814k 실측 조사 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion); + + try + { + Cam(); + Prefab("Assets/Res_Addr/PC/Ai01.prefab"); + Prefab("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab"); + Counts(); + ToonMats(); + } + catch (Exception ex) { L("EXCEPTION " + ex); } + + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + // ── ① 카메라 각 (811o 수식 그대로 · 814d 프리셋 값) ────────────────────── + static void Cam() + { + L(""); + L("== ① 카메라 각 (WLLookModeSettings 프리셋 실측) =="); + var so = Resources.Load("WL/WLLookModeSettings"); + if (so == null) { L(" WLLookModeSettings 없음"); return; } + for (int i = 0; i < so.presets.Length; i++) + { + var p = so.presets[i]; + float down = WL.CameraFraming.CameraFramingOverride.DownAngleDeg(p.cameraHeight, p.distance, p.distanceUp); + float above = WL.CameraFraming.CameraFramingOverride.CamHeightAbove(p.cameraHeight, p.distance, p.distanceUp); + float horiz = WL.CameraFraming.CameraFramingOverride.CamHorizontal(p.cameraHeight, p.distance); + L(string.Format(" [{0}] {1} pixelRig={2} h={3} d={4} up={5} ortho={6} pxH={7} → 내림각 {8:0.000}° · 높이 {9:0.000} m · 수평 {10:0.000} m", + i, p.name, p.pixelRig ? 1 : 0, p.cameraHeight, p.distance, p.distanceUp, p.orthoSize, p.pixelHeight, down, above, horiz)); + } + // 도트A 프리셋 기준 캐릭터 픽셀 + var d = so.presets.Length > 1 ? so.presets[1] : null; + if (d != null) + L(string.Format(" 도트A: orthoSize {0} · pixelHeight {1} → 월드 1 m = {2:0.000} px · 픽셀 1개 = {3:0.00000} m", + d.orthoSize, d.pixelHeight, d.pixelHeight / (2f * d.orthoSize), (2f * d.orthoSize) / d.pixelHeight)); + } + + // ── ② 프리팹 전수 ──────────────────────────────────────────────────────── + static void Prefab(string path) + { + L(""); + L("== ② 프리팹 " + path + " =="); + var go = AssetDatabase.LoadAssetAtPath(path); + if (go == null) { L(" 로드 실패"); return; } + + var anim = go.GetComponentInChildren(true); + L(" Animator = " + (anim == null ? "없음" : anim.gameObject.name)); + if (anim != null) + { + L(" avatar = " + (anim.avatar == null ? "null" : anim.avatar.name + " · isHuman=" + anim.avatar.isHuman + " · isValid=" + anim.avatar.isValid)); + L(" applyRootMotion = " + anim.applyRootMotion + " · cullingMode = " + anim.cullingMode); + var rac = anim.runtimeAnimatorController; + L(" controller = " + (rac == null ? "null" : rac.name + " (" + AssetDatabase.GetAssetPath(rac) + ")")); + if (rac != null) + { + var clips = rac.animationClips; + L(" clips = " + clips.Length); + var seen = new HashSet(); + foreach (var c in clips) + { + if (c == null || !seen.Add(c.name)) continue; + L(string.Format(" · {0,-28} len {1,6:0.000}s fps {2,5:0.#} frames {3,4} loop {4} legacy {5} hasMotion {6} hasRoot {7} empty {8}", + c.name, c.length, c.frameRate, Mathf.RoundToInt(c.length * c.frameRate), c.isLooping, c.legacy, c.hasMotionCurves, c.hasRootCurves, c.empty)); + } + var ac = rac as UnityEditor.Animations.AnimatorController; + if (ac != null) + { + L(" layers = " + ac.layers.Length); + for (int li = 0; li < ac.layers.Length; li++) + { + var lay = ac.layers[li]; + var sm = lay.stateMachine; + if (sm == null) { L(" layer[" + li + "] " + lay.name + " (stateMachine null)"); continue; } + var names = new List(); + foreach (var st in sm.states) + names.Add(st.state.name + "|" + (st.state.motion == null ? "-" : st.state.motion.name) + "|hash " + Animator.StringToHash(st.state.name) + + "|full " + Animator.StringToHash(lay.name + "." + st.state.name)); + L(" layer[" + li + "] " + lay.name + " · states " + names.Count + " · default=" + (sm.defaultState == null ? "-" : sm.defaultState.name)); + foreach (var n in names) L(" " + n); + } + } + } + } + + var smrs = go.GetComponentsInChildren(true); + var mrs = go.GetComponentsInChildren(true); + L(" SkinnedMeshRenderer = " + smrs.Length + " · MeshRenderer = " + mrs.Length); + int tri = 0; var mats = new HashSet(); + foreach (var r in smrs) + { + if (r.sharedMesh != null) tri += r.sharedMesh.triangles.Length / 3; + foreach (var m in r.sharedMaterials) if (m != null) mats.Add(m.name + " [" + (m.shader == null ? "-" : m.shader.name) + "]"); + } + foreach (var r in mrs) + { + var mf = r.GetComponent(); + if (mf != null && mf.sharedMesh != null) tri += mf.sharedMesh.triangles.Length / 3; + foreach (var m in r.sharedMaterials) if (m != null) mats.Add(m.name + " [" + (m.shader == null ? "-" : m.shader.name) + "]"); + } + L(" 삼각형 합계 = " + tri + " · 머티리얼 " + mats.Count + "종"); + foreach (var m in mats) L(" " + m); + + // 본 수 + if (smrs.Length > 0 && smrs[0].bones != null) L(" bones[0] = " + smrs[0].bones.Length); + var allT = go.GetComponentsInChildren(true); + L(" transform 노드 = " + allT.Length); + + // 스케일·ClassConfig + L(" root localScale = " + go.transform.localScale.ToString("F4")); + foreach (var c in go.GetComponents()) + if (c != null) L(" root comp: " + c.GetType().FullName); + + // 인스턴스를 임시로 만들어 바운드 실측(씬 저장 0) + var inst = (GameObject)PrefabUtility.InstantiatePrefab(go); + try + { + inst.transform.position = Vector3.zero; + inst.transform.rotation = Quaternion.identity; + var b = WorldBounds(inst); + L(string.Format(" 바운드(스케일 1) center {0} size {1} · 높이 {2:0.0000} m", b.center.ToString("F4"), b.size.ToString("F4"), b.size.y)); + inst.transform.localScale = Vector3.one * 0.7f; + var b7 = WorldBounds(inst); + L(string.Format(" 바운드(스케일 0.7) 높이 {0:0.0000} m · 폭 {1:0.0000} m · 깊이 {2:0.0000} m", b7.size.y, b7.size.x, b7.size.z)); + } + finally { UnityEngine.Object.DestroyImmediate(inst); } + } + + static Bounds WorldBounds(GameObject root) + { + var rs = root.GetComponentsInChildren(true); + bool any = false; var b = new Bounds(); + foreach (var r in rs) + { + if (r == null || r is ParticleSystemRenderer) continue; + if (!any) { b = r.bounds; any = true; } + else b.Encapsulate(r.bounds); + } + return b; + } + + // ── ③ 전 캐릭터 수 (확장 견적용) ──────────────────────────────────────── + static void Counts() + { + L(""); + L("== ③ 캐릭터 프리팹 수 (확장 견적 근거) =="); + var pc = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/PC" }); + var mob = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" }); + L(" Res_Addr/PC 프리팹 = " + pc.Length); + L(" Res_Addr/Mobs 프리팹 = " + mob.Length); + int mobWithAnim = 0; var ctrls = new HashSet(); + foreach (var g in mob) + { + var p = AssetDatabase.GUIDToAssetPath(g); + var go = AssetDatabase.LoadAssetAtPath(p); + if (go == null) continue; + var a = go.GetComponentInChildren(true); + if (a != null && a.runtimeAnimatorController != null) { mobWithAnim++; ctrls.Add(a.runtimeAnimatorController.name); } + } + L(" 그중 Animator+Controller 보유 = " + mobWithAnim + " · 서로 다른 컨트롤러 " + ctrls.Count + "종"); + } + + // ── ④ Toon 머티리얼 (814c) ────────────────────────────────────────────── + static void ToonMats() + { + L(""); + L("== ④ 814c Toon 머티리얼 =="); + string dir = "Assets/WL/Look/Env/Materials"; + var all = AssetDatabase.FindAssets("t:Material", new[] { dir }); + L(" " + dir + " = " + all.Length + "개"); + foreach (var key in new[] { "Ai_", "Batty" }) + { + var hits = new List(); + foreach (var g in all) + { + var p = AssetDatabase.GUIDToAssetPath(g); + if (Path.GetFileName(p).StartsWith(key, StringComparison.OrdinalIgnoreCase)) hits.Add(Path.GetFileName(p)); + } + L(" '" + key + "*' → " + hits.Count + "개: " + string.Join(", ", hits.ToArray())); + } + var envSo = Resources.Load("WL/WLEnvLookSettings"); + L(" WLEnvLookSettings = " + (envSo == null ? "없음" : "있음")); + } +} diff --git a/AgentScripts/WL814k_Survey2.cs b/AgentScripts/WL814k_Survey2.cs new file mode 100644 index 000000000..f1ebf2bb5 --- /dev/null +++ b/AgentScripts/WL814k_Survey2.cs @@ -0,0 +1,133 @@ +// WL-814k 실측 조사 ② — 조명 · Toon 머티리얼 매칭 · 샘플링 경로 (읽기만) +// run: unity command run_script --file AgentScripts/WL814k_Survey2.cs --entry WL814k_Survey2.Run + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +public static class WL814k_Survey2 +{ + const string kTxt = "AgentScripts/WL814k_SURVEY2.txt"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + L("WL-814k 실측 ② · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + try + { + Lights(); + ToonMatch("Assets/Res_Addr/PC/Ai01.prefab"); + ToonMatch("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab"); + SamplePath(); + Srp(); + } + catch (Exception ex) { L("EXCEPTION " + ex); } + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + // 게임 맵의 방향광 실측 + static void Lights() + { + L(""); + L("== 방향광 (Res_Addr/Map/WL_Nature.prefab + Ingame 씬) =="); + var map = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Map/WL_Nature.prefab"); + if (map != null) + { + foreach (var lt in map.GetComponentsInChildren(true)) + L(string.Format(" [map] {0} type={1} color={2} intensity={3} shadows={4} euler={5}", + lt.name, lt.type, lt.color, lt.intensity, lt.shadows, lt.transform.eulerAngles.ToString("F2"))); + } + else L(" WL_Nature.prefab 없음"); + + foreach (var sc in new[] { "Assets/Scenes/Ingame.unity", "Assets/Scenes/Title.unity" }) + { + if (!File.Exists(sc)) { L(" " + sc + " 없음"); continue; } + var txt = File.ReadAllText(sc); + int i = txt.IndexOf("m_Type: 1"); + L(" " + sc + " · Light(dir) 블록 " + (i >= 0 ? "있음" : "없음") + " · 파일 " + new FileInfo(sc).Length + " B"); + } + L(" RenderSettings.ambientMode(현재 씬) = " + RenderSettings.ambientMode + " · ambientLight = " + RenderSettings.ambientLight + + " · ambientIntensity = " + RenderSettings.ambientIntensity + " · sun = " + (RenderSettings.sun == null ? "null" : RenderSettings.sun.name)); + } + + // 캐릭터 머티리얼 → *_Toon.mat 존재 여부 + static void ToonMatch(string prefab) + { + L(""); + L("== Toon 매칭 " + prefab + " =="); + var go = AssetDatabase.LoadAssetAtPath(prefab); + if (go == null) { L(" 로드 실패"); return; } + var seen = new HashSet(); + foreach (var r in go.GetComponentsInChildren(true)) + foreach (var m in r.sharedMaterials) + { + if (m == null || !seen.Add(m.name)) continue; + string toon = "Assets/WL/Look/Env/Materials/" + m.name + "_Toon.mat"; + var t = AssetDatabase.LoadAssetAtPath(toon); + L(" " + m.name.PadRight(16) + " [" + (m.shader == null ? "-" : m.shader.name) + "] → " + (t != null ? "Toon 있음" : "Toon 없음")); + } + } + + // 샘플링 경로 확인 — Generic/Humanoid 둘 다 AnimationMode 로 찍히는가 + static void SamplePath() + { + L(""); + L("== 샘플링 경로 (AnimationMode.SampleAnimationClip) =="); + foreach (var pair in new[] { + new[]{ "Assets/Res_Addr/PC/Ai01.prefab", "Idle" }, + new[]{ "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", "Attack" } }) + { + var go = AssetDatabase.LoadAssetAtPath(pair[0]); + if (go == null) { L(" " + pair[0] + " 로드 실패"); continue; } + var inst = (GameObject)PrefabUtility.InstantiatePrefab(go); + try + { + var anim = inst.GetComponentInChildren(true); + AnimationClip clip = null; + foreach (var c in anim.runtimeAnimatorController.animationClips) if (c.name == pair[1]) { clip = c; break; } + if (clip == null) { L(" 클립 " + pair[1] + " 없음"); continue; } + + // 대표 본의 t=0 / t=len/2 포즈 차이를 잰다 + var bones = inst.GetComponentsInChildren(true); + Transform probe = bones.Length > 3 ? bones[bones.Length / 2] : inst.transform; + AnimationMode.StartAnimationMode(); + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, clip, 0f); + AnimationMode.EndSampling(); + Vector3 p0 = probe.localPosition; Quaternion r0 = probe.localRotation; + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, clip, clip.length * 0.5f); + AnimationMode.EndSampling(); + Vector3 p1 = probe.localPosition; Quaternion r1 = probe.localRotation; + AnimationMode.StopAnimationMode(); + L(string.Format(" {0} · clip {1} · probe {2} · Δpos {3:0.0000} · Δrot {4:0.00}° → {5}", + Path.GetFileNameWithoutExtension(pair[0]), clip.name, probe.name, + Vector3.Distance(p0, p1), Quaternion.Angle(r0, r1), + (Vector3.Distance(p0, p1) > 0.0001f || Quaternion.Angle(r0, r1) > 0.05f) ? "샘플링 OK" : "변화 없음(확인필요)")); + } + catch (Exception ex) { L(" EXCEPTION " + ex.Message); if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); } + finally { UnityEngine.Object.DestroyImmediate(inst); } + } + } + + static void Srp() + { + L(""); + L("== 렌더 파이프라인 =="); + var rp = UnityEngine.Rendering.GraphicsSettings.defaultRenderPipeline; + L(" defaultRenderPipeline = " + (rp == null ? "null(빌트인)" : rp.name + " (" + AssetDatabase.GetAssetPath(rp) + ")")); + L(" currentRenderPipeline = " + (UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline == null ? "null" : UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline.name)); + L(" QualitySettings.renderPipeline = " + (QualitySettings.renderPipeline == null ? "null" : QualitySettings.renderPipeline.name)); + L(" SystemInfo.graphicsDeviceType = " + SystemInfo.graphicsDeviceType + " · supportsComputeShaders = " + SystemInfo.supportsComputeShaders); + L(" Application.isBatchMode = " + Application.isBatchMode); + // Unlit 투명 셰이더 후보 + foreach (var s in new[] { "Sprites/Default", "Unlit/Transparent", "Universal Render Pipeline/Unlit", "Universal Render Pipeline/2D/Sprite-Unlit-Default" }) + L(" Shader.Find(\"" + s + "\") = " + (Shader.Find(s) != null ? "OK" : "없음")); + } +} diff --git a/AgentScripts/WL814k_Survey3.cs b/AgentScripts/WL814k_Survey3.cs new file mode 100644 index 000000000..a194860aa --- /dev/null +++ b/AgentScripts/WL814k_Survey3.cs @@ -0,0 +1,87 @@ +// WL-814k 실측 조사 ③ — Humanoid/Generic 샘플링 경로 확정 (읽기만) +// run: unity command run_script --file AgentScripts/WL814k_Survey3.cs --entry WL814k_Survey3.Run + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +public static class WL814k_Survey3 +{ + const string kTxt = "AgentScripts/WL814k_SURVEY3.txt"; + static StringBuilder s_sb; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); } + + public static void Run() + { + s_sb = new StringBuilder(); + L("WL-814k 실측 ③ 샘플링 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + try + { + Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Idle", "Run", "Atk2_wing", "LightHit", "death_F3441" }); + Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "Idle", "Move", "Attack", "Damage", "Die" }); + } + catch (Exception ex) { L("EXCEPTION " + ex); } + finally { if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); } + File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL814k] → " + kTxt); + } + + static void Test(string prefab, string[] clipNames) + { + L(""); + L("== " + prefab + " =="); + var src = AssetDatabase.LoadAssetAtPath(prefab); + var inst = (GameObject)PrefabUtility.InstantiatePrefab(src); + inst.transform.position = Vector3.zero; inst.transform.rotation = Quaternion.identity; + try + { + var anim = inst.GetComponentInChildren(true); + var rac = anim.runtimeAnimatorController; + foreach (var name in clipNames) + { + AnimationClip clip = null; + foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; } + if (clip == null) { L(" " + name + " → 클립 없음"); continue; } + + // ⓐ AnimationMode 경로 + float aSpread = Spread(inst, clip, true); + // ⓑ clip.SampleAnimation 경로 + float bSpread = Spread(inst, clip, false); + L(string.Format(" {0,-14} len {1,5:0.00}s · AnimationMode Δbounds {2:0.0000} m · SampleAnimation Δbounds {3:0.0000} m", + name, clip.length, aSpread, bSpread)); + } + } + finally { if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); UnityEngine.Object.DestroyImmediate(inst); } + } + + // 클립을 8등분해 찍고, 전체 렌더러 바운드 중심의 최대 이동량(= 실제로 포즈가 바뀌었는가) + static float Spread(GameObject inst, AnimationClip clip, bool useAnimationMode) + { + var rs = inst.GetComponentsInChildren(true); + Vector3 min = Vector3.positiveInfinity, max = Vector3.negativeInfinity; + for (int i = 0; i < 8; i++) + { + float t = clip.length * i / 8f; + if (useAnimationMode) + { + AnimationMode.StartAnimationMode(); + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, clip, t); + AnimationMode.EndSampling(); + AnimationMode.StopAnimationMode(); + } + else + { + clip.SampleAnimation(inst, t); + } + Bounds b = new Bounds(); bool any = false; + foreach (var r in rs) { if (!any) { b = r.bounds; any = true; } else b.Encapsulate(r.bounds); } + if (!any) continue; + min = Vector3.Min(min, b.min); max = Vector3.Max(max, b.max); + } + if (float.IsInfinity(min.x)) return -1f; + return (max - min).magnitude; + } +} diff --git a/Assets/WL/Look/Sprite.meta b/Assets/WL/Look/Sprite.meta new file mode 100644 index 000000000..05ccbf19e --- /dev/null +++ b/Assets/WL/Look/Sprite.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3b1e4280b824eb84c9918c582b2df1b1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Baked.meta b/Assets/WL/Look/Sprite/Baked.meta new file mode 100644 index 000000000..6f502d786 --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f60da21b8811ca438a64ff9cf16bd2e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Baked/Ai01.png b/Assets/WL/Look/Sprite/Baked/Ai01.png new file mode 100644 index 000000000..8e040c004 Binary files /dev/null and b/Assets/WL/Look/Sprite/Baked/Ai01.png differ diff --git a/Assets/WL/Look/Sprite/Baked/Ai01.png.meta b/Assets/WL/Look/Sprite/Baked/Ai01.png.meta new file mode 100644 index 000000000..f8d4f4e72 --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Ai01.png.meta @@ -0,0 +1,181 @@ +fileFormatVersion: 2 +guid: d5e256fc5c31a4943b1afb9820681cec +TextureImporter: + internalIDToNameTable: + - first: + 213: 5980685433440855488 + second: Ai01_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 1024 + resizeAlgorithm: 0 + textureFormat: 4 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Server + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 1024 + resizeAlgorithm: 0 + textureFormat: 4 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 1 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Ai01_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 2048 + height: 1536 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 0c56895f6b9aff250800000000000000 + internalID: 5980685433440855488 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset b/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset new file mode 100644 index 000000000..7feebb52a --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset @@ -0,0 +1,86 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7485ff60a4b963544b28a1b345440fb9, type: 3} + m_Name: Ai01_SpriteSet + m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteSet + sourcePrefab: Assets/Res_Addr/PC/Ai01.prefab + actorNamePrefix: Ai01 + bakeStamp: "2026-09-12 18:38 \xB7 WL-814k" + sheet: {fileID: 2800000, guid: d5e256fc5c31a4943b1afb9820681cec, type: 3} + frameWidth: 56 + frameHeight: 56 + columns: 16 + rows: 12 + dirCount: 8 + worldPerPixel: 0.028333334 + bakeScale: 0.7 + footPixelX: 28.5 + footPixelY: 19.5 + pitchDeg: 43.60939 + actions: + - key: idle + stateNames: + - idle + - land + - getup + clipName: Idle + startIndex: 0 + frameCount: 4 + fps: 2 + loop: 1 + clipLength: 2 + - key: run + stateNames: + - run + - dodge + clipName: Run + startIndex: 32 + frameCount: 6 + fps: 11.249999 + loop: 1 + clipLength: 0.53333336 + - key: attack + stateNames: + - attack + - attack2 + - skill2 + - skill3 + - skill4 + - skill5 + - casting + - casting_end + clipName: Atk2_wing + startIndex: 80 + frameCount: 6 + fps: 4 + loop: 1 + clipLength: 1.5 + - key: hit + stateNames: + - hit + - stun + clipName: LightHit + startIndex: 128 + frameCount: 2 + fps: 2 + loop: 1 + clipLength: 1 + - key: die + stateNames: + - die + - lying + clipName: death_F3441 + startIndex: 144 + frameCount: 6 + fps: 2.8571427 + loop: 0 + clipLength: 2.1000001 diff --git a/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset.meta b/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset.meta new file mode 100644 index 000000000..a9a3d0728 --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9afd69d5485f7584b8f40e7673bca04e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Baked/Batty_A.png b/Assets/WL/Look/Sprite/Baked/Batty_A.png new file mode 100644 index 000000000..10939d61c Binary files /dev/null and b/Assets/WL/Look/Sprite/Baked/Batty_A.png differ diff --git a/Assets/WL/Look/Sprite/Baked/Batty_A.png.meta b/Assets/WL/Look/Sprite/Baked/Batty_A.png.meta new file mode 100644 index 000000000..5ca0a1ba9 --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Batty_A.png.meta @@ -0,0 +1,181 @@ +fileFormatVersion: 2 +guid: 1437ff6eefe10ba4cb73442f8f923071 +TextureImporter: + internalIDToNameTable: + - first: + 213: -3965868031581062660 + second: Batty_A_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 0 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 1024 + resizeAlgorithm: 0 + textureFormat: 4 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Server + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 1024 + resizeAlgorithm: 0 + textureFormat: 4 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 1 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: Batty_A_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 768 + height: 576 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: cf9788f890866f8c0800000000000000 + internalID: -3965868031581062660 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset b/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset new file mode 100644 index 000000000..5bdd8256b --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset @@ -0,0 +1,77 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7485ff60a4b963544b28a1b345440fb9, type: 3} + m_Name: Batty_A_SpriteSet + m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteSet + sourcePrefab: Assets/Res_Addr/Mobs/Mob/Batty_A.prefab + actorNamePrefix: Batty_A + bakeStamp: "2026-09-12 18:38 \xB7 WL-814k" + sheet: {fileID: 2800000, guid: 1437ff6eefe10ba4cb73442f8f923071, type: 3} + frameWidth: 48 + frameHeight: 48 + columns: 16 + rows: 12 + dirCount: 8 + worldPerPixel: 0.028333334 + bakeScale: 1.5 + footPixelX: 24.5 + footPixelY: 14.5 + pitchDeg: 43.60939 + actions: + - key: idle + stateNames: + - idle + clipName: Idle + startIndex: 0 + frameCount: 4 + fps: 4.173913 + loop: 1 + clipLength: 0.9583333 + - key: run + stateNames: + - run + clipName: Move + startIndex: 32 + frameCount: 6 + fps: 6.2608695 + loop: 1 + clipLength: 0.9583334 + - key: attack + stateNames: + - attack + - casting + - casting_end + clipName: Attack + startIndex: 80 + frameCount: 6 + fps: 10.285716 + loop: 1 + clipLength: 0.58333325 + - key: hit + stateNames: + - hit + - stun + clipName: Damage + startIndex: 128 + frameCount: 2 + fps: 3.4285705 + loop: 1 + clipLength: 0.5833335 + - key: die + stateNames: + - die + clipName: Die + startIndex: 144 + frameCount: 6 + fps: 6 + loop: 0 + clipLength: 1 diff --git a/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset.meta b/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset.meta new file mode 100644 index 000000000..4e3467fa0 --- /dev/null +++ b/Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02557ce4e57225d4db041caa2e6e44d7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Editor.meta b/Assets/WL/Look/Sprite/Editor.meta new file mode 100644 index 000000000..3c31775a8 --- /dev/null +++ b/Assets/WL/Look/Sprite/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 518e04114c590e847b8493855c603c59 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs b/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs new file mode 100644 index 000000000..06ddcc440 --- /dev/null +++ b/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs @@ -0,0 +1,696 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSpriteBaker.cs — 3D 캐릭터 프리팹 → 「도트 스프라이트 시트」 굽기 (에디터 전용 · WL-814k · #814) +// +// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」 +// +// ■ 🔴 이 툴은 **프리팹 무관**하다 — 대상 프리팹은 인자로 받는다. 캐릭터 이름이 코드에 0개. +// (PD 가 캐릭터 에셋 교체를 검토 중 → 특정 프리팹에 하드코딩하면 툴이 통째로 버려진다.) +// +// ■ 절차 +// ① 임시 씬(Empty · Single)에 프리팹을 인스턴스화하고 **프리팹 링크를 끊는다**(Unpack). +// → 이후 무슨 짓을 해도 원본 프리팹 에셋에 한 글자도 안 쓴다. 씬 저장 0. +// ② 814c Toon 머티리얼(`<이름>_Toon.mat`)이 있으면 **인스턴스에만** 갈아끼운다(원본 .mat 무변경). +// ③ 인게임과 같은 **내림각**(811o 궤도 수식 · 실측 43.609°)의 **직교 카메라**를 만들고, +// 픽셀 1개의 월드 크기를 도트 프리셋과 같게 맞춘다(= 스프라이트 1px 이 화면 1px). +// ④ `AnimationMode.SampleAnimationClip` 로 포즈를 고정(Generic·Humanoid 둘 다 실측 OK) → +// 방향(피벗 Y 회전) × 동작 × 프레임마다 **2번** 렌더한다(배경 검정 / 배경 흰색). +// 두 장의 차이로 **알파를 정확히 복원**한다 — URP 가 불투명 패스에서 알파를 어떻게 쓰든 안 흔들린다. +// ⑤ 도트화를 **굽는 시점에 강제**: 알파 이진화(0.5) → 팔레트 양자화(채널당 6단계) → 1px 검정 외곽선. +// ⑥ 전 프레임의 실루엣을 합쳐 **꽉 차는 프레임 크기**를 8의 배수로 자동 산출하고, 그 크기로 잘라 +// 시트 1장(PNG)에 격자로 붙인다 + 메타 SO(WLSpriteSet)를 만든다. +// +// ■ 왜 서브 스프라이트(spriteImportMode Multiple)가 아니라 「단일 시트 + UV 산술」인가 +// 런타임 빌보드는 `Sprite` 객체가 아니라 **쿼드 메시 + MaterialPropertyBlock 의 `_BaseMap_ST`** 로 그린다. +// Multiple 로 자르면 프레임 수만큼(시범 2종 = 384개) 서브 에셋과 .meta 줄이 생기고 런타임 이득은 0이다. +// 시트 1장 + `UvRect()` 산술이면 서브 에셋 0 · 프레임 전환 시 할당 0. +// +// 🔴 금지 준수: 원본 프리팹/머티리얼/애니메이션 **쓰기 0**. `AssetDatabase.SaveAssets()` 는 +// 이 툴이 만든 에셋에만 쓰고, 임포트 설정은 대상 PNG 하나에만 건다. +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace WL.Look.SpriteBake.EditorTools +{ + public static class WLSpriteBaker + { + public sealed class Result + { + public bool ok; + public string prefabPath = ""; + public string pngPath = ""; + public string setPath = ""; + public int frameSize, columns, rows, sheetW, sheetH; + public int dirCount, totalFrames; + public long pngBytes; + public double seconds; + public float worldPerPixel, pitchDeg, bakeScale; + public float charPixelHeight; + public bool clipped; + public readonly List notes = new List(); + public readonly StringBuilder log = new StringBuilder(); + public string Summary + { + get + { + return string.Format("{0} · {1}×{1} px · {2}방향 × {3}프레임 · 시트 {4}×{5} · {6:N0} KB · {7:0.0}초", + Path.GetFileNameWithoutExtension(prefabPath), frameSize, dirCount, totalFrames / Mathf.Max(1, dirCount), + sheetW, sheetH, pngBytes / 1024.0, seconds); + } + } + } + + // ═════════════════════════════════════════════════════════════════════ + // 진입점 — 🔴 대상 프리팹은 **인자** + // ═════════════════════════════════════════════════════════════════════ + + public static Result Bake(string prefabPath, WLSpriteBakeSettings cfg, + string outputDirOverride = null, string actorNamePrefixOverride = null, + float rootScale = 0f) + { + var r = new Result { prefabPath = prefabPath }; + var sw = System.Diagnostics.Stopwatch.StartNew(); + if (cfg == null) { r.log.AppendLine("FAIL — WLSpriteBakeSettings 없음"); return r; } + + var src = AssetDatabase.LoadAssetAtPath(prefabPath); + if (src == null) { r.log.AppendLine("FAIL — 프리팹 로드 실패: " + prefabPath); return r; } + + string outDir = string.IsNullOrEmpty(outputDirOverride) ? cfg.outputDir : outputDirOverride; + string baseName = Path.GetFileNameWithoutExtension(prefabPath); + string prefix = string.IsNullOrEmpty(actorNamePrefixOverride) ? baseName : actorNamePrefixOverride; + + if (!Application.isBatchMode && !EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) + { r.log.AppendLine("취소 — 열린 씬 저장 거부"); return r; } + + Scene temp = default(Scene); + GameObject pivot = null, inst = null, camGo = null; + RenderTexture rt = null; + Texture2D readTex = null; + bool animMode = false; + + try + { + temp = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SetupWorld(cfg); + + // ── ① 인스턴스 (프리팹 링크 완전 절단 → 원본 에셋 쓰기 0) ──── + pivot = new GameObject("[WL814k] Pivot"); + inst = (GameObject)PrefabUtility.InstantiatePrefab(src); + PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + inst.transform.SetParent(pivot.transform, false); + inst.transform.localPosition = Vector3.zero; + inst.transform.localRotation = Quaternion.identity; + // 🔴 인게임에서 실제로 쓰는 크기로 굽는다(인자) — 0 이면 프리팹 자체 스케일. + // 런타임 스케일과 같게 구워야 스프라이트 1px = 화면 1px 이 유지된다. + if (rootScale > 0.0001f) inst.transform.localScale = Vector3.one * rootScale; + StripNonVisual(inst); + r.bakeScale = Mathf.Abs(inst.transform.lossyScale.y); + if (r.bakeScale < 0.0001f) r.bakeScale = 1f; + + int toonHit = cfg.useToonMaterials ? SwapToonMaterials(inst, cfg, r) : 0; + r.log.AppendLine("Toon 머티리얼 교체 = " + toonHit + "슬롯 (인스턴스에만 · 원본 .mat 무변경)"); + + Vector3 srcLocalPos = inst.transform.localPosition; + Quaternion srcLocalRot = inst.transform.localRotation; + + // ── ② 동작 수집 (이름 규약 · 프리팹 무관) ──────────────────── + var anim = inst.GetComponentInChildren(true); + if (anim == null || anim.runtimeAnimatorController == null) + { r.log.AppendLine("FAIL — Animator/Controller 없음"); return r; } + var plan = CollectActions(anim, cfg, r); + if (plan.Count == 0) { r.log.AppendLine("FAIL — 구울 동작을 하나도 못 찾았다"); return r; } + + // 🔴 샘플링 경로 — 기본은 **진짜 Animator 구동**(anim.Play + anim.Update). + // 실측(814k): AnimationMode.SampleAnimationClip 은 **루트 모션을 포즈에 얹는다** + // (Ai01 Run 이 2.0 m, death 가 3.0 m 밀려 프레임 밖으로 나갔다) — 그런데 게임의 + // Animator 는 applyRootMotion=false 라 그 이동을 버린다. 즉 AnimationMode 로 구우면 + // **게임에 안 나오는 그림**이 구워진다. Animator 구동은 컨트롤러·리타깃까지 게임과 같다. + anim.applyRootMotion = false; + anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; + anim.Rebind(); + + // ── ③ 카메라 ──────────────────────────────────────────────── + float pitch = cfg.PitchDeg; + float wpp = cfg.WorldPerPixel; + r.pitchDeg = pitch; r.worldPerPixel = wpp; + int P = Mathf.Max(32, cfg.probeSize); + camGo = new GameObject("[WL814k] BakeCam"); + var cam = camGo.AddComponent(); + cam.orthographic = true; + cam.orthographicSize = P * wpp * 0.5f; + cam.nearClipPlane = 0.01f; + cam.farClipPlane = 200f; + cam.clearFlags = CameraClearFlags.SolidColor; + cam.allowHDR = false; + cam.allowMSAA = false; + cam.useOcclusionCulling = false; + camGo.transform.rotation = Quaternion.Euler(pitch, 0f, 0f); + // 월드 원점이 (P/2, P/2) 픽셀의 **중심**에 찍히도록 반 픽셀 민다. + Vector3 aim = Vector3.zero + - camGo.transform.right * (wpp * 0.5f) + - camGo.transform.up * (wpp * 0.5f); + camGo.transform.position = aim - camGo.transform.forward * 50f; + + rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + rt.filterMode = FilterMode.Point; + rt.antiAliasing = 1; + rt.Create(); + readTex = new Texture2D(P, P, TextureFormat.RGBA32, false, false); + + // ── ④ 전 프레임 렌더 (프로브 해상도) ───────────────────────── + int dirN = Mathf.Max(1, cfg.dirCount); + r.dirCount = dirN; + float step = 360f / dirN; + var frames = new List(256); + int minX = int.MaxValue, maxX = int.MinValue, minY = int.MaxValue, maxY = int.MinValue; + + if (cfg.sampleWithAnimationMode) { AnimationMode.StartAnimationMode(); animMode = true; } + var black = new Color32[P * P]; + var white = new Color32[P * P]; + + for (int ai = 0; ai < plan.Count; ai++) + { + var pa = plan[ai]; + for (int d = 0; d < dirN; d++) + { + pivot.transform.rotation = Quaternion.Euler(0f, d * step, 0f); + for (int f = 0; f < pa.frames; f++) + { + float nt = pa.loop + ? (float)f / pa.frames + : (pa.frames > 1 ? (float)f / (pa.frames - 1) : 0f); + + if (animMode) + { + AnimationMode.BeginSampling(); + AnimationMode.SampleAnimationClip(inst, pa.clip, pa.clip.length * nt); + AnimationMode.EndSampling(); + } + else + { + anim.Play(pa.stateHash, 0, nt); + anim.Update(0f); + } + // 제자리로(빌보드는 액터 위치에 붙으므로 스프라이트는 제자리 애니메이션이어야 한다) + inst.transform.localPosition = srcLocalPos; + inst.transform.localRotation = srcLocalRot; + + RenderInto(cam, rt, readTex, Color.black, black); + RenderInto(cam, rt, readTex, Color.white, white); + + var px = Compose(black, white, P, cfg); + frames.Add(px); + TightBox(px, P, ref minX, ref maxX, ref minY, ref maxY); + } + } + } + if (animMode) { AnimationMode.StopAnimationMode(); animMode = false; } + + if (minX > maxX) { r.log.AppendLine("FAIL — 렌더 결과가 전부 비었다(알파 0)"); return r; } + + // ── ⑤ 프레임 크기 · 발밑 픽셀 산출 ────────────────────────── + int origin = P / 2; // 월드 원점이 찍힌 픽셀 인덱스 + int margin = Mathf.Max(0, cfg.marginPx); + int needW = 2 * Mathf.Max(origin - minX, maxX - origin) + 2 * margin; + int needH = (maxY - minY + 1) + 2 * margin; + int need = Mathf.Max(needW, needH); + int S = cfg.autoFrameSize + ? Mathf.Clamp(RoundUp8(need), Mathf.Max(8, cfg.frameSizeMin), Mathf.Max(8, cfg.frameSizeMax)) + : Mathf.Max(8, cfg.frameSize); + + int below = (origin - minY) + margin; + int above = (maxY - origin) + margin + 1; + int fpxI = S / 2; + int fpyI; + if (below + above <= S) fpyI = below; + else { fpyI = Mathf.RoundToInt(S * (float)below / (below + above)); r.clipped = true; r.notes.Add("프레임 상한 초과 — 실루엣이 잘렸다(needed " + need + " px)"); } + fpyI = Mathf.Clamp(fpyI, 0, S); + + r.frameSize = S; + r.charPixelHeight = (maxY - minY + 1); + r.log.AppendLine(string.Format("실루엣 실측(프로브 {0}px) — x [{1}..{2}] y [{3}..{4}] · 캐릭터 높이 {5} px · 폭 {6} px → 프레임 {7}×{7} · 발밑 픽셀 ({8}.5, {9}.5)", + P, minX, maxX, minY, maxY, maxY - minY + 1, maxX - minX + 1, S, fpxI, fpyI)); + + // ── ⑥ 자르기 + 외곽선 → 시트 ──────────────────────────────── + int total = frames.Count; + r.totalFrames = total; + int cols = cfg.sheetColumns > 0 ? cfg.sheetColumns : Mathf.CeilToInt(Mathf.Sqrt(total)); + while (cols > 1 && cols * S > Mathf.Max(S, cfg.sheetMaxWidth)) cols--; + int rows = Mathf.CeilToInt(total / (float)cols); + r.columns = cols; r.rows = rows; + r.sheetW = cols * S; r.sheetH = rows * S; + + var sheet = new Color32[r.sheetW * r.sheetH]; // 기본 = 완전 투명(0,0,0,0) + for (int i = 0; i < total; i++) + { + var cropped = Crop(frames[i], P, S, origin - fpxI, origin - fpyI); + if (cfg.outline) Outline(cropped, S, cfg); + int col = i % cols, row = i / cols; + Blit(cropped, S, sheet, r.sheetW, col * S, r.sheetH - (row + 1) * S); + } + + // ── ⑦ PNG + 임포트 설정 ──────────────────────────────────── + if (!Directory.Exists(outDir)) Directory.CreateDirectory(outDir); + string png = (outDir.TrimEnd('/') + "/" + baseName + ".png").Replace('\\', '/'); + var tex = new Texture2D(r.sheetW, r.sheetH, TextureFormat.RGBA32, false, false); + tex.SetPixels32(sheet); tex.Apply(false, false); + File.WriteAllBytes(png, tex.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(tex); + AssetDatabase.ImportAsset(png, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + ApplyImportSettings(png, Mathf.Max(r.sheetW, r.sheetH)); + r.pngPath = png; + r.pngBytes = new FileInfo(png).Length; + + // ── ⑧ 메타 SO ───────────────────────────────────────────── + string setPath = (outDir.TrimEnd('/') + "/" + baseName + "_SpriteSet.asset").Replace('\\', '/'); + var set = AssetDatabase.LoadAssetAtPath(setPath); + bool created = set == null; + if (created) set = ScriptableObject.CreateInstance(); + set.sourcePrefab = prefabPath; + set.actorNamePrefix = prefix; + set.bakeStamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm") + " · WL-814k"; + set.sheet = AssetDatabase.LoadAssetAtPath(png); + set.frameWidth = S; set.frameHeight = S; + set.columns = cols; set.rows = rows; + set.dirCount = dirN; + set.worldPerPixel = wpp; + set.bakeScale = r.bakeScale; + set.footPixelX = fpxI + 0.5f; + set.footPixelY = fpyI + 0.5f; + set.pitchDeg = pitch; + + var acts = new WLSpriteAction[plan.Count]; + int start = 0; + for (int i = 0; i < plan.Count; i++) + { + var pa = plan[i]; + acts[i] = new WLSpriteAction + { + key = pa.key, + stateNames = pa.stateNames.ToArray(), + clipName = pa.clip.name, + startIndex = start, + frameCount = pa.frames, + fps = pa.frames / Mathf.Max(0.0001f, pa.clip.length), + loop = pa.loop, + clipLength = pa.clip.length, + }; + start += pa.frames * dirN; + } + set.actions = acts; + + if (created) AssetDatabase.CreateAsset(set, setPath); + else EditorUtility.SetDirty(set); + AssetDatabase.SaveAssetIfDirty(set); + r.setPath = setPath; + r.ok = true; + } + catch (Exception ex) + { + r.log.AppendLine("EXCEPTION " + ex); + } + finally + { + if (animMode && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); + if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } + if (readTex != null) UnityEngine.Object.DestroyImmediate(readTex); + if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo); + if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot); + // 🔴 임시 씬은 저장하지 않는다. 빈 씬으로 갈아 끼워 흔적 0. + if (temp.IsValid()) EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + sw.Stop(); + r.seconds = sw.Elapsed.TotalSeconds; + return r; + } + + // ═════════════════════════════════════════════════════════════════════ + // 월드(조명 · 환경광) + // ═════════════════════════════════════════════════════════════════════ + static void SetupWorld(WLSpriteBakeSettings cfg) + { + RenderSettings.fog = false; + if (cfg.useSkyboxAmbient) + { + // 🔴 인게임 `Assets/Scenes/Ingame.unity` 실측 = m_AmbientMode 0(Skybox) + 기본 스카이박스. + // Flat 0.212 회색으로 구우면 게임보다 어둡게 구워진다(실측으로 확인). + RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource("Default-Skybox.mat"); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox; + RenderSettings.ambientIntensity = cfg.ambientIntensity; + RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Skybox; + DynamicGI.UpdateEnvironment(); + } + else + { + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = cfg.ambient; + RenderSettings.skybox = null; + RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom; + RenderSettings.customReflectionTexture = null; + } + + var sun = new GameObject("[WL814k] Sun").AddComponent(); + sun.type = LightType.Directional; + sun.color = cfg.sunColor; + sun.intensity = cfg.sunIntensity; + sun.shadows = LightShadows.None; // 🔴 그림자 off (시트에 그림자가 구워지면 안 된다) + sun.transform.rotation = Quaternion.Euler(cfg.sunEuler); + + if (cfg.fillIntensity > 0.0001f) + { + var fill = new GameObject("[WL814k] Fill").AddComponent(); + fill.type = LightType.Directional; + fill.color = cfg.fillColor; + fill.intensity = cfg.fillIntensity; + fill.shadows = LightShadows.None; + fill.transform.rotation = Quaternion.Euler(cfg.fillEuler); + } + } + + /// 렌더에 필요 없는 것(콜라이더·에이전트·리지드바디·파티클·게임 스크립트)을 인스턴스에서 뗀다. + static void StripNonVisual(GameObject inst) + { + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); + foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c.gameObject); + foreach (var mb in inst.GetComponentsInChildren(true)) + if (mb != null) UnityEngine.Object.DestroyImmediate(mb); + foreach (var sm in inst.GetComponentsInChildren(true)) + { + if (sm == null) continue; + sm.updateWhenOffscreen = true; // 에디트 모드에서 컬링으로 사라지지 않게 + sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + sm.receiveShadows = false; + } + foreach (var mr in inst.GetComponentsInChildren(true)) + { + if (mr == null) continue; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + } + } + + /// 814c Toon 머티리얼로 교체 — 🔴 인스턴스의 sharedMaterials 배열만 바꾼다(디스크 .mat 무변경). + static int SwapToonMaterials(GameObject inst, WLSpriteBakeSettings cfg, Result r) + { + int hit = 0; + var cache = new Dictionary(); + foreach (var rend in inst.GetComponentsInChildren(true)) + { + if (rend == null || rend is ParticleSystemRenderer) continue; + var mats = rend.sharedMaterials; + bool any = false; + for (int i = 0; i < mats.Length; i++) + { + var m = mats[i]; + if (m == null) continue; + Material toon; + if (!cache.TryGetValue(m.name, out toon)) + { + string p = cfg.toonMaterialDir.TrimEnd('/') + "/" + m.name + cfg.toonMaterialSuffix + ".mat"; + toon = AssetDatabase.LoadAssetAtPath(p); + cache[m.name] = toon; + } + if (toon != null) { mats[i] = toon; any = true; hit++; } + } + if (any) rend.sharedMaterials = mats; + } + return hit; + } + + // ═════════════════════════════════════════════════════════════════════ + // 동작 수집 — 이름 규약(SO)만 본다. 캐릭터 이름 0. + // ═════════════════════════════════════════════════════════════════════ + sealed class PlanAction + { + public string key; + public AnimationClip clip; + public string stateName; + public int stateHash; + public int frames; + public bool loop; + public readonly List stateNames = new List(); + } + + static List CollectActions(Animator anim, WLSpriteBakeSettings cfg, Result r) + { + var list = new List(); + var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController; + var byState = new Dictionary(StringComparer.Ordinal); + var realName = new Dictionary(StringComparer.OrdinalIgnoreCase); // 후보(대소문자 무시) → 실제 상태 이름 + + if (ac != null && ac.layers.Length > 0 && ac.layers[0].stateMachine != null) + { + foreach (var ch in ac.layers[0].stateMachine.states) + { + var clip = FirstClip(ch.state.motion); + if (clip == null) continue; + if (!byState.ContainsKey(ch.state.name)) byState[ch.state.name] = clip; + if (!realName.ContainsKey(ch.state.name)) realName[ch.state.name] = ch.state.name; + } + } + else + { + // AnimatorOverrideController 등 — 클립 이름으로만 맞춘다 + foreach (var c in anim.runtimeAnimatorController.animationClips) + if (c != null && !byState.ContainsKey(c.name)) { byState[c.name] = c; realName[c.name] = c.name; } + r.notes.Add("AnimatorController 가 아니어서 상태 이름 대신 클립 이름으로 맞췄다"); + } + r.log.AppendLine("레이어0 상태 " + byState.Count + "개: " + string.Join(", ", new List(byState.Keys).ToArray())); + + foreach (var ba in cfg.actions) + { + if (ba == null) continue; + AnimationClip clip = null; string matched = null; + foreach (var n in ba.stateNames) + { + if (string.IsNullOrEmpty(n)) continue; + string real; + if (!realName.TryGetValue(n, out real)) continue; + if (byState.TryGetValue(real, out clip)) { matched = real; break; } + } + if (clip == null || matched == null || clip.length <= 0.0001f) + { + r.notes.Add("동작 '" + ba.key + "' — 상태를 못 찾음(후보: " + string.Join("/", ba.stateNames) + ") → 「미확인」 · 건너뜀"); + continue; + } + var pa = new PlanAction { key = ba.key, clip = clip, frames = Mathf.Max(1, ba.frames), loop = ba.loop, + stateName = matched, stateHash = Animator.StringToHash(matched) }; + if (ac != null && !anim.HasState(0, pa.stateHash)) + { r.notes.Add("동작 '" + ba.key + "' — 상태 '" + matched + "' 가 레이어0 에 없다 → 건너뜀"); continue; } + // 실제로 컨트롤러에 있는 상태 이름만 메타에 넣는다(런타임 해시 비교용) + pa.stateNames.Add(matched); + foreach (var n in ba.stateNames) { string real2; if (realName.TryGetValue(n, out real2) && !pa.stateNames.Contains(real2)) pa.stateNames.Add(real2); } + foreach (var n in ba.alsoMapStateNames) { string real3; if (realName.TryGetValue(n, out real3) && !pa.stateNames.Contains(real3)) pa.stateNames.Add(real3); } + list.Add(pa); + r.log.AppendLine(string.Format(" 동작 {0,-7} ← 상태 [{1}] · 클립 {2} ({3:0.000}s · {4}프레임 · loop {5})", + ba.key, string.Join("/", pa.stateNames.ToArray()), clip.name, clip.length, pa.frames, pa.loop)); + } + return list; + } + + static AnimationClip FirstClip(UnityEngine.Motion m) + { + var c = m as AnimationClip; + if (c != null) return c; + var bt = m as UnityEditor.Animations.BlendTree; + if (bt == null) return null; + foreach (var ch in bt.children) + { + var r = FirstClip(ch.motion); + if (r != null) return r; + } + return null; + } + + // ═════════════════════════════════════════════════════════════════════ + // 렌더 · 도트화 + // ═════════════════════════════════════════════════════════════════════ + static void RenderInto(Camera cam, RenderTexture rt, Texture2D read, Color bg, Color32[] outPx) + { + cam.backgroundColor = bg; + var prev = cam.targetTexture; + cam.targetTexture = rt; + cam.Render(); + cam.targetTexture = prev; + + var active = RenderTexture.active; + RenderTexture.active = rt; + read.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + read.Apply(false, false); + RenderTexture.active = active; + + var src = read.GetPixels32(); + Array.Copy(src, outPx, outPx.Length); + } + + /// 검정 배경 · 흰 배경 두 장에서 알파를 복원하고 도트화한다(이진화 → 양자화). + static Color32[] Compose(Color32[] black, Color32[] white, int P, WLSpriteBakeSettings cfg) + { + var outPx = new Color32[P * P]; + int levels = cfg.paletteLevels; + bool quant = levels >= 2 && levels <= 256; + float cut = Mathf.Clamp01(cfg.alphaCutoff); + for (int i = 0; i < outPx.Length; i++) + { + Color32 b = black[i], w = white[i]; + // 배경만 다르다 → 차이가 곧 「덜 덮인 정도」 + float cov = 1f - (((w.r - b.r) + (w.g - b.g) + (w.b - b.b)) / (3f * 255f)); + if (cov < cut) { outPx[i] = new Color32(0, 0, 0, 0); continue; } + // 검정 배경 위 값 = cov × 색 → 색 = 값 ÷ cov + float inv = cov > 0.001f ? 1f / cov : 1f; + float fr = Mathf.Clamp(b.r * inv, 0f, 255f); + float fg = Mathf.Clamp(b.g * inv, 0f, 255f); + float fb = Mathf.Clamp(b.b * inv, 0f, 255f); + if (quant && cfg.paletteValueBands) + { + // 밝기만 단계로 끊는다(색상·채도 유지) → 도트 특유의 평평한 명암, 색 얼룩 0 + float v = Mathf.Max(fr, Mathf.Max(fg, fb)); + if (v > 0.5f) + { + float n = levels - 1; + float qv = Mathf.Round(v / 255f * n) / n * 255f; + float k = qv / v; + fr *= k; fg *= k; fb *= k; + } + outPx[i] = new Color32((byte)Mathf.Clamp(Mathf.RoundToInt(fr), 0, 255), + (byte)Mathf.Clamp(Mathf.RoundToInt(fg), 0, 255), + (byte)Mathf.Clamp(Mathf.RoundToInt(fb), 0, 255), 255); + } + else outPx[i] = new Color32(Q(fr, levels, quant), Q(fg, levels, quant), Q(fb, levels, quant), 255); + } + return outPx; + } + + static byte Q(float v, int levels, bool quant) + { + v = Mathf.Clamp(v, 0f, 255f); + if (!quant) return (byte)Mathf.RoundToInt(v); + float n = levels - 1; + float q = Mathf.Round(v / 255f * n) / n; + return (byte)Mathf.RoundToInt(q * 255f); + } + + static void TightBox(Color32[] px, int P, ref int minX, ref int maxX, ref int minY, ref int maxY) + { + for (int y = 0; y < P; y++) + { + int row = y * P; + for (int x = 0; x < P; x++) + { + if (px[row + x].a == 0) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + + /// 프로브 프레임에서 S×S 를 잘라낸다. (ox, oy) = 잘라낼 좌하단의 프로브 좌표. + static Color32[] Crop(Color32[] src, int P, int S, int ox, int oy) + { + var dst = new Color32[S * S]; + for (int y = 0; y < S; y++) + { + int sy = oy + y; + if (sy < 0 || sy >= P) continue; + int srow = sy * P, drow = y * S; + for (int x = 0; x < S; x++) + { + int sx = ox + x; + if (sx < 0 || sx >= P) continue; + dst[drow + x] = src[srow + sx]; + } + } + return dst; + } + + /// 알파 경계 **바깥**에 1px 외곽선(색 = SO). + static void Outline(Color32[] px, int S, WLSpriteBakeSettings cfg) + { + var col = (Color32)cfg.outlineColor; + col.a = 255; + var add = new List(S * 4); + for (int y = 0; y < S; y++) + { + int row = y * S; + for (int x = 0; x < S; x++) + { + int i = row + x; + if (px[i].a != 0) continue; + if (Solid(px, S, x - 1, y) || Solid(px, S, x + 1, y) || Solid(px, S, x, y - 1) || Solid(px, S, x, y + 1) + || (cfg.outlineDiagonal && (Solid(px, S, x - 1, y - 1) || Solid(px, S, x + 1, y - 1) || Solid(px, S, x - 1, y + 1) || Solid(px, S, x + 1, y + 1)))) + add.Add(i); + } + } + for (int i = 0; i < add.Count; i++) px[add[i]] = col; + } + + static bool Solid(Color32[] px, int S, int x, int y) + { + if (x < 0 || y < 0 || x >= S || y >= S) return false; + return px[y * S + x].a != 0; + } + + static void Blit(Color32[] src, int S, Color32[] dst, int dstW, int dx, int dy) + { + for (int y = 0; y < S; y++) + { + int srow = y * S, drow = (dy + y) * dstW + dx; + Array.Copy(src, srow, dst, drow, S); + } + } + + static int RoundUp8(int v) { return ((v + 7) / 8) * 8; } + + // ═════════════════════════════════════════════════════════════════════ + // 임포트 설정 — 🔴 코드로 지정(Point · 압축 없음 · mipmap off) + // ═════════════════════════════════════════════════════════════════════ + public static void ApplyImportSettings(string pngPath, int maxSize) + { + var ti = AssetImporter.GetAtPath(pngPath) as TextureImporter; + if (ti == null) return; + ti.textureType = TextureImporterType.Default; // 🔴 Sprite(Multiple) 가 아니다 — UV 는 런타임 산술 + ti.npotScale = TextureImporterNPOTScale.None; + ti.filterMode = FilterMode.Point; + ti.mipmapEnabled = false; + ti.wrapMode = TextureWrapMode.Clamp; + ti.alphaIsTransparency = true; + ti.alphaSource = TextureImporterAlphaSource.FromInput; + ti.sRGBTexture = true; + ti.isReadable = false; + ti.textureCompression = TextureImporterCompression.Uncompressed; + ti.maxTextureSize = NextPow2AtLeast(maxSize); + var def = ti.GetDefaultPlatformTextureSettings(); + def.format = TextureImporterFormat.RGBA32; + def.textureCompression = TextureImporterCompression.Uncompressed; + def.maxTextureSize = ti.maxTextureSize; + ti.SetPlatformTextureSettings(def); + var android = ti.GetPlatformTextureSettings("Android"); + android.overridden = true; + android.format = TextureImporterFormat.RGBA32; + android.textureCompression = TextureImporterCompression.Uncompressed; + android.maxTextureSize = ti.maxTextureSize; + ti.SetPlatformTextureSettings(android); + ti.SaveAndReimport(); + } + + static int NextPow2AtLeast(int v) + { + int p = 32; + while (p < v && p < 8192) p <<= 1; + return p; + } + } +} diff --git a/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs.meta b/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs.meta new file mode 100644 index 000000000..66a72ff3f --- /dev/null +++ b/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 19bec13898ca6d64d908bce11eacb109 \ No newline at end of file diff --git a/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs b/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs new file mode 100644 index 000000000..84ad3f29c --- /dev/null +++ b/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs @@ -0,0 +1,92 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSpriteBakerWindow.cs — 「도트로 굽기」 에디터 창 (WL-814k · #814) +// +// 메뉴: WL ▸ 도트 스프라이트 굽기 +// 🔴 캐릭터 이름이 코드에 0개 — 창에 **아무 캐릭터 프리팹이나** 끌어다 놓으면 그것을 굽는다. +// (PD 가 캐릭터 에셋 교체를 검토 중 → 특정 프리팹에 하드코딩하지 않는다.) +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace WL.Look.SpriteBake.EditorTools +{ + public sealed class WLSpriteBakerWindow : EditorWindow + { + [SerializeField] List _targets = new List(); + [SerializeField] WLSpriteBakeSettings _cfg; + [SerializeField] string _outDir = ""; + [SerializeField] Vector2 _scroll; + [SerializeField] string _report = ""; + + [MenuItem("WL/도트 스프라이트 굽기 (3D → 도트 시트)", false, 30)] + static void Open() + { + var w = GetWindow(false, "도트 굽기", true); + w.minSize = new Vector2(460f, 360f); + if (w._cfg == null) w._cfg = WLSpriteBakeSettings.Instance; + w.Show(); + } + + void OnGUI() + { + EditorGUILayout.HelpBox( + "3D 캐릭터 프리팹을 인게임 도트 카메라와 같은 각도로 미리 렌더해서 스프라이트 시트(PNG) + 메타 SO 로 굽는다.\n" + + "· 어떤 캐릭터 프리팹이든 아래 목록에 넣으면 된다(툴은 프리팹 이름을 모른다).\n" + + "· 굽는 시점에 1px 외곽선 · 팔레트 양자화 · 알파 이진화를 강제한다.\n" + + "· 원본 프리팹 / 머티리얼 / 애니메이션은 한 글자도 안 고친다(읽기·샘플링만).", MessageType.Info); + + _cfg = (WLSpriteBakeSettings)EditorGUILayout.ObjectField("굽기 설정(SO)", _cfg, typeof(WLSpriteBakeSettings), false); + if (_cfg == null) + { + EditorGUILayout.HelpBox("Resources/WL/WLSpriteBakeSettings.asset 을 넣어라.", MessageType.Warning); + if (GUILayout.Button("기본 설정 에셋 찾기")) _cfg = WLSpriteBakeSettings.Instance; + return; + } + + _outDir = EditorGUILayout.TextField(new GUIContent("출력 폴더(비우면 SO 값)"), _outDir); + EditorGUILayout.LabelField(" → " + (string.IsNullOrEmpty(_outDir) ? _cfg.outputDir : _outDir), EditorStyles.miniLabel); + EditorGUILayout.LabelField(string.Format(" 내림각 {0:0.000}° · 픽셀 1개 {1:0.00000} m · 방향 {2} · 프레임 {3}×{3}{4}", + _cfg.PitchDeg, _cfg.WorldPerPixel, _cfg.dirCount, _cfg.frameSize, _cfg.autoFrameSize ? " (자동)" : ""), EditorStyles.miniLabel); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("구울 프리팹 (드래그해서 넣기)", EditorStyles.boldLabel); + var so = new SerializedObject(this); + EditorGUILayout.PropertyField(so.FindProperty("_targets"), new GUIContent("대상"), true); + so.ApplyModifiedProperties(); + + using (new EditorGUI.DisabledScope(_targets.Count == 0)) + { + if (GUILayout.Button("굽기 시작", GUILayout.Height(30f))) BakeAll(); + } + + EditorGUILayout.Space(); + _scroll = EditorGUILayout.BeginScrollView(_scroll); + EditorGUILayout.TextArea(_report, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + + void BakeAll() + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < _targets.Count; i++) + { + var go = _targets[i]; + if (go == null) continue; + string path = AssetDatabase.GetAssetPath(go); + if (string.IsNullOrEmpty(path)) { sb.AppendLine("건너뜀(프리팹 에셋이 아님): " + go.name); continue; } + EditorUtility.DisplayProgressBar("도트로 굽는 중", path, (i + 0.5f) / _targets.Count); + var r = WLSpriteBaker.Bake(path, _cfg, string.IsNullOrEmpty(_outDir) ? null : _outDir); + sb.AppendLine((r.ok ? "OK " : "FAIL ") + r.Summary); + sb.AppendLine(r.log.ToString()); + foreach (var n in r.notes) sb.AppendLine(" · " + n); + sb.AppendLine(); + } + EditorUtility.ClearProgressBar(); + AssetDatabase.Refresh(); + _report = sb.ToString(); + Debug.Log("[WL814k] 굽기 완료\n" + _report); + } + } +} diff --git a/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs.meta b/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs.meta new file mode 100644 index 000000000..cf4507487 --- /dev/null +++ b/Assets/WL/Look/Sprite/Editor/WLSpriteBakerWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a3e14cd2c39a344caa5c31b7582ba03 \ No newline at end of file diff --git a/Assets/WL/Look/Sprite/Resources.meta b/Assets/WL/Look/Sprite/Resources.meta new file mode 100644 index 000000000..1be5b9930 --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bf6fc714ae9b81a49bc424ea8cfac3b7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Resources/WL.meta b/Assets/WL/Look/Sprite/Resources/WL.meta new file mode 100644 index 000000000..9b199e488 --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources/WL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 948528c800001d74d8e2f24cddc3b799 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset new file mode 100644 index 000000000..8561f589d --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset @@ -0,0 +1,119 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e9dcf8e4e8011b84ab5e5df43a7e0255, type: 3} + m_Name: WLSpriteBakeSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteBakeSettings + pitchDegOverride: 0 + orbitCameraHeight: 80 + orbitDistance: 3 + orbitDistanceUp: 3 + dirCount: 8 + worldPerPixelOverride: 0 + refOrthoSize: 3.4 + refPixelHeight: 240 + frameSize: 64 + autoFrameSize: 1 + frameSizeMin: 16 + frameSizeMax: 128 + marginPx: 2 + probeSize: 192 + outline: 1 + outlineColor: {r: 0.055, g: 0.043, b: 0.067, a: 1} + outlineDiagonal: 0 + paletteLevels: 6 + alphaCutoff: 0.5 + useToonMaterials: 1 + toonMaterialDir: Assets/WL/Look/Env/Materials + toonMaterialSuffix: _Toon + sunEuler: {x: 26.4, y: 238.6, z: 0} + sunColor: {r: 1, g: 0.92, b: 0.703, a: 1} + sunIntensity: 1.2 + fillEuler: {x: 36.1, y: 116.1, z: 0} + fillColor: {r: 0.596, g: 0.703, b: 0.839, a: 1} + fillIntensity: 0.3 + ambient: {r: 0.212, g: 0.227, b: 0.259, a: 1} + outputDir: Assets/WL/Look/Sprite/Baked + sheetColumns: 16 + sheetMaxWidth: 2048 + actions: + - key: idle + stateNames: + - idle + - Idle + - wait + - IdleA + - IdleB + alsoMapStateNames: + - land + - getup + frames: 4 + loop: 1 + required: 0 + - key: run + stateNames: + - run + - Run + - move + - Move + - walk + alsoMapStateNames: + - dodge + frames: 6 + loop: 1 + required: 0 + - key: attack + stateNames: + - attack + - Attack + - atk + - ATK0 + - ATK1 + alsoMapStateNames: + - ATK2 + - ATK3 + - ATK4 + - Shoot + - attack2 + - skill2 + - skill3 + - skill4 + - skill5 + - casting + - casting_end + frames: 6 + loop: 1 + required: 0 + - key: hit + stateNames: + - hit + - Hit + - damage + - Damage + alsoMapStateNames: + - stun + - Swoon + frames: 2 + loop: 1 + required: 0 + - key: die + stateNames: + - die + - Die + - death + - Die0 + - Die1 + alsoMapStateNames: + - lying + - Die2 + frames: 6 + loop: 0 + required: 0 diff --git a/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset.meta b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset.meta new file mode 100644 index 000000000..c527bb8ad --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b40b06e2f8e6234580661b2c962fc01 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset new file mode 100644 index 000000000..3e69a46fd --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset @@ -0,0 +1,31 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8f39fe71df28ff94ebdc9f76766e84e9, type: 3} + m_Name: WLSpriteLookSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteLookSettings + enabled_: 1 + verboseLog: 0 + onlyInPixelMode: 1 + sweepSeconds: 0.5 + maxActors: 24 + sets: + - {fileID: 11400000, guid: 9afd69d5485f7584b8f40e7673bca04e, type: 2} + - {fileID: 11400000, guid: 02557ce4e57225d4db041caa2e6e44d7, type: 2} + yAxisOnly: 0 + yAxisOnlyCompensate: 1 + depthBias: 0.02 + alphaCutoff: 0.5 + quadLayer: -1 + alsoHideMeshRenderers: 1 + castShadows: 0 + statePollSeconds: 0 + fallbackActionKey: idle diff --git a/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset.meta b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset.meta new file mode 100644 index 000000000..8b69199e6 --- /dev/null +++ b/Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 481482cb77548cb47959bef245114fc2 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Sprite/SpriteBillboard.cs b/Assets/WL/Look/Sprite/SpriteBillboard.cs new file mode 100644 index 000000000..cdfb45f84 --- /dev/null +++ b/Assets/WL/Look/Sprite/SpriteBillboard.cs @@ -0,0 +1,620 @@ +// ───────────────────────────────────────────────────────────────────────────── +// SpriteBillboard.cs — 도트 모드에서 3D 캐릭터를 「구운 도트 스프라이트」로 바꿔 그린다 (WL-814k · #814) +// +// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」 +// +// ■ 이 파일이 하는 것 — 🔴 원본 코드 수정 0 · 원본 파괴 0 +// ① 814d `LookModeHub.CameraModeChanged` 를 구독해 **도트 모드(1~3)에서만** 켠다. +// ② 대상 액터의 원본 `SkinnedMeshRenderer` 를 **끄고**(`enabled=false` · 파괴 0), +// 자식으로 **빌보드 쿼드 1개**(MeshRenderer + Unlit 알파컷 · 런타임 인스턴스)를 붙인다. +// ③ 프레임은 원본 `Animator` **폴링**으로 고른다(813x/813t2 방식): +// 상태 해시(레이어 0 shortNameHash) → 동작 · `normalizedTime` → 프레임 · 상대 yaw → 방향. +// ④ 모드에서 나가거나 SO 를 끄면 **전부 원복**한다(끈 렌더러를 다시 켜고 쿼드를 지운다) = C8. +// +// ■ 왜 「카메라 정렬」이 기본인가 (yAxisOnly = false) +// 스프라이트는 내림각 43.6° 카메라로 **이미 눌린 그림**이다. 그걸 수직 쿼드에 붙이면 한 번 더 눌린다 +// (cos 43.6° = 0.724 → 키가 28 % 줄어든다). 쿼드를 카메라와 같은 자세로 두면 구운 픽셀이 +// 화면 픽셀과 1:1 로 맞는다. 도트 모드의 카메라는 롤도 없고 내림각도 고정이라 기울어 보이지 않는다. +// 「Y축만」 모드도 SO 스위치로 남겨 뒀다(발주서 문구 · 눌림 보정 옵션 포함). +// +// ■ GC 0 — 부착할 때만 배열을 만든다. 매 프레임은 구조체 읽기 + MaterialPropertyBlock.SetVector 뿐이다 +// (문자열·람다·LINQ·GetComponent 0). 로그 문자열은 만들기 전에 verboseLog 로 막는다(811b FIX-3). +// +// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; +using WL.Look.Toggle; + +namespace WL.Look.SpriteBake +{ + /// 도트 모드 한정 3D→스프라이트 교체. 정적 · 러너 GameObject 1개. + public static class SpriteBillboard + { + // ── 액터 1명의 교체 상태 ────────────────────────────────────────────── + public sealed class Entry + { + public Transform actor; + public Animator anim; + public WLSpriteSet set; + + public SkinnedMeshRenderer[] smrs; + public bool[] smrWas; + public MeshRenderer[] mrs; + public bool[] mrWas; + + public GameObject quad; + public Transform quadTf; + public MeshRenderer quadMr; + public MaterialPropertyBlock mpb; + + public WLSpriteAction action; + public int dir = -1; + public int frame = -1; + public int lastIndex = -1; + public float scaleK = 1f; + public bool manual; // 프로브/캡처가 프레임을 직접 지정한 상태 + } + + static WLSpriteLookSettings Cfg { get { return WLSpriteLookSettings.Instance; } } + + static readonly List s_entries = new List(32); + static readonly HashSet s_seen = new HashSet(); + static Material s_mat; + static Mesh s_mesh; + static bool s_applied; + static bool s_hooked; + static bool s_faulted; + static float s_nextSweep; + + // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── + public static int Applies, Restores, Attached, Detached, HubEvents, Sweeps, FrameChanges; + public static string LastLog = ""; + public static bool IsApplied { get { return s_applied; } } + public static int ActiveCount { get { return s_entries.Count; } } + public static List EntriesForProbe { get { return s_entries; } } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[WL814k SpriteBillboard] " + msg); + } + + // ═════════════════════════════════════════════════════════════════════ + // 켜고 끄기 + // ═════════════════════════════════════════════════════════════════════ + + /// 지금 켜야 하는 상태인가(SO + 도트 모드 조건). + public static bool ShouldBeOn + { + get + { + var c = Cfg; + if (c == null || !WLSpriteLookSettings.Enabled) return false; + if (!c.onlyInPixelMode) return true; + if (!WLLookModeSettings.Enabled) return false; + return LookModeHub.IsPixelMode; + } + } + + /// 대상 액터를 찾아 스프라이트로 바꾼다. 반환 = 새로 바뀐 수. + public static int ApplyNow(string reason) + { + var c = Cfg; + if (c == null || !WLSpriteLookSettings.Enabled) { Log("건너뜀(SO off) — " + reason); return 0; } + s_applied = true; + Applies++; + int n = Sweep(); + Log("적용(" + reason + ") — 대상 " + s_entries.Count + "명(+ " + n + ")"); + return n; + } + + /// 전부 원복한다(끈 렌더러를 다시 켜고 쿼드를 지운다) = C8. + public static int RestoreNow(string reason) + { + int n = s_entries.Count; + for (int i = s_entries.Count - 1; i >= 0; i--) DetachAt(i); + s_entries.Clear(); + s_seen.Clear(); + s_applied = false; + if (n > 0) Restores++; + Log("원복(" + reason + ") — " + n + "명"); + return n; + } + + // ═════════════════════════════════════════════════════════════════════ + // 스윕 — 새로 스폰된 액터 찾기 (캐시 히트 시 할당 0 · 814c SweepNewSpawns 방식) + // ═════════════════════════════════════════════════════════════════════ + + public static int Sweep() + { + var c = Cfg; + if (c == null || !s_applied) return 0; + Sweeps++; + int added = 0; + + // ① PC + var pc = PcTransform(); + if (pc != null) added += TryAttachIfNew(pc, c); + + // ② 몹 루트의 직속 자식(814c 와 같은 경로 = InGameInfo.tf_Objs) + var root = MobRoot(); + if (root != null) + { + int cnt = root.childCount; + for (int i = 0; i < cnt; i++) + { + var ch = root.GetChild(i); + if (ch == null) continue; + added += TryAttachIfNew(ch, c); + } + } + return added; + } + + static Transform PcTransform() + { + if (!Application.isPlaying) return null; + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) return null; + return pc.transform; + } + + static Transform MobRoot() + { + if (!Application.isPlaying) return null; + var info = InGameInfo.Ins; + if (DSUtil.CheckNull(info)) return null; + return info.tf_Objs; + } + + static int TryAttachIfNew(Transform t, WLSpriteLookSettings c) + { + if (t == null) return 0; + int id = t.GetInstanceID(); + if (s_seen.Contains(id)) return 0; + s_seen.Add(id); + if (s_entries.Count >= Mathf.Max(1, c.maxActors)) return 0; + var set = MatchSet(t.name, c); + if (set == null) return 0; + return TryAttach(t.gameObject, set) != null ? 1 : 0; + } + + /// 액터 이름 → 시트 SO(SO 목록의 actorNamePrefix 로 매칭). 없으면 null. + public static WLSpriteSet MatchSet(string actorName, WLSpriteLookSettings c) + { + if (c == null || c.sets == null || string.IsNullOrEmpty(actorName)) return null; + for (int i = 0; i < c.sets.Length; i++) + { + var s = c.sets[i]; + if (s == null || !s.IsUsable || string.IsNullOrEmpty(s.actorNamePrefix)) continue; + if (actorName.StartsWith(s.actorNamePrefix, System.StringComparison.Ordinal)) return s; + } + return null; + } + + // ═════════════════════════════════════════════════════════════════════ + // 부착 / 떼기 (에디트 모드에서도 동작 — 캡처가 이 경로를 쓴다) + // ═════════════════════════════════════════════════════════════════════ + + /// 액터 1명을 스프라이트로 바꾼다. 이미 바뀌었으면 기존 Entry 를 준다. + public static Entry TryAttach(GameObject actor, WLSpriteSet set) + { + if (actor == null || set == null || !set.IsUsable) return null; + for (int i = 0; i < s_entries.Count; i++) if (s_entries[i].actor == actor.transform) return s_entries[i]; + + var c = Cfg; + var e = new Entry(); + e.actor = actor.transform; + e.anim = actor.GetComponentInChildren(true); + e.set = set; + + // 원본 렌더러 끄기(파괴 0 · 원값 기억) + e.smrs = actor.GetComponentsInChildren(true); + e.smrWas = new bool[e.smrs.Length]; + for (int i = 0; i < e.smrs.Length; i++) + { + if (e.smrs[i] == null) continue; + e.smrWas[i] = e.smrs[i].enabled; + e.smrs[i].enabled = false; + } + if (c == null || c.alsoHideMeshRenderers) + { + var all = actor.GetComponentsInChildren(true); + var keep = new List(all.Length); + for (int i = 0; i < all.Length; i++) + if (all[i] != null && all[i].GetComponent() == null) keep.Add(all[i]); + e.mrs = keep.ToArray(); + e.mrWas = new bool[e.mrs.Length]; + for (int i = 0; i < e.mrs.Length; i++) { e.mrWas[i] = e.mrs[i].enabled; e.mrs[i].enabled = false; } + } + else { e.mrs = new MeshRenderer[0]; e.mrWas = new bool[0]; } + + // 빌보드 쿼드 + e.quad = new GameObject("[WL814k] SpriteQuad"); + e.quad.hideFlags = HideFlags.DontSave; + e.quadTf = e.quad.transform; + e.quadTf.SetParent(actor.transform, false); + int layer = (c != null && c.quadLayer >= 0) ? c.quadLayer : actor.layer; + e.quad.layer = layer; + var mf = e.quad.AddComponent(); + mf.sharedMesh = QuadMesh(); + e.quadMr = e.quad.AddComponent(); + e.quadMr.sharedMaterial = SheetMaterial(set.sheet); // 시트별 1개(액터끼리 공유 · 시트가 섞여도 안 덮인다) + e.quadMr.shadowCastingMode = (c != null && c.castShadows) + ? UnityEngine.Rendering.ShadowCastingMode.On : UnityEngine.Rendering.ShadowCastingMode.Off; + e.quadMr.receiveShadows = false; + e.quadMr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + e.quadMr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + e.mpb = new MaterialPropertyBlock(); + + // 스케일 보정(액터가 구울 때와 다른 크기로 스폰될 수 있다) + float bs = set.bakeScale > 0.0001f ? set.bakeScale : 1f; + e.scaleK = Mathf.Abs(actor.transform.lossyScale.y) / bs; + if (e.scaleK < 0.0001f) e.scaleK = 1f; + + s_entries.Add(e); + Attached++; + return e; + } + + static void DetachAt(int i) + { + var e = s_entries[i]; + if (e == null) return; + if (e.smrs != null) + for (int k = 0; k < e.smrs.Length; k++) if (e.smrs[k] != null) e.smrs[k].enabled = e.smrWas[k]; + if (e.mrs != null) + for (int k = 0; k < e.mrs.Length; k++) if (e.mrs[k] != null) e.mrs[k].enabled = e.mrWas[k]; + if (e.quad != null) + { + if (Application.isPlaying) Object.Destroy(e.quad); + else Object.DestroyImmediate(e.quad); + } + s_entries.RemoveAt(i); + Detached++; + } + + /// 액터 1명만 원복한다(프로브용). + public static bool Detach(GameObject actor) + { + if (actor == null) return false; + for (int i = 0; i < s_entries.Count; i++) + if (s_entries[i].actor == actor.transform) { DetachAt(i); s_seen.Remove(actor.transform.GetInstanceID()); return true; } + return false; + } + + // ═════════════════════════════════════════════════════════════════════ + // 프레임 · 방향 · 자세 갱신 (프레임당 할당 0) + // ═════════════════════════════════════════════════════════════════════ + + /// 프로브/캡처가 프레임을 직접 지정한다(Animator 폴링 대신). + public static bool SetManualFrame(GameObject actor, string actionKey, int dir, int frame) + { + for (int i = 0; i < s_entries.Count; i++) + { + var e = s_entries[i]; + if (e.actor != actor.transform) continue; + var a = e.set.FindByKey(actionKey); + if (a == null) return false; + e.manual = true; e.action = a; + e.dir = Mathf.Clamp(dir, 0, e.set.dirCount - 1); + e.frame = Mathf.Clamp(frame, 0, a.frameCount - 1); + e.lastIndex = -1; + return true; + } + return false; + } + + /// 전체 갱신(러너가 매 프레임 부른다). cam 이 null 이면 Camera.main. + public static void UpdateAll(Camera cam) + { + if (cam == null) cam = Camera.main; + if (cam == null) return; + var c = Cfg; + if (c == null) return; + + Vector3 camFwd = cam.transform.forward; + Vector3 camUp = cam.transform.up; + Vector3 camRight = cam.transform.right; + Quaternion camRot = cam.transform.rotation; + + // yAxisOnly 용 수평 전방 · 눌림 보정 + Vector3 flatFwd = camFwd; flatFwd.y = 0f; + if (flatFwd.sqrMagnitude < 1e-6f) flatFwd = Vector3.forward; else flatFwd.Normalize(); + float comp = 1f; + if (c.yAxisOnly && c.yAxisOnlyCompensate) + { + float cosP = Mathf.Cos(Mathf.Abs(Mathf.Asin(Mathf.Clamp(-camFwd.y, -1f, 1f)))); + comp = cosP > 0.05f ? 1f / cosP : 1f; + } + + for (int i = s_entries.Count - 1; i >= 0; i--) + { + var e = s_entries[i]; + if (e == null || e.actor == null || e.quadTf == null) { if (e != null && e.actor != null) s_seen.Remove(e.actor.GetInstanceID()); s_entries.RemoveAt(i); continue; } + UpdateEntry(e, c, camFwd, camUp, camRight, camRot, flatFwd, comp); + } + } + + static void UpdateEntry(Entry e, WLSpriteLookSettings c, + Vector3 camFwd, Vector3 camUp, Vector3 camRight, Quaternion camRot, + Vector3 flatFwd, float comp) + { + var set = e.set; + + // ── 동작 · 프레임 (Animator 폴링 · 원본 코드 수정 0) ────────────── + if (!e.manual && e.anim != null && e.anim.runtimeAnimatorController != null) + { + var st = e.anim.GetCurrentAnimatorStateInfo(0); + int hash = st.shortNameHash; + float nt = st.normalizedTime; + if (e.anim.IsInTransition(0)) + { + var nx = e.anim.GetNextAnimatorStateInfo(0); + if (set.FindByStateHash(nx.shortNameHash) != null) { hash = nx.shortNameHash; nt = nx.normalizedTime; } + } + var act = set.FindByStateHash(hash); + if (act == null) act = set.FindByKey(c.fallbackActionKey); + if (act == null && set.actions.Length > 0) act = set.actions[0]; + if (act != null) + { + e.action = act; + int fc = Mathf.Max(1, act.frameCount); + int f = act.loop + ? Mathf.FloorToInt(Mathf.Repeat(nt, 1f) * fc) + : Mathf.FloorToInt(Mathf.Clamp01(nt) * fc); + e.frame = Mathf.Clamp(f, 0, fc - 1); + } + } + if (e.action == null && set.actions.Length > 0) { e.action = set.actions[0]; e.frame = 0; } + if (e.action == null) return; + + // ── 방향 (캐릭터 forward 와 카메라 forward 의 상대 yaw) ─────────── + if (!e.manual) + { + Vector3 af = e.actor.forward; af.y = 0f; + if (af.sqrMagnitude > 1e-6f) + { + float rel = Vector3.SignedAngle(flatFwd, af, Vector3.up); + float step = 360f / Mathf.Max(1, set.dirCount); + int d = Mathf.RoundToInt(Mathf.Repeat(rel, 360f) / step); + if (d >= set.dirCount) d -= set.dirCount; + e.dir = d; + } + else if (e.dir < 0) e.dir = 0; + } + + // ── UV (바뀔 때만 MPB 를 다시 넣는다) ───────────────────────────── + int index = e.action.startIndex + e.dir * e.action.frameCount + e.frame; + if (index != e.lastIndex) + { + e.lastIndex = index; + FrameChanges++; + Vector4 st4 = set.UvRect(index); + e.mpb.SetVector(IdBaseMapST, st4); + e.mpb.SetVector(IdMainTexST, st4); + e.quadMr.SetPropertyBlock(e.mpb); + } + + // ── 자세 · 크기 (발밑 정렬 · 픽셀 격자) ─────────────────────────── + float wpp = set.worldPerPixel * e.scaleK; + float w = set.frameWidth * wpp; + float h = set.frameHeight * wpp; + Vector3 foot = e.actor.position; + + // 지면 파묻힘 방지 — 쿼드의 발밑 아래쪽이 지면 밑으로 내려간 만큼 시선 방향으로 당긴다. + // 직교 카메라에서는 시선 방향 이동이 화면 위치를 바꾸지 않으므로 그림은 그대로다. + float bias = c.depthBias; + if (c.autoDepthBias) + { + float sinP = Mathf.Clamp(-camFwd.y, 0.02f, 1f); + float cosP = Mathf.Sqrt(Mathf.Max(0f, 1f - sinP * sinP)); + bias += (set.footPixelY * wpp) * cosP / sinP; + } + + if (!c.yAxisOnly) + { + Vector3 center = foot + + camRight * ((set.frameWidth * 0.5f - set.footPixelX) * wpp) + + camUp * ((set.frameHeight * 0.5f - set.footPixelY) * wpp) + - camFwd * bias; + e.quadTf.SetPositionAndRotation(center, camRot); + e.quadTf.localScale = new Vector3(w / SafeScale(e.actor.lossyScale.x), + h / SafeScale(e.actor.lossyScale.y), + 1f / SafeScale(e.actor.lossyScale.z)); + } + else + { + float hh = h * comp; + Quaternion rot = Quaternion.LookRotation(flatFwd, Vector3.up); + Vector3 right = rot * Vector3.right; + Vector3 center = foot + + right * ((set.frameWidth * 0.5f - set.footPixelX) * wpp) + + Vector3.up * ((set.frameHeight * 0.5f - set.footPixelY) * wpp * comp) + - flatFwd * c.depthBias; // 수직 쿼드는 지면에 안 파묻힌다 → 기본 여유분만 + e.quadTf.SetPositionAndRotation(center, rot); + e.quadTf.localScale = new Vector3(w / SafeScale(e.actor.lossyScale.x), + hh / SafeScale(e.actor.lossyScale.y), + 1f / SafeScale(e.actor.lossyScale.z)); + } + } + + static float SafeScale(float v) { v = Mathf.Abs(v); return v < 0.0001f ? 1f : v; } + + static readonly int IdBaseMapST = Shader.PropertyToID("_BaseMap_ST"); + static readonly int IdMainTexST = Shader.PropertyToID("_MainTex_ST"); + static readonly int IdBaseMap = Shader.PropertyToID("_BaseMap"); + static readonly int IdMainTex = Shader.PropertyToID("_MainTex"); + + // ═════════════════════════════════════════════════════════════════════ + // 쿼드 메시 · 머티리얼 (런타임 인스턴스 · 디스크 에셋 0) + // ═════════════════════════════════════════════════════════════════════ + + public static Mesh QuadMesh() + { + if (s_mesh != null) return s_mesh; + s_mesh = new Mesh(); + s_mesh.name = "[WL814k] BillboardQuad"; + s_mesh.hideFlags = HideFlags.HideAndDontSave; + s_mesh.vertices = new[] + { + new Vector3(-0.5f, -0.5f, 0f), new Vector3(0.5f, -0.5f, 0f), + new Vector3(-0.5f, 0.5f, 0f), new Vector3(0.5f, 0.5f, 0f), + }; + s_mesh.uv = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) }; + s_mesh.normals = new[] { -Vector3.forward, -Vector3.forward, -Vector3.forward, -Vector3.forward }; + s_mesh.triangles = new[] { 0, 1, 2, 2, 1, 3 }; + s_mesh.RecalculateBounds(); + return s_mesh; + } + + public static Material QuadMaterial(Texture2D sheet) + { + var c = Cfg; + if (s_mat == null) + { + var sh = Shader.Find("Universal Render Pipeline/Unlit"); + if (sh == null) sh = Shader.Find("Unlit/Transparent"); + s_mat = new Material(sh); + s_mat.name = "[WL814k] SpriteBillboard (런타임 인스턴스)"; + s_mat.hideFlags = HideFlags.HideAndDontSave; + s_mat.SetFloat("_Surface", 0f); // Opaque + AlphaClip → 깊이 정상 + s_mat.SetFloat("_AlphaClip", 1f); + s_mat.SetFloat("_Cutoff", c != null ? c.alphaCutoff : 0.5f); + s_mat.SetFloat("_Cull", 0f); // 양면 — 감기 방향 신경 안 쓴다 + s_mat.SetFloat("_ZWrite", 1f); + s_mat.EnableKeyword("_ALPHATEST_ON"); + s_mat.renderQueue = 2450; // AlphaTest + } + if (sheet != null) + { + s_mat.SetTexture(IdBaseMap, sheet); + s_mat.SetTexture(IdMainTex, sheet); + } + return s_mat; + } + + static readonly Dictionary s_sheetMats = new Dictionary(); + + /// 시트 1장당 머티리얼 1개(런타임 인스턴스 · 디스크 에셋 0). 시트가 섞여도 서로 안 덮는다. + public static Material SheetMaterial(Texture2D sheet) + { + if (sheet == null) return QuadMaterial(null); + Material m; + if (s_sheetMats.TryGetValue(sheet, out m) && m != null) return m; + m = new Material(QuadMaterial(null)); + m.name = "[WL814k] Sprite " + sheet.name; + m.hideFlags = HideFlags.HideAndDontSave; + m.SetTexture(IdBaseMap, sheet); + m.SetTexture(IdMainTex, sheet); + s_sheetMats[sheet] = m; + return m; + } + + // ═════════════════════════════════════════════════════════════════════ + // 틱 · 허브 · 러너 + // ═════════════════════════════════════════════════════════════════════ + + public static void TickForProbe() { Tick(); } + public static void EnsureRunnerForProbe() { EnsureRunner(); } + + internal static void Tick() + { + if (s_faulted || !WLSpriteLookSettings.Enabled) return; + var c = Cfg; + if (c == null) return; + + bool want = ShouldBeOn; + if (want != s_applied) + { + try { if (want) ApplyNow("모드"); else RestoreNow("모드"); } + catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; RestoreNow("fault"); return; } + } + if (!s_applied) return; + + float now = Time.unscaledTime; + if (c.sweepSeconds > 0f && now >= s_nextSweep) + { + s_nextSweep = now + Mathf.Max(0.1f, c.sweepSeconds); + try { Sweep(); } + catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외·스윕) — " + ex.Message; RestoreNow("fault"); return; } + } + UpdateAll(null); + } + + internal static void HookHub() + { + if (s_hooked) return; + s_hooked = true; + LookModeHub.CameraModeChanged += OnCameraMode; + } + + internal static void UnhookHub() + { + if (!s_hooked) return; + s_hooked = false; + LookModeHub.CameraModeChanged -= OnCameraMode; + } + + static void OnCameraMode(int mode) + { + HubEvents++; + // 실제 적용은 Tick 이 ShouldBeOn 으로 판정한다(모드 전환 타이밍과 카메라 조립 순서를 안 탄다). + } + + static SpriteBillboardRunner s_runner; + + internal static void EnsureRunner() + { + if (!Application.isPlaying || s_runner != null) return; + var go = new GameObject("[WL814k] SpriteBillboardRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (!WLSpriteLookSettings.Enabled) return; + EnsureRunner(); + } + + /// 프로브용 — 카운터 초기화(적용 상태는 유지). + public static void ResetDiagnostics() + { + Applies = Restores = Attached = Detached = HubEvents = Sweeps = FrameChanges = 0; + LastLog = ""; + s_faulted = false; + } + } + + /// 스프라이트 교체의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class SpriteBillboardRunner : MonoBehaviour + { + void Update() { SpriteBillboard.Tick(); } + void OnEnable() { SpriteBillboard.HookHub(); } + void OnDisable() { SpriteBillboard.UnhookHub(); SpriteBillboard.RestoreNow("runner-disable"); } + void OnApplicationQuit() { SpriteBillboard.RestoreNow("app-quit"); } + } + + /// 🔴 에디터 전용 안전망 — 플레이모드를 벗어날 때 반드시 원복한다. + internal static class SpriteEditorSafetyNet + { +#if UNITY_EDITOR + [UnityEditor.InitializeOnLoadMethod] + static void Hook() + { + UnityEditor.EditorApplication.playModeStateChanged -= OnPlayMode; + UnityEditor.EditorApplication.playModeStateChanged += OnPlayMode; + } + + static void OnPlayMode(UnityEditor.PlayModeStateChange s) + { + if (s == UnityEditor.PlayModeStateChange.ExitingPlayMode) SpriteBillboard.RestoreNow("exit-playmode"); + } +#endif + } +} diff --git a/Assets/WL/Look/Sprite/SpriteBillboard.cs.meta b/Assets/WL/Look/Sprite/SpriteBillboard.cs.meta new file mode 100644 index 000000000..9133a02b8 --- /dev/null +++ b/Assets/WL/Look/Sprite/SpriteBillboard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2ca3b74c48b2f34fbd64fa57133fb66 \ No newline at end of file diff --git a/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs b/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs new file mode 100644 index 000000000..59fae8b1b --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs @@ -0,0 +1,204 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSpriteBakeSettings.cs — 「3D → 도트 스프라이트」 굽기 값의 단일 출처 (WL-814k · #814) +// +// 🔴 이 에셋에는 **특정 캐릭터 이름이 하나도 없다**. 굽는 대상 프리팹은 **인자**로 받는다 +// (PD 가 캐릭터 에셋 교체를 검토 중 → 프리팹에 하드코딩하면 툴이 통째로 버려진다). +// 여기 있는 값은 「어떤 프리팹이든 이렇게 굽는다」는 규칙뿐이다. +// +// ■ 값을 코드 상수로 두지 않는다(C45) — 방향 수 · 프레임 수 · 해상도 · 팔레트 단계 · 외곽선 색 전부 에셋. +// ■ 동작 표는 **이름 규약**으로만 적는다 — Animator 상태 이름 후보 목록(첫 번째로 찾은 것을 쓴다). +// 실측(814k): PC `pcanim_mage` 와 잡몹 `Batty_Animations_Check` 가 **같은 상태 이름**을 쓴다 +// (idle · run · attack · hit · die) → 이 표 하나로 PC·몹이 동시에 걸린다. +// +// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.SpriteBake +{ + /// 구울 동작 1줄 — 「이 이름의 상태를 찾아 이만큼 프레임을 뽑아라」. + [System.Serializable] + public sealed class WLSpriteBakeAction + { + [Tooltip("동작 키(시트 메타에 그대로 들어간다).")] + public string key = "idle"; + + [Tooltip("Animator 상태 이름 후보 — 위에서부터 찾아 처음 맞는 것을 굽는다.")] + public string[] stateNames = new string[] { "idle" }; + + [Tooltip("굽지는 않지만 런타임에서 이 동작으로 **볼** 상태 이름들(예: 스킬 상태를 공격 그림으로). " + + "컨트롤러에 실제로 있는 것만 시트 메타에 들어간다.")] + public string[] alsoMapStateNames = new string[0]; + + [Tooltip("이 동작을 몇 프레임으로 구울지(클립 길이를 균등 분할).")] + public int frames = 4; + + [Tooltip("루프 동작인가(사망은 끄면 마지막 프레임에서 멈춘다).")] + public bool loop = true; + + [Tooltip("이 동작을 못 찾으면 굽기를 실패로 볼지(끄면 「미확인」으로 건너뛴다).")] + public bool required = false; + } + + [CreateAssetMenu(fileName = "WLSpriteBakeSettings", menuName = "WL/Sprite Bake Settings (도트 굽기)", order = 37)] + public sealed class WLSpriteBakeSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLSpriteBakeSettings"; + + static WLSpriteBakeSettings s_instance; + static bool s_tried; + + /// 에셋이 없으면 null. 에디터 툴만 쓴다. + public static WLSpriteBakeSettings Instance + { + get + { + if (s_instance == null && !s_tried) { s_tried = true; s_instance = Resources.Load(ResourcesPath); } + return s_instance; + } + } + + public static void ClearCache() { s_instance = null; s_tried = false; } + + // ── 카메라 (인게임 도트 카메라와 같게) ──────────────────────────────── + [Header("카메라 — 인게임 도트 모드와 같은 구도로 굽는다")] + [Tooltip("내림각(도). 0 이면 아래 3값(811o 궤도 파라미터)에서 자동 산출한다. 실측 기본 = 43.609°.")] + public float pitchDegOverride = 0f; + + [Tooltip("811o 궤도 파라미터 cameraHeight — pitchDegOverride 가 0 일 때 여기서 내림각을 만든다.")] + public float orbitCameraHeight = 80f; + public float orbitDistance = 3f; + public float orbitDistanceUp = 3f; + + [Tooltip("방향 수 — 카메라 기준 상대 yaw 를 이 수로 나눈다. 8 = 45° 간격.")] + public int dirCount = 8; + + // ── 픽셀 크기 ──────────────────────────────────────────────────────── + [Header("픽셀 크기 — 도트 모드 화면 픽셀과 1:1 로 맞춘다")] + [Tooltip("픽셀 1개의 월드 크기(m). 0 이면 아래 두 값(도트 프리셋)에서 (2×orthoSize)/pixelHeight 로 산출. 실측 도트A = 0.0283333.")] + public float worldPerPixelOverride = 0f; + + [Tooltip("도트 프리셋 orthoSize(직교 반높이 m) — worldPerPixelOverride 가 0 일 때 쓴다.")] + public float refOrthoSize = 3.4f; + + [Tooltip("도트 프리셋 세로 픽셀 수 — worldPerPixelOverride 가 0 일 때 쓴다.")] + public int refPixelHeight = 240; + + [Header("프레임 크기")] + [Tooltip("기본 프레임 해상도(px). autoFrameSize 가 꺼져 있으면 이 값을 그대로 쓴다.")] + public int frameSize = 64; + + [Tooltip("켜면 실제 실루엣을 재서 프레임을 8의 배수로 자동 조정한다(꽉 차게 · 아래 min/max 안에서).")] + public bool autoFrameSize = true; + public int frameSizeMin = 16; + public int frameSizeMax = 128; + + [Tooltip("실루엣 둘레에 남길 여백(px) — 외곽선 1px 이 잘리지 않게 최소 2 권장.")] + public int marginPx = 2; + + [Tooltip("실루엣을 재는 1차 렌더 해상도(px). 캐릭터가 이보다 크면 잘린다 — 넉넉히.")] + public int probeSize = 192; + + [Header("샘플링 경로")] + [Tooltip("🔴 기본(끔) = **진짜 Animator 구동**(anim.Play + anim.Update · 게임과 같은 컨트롤러·리타깃 · applyRootMotion=false). " + + "켜면 AnimationMode.SampleAnimationClip 로 굽는다 — 실측(814k)에서 이 경로는 **루트 모션을 포즈에 얹어** " + + "Ai01 Run 이 2.0 m, death 가 3.0 m 밀려 프레임 밖으로 나갔다(게임에는 안 나오는 그림). 진단용으로만 켠다.")] + public bool sampleWithAnimationMode = false; + + // ── 도트화 (굽는 시점에 강제) ──────────────────────────────────────── + [Header("도트화 — 굽는 시점에 강제한다")] + [Tooltip("외곽선 1px 을 알파 경계 바깥에 두른다.")] + public bool outline = true; + public Color outlineColor = new Color(0.055f, 0.043f, 0.067f, 1f); + + [Tooltip("외곽선을 대각선 이웃까지 볼지(켜면 모서리가 더 두껍다).")] + public bool outlineDiagonal = false; + + [Tooltip("팔레트 양자화 — 단계 수(2~256 · 6 = 도트 느낌). 0/1 이면 끈다.")] + public int paletteLevels = 6; + + [Tooltip("🔴 켜면 **밝기만** 단계로 끊고 색상·채도는 유지한다(명암이 도트처럼 평평해진다). " + + "끄면 R·G·B 를 각각 끊는다(발주서 문구 그대로) — 실측(814k): 어두운 색이 채널마다 다른 쪽으로 " + + "반올림돼 **없던 붉은/초록 얼룩**이 생겼다. 그래서 기본을 밝기 단계로 뒀다.")] + public bool paletteValueBands = true; + + [Tooltip("알파 이진화 임계(0~1). 이 값 이상만 남기고 나머지는 완전 투명 → 가장자리가 또렷해진다.")] + public float alphaCutoff = 0.5f; + + // ── 렌더 ───────────────────────────────────────────────────────────── + [Header("렌더")] + [Tooltip("814c Toon 머티리얼(<이름>_Toon.mat)이 있으면 그것으로 바꿔 굽는다(원본 .mat 수정 0 · 인스턴스에만).")] + public bool useToonMaterials = true; + public string toonMaterialDir = "Assets/WL/Look/Env/Materials"; + public string toonMaterialSuffix = "_Toon"; + + [Tooltip("주 방향광 — 게임 맵(WL_Nature) Sun 실측값.")] + public Vector3 sunEuler = new Vector3(26.4f, 238.6f, 0f); + public Color sunColor = new Color(1f, 0.92f, 0.703f, 1f); + public float sunIntensity = 1.2f; + + [Tooltip("보조 방향광(그림자 없음) — 게임 맵 Sun_Ambient 실측값. intensity 0 이면 안 만든다.")] + public Vector3 fillEuler = new Vector3(36.1f, 116.1f, 0f); + public Color fillColor = new Color(0.596f, 0.703f, 0.839f, 1f); + public float fillIntensity = 0.3f; + + [Tooltip("🔴 켜면 인게임과 같은 **Skybox 환경광**(Ingame.unity 실측 = m_AmbientMode 0 + 기본 스카이박스). " + + "끄면 아래 단색으로 굽는다(더 어둡다 · 재현성 우선일 때).")] + public bool useSkyboxAmbient = true; + + [Tooltip("Skybox 환경광 세기(인게임 m_AmbientIntensity 실측 = 1).")] + public float ambientIntensity = 1f; + + [Tooltip("useSkyboxAmbient 가 꺼졌을 때 쓰는 환경광 단색.")] + public Color ambient = new Color(0.212f, 0.227f, 0.259f, 1f); + + // ── 출력 ───────────────────────────────────────────────────────────── + [Header("출력")] + [Tooltip("시트 PNG · 메타 SO 가 들어갈 폴더.")] + public string outputDir = "Assets/WL/Look/Sprite/Baked"; + + [Tooltip("시트 가로 칸 수. 0 이면 정사각에 가깝게 자동.")] + public int sheetColumns = 16; + + [Tooltip("시트 가로 픽셀 상한(초과하면 칸 수를 줄인다).")] + public int sheetMaxWidth = 2048; + + // ── 동작 표 ────────────────────────────────────────────────────────── + [Header("동작 표 — 🔴 프리팹 이름은 여기 없다(대상은 인자)")] + public WLSpriteBakeAction[] actions = new WLSpriteBakeAction[] + { + new WLSpriteBakeAction { key = "idle", stateNames = new[]{ "idle", "Idle", "wait", "IdleA", "IdleB" }, frames = 4, loop = true, + alsoMapStateNames = new[]{ "land", "getup" } }, + new WLSpriteBakeAction { key = "run", stateNames = new[]{ "run", "Run", "move", "Move", "walk" }, frames = 6, loop = true, + alsoMapStateNames = new[]{ "dodge" } }, + new WLSpriteBakeAction { key = "attack", stateNames = new[]{ "attack", "Attack", "atk", "ATK0", "ATK1" }, frames = 6, loop = true, + alsoMapStateNames = new[]{ "ATK2", "ATK3", "ATK4", "Shoot", "attack2", "skill2", "skill3", "skill4", "skill5", "casting", "casting_end" } }, + new WLSpriteBakeAction { key = "hit", stateNames = new[]{ "hit", "Hit", "damage", "Damage" }, frames = 2, loop = true, + alsoMapStateNames = new[]{ "stun", "Swoon" } }, + new WLSpriteBakeAction { key = "die", stateNames = new[]{ "die", "Die", "death", "Die0", "Die1" }, frames = 6, loop = false, + alsoMapStateNames = new[]{ "lying", "Die2" } }, + }; + + /// 내림각(도) — override 가 0 이면 811o 궤도 수식으로 산출. + public float PitchDeg + { + get + { + if (pitchDegOverride > 0.01f) return pitchDegOverride; + return WL.CameraFraming.CameraFramingOverride.DownAngleDeg(orbitCameraHeight, orbitDistance, orbitDistanceUp); + } + } + + /// 픽셀 1개의 월드 크기(m). + public float WorldPerPixel + { + get + { + if (worldPerPixelOverride > 0.000001f) return worldPerPixelOverride; + int ph = Mathf.Max(1, refPixelHeight); + return (2f * refOrthoSize) / ph; + } + } + } +} diff --git a/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs.meta b/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs.meta new file mode 100644 index 000000000..e466326ba --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteBakeSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9dcf8e4e8011b84ab5e5df43a7e0255 \ No newline at end of file diff --git a/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs b/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs new file mode 100644 index 000000000..dd9ada6e9 --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs @@ -0,0 +1,101 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSpriteLookSettings.cs — 런타임 빌보드 교체(도트 모드 한정)의 값 단일 출처 (WL-814k · #814) +// +// ■ C8 롤백: `enabled_ = 0` → 러너를 아예 만들지 않는다 = 원본 3D 100 %. +// ■ 🔴 여기에도 특정 캐릭터 이름은 **SO 참조 목록**으로만 들어간다(코드 상수 0). +// 시트 SO(WLSpriteSet)가 `actorNamePrefix` 를 들고 있으므로, 목록에 SO 를 넣기만 하면 대상이 된다. +// +// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.SpriteBake +{ + [CreateAssetMenu(fileName = "WLSpriteLookSettings", menuName = "WL/Sprite Look Settings (도트 빌보드)", order = 38)] + public sealed class WLSpriteLookSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLSpriteLookSettings"; + + static WLSpriteLookSettings s_instance; + static bool s_tried; + + /// 에셋이 없으면 null(호출부는 전부 건너뛴다 = C8). + public static WLSpriteLookSettings Instance + { + get + { + if (s_instance == null && !s_tried) { s_tried = true; s_instance = Resources.Load(ResourcesPath); } + return s_instance; + } + } + + public static void ClearCache() { s_instance = null; s_tried = false; } + + /// 프로브·QA 용 런타임 스위치(에셋을 안 건드리고 끈다). + public static bool RuntimeDisabled; + + /// 이 축이 살아 있는가 = 에셋 존재 · enabled_ · 런타임 스위치 off. + public static bool Enabled + { + get { if (RuntimeDisabled) return false; var s = Instance; return s != null && s.enabled_; } + } + + [Header("공통 (C8 롤백)")] + [Tooltip("끄면 러너 0 · 교체 0 = 원본 3D 100 %.")] + public bool enabled_ = true; + + [Tooltip("켜면 교체/복원마다 Console 에 1줄(문자열은 이 값으로 막고 만든다 · GC 주의).")] + public bool verboseLog = false; + + [Header("언제 켜는가")] + [Tooltip("🔴 도트 모드(814d LookModeHub 프리셋 pixelRig)에서만 켠다. 끄면 항상 켠다(디버그).")] + public bool onlyInPixelMode = true; + + [Tooltip("새로 스폰된 액터를 찾아보는 주기(초). 0.5 = 프레임당 비용 0에 가깝다.")] + public float sweepSeconds = 0.5f; + + [Tooltip("동시에 스프라이트로 바꿀 액터 수 상한(초과분은 원본 3D 그대로).")] + public int maxActors = 24; + + [Header("시트 목록 — 대상 = 여기 있는 SO 의 actorNamePrefix")] + public WLSpriteSet[] sets = new WLSpriteSet[0]; + + [Header("빌보드")] + [Tooltip("🔴 기본(끔) = 카메라 정렬(구운 각도 그대로 · 스프라이트 1px = 화면 1px). " + + "켜면 Y축만 회전하는 수직 쿼드 — 구울 때의 내림각만큼 세로가 눌린다(cos 43.6° = 0.72).")] + public bool yAxisOnly = false; + + [Tooltip("yAxisOnly 일 때 눌림을 1/cos(내림각) 로 보정한다.")] + public bool yAxisOnlyCompensate = true; + + [Tooltip("쿼드를 카메라 쪽으로 살짝 당긴다(m) — z-파이팅 여유분.")] + public float depthBias = 0.02f; + + [Tooltip("🔴 켜면 쿼드가 지면에 파묻히지 않도록 필요한 만큼 자동으로 더 당긴다. " + + "쿼드는 카메라와 같은 각도로 기울어 있어서 **발밑보다 아래쪽 부분이 지면 속으로 들어간다** — " + + "실측(814k): 끄면 캐릭터 아랫부분 7 px 이 지면에 잘렸다. 직교 카메라(도트 모드)에서는 " + + "시선 방향으로 당겨도 화면 위치가 1 px 도 안 움직이므로 공짜다.")] + public bool autoDepthBias = true; + + [Tooltip("알파 컷 임계(구울 때 이미 이진화했으므로 0.5 고정 권장).")] + [Range(0.01f, 0.99f)] public float alphaCutoff = 0.5f; + + [Tooltip("빌보드 쿼드가 그려질 레이어(-1 = 액터와 같은 레이어).")] + public int quadLayer = -1; + + [Tooltip("SkinnedMeshRenderer 외에 일반 MeshRenderer(무기·장식)도 같이 끈다. " + + "구운 시트에는 그것들도 그려져 있으므로 켜 두는 것이 맞다. 파티클은 건드리지 않는다.")] + public bool alsoHideMeshRenderers = true; + + [Tooltip("그림자 — 구운 스프라이트는 그림자를 못 만든다(한계). 켜면 쿼드 모양 그림자가 생겨 더 이상하다.")] + public bool castShadows = false; + + [Header("동작 폴링")] + [Tooltip("Animator 상태를 몇 초마다 다시 볼지. 0 = 매 프레임(가장 정확 · 그래도 할당 0).")] + public float statePollSeconds = 0f; + + [Tooltip("모르는 상태에 들어갔을 때 쓸 동작 키.")] + public string fallbackActionKey = "idle"; + } +} diff --git a/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs.meta b/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs.meta new file mode 100644 index 000000000..4a60b8017 --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteLookSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f39fe71df28ff94ebdc9f76766e84e9 \ No newline at end of file diff --git a/Assets/WL/Look/Sprite/WLSpriteSet.cs b/Assets/WL/Look/Sprite/WLSpriteSet.cs new file mode 100644 index 000000000..28962d250 --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteSet.cs @@ -0,0 +1,163 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLSpriteSet.cs — 「3D 를 도트로 구운」 스프라이트 시트 1장의 메타 (WL-814k · #814) +// +// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」 +// +// ■ 이 에셋이 들고 있는 것 — 그림(PNG) 1장 + 그 그림을 「어떻게 읽는지」의 표 +// · 시트 = 방향 × 동작 × 프레임을 격자로 붙인 PNG 1장(서브 스프라이트 0 · UV 는 런타임 산술). +// · 동작표 = Animator **상태 이름** → 시트 시작 인덱스 · 프레임 수 · fps. +// 런타임(SpriteBillboard)은 원본 Animator 를 **폴링**만 한다 — 원본 코드 수정 0. +// · worldPerPixel = 구울 때 픽셀 1개가 차지한 월드 크기(m). 런타임 쿼드 크기 = 프레임 px × 이 값. +// 도트 모드의 화면 픽셀 크기와 같게 구우면 **스프라이트 1픽셀 = 화면 1픽셀**이 된다. +// · footPixelX/Y = 월드 원점(캐릭터 발밑)이 프레임 안 어느 픽셀에 찍혔는지(좌하단 원점). +// 런타임은 이 값으로 쿼드를 발밑에 맞춘다 — 동작마다 키가 달라져도 발이 안 뜬다. +// +// ■ 인덱스 규약 (시트 안 위치) +// globalIndex = action.startIndex + dir * action.frameCount + frame +// col = globalIndex % columns · row = globalIndex / columns (row 0 = 시트 **위**) +// +// 🔴 이 파일은 데이터만 든다. 굽는 쪽은 Editor/WLSpriteBaker.cs, 쓰는 쪽은 SpriteBillboard.cs. +// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.SpriteBake +{ + /// 동작 1개(대기·이동·공격·피격·사망 …)가 시트 어디에 있는지. + [System.Serializable] + public sealed class WLSpriteAction + { + [Tooltip("동작 키 — idle / run / attack / hit / die (SO 의 동작 표와 같은 문자열).")] + public string key = "idle"; + + [Tooltip("이 동작으로 볼 Animator 상태 이름들(레이어 0 shortNameHash 로 비교).")] + public string[] stateNames = new string[0]; + + [Tooltip("실제로 구운 AnimationClip 이름(보고·디버그용).")] + public string clipName = ""; + + [Tooltip("시트 안 시작 인덱스(방향 0 · 프레임 0).")] + public int startIndex; + + [Tooltip("방향 1개당 프레임 수.")] + public int frameCount = 1; + + [Tooltip("재생 fps(클립 길이 ÷ 프레임 수 로 산출).")] + public float fps = 8f; + + [Tooltip("클립이 루프인가(사망은 false → 마지막 프레임에서 멈춘다).")] + public bool loop = true; + + [Tooltip("원본 클립 길이(초).")] + public float clipLength = 1f; + + // 런타임 캐시(직렬화 안 함 · 첫 사용 때 1회 만든다 → 이후 할당 0) + [System.NonSerialized] public int[] hashes; + } + + /// 구운 스프라이트 시트 1장 + 읽는 법. 프리팹 1종당 1개. + [CreateAssetMenu(fileName = "WLSpriteSet", menuName = "WL/Sprite Set (구운 도트)", order = 36)] + public sealed class WLSpriteSet : ScriptableObject + { + [Header("출처")] + [Tooltip("어느 프리팹을 구웠는가(경로).")] + public string sourcePrefab = ""; + + [Tooltip("액터 이름이 이 문자열로 시작하면 이 시트를 쓴다(예: Ai01 → 'Ai01(Clone)' 매칭).")] + public string actorNamePrefix = ""; + + [Tooltip("구운 시각 · 툴 버전(보고용).")] + public string bakeStamp = ""; + + [Header("시트")] + public Texture2D sheet; + public int frameWidth = 64; + public int frameHeight = 64; + public int columns = 16; + public int rows = 1; + + [Header("굽기 파라미터")] + [Tooltip("방향 수(카메라 기준 상대 yaw 를 이 수로 나눈다).")] + public int dirCount = 8; + + [Tooltip("구울 때 픽셀 1개가 차지한 월드 크기(m). 런타임 쿼드 크기 = 프레임 px × 이 값.")] + public float worldPerPixel = 0.0283333f; + + [Tooltip("구울 때 루트 lossyScale.y. 런타임 액터 스케일이 다르면 이 비율로 쿼드를 키운다.")] + public float bakeScale = 1f; + + [Tooltip("월드 원점(발밑)이 프레임 안 어느 픽셀에 찍혔는지 — 좌하단 원점 · 픽셀 단위.")] + public float footPixelX = 32f; + public float footPixelY = 4f; + + [Tooltip("구울 때 카메라 내림각(도). 런타임 도트 카메라와 같아야 한다.")] + public float pitchDeg = 43.609f; + + [Header("동작 표")] + public WLSpriteAction[] actions = new WLSpriteAction[0]; + + /// 방향 1개 기준 총 프레임 수(= 시트 총 칸 수 ÷ 방향 수). + public int TotalFrames + { + get + { + int n = 0; + if (actions != null) for (int i = 0; i < actions.Length; i++) if (actions[i] != null) n += actions[i].frameCount; + return n * Mathf.Max(1, dirCount); + } + } + + /// Animator 상태 해시(레이어 0 shortNameHash)로 동작을 찾는다. 없으면 null. + public WLSpriteAction FindByStateHash(int shortNameHash) + { + if (actions == null) return null; + for (int i = 0; i < actions.Length; i++) + { + var a = actions[i]; + if (a == null) continue; + if (a.hashes == null) BuildHashes(a); + for (int h = 0; h < a.hashes.Length; h++) if (a.hashes[h] == shortNameHash) return a; + } + return null; + } + + /// 키로 동작을 찾는다(폴백용 · 예: "idle"). + public WLSpriteAction FindByKey(string key) + { + if (actions == null || string.IsNullOrEmpty(key)) return null; + for (int i = 0; i < actions.Length; i++) if (actions[i] != null && actions[i].key == key) return actions[i]; + return null; + } + + static void BuildHashes(WLSpriteAction a) + { + var src = a.stateNames ?? new string[0]; + a.hashes = new int[src.Length]; + for (int i = 0; i < src.Length; i++) a.hashes[i] = Animator.StringToHash(src[i]); + } + + /// 시트가 실제로 쓸 수 있는 상태인가. + public bool IsUsable + { + get + { + return sheet != null && frameWidth > 0 && frameHeight > 0 && columns > 0 + && dirCount > 0 && actions != null && actions.Length > 0; + } + } + + /// globalIndex → 시트 UV 사각형(x, y, w, h · 0~1). 할당 0. + public Vector4 UvRect(int globalIndex) + { + int cols = Mathf.Max(1, columns); + int col = globalIndex % cols; + int row = globalIndex / cols; + float tw = sheet != null ? sheet.width : 1f; + float th = sheet != null ? sheet.height : 1f; + float u = (col * frameWidth) / tw; + float v = (th - (row + 1) * frameHeight) / th; // row 0 = 시트 위 + return new Vector4(frameWidth / tw, frameHeight / th, u, v); // (scaleX, scaleY, offsetX, offsetY) = _ST 규약 + } + } +} diff --git a/Assets/WL/Look/Sprite/WLSpriteSet.cs.meta b/Assets/WL/Look/Sprite/WLSpriteSet.cs.meta new file mode 100644 index 000000000..a4d34df75 --- /dev/null +++ b/Assets/WL/Look/Sprite/WLSpriteSet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7485ff60a4b963544b28a1b345440fb9 \ No newline at end of file