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

583 lines
29 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 = "";
// 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 = "";
public WLIslandLookSettings cfg;
Bounds _bounds = new Bounds(Vector3.zero, Vector3.one * 16f);
readonly List<Rect> _blockers = new List<Rect>(128);
// ── 816o 캐시 — 한 번 뽑은 후보를 들고 있다가, 구름이 흐르면 「고르기」만 다시 한다 ──
struct Cand { public float x, z, y; public int def; public float sc; public float yaw; }
Cand[] _cand;
int _candCount;
InstancingSettings[] _cset;
WLScatterDef[] _cdef;
long[] _ctri;
bool _cacheValid;
float _cacheY;
bool _fastRefresh;
readonly WLCloudShadowField _field = new WLCloudShadowField();
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>
/// 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++;
}
// ─────────────────────────────────────────────────────────────────
// 점 뽑기 — 데모 `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;
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);
// 경계 박스 — 구름 격자를 굽기 전에 정해져야 한다
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
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));
}
_bounds = new Bounds((bmin + bmax) * 0.5f, bmax - bmin);
_cacheY = tiles[0].center.y;
// ── 816o — 바닥과 **같은 식**으로 구름 그림자 색 띠의 경계를 굽는다 ──
float coverage = 1f;
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;
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);
for (int ix = ix0; ix <= ix1; ix++)
{
for (int iz = iz0; iz <= iz1; iz++)
{
// 같은 칸이면 몇 번 다시 깔아도 같은 결과(확장 때 풀이 튀지 않는다)
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 (!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; }
}
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 });
}
}
}
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;
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;
}
Dictionary<InstancingSettings, List<InstanceData>> BuildFromCache(bool refresh)
{
if (refresh)
{
_field.Tick();
BakeField();
}
Instances = 0; Triangles = 0; DrawnConfigs = 0; CloudExcluded = 0;
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;
for (int i = 0; i < _candCount; i++)
{
var c = _cand[i];
if (edge && !_field.IsEdge(c.x, c.z)) { CloudExcluded++; continue; }
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 * c.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 });
}
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") : "")
+ (_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 + ")" : ""));
cfg.Log(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;
}
// 길 제외
if (t.roadMask != null && t.roadTf != null)
{
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;
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// 제외 사각형 — 농사 타일 · 건물 · 소품
// ─────────────────────────────────────────────────────────────────
void BuildBlockers(List<TileRef> tiles)
{
_blockers.Clear();
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; i++)
{
var f = farms[i];
if (f == null || f.gameObject.scene != scene) continue;
var bc = f.GetComponent<BoxCollider>();
if (bc != null) AddBox(f.transform, bc.center, bc.size, cfg.farmMargin);
else AddRect(f.transform.position.x, f.transform.position.z, 6f, 5f, cfg.farmMargin);
}
// ② 농사 타일 하나하나(1×1) — Farm 이 런타임에 만든다. 이중 안전.
var soils = Object.FindObjectsByType<FISoil>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < soils.Length; i++)
{
var s = soils[i];
if (s == null || s.gameObject.scene != scene) continue;
var p = s.transform.position;
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));
}
}
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;
}
return false;
}
void AddBox(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);
AddRect(c.x, c.z, hx * 2f, hz * 2f, margin);
}
void AddRect(float cx, float cz, float w, float h, float margin)
{
_blockers.Add(new Rect(cx - w * 0.5f - margin, cz - h * 0.5f - margin, w + margin * 2f, h + margin * 2f));
}
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;
}
}
}