Merge branch 'wl/gameplay/WL-816zd-grass-round-outline'

This commit is contained in:
깃 관리자 2026-09-15 17:18:39 +09:00
commit a0db722896
4 changed files with 208 additions and 17 deletions

View File

@ -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

View File

@ -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);
}
/// <summary>길(Road 메시) 위인가 — 여백 포함. 816zd 가 둥근 윤곽 모드에서도 그대로 쓴다.</summary>
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<TileRef> 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;
}
/// <summary>둥글린 윤곽 안인가 — 거리장을 이중선형으로 읽어 경계를 칸 크기보다 매끄럽게 만든다.</summary>
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;
}
/// <summary>
/// 칸 하나를 어느 타일 몫으로 볼지 — 중심까지의 체비쇼프 거리가 가장 가까운 타일(동률이면 앞 번호).
/// 마스크가 받아들이는 점은 어떤 타일 중심에서든 `4 + overhang` 안이고, 칸 중심은 거기서 흔들기 폭
/// 안에 있다 → 그 칸은 **주인 타일의 넓힌 범위 안**이므로 정확히 한 번 만들어진다(빠짐도 중복도 0).
/// </summary>
static int OwnerTile(List<TileRef> 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;
}
/// <summary>어느 타일이든 윗면이면 true (816j2 `WLShoreFoam.OnAnyTile` 과 같은 판정).</summary>
static bool OnAnyTileMask(List<TileRef> 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;
}
// ─────────────────────────────────────────────────────────────────

View File

@ -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;

View File

@ -232,8 +232,11 @@ namespace WL.Look.Farm
return cfg.shoreFoamFallbackY;
}
/// <summary>챔퍼 거리 변환 — seed 가 true 인 칸에서의 거리(m).</summary>
static float[] Chamfer(bool[] inside, int nx, int nz, float cell, bool seedOutside)
/// <summary>
/// 챔퍼 거리 변환 — seed 가 true 인 칸에서의 거리(m).
/// 816zd 가 풀 배치의 둥근 윤곽(침식→팽창)에도 그대로 쓴다 — 거리 정의를 한 곳에만 둔다.
/// </summary>
public static float[] Chamfer(bool[] inside, int nx, int nz, float cell, bool seedOutside)
{
const float BIG = 1e9f;
var d = new float[nx * nz];