// ───────────────────────────────────────────────────────────────────────────── // 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; } } }