diff --git a/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset b/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset new file mode 100644 index 000000000..de89d26c3 --- /dev/null +++ b/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset @@ -0,0 +1,50 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9c17b0a1c2d3e4f5a6b7c8d9e0f1a2b3, type: 3} + m_Name: WLCharacterBacklightSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Look.Character.WLCharacterBacklightSettings + enabled_: 1 + verboseLog: 0 + shaderNameFilter: Shader Graphs/Toon + skinMaterialHints: + - Skin + rimColor: {r: 0.28, g: 0.62, b: 1, a: 1} + rimStrength: 1.45 + rimUseDepth: 1 + rimWidth: 0.0045 + rimDepthThreshold: 0.12 + rimFresnelNear: 0.6 + rimFresnelFar: 0 + rimThreshold: 0.52 + rimThresholdFar: 0.36 + rimNearDistance: 3 + rimFarDistance: 12 + rimSoftness: 0.05 + rimDirectional: 0.85 + rimOpacity: 0.85 + backlightUp: 0.7 + backlightSide: 0.8 + wrapColor: {r: 0.75, g: 0.85, b: 1, a: 1} + wrapStrength: 0.12 + wrapPower: 1.6 + sssColor: {r: 1, g: 0.32, b: 0.22, a: 1} + sssStrength: 1.5 + sssPower: 1.7 + bloomEnabled: 1 + bloomScenes: + - Level01 + bloomThreshold: 1.05 + bloomIntensity: 0.55 + bloomScatter: 0.55 + bloomHdr: 1 + bloomQuarter: 1 + bloomMaxIterations: 4 diff --git a/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset.meta b/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset.meta new file mode 100644 index 000000000..c0a94fbb4 --- /dev/null +++ b/Assets/WL/Look/Character/Resources/WL/WLCharacterBacklightSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e2a36e53585fe204f9513de88d1792d9 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader new file mode 100644 index 000000000..3002007b6 --- /dev/null +++ b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader @@ -0,0 +1,183 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCharBacklight.shader — 캐릭터 역광(림) + 라이트 랩 + 피부 투과(SSS 흉내) 덧그리기 패스 +// +// 2026-09-17 PD 「우리 게임의 캐릭터가 조금 더 돋보이게 · 역광 하이라이트로 입체감·배경 분리」 +// + 팁 「메인 조명이 따뜻하면 역광은 차가운 색 · 피부는 역광에 붉게 비침(SSS) · Bloom 으로 번짐」. +// +// ■ 왜 별도 패스인가(814t `WL/RimAdd` 와 같은 이유) +// 캐릭터 본체는 원본 `Shader Graphs/Toon`(Assets/3DPixelArtEnvironment · 원본 0줄)이고 Fresnel 입력이 없다. +// → 같은 메시를 머티리얼 배열 뒤에 덧붙여 한 번 더 그린다(서브메시 1개 = 전체가 다시 그려진다). +// 본체 재질·그래프·프리팹 0줄 · 런타임에 켜고 끈다 · 모바일 비용 = 캐릭터 부위당 드로우 +1(화면 점유 작음) + 림 픽셀의 깊이 샘플 1회. +// +// ■ 무엇을 그리나 +// ① 역광 림(차가운 색 · 또렷한 띠) +// · 화면 공간 깊이 림(기본): 가상 역광 쪽으로 몇 픽셀 옮긴 자리의 깊이가 훨씬 멀면 = 「역광을 받는 실루엣 가장자리」. +// → 굵기가 화면 픽셀로 일정(멀리서도 1 px 로 사라지지 않는다) · 머리카락 안쪽 가닥에는 안 생긴다(실측: 프레넬은 가닥마다 줄이 생겼다). +// · 프레넬 림(보조): 가까울 때만 섞어 띠에 두께감을 준다. 깊이 텍스처가 없으면(_UseDepthRim 0) 이것만 쓴다. +// ② 라이트 랩 = 역광 쪽의 넓고 옅은 Fresnel(빛이 얼굴 안쪽으로 살짝 스며든 느낌) — 가산 +// ③ 피부 투과 = 피부 재질에만 림 안쪽에 따뜻한 붉은 띠(귀·볼 가장자리) — 가산 +// 가상 역광 방향은 전역 벡터 `_WLBacklightDir`(C# 이 카메라에서 매 프레임 계산)로 받는다 → 카메라가 돌아도 항상 같은 쪽에 맺힌다. +// ───────────────────────────────────────────────────────────────────────────── +Shader "WL/CharBacklight" +{ + Properties + { + [HDR] _RimColor ("Rim Color (차가운 역광)", Color) = (0.28, 0.62, 1.0, 1) + _RimStrength ("Rim Strength", Float) = 1.45 + _RimOpacity ("Rim Opacity (1 = 바탕을 덮어 림 색 그대로 · 0 = 순수 가산 → 밝은 바탕 위에선 흰색이 된다)", Range(0, 1)) = 0.85 + + [Header(Screen space depth rim)] + _UseDepthRim ("Use Depth Rim (1 = 깊이 텍스처 있음)", Float) = 1 + _RimWidth ("Rim Width (화면 짧은 변 대비 비율)", Range(0, 0.02)) = 0.0045 + _RimDepthThreshold ("Depth Threshold (m · 이보다 멀면 실루엣)", Float) = 0.12 + + [Header(Fresnel rim)] + _RimThreshold ("Fresnel Threshold — 가까울 때(클수록 가는 띠)", Range(0, 1)) = 0.52 + _RimThresholdFar ("Fresnel Threshold — 멀 때", Range(0, 1)) = 0.36 + _RimNearDist ("Near Distance (m)", Float) = 3 + _RimFarDist ("Far Distance (m)", Float) = 12 + _RimSoftness ("Fresnel Softness", Range(0.001, 0.5)) = 0.05 + _RimDirectional ("Directionality (0 = 둘레 전부 · 1 = 역광 쪽만)", Range(0, 1)) = 0.85 + _FresnelRimNear ("Fresnel Rim Weight — 가까울 때(깊이 림과 섞는 양)", Range(0, 1)) = 0.6 + _FresnelRimFar ("Fresnel Rim Weight — 멀 때", Range(0, 1)) = 0.0 + + [Header(Light wrap and SSS)] + [HDR] _WrapColor ("Light Wrap Color", Color) = (0.75, 0.85, 1.0, 1) + _WrapStrength ("Light Wrap Strength", Float) = 0.12 + _WrapPower ("Light Wrap Power", Float) = 1.6 + [HDR] _SssColor ("SSS Color (따뜻한 붉은빛)", Color) = (1.0, 0.32, 0.22, 1) + _SssStrength ("SSS Strength (피부만 > 0)", Float) = 0 + _SssPower ("SSS Power", Float) = 1.7 + } + + SubShader + { + Tags { "RenderPipeline" = "UniversalPipeline" "RenderType" = "Transparent" "Queue" = "Transparent" } + + Pass + { + Name "WLCharBacklight" + Tags { "LightMode" = "SRPDefaultUnlit" } + + Cull Back + ZWrite Off + ZTest LEqual + Offset -1, -1 + // 미리 곱한 알파: 결과 = src.rgb + dst × (1 − src.a). 림은 a 로 바탕을 덮고(차가운 색이 그대로 보인다 — 실측: 순수 가산은 노란 머리·살색 위에서 + // 흰 테가 됐다), 라이트 랩·피부 투과는 a = 0 이라 순수 가산으로 얹힌다. + Blend One OneMinusSrcAlpha + + HLSLPROGRAM + #pragma vertex vert + #pragma fragment frag + #pragma multi_compile_instancing + #pragma target 3.0 + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" + + CBUFFER_START(UnityPerMaterial) + float4 _RimColor; + float _RimStrength; + float _RimOpacity; + float _UseDepthRim; + float _RimWidth; + float _RimDepthThreshold; + float _RimThreshold; + float _RimThresholdFar; + float _RimNearDist; + float _RimFarDist; + float _RimSoftness; + float _RimDirectional; + float _FresnelRimNear; + float _FresnelRimFar; + float4 _WrapColor; + float _WrapStrength; + float _WrapPower; + float4 _SssColor; + float _SssStrength; + float _SssPower; + CBUFFER_END + + // xyz = 표면에서 가상 역광 쪽을 가리키는 월드 방향(정규화) · w = 1 이면 유효(0 = 아직 안 들어옴 → 위쪽) + float4 _WLBacklightDir; + + struct Attributes + { + float4 positionOS : POSITION; + float3 normalOS : NORMAL; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + float3 normalWS : TEXCOORD0; + float3 positionWS : TEXCOORD1; + UNITY_VERTEX_INPUT_INSTANCE_ID + UNITY_VERTEX_OUTPUT_STEREO + }; + + Varyings vert(Attributes input) + { + Varyings o = (Varyings)0; + UNITY_SETUP_INSTANCE_ID(input); + UNITY_TRANSFER_INSTANCE_ID(input, o); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); + o.positionWS = TransformObjectToWorld(input.positionOS.xyz); + o.positionCS = TransformWorldToHClip(o.positionWS); + o.normalWS = TransformObjectToWorldNormal(input.normalOS); + return o; + } + + half4 frag(Varyings input) : SV_Target + { + UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); + float3 n = normalize(input.normalWS); + float3 v = normalize(GetWorldSpaceViewDir(input.positionWS)); + float3 b = _WLBacklightDir.w > 0.5 ? normalize(_WLBacklightDir.xyz) : float3(0.0, 1.0, 0.0); + + float fres = 1.0 - saturate(dot(n, v)); // 가장자리 1 · 정면 0 + float toward = saturate(dot(n, b) * 0.5 + 0.5); // 역광 쪽을 보는 정도(반-램버트) + float dirMask = lerp(1.0, smoothstep(0.35, 0.85, toward), saturate(_RimDirectional)); + + float dist = distance(_WorldSpaceCameraPos, input.positionWS); + float farT = saturate((dist - _RimNearDist) / max(_RimFarDist - _RimNearDist, 0.01)); + + // ①-a 프레넬 림(보조) + float thr = lerp(_RimThreshold, _RimThresholdFar, farT); + float rimF = smoothstep(thr - _RimSoftness, thr + _RimSoftness, fres * dirMask); + + // ①-b 화면 공간 깊이 림 — 역광 쪽으로 _RimWidth 만큼 옮긴 자리가 훨씬 멀면 실루엣 가장자리 + float rimD = 0.0; + if (_UseDepthRim > 0.5) + { + float2 uv = GetNormalizedScreenSpaceUV(input.positionCS); + float3 bVS = TransformWorldToViewDir(b); + float2 dirSS = dot(bVS.xy, bVS.xy) > 1e-5 ? normalize(bVS.xy) : float2(0.7071, 0.7071); + float minSide = min(_ScaledScreenParams.x, _ScaledScreenParams.y); + float2 offs = dirSS * (_RimWidth * minSide) / _ScaledScreenParams.xy; + float dHere = LinearEyeDepth(input.positionCS.z, _ZBufferParams); + float dOff = LinearEyeDepth(SampleSceneDepth(uv + offs), _ZBufferParams); + rimD = step(_RimDepthThreshold, dOff - dHere); + // 역광을 등진 면(카메라 쪽 아래를 보는 면)은 뺀다 — 발밑·턱 아래에 줄이 생기지 않게 + rimD *= smoothstep(0.15, 0.45, toward); + } + + float wF = lerp(_FresnelRimNear, _FresnelRimFar, farT); + float rim = _UseDepthRim > 0.5 ? saturate(max(rimD, rimF * wF)) : rimF; + + // ② 라이트 랩(넓고 옅게) + float wrap = pow(fres, max(_WrapPower, 0.01)) * toward * _WrapStrength; + // ③ 피부 투과 — 림 안쪽의 따뜻한 띠(림이 덮는 곳은 줄인다 → 바깥 차가움 · 안쪽 따뜻함의 두 톤) + float sss = pow(fres, max(_SssPower, 0.01)) * toward * _SssStrength * (1.0 - rim * 0.65); + + float3 col = _RimColor.rgb * (rim * _RimStrength) + _WrapColor.rgb * wrap + _SssColor.rgb * sss; + return half4(col, rim * saturate(_RimOpacity)); + } + ENDHLSL + } + } + + Fallback Off +} diff --git a/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader.meta b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader.meta new file mode 100644 index 000000000..a6e8eed5a --- /dev/null +++ b/Assets/WL/Look/Character/Shaders/WLCharBacklight.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 21b48ce884e558949a29aadfe608393a +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Character/WLCharacterBacklight.cs b/Assets/WL/Look/Character/WLCharacterBacklight.cs new file mode 100644 index 000000000..c0269239f --- /dev/null +++ b/Assets/WL/Look/Character/WLCharacterBacklight.cs @@ -0,0 +1,330 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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 s_buf = new List(16); + static readonly List s_tmp = new List(4); + + public static bool IsOurs(Material m) { return m != null && (m == s_rim || m == s_rimSkin); } + + /// root 아래 캐릭터 렌더러에 역광 패스를 덧붙인다(설정이 꺼져 있으면 떼어낸다). + 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(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); + } + + /// root 아래에서 우리 재질을 전부 뗀다. + public static void Remove(GameObject root) + { + if (root == null) return; + s_buf.Clear(); + root.GetComponentsInChildren(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; + } + + /// 설정 값을 런타임 재질 2장에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출). + 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(); + return mf != null && mf.sharedMesh != null ? mf.sharedMesh.subMeshCount : 0; + } + } + + /// 가상 역광 방향(전역 벡터) 갱신 + 번짐(Bloom) 스코프 — 숨은 오브젝트 1개. + 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 s_roots = new List(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(); + } + + 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(); + _bloom = _profile.Add(true); + var v = _volumeGo.AddComponent(); + 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(); + 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(); + 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; + } + } +} diff --git a/Assets/WL/Look/Character/WLCharacterBacklight.cs.meta b/Assets/WL/Look/Character/WLCharacterBacklight.cs.meta new file mode 100644 index 000000000..e3cc5d4b5 --- /dev/null +++ b/Assets/WL/Look/Character/WLCharacterBacklight.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0f2bd90c4ecc02418521d44fc2cc372 \ No newline at end of file diff --git a/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs b/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs new file mode 100644 index 000000000..54cc3dbf9 --- /dev/null +++ b/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs @@ -0,0 +1,111 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCharacterBacklightSettings.cs — 캐릭터 역광(림·라이트 랩·피부 투과) + 번짐(Bloom) 값 (C45 · 코드 상수 금지) +// +// 2026-09-17 PD 「캐릭터가 조금 더 돋보이게」 + 팁(차가운 역광 · SSS · Bloom). +// enabled_ = 0 → 아무 것도 덧붙이지 않는다(캐릭터 재질 배열 원래대로) · bloomEnabled = 0 → 카메라/볼륨 0 변경. +// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.Character +{ + public class WLCharacterBacklightSettings : ScriptableObject + { + [Header("스위치")] + [Tooltip("0 = 역광 패스 전체 off(재질 배열 원래대로).")] + public int enabled_ = 1; + [Tooltip("1 이면 적용 내역을 로그로 남긴다.")] + public int verboseLog = 0; + + [Header("대상")] + [Tooltip("이 셰이더 이름의 재질을 가진 렌더러에만 덧붙인다(얼굴 Unlit/Transparent · 파티클 제외). 비우면 파티클 외 전부.")] + public string shaderNameFilter = "Shader Graphs/Toon"; + [Tooltip("재질 이름에 이 글자가 들어 있으면 피부로 보고 피부 투과(SSS)를 켠 재질을 물린다.")] + public string[] skinMaterialHints = { "Skin" }; + + [Header("① 역광 림 — 메인 조명(따뜻함)과 반대인 차가운 색")] + [ColorUsage(false, true)] public Color rimColor = new Color(0.28f, 0.62f, 1f, 1f); + [Tooltip("림 밝기 배수(1 초과 = HDR · Bloom 이 집어낸다).")] + public float rimStrength = 1.45f; + [Header("①-b 화면 공간 깊이 림(기본) — 실루엣 바깥쪽에만 · 화면 픽셀 굵기 일정")] + [Tooltip("1 = 깊이 림 사용(URP 에셋의 Depth Texture 가 꺼져 있으면 자동으로 프레넬만 쓴다). 실측: 프레넬만 쓰면 머리카락 안쪽 가닥마다 줄이 생긴다.")] + public int rimUseDepth = 1; + [Tooltip("띠 굵기 = 화면 짧은 변 × 이 값(0.0045 → 1080 기준 ≈ 5 px).")] + [Range(0f, 0.02f)] public float rimWidth = 0.0045f; + [Tooltip("역광 쪽 이웃 픽셀이 이만큼(m) 더 멀면 실루엣으로 본다(작으면 머리카락 가닥에도 생긴다).")] + public float rimDepthThreshold = 0.12f; + [Tooltip("깊이 림에 섞는 프레넬 림의 양 — 가까울 때(두께감).")] + [Range(0f, 1f)] public float rimFresnelNear = 0.6f; + [Tooltip("깊이 림에 섞는 프레넬 림의 양 — 멀 때(0 = 깊이 림만 · 가닥 줄 없음).")] + [Range(0f, 1f)] public float rimFresnelFar = 0f; + + [Header("①-a 프레넬 림(보조 · 깊이 텍스처가 없을 때는 이것만)")] + [Tooltip("가까울 때(rimNearDistance 이하)의 띠 굵기 — 클수록 가는 띠(0.4 = 굵게 · 0.7 = 가늘게).")] + [Range(0f, 1f)] public float rimThreshold = 0.52f; + [Tooltip("멀 때(rimFarDistance 이상)의 띠 굵기 — 작게 줘서 굵게. 게임 카메라(섬 12 m · 던전 11 m)에서 캐릭터가 ~70 px 라 가는 띠는 1 px 로 사라진다(실측).")] + [Range(0f, 1f)] public float rimThresholdFar = 0.36f; + [Tooltip("이 거리(m) 이하 = 가까움.")] + public float rimNearDistance = 3f; + [Tooltip("이 거리(m) 이상 = 멂.")] + public float rimFarDistance = 12f; + [Tooltip("띠 가장자리 부드러움(작을수록 셀 느낌).")] + [Range(0.001f, 0.5f)] public float rimSoftness = 0.05f; + [Tooltip("0 = 둘레 전부 · 1 = 가상 역광 쪽 윤곽만.")] + [Range(0f, 1f)] public float rimDirectional = 0.85f; + [Tooltip("림이 바탕을 덮는 정도(1 = 림 색 그대로 · 0 = 순수 가산). 실측: 순수 가산은 노란 머리·살색 위에서 흰 테가 돼 차가운 색이 안 보였다.")] + [Range(0f, 1f)] public float rimOpacity = 0.85f; + + [Header("가상 역광 위치(카메라 기준) — 캐릭터 뒤 + 위 + 옆")] + [Tooltip("위쪽 성분(클수록 머리·어깨 윗선에 맺힌다).")] + public float backlightUp = 0.7f; + [Tooltip("옆 성분(+ = 화면 오른쪽 · − = 왼쪽). 메인 조명이 왼쪽 앞에서 오므로 오른쪽 뒤가 자연스럽다.")] + public float backlightSide = 0.8f; + + [Header("② 라이트 랩 — 빛이 안쪽으로 스며드는 옅은 번짐")] + [ColorUsage(false, true)] public Color wrapColor = new Color(0.75f, 0.85f, 1f, 1f); + public float wrapStrength = 0.12f; + public float wrapPower = 1.6f; + + [Header("③ 피부 투과(SSS 흉내) — 피부 재질에만")] + [ColorUsage(false, true)] public Color sssColor = new Color(1f, 0.32f, 0.22f, 1f); + public float sssStrength = 1.5f; + public float sssPower = 1.7f; + + [Header("④ 번짐(Bloom) — 역광이 화사하게 번지게(전체 화면 패스 · 모바일 비용 있음)")] + [Tooltip("0 = 카메라·볼륨을 건드리지 않는다.")] + public int bloomEnabled = 1; + [Tooltip("이 씬이 떠 있을 때만 켠다(전투 맵의 기존 후처리는 그대로).")] + public string[] bloomScenes = { "Level01" }; + [Tooltip("이 밝기 이상만 번진다(HDR 1 초과 = 림·반짝임만).")] + public float bloomThreshold = 1.05f; + public float bloomIntensity = 0.55f; + [Range(0f, 1f)] public float bloomScatter = 0.55f; + [Tooltip("1 = 카메라 HDR 을 켠다(림만 골라 번지게 · 끄면 흰 거품·밝은 모래도 같이 번진다).")] + public int bloomHdr = 1; + [Tooltip("1 = 1/4 해상도에서 시작(모바일 권장).")] + public int bloomQuarter = 1; + [Tooltip("블러 단계 수(작을수록 쌈).")] + public int bloomMaxIterations = 4; + + // ───────────────────────────────────────── 싱글턴(Resources) + public const string ResourcePath = "WL/WLCharacterBacklightSettings"; + static WLCharacterBacklightSettings s_inst; + static bool s_tried; + + public static WLCharacterBacklightSettings Instance + { + get + { + if (s_inst == null && !s_tried) + { + s_tried = true; + s_inst = Resources.Load(ResourcePath); + } + return s_inst; + } + } + + public static void Invalidate() { s_inst = null; s_tried = false; } + } +} diff --git a/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs.meta b/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs.meta new file mode 100644 index 000000000..60451ce96 --- /dev/null +++ b/Assets/WL/Look/Character/WLCharacterBacklightSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9c17b0a1c2d3e4f5a6b7c8d9e0f1a2b3 diff --git a/Assets/WL/Look/Character/WLCharacterLook.cs b/Assets/WL/Look/Character/WLCharacterLook.cs index 42c7587d5..6eba16a66 100644 --- a/Assets/WL/Look/Character/WLCharacterLook.cs +++ b/Assets/WL/Look/Character/WLCharacterLook.cs @@ -43,6 +43,8 @@ namespace WL.Look.Character ApplyMode(root, WLCharacterLookSettings.ActiveMode, cfg); // WL-814x — 팔레트 아틀라스 UV. 그쪽 SO 의 enabled_ = 0 이면 아무 일도 일어나지 않는다. WLCharacterPalette.Apply(root); + // 2026-09-17 PD 「캐릭터가 더 돋보이게」 — 역광(림·라이트 랩·피부 투과) 가산 패스를 재질 배열 뒤에 덧붙인다(그쪽 SO enabled_ = 0 이면 뗀다). + WLCharacterBacklight.Apply(root); } /// 모드를 직접 지정해 맞춘다(캡처·프로브·A/B 용).