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

1086 lines
58 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLIslandGrass.cs — 섬 타일 **윗면**에 데모와 똑같은 풀·꽃·자갈을 깐다 (WL-816e · #816)
//
// ■ 왜 이렇게 하나 (816a 의 「터레인이 없어 구조상 불가」를 뒤집은 근거)
// 데모의 풀은 Terrain 기능이 아니라 `Environment.Instancing.InstancesBehaviour`
// (추상 클래스)가 GPU 인스턴싱으로 그리는 것이다. Terrain 은 **점을 어디에 찍을지**
// 알려 주는 역할만 한다(`TerrainInstancesBehaviour.GetInstanceData`).
// → 같은 추상 클래스를 상속해 **점을 섬 타일 윗면에서 뽑으면** 지형 없이 같은 그림이 나온다.
// 데모 에셋(`Assets/3DPixelArtEnvironment/**`)은 **한 글자도 고치지 않는다**(상속만).
//
// ■ 비용 — 그리기는 `Graphics.DrawMeshInstancedIndirect` 1회/설정.
// 풀·꽃·자갈 3종 = **드로우콜 3개**(인스턴스 수와 무관). 그림자 캐스팅 off.
//
// ■ 안전 — 콜라이더 0 · NavMeshSurface 가 굽는 대상 0 · 레이캐스트 대상 0.
// 걷기·밭 갈기·수확·건설에 물리적으로 관여할 수 없다.
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using Environment.Instancing;
using CryingSnow.FarmingIsland;
using FIIsland = CryingSnow.FarmingIsland.Island;
using FIFarm = CryingSnow.FarmingIsland.Farm;
using FISoil = CryingSnow.FarmingIsland.Soil;
namespace WL.Look.Farm
{
[DisallowMultipleComponent]
public sealed class WLIslandGrass : InstancesBehaviour
{
public const string ObjectName = "~WL_IslandGrass";
// ── 진단(프로브·보고가 읽는다) ─────────────────────────────────
public static int Instances, Tiles, Rebuilds, Excluded, DrawnConfigs;
public static long Triangles;
public static float UsedDensity;
public static string LastLog = "";
// 816q — 가시 범위 배치 진단
public static int ViewCulled, ViewRefreshes;
public static bool ViewCullOn;
/// <summary>실제로 그린 인스턴스 좌표 집합의 지문. 두 프레임이 같으면 생성·소멸·이동이 0 이다.</summary>
public static ulong InstanceKey;
// 816o — 구름 경계 배치 진단
public static int Candidates, CloudRefreshes, CloudExcluded, EdgeCells, EdgeDilate, EdgeSamples;
public static float EdgeFraction, EdgeCellSize, EdgeSampleCell, BakeMs, FilterMs;
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 = "";
// 816zh — 구름 그림자 경계 띠(부드러운 성장) 진단
public static bool CloudBandOn;
public static int BandCandidates, BandCompacted, BandRefreshes, BandSamples;
public static float BandMs, BandSampleCell;
public static string CloudBandWhy = "";
public WLIslandLookSettings cfg;
Bounds _bounds = new Bounds(Vector3.zero, Vector3.one * 16f);
readonly List<Rect> _blockers = new List<Rect>(128);
// 816za — 밭 자리만 따로. 반듯한 사각형이 아니라 「모서리 둥글게 + 경계 노이즈」로 판정한다.
readonly List<Rect> _farmRounds = new List<Rect>(8);
// 816zf — 흙 데칼이 살아 있으면 밭 제외는 위 두 목록 대신 `WLSoftDirt` 마스크가 판정한다.
bool _softDirt;
// ── 816o 캐시 — 한 번 뽑은 후보를 들고 있다가, 구름이 흐르면 「고르기」만 다시 한다 ──
// 816zh — band: 1 = 물리 경계 띠 안(항상 배율 1) · 0 = 들판(구름 경계 가중치 w 가 배율을 정한다 · 0~1)
struct Cand { public float x, z, y; public int def; public float sc; public float yaw; public byte band; public float w; }
Cand[] _cand;
int _candCount;
InstancingSettings[] _cset;
WLScatterDef[] _cdef;
long[] _ctri;
bool _cacheValid;
float _cacheY;
bool _fastRefresh;
readonly WLCloudShadowField _field = new WLCloudShadowField();
// ── 816zh — 구름 그림자 경계 띠(부드러운 성장) ──
bool _bandOn; // 이번 캐시가 띠 가중치를 쓰는가(바닥 재질에서 구름을 읽었을 때만)
bool _bandRefresh; // 지금 토글이 「띠 목표 재계산」 갱신인가(816o 굽기·로그는 건너뛴다)
float _bandNext; // 다음 갱신 시각(unscaled)
int _bandPendingFrame = -1; // 프레임 끝 토글을 예약한 프레임(같은 프레임에 두 번 예약 방지 · 코루틴이 죽어도 다음 프레임에 복구)
static readonly WaitForEndOfFrame s_eof = new WaitForEndOfFrame();
public override Bounds CalculateInstancesBounds() { return _bounds; }
/// <summary>다시 깐다(섬이 확장됐을 때). 멱등 — 몇 번 불러도 안전.</summary>
public void Rebuild()
{
_cacheValid = false;
_fastRefresh = false;
if (!isActiveAndEnabled) { enabled = true; return; } // OnEnable 이 알아서 만든다
enabled = false; // OnDisable → 버퍼 해제
enabled = true; // OnEnable → 다시 생성
Rebuilds++;
}
/// <summary>
/// 816q — 카메라가 움직인 만큼 **가시 범위만** 다시 고른다(타일 재수집 없음).
/// 좌표는 월드 고정 격자라 이미 보이던 풀은 제자리 그대로다 — 늘고 주는 것은 여유 폭 안에서만.
/// </summary>
public void RefreshView()
{
if (!_cacheValid || !isActiveAndEnabled) return;
if (cfg == null || cfg.viewCullEnabled == 0) return;
_fastRefresh = true;
enabled = false;
enabled = true;
_fastRefresh = false;
ViewRefreshes++;
}
/// <summary>카메라가 「다시 골라야 할 만큼」 움직였는지(위치 보폭 또는 회전 각).</summary>
public bool ViewMovedEnough()
{
if (cfg == null || cfg.viewCullEnabled == 0 || !_cacheValid) return false;
var cam = PickCamera();
if (cam == null) return false;
if (!_viewOn) return true; // 처음 깔 때 카메라가 아직 없었다 → 이제 생겼으니 다시 고른다
if ((cam.transform.position - _viewBuiltPos).sqrMagnitude
> Mathf.Max(0.05f, cfg.viewRebuildStep) * Mathf.Max(0.05f, cfg.viewRebuildStep)) return true;
return Vector3.Angle(cam.transform.forward, _viewBuiltFwd) > Mathf.Max(0.1f, cfg.viewRebuildAngle);
}
/// <summary>
/// 816o — 구름이 흐른 만큼 **띠만** 다시 고른다. 타일·제외 사각형은 다시 훑지 않는다
/// (`FindObjectsByType` 를 타지 않으므로 전체 재생성보다 훨씬 싸다).
/// </summary>
public void RefreshCloudEdges()
{
if (!_cacheValid || !isActiveAndEnabled) return;
if (cfg == null || cfg.cloudEdgeEnabled == 0) return;
_fastRefresh = true;
enabled = false;
enabled = true;
_fastRefresh = false;
CloudRefreshes++;
}
/// <summary>
/// 816zh — 구름이 흐른 만큼 풀잎 **배율 목표만** 다시 계산해 버퍼를 다시 채운다(좌표·후보 불변 · 팝핑 0).
/// 갱신 주기 = `cloudBandRefreshSeconds`(LateUpdate 가 스스로 돈다). 프로브가 직접 불러도 안전.
/// </summary>
public void RefreshCloudBand()
{
if (!_cacheValid || !isActiveAndEnabled || !_bandOn) return;
_fastRefresh = true; _bandRefresh = true;
enabled = false;
enabled = true;
_fastRefresh = false; _bandRefresh = false;
BandRefreshes++;
}
// 🔴 기반 클래스(InstancesBehaviour)에는 Update·OnEnable·OnDisable 만 있다(private) — LateUpdate 는 여기 정의해도 가려지는 것이 없다.
// OnEnable/OnDisable/Update 를 이 클래스에 정의하면 기반 것이 가려져 버퍼가 안 만들어진다 — 절대 정의하지 말 것.
void LateUpdate()
{
if (!_bandOn || !_cacheValid || cfg == null) return;
if (_bandPendingFrame == Time.frameCount) return;
if (Time.unscaledTime < _bandNext) return;
_bandPendingFrame = Time.frameCount;
StartCoroutine(RefreshCloudBandAtEndOfFrame());
}
/// <summary>
/// 토글(OnDisable → 버퍼 해제 → OnEnable → 재생성)은 **프레임 끝**에 한다 — 기반 Update 가 이번 프레임 드로우에 이미 넘긴
/// 버퍼를 렌더링 전에 해제하면 그 프레임의 풀이 통째로 빠져 5 Hz 깜빡임이 된다.
/// </summary>
System.Collections.IEnumerator RefreshCloudBandAtEndOfFrame()
{
yield return s_eof;
if (cfg == null) yield break;
_bandNext = Time.unscaledTime + Mathf.Max(0.02f, cfg.cloudBandRefreshSeconds);
RefreshCloudBand();
}
// ─────────────────────────────────────────────────────────────────
// 점 뽑기 — 데모 `TerrainInstancesBehaviour.GetInstanceData` 와 같은 절차
// ─────────────────────────────────────────────────────────────────
public override Dictionary<InstancingSettings, List<InstanceData>> GetInstanceData()
{
if (_fastRefresh && _cacheValid) return BuildFromCache(true);
_cacheValid = false;
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;
CloudBandOn = false; CloudBandWhy = ""; _bandOn = false;
BandCandidates = 0; BandCompacted = 0; BandSamples = 0; BandMs = 0f; BandSampleCell = 0f;
if (cfg == null) cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.grassEnabled == 0) { LastLog = "꺼짐"; return null; }
if (cfg.scatter == null || cfg.scatter.Length == 0) { LastLog = "scatter 표가 비었다"; return null; }
// 🔴 컴퓨트 버퍼(StructuredBuffer)를 못 쓰는 기기에서는 깔지 않는다(데모 셰이더와 같은 요구).
if (!SystemInfo.supportsComputeShaders || !SystemInfo.supportsInstancing)
{
LastLog = "기기가 인스턴싱/컴퓨트버퍼 미지원 → 풀 생략(그래도 게임은 그대로 돈다)";
cfg.Log(LastLog);
return null;
}
// 우리 오브젝트는 항상 원점·무회전·크기 1 이어야 한다(_LocalToWorld = 단위행렬).
transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
transform.localScale = Vector3.one;
transform.hasChanged = true; // 기반 클래스 Update 가 _LocalToWorld 를 밀어 넣게 한다
var tiles = CollectTiles();
Tiles = tiles.Count;
if (tiles.Count == 0) { LastLog = "깔 타일 0"; return null; }
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 - 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;
// ── 816zh — 구름 그림자 경계 띠(부드러운 성장): 바닥 재질의 구름 식을 읽는다(816o 와 같은 필드 · 같은 재질) ──
// 못 읽으면(구름 꺼짐 등) 물리 경계 띠만 = 816ze 그대로.
if (cfg.cloudBandEnabled != 0)
{
var bsrc = cfg.cloudEdgeSourceMaterial != null ? cfg.cloudEdgeSourceMaterial : cfg.islandTopMaterial;
if (_field.Configure(bsrc)) { _bandOn = true; CloudBandOn = true; }
else CloudBandWhy = _field.Why;
}
// ── 816o — 바닥과 **같은 식**으로 구름 그림자 색 띠의 경계를 굽는다 ──
// 816zh 띠가 켜져 있으면 816o(하드 컷 = 순간이동)는 쓰지 않는다 — 둘을 겹치면 팝핑이 되살아난다.
float coverage = 1f;
if (cfg.cloudEdgeEnabled != 0 && _bandOn) CloudWhy = "816zh 구름 띠가 대신한다";
else if (cfg.cloudEdgeEnabled != 0)
{
var src = cfg.cloudEdgeSourceMaterial != null ? cfg.cloudEdgeSourceMaterial : cfg.islandTopMaterial;
if (_field.Configure(src))
{
BakeField();
coverage = Mathf.Clamp(_field.EdgeFraction, 0.02f, 1f);
_edgeOn = true; CloudEdgeOn = true;
}
else CloudWhy = _field.Why;
}
// 밀도 자동 조절(모바일 예산) — 띠 면적(coverage)을 실측해서 반영한다
float edgeScale = _edgeOn ? Mathf.Max(0.05f, cfg.cloudEdgeDensityScale) : 1f;
float budgetScale = 1f;
if (cfg.maxInstances > 0)
{
float area = tiles.Count * 64f; // 타일 8×8
float topLayer = 0f;
for (int i = 0; i < cfg.scatter.Length; i++)
{
var s = cfg.scatter[i];
if (s == null || s.enabled_ == 0) continue;
if (s.density > topLayer) topLayer = s.density;
}
float d0 = topLayer * edgeScale;
float est = area * d0 * d0 * 0.93f * coverage; // 윗면은 8×8 의 약 93 %
if (est > cfg.maxInstances) budgetScale = Mathf.Sqrt(cfg.maxInstances / est);
}
float densScale = budgetScale * edgeScale;
// 같은 density 끼리 한 층으로 묶는다(데모의 FirstLayer/SecondLayer 와 같은 구조)
var layers = new List<float>(4);
for (int i = 0; i < cfg.scatter.Length; i++)
{
var s = cfg.scatter[i];
if (s == null || s.enabled_ == 0 || s.mesh == null || s.material == null || s.scale <= 0f) continue;
float d = s.density * densScale;
if (d <= 0f) continue;
bool found = false;
for (int k = 0; k < layers.Count; k++) if (Mathf.Abs(layers[k] - d) < 1e-5f) { found = true; break; }
if (!found) layers.Add(d);
}
var allDefs = new List<WLScatterDef>(8);
var allSets = new List<InstancingSettings>(8);
var cands = new List<Cand>(8192);
for (int L = 0; L < layers.Count; L++)
{
float density = layers[L];
// 이 층에 속한 정의들 + 누적 확률
var defs = new List<WLScatterDef>(4);
float wsum = 0f;
for (int i = 0; i < cfg.scatter.Length; i++)
{
var s = cfg.scatter[i];
if (s == null || s.enabled_ == 0 || s.mesh == null || s.material == null || s.scale <= 0f) continue;
if (Mathf.Abs(s.density * densScale - density) > 1e-5f) continue;
defs.Add(s); wsum += Mathf.Max(0f, s.probability);
}
if (defs.Count == 0 || wsum <= 0f) continue;
int baseIdx = allDefs.Count;
for (int i = 0; i < defs.Count; i++)
{
allDefs.Add(defs[i]);
allSets.Add(new InstancingSettings
{
Mesh = defs[i].mesh,
Material = defs[i].material,
Probability = defs[i].probability,
Scale = defs[i].scale,
NormalOffset = defs[i].normalOffset,
});
}
float step = 1f / density;
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 - 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;
float jz = (Frac(h, 1) - 0.5f) * 2f * pv;
float wx = (ix + 0.5f + jx) * step;
float wz = (iz + 0.5f + jz) * step;
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; }
// 어느 정의가 걸리나(가중 추첨 · 결정적)
float r = Frac(h, 2) * wsum;
int pick = defs.Count - 1;
float acc = 0f;
for (int d = 0; d < defs.Count; d++)
{
acc += Mathf.Max(0f, defs[d].probability);
if (r <= acc) { pick = d; break; }
}
// 2026-09-15 PD 「그라데이션과 무관하게 풀이 얼룩덜룩」 — 경계 전용 정의는 물리 경계 띠 밖(들판 한가운데)에는 깔지 않는다.
// 816zh(PD 「그림자를 기준으로」) — 구름 띠가 켜져 있으면 들판에도 후보를 두되 band = 0 으로 표시한다:
// 배율 = 구름 경계 가중치 w(주기마다 목표 재계산 · lerp 로 자라고 줄어듦). 물리 띠 안(band = 1)은 w 와 무관하게 항상 1.
byte band = 1;
if (defs[pick].edgeOnly_ != 0)
{
bool physical = cfg.grassEdgeBand > 0f;
bool near = physical && NearBoundary(tiles, wx, wz, cfg.grassEdgeBand);
if (!near)
{
if (_bandOn) { band = 0; BandCandidates++; }
else if (physical) { Excluded++; continue; }
}
}
float sc = 1f + (Frac(h, 3) - 0.5f) * 2f * sv;
float yaw = cfg.randomYaw != 0 ? Frac(h, 4) * 360f : 0f;
cands.Add(new Cand { x = wx, z = wz, y = t.center.y, def = baseIdx + pick, sc = sc, yaw = yaw, band = band, w = 1f });
}
}
}
if (density > UsedDensity) UsedDensity = density;
}
_cdef = allDefs.ToArray();
_cset = allSets.ToArray();
_ctri = new long[_cdef.Length];
for (int i = 0; i < _cdef.Length; i++)
{
long tri = 0;
var m = _cdef[i].mesh;
for (int s = 0; s < m.subMeshCount; s++) tri += m.GetIndexCount(s) / 3;
_ctri[i] = tri;
}
_cand = cands.ToArray();
_candCount = _cand.Length;
Candidates = _candCount;
_budgetScale = budgetScale;
_cacheValid = _cdef.Length > 0 && _candCount > 0;
if (!_cacheValid) { LastLog = "후보 0"; cfg.Log(LastLog); return null; }
return BuildFromCache(false);
}
// ─────────────────────────────────────────────────────────────────
// 816o — 띠 굽기 + 캐시에서 고르기
// ─────────────────────────────────────────────────────────────────
float _budgetScale = 1f;
bool _edgeOn;
// ── 816q — 가시 범위 ────────────────────────────────────────────
/// <summary>측정·검증용. 비우면 `Camera.main`(없으면 첫 활성 카메라)을 쓴다.</summary>
public Camera viewCameraOverride;
readonly Vector2[] _poly = new Vector2[4]; // 카메라가 바닥에서 보는 사다리꼴
readonly Vector2[] _polyN = new Vector2[4]; // 변의 바깥 법선
readonly float[] _polyD = new float[4];
Vector3 _viewCamPos, _viewCamFwd; // 지금 심어 둔 절두체를 만든 카메라 자세
Vector3 _viewBuiltPos, _viewBuiltFwd; // 마지막으로 **다시 고른** 시점의 카메라 자세
bool _viewOn;
Camera PickCamera()
{
if (viewCameraOverride != null) return viewCameraOverride;
var c = Camera.main;
if (c != null) return c;
var all = Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
for (int i = 0; i < all.Length; i++) if (all[i].isActiveAndEnabled) return all[i];
return null;
}
/// <summary>
/// 카메라가 **바닥 평면에서** 보는 사다리꼴을 구해 각 변을 여유 폭만큼 밖으로 민다.
/// 🔴 절두체 평면을 그대로 밀면 안 된다 — 위쪽 평면은 바닥과 거의 나란해서(15°)
/// 8 m 밀면 바닥에서는 8/sin15° ≈ 31 m 나 뻗어 면적이 6 배가 된다(실측 618 → 3,731 ㎡).
/// 바닥 평면에서 2 차원으로 밀면 넓이가 목적대로 「둘레 × 여유」 만큼만 는다.
/// </summary>
bool TryViewPlanes()
{
var cam = PickCamera();
if (cam == null) return false;
float y = _cacheY;
float maxD = Mathf.Max(1f, cfg.viewMaxDistance);
for (int i = 0; i < 4; i++)
{
// 뷰포트 모서리를 (0,0) → (1,0) → (1,1) → (0,1) 순서로 돈다(사다리꼴이 꼬이지 않게)
float vx = (i == 1 || i == 2) ? 1f : 0f;
float vy = (i >= 2) ? 1f : 0f;
var ray = cam.ViewportPointToRay(new Vector3(vx, vy, 0f));
float t = maxD;
if (ray.direction.y < -1e-4f) t = Mathf.Min((y - ray.origin.y) / ray.direction.y, maxD);
var h = ray.origin + ray.direction * t;
_poly[i] = new Vector2(h.x, h.z);
}
// 변마다 바깥 법선. 사다리꼴의 감김 방향은 카메라 자세에 따라 뒤집히므로 부호를 맞춘다
var c2 = Vector2.zero;
for (int i = 0; i < 4; i++) c2 += _poly[i];
c2 *= 0.25f;
for (int i = 0; i < 4; i++)
{
var a = _poly[i]; var bb = _poly[(i + 1) & 3];
var e = bb - a;
var n = new Vector2(e.y, -e.x);
if (n.sqrMagnitude < 1e-8f) n = new Vector2(1f, 0f);
n.Normalize();
if (Vector2.Dot(n, a - c2) < 0f) n = -n; // 중심 반대쪽 = 바깥
_polyN[i] = n; _polyD[i] = Vector2.Dot(n, a);
}
_viewCamPos = cam.transform.position;
_viewCamFwd = cam.transform.forward;
return true;
}
bool InView(float x, float z)
{
float m = Mathf.Max(0f, cfg.viewMargin);
var p = new Vector2(x, z);
for (int i = 0; i < 4; i++) if (Vector2.Dot(_polyN[i], p) - _polyD[i] > m) return false;
return true;
}
void BakeField()
{
float cell = Mathf.Max(0.05f, cfg.cloudEdgeCell);
int dil = Mathf.Max(0, cfg.cloudEdgeDilate);
// 갱신 주기 동안 구름이 흐르는 만큼 미리 덮어 둔다 — 띠가 움직여도 빈틈이 안 생긴다
if (cfg.cloudEdgeDriftCover != 0 && cfg.cloudEdgeRefreshSeconds > 0f)
dil += Mathf.CeilToInt(_field.DriftPerSecond * cfg.cloudEdgeRefreshSeconds / cell);
var sw = System.Diagnostics.Stopwatch.StartNew();
_field.Bake(_bounds, _cacheY, cell, cfg.cloudEdgeMaxCells, dil,
cfg.cloudEdgeSampleCell, cfg.cloudEdgeMaxSamples);
sw.Stop();
BakeMs = (float)sw.Elapsed.TotalMilliseconds;
EdgeCells = _field.Cells; EdgeCellSize = _field.CellSize;
EdgeSamples = _field.Samples; EdgeSampleCell = _field.SampleCellSize;
EdgeFraction = _field.EdgeFraction; EdgeDilate = dil;
}
/// <summary>
/// 816zh — 구름 「높이」(강도를 걷어 낸 cover + 0.5·noise)를 성긴 격자에서 계산해 두고(6 옥타브 노이즈 비용은 여기서만 ·
/// 간격·상한은 816o 의 `cloudEdgeSampleCell`/`cloudEdgeMaxSamples` 재사용), 들판 후보마다 목표 배율
/// w = smoothstep(1 |높이 cover| / cloudBandWidth) 을 구한다 — 그림자 경계에서 1, 안쪽·바깥쪽은 0.
/// follow = true 면 현재값이 목표로 `cloudBandLerp` 만큼 다가간다(자라고 줄어듦) · false 면 목표값을 그대로 쓴다(첫 굽기).
/// </summary>
void BakeBand(bool follow)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
_field.TickTime();
_field.BakeLevels(_bounds, _cacheY, cfg.cloudEdgeSampleCell, cfg.cloudEdgeMaxSamples);
float width = Mathf.Max(1e-4f, cfg.cloudBandWidth);
float k = follow ? Mathf.Clamp01(cfg.cloudBandLerp) : 1f;
for (int i = 0; i < _candCount; i++)
{
if (_cand[i].band != 0) continue;
// 2026-09-16 PD — 기본은 화면에 보이는 색 계단선(ContourWeight) · 0 이면 노이즈 0선(BandWeight)
float target = cfg.cloudBandMode != 0 ? _field.ContourWeight(_cand[i].x, _cand[i].z, width)
: _field.BandWeight(_cand[i].x, _cand[i].z, width);
_cand[i].w += (target - _cand[i].w) * k;
}
sw.Stop();
BandMs = (float)sw.Elapsed.TotalMilliseconds;
BandSamples = _field.LevelSamples; BandSampleCell = _field.LevelCell;
_bandNext = Time.unscaledTime + Mathf.Max(0.02f, cfg.cloudBandRefreshSeconds);
}
Dictionary<InstancingSettings, List<InstanceData>> BuildFromCache(bool refresh)
{
if (refresh && !_bandRefresh)
{
_field.Tick();
BakeField();
}
// 816zh — 첫 굽기는 목표값 그대로(로드 때 자라나지 않는다) · 띠 갱신은 현재값이 목표로 lerp(자라고 줄어듦) · 뷰 갱신은 손대지 않는다
if (_bandOn && (!refresh || _bandRefresh)) BakeBand(refresh);
Instances = 0; Triangles = 0; DrawnConfigs = 0; CloudExcluded = 0; ViewCulled = 0; BandCompacted = 0;
// 816q — 카메라가 보는 범위(+여유 폭)만 남긴다. 좌표는 손대지 않는다(순간이동 0).
_viewOn = false;
if (cfg.viewCullEnabled != 0 && TryViewPlanes())
{
_viewOn = true;
_viewBuiltPos = _viewCamPos; _viewBuiltFwd = _viewCamFwd;
}
ViewCullOn = _viewOn;
var sw = System.Diagnostics.Stopwatch.StartNew();
var buckets = new List<InstanceData>[_cdef.Length];
for (int i = 0; i < buckets.Length; i++) buckets[i] = new List<InstanceData>(256);
Vector3 bc = _bounds.center;
bool edge = _edgeOn;
float minW = Mathf.Max(0f, cfg.cloudBandMinScale);
ulong key = 0UL;
for (int i = 0; i < _candCount; i++)
{
var c = _cand[i];
if (_viewOn && !InView(c.x, c.z)) { ViewCulled++; continue; }
if (edge && !_field.IsEdge(c.x, c.z)) { CloudExcluded++; continue; }
float sc = c.sc;
if (c.band == 0)
{
// 816zh — 들판 풀잎은 구름 경계 가중치가 배율. 거의 0 이면 버퍼에서 뺀다(정점 0 · 압축).
if (c.w < minW) { BandCompacted++; continue; }
sc *= c.w;
}
var rot = c.yaw != 0f ? Quaternion.Euler(0f, c.yaw, 0f) : Quaternion.identity;
var trs = Matrix4x4.TRS(new Vector3(c.x, c.y, c.z) - bc, rot, Vector3.one * sc);
var def = _cdef[c.def];
trs *= Matrix4x4.TRS(def.normalOffset * Vector3.up, Quaternion.identity,
new Vector3(def.scale, def.scale, def.scale));
buckets[c.def].Add(new InstanceData { TRS = trs, Normal = Vector3.up });
// 좌표 지문 — 순서에 무관하게 더한다(집합이 같으면 값도 같다)
key += (ulong)Mathf.RoundToInt(c.x * 1000f) * 73856093UL
^ (ulong)Mathf.RoundToInt(c.z * 1000f) * 19349663UL
^ (ulong)(c.def + 1) * 83492791UL;
}
InstanceKey = key;
sw.Stop();
FilterMs = (float)sw.Elapsed.TotalMilliseconds;
var result = new Dictionary<InstancingSettings, List<InstanceData>>();
for (int i = 0; i < buckets.Length; i++)
{
if (buckets[i].Count == 0) continue;
result.Add(_cset[i], buckets[i]);
Instances += buckets[i].Count;
DrawnConfigs++;
Triangles += _ctri[i] * buckets[i].Count;
}
LastLog = "타일 " + Tiles + " · 후보 " + Candidates + " · 인스턴스 " + Instances
+ " · 드로우콜 " + 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")
+ (_edgeOn
? " · 구름띠 " + (EdgeFraction * 100f).ToString("F1") + "%(칸 " + EdgeCellSize.ToString("F2")
+ "m×" + EdgeCells + " · 넓힘 " + EdgeDilate + " · 노이즈 " + EdgeSampleCell.ToString("F2")
+ "m×" + EdgeSamples + ") · 띠밖 제외 " + CloudExcluded
+ " · 굽기 " + BakeMs.ToString("F1") + "ms · 고르기 " + FilterMs.ToString("F1") + "ms"
: " · 구름띠 off" + (CloudWhy.Length > 0 ? "(" + CloudWhy + ")" : ""))
+ (_bandOn
? " · 구름경계띠(816zh) 폭 " + cfg.cloudBandWidth.ToString("F2") + " · 들판후보 " + BandCandidates
+ " · 압축 " + BandCompacted + " · 표본 " + BandSampleCell.ToString("F2") + "m×" + BandSamples
+ " · 굽기 " + BandMs.ToString("F1") + "ms · 갱신 " + BandRefreshes
: " · 구름경계띠(816zh) off" + (CloudBandWhy.Length > 0 ? "(" + CloudBandWhy + ")" : ""));
if (!_bandRefresh) cfg.Log(LastLog); // 816zh — 주기 갱신마다 로그를 찍지 않는다(프로브는 LastLog 를 읽는다)
return result.Count == 0 ? null : result;
}
// ─────────────────────────────────────────────────────────────────
// 섬 타일 모으기
// ─────────────────────────────────────────────────────────────────
public struct TileRef
{
public Transform tf; // 섬 트랜스폼
public Vector3 center; // 윗면 중심(월드 · y = 윗면)
public WLTileMask mask; // 윗면 마스크(섬 로컬)
public Transform roadTf;
public WLTileMask roadMask;
}
readonly List<TileRef> _tiles = new List<TileRef>(96);
List<TileRef> CollectTiles()
{
_tiles.Clear();
var islands = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || !isl.IsUnlocked || isl.IsBridge) continue;
if (!isl.gameObject.activeInHierarchy) continue;
if (isl.transform.localScale.x < 0.99f) continue; // 확장 애니메이션 중
var mf = isl.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
var mask = cfg.FindTileMask(mf.sharedMesh.name);
if (mask == null) continue; // 모르는 메시 = 안 깐다(안전)
Transform roadTf = null; WLTileMask roadMask = null;
var rt = isl.transform.Find("Road");
if (rt != null && rt.gameObject.activeInHierarchy)
{
var rmf = rt.GetComponent<MeshFilter>();
if (rmf != null && rmf.sharedMesh != null)
{
roadMask = cfg.FindRoadMask(rmf.sharedMesh.name);
if (roadMask != null) roadTf = rt;
}
}
_tiles.Add(new TileRef
{
tf = isl.transform,
center = isl.transform.position, // 윗면 y = 섬 원점 y (실측: 메시 maxY = 0)
mask = mask,
roadTf = roadTf,
roadMask = roadMask,
});
}
return _tiles;
}
bool OnTile(TileRef t, float wx, float wz)
{
var w = new Vector3(wx, t.center.y, wz);
var l = t.tf.InverseTransformPoint(w);
// 가장자리 여백 — 점과 ±inset 네 방향이 전부 윗면이어야 한다
float e = cfg.edgeInset;
if (!t.mask.At(l.x, l.z)) return false;
if (e > 0f)
{
if (!t.mask.At(l.x + e, l.z) || !t.mask.At(l.x - e, l.z) ||
!t.mask.At(l.x, l.z + e) || !t.mask.At(l.x, l.z - e)) return false;
}
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 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);
}
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;
}
// ─────────────────────────────────────────────────────────────────
// 제외 사각형 — 농사 타일 · 건물 · 소품
// ─────────────────────────────────────────────────────────────────
void BuildBlockers(List<TileRef> tiles)
{
_blockers.Clear();
_farmRounds.Clear();
bool round = cfg.farmEdgeRoundEnabled != 0 && cfg.farmEdgeRadius > 0f;
// 816zf — 흙 데칼이 경계를 쥐고 있으면 밭 제외는 **데칼 마스크**가 정한다(경계 정의 1개).
_softDirt = WLSoftDirt.Ready;
var scene = tiles.Count > 0 ? tiles[0].tf.gameObject.scene : default(UnityEngine.SceneManagement.Scene);
// ① 농사 — Farm 이 Awake 에서 만드는 BoxCollider(size = (length,2,width))가 밭의 정확한 넓이다.
var farms = Object.FindObjectsByType<FIFarm>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < farms.Length && !_softDirt; i++)
{
var f = farms[i];
if (f == null || f.gameObject.scene != scene) continue;
var bc = f.GetComponent<BoxCollider>();
Rect r = bc != null
? BoxRect(f.transform, bc.center, bc.size, cfg.farmMargin)
: MakeRect(f.transform.position.x, f.transform.position.z, 6f, 5f, cfg.farmMargin);
if (round) _farmRounds.Add(r); else _blockers.Add(r);
}
// ② 농사 타일 하나하나(1×1) — Farm 이 런타임에 만든다. 이중 안전.
// 라운드 모드에서는 ① 안에 든 타일을 빼야 한다. 안 그러면 반듯한 1×1 사각형들이 깎아낸 모서리를 도로 메운다.
// 816zf 데칼 모드에서는 1×1 사각형이 **둥근 경계를 도로 각지게** 만들므로 아예 넣지 않는다.
var soils = Object.FindObjectsByType<FISoil>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < soils.Length && !_softDirt; i++)
{
var s = soils[i];
if (s == null || s.gameObject.scene != scene) continue;
var p = s.transform.position;
if (round && InAnyFarmRect(p.x, p.z)) continue;
AddRect(p.x, p.z, 1f, 1f, cfg.farmMargin);
}
// ③ 건물·소품 — 섬 본체(Colliders/…)와 움직이는 것(플레이어·동물)은 뺀다.
var cols = Object.FindObjectsByType<Collider>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < cols.Length; i++)
{
var c = cols[i];
if (c == null || c.gameObject.scene != scene) continue;
var tf = c.transform;
// 섬 본체 박스 + 네 방향 벽(Colliders 아래)
if (tf.name == "Colliders" || (tf.parent != null && tf.parent.name == "Colliders")) continue;
// 물
if (c.gameObject.layer == LayerMask.NameToLayer("Water")) continue;
// 움직이는 것
if (c.GetComponentInParent<PlayerController>() != null) continue;
if (c.attachedRigidbody != null && !c.attachedRigidbody.isKinematic) continue;
// 밭 트리거는 ①에서 이미 넣었다
if (c.GetComponent<FIFarm>() != null) continue;
if (c.GetComponent<FISoil>() != null) continue;
var b = ColliderXZ(c);
if (b.width <= 0f || b.height <= 0f) continue;
if (b.width >= cfg.propMaxSize && b.height >= cfg.propMaxSize) continue; // 섬 크기 = 본체
_blockers.Add(Inflate(b, cfg.propMargin));
}
}
/// <summary>
/// 점 둘레 `band` m 안에 경계(섬 윤곽 밖·타일 밖·소품/흙 제외)가 있는가 — 8방향 표본.
/// 경계 전용 풀잎(`edgeOnly_`)은 여기서 true 인 곳에만 깔린다(들판 안쪽 = 깔지 않음).
/// </summary>
bool NearBoundary(List<TileRef> tiles, float x, float z, float band)
{
for (int k = 0; k < 8; k++)
{
float a = k * (Mathf.PI * 0.25f);
float sx = x + Mathf.Cos(a) * band, sz = z + Mathf.Sin(a) * band;
if (_outOn) { if (!InRoundOutline(sx, sz)) return true; }
else if (OwnerTile(tiles, sx, sz) < 0) return true;
if (Blocked(sx, sz)) return true;
}
return false;
}
bool Blocked(float x, float z)
{
for (int i = 0; i < _blockers.Count; i++)
{
var r = _blockers[i];
if (x >= r.xMin && x <= r.xMax && z >= r.yMin && z <= r.yMax) return true;
}
// 816zf — 흙 데칼과 **같은 경계**를 쓴다. 데칼 가장자리보다 `softDirtGrassBite` 안쪽만 막으므로
// 그 폭만큼 풀 톱니가 흙 안으로 파고든다(두 군데서 따로 계산하지 않는다).
if (_softDirt) return WLSoftDirt.Blocks(x, z);
for (int i = 0; i < _farmRounds.Count; i++)
if (InRoundedFarm(_farmRounds[i], x, z)) return true;
return false;
}
// 밭 사각형 안인가 — 타일을 어느 밭에 딸린 것으로 볼지 가르는 데만 쓴다(모서리·노이즈 무시).
bool InAnyFarmRect(float x, float z)
{
for (int i = 0; i < _farmRounds.Count; i++)
{
var r = _farmRounds[i];
if (x >= r.xMin && x <= r.xMax && z >= r.yMin && z <= r.yMax) return true;
}
return false;
}
// 816za — 밭 자리 판정. 네 모서리를 반지름만큼 깎고, 경계 전체를 노이즈로 흔든다.
// 노이즈가 () 면 흙이 물러나 풀이 파고들고, (+) 면 흙이 조금 넓어진다. 평균 0 이라 총량은 그대로다.
bool InRoundedFarm(Rect r, float x, float z)
{
float hx = r.width * 0.5f, hz = r.height * 0.5f;
float cx = r.xMin + hx, cz = r.yMin + hz;
if (cfg.farmEdgeNoise > 0f && cfg.farmEdgeNoiseCell > 0.0001f)
{
float n = (CellNoise(x / cfg.farmEdgeNoiseCell, z / cfg.farmEdgeNoiseCell) * 2f - 1f) * cfg.farmEdgeNoise;
hx += n; hz += n;
}
if (hx <= 0f || hz <= 0f) return false;
float dx = Mathf.Abs(x - cx), dz = Mathf.Abs(z - cz);
if (dx > hx || dz > hz) return false;
float rad = Mathf.Min(cfg.farmEdgeRadius, Mathf.Min(hx, hz));
if (rad <= 0f) return true;
float ox = dx - (hx - rad), oz = dz - (hz - rad);
if (ox <= 0f || oz <= 0f) return true; // 모서리 원호 밖 = 십자 몸통 = 안쪽
return ox * ox + oz * oz <= rad * rad;
}
static Rect BoxRect(Transform tf, Vector3 center, Vector3 size, float margin)
{
// 콜라이더가 disabled 여도 안전하게 — bounds 대신 직접 계산한다.
var c = tf.TransformPoint(center);
var ex = tf.TransformVector(new Vector3(size.x * 0.5f, 0f, 0f));
var ez = tf.TransformVector(new Vector3(0f, 0f, size.z * 0.5f));
float hx = Mathf.Abs(ex.x) + Mathf.Abs(ez.x);
float hz = Mathf.Abs(ex.z) + Mathf.Abs(ez.z);
return MakeRect(c.x, c.z, hx * 2f, hz * 2f, margin);
}
static Rect MakeRect(float cx, float cz, float w, float h, float margin)
{
return new Rect(cx - w * 0.5f - margin, cz - h * 0.5f - margin, w + margin * 2f, h + margin * 2f);
}
void AddRect(float cx, float cz, float w, float h, float margin)
{
_blockers.Add(MakeRect(cx, cz, w, h, margin));
}
static Rect Inflate(Rect r, float m)
{
return new Rect(r.xMin - m, r.yMin - m, r.width + m * 2f, r.height + m * 2f);
}
static Rect ColliderXZ(Collider c)
{
var bx = c as BoxCollider;
if (bx != null)
{
var tf = c.transform;
var ctr = tf.TransformPoint(bx.center);
var ex = tf.TransformVector(new Vector3(bx.size.x * 0.5f, 0f, 0f));
var ez = tf.TransformVector(new Vector3(0f, 0f, bx.size.z * 0.5f));
float hx = Mathf.Abs(ex.x) + Mathf.Abs(ez.x);
float hz = Mathf.Abs(ex.z) + Mathf.Abs(ez.z);
return new Rect(ctr.x - hx, ctr.z - hz, hx * 2f, hz * 2f);
}
var sp = c as SphereCollider;
if (sp != null)
{
var tf = c.transform;
var ctr = tf.TransformPoint(sp.center);
float s = Mathf.Max(Mathf.Abs(tf.lossyScale.x), Mathf.Abs(tf.lossyScale.z));
float r = sp.radius * s;
return new Rect(ctr.x - r, ctr.z - r, r * 2f, r * 2f);
}
var cp = c as CapsuleCollider;
if (cp != null)
{
var tf = c.transform;
var ctr = tf.TransformPoint(cp.center);
float s = Mathf.Max(Mathf.Abs(tf.lossyScale.x), Mathf.Abs(tf.lossyScale.z));
float r = cp.radius * s;
return new Rect(ctr.x - r, ctr.z - r, r * 2f, r * 2f);
}
var b = c.bounds; // MeshCollider 등
if (b.size.x <= 0f || b.size.z <= 0f) return new Rect(0f, 0f, 0f, 0f);
return new Rect(b.min.x, b.min.z, b.size.x, b.size.z);
}
// ── 결정적 난수 ─────────────────────────────────────────────────
static uint Hash(uint x)
{
x ^= x >> 16; x *= 0x7feb352dU;
x ^= x >> 15; x *= 0x846ca68bU;
x ^= x >> 16;
return x;
}
static float Frac(uint h, int k)
{
uint v = Hash(h + (uint)(k * 0x9E3779B9U));
return (v & 0xFFFFFF) / 16777215f;
}
// 816za — 셀 격자 값 노이즈 [0,1]. 셀 네 귀퉁이 난수를 부드럽게 섞어 경계를 들쭉날쭉하게 만든다.
static float CellNoise(float fx, float fz)
{
int x0 = Mathf.FloorToInt(fx), z0 = Mathf.FloorToInt(fz);
float tx = fx - x0, tz = fz - z0;
tx = tx * tx * (3f - 2f * tx);
tz = tz * tz * (3f - 2f * tz);
float a = CellRand(x0, z0), b = CellRand(x0 + 1, z0);
float c = CellRand(x0, z0 + 1), d = CellRand(x0 + 1, z0 + 1);
return Mathf.Lerp(Mathf.Lerp(a, b, tx), Mathf.Lerp(c, d, tx), tz);
}
static float CellRand(int x, int z)
{
uint h = Hash(unchecked((uint)x * 73856093U) ^ unchecked((uint)z * 19349663U) ^ 0x9E3779B9U);
return (h & 0xFFFFFF) / 16777215f;
}
}
}