Merge branch 'wl/systems/WL-816zf-soft-dirt'

This commit is contained in:
깃 관리자 2026-09-15 19:11:37 +09:00
commit 6b3040366d
5 changed files with 479 additions and 10 deletions

View File

@ -300,17 +300,24 @@ namespace WL.Island
var rends = f.GetComponentsInChildren<Renderer>(true); var rends = f.GetComponentsInChildren<Renderer>(true);
for (int r = 0; r < rends.Length; r++) for (int r = 0; r < rends.Length; r++)
{ {
if (rends[r] == null || !rends[r].enabled) continue; var rr = rends[r];
if (rr == null) continue;
// 🔴 WL-816zf — 구역 바운즈는 **이미 꺼 둔 렌더러까지 센다**.
// 3 s 재훑기에서는 밭 렌더러가 전부 꺼져 있어, 켜진 것만 세면 두 번째 훑기부터
// 구역을 통째로 잃는다(ZoneBoundsValid = false → 흙 데칼·발판 자리 소실).
// 끈 렌더러도 `bounds` 는 그대로 유효하다.
if (visible) if (visible)
{ {
if (!boundsSet) { b = rends[r].bounds; boundsSet = true; } if (!boundsSet) { b = rr.bounds; boundsSet = true; }
else b.Encapsulate(rends[r].bounds); else b.Encapsulate(rr.bounds);
if (!zoneSet) { zone = rends[r].bounds; zoneSet = true; } // WL-816u — 구역 전체 if (!zoneSet) { zone = rr.bounds; zoneSet = true; } // WL-816u — 구역 전체
else zone.Encapsulate(rends[r].bounds); else zone.Encapsulate(rr.bounds);
} }
if (!rr.enabled) continue;
// 🔴 WL-816w ③ — 흙칸(Soil)은 남긴다(밭 자리가 흙으로 보이게). 콜라이더는 아래에서 끈다. // 🔴 WL-816w ③ — 흙칸(Soil)은 남긴다(밭 자리가 흙으로 보이게). 콜라이더는 아래에서 끈다.
if (IsSoilRenderer(cfg, rends[r])) { SoilRenderersKept++; continue; } // 816zf 데칼이 켜져 있으면 `IsSoilRenderer` 가 false 라 흙칸도 여기서 꺼진다(배타).
rends[r].enabled = false; s_hiddenRends.Add(rends[r]); RenderersHidden++; if (IsSoilRenderer(cfg, rr)) { SoilRenderersKept++; continue; }
rr.enabled = false; s_hiddenRends.Add(rr); RenderersHidden++;
} }
var cols = f.GetComponentsInChildren<Collider>(true); var cols = f.GetComponentsInChildren<Collider>(true);
@ -368,10 +375,15 @@ namespace WL.Island
/// <summary>WL-816w 진단 — 남겨 둔 흙칸 렌더러 수 · 끈 울타리 렌더러 수.</summary> /// <summary>WL-816w 진단 — 남겨 둔 흙칸 렌더러 수 · 끈 울타리 렌더러 수.</summary>
public static int SoilRenderersKept, FenceRenderersHidden; public static int SoilRenderersKept, FenceRenderersHidden;
/// <summary>WL-816w ③ — 이 렌더러가 흙칸(FI `Soil`)의 것인가(자기 자신 또는 부모).</summary> /// <summary>
/// WL-816w ③ — 이 렌더러가 흙칸(FI `Soil`)의 것인가(자기 자신 또는 부모).
/// 🔴 WL-816zf — 부드러운 흙 데칼(`WLSoftDirt`)이 켜져 있으면 **false 를 돌려 흙 타일을 숨긴다**.
/// 각진 1×1 타일과 둥근 데칼이 겹치면 데칼 가장자리 밖으로 타일 모서리가 삐져나온다(배타).
/// </summary>
static bool IsSoilRenderer(WLIslandSettings cfg, Renderer r) static bool IsSoilRenderer(WLIslandSettings cfg, Renderer r)
{ {
if (cfg.showFarmSoil == 0 || r == null) return false; if (cfg.showFarmSoil == 0 || r == null) return false;
if (global::WL.Look.Farm.WLSoftDirt.Active) return false;
return r.GetComponentInParent<FISoil>(true) != null; return r.GetComponentInParent<FISoil>(true) != null;
} }

View File

@ -62,6 +62,8 @@ namespace WL.Look.Farm
readonly List<Rect> _blockers = new List<Rect>(128); readonly List<Rect> _blockers = new List<Rect>(128);
// 816za — 밭 자리만 따로. 반듯한 사각형이 아니라 「모서리 둥글게 + 경계 노이즈」로 판정한다. // 816za — 밭 자리만 따로. 반듯한 사각형이 아니라 「모서리 둥글게 + 경계 노이즈」로 판정한다.
readonly List<Rect> _farmRounds = new List<Rect>(8); readonly List<Rect> _farmRounds = new List<Rect>(8);
// 816zf — 흙 데칼이 살아 있으면 밭 제외는 위 두 목록 대신 `WLSoftDirt` 마스크가 판정한다.
bool _softDirt;
// ── 816o 캐시 — 한 번 뽑은 후보를 들고 있다가, 구름이 흐르면 「고르기」만 다시 한다 ── // ── 816o 캐시 — 한 번 뽑은 후보를 들고 있다가, 구름이 흐르면 「고르기」만 다시 한다 ──
struct Cand { public float x, z, y; public int def; public float sc; public float yaw; } struct Cand { public float x, z, y; public int def; public float sc; public float yaw; }
@ -741,11 +743,13 @@ namespace WL.Look.Farm
_blockers.Clear(); _blockers.Clear();
_farmRounds.Clear(); _farmRounds.Clear();
bool round = cfg.farmEdgeRoundEnabled != 0 && cfg.farmEdgeRadius > 0f; bool round = cfg.farmEdgeRoundEnabled != 0 && cfg.farmEdgeRadius > 0f;
// 816zf — 흙 데칼이 경계를 쥐고 있으면 밭 제외는 **데칼 마스크**가 정한다(경계 정의 1개).
_softDirt = WLSoftDirt.Ready;
var scene = tiles.Count > 0 ? tiles[0].tf.gameObject.scene : default(UnityEngine.SceneManagement.Scene); var scene = tiles.Count > 0 ? tiles[0].tf.gameObject.scene : default(UnityEngine.SceneManagement.Scene);
// ① 농사 — Farm 이 Awake 에서 만드는 BoxCollider(size = (length,2,width))가 밭의 정확한 넓이다. // ① 농사 — Farm 이 Awake 에서 만드는 BoxCollider(size = (length,2,width))가 밭의 정확한 넓이다.
var farms = Object.FindObjectsByType<FIFarm>(FindObjectsInactive.Include, FindObjectsSortMode.None); var farms = Object.FindObjectsByType<FIFarm>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < farms.Length; i++) for (int i = 0; i < farms.Length && !_softDirt; i++)
{ {
var f = farms[i]; var f = farms[i];
if (f == null || f.gameObject.scene != scene) continue; if (f == null || f.gameObject.scene != scene) continue;
@ -758,8 +762,9 @@ namespace WL.Look.Farm
// ② 농사 타일 하나하나(1×1) — Farm 이 런타임에 만든다. 이중 안전. // ② 농사 타일 하나하나(1×1) — Farm 이 런타임에 만든다. 이중 안전.
// 라운드 모드에서는 ① 안에 든 타일을 빼야 한다. 안 그러면 반듯한 1×1 사각형들이 깎아낸 모서리를 도로 메운다. // 라운드 모드에서는 ① 안에 든 타일을 빼야 한다. 안 그러면 반듯한 1×1 사각형들이 깎아낸 모서리를 도로 메운다.
// 816zf 데칼 모드에서는 1×1 사각형이 **둥근 경계를 도로 각지게** 만들므로 아예 넣지 않는다.
var soils = Object.FindObjectsByType<FISoil>(FindObjectsInactive.Include, FindObjectsSortMode.None); var soils = Object.FindObjectsByType<FISoil>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < soils.Length; i++) for (int i = 0; i < soils.Length && !_softDirt; i++)
{ {
var s = soils[i]; var s = soils[i];
if (s == null || s.gameObject.scene != scene) continue; if (s == null || s.gameObject.scene != scene) continue;
@ -801,6 +806,9 @@ namespace WL.Look.Farm
var r = _blockers[i]; var r = _blockers[i];
if (x >= r.xMin && x <= r.xMax && z >= r.yMin && z <= r.yMax) return true; if (x >= r.xMin && x <= r.xMax && z >= r.yMin && z <= r.yMax) return true;
} }
// 816zf — 흙 데칼과 **같은 경계**를 쓴다. 데칼 가장자리보다 `softDirtGrassBite` 안쪽만 막으므로
// 그 폭만큼 풀 톱니가 흙 안으로 파고든다(두 군데서 따로 계산하지 않는다).
if (_softDirt) return WLSoftDirt.Blocks(x, z);
for (int i = 0; i < _farmRounds.Count; i++) for (int i = 0; i < _farmRounds.Count; i++)
if (InRoundedFarm(_farmRounds[i], x, z)) return true; if (InRoundedFarm(_farmRounds[i], x, z)) return true;
return false; return false;

View File

@ -132,6 +132,8 @@ namespace WL.Look.Farm
if (cfg.applyLook != 0 && cfg.applyReferenceLook != 0) ApplyReferenceLook(cfg, scene); if (cfg.applyLook != 0 && cfg.applyReferenceLook != 0) ApplyReferenceLook(cfg, scene);
if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene); if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene);
// 🔴 816zf — 흙 데칼은 **풀보다 먼저** 만든다. 풀의 밭 제외가 이 마스크를 읽기 때문이다.
if (cfg.softDirtEnabled != 0) SpawnSoftDirt(cfg, scene);
if (cfg.grassEnabled != 0) SpawnGrass(cfg, scene); if (cfg.grassEnabled != 0) SpawnGrass(cfg, scene);
if (cfg.shoreFoamEnabled != 0) SpawnShoreFoam(cfg, scene); if (cfg.shoreFoamEnabled != 0) SpawnShoreFoam(cfg, scene);
@ -141,6 +143,7 @@ namespace WL.Look.Farm
+ " · 조명 " + (cfg.applyLighting != 0 ? "데모" : "원본") + " · 조명 " + (cfg.applyLighting != 0 ? "데모" : "원본")
+ " · ReferenceLook " + (ReferenceLookApplied ? "on" : "off") + " · ReferenceLook " + (ReferenceLookApplied ? "on" : "off")
+ " · 풀 " + WLIslandGrass.LastLog + " · 풀 " + WLIslandGrass.LastLog
+ " · 흙데칼 " + WLSoftDirt.LastLog
+ " · 둘레거품 " + WLShoreFoam.LastLog); + " · 둘레거품 " + WLShoreFoam.LastLog);
// 늦게 생기는 오브젝트(상인·작물·아이템)까지 다시 훑는다 // 늦게 생기는 오브젝트(상인·작물·아이템)까지 다시 훑는다
@ -159,6 +162,18 @@ namespace WL.Look.Farm
// 그래서 조명은 재훑기에서 **다시 걸지 않는다** — 다시 걸면 `WLReferenceLook` 의 // 그래서 조명은 재훑기에서 **다시 걸지 않는다** — 다시 걸면 `WLReferenceLook` 의
// 무드(Flat)를 Skybox 로 되돌려 오히려 룩이 바뀐다(실측으로 확인). // 무드(Flat)를 Skybox 로 되돌려 오히려 룩이 바뀐다(실측으로 확인).
if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene); if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene);
// 🔴 816zf — 밭 구역(`WLIslandGateFlow.ZoneBounds`)은 게이트 플로우가 밭을 훑은 뒤에야 정해진다.
// 재훑기에서 구역이 처음 잡히거나 바뀌면 데칼을 굽고, **그때만** 풀도 다시 깐다(경계가 같아지게).
if (cfg.softDirtEnabled != 0)
{
int before = WLSoftDirt.Rebuilds;
SpawnSoftDirt(cfg, scene);
if (WLSoftDirt.Rebuilds != before && cfg.grassEnabled != 0)
{
var g2 = Object.FindFirstObjectByType<WLIslandGrass>(FindObjectsInactive.Include);
if (g2 != null) { g2.Rebuild(); GrassRebuilds++; }
}
}
HookIslands(cfg, scene); HookIslands(cfg, scene);
} }
} }
@ -249,6 +264,13 @@ namespace WL.Look.Farm
static void RequestGrassRebuild(WLIslandLookSettings cfg) static void RequestGrassRebuild(WLIslandLookSettings cfg)
{ {
// 🔴 816zf — 흙 데칼을 **먼저** 다시 굽는다. 섬 확장·밭 재구성으로 구역이 바뀌면
// 풀의 밭 제외도 그 새 경계를 읽어야 흙과 풀이 같은 선에서 만난다.
if (cfg.softDirtEnabled != 0)
{
var sd = Object.FindFirstObjectByType<WLSoftDirt>(FindObjectsInactive.Include);
if (sd != null) { sd.cfg = cfg; sd.Rebuild(); }
}
if (cfg.grassEnabled != 0) if (cfg.grassEnabled != 0)
{ {
var g = Object.FindFirstObjectByType<WLIslandGrass>(FindObjectsInactive.Include); var g = Object.FindFirstObjectByType<WLIslandGrass>(FindObjectsInactive.Include);
@ -264,6 +286,24 @@ namespace WL.Look.Farm
public static int ShoreFoamRebuilds; public static int ShoreFoamRebuilds;
public static bool ShoreFoamSpawned; public static bool ShoreFoamSpawned;
public static bool SoftDirtSpawned;
/// <summary>816zf — 밭 자리 부드러운 흙 데칼. 풀·거품과 같은 자리에서 만들고 같은 훅으로 다시 만든다.</summary>
public static void SpawnSoftDirt(WLIslandLookSettings cfg, Scene scene)
{
var d = Object.FindFirstObjectByType<WLSoftDirt>(FindObjectsInactive.Include);
if (d == null)
{
var go = new GameObject(WLSoftDirt.ObjectName);
SceneManager.MoveGameObjectToScene(go, scene);
go.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
d = go.AddComponent<WLSoftDirt>();
d.cfg = cfg;
d.Rebuild(); // OnEnable 이 cfg 전에 돌 수 있다
}
else { d.cfg = cfg; d.Rebuild(); }
SoftDirtSpawned = true;
}
/// <summary>섬 둘레 거품 띠 — 풀과 같은 자리에서 만들고 같은 훅으로 다시 만든다(816j2).</summary> /// <summary>섬 둘레 거품 띠 — 풀과 같은 자리에서 만들고 같은 훅으로 다시 만든다(816j2).</summary>
public static void SpawnShoreFoam(WLIslandLookSettings cfg, Scene scene) public static void SpawnShoreFoam(WLIslandLookSettings cfg, Scene scene)

View File

@ -241,6 +241,57 @@ namespace WL.Look.Farm
[Tooltip("거품이 흐르는 속도(1 = 초당 무늬 1칸). 0 이면 정지.")] [Tooltip("거품이 흐르는 속도(1 = 초당 무늬 1칸). 0 이면 정지.")]
public float shoreFoamScroll = 0.06f; public float shoreFoamScroll = 0.06f;
// ───────────────────────────────── §816zf 부드러운 흙 데칼
[Header("§816zf — 흙 자리를 데모처럼 「둥글게 번지게」 (PD 2026-09-15)")]
[Tooltip("0 = 지금(각진 1×1 흙 타일 · 100 % 복귀) · 1 = 밭 구역을 덮는 **부드러운 흙 데칼 1장**" +
"(드로우콜 +1)으로 바꾸고 흙 타일 렌더러는 숨긴다(`WLIslandSettings.showFarmSoil` 과 배타). " +
"816za 풀 제외도 이 데칼과 **같은 경계**를 쓰게 된다.")]
public int softDirtEnabled = 1;
[Tooltip("데칼에 쓸 머티리얼. **비워 두는 것이 기본** — 비우면 816j2 거품 띠와 같은 URP Unlit 셰이더로 " +
"런타임에 반투명 머티리얼을 만든다(에셋 0줄). 채우면 그 머티리얼의 복사본을 쓴다.")]
public Material softDirtMaterial;
[Tooltip("흙 색(리니어) — 816r 데모 흙 톤 `#A4A88B` 를 리니어로 옮긴 값.")]
public Color softDirtColor = new Color(0.371f, 0.392f, 0.258f, 1f);
[Tooltip("데칼 전체의 불투명도. 1 = 흙색 그대로 · 낮추면 밑의 잔디가 비쳐 흙이 옅어진다.")]
[Range(0f, 1f)] public float softDirtOpacity = 1f;
[Tooltip("흙칸 윗면보다 얼마나 위에 깔 것인가(m). 너무 작으면 z-fighting, 너무 크면 떠 보인다.")]
public float softDirtY = 0.02f;
[Tooltip("밭 구역 바운즈를 사방으로 이만큼(m) 넓혀 흙으로 친다. 데칼 가장자리가 페이드로 " +
"물러나는 만큼을 메워, 흙 자리가 지금보다 좁아 보이지 않게 한다.")]
public float softDirtExpand = 0.2f;
[Tooltip("흙 사각형의 네 모서리를 깎는 반지름(m). 클수록 둥글게 번진 모양으로 읽힌다.")]
public float softDirtCornerRadius = 1.4f;
[Tooltip("흙 경계를 흔드는 진폭(m). 이만큼 흙이 풀 쪽으로 밀려나거나 물러나 경계가 들쭉날쭉해진다.")]
public float softDirtNoise = 0.45f;
[Tooltip("경계 노이즈의 셀 크기(m). 작을수록 잘게 들쭉날쭉하고, 클수록 크게 굽이친다.")]
public float softDirtNoiseCell = 0.9f;
[Tooltip("가장자리가 흙 → 풀로 사라지는 폭(m). 데모의 스플랫 번짐에 해당한다(0.8~1.2 권장).")]
public float softDirtFeather = 1.0f;
[Tooltip("풀을 데칼 가장자리보다 이만큼(m) **안쪽까지** 들여보낸다 — 흙 가장자리에 풀 톱니가 " +
"파고든 것처럼 보이게 하는 값. 0 이면 풀이 흙 경계에서 딱 끊긴다.")]
public float softDirtGrassBite = 0.3f;
[Tooltip("데칼 사각형을 구역 바깥으로 더 두는 여유 폭(m). 페이드+노이즈가 잘리지 않을 만큼 " +
"자동으로 커진다 — 보통 손댈 일이 없다.")]
public float softDirtPad = 1.5f;
[Tooltip("마스크 텍스처 한 변의 픽셀 수. 256 = 256 KB · 굽는 데 한 번 몇 ms. " +
"경계가 뭉개져 보이면 512 로 올린다.")]
public int softDirtTexRes = 256;
[Tooltip("흙 얼룩의 세기(0~1). 한 덩어리 단색으로 보이지 않게 밝기를 이만큼 흔든다.")]
[Range(0f, 1f)] public float softDirtTexNoise = 0.08f;
// ───────────────────────────────── §1 풀밭 // ───────────────────────────────── §1 풀밭
[Header("§1 — 섬 타일 위 풀밭 (데모와 같은 인스턴싱)")] [Header("§1 — 섬 타일 위 풀밭 (데모와 같은 인스턴싱)")]
[Tooltip("0 이면 풀을 깔지 않는다.")] [Tooltip("0 이면 풀을 깔지 않는다.")]

View File

@ -0,0 +1,358 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSoftDirt.cs — 밭(흙) 자리를 데모처럼 **둥글게 번지는 한 장**으로 (WL-816zf · #816)
//
// PD 지시(2026-09-15) 「데모씬을 보면 자연스럽게 흙과 잔디가 라운딩 된 것처럼 배치되어 있어.
// 데모씬과 유사하게 맞출 방법을 찾아서 최대한 맞춰.」
//
// ■ 왜 각져 보이는가
// 데모는 **터레인 스플랫**이라 흙이 잔디 속으로 부드럽게 번진다.
// 우리 섬은 FI 의 **1×1 흙 타일(Soil)** 묶음이라 경계가 계단처럼 각진다.
// 타일을 아무리 골라 숨겨도 최소 단위가 1 m 라 「둥글게」가 안 나온다.
//
// ■ 어떻게 바꾸는가
// 밭 구역(`WLIslandGateFlow.ZoneBounds`)을 덮는 **평면 메시 1장**(4정점 · 2삼각형)에
// 런타임에 구운 **마스크 텍스처**(모서리를 깎은 사각형 + 경계 노이즈 + 가장자리 페이드)를 씌운다.
// 모양은 전부 텍스처의 알파가 만들므로 메시는 사각형 하나면 된다 = **드로우콜 +1**.
// 켜지면 각진 흙 타일 렌더러는 숨긴다(`WLIslandGateFlow.IsSoilRenderer` 가 배타 처리).
//
// ■ 풀과 흙이 같은 선을 쓴다
// 816za 의 풀 제외(밭 자리)는 이 파일의 `SignedDist` 를 그대로 쓴다.
// 풀은 데칼 가장자리보다 `softDirtGrassBite`(m) **안쪽까지** 들어와, 흙 가장자리에
// 풀 톱니가 파고든 것처럼 읽힌다 — 경계를 두 군데서 따로 계산하지 않는다.
//
// ■ 되돌리기 — `softDirtEnabled = 0` 이면 흙 타일이 그대로 보이고 풀 제외도 816za 방식으로 돌아간다.
//
// 🔴 FI 코드 0줄 · 씬 파일 0줄 · 에셋 0줄. 메시·텍스처·머티리얼 전부 런타임 생성이다.
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using FISoil = CryingSnow.FarmingIsland.Soil;
namespace WL.Look.Farm
{
[DisallowMultipleComponent]
public sealed class WLSoftDirt : MonoBehaviour
{
public const string ObjectName = "~WL_SoftDirt";
// ── 진단(프로브·보고가 읽는다)
public static int Rebuilds;
public static string LastLog = "꺼짐";
public WLIslandLookSettings cfg;
MeshFilter _mf;
MeshRenderer _mr;
Mesh _mesh;
Texture2D _tex;
Material _inst;
static readonly int IdBaseMap = Shader.PropertyToID("_BaseMap");
static readonly int IdMainTex = Shader.PropertyToID("_MainTex");
static readonly int IdBaseColor = Shader.PropertyToID("_BaseColor");
static readonly int IdColor = Shader.PropertyToID("_Color");
static readonly int IdSurface = Shader.PropertyToID("_Surface");
static readonly int IdBlend = Shader.PropertyToID("_Blend");
static readonly int IdSrcBlend = Shader.PropertyToID("_SrcBlend");
static readonly int IdDstBlend = Shader.PropertyToID("_DstBlend");
static readonly int IdZWrite = Shader.PropertyToID("_ZWrite");
static readonly int IdCull = Shader.PropertyToID("_Cull");
// ─────────────────────────────────────────────────────────────────
// 마스크 — 흙 경계의 **단일 정의**. 데칼 텍스처와 풀 제외가 같은 값을 읽는다.
// ─────────────────────────────────────────────────────────────────
static bool s_ready;
static float s_cx, s_cz, s_hx, s_hz, s_rad, s_noise, s_cell, s_bite;
/// <summary>설정이 데칼을 쓰기로 했는가 — 흙 타일 숨김(배타)을 가르는 값. 구역을 몰라도 참이다.</summary>
public static bool Active
{
get
{
var c = WLIslandLookSettings.Instance;
return c != null && c.enabled_ != 0 && c.softDirtEnabled != 0;
}
}
/// <summary>마스크를 실제로 계산해 두었는가(밭 구역을 알아낸 뒤). 풀 제외는 이것이 참일 때만 바뀐다.</summary>
public static bool Ready { get { return s_ready && Active; } }
/// <summary>
/// 흙 경계까지의 부호 있는 거리(m). **음수 = 흙 안쪽 · 양수 = 바깥(풀)**.
/// 모서리를 `softDirtCornerRadius` 만큼 깎은 사각형에 셀 노이즈로 경계를 흔든 모양이다.
/// </summary>
public static float SignedDist(float x, float z)
{
float dx = Mathf.Abs(x - s_cx) - (s_hx - s_rad);
float dz = Mathf.Abs(z - s_cz) - (s_hz - s_rad);
float ox = Mathf.Max(dx, 0f), oz = Mathf.Max(dz, 0f);
float d = Mathf.Min(Mathf.Max(dx, dz), 0f) + Mathf.Sqrt(ox * ox + oz * oz) - s_rad;
if (s_noise > 0f && s_cell > 0.0001f)
d -= (CellNoise(x / s_cell, z / s_cell) * 2f - 1f) * s_noise;
return d;
}
/// <summary>여기에 풀을 깔지 말 것인가 — 데칼 가장자리보다 `softDirtGrassBite` 만큼 **더 안쪽**만 막는다.</summary>
public static bool Blocks(float x, float z)
{
if (!s_ready) return false;
// 싸게 먼저 거른다 — 구역 밖이면 노이즈를 계산하지 않는다.
if (Mathf.Abs(x - s_cx) > s_hx + s_noise || Mathf.Abs(z - s_cz) > s_hz + s_noise) return false;
return SignedDist(x, z) < -s_bite;
}
// ─────────────────────────────────────────────────────────────────
void OnEnable() { Ensure(); _dirty = true; Rebuild(); }
void OnDisable() { if (_mr != null) _mr.enabled = false; s_ready = false; }
void OnDestroy()
{
if (_inst != null) Destroy(_inst);
if (_tex != null) Destroy(_tex);
if (_mesh != null) Destroy(_mesh);
s_ready = false;
}
void Ensure()
{
if (_mf == null) _mf = GetComponent<MeshFilter>();
if (_mf == null) _mf = gameObject.AddComponent<MeshFilter>();
if (_mr == null) _mr = GetComponent<MeshRenderer>();
if (_mr == null) _mr = gameObject.AddComponent<MeshRenderer>();
_mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_mr.receiveShadows = false;
_mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
_mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
if (_mesh == null)
{
_mesh = new Mesh { name = "WL_SoftDirtMesh" };
_mesh.MarkDynamic();
_mesh.hideFlags = HideFlags.DontSave;
}
_mf.sharedMesh = _mesh;
}
// 마지막으로 구운 조건 — 같으면 다시 굽지 않는다(1초 폴링에서 공짜로 돌게).
bool _dirty = true;
float _lastCx, _lastCz, _lastHx, _lastHz, _lastY, _lastSig = float.NaN;
/// <summary>다시 만든다(섬 확장·밭 재구성). 멱등 — 조건이 그대로면 아무것도 하지 않는다.</summary>
public void Rebuild()
{
Ensure();
if (cfg == null) cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.softDirtEnabled == 0)
{ _mr.enabled = false; s_ready = false; LastLog = "꺼짐"; return; }
if (!global::WL.Island.WLIslandGateFlow.ZoneBoundsValid)
{ _mr.enabled = false; s_ready = false; LastLog = "밭 구역 미확정 — 대기"; return; }
var zone = global::WL.Island.WLIslandGateFlow.ZoneBounds;
float hx = zone.extents.x + cfg.softDirtExpand;
float hz = zone.extents.z + cfg.softDirtExpand;
if (hx <= 0.01f || hz <= 0.01f)
{ _mr.enabled = false; s_ready = false; LastLog = "밭 구역 넓이 0"; return; }
float y = SoilTopY(zone) + cfg.softDirtY;
// 값이 바뀌면 다시 굽는다(인스펙터에서 만져도 다음 폴링에 반영된다).
float sig = cfg.softDirtCornerRadius * 7.1f + cfg.softDirtNoise * 13.3f + cfg.softDirtNoiseCell * 3.7f
+ cfg.softDirtFeather * 29.3f + cfg.softDirtGrassBite * 5.9f + cfg.softDirtPad * 11.9f
+ cfg.softDirtTexRes * 0.017f + cfg.softDirtTexNoise * 23.1f + cfg.softDirtOpacity * 17.7f
+ cfg.softDirtColor.r * 101f + cfg.softDirtColor.g * 103f + cfg.softDirtColor.b * 107f;
bool same = !_dirty && _tex != null && _inst != null
&& Mathf.Abs(_lastCx - zone.center.x) < 0.001f && Mathf.Abs(_lastCz - zone.center.z) < 0.001f
&& Mathf.Abs(_lastHx - hx) < 0.001f && Mathf.Abs(_lastHz - hz) < 0.001f
&& Mathf.Abs(_lastY - y) < 0.001f && Mathf.Abs(_lastSig - sig) < 0.0001f;
// 마스크 값은 매번 갱신한다 — 풀(816za)이 읽는 단일 출처다.
s_cx = zone.center.x; s_cz = zone.center.z;
s_hx = hx; s_hz = hz;
s_rad = Mathf.Clamp(cfg.softDirtCornerRadius, 0f, Mathf.Min(hx, hz));
s_noise = Mathf.Max(0f, cfg.softDirtNoise);
s_cell = Mathf.Max(0.05f, cfg.softDirtNoiseCell);
s_bite = Mathf.Max(0f, cfg.softDirtGrassBite);
s_ready = true;
if (same) { _mr.enabled = true; return; }
if (!EnsureMaterial()) { _mr.enabled = false; s_ready = false; LastLog = "🔴 URP Unlit 셰이더를 못 찾음"; return; }
float feather = Mathf.Max(0.05f, cfg.softDirtFeather);
float pad = Mathf.Max(cfg.softDirtPad, feather + s_noise + 0.25f);
int res = Mathf.Clamp(cfg.softDirtTexRes, 32, 1024);
BakeMask(hx + pad, hz + pad, res, feather);
BuildQuad(hx + pad, hz + pad, y);
_inst.SetTexture(IdBaseMap, _tex);
if (_inst.HasProperty(IdMainTex)) _inst.SetTexture(IdMainTex, _tex);
var col = cfg.softDirtColor; col.a = Mathf.Clamp01(cfg.softDirtOpacity);
_inst.SetColor(IdBaseColor, col);
if (_inst.HasProperty(IdColor)) _inst.SetColor(IdColor, col);
_mr.sharedMaterial = _inst;
_mr.enabled = true;
_lastCx = s_cx; _lastCz = s_cz; _lastHx = hx; _lastHz = hz; _lastY = y; _lastSig = sig;
_dirty = false;
Rebuilds++;
LastLog = "흙 데칼 — 구역 " + (hx * 2f).ToString("F1") + "×" + (hz * 2f).ToString("F1")
+ " m · 모서리 R " + s_rad.ToString("F2") + " m · 노이즈 ±" + s_noise.ToString("F2")
+ " m(셀 " + s_cell.ToString("F2") + ") · 페이드 " + feather.ToString("F2")
+ " m · 풀 파고듦 " + s_bite.ToString("F2") + " m · 텍스처 " + res + "² · 드로우콜 +1";
cfg.Log(LastLog);
}
// ─────────────────────────────────────────────────────────────────
/// <summary>
/// 흙칸(Soil) 윗면 높이 — 데칼을 여기 바로 위에 깐다(밭 바닥이 곧 걸어다니는 면).
/// 렌더러를 꺼 두어도 `bounds` 는 유효하다. 구역 안에 흙칸이 하나도 없으면 구역 바닥을 쓴다.
/// </summary>
static float SoilTopY(Bounds zone)
{
float y = float.MinValue;
var soils = Object.FindObjectsByType<FISoil>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < soils.Length; i++)
{
var s = soils[i];
if (s == null) continue;
var p = s.transform.position;
if (Mathf.Abs(p.x - zone.center.x) > zone.extents.x + 1f) continue; // 다른 섬의 밭은 뺀다
if (Mathf.Abs(p.z - zone.center.z) > zone.extents.z + 1f) continue;
var r = s.GetComponentInChildren<Renderer>(true);
float t = r != null ? r.bounds.max.y : p.y;
if (t > y) y = t;
}
return y > -1e8f ? y : zone.min.y;
}
/// <summary>URP Unlit 반투명 런타임 머티리얼(816j2 `Farm_ShoreFoam.mat` 과 같은 구성).</summary>
bool EnsureMaterial()
{
if (cfg.softDirtMaterial != null)
{
if (_inst == null || _inst.shader != cfg.softDirtMaterial.shader)
{
if (_inst != null) Destroy(_inst);
_inst = new Material(cfg.softDirtMaterial) { name = "WL_SoftDirt(runtime)", hideFlags = HideFlags.DontSave };
}
return true;
}
if (_inst != null) return true;
// 에셋을 새로 만들지 않는다 — 816j2 거품 띠가 쓰는 URP Unlit 셰이더를 그대로 빌려 쓴다.
Shader sh = cfg.shoreFoamMaterial != null ? cfg.shoreFoamMaterial.shader : null;
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Transparent");
if (sh == null) return false;
_inst = new Material(sh) { name = "WL_SoftDirt(runtime)", hideFlags = HideFlags.DontSave };
// URP Unlit 을 반투명으로 — Farm_ShoreFoam.mat 실측값과 같다(_Surface 1 · SrcAlpha/OneMinusSrcAlpha · ZWrite 0).
if (_inst.HasProperty(IdSurface)) _inst.SetFloat(IdSurface, 1f);
if (_inst.HasProperty(IdBlend)) _inst.SetFloat(IdBlend, 0f);
if (_inst.HasProperty(IdSrcBlend)) _inst.SetFloat(IdSrcBlend, (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
if (_inst.HasProperty(IdDstBlend)) _inst.SetFloat(IdDstBlend, (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
if (_inst.HasProperty(IdZWrite)) _inst.SetFloat(IdZWrite, 0f);
if (_inst.HasProperty(IdCull)) _inst.SetFloat(IdCull, (float)UnityEngine.Rendering.CullMode.Off);
_inst.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
_inst.SetShaderPassEnabled("ShadowCaster", false);
_inst.SetShaderPassEnabled("DepthOnly", false);
_inst.SetShaderPassEnabled("MOTIONVECTORS", false);
_inst.SetOverrideTag("RenderType", "Transparent");
// 거품 띠(3050)보다 먼저 — 땅에 붙는 데칼이 먼저 깔리고 그 위로 물가 거품이 온다.
_inst.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
return true;
}
/// <summary>마스크를 굽는다 — 알파 = 흙 덮임(1) → 풀(0) 으로 `feather`(m) 폭에서 부드럽게 넘어간다.</summary>
void BakeMask(float halfX, float halfZ, int res, float feather)
{
// 구운 뒤 CPU 사본을 버리므로(아래 `Apply(false, true)`) 다시 구울 때는 새로 만든다.
if (_tex != null) Destroy(_tex);
// linear:true — RGB 값을 그대로 곱하게 한다(sRGB 변환이 끼면 얼룩 세기가 뜻대로 안 나온다).
_tex = new Texture2D(res, res, TextureFormat.RGBA32, false, true)
{ name = "WL_SoftDirtMask", hideFlags = HideFlags.DontSave, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear };
float texNoise = Mathf.Clamp01(cfg.softDirtTexNoise);
float half = feather * 0.5f;
var px = new Color32[res * res];
float stepX = halfX * 2f / res, stepZ = halfZ * 2f / res;
for (int j = 0; j < res; j++)
{
float wz = s_cz - halfZ + (j + 0.5f) * stepZ;
for (int i = 0; i < res; i++)
{
float wx = s_cx - halfX + (i + 0.5f) * stepX;
float d = SignedDist(wx, wz);
// 1 = 흙 · 0 = 풀. 가장자리에서 feather(m) 폭으로 부드럽게 사라진다.
float a = Mathf.Clamp01((half - d) / Mathf.Max(0.0001f, feather));
a = a * a * (3f - 2f * a); // smoothstep — 띠가 선처럼 보이지 않게
float v = 1f;
if (texNoise > 0f) // 흙 얼룩 — 한 덩어리 단색으로 보이지 않게
v = 1f - texNoise * CellNoise(wx * 1.7f, wz * 1.7f);
byte c = (byte)Mathf.RoundToInt(Mathf.Clamp01(v) * 255f);
px[j * res + i] = new Color32(c, c, c, (byte)Mathf.RoundToInt(a * 255f));
}
}
_tex.SetPixels32(px);
_tex.Apply(false, true); // 밉 없음 + CPU 사본 해제
}
/// <summary>구역을 덮는 사각형 한 장(4정점 · 2삼각형). 모양은 전부 마스크가 만든다.</summary>
void BuildQuad(float halfX, float halfZ, float y)
{
float x0 = s_cx - halfX, x1 = s_cx + halfX;
float z0 = s_cz - halfZ, z1 = s_cz + halfZ;
_mesh.Clear();
_mesh.SetVertices(new System.Collections.Generic.List<Vector3>(4)
{
new Vector3(x0, y, z0), new Vector3(x1, y, z0),
new Vector3(x0, y, z1), new Vector3(x1, y, z1)
});
_mesh.SetUVs(0, new System.Collections.Generic.List<Vector2>(4)
{
new Vector2(0f, 0f), new Vector2(1f, 0f),
new Vector2(0f, 1f), new Vector2(1f, 1f)
});
_mesh.SetTriangles(new int[] { 0, 2, 1, 1, 2, 3 }, 0);
_mesh.RecalculateBounds();
}
// ─────────────────────────────────────────────────────────────────
// 816za 와 **같은 식**의 셀 격자 값 노이즈 [0,1] — 경계를 들쭉날쭉하게 만든다.
// ─────────────────────────────────────────────────────────────────
static float CellNoise(float fx, float fz)
{
int x0 = Mathf.FloorToInt(fx), z0 = Mathf.FloorToInt(fz);
float tx = fx - x0, tz = fz - z0;
tx = tx * tx * (3f - 2f * tx);
tz = tz * tz * (3f - 2f * tz);
float a = CellRand(x0, z0), b = CellRand(x0 + 1, z0);
float c = CellRand(x0, z0 + 1), d = CellRand(x0 + 1, z0 + 1);
return Mathf.Lerp(Mathf.Lerp(a, b, tx), Mathf.Lerp(c, d, tx), tz);
}
static float CellRand(int x, int z)
{
uint h = Hash(unchecked((uint)x * 73856093U) ^ unchecked((uint)z * 19349663U) ^ 0x9E3779B9U);
return (h & 0xFFFFFF) / 16777215f;
}
static uint Hash(uint x)
{
x ^= x >> 16; x *= 0x7feb352dU;
x ^= x >> 15; x *= 0x846ca68bU;
x ^= x >> 16;
return x;
}
}
}