Project_WL/Assets/WL/Look/Farm/WLSoftDirt.cs

359 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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