// ───────────────────────────────────────────────────────────────────────────── // 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 } }