228 lines
13 KiB
GLSL
228 lines
13 KiB
GLSL
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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
|
||
}
|