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

396 lines
22 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLShoreSkirt.cs — 섬 둘레 완만한 모래 경사 테두리(물가 스커트) (WL-816zg · #816)
//
// PD 결정(2026-09-15 「물은 진행」) — 데모 연못처럼 섬 가장자리가 완만한 모래 경사로 물에 잠기게.
// · 둥근 윤곽 : 열린 타일 발자국의 **유클리드 거리장**으로 띠를 만든다 → 볼록 모서리가 저절로 둥글다.
// · 흰 물가 띠 : 물 셰이더(Farm_Water_Demo3)가 **깊이**로 만든다. 경사가 물 아래까지 이어져 얕은 물이 생긴다.
// · 타일 옆면 가림 : 경사가 타일 가장자리(윗면 skirtTopDrop)에서 시작해 수직 옆면을 덮는다.
//
// ■ 만드는 법
// 1) 열린 타일(FI `Island` · IsUnlocked && activeInHierarchy · 한 변 = IslandManager.IslandSize)의 중심을 모은다.
// 2) 발자국의 **바깥 테두리 선분**(이웃 타일이 없는 변)만 골라 둔다. 격자 정점마다 그 선분들까지의 최단 거리가
// 부호 있는 거리 d(안쪽 , 바깥 +)다 — 타일별 사각형 SDF 의 min 은 타일 이음매에서 「안쪽인데 0 근처」로 틀려
// 윗면 위에 조각이 생기므로 쓰지 않는다(WLSoftDirt.SignedDist 의 「부호 있는 거리 → 띠」 방식은 그대로).
// 3) skirtInnerKeep ≤ d ≤ skirtWidth 인 격자(셀 skirtCell)만 만들고, y = smoothstep(d / skirtWidth) 로 윗면 → skirtBottomY.
// 격자 원점을 타일 가장자리에 맞춰 정점이 가장자리 선 위에 정확히 놓인다(틈 0 · 윗면 z-fight 0).
// 4) 색 = 정점색. d 0~skirtGrassBand 는 풀색(islandTopMaterial `_DiffuseColor`) → 모래색(softDirtColor) · 물 아래 × skirtUnderwaterMul.
// 셰이더 `WL/Shore Skirt` = 언릿 정점색 · URP · DepthOnly/DepthNormals 패스 포함(물 셰이더가 깊이 텍스처에서 경사를 봐야 한다).
// 메시 1장 · 드로우콜 +1 · 삼각형 수 = LastTriangles(로그에도 남긴다).
//
// ■ 되돌리기 — `shoreSkirtEnabled = 0` 이면 만들지 않는다(지금 상태 100 %).
//
// 🔴 FI 코드 0줄 · 씬 파일 0줄 · 에셋 0줄. 메시·머티리얼은 런타임 생성이다.
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using FIIsland = CryingSnow.FarmingIsland.Island;
using FIIslandManager = CryingSnow.FarmingIsland.IslandManager;
namespace WL.Look.Farm
{
[DisallowMultipleComponent]
public sealed class WLShoreSkirt : MonoBehaviour
{
public const string ObjectName = "~WL_ShoreSkirt";
public const string ShaderName = "WL/Shore Skirt";
// ── 진단(프로브·보고가 읽는다)
public static int Rebuilds;
public static int LastTiles, LastVertices, LastTriangles;
public static string LastLog = "꺼짐";
public WLIslandLookSettings cfg;
MeshFilter _mf;
MeshRenderer _mr;
Mesh _mesh;
Material _inst;
bool _vertexColor; // 전용 셰이더를 찾았는가(아니면 URP Unlit 모래 단색 대체)
static readonly int IdBaseColor = Shader.PropertyToID("_BaseColor");
static readonly int IdColor = Shader.PropertyToID("_Color");
static readonly int IdDiffuseColor = Shader.PropertyToID("_DiffuseColor");
struct Seg { public float ax, az, bx, bz; }
readonly List<Vector3> _tiles = new List<Vector3>(); // x·z = 타일 중심 · y = 렌더러 윗면 실측
readonly List<Seg> _edges = new List<Seg>(); // 발자국 바깥 테두리 선분
readonly List<Vector3> _verts = new List<Vector3>();
readonly List<Color32> _cols = new List<Color32>();
readonly List<int> _tris = new List<int>();
// 마지막으로 만든 조건 — 같으면 다시 만들지 않는다(1초 폴링·재훑기에서 공짜로 돌게).
bool _dirty = true;
long _lastTileSig;
float _lastValSig = float.NaN;
void OnEnable() { Ensure(); _dirty = true; Rebuild(); }
void OnDisable() { if (_mr != null) _mr.enabled = false; }
void OnDestroy()
{
if (_inst != null) Destroy(_inst);
if (_mesh != null) Destroy(_mesh);
}
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 = ShadowCastingMode.Off;
_mr.receiveShadows = false;
_mr.lightProbeUsage = LightProbeUsage.Off;
_mr.reflectionProbeUsage = ReflectionProbeUsage.Off;
_mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion;
if (_mesh == null)
{
_mesh = new Mesh { name = "WL_ShoreSkirtMesh" };
_mesh.MarkDynamic();
_mesh.hideFlags = HideFlags.DontSave;
}
_mf.sharedMesh = _mesh;
}
/// <summary>다시 만든다(섬 확장·재훑기). 멱등 — 발자국·값이 그대로면 아무것도 하지 않는다.</summary>
public void Rebuild()
{
Ensure();
if (cfg == null) cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.shoreSkirtEnabled == 0)
{ _mr.enabled = false; LastLog = "꺼짐"; return; }
float size = TileSize();
if (size <= 0.01f) { _mr.enabled = false; LastLog = "타일 크기 미확인(IslandManager 없음) — 대기"; return; }
float half = size * 0.5f;
long tileSig = CollectTiles(out float topMeasured);
LastTiles = _tiles.Count;
if (_tiles.Count == 0) { _mr.enabled = false; LastLog = "열린 타일 0 — 대기"; return; }
bool fixedTop = cfg.skirtTopMode != 0 || float.IsNaN(topMeasured);
float topY = fixedTop ? cfg.skirtTopY : topMeasured;
float waterY = FindWaterY(out bool waterFound);
Color grass = ToVertexSpace(GrassColor());
Color sand = ToVertexSpace(cfg.softDirtColor);
// 값이 바뀌면 다시 만든다(인스펙터에서 만져도 다음 폴링에 반영된다).
float valSig = cfg.skirtWidth * 7.1f + cfg.skirtBottomY * 13.3f + cfg.skirtGrassBand * 3.7f + cfg.skirtSandBlend * 29.3f
+ cfg.skirtCell * 5.9f + cfg.skirtInnerKeep * 11.9f + cfg.skirtTopDrop * 17.7f + cfg.skirtUnderwaterMul * 31.7f
+ cfg.skirtMaxGridNodes * 0.001f + topY * 37.3f + waterY * 41.1f
+ grass.r * 101f + grass.g * 103f + grass.b * 107f + sand.r * 109f + sand.g * 113f + sand.b * 127f;
bool same = !_dirty && _inst != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f;
if (same) { _mr.enabled = true; return; }
if (!EnsureMaterial()) { _mr.enabled = false; LastLog = "🔴 셰이더를 못 찾음(WL/Shore Skirt · URP Unlit 둘 다)"; return; }
BuildEdges(size, half);
float cell = BuildMesh(half, topY, waterY, grass, sand);
_mr.sharedMaterial = _inst;
_mr.enabled = true;
_lastTileSig = tileSig; _lastValSig = valSig; _dirty = false;
Rebuilds++;
LastLog = "물가 스커트 — 타일 " + _tiles.Count + " · 테두리 변 " + _edges.Count
+ " · 정점 " + LastVertices + " · 삼각형 " + LastTriangles
+ " · 폭 " + cfg.skirtWidth.ToString("F1") + " m · 윗면 y " + topY.ToString("F2")
+ (fixedTop ? "(고정 · 실측 " + (float.IsNaN(topMeasured) ? "없음" : topMeasured.ToString("F2"))
: "(실측 · 고정값 " + cfg.skirtTopY.ToString("F2")) + ")"
+ " → 바닥 " + cfg.skirtBottomY.ToString("F2") + " · 물 " + waterY.ToString("F2") + (waterFound ? "" : "(미발견·대체값)")
+ " · 격자 " + cell.ToString("F2") + " m · 셰이더 " + (_vertexColor ? ShaderName : "대체 URP Unlit(정점색 없음 · 모래 단색)")
+ " · 드로우콜 +1";
cfg.Log(LastLog);
}
// ─────────────────────────────────────────────────────────────────
// 입력 수집
// ─────────────────────────────────────────────────────────────────
static float TileSize()
{
var im = FIIslandManager.Instance;
return im != null ? im.IslandSize : 0f;
}
/// <summary>열린 타일 중심을 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다.</summary>
long CollectTiles(out float topMeasured)
{
_tiles.Clear();
topMeasured = float.NaN;
var scene = gameObject.scene;
var islands = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
long sig = 17;
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue;
if (scene.IsValid() && isl.gameObject.scene != scene) continue;
var p = isl.transform.position;
float top = p.y;
var r = isl.GetComponent<Renderer>(); // 섬 본체 렌더러(FIIsland 와 같은 오브젝트 · SwapMaterials 와 같은 판별)
if (r != null) top = r.bounds.max.y;
if (float.IsNaN(topMeasured) || top > topMeasured) topMeasured = top;
_tiles.Add(new Vector3(p.x, top, p.z));
sig += ((long)Mathf.RoundToInt(p.x * 8f) * 73856093L) ^ ((long)Mathf.RoundToInt(p.z * 8f) * 19349663L);
}
return sig ^ ((long)_tiles.Count << 40);
}
/// <summary>물 높이 — SpawnSeabed 와 같은 판별(씬의 `Water` 렌더러). 없으면 거품 띠의 대체값.</summary>
float FindWaterY(out bool found)
{
var scene = gameObject.scene;
var mrs = Object.FindObjectsByType<MeshRenderer>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < mrs.Length; i++)
{
var r = mrs[i];
if (r == null || r.gameObject.name != "Water") continue;
if (scene.IsValid() && r.gameObject.scene != scene) continue;
found = true;
return r.transform.position.y;
}
found = false;
return cfg.shoreFoamFallbackY;
}
/// <summary>풀색 — 타일 윗면 머티리얼(Farm_IslandTop_Demo3)의 `_DiffuseColor`. 없으면 `_BaseColor`·`_Color` → 설정 대체값.</summary>
Color GrassColor()
{
var m = cfg.islandTopMaterial;
if (m != null)
{
if (m.HasProperty(IdDiffuseColor)) return m.GetColor(IdDiffuseColor);
if (m.HasProperty(IdBaseColor)) return m.GetColor(IdBaseColor);
if (m.HasProperty(IdColor)) return m.GetColor(IdColor);
}
return cfg.skirtGrassFallback;
}
/// <summary>
/// 설정·머티리얼의 색은 sRGB 로 적힌 값이다(URP Unlit `_BaseColor` 와 같은 해석 — 바닥판·흙 데칼과 같은 숫자면 같은 색).
/// 정점색은 변환 없이 셰이더로 들어가므로 리니어 파이프라인에서는 리니어로 옮겨 넣는다.
/// </summary>
static Color ToVertexSpace(Color c)
{
return QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c;
}
/// <summary>전용 셰이더(정점색) → 없으면 URP Unlit 모래 단색 대체. 에셋은 만들지 않는다.</summary>
bool EnsureMaterial()
{
if (_inst != null) return true;
Shader sh = cfg.shoreSkirtMaterial != null ? cfg.shoreSkirtMaterial.shader : null;
if (sh == null) sh = Shader.Find(ShaderName);
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
if (sh == null) return false;
_vertexColor = sh.name == ShaderName;
_inst = cfg.shoreSkirtMaterial != null && cfg.shoreSkirtMaterial.shader == sh
? new Material(cfg.shoreSkirtMaterial) : new Material(sh);
_inst.name = "WL_ShoreSkirt(runtime)";
_inst.hideFlags = HideFlags.DontSave;
if (!_vertexColor)
{
// 대체 셰이더의 색 프로퍼티는 sRGB 해석 → 설정값을 그대로 넣는다(흙 데칼과 같은 색)
if (_inst.HasProperty(IdBaseColor)) _inst.SetColor(IdBaseColor, cfg.softDirtColor);
if (_inst.HasProperty(IdColor)) _inst.SetColor(IdColor, cfg.softDirtColor);
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// 거리장 — 발자국 바깥 테두리 선분까지의 유클리드 거리(정확) · 부호 = 타일 안()/밖(+)
// ─────────────────────────────────────────────────────────────────
void BuildEdges(float size, float half)
{
_edges.Clear();
float tol = size * 0.25f; // 이웃 판정 허용 오차(타일 격자는 정수 배)
for (int i = 0; i < _tiles.Count; i++)
{
float cx = _tiles[i].x, cz = _tiles[i].z;
if (!HasTile(cx + size, cz, tol)) _edges.Add(new Seg { ax = cx + half, az = cz - half, bx = cx + half, bz = cz + half });
if (!HasTile(cx - size, cz, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx - half, bz = cz + half });
if (!HasTile(cx, cz + size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz + half, bx = cx + half, bz = cz + half });
if (!HasTile(cx, cz - size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx + half, bz = cz - half });
}
}
bool HasTile(float x, float z, float tol)
{
for (int i = 0; i < _tiles.Count; i++)
if (Mathf.Abs(_tiles[i].x - x) <= tol && Mathf.Abs(_tiles[i].z - z) <= tol) return true;
return false;
}
bool Inside(float x, float z, float half)
{
for (int i = 0; i < _tiles.Count; i++)
if (Mathf.Abs(x - _tiles[i].x) <= half && Mathf.Abs(z - _tiles[i].z) <= half) return true;
return false;
}
/// <summary>발자국 테두리까지의 부호 있는 거리(m). **음수 = 타일 안 · 양수 = 바깥(물 쪽)** · 테두리 선 위 = 0.</summary>
float SignedDist(float x, float z, float half)
{
float best = float.MaxValue;
for (int i = 0; i < _edges.Count; i++)
{
var s = _edges[i];
float ex = s.bx - s.ax, ez = s.bz - s.az;
float t = Mathf.Clamp01(((x - s.ax) * ex + (z - s.az) * ez) / (ex * ex + ez * ez));
float dx = x - (s.ax + ex * t), dz = z - (s.az + ez * t);
float d2 = dx * dx + dz * dz;
if (d2 < best) best = d2;
}
float d = Mathf.Sqrt(best);
return Inside(x, z, half) ? -d : d;
}
// ─────────────────────────────────────────────────────────────────
// 메시 — 격자(셀 skirtCell) · 정점마다 d → 높이(smoothstep)·정점색
// ─────────────────────────────────────────────────────────────────
float BuildMesh(float half, float topY, float waterY, Color grass, Color sand)
{
float cell = Mathf.Max(0.1f, cfg.skirtCell);
float width = Mathf.Max(cell, cfg.skirtWidth);
float keep = Mathf.Max(0f, cfg.skirtInnerKeep);
float top = topY - Mathf.Max(0f, cfg.skirtTopDrop);
float bottom = cfg.skirtBottomY;
float band = Mathf.Max(0f, cfg.skirtGrassBand);
float blend = Mathf.Max(0.01f, cfg.skirtSandBlend);
float uw = Mathf.Clamp01(cfg.skirtUnderwaterMul);
float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue;
for (int i = 0; i < _tiles.Count; i++)
{
var t = _tiles[i];
if (t.x - half < minX) minX = t.x - half;
if (t.x + half > maxX) maxX = t.x + half;
if (t.z - half < minZ) minZ = t.z - half;
if (t.z + half > maxZ) maxZ = t.z + half;
}
// 격자 정점 수 상한 — 넘으면 칸을 키운다(모바일 안전 · 섬이 아무리 커져도 비용이 묶인다)
int maxNodes = Mathf.Max(1000, cfg.skirtMaxGridNodes);
int mc, nx, nz;
while (true)
{
mc = Mathf.CeilToInt((width + cell * 2f) / cell);
nx = Mathf.CeilToInt((maxX - minX) / cell - 0.0001f) + 2 * mc + 1;
nz = Mathf.CeilToInt((maxZ - minZ) / cell - 0.0001f) + 2 * mc + 1;
if ((long)nx * nz <= maxNodes) break;
cell *= 2f;
}
// 원점 = 발자국 최소 가장자리에서 정수 칸만큼 바깥 → 칸이 타일 한 변을 나누면 정점이 모든 타일 가장자리 선 위에 놓인다
float x0 = minX - mc * cell, z0 = minZ - mc * cell;
var dist = new float[nx * nz];
var index = new int[nx * nz];
for (int j = 0; j < nz; j++)
{
float z = z0 + j * cell;
for (int i = 0; i < nx; i++) dist[j * nx + i] = SignedDist(x0 + i * cell, z, half);
}
_verts.Clear(); _cols.Clear(); _tris.Clear();
float outerKeep = width + cell * 2f; // 사각형 네 귀가 전부 있어야 하므로 바깥으로 두 칸 여유
for (int j = 0; j < nz; j++)
{
for (int i = 0; i < nx; i++)
{
int n = j * nx + i;
float d = dist[n];
if (d < -keep || d > outerKeep) { index[n] = -1; continue; }
float t = Mathf.Clamp01(d / width);
t = t * t * (3f - 2f * t); // smoothstep — 윗면에서도 바닥에서도 접선이 수평(완만)
float y = top + (bottom - top) * t;
index[n] = _verts.Count;
_verts.Add(new Vector3(x0 + i * cell, y, z0 + j * cell));
float k = Mathf.Clamp01((d - band) / blend); // 풀 띠(0~band) → blend 폭에서 모래로
k = k * k * (3f - 2f * k);
var c = Color.LerpUnclamped(grass, sand, k);
if (y < waterY) { c.r *= uw; c.g *= uw; c.b *= uw; }
c.a = 1f;
_cols.Add(c);
}
}
for (int j = 0; j < nz - 1; j++)
{
for (int i = 0; i < nx - 1; i++)
{
int n = j * nx + i;
int ia = index[n], ib = index[n + 1], ic = index[n + nx], id = index[n + nx + 1];
if (ia < 0 || ib < 0 || ic < 0 || id < 0) continue;
// 네 귀가 전부 경사 바깥(d ≥ width)이면 바닥과 같은 높이의 평판 — 만들지 않는다
float dm = Mathf.Min(Mathf.Min(dist[n], dist[n + 1]), Mathf.Min(dist[n + nx], dist[n + nx + 1]));
if (dm >= width) continue;
// 바닥판(SpawnSeabed)과 같은 감김 — (x0,z0)→(x0,z1)→(x1,z1) 이 위를 향한다
_tris.Add(ia); _tris.Add(ic); _tris.Add(id);
_tris.Add(ia); _tris.Add(id); _tris.Add(ib);
}
}
_mesh.Clear();
_mesh.indexFormat = _verts.Count > 65000 ? IndexFormat.UInt32 : IndexFormat.UInt16;
_mesh.SetVertices(_verts);
_mesh.SetColors(_cols);
_mesh.SetTriangles(_tris, 0);
_mesh.RecalculateNormals(); // DepthNormals 패스용
_mesh.RecalculateBounds();
LastVertices = _verts.Count;
LastTriangles = _tris.Count / 3;
return cell;
}
}
}