[WL] 던전 풀밭 = 섬 값 · 물가 물보라(절차적 거품 셰이더) · 등급 연출(PC 1 · 몬스터/NPC 2 · 프랍 3 — 톤 공유·세기만 차등)

- WLDungeonGrassLook: 던전 데모 인스턴서/터레인 재질/태양을 섬 설정 값으로(풀 0.22·꽃 0.26·구름 0.5/0.025·밝기 0.12·그림자 0.45) + ssaoOffScenes 에 던전 2개
- WLShoreSpray.shader + WLShoreFoam: uv2 = 물가선 거리(m) · 또렷한 물가 거품(밀려왔다 빠짐) + 밀려오는 포말 줄 · 텍스처 0장 · shoreSprayEnabled 0 = 예전 띠
- WLCharacterBacklight: 2등급(Actor 역할 스캔 1.5 s · 림/채움빛 약하게 · MK Toon 그늘색·Toon 그늘 값은 프로퍼티 블록) · 3등급(Toon 프랍 공유 재질 복사본 · 5 s 스캔 · 복원 가능) · 디졸브 픽셀 버림 · ForceScan
- 실측: 같은 프레임 끔/켬 쌍 렌더(섬·던전) · trace 0 · 콘솔 에러 0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-17 22:45:45 +09:00
parent b2d08cf739
commit a0634ad735
12 changed files with 679 additions and 13 deletions

View File

@ -47,6 +47,8 @@ namespace WL.Island
WLIslandSceneSetup.EnsureDungeonMapNodes(cfg, scene, d);
FixDuplicates(cfg, scene);
// 2026-09-17 PD 「인게임의 풀밭의 느낌도 섬과 동일하게」 — 던전 씬의 풀 인스턴서·터레인 재질·태양을 섬 값으로(로딩 화면 중 · 씬/원본 재질 0줄)
WL.Look.Farm.WLDungeonGrassLook.Apply(scene);
// 🔴 WL-816i — 던전 카메라를 **섬 구도**(각 45° · 거리 22 m · FOV 60)로 맞춘다.
// 씬이 내려가면 이 오브젝트도 사라지고 OnDestroy 가 원래 값을 돌려놓는다(C8).

View File

@ -53,6 +53,32 @@ MonoBehaviour:
faceRendererHints:
- Face
- Head
tiersEnabled: 1
tier2RimStrength: 0.7
tier2RimOpacity: 0.35
tier2FillStrength: 0.07
tier2MinimumDarknessAdd: 0.1
tier2ShadowBlend: 0.6
tier2ShadowColor: {r: 0.36, g: 0.31, b: 0.4, a: 0}
tier2MkShadowColor: {r: 0.3, g: 0.25, b: 0.34, a: 1}
tier2Roles:
- Mob
- Pet
- Worker
- Fairy
- EnemyPC
- Farmer
- Summon
- BattleNPC
- EnemyPet
- EnemySummon
tier2ScanSeconds: 1.5
tier3MinimumDarknessAdd: 0.06
tier3ShadowBlend: 0.35
tier3ShadowColor: {r: 0.29, g: 0.27, b: 0.39, a: 0}
tier3ShaderNames:
- Shader Graphs/Toon
tier3ScanSeconds: 5
bloomEnabled: 1
bloomScenes:
- Level01

View File

@ -53,6 +53,7 @@ Shader "WL/CharBacklight"
[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
}
SubShader
@ -105,6 +106,7 @@ Shader "WL/CharBacklight"
float4 _FaceColor;
float _FaceStrength;
float _FaceWrap;
float _FillStrength;
CBUFFER_END
// xyz = 표면에서 가상 역광 쪽을 가리키는 월드 방향(정규화) · w = 1 이면 유효(0 = 아직 안 들어옴 → 위쪽)
@ -164,6 +166,9 @@ Shader "WL/CharBacklight"
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);
@ -193,6 +198,12 @@ Shader "WL/CharBacklight"
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;
return half4(col, rim * saturate(_RimOpacity));

View File

@ -31,11 +31,11 @@ namespace WL.Look.Character
public static int AppliedRenderers, SkinRenderers, MultiSubmeshRenderers;
public static string LastLog = "";
static Material s_rim, s_rimSkin;
static Material s_rim, s_rimSkin, s_rimT2;
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); }
public static bool IsOurs(Material m) { return m != null && (m == s_rim || m == s_rimSkin || m == s_rimT2); }
/// <summary>root 아래 캐릭터 렌더러에 역광 패스를 덧붙인다(설정이 꺼져 있으면 떼어낸다).</summary>
public static void Apply(GameObject root)
@ -107,6 +107,7 @@ namespace WL.Look.Character
{
r.sharedMaterials = s_tmp.ToArray();
BrightenBase(null, r, s_tmp.Count > 0 ? s_tmp[0] : null, false); // 우리가 붙였던 렌더러만 본체 값도 되돌린다
RestoreMkTint(r, s_tmp.Count > 0 ? s_tmp[0] : null);
}
}
LastLog = "역광 패스 제거 @" + root.name;
@ -145,27 +146,178 @@ namespace WL.Look.Character
r.SetPropertyBlock(s_mpb);
}
/// <summary>설정 값을 런타임 재질 2장에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출).</summary>
/// <summary>2등급 본체 색(MK Toon 그늘 색)을 재질 원래 값으로 되쓴다.</summary>
static void RestoreMkTint(Renderer r, Material baseMat)
{
if (r == null || baseMat == null || !baseMat.HasProperty(kGoochDark)) return;
if (s_mpb == null) s_mpb = new MaterialPropertyBlock();
r.GetPropertyBlock(s_mpb);
s_mpb.SetColor(kGoochDark, baseMat.GetColor(kGoochDark));
r.SetPropertyBlock(s_mpb);
}
// ─────────────────────────────────────────────────────────────────
// 등급 — 2026-09-17 PD 「톤앤매너는 전체가 공유 · 플레이어 1등급 · 몬스터와 NPC 2등급 · 배경(프랍) 3등급」
// 공유하는 것 = 빛 방향(가상 역광·채움빛) · 림 색 · 그늘 색 계열(따뜻한 보랏빛). 등급은 **세기**만 다르다.
// 2등급 = 같은 덧그리기 패스(림 약하게 + 몸 전체 채움빛 약하게) + 본체 그늘을 조금 밝게(Toon = 그늘 최소 밝기/색 · MK Toon = _GoochDarkColor)
// 3등급 = 덧그리기 없음(드로우콜 0 추가) · 프랍이 공유하는 Toon 재질의 **런타임 복사본**에 그늘 값만 살짝(재질당 1장 → 배칭 유지)
// ─────────────────────────────────────────────────────────────────
public static int Tier2Roots, Tier2Renderers, Tier3Renderers, Tier3Materials;
static readonly int kGoochDark = Shader.PropertyToID("_GoochDarkColor");
public static void ApplyTier2(GameObject root)
{
if (root == null) return;
var cfg = WLCharacterBacklightSettings.Instance;
if (cfg == null) return;
if (cfg.enabled_ == 0 || cfg.tiersEnabled == 0) { Remove(root); return; }
if (!EnsureMaterials(cfg)) return;
int n = 0;
s_buf.Clear();
root.GetComponentsInChildren<Renderer>(false, s_buf);
for (int i = 0; i < s_buf.Count; i++)
{
var r = s_buf[i];
if (r == null || !(r is SkinnedMeshRenderer || r is MeshRenderer)) continue;
var mats = r.sharedMaterials;
if (mats == null || mats.Length == 0) continue;
s_tmp.Clear();
Material baseMat = null; bool hasT2 = false;
for (int m = 0; m < mats.Length; m++)
{
if (IsOurs(mats[m])) { if (mats[m] == s_rimT2) hasT2 = true; continue; }
s_tmp.Add(mats[m]);
if (baseMat == null && mats[m] != null) baseMat = mats[m];
}
if (baseMat == null || baseMat.renderQueue >= 2500) continue; // 불투명 본체만(반투명 이펙트 메시 제외)
if (!(hasT2 && mats.Length == s_tmp.Count + 1))
{
s_tmp.Add(s_rimT2);
r.sharedMaterials = s_tmp.ToArray();
}
TintTier2Base(cfg, r, baseMat);
n++;
}
Tier2Renderers += n;
}
static void TintTier2Base(WLCharacterBacklightSettings cfg, Renderer r, Material baseMat)
{
if (s_mpb == null) s_mpb = new MaterialPropertyBlock();
bool mk = baseMat.HasProperty(kGoochDark);
bool toon = baseMat.HasProperty(kMinDark) && baseMat.HasProperty(kShadowCol);
if (!mk && !toon) return;
r.GetPropertyBlock(s_mpb);
if (mk) s_mpb.SetColor(kGoochDark, cfg.tier2MkShadowColor);
if (toon)
{
s_mpb.SetFloat(kMinDark, Mathf.Clamp01(baseMat.GetFloat(kMinDark) + cfg.tier2MinimumDarknessAdd));
s_mpb.SetColor(kShadowCol, Color.Lerp(baseMat.GetColor(kShadowCol), cfg.tier2ShadowColor, cfg.tier2ShadowBlend));
}
r.SetPropertyBlock(s_mpb);
}
// ── 3등급(프랍) — 공유 재질 복사본으로 갈아끼운다
static readonly Dictionary<Material, Material> s_t3Copy = new Dictionary<Material, Material>();
static readonly HashSet<Material> s_t3Made = new HashSet<Material>();
static readonly HashSet<int> s_t3Seen = new HashSet<int>();
struct T3State { public Renderer r; public Material[] mats; }
static readonly List<T3State> s_t3Swapped = new List<T3State>(256);
/// <summary>3등급으로 갈아끼운 프랍 재질을 전부 원래대로(설정을 끄면 다음 검색 때 자동으로 불린다).</summary>
public static void RestoreTier3()
{
for (int i = 0; i < s_t3Swapped.Count; i++) if (s_t3Swapped[i].r != null) s_t3Swapped[i].r.sharedMaterials = s_t3Swapped[i].mats;
s_t3Swapped.Clear(); s_t3Seen.Clear();
}
public static void ScanTier3()
{
var cfg = WLCharacterBacklightSettings.Instance;
if (cfg == null) return;
if (cfg.enabled_ == 0 || cfg.tiersEnabled == 0 || cfg.tier3ShaderNames == null || cfg.tier3ShaderNames.Length == 0)
{ if (s_t3Swapped.Count > 0) RestoreTier3(); return; }
if (s_t3Seen.Count > 20000) s_t3Seen.Clear();
for (int i = s_t3Swapped.Count - 1; i >= 0; i--) if (s_t3Swapped[i].r == null) s_t3Swapped.RemoveAt(i); // 씬이 내려가 사라진 것
var rends = Object.FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None);
int swapped = 0;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null || !s_t3Seen.Add(r.GetInstanceID())) continue;
var mats = r.sharedMaterials;
if (mats == null || mats.Length == 0) continue;
bool any = false;
for (int m = 0; m < mats.Length; m++) if (IsTier3Source(cfg, mats[m])) { any = true; break; }
if (!any) continue;
// 캐릭터·몬스터·동물에 달린 것은 1·2등급이 맡는다
if (r.GetComponentInParent<Actor>() != null || r.GetComponentInParent<WLCharacterLook>() != null
|| r.GetComponentInParent<CryingSnow.FarmingIsland.PlayerController>() != null
|| r.GetComponentInParent<CryingSnow.FarmingIsland.AnimalController>() != null) continue;
s_t3Swapped.Add(new T3State { r = r, mats = (Material[])mats.Clone() });
for (int m = 0; m < mats.Length; m++) if (IsTier3Source(cfg, mats[m])) mats[m] = Tier3Copy(cfg, mats[m]);
r.sharedMaterials = mats;
swapped++;
}
Tier3Renderers += swapped; Tier3Materials = s_t3Copy.Count;
}
static bool IsTier3Source(WLCharacterBacklightSettings cfg, Material m)
{
if (m == null || m.shader == null || s_t3Made.Contains(m) || IsOurs(m)) return false;
if (!m.HasProperty(kMinDark) || !m.HasProperty(kShadowCol)) return false;
var n = m.shader.name;
for (int i = 0; i < cfg.tier3ShaderNames.Length; i++) if (cfg.tier3ShaderNames[i] == n) return true;
return false;
}
static Material Tier3Copy(WLCharacterBacklightSettings cfg, Material src)
{
Material c;
if (s_t3Copy.TryGetValue(src, out c) && c != null) return c;
c = new Material(src) { name = src.name + " (WL tier3)" };
c.SetFloat(kMinDark, Mathf.Clamp01(src.GetFloat(kMinDark) + cfg.tier3MinimumDarknessAdd));
c.SetColor(kShadowCol, Color.Lerp(src.GetColor(kShadowCol), cfg.tier3ShadowColor, cfg.tier3ShadowBlend));
s_t3Copy[src] = c; s_t3Made.Add(c);
return c;
}
/// <summary>설정 값을 런타임 재질에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출).</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);
if (s_rimT2 != null) PushTier2(cfg, s_rimT2);
}
static bool EnsureMaterials(WLCharacterBacklightSettings cfg)
{
if (s_rim != null && s_rimSkin != null) return true;
if (s_rim != null && s_rimSkin != null && s_rimT2 != 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);
if (s_rimT2 == null) s_rimT2 = new Material(sh) { name = "WL_CharBacklight_Tier2(runtime)" };
PushTier2(cfg, s_rimT2);
return true;
}
/// <summary>2등급(몬스터·NPC) 재질 = 1등급과 같은 색·방향(톤 공유)에 세기만 낮춘다 · 얼굴빛/피부 투과 없음 · 몸 전체 채움빛 약하게.</summary>
static void PushTier2(WLCharacterBacklightSettings c, Material m)
{
Push(c, m, false);
m.SetFloat("_RimStrength", c.tier2RimStrength);
m.SetFloat("_RimOpacity", c.tier2RimOpacity);
m.SetFloat("_WrapStrength", c.wrapStrength * 0.5f);
m.SetFloat("_FaceStrength", 0f);
m.SetFloat("_FillStrength", c.tier2FillStrength);
}
static void Push(WLCharacterBacklightSettings c, Material m, bool skin)
{
m.SetColor("_RimColor", c.rimColor);
@ -277,6 +429,20 @@ namespace WL.Look.Character
UpdateBloomScope(cfg, cam);
}
if (cfg != null)
{
if (Time.unscaledTime >= _nextTier2)
{
_nextTier2 = Time.unscaledTime + Mathf.Max(0.5f, cfg.tier2ScanSeconds);
ScanTier2(cfg);
}
if (Time.unscaledTime >= _nextTier3)
{
_nextTier3 = Time.unscaledTime + Mathf.Max(1f, cfg.tier3ScanSeconds);
WLCharacterBacklight.ScanTier3();
}
}
if (Time.unscaledTime >= _nextRescan)
{
_nextRescan = Time.unscaledTime + 2f;
@ -291,6 +457,47 @@ namespace WL.Look.Character
}
}
// ── 등급 검색 — 원본 Actor 를 역할로 나눠 2등급을 붙인다(PC 는 1등급 · 원본 0줄: 읽기만 한다)
float _nextTier2, _nextTier3;
static HashSet<string> s_t2Roles; static string[] s_t2RolesSrc;
public static int Tier2Scans, LastTier2Actors;
/// <summary>지금 바로 1·2·3등급을 다시 맞춘다(프로브의 끔/켬 비교용).</summary>
public static void ForceScan()
{
var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return;
for (int i = s_roots.Count - 1; i >= 0; i--) if (s_roots[i] != null && s_roots[i].activeInHierarchy) WLCharacterBacklight.Apply(s_roots[i]);
ScanTier2(cfg);
WLCharacterBacklight.ScanTier3();
}
static void ScanTier2(WLCharacterBacklightSettings cfg)
{
if (s_t2Roles == null || s_t2RolesSrc != cfg.tier2Roles)
{
s_t2RolesSrc = cfg.tier2Roles; s_t2Roles = new HashSet<string>();
if (cfg.tier2Roles != null) for (int i = 0; i < cfg.tier2Roles.Length; i++) if (!string.IsNullOrEmpty(cfg.tier2Roles[i])) s_t2Roles.Add(cfg.tier2Roles[i]);
}
int n = 0;
var actors = Object.FindObjectsByType<Actor>(FindObjectsSortMode.None);
for (int i = 0; i < actors.Length; i++)
{
var a = actors[i];
if (a == null || !a.gameObject.activeInHierarchy) continue;
if (a.m_Role == eRole.PC)
{
// 룩 컴포넌트 없는 PC 프리팹도 1등급
if (!s_roots.Contains(a.gameObject) && a.GetComponentInChildren<WLCharacterLook>() == null) WLCharacterBacklight.Apply(a.gameObject);
continue;
}
if (!s_t2Roles.Contains(a.m_Role.ToString())) continue;
WLCharacterBacklight.ApplyTier2(a.gameObject); n++;
}
var animals = Object.FindObjectsByType<CryingSnow.FarmingIsland.AnimalController>(FindObjectsSortMode.None);
for (int i = 0; i < animals.Length; i++) if (animals[i] != null) { WLCharacterBacklight.ApplyTier2(animals[i].gameObject); n++; }
LastTier2Actors = n; WLCharacterBacklight.Tier2Roots = n; Tier2Scans++;
}
// ── 얼굴 포인트 라이트 — 카메라에 가장 가까운 등록 캐릭터의 얼굴(머리) 렌더러 경계 중심을 전역 벡터로 넘긴다
static readonly int kFaceCenterId = Shader.PropertyToID("_WLFaceCenter");
static readonly int kFaceDirId = Shader.PropertyToID("_WLFaceLightDir");

View File

@ -99,6 +99,34 @@ namespace WL.Look.Character
[Tooltip("얼굴 중심으로 쓸 렌더러 이름 힌트(앞에서부터 찾는다 · 못 찾으면 얼굴 빛 없음).")]
public string[] faceRendererHints = { "Face", "Head" };
[Header("등급 — 2026-09-17 PD 「톤은 전체 공유 · 플레이어 1등급 · 몬스터와 NPC 2등급 · 배경(프랍) 3등급」")]
[Tooltip("1 = 2·3등급도 적용한다. 0 = 플레이어(1등급)만.")]
public int tiersEnabled = 1;
[Tooltip("2등급(몬스터·NPC·동료) — 림 세기/불투명도(1등급보다 약하게).")]
public float tier2RimStrength = 0.7f;
[Range(0f, 1f)] public float tier2RimOpacity = 0.35f;
[Tooltip("2등급 — 몸 전체 채움빛 세기(얼굴 중심 없음).")]
public float tier2FillStrength = 0.07f;
[Tooltip("2등급 — 본체가 Toon 그래프일 때: 그늘 최소 밝기에 더하는 양 · 그늘 색을 공유 그늘 색 쪽으로 섞는 비율(재질마다 원래 값이 달라 상대값으로).")]
[Range(0f, 0.5f)] public float tier2MinimumDarknessAdd = 0.10f;
[Range(0f, 1f)] public float tier2ShadowBlend = 0.6f;
public Color tier2ShadowColor = new Color(0.36f, 0.31f, 0.40f, 0f);
[Tooltip("2등급 — 본체가 MK Toon(힘민지 몬스터)일 때 그늘 색 `_GoochDarkColor`(원본 검정). 전체가 공유하는 따뜻한 보랏빛 그늘 계열.")]
public Color tier2MkShadowColor = new Color(0.30f, 0.25f, 0.34f, 1f);
[Tooltip("2등급 대상 역할(원본 eRole 이름). PC 는 1등급이라 제외.")]
public string[] tier2Roles = { "Mob", "Pet", "Worker", "Fairy", "EnemyPC", "Farmer", "Summon", "BattleNPC", "EnemyPet", "EnemySummon" };
[Tooltip("2등급 재검색 주기(s) — 새로 나온 몬스터에 붙인다.")]
public float tier2ScanSeconds = 1.5f;
[Tooltip("3등급(배경 프랍) — 덧그리기 없음(비용 0) · 공유 재질의 런타임 복사본에 그늘 값만 살짝: 그늘 최소 밝기에 더하는 양 · 공유 그늘 색 쪽으로 섞는 비율.")]
[Range(0f, 0.5f)] public float tier3MinimumDarknessAdd = 0.06f;
[Range(0f, 1f)] public float tier3ShadowBlend = 0.35f;
public Color tier3ShadowColor = new Color(0.29f, 0.27f, 0.39f, 0f);
[Tooltip("3등급 대상 셰이더 이름(이 셰이더를 쓰는 MeshRenderer · 캐릭터/몬스터/지형/풀 제외).")]
public string[] tier3ShaderNames = { "Shader Graphs/Toon" };
[Tooltip("3등급 재검색 주기(s) — 섬 확장·설치물처럼 나중에 생기는 프랍.")]
public float tier3ScanSeconds = 5f;
[Header("⑤ 번짐(Bloom) — 역광이 화사하게 번지게(전체 화면 패스 · 모바일 비용 있음)")]
[Tooltip("0 = 카메라·볼륨을 건드리지 않는다.")]
public int bloomEnabled = 1;

View File

@ -58,6 +58,8 @@ MonoBehaviour:
ssaoOffScenes:
- Level01
- WL_FarmLook
- WL_Dungeon01
- WL_Dungeon02
fogOff: 1
disableFiGraphicsManager: 1
rescanAtSeconds:
@ -77,6 +79,21 @@ MonoBehaviour:
shoreFoamEnabled: 1
shoreFoamUp: 0.3
shoreFoamDown: 2
shoreSprayEnabled: 1
shoreSprayColor: {r: 1, g: 1, b: 1, a: 1}
shoreSprayOpacity: 0.92
shoreSprayNoiseScale: 2.4
shoreSprayEdgeMin: 0.1
shoreSprayEdgeMax: 0.36
shoreSpraySandReach: 0.16
shoreSprayWaveSpeed: 0.9
shoreSprayLineSpacing: 0.62
shoreSprayLineSpeed: 0.22
shoreSprayLineWidth: 0.16
shoreSprayLineReach: 1.7
shoreSprayLineBreak: 0.6
dungeonGrassMatchIsland: 1
dungeonLightMatchIsland: 1
shoreFoamMaterial: {fileID: 2100000, guid: d24a4c5ce16646c409631c112b57f2cf, type: 2}
shoreFoamWidth: 1.6
shoreFoamInner: 0.5

View File

@ -0,0 +1,158 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLShoreSpray.shader — 섬 물가의 물보라(절차적 거품) · 언릿 · 알파 블렌드
//
// 2026-09-17 PD 「섬 가장자리의 물거품이 안개처럼 보이고 부자연스럽다 → 좀 더 자연스러운 물보라처럼」.
// 예전 띠 = 옅은 알파 그라데이션 텍스처(폭 2 m) → 흰 안개처럼 번져 보였다.
// 이 셰이더 = 경계가 **또렷한 조각**만 그린다(그라데이션 0):
// ① 물가 거품 띠: 물가선에 붙어 있고, 바다 쪽 가장자리가 노이즈로 끊긴다. 해안 구간마다 다른 박자로 밀려왔다(_EdgeMax) 빠진다(_EdgeMin).
// ② 포말 줄: 그 바깥에서 해안으로 밀려오는 가는 줄 2~3가닥. 멀수록 가늘고 듬성듬성(노이즈 임계가 올라가 조각으로 흩어진다).
// 입력: uv2.x = 물가선까지의 수평 거리(m · + 바다 / − 모래) — `WLShoreFoam.BuildFromTerrain` 이 채운다. 무늬 좌표 = 월드 xz(이음매 없음).
// 모바일: 텍스처 0장 · 그라디언트 노이즈 3회(좁은 띠 위에서만) · 조명 없음.
// ─────────────────────────────────────────────────────────────────────────────
Shader "WL/ShoreSpray"
{
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
_Opacity ("Opacity", Range(0, 1)) = 0.92
_NoiseScale ("Noise Scale (1/m)", Float) = 2.4
_EdgeMin ("Contact Foam Width — 빠졌을 때 (m)", Float) = 0.10
_EdgeMax ("Contact Foam Width — 밀려왔을 때 (m)", Float) = 0.42
_SandReach ("Sand Reach (m)", Float) = 0.16
_WaveSpeed ("Wave Speed (rad/s)", Float) = 0.9
_LineSpacing ("Wash Line Spacing (m)", Float) = 0.62
_LineSpeed ("Wash Line Speed (spacing/s)", Float) = 0.22
_LineWidth ("Wash Line Width (0~1)", Range(0.02, 0.6)) = 0.2
_LineReach ("Wash Line Reach (m)", Float) = 1.7
_LineBreak ("Wash Line Break (0~1)", Range(0, 1)) = 0.45
}
SubShader
{
Tags { "RenderPipeline" = "UniversalPipeline" "RenderType" = "Transparent" "Queue" = "Transparent+50" "IgnoreProjector" = "True" }
Pass
{
Name "WLShoreSpray"
Tags { "LightMode" = "SRPDefaultUnlit" }
Cull Off
ZWrite Off
ZTest LEqual
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 3.0
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _Color;
float _Opacity;
float _NoiseScale;
float _EdgeMin;
float _EdgeMax;
float _SandReach;
float _WaveSpeed;
float _LineSpacing;
float _LineSpeed;
float _LineWidth;
float _LineReach;
float _LineBreak;
CBUFFER_END
struct Attributes
{
float4 positionOS : POSITION;
float2 uv2 : TEXCOORD1;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
float dist : TEXCOORD1; // 물가선까지 수평 거리(m · + 바다 / − 모래)
};
Varyings vert(Attributes input)
{
Varyings o = (Varyings)0;
o.positionWS = TransformObjectToWorld(input.positionOS.xyz);
o.positionCS = TransformWorldToHClip(o.positionWS);
o.dist = input.uv2.x;
return o;
}
float hash21(float2 p)
{
p = frac(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return frac(p.x * p.y);
}
float2 grad2(float2 i)
{
float a = hash21(i) * 6.2831853;
return float2(cos(a), sin(a));
}
// 그라디언트(펄린) 노이즈 0..1 — 값 노이즈는 격자 모양(둥근 네모)이 임계 경계에 그대로 드러난다(2026-09-16 지형 실측과 같은 이유)
float gnoise(float2 p)
{
float2 i = floor(p), f = frac(p);
float2 u = f * f * (3.0 - 2.0 * f);
float a = dot(grad2(i), f);
float b = dot(grad2(i + float2(1, 0)), f - float2(1, 0));
float c = dot(grad2(i + float2(0, 1)), f - float2(0, 1));
float d = dot(grad2(i + float2(1, 1)), f - float2(1, 1));
return saturate(0.5 + 0.72 * lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y));
}
// 화면 미분으로 1 px 만 부드럽게 한 계단(또렷하되 계단 현상 없음)
float aastep(float x, float edge)
{
float fw = max(fwidth(x), 1e-4);
return smoothstep(edge - fw, edge + fw, x);
}
half4 frag(Varyings input) : SV_Target
{
float d = input.dist;
float2 w = input.positionWS.xz;
float t = _Time.y;
float n1 = gnoise(w * _NoiseScale + float2(t * 0.13, t * 0.09));
float n2 = gnoise(w * _NoiseScale * 2.6 + float2(-t * 0.21, t * 0.05));
float n = n1 * 0.62 + n2 * 0.38;
// 해안 구간마다 다른 박자로 밀려왔다 빠진다
float ph = gnoise(w * 0.11) * 6.2831853;
float breath = 0.5 + 0.5 * sin(t * _WaveSpeed + ph);
float edgeW = lerp(_EdgeMin, _EdgeMax, breath);
// ① 물가 거품 띠 — 모래 쪽 끝(−) ~ 바다 쪽 끝(+ · 노이즈로 끊김)
float seaEdge = edgeW * (0.45 + 1.1 * n);
float sandEdge = -_SandReach * (0.35 + 0.65 * n2) * (0.4 + 0.6 * breath);
float contact = aastep(d, sandEdge) * (1.0 - aastep(d, seaEdge));
// ② 포말 줄 — 해안으로 밀려온다(시간이 흐르면 같은 줄의 d 가 줄어든다)
float k = d / max(_LineSpacing, 0.05) + t * _LineSpeed;
float core = 1.0 - abs(frac(k) - 0.5) * 2.0; // 줄 중심 1
float distT = saturate(d / max(_LineReach, 0.05));
float width = _LineWidth * (1.0 - 0.6 * distT); // 멀수록 가늘다
float washLine = aastep(core, 1.0 - width);
float keepTh = lerp(_LineBreak * 0.6, 0.98, distT * distT); // 멀수록 듬성듬성
washLine *= aastep(n, keepTh);
washLine *= step(seaEdge, d) * step(d, _LineReach);
float a = saturate(max(contact, washLine)) * _Opacity * _Color.a;
return half4(_Color.rgb, a);
}
ENDHLSL
}
}
Fallback Off
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 572f88bfeac39f241831f44603fa78a9
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,131 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLDungeonGrassLook.cs — 던전(인게임) 풀밭을 섬과 같은 느낌으로 맞춘다
//
// 2026-09-17 PD 「인게임의 풀밭의 느낌도 섬과 동일하게 맞춰줘」.
//
// ■ 실측(WL_Dungeon01 · 데모 씬 복사본)으로 본 차이
// 항목 섬 던전(원래)
// 풀/꽃 크기 0.22 / 0.26 0.35 / 0.35
// 구름 그늘 세기 0.5 1.0
// 구름 그늘 크기 0.025 0.010
// 토온 밝기 단계 0.12 0.25
// 태양 그림자 0.45 1.0
// SSAO 끔 켬(풀잎마다 발밑 그늘 = 얼룩)
// ■ 무엇을 하나(씬 파일·원본 재질 0줄 — 전부 런타임)
// 던전 씬의 데모 인스턴서(`TerrainInstancesBehaviour`) 항목을 섬 값으로 바꾸고(크기 · 재질은 구름/밝기만 덮은 런타임 복사본)
// 인스턴서를 껐다 켜 다시 심는다. 터레인 재질도 같은 복사본으로, 디렉셔널 라이트는 섬의 색·세기·그림자 진하기로 맞춘다.
// SSAO 는 `WLIslandLookSettings.ssaoOffScenes` 에 던전 씬을 넣어 `WLSkyScope` 가 끈다(나갈 때 복원).
// ■ 값 = 전부 `WLIslandLookSettings`(섬과 같은 칸을 읽는다 → 섬을 고치면 던전도 따라온다) · `dungeonGrassMatchIsland 0` = 아무 것도 안 한다.
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using DemoInstancer = global::Environment.Instancing.TerrainInstancesBehaviour;
using DemoLayer = global::Environment.Instancing.TerrainInstancesBehaviour.TerrainInstancingInput;
namespace WL.Look.Farm
{
public static class WLDungeonGrassLook
{
// ── 진단(프로브가 읽는다)
public static int Instancers, EntriesScaled, MaterialsTuned, TerrainsTuned, LightsMatched;
public static string LastLog = "";
static readonly Dictionary<Material, Material> s_tuned = new Dictionary<Material, Material>();
public static void Apply(Scene scene)
{
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.dungeonGrassMatchIsland == 0 || !scene.IsValid() || !scene.isLoaded) return;
Mesh grassMesh = ScatterMesh(cfg, cfg.demoGrass), flowerMesh = ScatterMesh(cfg, cfg.demoFlower);
int insts = 0, scaled = 0, terrains = 0, lights = 0; int tunedBefore = s_tuned.Count;
var roots = scene.GetRootGameObjects();
for (int r = 0; r < roots.Length; r++)
{
var list = roots[r].GetComponentsInChildren<DemoInstancer>(true);
for (int i = 0; i < list.Length; i++)
{
var inst = list[i]; if (inst == null) continue;
bool was = inst.enabled;
if (was) inst.enabled = false; // OnDisable → 버퍼 해제
scaled += TuneLayer(cfg, inst.FirstLayer, grassMesh, flowerMesh);
scaled += TuneLayer(cfg, inst.SecondLayer, grassMesh, flowerMesh);
scaled += TuneLayer(cfg, inst.ThirdLayer, grassMesh, flowerMesh);
scaled += TuneLayer(cfg, inst.FourthLayer, grassMesh, flowerMesh);
if (was) inst.enabled = true; // OnEnable → 새 값으로 다시 심는다
insts++;
}
var ts = roots[r].GetComponentsInChildren<Terrain>(true);
for (int i = 0; i < ts.Length; i++)
{
var t = ts[i]; if (t == null || t.materialTemplate == null) continue;
var m = Tuned(cfg, t.materialTemplate);
if (m != t.materialTemplate) { t.materialTemplate = m; terrains++; }
}
if (cfg.dungeonLightMatchIsland != 0)
{
var ls = roots[r].GetComponentsInChildren<Light>(true);
for (int i = 0; i < ls.Length; i++)
{
var l = ls[i]; if (l == null || l.type != LightType.Directional) continue;
l.color = cfg.dirLightColor;
l.intensity = cfg.dirLightIntensity;
if (cfg.dirLightShadowStrength >= 0f) l.shadowStrength = Mathf.Clamp01(cfg.dirLightShadowStrength);
if (cfg.dirLightSoftShadows != 0 && l.shadows != LightShadows.None) l.shadows = LightShadows.Soft;
lights++;
}
}
}
Instancers = insts; EntriesScaled = scaled; TerrainsTuned = terrains; LightsMatched = lights; MaterialsTuned = s_tuned.Count;
LastLog = "던전 풀밭 = 섬 값 — 인스턴서 " + insts + " · 크기 바꾼 항목 " + scaled + " · 재질 복사본 " + s_tuned.Count + "(새로 " + (s_tuned.Count - tunedBefore) + ")"
+ " · 터레인 " + terrains + " · 태양 " + lights + " @" + scene.name;
cfg.Log(LastLog);
}
static int TuneLayer(WLIslandLookSettings cfg, DemoLayer layer, Mesh grassMesh, Mesh flowerMesh)
{
if (layer == null || layer.Settings == null) return 0;
int n = 0;
for (int i = 0; i < layer.Settings.Length; i++)
{
var e = layer.Settings[i]; if (e == null) continue;
if (e.Material != null) e.Material = Tuned(cfg, e.Material);
if (grassMesh != null && e.Mesh == grassMesh && cfg.demoGrass != null && cfg.demoGrass.scale > 0f && !Mathf.Approximately(e.Scale, cfg.demoGrass.scale)) { e.Scale = cfg.demoGrass.scale; n++; }
else if (flowerMesh != null && e.Mesh == flowerMesh && cfg.demoFlower != null && cfg.demoFlower.scale > 0f && !Mathf.Approximately(e.Scale, cfg.demoFlower.scale)) { e.Scale = cfg.demoFlower.scale; n++; }
}
return n;
}
static Mesh ScatterMesh(WLIslandLookSettings cfg, WLDemoScatter d)
{
var sc = cfg.scatter;
if (d == null || sc == null || d.scatterIndex < 0 || d.scatterIndex >= sc.Length || sc[d.scatterIndex] == null) return null;
return sc[d.scatterIndex].mesh;
}
/// <summary>섬의 `WLDemoTerrain.Tuned` 와 같은 규칙 — 구름 세기·크기·밝기 단계만 덮은 런타임 복사본(원본 재질 0줄).</summary>
static Material Tuned(WLIslandLookSettings cfg, Material src)
{
if (src == null) return null;
Material m;
if (s_tuned.TryGetValue(src, out m) && m != null) return m;
foreach (var kv in s_tuned) if (kv.Value == src) return src; // 이미 우리 복사본
bool strength = cfg.demoCloudStrength > 0f && src.HasProperty("_Cloud_Strength");
bool density = cfg.demoCloudDensity > 0f && src.HasProperty("_Cloud_Density");
bool bright = cfg.demoTerrainBrightness > 0f && src.HasProperty("_Brightness") && src.GetFloat("_Brightness") < 0.99f; // 자갈(1.0)은 그대로
if (!strength && !density && !bright) return src;
m = new Material(src) { name = src.name + " (WL dungeon=island)" };
if (strength) m.SetFloat("_Cloud_Strength", cfg.demoCloudStrength);
if (density) m.SetFloat("_Cloud_Density", cfg.demoCloudDensity);
if (bright) m.SetFloat("_Brightness", cfg.demoTerrainBrightness);
s_tuned[src] = m;
return m;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2debbf9cab3929a4aa60dcac2624d8b9

View File

@ -204,8 +204,8 @@ namespace WL.Look.Farm
[Header("§0-e — 섬에서는 SSAO 를 끈다 (렌더러 에셋 0줄 · 나갈 때 복원)")]
[Tooltip("1 = 아래 씬이 떠 있는 동안 URP 렌더러의 SSAO 피처를 끈다(전투 맵은 그대로). 0 = 손대지 않음.")]
public int islandSsaoOff = 1;
[Tooltip("SSAO 를 끌 씬 이름(섬만 · 던전은 아레나 룩 그대로).")]
public string[] ssaoOffScenes = new[] { "Level01", "WL_FarmLook" };
[Tooltip("SSAO 를 끌 씬 이름. 2026-09-17 PD 「인게임 풀밭도 섬과 동일하게」 → 던전 2개 추가(풀잎마다 발밑 그늘 = 얼룩의 원인).")]
public string[] ssaoOffScenes = new[] { "Level01", "WL_FarmLook", "WL_Dungeon01", "WL_Dungeon02" };
[Tooltip("1 이면 안개를 끈다(데모 = off).")]
public int fogOff = 1;
@ -281,6 +281,42 @@ namespace WL.Look.Farm
"2026-09-16 실측: 1.2 m 에 알파가 끝까지 남아 격자 칸 경계가 네모 계단으로 보였다 → 2 m.")]
public float shoreFoamDown = 2f;
// ── 2026-09-17 PD 「섬 가장자리의 물거품이 안개처럼 보이고 부자연스럽다 → 좀 더 자연스러운 물보라처럼」
// 옅은 그라데이션 띠(텍스처) 대신 셰이더 `WL/ShoreSpray` 가 절차적으로 그린다: ① 물가선에 붙은 **또렷한 거품 띠**(노이즈로 가장자리가 끊기고, 구간마다 다른 박자로 밀려왔다 빠진다)
// ② 그 바깥에서 해안으로 **밀려오는 가는 포말 줄** 2~3가닥(멀수록 듬성듬성). 전부 경계가 또렷한 조각이라 안개처럼 번지지 않는다.
[Header("§816zg — 물보라(절차적 거품) · 2026-09-17 PD 「안개 같다 → 자연스러운 물보라」")]
[Tooltip("1 = 물가 띠를 `WL/ShoreSpray` 셰이더로 그린다(데모 터레인 물가선 모드에서만). 0 = 예전 텍스처 띠(옅은 그라데이션).")]
public int shoreSprayEnabled = 1;
public Color shoreSprayColor = new Color(1f, 1f, 1f, 1f);
[Tooltip("거품 조각의 불투명도(0~1).")]
[Range(0f, 1f)] public float shoreSprayOpacity = 0.92f;
[Tooltip("거품 무늬 노이즈 크기(1/m). 클수록 잘게 끊긴다.")]
public float shoreSprayNoiseScale = 2.4f;
[Tooltip("물가 거품 띠가 바다 쪽으로 뻗는 폭(m) — 빠졌을 때 / 밀려왔을 때.")]
public float shoreSprayEdgeMin = 0.10f;
public float shoreSprayEdgeMax = 0.36f;
[Tooltip("물가 거품이 모래 위로 올라오는 길이(m).")]
public float shoreSpraySandReach = 0.16f;
[Tooltip("밀려왔다 빠지는 박자(rad/s).")]
public float shoreSprayWaveSpeed = 0.9f;
[Tooltip("포말 줄 사이 간격(m).")]
public float shoreSprayLineSpacing = 0.62f;
[Tooltip("포말 줄이 해안으로 밀려오는 속도(줄 간격/초).")]
public float shoreSprayLineSpeed = 0.22f;
[Tooltip("포말 줄 굵기(0~1 · 간격 대비).")]
[Range(0.02f, 0.6f)] public float shoreSprayLineWidth = 0.16f;
[Tooltip("포말 줄이 보이는 최대 거리(m · shoreFoamDown 이하).")]
public float shoreSprayLineReach = 1.7f;
[Tooltip("포말 줄이 끊기는 정도(0 = 이어진 줄 · 1 = 거의 점).")]
[Range(0f, 1f)] public float shoreSprayLineBreak = 0.6f;
// ── 2026-09-17 PD 「인게임의 풀밭의 느낌도 섬과 동일하게」
[Header("§816zh — 던전(인게임) 풀밭을 섬 값으로 맞춘다")]
[Tooltip("1 = 던전 씬의 데모 인스턴서·터레인 재질을 섬과 같은 값(풀/꽃 크기 · 구름 세기/크기 · 밝기 단계)으로 런타임에 맞춘다(씬·원본 재질 0줄). 0 = 던전 원래대로.")]
public int dungeonGrassMatchIsland = 1;
[Tooltip("1 = 던전의 디렉셔널 라이트도 섬의 색·세기·그림자 진하기(`dirLight*`)로 맞춘다.")]
public int dungeonLightMatchIsland = 1;
// ───────────────────────────────── §816zf 부드러운 흙 데칼
[Header("§816zf — 흙 자리를 데모처럼 「둥글게 번지게」 (PD 2026-09-15)")]
[Tooltip("0 = 지금(각진 1×1 흙 타일 · 100 % 복귀) · 1 = 밭 구역을 덮는 **부드러운 흙 데칼 1장**" +

View File

@ -73,6 +73,7 @@ namespace WL.Look.Farm
void Update()
{
if (cfg == null || _mr == null || _inst == null) return;
if (_spray) return; // 물보라 셰이더는 _Time 으로 스스로 움직인다(텍스처 오프셋 없음)
float sp = cfg.shoreFoamScroll;
if (sp == 0f) return;
_scroll += Time.deltaTime * sp;
@ -83,6 +84,8 @@ namespace WL.Look.Farm
_inst.SetTextureOffset(IdBase, new Vector2(_scroll, waterline ? 0f : _scroll * 0.37f));
}
Material _inst;
bool _spray;
public static bool SprayActive; public static string SprayLog = "";
static readonly int IdBase = Shader.PropertyToID("_BaseMap");
// ─────────────────────────────────────────────────────────────────
@ -93,11 +96,41 @@ namespace WL.Look.Farm
if (cfg == null || cfg.enabled_ == 0 || cfg.shoreFoamEnabled == 0)
{ _mr.enabled = false; LastLog = "꺼짐"; return; }
if (cfg.shoreFoamMaterial == null) { _mr.enabled = false; LastLog = "🔴 머티리얼 없음"; return; }
if (_inst == null || _inst.shader != cfg.shoreFoamMaterial.shader)
// 🔴 2026-09-17 PD 「물거품이 안개처럼 보인다 → 자연스러운 물보라」 — 데모 터레인 물가선 모드에서는 절차적 거품 셰이더 `WL/ShoreSpray`
// (또렷한 물가 거품 + 밀려오는 포말 줄)로 그린다. 셰이더가 없거나 `shoreSprayEnabled 0` 이면 예전 텍스처 띠.
Shader sprayShader = cfg.shoreSprayEnabled != 0 && cfg.demoTerrainEnabled != 0 ? Shader.Find("WL/ShoreSpray") : null;
_spray = sprayShader != null;
SprayActive = _spray;
if (_spray)
{
if (_inst != null) Destroy(_inst);
_inst = new Material(cfg.shoreFoamMaterial) { name = "WL_ShoreFoam(runtime)", hideFlags = HideFlags.DontSave };
if (_inst == null || _inst.shader != sprayShader)
{
if (_inst != null) Destroy(_inst);
_inst = new Material(sprayShader) { name = "WL_ShoreSpray(runtime)" };
}
_inst.SetColor("_Color", cfg.shoreSprayColor);
_inst.SetFloat("_Opacity", cfg.shoreSprayOpacity);
_inst.SetFloat("_NoiseScale", cfg.shoreSprayNoiseScale);
_inst.SetFloat("_EdgeMin", cfg.shoreSprayEdgeMin);
_inst.SetFloat("_EdgeMax", cfg.shoreSprayEdgeMax);
_inst.SetFloat("_SandReach", cfg.shoreSpraySandReach);
_inst.SetFloat("_WaveSpeed", cfg.shoreSprayWaveSpeed);
_inst.SetFloat("_LineSpacing", cfg.shoreSprayLineSpacing);
_inst.SetFloat("_LineSpeed", cfg.shoreSprayLineSpeed);
_inst.SetFloat("_LineWidth", cfg.shoreSprayLineWidth);
_inst.SetFloat("_LineReach", Mathf.Min(cfg.shoreSprayLineReach, cfg.shoreFoamDown));
_inst.SetFloat("_LineBreak", cfg.shoreSprayLineBreak);
SprayLog = "물보라 셰이더 · 물가 띠 " + cfg.shoreSprayEdgeMin.ToString("F2") + "~" + cfg.shoreSprayEdgeMax.ToString("F2") + " m · 포말 줄 간격 " + cfg.shoreSprayLineSpacing.ToString("F2") + " m";
}
else
{
if (cfg.shoreFoamMaterial == null) { _mr.enabled = false; LastLog = "🔴 머티리얼 없음"; return; }
if (_inst == null || _inst.shader != cfg.shoreFoamMaterial.shader)
{
if (_inst != null) Destroy(_inst);
_inst = new Material(cfg.shoreFoamMaterial) { name = "WL_ShoreFoam(runtime)", hideFlags = HideFlags.DontSave };
}
SprayLog = "텍스처 띠(예전 방식)";
}
_mr.sharedMaterial = _inst;
@ -217,9 +250,12 @@ namespace WL.Look.Farm
var tp = terrain.transform.position;
var sz = terrain.terrainData.size;
// 띠 텍스처 = 런타임 생성(물가선에서 진하고 모래 쪽은 짧게 · 바다 쪽은 길게 들쭉날쭉 사라진다) — 에셋 텍스처는 네모 띠(816j2)용이라 가장자리가 딱 끊긴다
var tex = FoamTexture(up / (up + down));
_inst.SetTexture(IdBase, tex);
if (_inst.HasProperty("_MainTex")) _inst.SetTexture("_MainTex", tex);
if (!_spray)
{
var tex = FoamTexture(up / (up + down));
_inst.SetTexture(IdBase, tex);
if (_inst.HasProperty("_MainTex")) _inst.SetTexture("_MainTex", tex);
}
int nx = Mathf.Clamp(Mathf.CeilToInt(sz.x / cell) + 1, 2, 1024);
int nz = Mathf.Clamp(Mathf.CeilToInt(sz.z / cell) + 1, 2, 1024);
float stepX = sz.x / (nx - 1), stepZ = sz.z / (nz - 1);
@ -280,6 +316,7 @@ namespace WL.Look.Farm
for (int k = 0; k < vmap.Length; k++) vmap[k] = -1;
var verts = new List<Vector3>(2048);
var uvs = new List<Vector2>(2048);
var uvs2 = new List<Vector2>(2048); // x = 물가선까지 수평 거리(m · + 바다 / − 모래) — 물보라 셰이더 입력
var tris = new List<int>(4096);
for (int j = 0; j < nz - 1; j++)
@ -302,6 +339,7 @@ namespace WL.Look.Farm
verts.Add(new Vector3(x, Mathf.Max(hgt[k], waterY) + lift, z));
float v = Mathf.Clamp01((f[k] + up) / (down + up));
uvs.Add(new Vector2((x * 0.7071f + z * 0.7071f) / tileLen, v));
uvs2.Add(new Vector2(Mathf.Clamp(f[k], -up - 1f, down + 1f), 0f));
vmap[k] = verts.Count - 1;
return vmap[k];
}
@ -312,6 +350,7 @@ namespace WL.Look.Farm
? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
_mesh.SetVertices(verts);
_mesh.SetUVs(0, uvs);
_mesh.SetUVs(1, uvs2);
_mesh.SetTriangles(tris, 0);
_mesh.RecalculateBounds();
_mr.enabled = true;