// ───────────────────────────────────────────────────────────────────────────── // WLReferenceLook.cs — 레퍼런스 느낌 4요소를 씬에 얹는다 (WL-814t · #814) // // Ⓐ 외곽선(inverted hull · 화면 픽셀 두께) Ⓑ 명암 대비(Toon 램프) Ⓒ 색조·분위기(Volume+라이트) // Ⓓ 림라이트(가산 패스) // // ■ 원칙 // · 에셋(머티리얼·그래프·프리팹·원본 텍스처)을 **한 글자도 안 바꾼다**. // 전부 런타임 인스턴스(머티리얼 복제 · 머티리얼 배열 뒤에 덧붙이기 · 임시 Volume)로만 한다. // · `enabled_ = 0` 이면 아무 것도 하지 않는다 = WL-814s 상태 100 %(C8). // · 되돌릴 수 있게 바꾸기 전 값을 전부 저장한다(OnDisable 에서 복구). // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace WL.Look.Arena { [DisallowMultipleComponent] public class WLReferenceLook : MonoBehaviour { // ── 진단(프로브가 읽는다) public static int OutlinedRenderers, RimRenderers, ContrastMaterials; public static string LastLog = ""; public static bool IsApplied { get { return s_applied; } } [Tooltip("캐릭터 루트(비우면 이름으로 찾는다).")] public GameObject characterRoot; [Tooltip("캐릭터 루트를 못 찾을 때 쓰는 이름.")] public string characterRootName = "PC_LH_M05"; /// 요소별 on/off + 세기. 캡처 스크립트가 단독 요소 비교에 쓴다. public struct Opt { public bool outline, contrast, mood, rim; public float outlineWidth, outlineWidthBackground; public Color outlineColor; public bool outlineOnBackground; public bool outlineScaleWithZoom; public float outlineRefOrthoSize, outlineDepthBias; public float outlineTintAmount, outlineTintDarkness; public float minimumDarkness, shadowColorScale, shadowTintAmount; public Color shadowTint; public int charShades; public float moodStrength; public float rimStrength, rimPower, rimUpBias; public Color rimColor; public bool rimOnBackground; public static Opt FromSettings(WLReferenceLookSettings c) { var o = new Opt(); if (c == null) return o; o.outline = c.outlineOn; o.contrast = c.contrastOn; o.mood = c.moodOn; o.rim = c.rimOn; o.outlineWidth = c.outlineWidth; o.outlineWidthBackground = c.outlineWidthBackground; o.outlineColor = c.outlineColor; o.outlineOnBackground = c.outlineOnBackground; o.outlineScaleWithZoom = c.outlineScaleWithZoom; o.outlineRefOrthoSize = c.outlineRefOrthoSize; o.outlineDepthBias = c.outlineDepthBias; o.outlineTintAmount = c.outlineTintAmount; o.outlineTintDarkness = c.outlineTintDarkness; o.minimumDarkness = c.minimumDarkness; o.shadowColorScale = c.shadowColorScale; o.shadowTintAmount = c.shadowTintAmount; o.shadowTint = c.shadowTint; o.charShades = c.charShades; o.moodStrength = c.moodStrength; o.rimStrength = c.rimStrength; o.rimPower = c.rimPower; o.rimUpBias = c.rimUpBias; o.rimColor = c.rimColor; o.rimOnBackground = c.rimOnBackground; return o; } } // ── 저장된 원래 상태 ──────────────────────────────────────────────── struct RendState { public Renderer r; public Material[] mats; } struct LightState { public Light l; public Color c; public float i; } static readonly List s_rends = new List(200); static readonly List s_lights = new List(8); static readonly List s_created = new List(32); static GameObject s_volumeGo; static bool s_applied; static Color s_ambient; static AmbientMode s_ambMode; static float s_ambIntensity; static Terrain s_terrain; static Material s_terrainMat; static Camera s_cam; static bool s_camPost; static bool s_camPostSaved; static readonly Dictionary s_clones = new Dictionary(); const string kToon = "Shader Graphs/Toon"; const string kToonTerrain = "Shader Graphs/ToonTerrain"; void OnEnable() { var cfg = WLReferenceLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0) { LastLog = "enabled_=0 → 적용 0(814s 상태)"; return; } Apply(Opt.FromSettings(cfg), cfg); } void OnDisable() { Restore(); } // ───────────────────────────────────────────────────────────────── /// 4요소를 지정한 세기로 얹는다. 이미 얹혀 있으면 먼저 되돌린다(멱등). public static void Apply(Opt o, WLReferenceLookSettings cfg) { Restore(); if (cfg == null) cfg = WLReferenceLookSettings.Instance; var charRoot = FindCharacterRoot(); var rends = Object.FindObjectsByType(FindObjectsSortMode.None); OutlinedRenderers = 0; RimRenderers = 0; ContrastMaterials = 0; Shader outlineShader = o.outline ? Shader.Find("WL/HullOutline") : null; // 줌 비례 — 직교 크기가 커지면(멀리서 보면) 화면 픽셀 두께를 같은 비율로 줄인다. // 그래야 「캐릭터 키 대비 굵기」가 프레이밍과 무관하게 일정하다(2D 스프라이트처럼). // 기준 = 아레나 실제 카메라(직교 10) → SO 의 px 값이 곧 「인게임에서 보이는 px」다. float zoom = 1f; { var c0 = Camera.main; if (c0 == null) { var cs0 = Object.FindObjectsByType(FindObjectsSortMode.None); if (cs0.Length > 0) c0 = cs0[0]; } if (o.outlineScaleWithZoom && c0 != null && c0.orthographic && c0.orthographicSize > 0.001f && o.outlineRefOrthoSize > 0.001f) zoom = o.outlineRefOrthoSize / c0.orthographicSize; } s_outlineShader = outlineShader; s_zoom = zoom; s_opt = o; s_outlines.Clear(); Material rimMat = null; if (o.rim) { var sh = Shader.Find("WL/RimAdd"); if (sh != null) { rimMat = Make(sh); rimMat.SetColor("_RimColor", o.rimColor); rimMat.SetFloat("_RimPower", o.rimPower); rimMat.SetFloat("_RimStrength", o.rimStrength); rimMat.SetFloat("_RimUpBias", o.rimUpBias); } } for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null || r is ParticleSystemRenderer) continue; var mats = r.sharedMaterials; if (mats == null || mats.Length == 0 || mats[0] == null) continue; bool isChar = charRoot != null && r.transform.IsChildOf(charRoot.transform); // Toon 계열 불투명만 대상(얼굴 Unlit/Transparent · 물은 제외) bool toon = false; for (int m = 0; m < mats.Length; m++) if (mats[m] != null && mats[m].shader != null && mats[m].shader.name == kToon) toon = true; if (!toon) continue; var newMats = mats; bool touched = false; // Ⓑ 대비 — 머티리얼 복제본으로 교체 if (o.contrast) { newMats = (Material[])mats.Clone(); for (int m = 0; m < newMats.Length; m++) { if (newMats[m] == null || newMats[m].shader == null || newMats[m].shader.name != kToon) continue; newMats[m] = CloneForContrast(newMats[m], o, isChar); touched = true; } } // Ⓐ 외곽선 · Ⓓ 림 — 머티리얼 배열 뒤에 덧붙인다(서브메시 1개면 한 번 더 그린다) var extra = new List(2); if (o.outline && (isChar || o.outlineOnBackground)) extra.Add(OutlineFor(mats[0], isChar)); if (o.rim && rimMat != null && (isChar || o.rimOnBackground)) extra.Add(rimMat); if (extra.Count > 0 && extra[0] != null) { if (!touched) newMats = (Material[])mats.Clone(); var list = new List(newMats); for (int e = 0; e < extra.Count; e++) if (extra[e] != null) list.Add(extra[e]); newMats = list.ToArray(); touched = true; if (o.outline && (isChar || o.outlineOnBackground)) OutlinedRenderers++; if (o.rim && rimMat != null && (isChar || o.rimOnBackground)) RimRenderers++; } if (touched) { s_rends.Add(new RendState { r = r, mats = mats }); r.sharedMaterials = newMats; } } // Ⓑ — 터레인(화면 면적의 대부분) if (o.contrast) { var t = Terrain.activeTerrain; if (t != null && t.materialTemplate != null && t.materialTemplate.shader != null && t.materialTemplate.shader.name == kToonTerrain) { s_terrain = t; s_terrainMat = t.materialTemplate; t.materialTemplate = CloneForContrast(t.materialTemplate, o, false); } } // Ⓒ — 라이트 · 앰비언트 · Volume if (o.mood && o.moodStrength > 0f) { float k = Mathf.Clamp01(o.moodStrength); s_ambMode = RenderSettings.ambientMode; s_ambient = RenderSettings.ambientLight; s_ambIntensity = RenderSettings.ambientIntensity; var lights = Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < lights.Length; i++) { var l = lights[i]; if (l == null) continue; s_lights.Add(new LightState { l = l, c = l.color, i = l.intensity }); if (l.type == LightType.Directional) { l.color = Color.Lerp(l.color, cfg != null ? cfg.keyLightColor : new Color(1f, .76f, .42f), k); l.intensity = Mathf.Lerp(l.intensity, cfg != null ? cfg.keyLightIntensity : 1.15f, k); } } RenderSettings.ambientMode = AmbientMode.Flat; RenderSettings.ambientLight = Color.Lerp( s_ambMode == AmbientMode.Flat ? s_ambient : RenderSettings.ambientSkyColor, cfg != null ? cfg.ambientColor : new Color(.259f, .165f, .373f), k); if (cfg != null && cfg.profile != null && cfg.profile.asset != null) { s_volumeGo = new GameObject("WL814t_ReferenceLook_Volume"); s_volumeGo.hideFlags = HideFlags.DontSave; var v = s_volumeGo.AddComponent(); v.isGlobal = true; v.priority = 100f; v.sharedProfile = cfg.profile.asset; v.weight = k; s_cam = Camera.main; if (s_cam == null) { var cs = Object.FindObjectsByType(FindObjectsSortMode.None); if (cs.Length > 0) s_cam = cs[0]; } if (s_cam != null) { var ac = s_cam.GetComponent(); if (ac != null) { s_camPost = ac.renderPostProcessing; s_camPostSaved = true; ac.renderPostProcessing = true; } } } } s_applied = true; LastLog = "Ⓐ" + (o.outline ? OutlinedRenderers.ToString() : "off") + " Ⓑ" + (o.contrast ? ContrastMaterials + "mat/md" + o.minimumDarkness.ToString("F2") : "off") + " Ⓒ" + (o.mood ? o.moodStrength.ToString("F2") : "off") + " Ⓓ" + (o.rim ? RimRenderers.ToString() : "off"); if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL814t ReferenceLook] " + LastLog); } /// 바꾼 것을 전부 원래대로. 몇 번 불러도 안전. public static void Restore() { for (int i = 0; i < s_rends.Count; i++) if (s_rends[i].r != null) s_rends[i].r.sharedMaterials = s_rends[i].mats; s_rends.Clear(); if (s_terrain != null && s_terrainMat != null) s_terrain.materialTemplate = s_terrainMat; s_terrain = null; s_terrainMat = null; for (int i = 0; i < s_lights.Count; i++) if (s_lights[i].l != null) { s_lights[i].l.color = s_lights[i].c; s_lights[i].l.intensity = s_lights[i].i; } if (s_lights.Count > 0) { RenderSettings.ambientMode = s_ambMode; RenderSettings.ambientLight = s_ambient; RenderSettings.ambientIntensity = s_ambIntensity; } s_lights.Clear(); if (s_volumeGo != null) { DestroyAny(s_volumeGo); s_volumeGo = null; } if (s_camPostSaved && s_cam != null) { var ac = s_cam.GetComponent(); if (ac != null) ac.renderPostProcessing = s_camPost; } s_camPostSaved = false; s_cam = null; for (int i = 0; i < s_created.Count; i++) if (s_created[i] != null) DestroyAny(s_created[i]); s_created.Clear(); s_clones.Clear(); s_outlines.Clear(); OutlinedRenderers = 0; RimRenderers = 0; ContrastMaterials = 0; if (s_applied) LastLog = "restored(814s 상태)"; s_applied = false; } // ── helpers ────────────────────────────────────────────────────── static GameObject FindCharacterRoot() { var go = GameObject.Find("PC_LH_M05"); if (go != null) return go; var anim = Object.FindFirstObjectByType(); return anim != null ? anim.gameObject : null; } static Shader s_outlineShader; static float s_zoom = 1f; static Opt s_opt; static readonly Dictionary s_outlines = new Dictionary(); /// 원본 머티리얼 1종당 외곽선 머티리얼 1장(색 연동을 하려면 BaseMap·DiffuseColor 가 원본별로 달라야 한다). static Material OutlineFor(Material src, bool isChar) { if (s_outlineShader == null) return null; Material m; if (s_outlines.TryGetValue(src, out m) && m != null) return m; m = Make(s_outlineShader); m.SetColor("_OutlineColor", s_opt.outlineColor); m.SetFloat("_OutlineWidth", (isChar ? s_opt.outlineWidth : s_opt.outlineWidthBackground) * s_zoom); m.SetFloat("_OutlineDepthBias", s_opt.outlineDepthBias); m.SetFloat("_TintAmount", s_opt.outlineTintAmount); m.SetFloat("_TintDarkness", s_opt.outlineTintDarkness); if (src != null) { if (src.HasProperty("_BaseMap")) { var t = src.GetTexture("_BaseMap"); if (t != null) m.SetTexture("_BaseMap", t); } if (src.HasProperty("_DiffuseColor")) m.SetColor("_TintColor", src.GetColor("_DiffuseColor")); } s_outlines[src] = m; return m; } static Material Make(Shader sh) { var m = new Material(sh); m.hideFlags = HideFlags.HideAndDontSave; s_created.Add(m); return m; } static Material CloneForContrast(Material src, Opt o, bool isChar) { Material c; // 캐릭터/배경이 같은 머티리얼을 공유하는 경우가 없어 키 하나로 충분하다 if (s_clones.TryGetValue(src, out c) && c != null) return c; c = new Material(src); c.hideFlags = HideFlags.HideAndDontSave; c.name = src.name + "_814t"; if (c.HasProperty("_MinimumDarkness")) c.SetFloat("_MinimumDarkness", o.minimumDarkness); if (c.HasProperty("_ShadowDiffuseColor")) { var s = c.GetColor("_ShadowDiffuseColor"); s = new Color(s.r * o.shadowColorScale, s.g * o.shadowColorScale, s.b * o.shadowColorScale, s.a); if (o.shadowTintAmount > 0f) s = Color.Lerp(s, new Color(s.grayscale, s.grayscale, s.grayscale, s.a) * o.shadowTint, o.shadowTintAmount); c.SetColor("_ShadowDiffuseColor", s); } if (isChar && o.charShades > 0 && c.HasProperty("_Shades")) c.SetFloat("_Shades", o.charShades); s_clones[src] = c; s_created.Add(c); ContrastMaterials++; return c; } static void DestroyAny(Object o) { if (o == null) return; if (Application.isPlaying) Destroy(o); else DestroyImmediate(o); } } }