331 lines
16 KiB
C#
331 lines
16 KiB
C#
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// 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();
|
||
|
|
}
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
LastLog = "역광 패스 제거 @" + root.name;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
|
||
|
|
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++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|