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

528 lines
31 KiB
C#
Raw Permalink 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>();
readonly List<int> _trisSand = new List<int>(); // Toon 모드 — 모래 서브메시
static readonly int IdShadowDiffuseColor = Shader.PropertyToID("_ShadowDiffuseColor");
Material _sandInst; // Toon 모드 — 타일 재질 복사본(모래색)
bool _toon;
// 마지막으로 만든 조건 — 같으면 다시 만들지 않는다(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 (_sandInst != null) Destroy(_sandInst);
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
+ cfg.skirtToonMode * 53f + cfg.skirtEdgeJitter * 59f + cfg.skirtSandShadowMul * 61f + cfg.skirtGrassRound * 67f
+ cfg.skirtWaveAmp * 71f + cfg.skirtWaveCell * 73f;
bool same = !_dirty && (_inst != null || _sandInst != 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);
if (_toon) _mr.sharedMaterials = new[] { cfg.islandTopMaterial, _sandInst }; // 풀 = 타일 재질 그대로 · 모래 = 복사본
else _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 · 셰이더 " + (_toon ? "Toon 2재질(풀=타일 · 모래=복사본 · 구름 동일)" : _vertexColor ? ShaderName : "대체 URP Unlit(정점색 없음 · 모래 단색)")
+ " · 드로우콜 +" + (_toon ? "2" : "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>격자 정수 좌표 → 0~1 결정적 해시(굽이 노이즈용).</summary>
static float Hash01(int x, int z)
{
uint h = (uint)(x * 73856093) ^ (uint)(z * 19349663) ^ 0x9E3779B9u; h ^= h >> 13; h *= 0x5bd1e995u; h ^= h >> 15;
return (h & 0xffffu) / 65535f;
}
/// <summary>경계 굽이 — 값 노이즈(격자 skirtWaveCell · 겹선형 · 결정적) × 진폭. 직선 변을 파도처럼 굽이치게 한다.</summary>
float Wave(float x, float z)
{
if (cfg.skirtWaveAmp <= 0f || cfg.skirtWaveCell <= 0.1f) return 0f;
float qx = x / cfg.skirtWaveCell, qz = z / cfg.skirtWaveCell;
int gx = Mathf.FloorToInt(qx), gz = Mathf.FloorToInt(qz); float fx = qx - gx, fz = qz - gz;
fx = fx * fx * (3f - 2f * fx); fz = fz * fz * (3f - 2f * fz);
float v00 = Hash01(gx, gz), v10 = Hash01(gx + 1, gz), v01 = Hash01(gx, gz + 1), v11 = Hash01(gx + 1, gz + 1);
return (Mathf.Lerp(Mathf.Lerp(v00, v10, fx), Mathf.Lerp(v01, v11, fx), fz) - 0.5f) * 2f * cfg.skirtWaveAmp;
}
// ── 마칭 트라이앵글 — s 의 부호가 갈리는 삼각형을 등고선(s=0)으로 잘라 풀/모래 서브메시에 나눠 담는다 ──
float[] _s;
readonly Dictionary<long, int> _cut = new Dictionary<long, int>();
/// <summary>노드 na/nb 사이 s=0 지점의 정점(위치·색 보간 · 변마다 한 번만 생성).</summary>
int CrossVertex(int va, int na, int vb, int nb)
{
long key = na < nb ? ((long)na << 32) | (uint)nb : ((long)nb << 32) | (uint)na;
int idx;
if (_cut.TryGetValue(key, out idx)) return idx;
float sa = _s[na], sb = _s[nb];
float t = Mathf.Clamp01(sa / (sa - sb));
idx = _verts.Count;
_verts.Add(Vector3.LerpUnclamped(_verts[va], _verts[vb], t));
_cols.Add(Color32.Lerp(_cols[va], _cols[vb], t));
_cut[key] = idx;
return idx;
}
/// <summary>삼각형(정점 va,vb,vc · 노드 na,nb,nc · 감김 유지)을 s 부호로 가른다.</summary>
void CutTri(int va, int na, int vb, int nb, int vc, int nc)
{
bool ga = _s[na] < 0f, gb = _s[nb] < 0f, gc = _s[nc] < 0f;
if (ga == gb && gb == gc) { var l = ga ? _tris : _trisSand; l.Add(va); l.Add(vb); l.Add(vc); return; }
// 홀로 다른 꼭짓점 o 와 나머지 p,q (a→b→c 순환 순서 유지)
int vo, no, vp, np, vq, nq; bool go;
if (ga != gb && ga != gc) { vo = va; no = na; vp = vb; np = nb; vq = vc; nq = nc; go = ga; }
else if (gb != ga && gb != gc) { vo = vb; no = nb; vp = vc; np = nc; vq = va; nq = na; go = gb; }
else { vo = vc; no = nc; vp = va; np = na; vq = vb; nq = nb; go = gc; }
int m1 = CrossVertex(vo, no, vp, np);
int m2 = CrossVertex(vo, no, vq, nq);
var lo = go ? _tris : _trisSand; // o 쪽 조각(삼각형 1)
var lr = go ? _trisSand : _tris; // 나머지 조각(사각형 = 삼각형 2)
lo.Add(vo); lo.Add(m1); lo.Add(m2);
lr.Add(m1); lr.Add(vp); lr.Add(vq);
lr.Add(m1); lr.Add(vq); lr.Add(m2);
}
/// <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()
{
// 2026-09-16 Lead — Toon 모드: 타일과 같은 재질 계열이라 구름 그림자·조명이 똑같이 떨어진다(이음새 0).
if (cfg.skirtToonMode != 0 && cfg.islandTopMaterial != null)
{
_toon = true; _vertexColor = false;
if (_sandInst == null)
_sandInst = new Material(cfg.islandTopMaterial) { name = "WL_ShoreSkirt_Sand(runtime)", hideFlags = HideFlags.DontSave };
var sc = cfg.softDirtColor; sc.a = 1f;
float sm = Mathf.Clamp01(cfg.skirtSandShadowMul);
if (_sandInst.HasProperty(IdDiffuseColor)) _sandInst.SetColor(IdDiffuseColor, sc);
if (_sandInst.HasProperty(IdShadowDiffuseColor)) _sandInst.SetColor(IdShadowDiffuseColor, new Color(sc.r * sm, sc.g * sm, sc.b * sm, 1f));
if (_sandInst.HasProperty(IdBaseColor)) _sandInst.SetColor(IdBaseColor, sc);
return true;
}
_toon = false;
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);
}
// 2026-09-16 PD 「각진 지형이 남는다」 — 풀 구역 모서리를 R 로 둥글린다(열기 연산 = 침식 E{d ≤ R} 뒤 팽창):
// distR = (E 까지의 거리) R. 직선 변에서는 d 와 같고, 볼록 모서리에서는 반지름 R 원호가 된다.
// E 의 테두리 띠(d ∈ [R 1.5칸, R]) 점만 모아 최소 거리를 재므로 비용은 (노드 수 × 띠 점 수).
float round = Mathf.Max(0f, cfg.skirtGrassRound);
var distR = dist;
if (round > 0.01f)
{
distR = new float[nx * nz];
var ringX = new List<float>(512); var ringZ = new List<float>(512);
float lo = -round - cell * 1.5f, hi = -round;
for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) { float dv = dist[j * nx + i]; if (dv <= hi && dv >= lo) { ringX.Add(x0 + i * cell); ringZ.Add(z0 + j * cell); } }
for (int j = 0; j < nz; j++)
{
float z = z0 + j * cell;
for (int i = 0; i < nx; i++)
{
int n = j * nx + i; float dv = dist[n];
if (ringX.Count == 0) { distR[n] = dv; continue; } // 섬이 2R 보다 작으면 둥글림 없음
if (dv <= hi) { distR[n] = dv + round; continue; } // E 안쪽
float x = x0 + i * cell; float best = float.MaxValue;
for (int k = 0; k < ringX.Count; k++) { float dx = x - ringX[k], dz = z - ringZ[k]; float d2 = dx * dx + dz * dz; if (d2 < best) best = d2; }
distR[n] = Mathf.Sqrt(best) - round;
}
}
// 🔴 실측(2026-09-16 09:5x): 모서리 필렛은 변을 따라 R 까지 이어지는 곡선 삼각형(깊이 R) — keep 이 R 보다 작으면 직사각형으로 잘린다.
keep = Mathf.Max(keep, round + cell);
}
_verts.Clear(); _cols.Clear(); _tris.Clear(); _trisSand.Clear();
float jitter = Mathf.Max(0f, cfg.skirtEdgeJitter); // 노드 단위 미세 흔들림(m) — 등고선을 살짝 거칠게
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 — 윗면에서도 바닥에서도 접선이 수평(완만)
// 타일 안(d<0)은 윗면 **위** 1 cm — 풀 서브메시는 타일 재질 그대로라 보이지 않고, 모래 서브메시가 모서리 위에 얹힌다(홈·립도 덮는다)
float y = (_toon && d < 0f) ? topY + 0.01f : 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);
}
}
// 2026-09-16 PD 「각진 경계 · 완만한 해변」 — 풀/모래 경계를 **격자 칸이 아니라 등고선으로 자른다**(칸 단위 계단 제거).
// 노드 값 s = 둥글린 거리 굽이(값 노이즈) 풀 띠 (+ 미세 흔들림). s<0 = 풀 · s≥0 = 모래. 삼각형이 두 쪽에 걸치면
// s=0 을 지나는 변 위에 정점을 보간해 만들고 세 조각으로 나눈다(마칭 트라이앵글).
if (_s == null || _s.Length < nx * nz) _s = new float[nx * nz];
_cut.Clear();
for (int j = 0; j < nz; j++)
for (int i = 0; i < nx; i++)
{
int n = j * nx + i;
if (index[n] < 0) { _s[n] = 0f; continue; }
float x = x0 + i * cell, z = z0 + j * cell;
_s[n] = distR[n] - Wave(x, z) - band + (Hash01(i * 3 + 7, j * 5 + 11) - 0.5f) * jitter;
}
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) 이 위를 향한다
if (_toon)
{
CutTri(ia, n, ic, n + nx, id, n + nx + 1);
CutTri(ia, n, id, n + nx + 1, ib, n + 1);
}
else
{
_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.subMeshCount = _toon ? 2 : 1;
_mesh.SetTriangles(_tris, 0);
if (_toon) _mesh.SetTriangles(_trisSand, 1);
_mesh.RecalculateNormals(); // DepthNormals 패스용 · Toon 조명용
_mesh.RecalculateBounds();
LastVertices = _verts.Count;
LastTriangles = (_tris.Count + _trisSand.Count) / 3;
return cell;
}
}
}