// ───────────────────────────────────────────────────────────────────────────── // 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 _tiles = new List(); // x·z = 타일 중심 · y = 렌더러 윗면 실측 readonly List _edges = new List(); // 발자국 바깥 테두리 선분 readonly List _verts = new List(); readonly List _cols = new List(); readonly List _tris = new List(); readonly List _trisSand = new List(); // 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(); if (_mf == null) _mf = gameObject.AddComponent(); if (_mr == null) _mr = GetComponent(); if (_mr == null) _mr = gameObject.AddComponent(); _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; } /// 다시 만든다(섬 확장·재훑기). 멱등 — 발자국·값이 그대로면 아무것도 하지 않는다. 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; } /// 열린 타일 중심을 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다. long CollectTiles(out float topMeasured) { _tiles.Clear(); topMeasured = float.NaN; var scene = gameObject.scene; var islands = Object.FindObjectsByType(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(); // 섬 본체 렌더러(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); } /// 물 높이 — SpawnSeabed 와 같은 판별(씬의 `Water` 렌더러). 없으면 거품 띠의 대체값. float FindWaterY(out bool found) { var scene = gameObject.scene; var mrs = Object.FindObjectsByType(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; } /// 격자 정수 좌표 → 0~1 결정적 해시(굽이 노이즈용). 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; } /// 경계 굽이 — 값 노이즈(격자 skirtWaveCell · 겹선형 · 결정적) × 진폭. 직선 변을 파도처럼 굽이치게 한다. 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 _cut = new Dictionary(); /// 노드 na/nb 사이 s=0 지점의 정점(위치·색 보간 · 변마다 한 번만 생성). 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; } /// 삼각형(정점 va,vb,vc · 노드 na,nb,nc · 감김 유지)을 s 부호로 가른다. 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); } /// 풀색 — 타일 윗면 머티리얼(Farm_IslandTop_Demo3)의 `_DiffuseColor`. 없으면 `_BaseColor`·`_Color` → 설정 대체값. 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; } /// /// 설정·머티리얼의 색은 sRGB 로 적힌 값이다(URP Unlit `_BaseColor` 와 같은 해석 — 바닥판·흙 데칼과 같은 숫자면 같은 색). /// 정점색은 변환 없이 셰이더로 들어가므로 리니어 파이프라인에서는 리니어로 옮겨 넣는다. /// static Color ToVertexSpace(Color c) { return QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c; } /// 전용 셰이더(정점색) → 없으면 URP Unlit 모래 단색 대체. 에셋은 만들지 않는다. 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; } /// 발자국 테두리까지의 부호 있는 거리(m). **음수 = 타일 안 · 양수 = 바깥(물 쪽)** · 테두리 선 위 = 0. 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(512); var ringZ = new List(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; } } }