Project_WL/Assets/WL/Look/Character/WLCharacterBacklight.cs

428 lines
22 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// WLCharacterBacklight.cs — 캐릭터 역광 패스 덧붙이기 + 가상 역광 방향 갱신 + 번짐(Bloom) 스코프
//
// 2026-09-17 PD 「우리 게임의 캐릭터가 조금 더 돋보이게 · 적절한 방법을 검토해 비주얼 완성도 강화」.
//
// ■ 어디서 불리나
// `WLCharacterLook.Apply(root)` 끝(= LH_M05 프리팹의 모든 인스턴스 · 섬 교체 모델 · 던전 PC)에서 `Apply(root)`.
// 재질 배열 **뒤에** `WL/CharBacklight` 런타임 재질 1장을 덧붙인다(서브메시 1개 = 메시 전체가 가산으로 한 번 더 그려진다).
// 피부 재질(이름 힌트)은 피부 투과를 켠 재질을, 나머지는 끈 재질을 쓴다. 몇 번 불러도 결과가 같다(멱등).
//
// ■ 가상 역광 방향
// 드라이버(숨은 오브젝트 1개)가 LateUpdate 마다 카메라에서 「뒤 + 위 + 옆」 방향을 계산해 전역 `_WLBacklightDir` 에 쓴다.
//
// ■ 번짐(Bloom)
// 설정의 씬이 떠 있을 때만 전역 Volume(Bloom 1개) + 메인 카메라 후처리/HDR 을 켜고, 벗어나면 원래 값으로 되돌린다.
//
// 🔴 본체 재질·셰이더 그래프·프리팹 0줄 · Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
namespace WL.Look.Character
{
public static class WLCharacterBacklight
{
// ── 진단(프로브가 읽는다)
public static int AppliedRenderers, SkinRenderers, MultiSubmeshRenderers;
public static string LastLog = "";
static Material s_rim, s_rimSkin;
static readonly List<Renderer> s_buf = new List<Renderer>(16);
static readonly List<Material> s_tmp = new List<Material>(4);
public static bool IsOurs(Material m) { return m != null && (m == s_rim || m == s_rimSkin); }
/// <summary>root 아래 캐릭터 렌더러에 역광 패스를 덧붙인다(설정이 꺼져 있으면 떼어낸다).</summary>
public static void Apply(GameObject root)
{
if (root == null) return;
var cfg = WLCharacterBacklightSettings.Instance;
if (cfg == null) return;
if (cfg.enabled_ == 0) { Remove(root); return; }
if (!EnsureMaterials(cfg)) { LastLog = "셰이더 WL/CharBacklight 없음 — 적용 0"; return; }
WLCharacterBacklightDriver.Ensure();
WLCharacterBacklightDriver.Register(root); // 나중에 붙는 무기·장비 렌더러까지 덮도록 2 s 마다 다시 맞춘다(실측: 던전 PC 의 무기 2개는 Awake 뒤에 붙는다)
int applied = 0, skin = 0, multi = 0;
s_buf.Clear();
root.GetComponentsInChildren<Renderer>(true, s_buf);
for (int i = 0; i < s_buf.Count; i++)
{
var r = s_buf[i];
if (r == null || r is ParticleSystemRenderer || r is TrailRenderer || r is LineRenderer) continue;
var mats = r.sharedMaterials;
if (mats == null || mats.Length == 0) continue;
bool match = false, isSkin = false;
s_tmp.Clear();
for (int m = 0; m < mats.Length; m++)
{
var mat = mats[m];
if (IsOurs(mat)) continue; // 이미 붙은 우리 재질은 걷어 내고 다시 붙인다(멱등)
s_tmp.Add(mat);
if (mat == null || mat.shader == null) continue;
if (string.IsNullOrEmpty(cfg.shaderNameFilter) || mat.shader.name == cfg.shaderNameFilter) match = true;
if (IsSkinName(cfg, mat.name)) isSkin = true;
}
if (!match)
{
if (s_tmp.Count != mats.Length) r.sharedMaterials = s_tmp.ToArray(); // 대상이 아닌데 붙어 있었으면 뗀다
continue;
}
var want = isSkin ? s_rimSkin : s_rim;
bool same = mats.Length == s_tmp.Count + 1 && mats[mats.Length - 1] == want;
if (!same)
{
s_tmp.Add(want);
r.sharedMaterials = s_tmp.ToArray();
}
BrightenBase(cfg, r, s_tmp.Count > 0 ? s_tmp[0] : null, cfg.baseBrighten != 0);
applied++; if (isSkin) skin++;
if (SubMeshCount(r) > 1) multi++; // 서브메시가 여럿이면 마지막 서브메시만 덧그려진다(진단용)
}
AppliedRenderers = applied; SkinRenderers = skin; MultiSubmeshRenderers = multi;
LastLog = "역광 패스 " + applied + "개(피부 " + skin + " · 다중 서브메시 " + multi + ") @" + root.name;
if (cfg.verboseLog != 0) Debug.Log("[WL CharBacklight] " + LastLog);
}
/// <summary>root 아래에서 우리 재질을 전부 뗀다.</summary>
public static void Remove(GameObject root)
{
if (root == null) return;
s_buf.Clear();
root.GetComponentsInChildren<Renderer>(true, s_buf);
for (int i = 0; i < s_buf.Count; i++)
{
var r = s_buf[i]; if (r == null) continue;
var mats = r.sharedMaterials; if (mats == null) continue;
s_tmp.Clear();
for (int m = 0; m < mats.Length; m++) if (!IsOurs(mats[m])) s_tmp.Add(mats[m]);
if (s_tmp.Count != mats.Length)
{
r.sharedMaterials = s_tmp.ToArray();
BrightenBase(null, r, s_tmp.Count > 0 ? s_tmp[0] : null, false); // 우리가 붙였던 렌더러만 본체 값도 되돌린다
}
}
LastLog = "역광 패스 제거 @" + root.name;
}
// ─────────────────────────────────────────────────────────────────
// 본체 밝히기 — 2026-09-17 PD 「캐릭터가 어둡게 보인다 · 원한 건 더 밝고 화사하게」
// 본체(Toon) 재질의 그늘 최소 밝기·그늘 색·구름 그늘 세기를 **렌더러별 프로퍼티 블록**으로 덮는다(재질 에셋·공유 재질 0줄).
// 끌 때는 같은 속성을 재질의 원래 값으로 되써서 되돌린다(다른 시스템이 쓰는 블록 속성은 건드리지 않는다).
// ─────────────────────────────────────────────────────────────────
static MaterialPropertyBlock s_mpb;
static readonly int kMinDark = Shader.PropertyToID("_MinimumDarkness");
static readonly int kShadowCol = Shader.PropertyToID("_ShadowDiffuseColor");
static readonly int kCloud = Shader.PropertyToID("_Cloud_Strength");
public static int BrightenedRenderers;
static void BrightenBase(WLCharacterBacklightSettings cfg, Renderer r, Material baseMat, bool on)
{
if (r == null || baseMat == null) return;
if (!baseMat.HasProperty(kMinDark) || !baseMat.HasProperty(kShadowCol)) return;
if (s_mpb == null) s_mpb = new MaterialPropertyBlock();
r.GetPropertyBlock(s_mpb);
if (on && cfg != null)
{
s_mpb.SetFloat(kMinDark, cfg.baseMinimumDarkness);
s_mpb.SetColor(kShadowCol, cfg.baseShadowColor);
if (cfg.baseCloudStrength >= 0f && baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, cfg.baseCloudStrength);
BrightenedRenderers++;
}
else
{
s_mpb.SetFloat(kMinDark, baseMat.GetFloat(kMinDark));
s_mpb.SetColor(kShadowCol, baseMat.GetColor(kShadowCol));
if (baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, baseMat.GetFloat(kCloud));
}
r.SetPropertyBlock(s_mpb);
}
/// <summary>설정 값을 런타임 재질 2장에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출).</summary>
public static void RefreshValues()
{
var cfg = WLCharacterBacklightSettings.Instance;
if (cfg == null) return;
if (s_rim != null) Push(cfg, s_rim, false);
if (s_rimSkin != null) Push(cfg, s_rimSkin, true);
}
static bool EnsureMaterials(WLCharacterBacklightSettings cfg)
{
if (s_rim != null && s_rimSkin != null) return true;
var sh = Shader.Find("WL/CharBacklight");
if (sh == null) return false;
if (s_rim == null) s_rim = new Material(sh) { name = "WL_CharBacklight(runtime)" };
if (s_rimSkin == null) s_rimSkin = new Material(sh) { name = "WL_CharBacklight_Skin(runtime)" };
Push(cfg, s_rim, false);
Push(cfg, s_rimSkin, true);
return true;
}
static void Push(WLCharacterBacklightSettings c, Material m, bool skin)
{
m.SetColor("_RimColor", c.rimColor);
m.SetFloat("_RimStrength", c.rimStrength);
m.SetFloat("_RimThreshold", c.rimThreshold);
m.SetFloat("_RimThresholdFar", c.rimThresholdFar);
m.SetFloat("_RimNearDist", c.rimNearDistance);
m.SetFloat("_RimFarDist", c.rimFarDistance);
m.SetFloat("_RimSoftness", c.rimSoftness);
m.SetFloat("_RimDirectional", c.rimDirectional);
m.SetFloat("_RimOpacity", c.rimOpacity);
// 깊이 림은 URP 에셋이 카메라 깊이 텍스처를 만들 때만(꺼져 있으면 샘플 값이 쓰레기 → 프레넬만)
var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
bool depthOk = c.rimUseDepth != 0 && urp != null && urp.supportsCameraDepthTexture;
m.SetFloat("_UseDepthRim", depthOk ? 1f : 0f);
m.SetFloat("_RimWidth", c.rimWidth);
m.SetFloat("_RimDepthThreshold", c.rimDepthThreshold);
m.SetFloat("_FresnelRimNear", c.rimFresnelNear);
m.SetFloat("_FresnelRimFar", c.rimFresnelFar);
m.SetColor("_WrapColor", c.wrapColor);
m.SetFloat("_WrapStrength", c.wrapStrength);
m.SetFloat("_WrapPower", c.wrapPower);
m.SetColor("_SssColor", c.sssColor);
m.SetFloat("_SssStrength", skin ? c.sssStrength : 0f);
m.SetFloat("_SssPower", c.sssPower);
m.SetColor("_FaceColor", c.faceColor);
m.SetFloat("_FaceStrength", c.faceEnabled != 0 ? c.faceStrength : 0f);
m.SetFloat("_FaceWrap", c.faceWrap);
}
static bool IsSkinName(WLCharacterBacklightSettings c, string matName)
{
if (c.skinMaterialHints == null || string.IsNullOrEmpty(matName)) return false;
for (int i = 0; i < c.skinMaterialHints.Length; i++)
{
var h = c.skinMaterialHints[i];
if (!string.IsNullOrEmpty(h) && matName.IndexOf(h, System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
}
static int SubMeshCount(Renderer r)
{
var smr = r as SkinnedMeshRenderer;
if (smr != null) return smr.sharedMesh != null ? smr.sharedMesh.subMeshCount : 0;
var mf = r.GetComponent<MeshFilter>();
return mf != null && mf.sharedMesh != null ? mf.sharedMesh.subMeshCount : 0;
}
}
/// <summary>가상 역광 방향(전역 벡터) 갱신 + 번짐(Bloom) 스코프 — 숨은 오브젝트 1개.</summary>
public sealed class WLCharacterBacklightDriver : MonoBehaviour
{
static WLCharacterBacklightDriver s_inst;
static readonly int kDirId = Shader.PropertyToID("_WLBacklightDir");
// ── 진단
public static bool BloomHeld;
public static string BloomLog = "";
public static Vector3 LastDir;
// 번짐 스코프가 바꾼 것(되돌리기용)
GameObject _volumeGo; VolumeProfile _profile; Bloom _bloom;
Camera _cam; bool _camPostPrev, _camHdrPrev;
float _nextScopeCheck;
// 다시 맞출 캐릭터 루트(죽은 참조는 훑을 때 걷어 낸다)
static readonly List<GameObject> s_roots = new List<GameObject>(8);
float _nextRescan;
public static int Rescans;
public static void Register(GameObject root)
{
if (root == null || s_roots.Contains(root)) return;
s_roots.Add(root);
}
public static void Ensure()
{
if (s_inst != null) return;
// 🔴 HideFlags.DontSave 를 쓰지 않는다 — Play 중 만든 DontSave 오브젝트는 Play 종료 뒤에도 에디터에 남는다(815c 장벽 고아 14개 실증).
if (!Application.isPlaying) return;
var go = new GameObject("~WL_CharBacklightDriver");
DontDestroyOnLoad(go);
s_inst = go.AddComponent<WLCharacterBacklightDriver>();
}
void LateUpdate()
{
var cfg = WLCharacterBacklightSettings.Instance;
var cam = Camera.main;
if (cfg != null && cam != null)
{
// 카메라 기준 「뒤(카메라가 보는 쪽의 수평 성분) + 위 + 옆」 = 표면에서 가상 역광을 향하는 방향
var f = cam.transform.forward; f.y = 0f;
if (f.sqrMagnitude < 1e-4f) f = cam.transform.up; // 수직 내려다보기 예외
f.Normalize();
var d = f + Vector3.up * cfg.backlightUp + cam.transform.right * cfg.backlightSide;
if (d.sqrMagnitude < 1e-6f) d = Vector3.up;
d.Normalize();
LastDir = d;
Shader.SetGlobalVector(kDirId, new Vector4(d.x, d.y, d.z, 1f));
UpdateFaceLight(cfg, cam, f);
}
if (Time.unscaledTime >= _nextScopeCheck)
{
_nextScopeCheck = Time.unscaledTime + 0.5f;
UpdateBloomScope(cfg, cam);
}
if (Time.unscaledTime >= _nextRescan)
{
_nextRescan = Time.unscaledTime + 2f;
for (int i = s_roots.Count - 1; i >= 0; i--)
{
var root = s_roots[i];
if (root == null) { s_roots.RemoveAt(i); continue; }
if (!root.activeInHierarchy) continue;
WLCharacterBacklight.Apply(root); // 멱등 — 바뀐 게 없으면 재질 배열 대입 0
Rescans++;
}
}
}
// ── 얼굴 포인트 라이트 — 카메라에 가장 가까운 등록 캐릭터의 얼굴(머리) 렌더러 경계 중심을 전역 벡터로 넘긴다
static readonly int kFaceCenterId = Shader.PropertyToID("_WLFaceCenter");
static readonly int kFaceDirId = Shader.PropertyToID("_WLFaceLightDir");
static readonly Dictionary<GameObject, Renderer> s_faceRend = new Dictionary<GameObject, Renderer>();
public static Vector3 LastFaceCenter; public static float LastFaceRadius; public static string LastFaceLog = "";
void UpdateFaceLight(WLCharacterBacklightSettings cfg, Camera cam, Vector3 camForwardFlat)
{
if (cfg.enabled_ == 0 || cfg.faceEnabled == 0 || cfg.faceStrength <= 0f)
{
Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; return;
}
// 대상 = 카메라에 가장 가까운 활성 캐릭터(지금은 PC 하나)
Renderer best = null; float bestD = float.MaxValue;
for (int i = 0; i < s_roots.Count; i++)
{
var root = s_roots[i];
if (root == null || !root.activeInHierarchy) continue;
Renderer fr;
if (!s_faceRend.TryGetValue(root, out fr) || fr == null) { fr = FindFaceRenderer(cfg, root); s_faceRend[root] = fr; }
if (fr == null || !fr.enabled || !fr.gameObject.activeInHierarchy) continue;
float dsq = (fr.bounds.center - cam.transform.position).sqrMagnitude;
if (dsq < bestD) { bestD = dsq; best = fr; }
}
if (best == null) { Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; LastFaceLog = "얼굴 렌더러 없음"; return; }
var b = best.bounds;
float radius = Mathf.Max(0.05f, b.extents.magnitude * cfg.faceRadiusScale);
LastFaceCenter = b.center; LastFaceRadius = radius; LastFaceLog = best.name + " r=" + radius.ToString("F2");
Shader.SetGlobalVector(kFaceCenterId, new Vector4(b.center.x, b.center.y, b.center.z, radius));
// 빛 방향(표면 → 빛) = 카메라 쪽(앞) + 위 + 옆 — 역광의 반대편에서 얼굴을 받쳐 주는 빛
var l = -camForwardFlat + Vector3.up * cfg.faceLightUp + cam.transform.right * cfg.faceLightSide;
if (l.sqrMagnitude < 1e-6f) l = Vector3.up;
l.Normalize();
Shader.SetGlobalVector(kFaceDirId, new Vector4(l.x, l.y, l.z, 0f));
}
static Renderer FindFaceRenderer(WLCharacterBacklightSettings cfg, GameObject root)
{
if (cfg.faceRendererHints == null) return null;
var rends = root.GetComponentsInChildren<Renderer>(true);
for (int h = 0; h < cfg.faceRendererHints.Length; h++)
{
var hint = cfg.faceRendererHints[h];
if (string.IsNullOrEmpty(hint)) continue;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null || r is ParticleSystemRenderer) continue;
if (r.name.IndexOf(hint, System.StringComparison.OrdinalIgnoreCase) >= 0) return r;
}
}
return null;
}
void UpdateBloomScope(WLCharacterBacklightSettings cfg, Camera cam)
{
bool want = cfg != null && cfg.enabled_ != 0 && cfg.bloomEnabled != 0 && cam != null && InScope(cfg);
if (!want) { ReleaseBloom(); return; }
if (_volumeGo == null)
{
_volumeGo = new GameObject("~WL_CharBacklight_BloomVolume");
DontDestroyOnLoad(_volumeGo);
_profile = ScriptableObject.CreateInstance<VolumeProfile>();
_bloom = _profile.Add<Bloom>(true);
var v = _volumeGo.AddComponent<Volume>();
v.isGlobal = true; v.priority = 50f; v.weight = 1f;
v.sharedProfile = _profile;
}
_bloom.active = true;
_bloom.threshold.Override(cfg.bloomThreshold);
_bloom.intensity.Override(cfg.bloomIntensity);
_bloom.scatter.Override(cfg.bloomScatter);
_bloom.downscale.Override(cfg.bloomQuarter != 0 ? BloomDownscaleMode.Quarter : BloomDownscaleMode.Half);
_bloom.maxIterations.Override(Mathf.Clamp(cfg.bloomMaxIterations, 2, 8));
if (_cam != cam)
{
RestoreCamera();
var ad = cam.GetComponent<UniversalAdditionalCameraData>();
if (ad != null)
{
_cam = cam; _camPostPrev = ad.renderPostProcessing; _camHdrPrev = cam.allowHDR;
ad.renderPostProcessing = true;
if (cfg.bloomHdr != 0) cam.allowHDR = true;
}
}
BloomHeld = true;
BloomLog = "번짐 켬 — 카메라 " + (_cam != null ? _cam.name : "없음") + " · 임계 " + cfg.bloomThreshold.ToString("F2") + " · 세기 " + cfg.bloomIntensity.ToString("F2");
}
static bool InScope(WLCharacterBacklightSettings cfg)
{
if (cfg.bloomScenes == null || cfg.bloomScenes.Length == 0) return false;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var s = SceneManager.GetSceneAt(i);
if (!s.isLoaded) continue;
for (int k = 0; k < cfg.bloomScenes.Length; k++)
if (!string.IsNullOrEmpty(cfg.bloomScenes[k]) && s.name == cfg.bloomScenes[k]) return true;
}
return false;
}
void RestoreCamera()
{
if (_cam != null)
{
var ad = _cam.GetComponent<UniversalAdditionalCameraData>();
if (ad != null) ad.renderPostProcessing = _camPostPrev;
_cam.allowHDR = _camHdrPrev;
}
_cam = null;
}
void ReleaseBloom()
{
if (!BloomHeld && _volumeGo == null) return;
RestoreCamera();
if (_volumeGo != null) Destroy(_volumeGo);
if (_profile != null) Destroy(_profile);
_volumeGo = null; _profile = null; _bloom = null;
BloomHeld = false;
BloomLog = "번짐 끔(스코프 밖 또는 설정 off)";
}
void OnDestroy()
{
ReleaseBloom();
if (s_inst == this) s_inst = null;
}
}
}