// ───────────────────────────────────────────────────────────────────────────── // 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 [Header(Face point light)] [HDR] _FaceColor ("Face Light Color", Color) = (1.0, 0.93, 0.85, 1) _FaceStrength ("Face Light Strength (0 = 끔)", Float) = 0.3 _FaceWrap ("Face Light Wrap (0 = 또렷 · 1 = 둥글게 감싸는 빛)", Range(0, 1)) = 0.5 _FillStrength ("Body Fill Strength (위치 없는 채움빛 · 2등급용)", Float) = 0 [Header(Hit flash overlay)] _HitFlash ("Hit Flash Opacity (렌더러별 프로퍼티 블록이 쓴다 · 0 = 없음)", Range(0, 1)) = 0 _HitFlashColor ("Hit Flash Color", Color) = (1, 1, 1, 1) } 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; float4 _FaceColor; float _FaceStrength; float _FaceWrap; float _FillStrength; float _HitFlash; float4 _HitFlashColor; CBUFFER_END // xyz = 표면에서 가상 역광 쪽을 가리키는 월드 방향(정규화) · w = 1 이면 유효(0 = 아직 안 들어옴 → 위쪽) float4 _WLBacklightDir; // 얼굴 포인트 라이트 — xyz = 얼굴 중심(월드) · w = 빛이 닿는 반지름(m · 0 = 없음) / 방향 = 표면에서 빛 쪽(카메라 앞·위·옆) float4 _WLFaceCenter; float4 _WLFaceLightDir; 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); // 본체가 이 픽셀을 안 그렸으면(디졸브·알파 클립으로 잘려 나간 자리) 덧그리지 않는다 — 몬스터 사망 디졸브에 빛 자국이 남지 않게 float dBase = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); clip(0.06 - (dBase - LinearEyeDepth(input.positionCS.z, _ZBufferParams))); 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); // ④ 얼굴 포인트 라이트 — 얼굴 중심에서 반지름 안쪽만, 카메라 쪽 앞·위에서 비추는 부드러운 빛(가산 · 얼굴 그림(Unlit 데칼)은 이 위에 그려져 또렷하게 남는다) float face = 0.0; if (_WLFaceCenter.w > 0.001 && _FaceStrength > 0.0) { float fd = length(input.positionWS - _WLFaceCenter.xyz) / _WLFaceCenter.w; float fall = saturate(1.0 - fd); fall = fall * fall * (3.0 - 2.0 * fall); float3 fl = dot(_WLFaceLightDir.xyz, _WLFaceLightDir.xyz) > 1e-5 ? normalize(_WLFaceLightDir.xyz) : v; float ndl = saturate(dot(n, fl) * (1.0 - 0.5 * _FaceWrap) + 0.5 * _FaceWrap); face = fall * ndl * _FaceStrength * (1.0 - rim); } // ④-b 위치 없는 채움빛(2등급 = 몬스터·NPC) — 같은 빛 방향으로 몸 전체를 옅게 if (_FillStrength > 0.0) { float3 fl2 = dot(_WLFaceLightDir.xyz, _WLFaceLightDir.xyz) > 1e-5 ? normalize(_WLFaceLightDir.xyz) : v; face += saturate(dot(n, fl2) * (1.0 - 0.5 * _FaceWrap) + 0.5 * _FaceWrap) * _FillStrength * (1.0 - rim); } float3 col = _RimColor.rgb * (rim * _RimStrength) + _WrapColor.rgb * wrap + _SssColor.rgb * sss + _FaceColor.rgb * face; float a = rim * saturate(_RimOpacity); // ⑤ 피격 하이라이트 — 본체 위에 흰색을 _HitFlash 만큼만 덮는다(완전히 하얗게 되지 않는다 · 2026-09-17 PD 「투명도 20 % 정도」) float hf = saturate(_HitFlash); col = col * (1.0 - hf) + _HitFlashColor.rgb * hf; a = 1.0 - (1.0 - a) * (1.0 - hf); return half4(col, a); } ENDHLSL } } Fallback Off }