[WL] 캐릭터 역광 — 화면 공간 깊이 림(차가운 색 · 픽셀 굵기 일정) + 라이트 랩 + 피부 투과 덧그리기 패스 · 섬 Bloom 스코프 (PD 「캐릭터가 더 돋보이게」)
- WLCharBacklight.shader: 재질 배열 뒤 덧그리기 1패스(미리 곱한 알파) · 깊이 림 + 가까울 때 프레넬 보조 · 전역 _WLBacklightDir(카메라 기준 뒤+위+옆) - WLCharacterBacklight: WLCharacterLook.Apply 끝에서 적용(섬 교체 모델·던전 PC) · 피부 재질만 SSS · 2 s 재적용(늦게 붙는 무기) · Bloom 스코프(Level01 · 카메라 후처리/HDR 원복) - WLCharacterBacklightSettings(.asset): 값 전부 설정 에셋 · enabled_ 0 = 원복 · bloomEnabled 0 = 카메라/볼륨 0 변경 - 본체 재질·Toon 그래프·프리팹 0줄 · Play 중 생성물 DontSave 미사용 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c313dd88fd
commit
86acc405e4
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e2a36e53585fe204f9513de88d1792d9
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 21b48ce884e558949a29aadfe608393a
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d0f2bd90c4ecc02418521d44fc2cc372
|
||||
|
|
@ -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<WLCharacterBacklightSettings>(ResourcePath);
|
||||
}
|
||||
return s_inst;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Invalidate() { s_inst = null; s_tried = false; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9c17b0a1c2d3e4f5a6b7c8d9e0f1a2b3
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>모드를 직접 지정해 맞춘다(캡처·프로브·A/B 용).</summary>
|
||||
|
|
|
|||
Loading…
Reference in New Issue