// ─────────────────────────────────────────────────────────────────────────────
// 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;
/// 설정이 데칼을 쓰기로 했는가 — 흙 타일 숨김(배타)을 가르는 값. 구역을 몰라도 참이다.
public static bool Active
{
get
{
var c = WLIslandLookSettings.Instance;
return c != null && c.enabled_ != 0 && c.softDirtEnabled != 0;
}
}
/// 마스크를 실제로 계산해 두었는가(밭 구역을 알아낸 뒤). 풀 제외는 이것이 참일 때만 바뀐다.
public static bool Ready { get { return s_ready && Active; } }
///
/// 흙 경계까지의 부호 있는 거리(m). **음수 = 흙 안쪽 · 양수 = 바깥(풀)**.
/// 모서리를 `softDirtCornerRadius` 만큼 깎은 사각형에 셀 노이즈로 경계를 흔든 모양이다.
///
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;
}
/// 여기에 풀을 깔지 말 것인가 — 데칼 가장자리보다 `softDirtGrassBite` 만큼 **더 안쪽**만 막는다.
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();
if (_mf == null) _mf = gameObject.AddComponent();
if (_mr == null) _mr = GetComponent();
if (_mr == null) _mr = gameObject.AddComponent();
_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;
/// 다시 만든다(섬 확장·밭 재구성). 멱등 — 조건이 그대로면 아무것도 하지 않는다.
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);
}
// ─────────────────────────────────────────────────────────────────
///
/// 흙칸(Soil) 윗면 높이 — 데칼을 여기 바로 위에 깐다(밭 바닥이 곧 걸어다니는 면).
/// 렌더러를 꺼 두어도 `bounds` 는 유효하다. 구역 안에 흙칸이 하나도 없으면 구역 바닥을 쓴다.
///
static float SoilTopY(Bounds zone)
{
float y = float.MinValue;
var soils = Object.FindObjectsByType(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(true);
float t = r != null ? r.bounds.max.y : p.y;
if (t > y) y = t;
}
return y > -1e8f ? y : zone.min.y;
}
/// URP Unlit 반투명 런타임 머티리얼(816j2 `Farm_ShoreFoam.mat` 과 같은 구성).
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;
}
/// 마스크를 굽는다 — 알파 = 흙 덮임(1) → 풀(0) 으로 `feather`(m) 폭에서 부드럽게 넘어간다.
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 사본 해제
}
/// 구역을 덮는 사각형 한 장(4정점 · 2삼각형). 모양은 전부 마스크가 만든다.
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(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(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;
}
}
}