// ───────────────────────────────────────────────────────────────────────────── // WLVignetteUtil.cs — 화면 가장자리 UI 비네트 조립 헬퍼 + Safe Area 내성 경로 탐색 // (WL-813tj · 813t/813j 공용 · #813) // // ■ 왜 UI 이미지인가 // 기준서 §D-1 813t·813j 가 "포스트 프로세싱 금지 → UI 이미지" 를 못 박았다. // URP Volume(Vignette)은 모바일에서 풀스크린 패스를 하나 더 만들고, 이 프로젝트는 UI 캔버스가 주인이다. // // ■ 왜 풀스크린 이미지가 아니라 "가장자리 4띠" 인가 (모바일 필레이트) // 반투명 풀스크린 쿼드 = 화면 전체 블렌드 1패스. 실제로 보이는 것은 가장자리뿐이므로 // 상·하·좌·우 4개 띠(두께 = 설정 px)만 그린다 → 덮는 면적이 두께에 비례해서만 는다. // 부드러운 감쇠는 **런타임 생성 그라디언트 스프라이트**(32×4 / 4×32)로 낸다 — 아트 에셋 의존 0. // 코너는 가로띠와 세로띠가 겹쳐 자연히 진해진다(비네트로서 옳은 방향). // // ■ 좌표 규약 // 좌 띠 = 좌측 세로 스트레치(폭 T) · 우 띠 = 같은 것을 localScale.x = −1 로 뒤집음 // 하 띠 = 하단 가로 스트레치(높이 T) · 상 띠 = localScale.y = −1 // → 스프라이트는 2장(가로 램프 · 세로 램프)만 있으면 되고, 회전이 없어 rect 가 그대로 잰다. // // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.UI; namespace WL.UI { /// 가장자리 비네트 4띠 묶음. 만들기·크기 맞추기·알파 넣기만 한다(수명 관리는 호출부). public sealed class WLEdgeVignette { public RectTransform root; public readonly Image[] strips = new Image[4]; // 0 좌 · 1 우 · 2 하 · 3 상 public bool IsBuilt { get { return root != null && strips[0] != null && strips[3] != null; } } /// 알파 0~1 을 4띠에 한 번에 넣는다. 0 이면 Image 를 꺼서 드로우콜까지 없앤다. public void SetAlpha(Color baseColor, float t) { float a = baseColor.a * Mathf.Clamp01(t); bool on = a > 0.001f; for (int i = 0; i < strips.Length; i++) { var img = strips[i]; if (img == null) continue; if (img.enabled != on) img.enabled = on; // 꺼도 색까지 0 으로 내린다 — 덤프가 옛 알파를 남겨 오해를 부르지 않게. img.color = new Color(baseColor.r, baseColor.g, baseColor.b, on ? a : 0f); } } /// 현재 첫 띠의 알파(진단용). public float CurrentAlpha { get { return strips[0] != null && strips[0].enabled ? strips[0].color.a : 0f; } } } public static class WLVignetteUtil { public const int UILayer = 5; // ── 런타임 생성 그라디언트 스프라이트(에셋 의존 0 · 정적 캐시) ──────── private static Sprite s_hRamp; // u=0 에서 알파 1 → u=1 에서 0 private static Sprite s_vRamp; // v=0 에서 알파 1 → v=1 에서 0 /// 가로 램프(왼쪽이 진하다). 좌/우 띠가 쓴다. public static Sprite HorizontalRamp { get { if (s_hRamp == null) s_hRamp = BuildRamp(32, 4, true); return s_hRamp; } } /// 세로 램프(아래가 진하다). 하/상 띠가 쓴다. public static Sprite VerticalRamp { get { if (s_vRamp == null) s_vRamp = BuildRamp(4, 32, false); return s_vRamp; } } private static Sprite BuildRamp(int w, int h, bool horizontal) { var tex = new Texture2D(w, h, TextureFormat.RGBA32, false); tex.name = "WL_VignetteRamp_" + (horizontal ? "H" : "V"); tex.wrapMode = TextureWrapMode.Clamp; tex.filterMode = FilterMode.Bilinear; tex.hideFlags = HideFlags.DontSave; int n = horizontal ? w : h; var px = new Color32[w * h]; for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { float t = (n <= 1) ? 0f : (float)(horizontal ? x : y) / (n - 1); // (1-t)^2 = 바깥이 진하고 안쪽으로 빠르게 사라지는 감쇠(선형보다 덜 답답하다) float a = (1f - t); a *= a; px[y * w + x] = new Color32(255, 255, 255, (byte)Mathf.RoundToInt(Mathf.Clamp01(a) * 255f)); } tex.SetPixels32(px); tex.Apply(false, false); var sp = Sprite.Create(tex, new Rect(0, 0, w, h), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect); sp.name = tex.name; sp.hideFlags = HideFlags.DontSave; return sp; } // ── 조립 ────────────────────────────────────────────────────────────── /// 부모 아래에 비네트 4띠를 만든다(이미 있으면 재사용). 루트는 전체 스트레치. public static WLEdgeVignette Build(RectTransform parent, string rootName) { var v = new WLEdgeVignette(); if (parent == null) return v; var rootT = parent.Find(rootName) as RectTransform; if (rootT == null) { var go = new GameObject(rootName, typeof(RectTransform)); go.layer = parent.gameObject.layer; rootT = (RectTransform)go.transform; rootT.SetParent(parent, false); } Stretch(rootT); rootT.localScale = Vector3.one; v.root = rootT; v.strips[0] = EnsureStrip(rootT, "Left"); v.strips[1] = EnsureStrip(rootT, "Right"); v.strips[2] = EnsureStrip(rootT, "Bottom"); v.strips[3] = EnsureStrip(rootT, "Top"); return v; } private static Image EnsureStrip(RectTransform parent, string childName) { var t = parent.Find(childName) as RectTransform; if (t == null) { var go = new GameObject(childName, typeof(RectTransform)); go.layer = parent.gameObject.layer; t = (RectTransform)go.transform; t.SetParent(parent, false); } var img = t.GetComponent(); if (img == null) img = t.gameObject.AddComponent(); img.raycastTarget = false; // 비네트는 절대 입력을 먹지 않는다 img.type = Image.Type.Simple; img.preserveAspect = false; return img; } /// 두께(px)·색을 4띠에 적용한다. unitsPerPx = 813c WLHudLayoutSettings.UnitsPerPx. public static void ApplyLayout(WLEdgeVignette v, float thicknessPx, float unitsPerPx, Color color) { if (v == null || !v.IsBuilt) return; float t = Mathf.Max(0f, thicknessPx) * unitsPerPx; Place(v.strips[0].rectTransform, new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(t, 0f), new Vector3(1f, 1f, 1f)); Place(v.strips[1].rectTransform, new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(1f, 0.5f), new Vector2(t, 0f), new Vector3(-1f, 1f, 1f)); Place(v.strips[2].rectTransform, new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, t), new Vector3(1f, 1f, 1f)); Place(v.strips[3].rectTransform, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, t), new Vector3(1f, -1f, 1f)); v.strips[0].sprite = HorizontalRamp; v.strips[1].sprite = HorizontalRamp; v.strips[2].sprite = VerticalRamp; v.strips[3].sprite = VerticalRamp; v.SetAlpha(color, 0f); // 만들자마자 보이지 않게 } private static void Place(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 size, Vector3 scale) { rt.anchorMin = aMin; rt.anchorMax = aMax; rt.pivot = pivot; rt.anchoredPosition = Vector2.zero; rt.sizeDelta = size; rt.localScale = scale; } public static void Stretch(RectTransform rt) { rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.pivot = new Vector2(0.5f, 0.5f); } /// RectTransform 자식 하나를 만들거나 찾는다(공통 조립용). public static RectTransform NewChild(RectTransform parent, string childName) { var t = parent != null ? parent.Find(childName) as RectTransform : null; if (t != null) { t.localScale = Vector3.one; return t; } var go = new GameObject(childName, typeof(RectTransform)); go.layer = parent != null ? parent.gameObject.layer : UILayer; t = (RectTransform)go.transform; t.SetParent(parent, false); t.localScale = Vector3.one; return t; } // ── Safe Area 내성 경로 탐색 (WL-813tj Safe Area 재부모화 회귀 방지) ── /// /// "A/B/C" 경로를 찾되, 한 단계가 없으면 **이름이 safeAreaPrefix 로 시작하는 자식 아래**에서 한 번 더 본다. /// 813tj 가 NewGameUI 캔버스 직속 5레이어를 WL_SafeArea_* 패널 아래로 옮겼기 때문에, /// 옛 경로 문자열(예: "MessageInfo/Chat" · "Common/MyInfoUI/Slider_hp")이 그대로 살아 있어야 한다. /// 비활성 자식도 따라간다(Transform.Find 와 달리 구분자 공백 허용). /// public const string SafeAreaPrefix = "WL_SafeArea"; public static Transform FindUiPath(Transform root, string path) { if (root == null || string.IsNullOrEmpty(path)) return null; var parts = path.Split('/'); var cur = root; for (int i = 0; i < parts.Length; i++) { var name = parts[i].Trim(); if (name.Length == 0) continue; var next = FindDirectChild(cur, name); if (next == null) { // Safe Area 패널을 한 겹 건너뛰고 다시 본다(패널은 순수 컨테이너라 경로에 없다). for (int c = 0; c < cur.childCount && next == null; c++) { var panel = cur.GetChild(c); if (panel.name.StartsWith(SafeAreaPrefix)) next = FindDirectChild(panel, name); } } if (next == null) return null; cur = next; } return cur; } private static Transform FindDirectChild(Transform parent, string name) { for (int c = 0; c < parent.childCount; c++) if (parent.GetChild(c).name == name) return parent.GetChild(c); return null; } /// 이 UI 트리의 캔버스 루트(NewGameUI)를 찾는다. public static Transform FindCanvasRoot(Transform any) { if (any == null) return null; var canvas = any.GetComponentInParent(true); if (canvas != null) return canvas.transform; var cur = any; while (cur.parent != null) cur = cur.parent; return cur; } /// 813c 와 같은 px→유닛 계수. 설정 에셋이 없으면 1(배치를 건너뛰라는 뜻). public static float UnitsPerPx(Canvas canvas) { var s = WLHudLayoutSettings.Instance; return s != null ? s.UnitsPerPx(canvas) : 1f; } } }