diff --git a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset
index ad0ddda1e..6bb6005c5 100644
--- a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset
+++ b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset
@@ -113,6 +113,10 @@ MonoBehaviour:
farmEdgeRadius: 1
farmEdgeNoise: 0.4
farmEdgeNoiseCell: 0.5
+ outlineRoundEnabled: 1
+ outlineRoundRadius: 1.5
+ outlineOverhang: 0.3
+ outlineRoundCell: 0.25
propMargin: 0.05
propMaxSize: 7.5
maxInstances: 40000
diff --git a/Assets/WL/Look/Farm/WLIslandGrass.cs b/Assets/WL/Look/Farm/WLIslandGrass.cs
index 96ef55851..008900263 100644
--- a/Assets/WL/Look/Farm/WLIslandGrass.cs
+++ b/Assets/WL/Look/Farm/WLIslandGrass.cs
@@ -50,6 +50,12 @@ namespace WL.Look.Farm
public static bool CloudEdgeOn;
public static string CloudWhy = "";
+ // 816zd — 둥근 윤곽 배치 진단
+ public static bool OutlineRoundOn;
+ public static int OutlineCells;
+ public static float OutlineRadius, OutlineOverhang, OutlineBakeMs;
+ public static string OutlineWhy = "";
+
public WLIslandLookSettings cfg;
Bounds _bounds = new Bounds(Vector3.zero, Vector3.one * 16f);
@@ -135,6 +141,8 @@ namespace WL.Look.Farm
Instances = 0; Tiles = 0; Excluded = 0; Triangles = 0; DrawnConfigs = 0; UsedDensity = 0f;
Candidates = 0; EdgeFraction = 1f; EdgeCells = 0; EdgeCellSize = 0f; EdgeDilate = 0;
BakeMs = 0f; FilterMs = 0f; CloudEdgeOn = false; CloudWhy = ""; _edgeOn = false;
+ OutlineRoundOn = false; OutlineCells = 0; OutlineRadius = 0f; OutlineOverhang = 0f;
+ OutlineBakeMs = 0f; OutlineWhy = ""; _outOn = false;
if (cfg == null) cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.grassEnabled == 0) { LastLog = "꺼짐"; return null; }
@@ -159,14 +167,18 @@ namespace WL.Look.Farm
BuildBlockers(tiles);
+ // 816zd — 풀을 깔 넓이를 「섬 footprint 를 열고(침식→팽창) 바깥으로 넓힌」 모양으로 미리 굽는다
+ BuildOutlineField(tiles);
+
// 경계 박스 — 구름 격자를 굽기 전에 정해져야 한다
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
+ float outPad = _outOn ? Mathf.Max(0f, cfg.outlineOverhang) : 0f; // 816zd — 절벽 위로 넘친 풀까지 덮는다
for (int i = 0; i < tiles.Count; i++)
{
var t = tiles[i];
- bmin = Vector3.Min(bmin, new Vector3(t.center.x - 4f, t.center.y - 1f, t.center.z - 4f));
- bmax = Vector3.Max(bmax, new Vector3(t.center.x + 4f, t.center.y + 3f, t.center.z + 4f));
+ bmin = Vector3.Min(bmin, new Vector3(t.center.x - 4f - outPad, t.center.y - 1f, t.center.z - 4f - outPad));
+ bmax = Vector3.Max(bmax, new Vector3(t.center.x + 4f + outPad, t.center.y + 3f, t.center.z + 4f + outPad));
}
_bounds = new Bounds((bmin + bmax) * 0.5f, bmax - bmin);
_cacheY = tiles[0].center.y;
@@ -255,18 +267,25 @@ namespace WL.Look.Farm
float pv = cfg.positionVariance;
float sv = cfg.scaleVariance;
+ // 816zd — 둥근 윤곽은 타일 밖으로 `overhang` 만큼 넘친다. 흔들기 폭까지 더해 칸 범위를 넓힌다.
+ // (이만큼 넓히면 마스크가 받아들일 수 있는 점의 칸이 하나도 빠지지 않는다 — 아래 OwnerTile 주석)
+ float pad = _outOn ? Mathf.Max(0f, cfg.outlineOverhang) + pv * step : 0f;
+
for (int i = 0; i < tiles.Count; i++)
{
var t = tiles[i];
- int ix0 = Mathf.CeilToInt((t.center.x - 4f) / step - 0.5f);
- int ix1 = Mathf.FloorToInt((t.center.x + 4f) / step - 0.5f);
- int iz0 = Mathf.CeilToInt((t.center.z - 4f) / step - 0.5f);
- int iz1 = Mathf.FloorToInt((t.center.z + 4f) / step - 0.5f);
+ int ix0 = Mathf.CeilToInt((t.center.x - 4f - pad) / step - 0.5f);
+ int ix1 = Mathf.FloorToInt((t.center.x + 4f + pad) / step - 0.5f);
+ int iz0 = Mathf.CeilToInt((t.center.z - 4f - pad) / step - 0.5f);
+ int iz1 = Mathf.FloorToInt((t.center.z + 4f + pad) / step - 0.5f);
for (int ix = ix0; ix <= ix1; ix++)
{
for (int iz = iz0; iz <= iz1; iz++)
{
+ // 816zd — 넓힌 범위는 이웃 타일과 겹친다. 칸 하나는 **한 타일만** 만든다(중복 0).
+ if (_outOn && OwnerTile(tiles, (ix + 0.5f) * step, (iz + 0.5f) * step) != i) continue;
+
// 같은 칸이면 몇 번 다시 깔아도 같은 결과(확장 때 풀이 튀지 않는다)
uint h = Hash((uint)(ix * 73856093) ^ (uint)(iz * 19349663) ^ (uint)(L * 83492791));
float jx = (Frac(h, 0) - 0.5f) * 2f * pv;
@@ -274,7 +293,13 @@ namespace WL.Look.Farm
float wx = (ix + 0.5f + jx) * step;
float wz = (iz + 0.5f + jz) * step;
- if (!OnTile(t, wx, wz)) { Excluded++; continue; }
+ if (_outOn)
+ {
+ // 윗면 마스크 + edgeInset 대신 둥글린 윤곽으로 자른다. 길 제외는 그대로.
+ if (!InRoundOutline(wx, wz)) { Excluded++; continue; }
+ if (OnRoad(t, wx, wz)) { Excluded++; continue; }
+ }
+ else if (!OnTile(t, wx, wz)) { Excluded++; continue; }
if (Blocked(wx, wz)) { Excluded++; continue; }
// 어느 정의가 걸리나(가중 추첨 · 결정적)
@@ -473,6 +498,11 @@ namespace WL.Look.Farm
+ " · 드로우콜 " + DrawnConfigs + " · 삼각형 " + Triangles + " · 제외점 " + Excluded
+ " · 밀도 " + UsedDensity.ToString("F2") + "(=" + (UsedDensity * UsedDensity).ToString("F2") + "개/㎡)"
+ (_budgetScale < 1f ? " · 예산으로 밀도 ×" + _budgetScale.ToString("F2") : "")
+ + (_outOn
+ ? " · 둥근윤곽 R" + OutlineRadius.ToString("F2") + "m·넘침 " + OutlineOverhang.ToString("F2")
+ + "m(칸 " + _outCell.ToString("F2") + "m×" + OutlineCells + " · 굽기 "
+ + OutlineBakeMs.ToString("F1") + "ms)"
+ : " · 둥근윤곽 off" + (OutlineWhy.Length > 0 ? "(" + OutlineWhy + ")" : ""))
+ (_viewOn
? " · 가시범위 절두체(여유 " + cfg.viewMargin.ToString("F1") + "m) · 범위밖 제외 " + ViewCulled
: " · 가시범위 off")
@@ -554,16 +584,153 @@ namespace WL.Look.Farm
!t.mask.At(l.x, l.z + e) || !t.mask.At(l.x, l.z - e)) return false;
}
- // 길 제외
- if (t.roadMask != null && t.roadTf != null)
+ return !OnRoad(t, wx, wz);
+ }
+
+ /// 길(Road 메시) 위인가 — 여백 포함. 816zd 가 둥근 윤곽 모드에서도 그대로 쓴다.
+ bool OnRoad(TileRef t, float wx, float wz)
+ {
+ if (t.roadMask == null || t.roadTf == null) return false;
+ var lr = t.roadTf.InverseTransformPoint(new Vector3(wx, t.center.y, wz));
+ float m = cfg.roadMargin;
+ if (t.roadMask.At(lr.x, lr.z)) return true;
+ if (m > 0f && (t.roadMask.At(lr.x + m, lr.z) || t.roadMask.At(lr.x - m, lr.z) ||
+ t.roadMask.At(lr.x, lr.z + m) || t.roadMask.At(lr.x, lr.z - m))) return true;
+ return false;
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // 816zd — 둥근 바깥 윤곽 (땅은 각진 그대로 · 풀 카펫만)
+ //
+ // 섬 윗면 footprint 를 반지름 R 로 **열고**(morphological opening = 침식 후 팽창)
+ // 바깥으로 overhang 만큼 팽창한 넓이에만 풀을 깐다.
+ // · 열기 → 볼록 모서리가 반지름 R 의 원호로 깎인다(모서리엔 풀이 빈다).
+ // 오목한 곳·안쪽 넓은 면은 그대로라 풀 카펫의 **바깥선만** 둥글게 읽힌다.
+ // · 팽창 → 가장자리 풀이 절벽 위로 살짝 넘친다.
+ // 거리 정의는 816j2 `WLShoreFoam.Chamfer` 를 그대로 쓴다(SOT 1곳).
+ // 팽창은 결합적이라 (E⊕B_R)⊕B_over = E⊕B_(R+over) — 거리장 한 장이면 충분하다.
+ // ─────────────────────────────────────────────────────────────────
+ float[] _outDist; // 침식된 집합까지의 거리(m)
+ int _outNx, _outNz;
+ float _outMinX, _outMinZ, _outCell, _outLimit;
+ bool _outOn;
+
+ void BuildOutlineField(List tiles)
+ {
+ _outOn = false;
+ _outDist = null;
+ if (cfg.outlineRoundEnabled == 0) { OutlineWhy = "꺼짐"; return; }
+
+ float R = Mathf.Max(0f, cfg.outlineRoundRadius);
+ float over = Mathf.Max(0f, cfg.outlineOverhang);
+ if (R <= 0f && over <= 0f) { OutlineWhy = "반지름·넘침 둘 다 0 = 현행과 같음"; return; }
+ if (tiles.Count == 0) { OutlineWhy = "타일 0"; return; }
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ float cell = Mathf.Max(0.05f, cfg.outlineRoundCell);
+ float half = cell * 0.5f;
+ float pad = R + over + cell * 2f;
+
+ float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue;
+ for (int i = 0; i < tiles.Count; i++)
{
- var lr = t.roadTf.InverseTransformPoint(w);
- float m = cfg.roadMargin;
- if (t.roadMask.At(lr.x, lr.z)) return false;
- if (m > 0f && (t.roadMask.At(lr.x + m, lr.z) || t.roadMask.At(lr.x - m, lr.z) ||
- t.roadMask.At(lr.x, lr.z + m) || t.roadMask.At(lr.x, lr.z - m))) return false;
+ var t = tiles[i];
+ float h = t.mask != null ? t.mask.half : 4f;
+ minX = Mathf.Min(minX, t.center.x - h); maxX = Mathf.Max(maxX, t.center.x + h);
+ minZ = Mathf.Min(minZ, t.center.z - h); maxZ = Mathf.Max(maxZ, t.center.z + h);
}
- return true;
+ minX -= pad; minZ -= pad; maxX += pad; maxZ += pad;
+ int nx = Mathf.Clamp(Mathf.CeilToInt((maxX - minX) / cell) + 1, 2, 1024);
+ int nz = Mathf.Clamp(Mathf.CeilToInt((maxZ - minZ) / cell) + 1, 2, 1024);
+
+ // ① 섬 윗면 footprint(모든 타일 마스크의 합집합)
+ var inside = new bool[nx * nz];
+ for (int j = 0; j < nz; j++)
+ {
+ float z = minZ + j * cell;
+ for (int i = 0; i < nx; i++) inside[j * nx + i] = OnAnyTileMask(tiles, minX + i * cell, z);
+ }
+
+ // ② 침식 — 「지금의 바깥선(edgeInset)」에서 다시 R 이상 안쪽인 칸만 남긴다.
+ // 침식은 결합적이라 (F⊖B_inset)⊖B_R = F⊖B_(inset+R) — 한 번에 깎는다.
+ // 이 기준 덕에 `outlineOverhang = edgeInset` 이면 바깥선이 지금과 같고(모서리만 둥글어짐),
+ // 그보다 크면 차이만큼 절벽 위로 넘친다.
+ float inset = Mathf.Max(0f, cfg.edgeInset);
+ var dIn = WLShoreFoam.Chamfer(inside, nx, nz, cell, true); // 안 → 가장 가까운 밖까지
+ var eroded = new bool[nx * nz];
+ int nEroded = 0;
+ for (int k = 0; k < eroded.Length; k++)
+ {
+ bool v = inside[k] && dIn[k] >= inset + R + half;
+ eroded[k] = v;
+ if (v) nEroded++;
+ }
+ sw.Stop();
+ if (nEroded == 0)
+ {
+ OutlineWhy = "섬이 반지름보다 얇다(침식 후 0칸) → 현행 배치 유지";
+ cfg.Log("둥근 윤곽 — " + OutlineWhy);
+ return;
+ }
+
+ // ③ 팽창 — 침식된 집합에서 (R + overhang) 안쪽이면 풀을 깐다
+ sw.Start();
+ _outDist = WLShoreFoam.Chamfer(eroded, nx, nz, cell, false);
+ sw.Stop();
+ _outNx = nx; _outNz = nz; _outMinX = minX; _outMinZ = minZ; _outCell = cell;
+ _outLimit = R + over + half;
+ _outOn = true;
+
+ OutlineRoundOn = true; OutlineCells = nx * nz;
+ OutlineRadius = R; OutlineOverhang = over;
+ OutlineBakeMs = (float)sw.Elapsed.TotalMilliseconds;
+ }
+
+ /// 둥글린 윤곽 안인가 — 거리장을 이중선형으로 읽어 경계를 칸 크기보다 매끄럽게 만든다.
+ bool InRoundOutline(float wx, float wz)
+ {
+ if (!_outOn) return true;
+ float fx = (wx - _outMinX) / _outCell;
+ float fz = (wz - _outMinZ) / _outCell;
+ int i0 = Mathf.FloorToInt(fx), j0 = Mathf.FloorToInt(fz);
+ if (i0 < 0 || j0 < 0 || i0 >= _outNx - 1 || j0 >= _outNz - 1) return false;
+ float tx = fx - i0, tz = fz - j0;
+ int k = j0 * _outNx + i0;
+ float a = _outDist[k], b = _outDist[k + 1];
+ float c = _outDist[k + _outNx], d = _outDist[k + _outNx + 1];
+ return Mathf.Lerp(Mathf.Lerp(a, b, tx), Mathf.Lerp(c, d, tx), tz) <= _outLimit;
+ }
+
+ ///
+ /// 칸 하나를 어느 타일 몫으로 볼지 — 중심까지의 체비쇼프 거리가 가장 가까운 타일(동률이면 앞 번호).
+ /// 마스크가 받아들이는 점은 어떤 타일 중심에서든 `4 + overhang` 안이고, 칸 중심은 거기서 흔들기 폭
+ /// 안에 있다 → 그 칸은 **주인 타일의 넓힌 범위 안**이므로 정확히 한 번 만들어진다(빠짐도 중복도 0).
+ ///
+ static int OwnerTile(List tiles, float cx, float cz)
+ {
+ int best = -1; float bd = float.MaxValue;
+ for (int i = 0; i < tiles.Count; i++)
+ {
+ var c = tiles[i].center;
+ float d = Mathf.Max(Mathf.Abs(cx - c.x), Mathf.Abs(cz - c.z));
+ if (d < bd - 1e-4f) { bd = d; best = i; }
+ }
+ return best;
+ }
+
+ /// 어느 타일이든 윗면이면 true (816j2 `WLShoreFoam.OnAnyTile` 과 같은 판정).
+ static bool OnAnyTileMask(List tiles, float wx, float wz)
+ {
+ for (int i = 0; i < tiles.Count; i++)
+ {
+ var t = tiles[i];
+ if (t.mask == null) continue;
+ float r = t.mask.half * 1.4143f; // 회전해도 안전한 바깥 반지름
+ if (Mathf.Abs(wx - t.center.x) > r || Mathf.Abs(wz - t.center.z) > r) continue;
+ var l = t.tf.InverseTransformPoint(new Vector3(wx, t.center.y, wz));
+ if (t.mask.At(l.x, l.z)) return true;
+ }
+ return false;
}
// ─────────────────────────────────────────────────────────────────
diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs
index 89def2543..f56d69a5b 100644
--- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs
+++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs
@@ -276,6 +276,23 @@ namespace WL.Look.Farm
[Tooltip("경계 노이즈의 셀 크기(m). 작을수록 잘게 들쭉날쭉해진다.")]
public float farmEdgeNoiseCell = 0.5f;
+ // ── 816zd: 땅은 각진 그대로 두고 **풀 카펫의 바깥 윤곽만** 둥글게 (PD 2026-09-15)
+ [Header("§1-816zd — 섬 바깥 윤곽 둥근 풀 배치 (땅은 무변경)")]
+ [Tooltip("1 이면 풀을 깔 넓이를 「섬 윗면 footprint 를 반지름만큼 **열고**(침식 후 팽창) " +
+ "바깥으로 넘침폭만큼 넓힌」 모양으로 정한다. 볼록 모서리가 반지름만큼 둥글어져 " +
+ "각진 타일 위에서도 풀 카펫의 바깥선만 둥글게 읽힌다. 0 이면 예전처럼 타일 마스크 + edgeInset.")]
+ public int outlineRoundEnabled = 1;
+
+ [Tooltip("모서리를 둥글게 깎는 반지름(m). 클수록 모서리에 풀이 더 비고 윤곽이 더 둥글다.")]
+ public float outlineRoundRadius = 1.5f;
+
+ [Tooltip("둥글린 윤곽을 바깥으로 넓히는 폭(m). 기준선은 위 edgeInset 이다 — 같은 값이면 바깥선이 " +
+ "지금과 똑같고(모서리만 둥글어짐), 그보다 크면 차이만큼 가장자리 풀이 절벽 위로 넘친다.")]
+ public float outlineOverhang = 0.3f;
+
+ [Tooltip("윤곽 계산 격자 칸 크기(m). 작을수록 윤곽선이 매끄럽지만 다시 깔 때 더 오래 걸린다.")]
+ public float outlineRoundCell = 0.25f;
+
[Tooltip("건물·소품 콜라이더에서 이만큼(m) 떨어뜨린다.")]
public float propMargin = 0.05f;
diff --git a/Assets/WL/Look/Farm/WLShoreFoam.cs b/Assets/WL/Look/Farm/WLShoreFoam.cs
index 359b8f641..e77d5c4f0 100644
--- a/Assets/WL/Look/Farm/WLShoreFoam.cs
+++ b/Assets/WL/Look/Farm/WLShoreFoam.cs
@@ -232,8 +232,11 @@ namespace WL.Look.Farm
return cfg.shoreFoamFallbackY;
}
- /// 챔퍼 거리 변환 — seed 가 true 인 칸에서의 거리(m).
- static float[] Chamfer(bool[] inside, int nx, int nz, float cell, bool seedOutside)
+ ///
+ /// 챔퍼 거리 변환 — seed 가 true 인 칸에서의 거리(m).
+ /// 816zd 가 풀 배치의 둥근 윤곽(침식→팽창)에도 그대로 쓴다 — 거리 정의를 한 곳에만 둔다.
+ ///
+ public static float[] Chamfer(bool[] inside, int nx, int nz, float cell, bool seedOutside)
{
const float BIG = 1e9f;
var d = new float[nx * nz];