[WL] 캐릭터 「밝고 화사」 기준선 — 본체 그늘 밝히기(프로퍼티 블록)·구름 그늘 제외 · 얼굴 중심 채움빛 · 림은 따뜻한 흰빛으로 약하게 (PD 승인 「이 상태를 기본으로」)

- PD 피드백 3회 반영: ① 너무 푸르다 → 림 (1,0.96,0.88) ② 얼굴 포인트 라이트 추가(_WLFaceCenter 전역 · 얼굴 렌더러 경계 중심 · 반지름 ×3 = 몸 전체 채움빛) ③ 라이팅이 과해 어두워 보인다 → 림 세기 1.0·불투명 0.5·굵기 0.003 · 본체 _MinimumDarkness 0.38·그늘색 (0.46,0.38,0.44)·_Cloud_Strength 0 을 렌더러별 MaterialPropertyBlock 으로(재질 에셋 0줄 · 끄면 원래 값 되쓰기)
- Bloom 임계 1.2·세기 0.4(화면 전체가 뿌옇게 뜨지 않게)
- 실측: 같은 프레임 끔/켬 쌍 렌더(구름 그늘 오차 제거) · 섬 5패스·던전 7패스 · trace 0 · 콘솔 에러 0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-17 22:23:02 +09:00
parent 86acc405e4
commit b2d08cf739
4 changed files with 184 additions and 24 deletions

View File

@ -17,12 +17,16 @@ MonoBehaviour:
shaderNameFilter: Shader Graphs/Toon
skinMaterialHints:
- Skin
rimColor: {r: 0.28, g: 0.62, b: 1, a: 1}
rimStrength: 1.45
baseBrighten: 1
baseMinimumDarkness: 0.38
baseShadowColor: {r: 0.46, g: 0.38, b: 0.44, a: 0}
baseCloudStrength: 0
rimColor: {r: 1, g: 0.96, b: 0.88, a: 1}
rimStrength: 1
rimUseDepth: 1
rimWidth: 0.0045
rimWidth: 0.003
rimDepthThreshold: 0.12
rimFresnelNear: 0.6
rimFresnelNear: 0.25
rimFresnelFar: 0
rimThreshold: 0.52
rimThresholdFar: 0.36
@ -30,20 +34,30 @@ MonoBehaviour:
rimFarDistance: 12
rimSoftness: 0.05
rimDirectional: 0.85
rimOpacity: 0.85
rimOpacity: 0.5
backlightUp: 0.7
backlightSide: 0.8
wrapColor: {r: 0.75, g: 0.85, b: 1, a: 1}
wrapStrength: 0.12
wrapColor: {r: 1, g: 0.95, b: 0.88, a: 1}
wrapStrength: 0.06
wrapPower: 1.6
sssColor: {r: 1, g: 0.32, b: 0.22, a: 1}
sssStrength: 1.5
sssStrength: 1
sssPower: 1.7
faceEnabled: 1
faceColor: {r: 1, g: 0.93, b: 0.85, a: 1}
faceStrength: 0.16
faceRadiusScale: 3
faceWrap: 0.8
faceLightUp: 0.6
faceLightSide: -0.4
faceRendererHints:
- Face
- Head
bloomEnabled: 1
bloomScenes:
- Level01
bloomThreshold: 1.05
bloomIntensity: 0.55
bloomThreshold: 1.2
bloomIntensity: 0.4
bloomScatter: 0.55
bloomHdr: 1
bloomQuarter: 1

View File

@ -48,6 +48,11 @@ Shader "WL/CharBacklight"
[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
}
SubShader
@ -97,10 +102,16 @@ Shader "WL/CharBacklight"
float4 _SssColor;
float _SssStrength;
float _SssPower;
float4 _FaceColor;
float _FaceStrength;
float _FaceWrap;
CBUFFER_END
// xyz = 표면에서 가상 역광 쪽을 가리키는 월드 방향(정규화) · w = 1 이면 유효(0 = 아직 안 들어옴 → 위쪽)
float4 _WLBacklightDir;
// 얼굴 포인트 라이트 — xyz = 얼굴 중심(월드) · w = 빛이 닿는 반지름(m · 0 = 없음) / 방향 = 표면에서 빛 쪽(카메라 앞·위·옆)
float4 _WLFaceCenter;
float4 _WLFaceLightDir;
struct Attributes
{
@ -172,7 +183,18 @@ Shader "WL/CharBacklight"
// ③ 피부 투과 — 림 안쪽의 따뜻한 띠(림이 덮는 곳은 줄인다 → 바깥 차가움 · 안쪽 따뜻함의 두 톤)
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;
// ④ 얼굴 포인트 라이트 — 얼굴 중심에서 반지름 안쪽만, 카메라 쪽 앞·위에서 비추는 부드러운 빛(가산 · 얼굴 그림(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);
}
float3 col = _RimColor.rgb * (rim * _RimStrength) + _WrapColor.rgb * wrap + _SssColor.rgb * sss + _FaceColor.rgb * face;
return half4(col, rim * saturate(_RimOpacity));
}
ENDHLSL

View File

@ -82,6 +82,7 @@ namespace WL.Look.Character
s_tmp.Add(want);
r.sharedMaterials = s_tmp.ToArray();
}
BrightenBase(cfg, r, s_tmp.Count > 0 ? s_tmp[0] : null, cfg.baseBrighten != 0);
applied++; if (isSkin) skin++;
if (SubMeshCount(r) > 1) multi++; // 서브메시가 여럿이면 마지막 서브메시만 덧그려진다(진단용)
}
@ -102,11 +103,48 @@ namespace WL.Look.Character
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();
if (s_tmp.Count != mats.Length)
{
r.sharedMaterials = s_tmp.ToArray();
BrightenBase(null, r, s_tmp.Count > 0 ? s_tmp[0] : null, false); // 우리가 붙였던 렌더러만 본체 값도 되돌린다
}
}
LastLog = "역광 패스 제거 @" + root.name;
}
// ─────────────────────────────────────────────────────────────────
// 본체 밝히기 — 2026-09-17 PD 「캐릭터가 어둡게 보인다 · 원한 건 더 밝고 화사하게」
// 본체(Toon) 재질의 그늘 최소 밝기·그늘 색·구름 그늘 세기를 **렌더러별 프로퍼티 블록**으로 덮는다(재질 에셋·공유 재질 0줄).
// 끌 때는 같은 속성을 재질의 원래 값으로 되써서 되돌린다(다른 시스템이 쓰는 블록 속성은 건드리지 않는다).
// ─────────────────────────────────────────────────────────────────
static MaterialPropertyBlock s_mpb;
static readonly int kMinDark = Shader.PropertyToID("_MinimumDarkness");
static readonly int kShadowCol = Shader.PropertyToID("_ShadowDiffuseColor");
static readonly int kCloud = Shader.PropertyToID("_Cloud_Strength");
public static int BrightenedRenderers;
static void BrightenBase(WLCharacterBacklightSettings cfg, Renderer r, Material baseMat, bool on)
{
if (r == null || baseMat == null) return;
if (!baseMat.HasProperty(kMinDark) || !baseMat.HasProperty(kShadowCol)) return;
if (s_mpb == null) s_mpb = new MaterialPropertyBlock();
r.GetPropertyBlock(s_mpb);
if (on && cfg != null)
{
s_mpb.SetFloat(kMinDark, cfg.baseMinimumDarkness);
s_mpb.SetColor(kShadowCol, cfg.baseShadowColor);
if (cfg.baseCloudStrength >= 0f && baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, cfg.baseCloudStrength);
BrightenedRenderers++;
}
else
{
s_mpb.SetFloat(kMinDark, baseMat.GetFloat(kMinDark));
s_mpb.SetColor(kShadowCol, baseMat.GetColor(kShadowCol));
if (baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, baseMat.GetFloat(kCloud));
}
r.SetPropertyBlock(s_mpb);
}
/// <summary>설정 값을 런타임 재질 2장에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출).</summary>
public static void RefreshValues()
{
@ -153,6 +191,9 @@ namespace WL.Look.Character
m.SetColor("_SssColor", c.sssColor);
m.SetFloat("_SssStrength", skin ? c.sssStrength : 0f);
m.SetFloat("_SssPower", c.sssPower);
m.SetColor("_FaceColor", c.faceColor);
m.SetFloat("_FaceStrength", c.faceEnabled != 0 ? c.faceStrength : 0f);
m.SetFloat("_FaceWrap", c.faceWrap);
}
static bool IsSkinName(WLCharacterBacklightSettings c, string matName)
@ -227,6 +268,7 @@ namespace WL.Look.Character
d.Normalize();
LastDir = d;
Shader.SetGlobalVector(kDirId, new Vector4(d.x, d.y, d.z, 1f));
UpdateFaceLight(cfg, cam, f);
}
if (Time.unscaledTime >= _nextScopeCheck)
@ -249,6 +291,61 @@ namespace WL.Look.Character
}
}
// ── 얼굴 포인트 라이트 — 카메라에 가장 가까운 등록 캐릭터의 얼굴(머리) 렌더러 경계 중심을 전역 벡터로 넘긴다
static readonly int kFaceCenterId = Shader.PropertyToID("_WLFaceCenter");
static readonly int kFaceDirId = Shader.PropertyToID("_WLFaceLightDir");
static readonly Dictionary<GameObject, Renderer> s_faceRend = new Dictionary<GameObject, Renderer>();
public static Vector3 LastFaceCenter; public static float LastFaceRadius; public static string LastFaceLog = "";
void UpdateFaceLight(WLCharacterBacklightSettings cfg, Camera cam, Vector3 camForwardFlat)
{
if (cfg.enabled_ == 0 || cfg.faceEnabled == 0 || cfg.faceStrength <= 0f)
{
Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; return;
}
// 대상 = 카메라에 가장 가까운 활성 캐릭터(지금은 PC 하나)
Renderer best = null; float bestD = float.MaxValue;
for (int i = 0; i < s_roots.Count; i++)
{
var root = s_roots[i];
if (root == null || !root.activeInHierarchy) continue;
Renderer fr;
if (!s_faceRend.TryGetValue(root, out fr) || fr == null) { fr = FindFaceRenderer(cfg, root); s_faceRend[root] = fr; }
if (fr == null || !fr.enabled || !fr.gameObject.activeInHierarchy) continue;
float dsq = (fr.bounds.center - cam.transform.position).sqrMagnitude;
if (dsq < bestD) { bestD = dsq; best = fr; }
}
if (best == null) { Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; LastFaceLog = "얼굴 렌더러 없음"; return; }
var b = best.bounds;
float radius = Mathf.Max(0.05f, b.extents.magnitude * cfg.faceRadiusScale);
LastFaceCenter = b.center; LastFaceRadius = radius; LastFaceLog = best.name + " r=" + radius.ToString("F2");
Shader.SetGlobalVector(kFaceCenterId, new Vector4(b.center.x, b.center.y, b.center.z, radius));
// 빛 방향(표면 → 빛) = 카메라 쪽(앞) + 위 + 옆 — 역광의 반대편에서 얼굴을 받쳐 주는 빛
var l = -camForwardFlat + Vector3.up * cfg.faceLightUp + cam.transform.right * cfg.faceLightSide;
if (l.sqrMagnitude < 1e-6f) l = Vector3.up;
l.Normalize();
Shader.SetGlobalVector(kFaceDirId, new Vector4(l.x, l.y, l.z, 0f));
}
static Renderer FindFaceRenderer(WLCharacterBacklightSettings cfg, GameObject root)
{
if (cfg.faceRendererHints == null) return null;
var rends = root.GetComponentsInChildren<Renderer>(true);
for (int h = 0; h < cfg.faceRendererHints.Length; h++)
{
var hint = cfg.faceRendererHints[h];
if (string.IsNullOrEmpty(hint)) continue;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null || r is ParticleSystemRenderer) continue;
if (r.name.IndexOf(hint, System.StringComparison.OrdinalIgnoreCase) >= 0) return r;
}
}
return null;
}
void UpdateBloomScope(WLCharacterBacklightSettings cfg, Camera cam)
{
bool want = cfg != null && cfg.enabled_ != 0 && cfg.bloomEnabled != 0 && cam != null && InScope(cfg);

View File

@ -24,19 +24,29 @@ namespace WL.Look.Character
[Tooltip("재질 이름에 이 글자가 들어 있으면 피부로 보고 피부 투과(SSS)를 켠 재질을 물린다.")]
public string[] skinMaterialHints = { "Skin" };
[Header("① 역광 림 — 메인 조명(따뜻함)과 반대인 차가운 색")]
[ColorUsage(false, true)] public Color rimColor = new Color(0.28f, 0.62f, 1f, 1f);
[Header("⓪ 본체 밝히기 — 2026-09-17 PD 「캐릭터가 어둡게 보인다 · 원한 건 더 밝고 화사하게」")]
[Tooltip("1 = 캐릭터 본체(Toon) 재질 값을 렌더러별 프로퍼티 블록으로 덮는다(재질 에셋 0줄 · 0 = 덮지 않음).")]
public int baseBrighten = 1;
[Tooltip("그늘의 최소 밝기(원본 0.2). 클수록 그늘이 밝다.")]
[Range(0f, 1f)] public float baseMinimumDarkness = 0.38f;
[Tooltip("그늘 색(원본 (0.216,0.209,0.371) = 어두운 남색). 밝고 따뜻하게.")]
public Color baseShadowColor = new Color(0.46f, 0.38f, 0.44f, 0f);
[Tooltip("구름 그늘 세기(원본은 지형과 같은 값). 0 = 캐릭터는 구름 그늘을 안 받는다(항상 화사). 음수 = 재질 값 그대로.")]
public float baseCloudStrength = 0f;
[Header("① 역광 림 — 2026-09-17 PD 「너무 푸른색 느낌」 → 태양색 계열의 따뜻한 흰빛(차가운 하늘색 (0.28,0.62,1) 에서 변경) · 「라이팅이 과하다」 → 약하게")]
[ColorUsage(false, true)] public Color rimColor = new Color(1f, 0.96f, 0.88f, 1f);
[Tooltip("림 밝기 배수(1 초과 = HDR · Bloom 이 집어낸다).")]
public float rimStrength = 1.45f;
public float rimStrength = 1.0f;
[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;
[Range(0f, 0.02f)] public float rimWidth = 0.003f;
[Tooltip("역광 쪽 이웃 픽셀이 이만큼(m) 더 멀면 실루엣으로 본다(작으면 머리카락 가닥에도 생긴다).")]
public float rimDepthThreshold = 0.12f;
[Tooltip("깊이 림에 섞는 프레넬 림의 양 — 가까울 때(두께감).")]
[Range(0f, 1f)] public float rimFresnelNear = 0.6f;
[Range(0f, 1f)] public float rimFresnelNear = 0.25f;
[Tooltip("깊이 림에 섞는 프레넬 림의 양 — 멀 때(0 = 깊이 림만 · 가닥 줄 없음).")]
[Range(0f, 1f)] public float rimFresnelFar = 0f;
@ -54,7 +64,7 @@ namespace WL.Look.Character
[Tooltip("0 = 둘레 전부 · 1 = 가상 역광 쪽 윤곽만.")]
[Range(0f, 1f)] public float rimDirectional = 0.85f;
[Tooltip("림이 바탕을 덮는 정도(1 = 림 색 그대로 · 0 = 순수 가산). 실측: 순수 가산은 노란 머리·살색 위에서 흰 테가 돼 차가운 색이 안 보였다.")]
[Range(0f, 1f)] public float rimOpacity = 0.85f;
[Range(0f, 1f)] public float rimOpacity = 0.5f;
[Header("가상 역광 위치(카메라 기준) — 캐릭터 뒤 + 위 + 옆")]
[Tooltip("위쪽 성분(클수록 머리·어깨 윗선에 맺힌다).")]
@ -63,23 +73,40 @@ namespace WL.Look.Character
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;
[ColorUsage(false, true)] public Color wrapColor = new Color(1f, 0.95f, 0.88f, 1f);
public float wrapStrength = 0.06f;
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 sssStrength = 1.0f;
public float sssPower = 1.7f;
[Header("④ 번짐(Bloom) — 역광이 화사하게 번지게(전체 화면 패스 · 모바일 비용 있음)")]
[Header("④ 얼굴 포인트 라이트 — 얼굴 주변만 살짝 밝히는 빛(2026-09-17 PD 「얼굴 쪽을 살짝 포인트로 하이라이트」)")]
[Tooltip("0 = 끔.")]
public int faceEnabled = 1;
[ColorUsage(false, true)] public Color faceColor = new Color(1f, 0.93f, 0.85f, 1f);
[Tooltip("밝기(가산). 0.2 = 은은 · 0.5 = 뚜렷.")]
public float faceStrength = 0.16f;
[Tooltip("빛이 닿는 반지름 = 얼굴(머리) 렌더러 경계 크기 × 이 값. 3 = 얼굴에서 가장 밝고 발끝으로 갈수록 옅어지는 몸 전체 채움빛(「밝고 화사」).")]
public float faceRadiusScale = 3.0f;
[Tooltip("0 = 빛 방향을 향한 면만 · 1 = 둥글게 감싸는 빛.")]
[Range(0f, 1f)] public float faceWrap = 0.8f;
[Tooltip("빛 위치(카메라 기준): 위쪽 성분.")]
public float faceLightUp = 0.6f;
[Tooltip("빛 위치(카메라 기준): 옆 성분(− = 화면 왼쪽 · 역광의 반대쪽).")]
public float faceLightSide = -0.4f;
[Tooltip("얼굴 중심으로 쓸 렌더러 이름 힌트(앞에서부터 찾는다 · 못 찾으면 얼굴 빛 없음).")]
public string[] faceRendererHints = { "Face", "Head" };
[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;
public float bloomThreshold = 1.2f;
public float bloomIntensity = 0.4f;
[Range(0f, 1f)] public float bloomScatter = 0.55f;
[Tooltip("1 = 카메라 HDR 을 켠다(림만 골라 번지게 · 끄면 흰 거품·밝은 모래도 같이 번진다).")]
public int bloomHdr = 1;