// ───────────────────────────────────────────────────────────────────────────── // 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; /// 실제로 그린 인스턴스 좌표 집합의 지문. 두 프레임이 같으면 생성·소멸·이동이 0 이다. 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 _blockers = new List(128); // 816za — 밭 자리만 따로. 반듯한 사각형이 아니라 「모서리 둥글게 + 경계 노이즈」로 판정한다. readonly List _farmRounds = new List(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; } /// 다시 깐다(섬이 확장됐을 때). 멱등 — 몇 번 불러도 안전. public void Rebuild() { _cacheValid = false; _fastRefresh = false; if (!isActiveAndEnabled) { enabled = true; return; } // OnEnable 이 알아서 만든다 enabled = false; // OnDisable → 버퍼 해제 enabled = true; // OnEnable → 다시 생성 Rebuilds++; } /// /// 816q — 카메라가 움직인 만큼 **가시 범위만** 다시 고른다(타일 재수집 없음). /// 좌표는 월드 고정 격자라 이미 보이던 풀은 제자리 그대로다 — 늘고 주는 것은 여유 폭 안에서만. /// public void RefreshView() { if (!_cacheValid || !isActiveAndEnabled) return; if (cfg == null || cfg.viewCullEnabled == 0) return; _fastRefresh = true; enabled = false; enabled = true; _fastRefresh = false; ViewRefreshes++; } /// 카메라가 「다시 골라야 할 만큼」 움직였는지(위치 보폭 또는 회전 각). 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); } /// /// 816o — 구름이 흐른 만큼 **띠만** 다시 고른다. 타일·제외 사각형은 다시 훑지 않는다 /// (`FindObjectsByType` 를 타지 않으므로 전체 재생성보다 훨씬 싸다). /// public void RefreshCloudEdges() { if (!_cacheValid || !isActiveAndEnabled) return; if (cfg == null || cfg.cloudEdgeEnabled == 0) return; _fastRefresh = true; enabled = false; enabled = true; _fastRefresh = false; CloudRefreshes++; } /// /// 816zh — 구름이 흐른 만큼 풀잎 **배율 목표만** 다시 계산해 버퍼를 다시 채운다(좌표·후보 불변 · 팝핑 0). /// 갱신 주기 = `cloudBandRefreshSeconds`(LateUpdate 가 스스로 돈다). 프로브가 직접 불러도 안전. /// 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()); } /// /// 토글(OnDisable → 버퍼 해제 → OnEnable → 재생성)은 **프레임 끝**에 한다 — 기반 Update 가 이번 프레임 드로우에 이미 넘긴 /// 버퍼를 렌더링 전에 해제하면 그 프레임의 풀이 통째로 빠져 5 Hz 깜빡임이 된다. /// 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> 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(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(8); var allSets = new List(8); var cands = new List(8192); for (int L = 0; L < layers.Count; L++) { float density = layers[L]; // 이 층에 속한 정의들 + 누적 확률 var defs = new List(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 — 가시 범위 ──────────────────────────────────────────── /// 측정·검증용. 비우면 `Camera.main`(없으면 첫 활성 카메라)을 쓴다. 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(FindObjectsSortMode.None); for (int i = 0; i < all.Length; i++) if (all[i].isActiveAndEnabled) return all[i]; return null; } /// /// 카메라가 **바닥 평면에서** 보는 사다리꼴을 구해 각 변을 여유 폭만큼 밖으로 민다. /// 🔴 절두체 평면을 그대로 밀면 안 된다 — 위쪽 평면은 바닥과 거의 나란해서(15°) /// 8 m 밀면 바닥에서는 8/sin15° ≈ 31 m 나 뻗어 면적이 6 배가 된다(실측 618 → 3,731 ㎡). /// 바닥 평면에서 2 차원으로 밀면 넓이가 목적대로 「둘레 × 여유」 만큼만 는다. /// 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; } /// /// 816zh — 구름 「높이」(강도를 걷어 낸 cover + 0.5·noise)를 성긴 격자에서 계산해 두고(6 옥타브 노이즈 비용은 여기서만 · /// 간격·상한은 816o 의 `cloudEdgeSampleCell`/`cloudEdgeMaxSamples` 재사용), 들판 후보마다 목표 배율 /// w = smoothstep(1 − |높이 − cover| / cloudBandWidth) 을 구한다 — 그림자 경계에서 1, 안쪽·바깥쪽은 0. /// follow = true 면 현재값이 목표로 `cloudBandLerp` 만큼 다가간다(자라고 줄어듦) · false 면 목표값을 그대로 쓴다(첫 굽기). /// 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> 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[_cdef.Length]; for (int i = 0; i < buckets.Length; i++) buckets[i] = new List(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>(); 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 _tiles = new List(96); List CollectTiles() { _tiles.Clear(); var islands = Object.FindObjectsByType(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(); 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(); 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); } /// 길(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 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; } /// 둥글린 윤곽 안인가 — 거리장을 이중선형으로 읽어 경계를 칸 크기보다 매끄럽게 만든다. 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; } // ───────────────────────────────────────────────────────────────── // 제외 사각형 — 농사 타일 · 건물 · 소품 // ───────────────────────────────────────────────────────────────── void BuildBlockers(List 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(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(); 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(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(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() != null) continue; if (c.attachedRigidbody != null && !c.attachedRigidbody.isKinematic) continue; // 밭 트리거는 ①에서 이미 넣었다 if (c.GetComponent() != null) continue; if (c.GetComponent() != 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)); } } /// /// 점 둘레 `band` m 안에 경계(섬 윤곽 밖·타일 밖·소품/흙 제외)가 있는가 — 8방향 표본. /// 경계 전용 풀잎(`edgeOnly_`)은 여기서 true 인 곳에만 깔린다(들판 안쪽 = 깔지 않음). /// bool NearBoundary(List 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; } } }