// ───────────────────────────────────────────────────────────────────────────── // WLDemoTerrain.cs — 섬 바닥을 데모와 「똑같은」 부품으로 조립 (WL-816zj · #816) // // PD(2026-09-16) 「데모와 가깝게가 아니라 **똑같은** 결과를 원한다」. // 같은 카메라로 찍은 데모와 우리 섬의 남은 차이 = ① 지형 셰이더(데모 `Terrain.mat` 스플랫·지형 법선 음영) // ② 풀 배치 스크립트. 값 흉내를 그만두고 **데모 부품 그대로** 조립한다. // // ■ 만드는 것 (전부 런타임 · 씬 파일 0줄 · 원본 에셋 0줄 · FI 코드 0줄) // `~WL_DemoTerrain`(이 컴포넌트) ─ 자식 `Terrain(데모 부품)`: // · `TerrainData` — 높이맵(`terrainHeightRes 129`) + 스플랫(`terrainAlphaRes 256` · 2층 = 데모 `Demo_Grass`·`Demo_Dirt` TerrainLayer) // · `Terrain` — `materialTemplate` = 데모 `Terrain.mat` **그대로**(값 무수정) · 콜라이더 없음(걷기·밭·수확은 FI 콜라이더 그대로) // · `TerrainInstancesBehaviour`(데모 스크립트) — Demo.unity 1281~1309 값 그대로(풀 100 : 꽃 1 · 밀도 2.5 · 자갈 밀도 0.05 …) // 범위 = 열린 타일 발자국 + 바깥 `terrainMargin`(한 변 최대 `terrainMaxSize`). // 높이 = 발자국 안 타일 윗면(y 0) → 둥글린 경계(`skirtGrassRound` 열기 연산 + `skirtWaveAmp/Cell` 굽이)에서 // 바깥 `terrainSlope` m 동안 smoothstep 으로 `terrainBottomY`(−1.8) → 그 밖은 평평(바다 바닥 겸함). // 스플랫 = 층0 잔디(둥글린 발자국 안 · 경계 `terrainBlend` 에 걸쳐 1→0) · 층1 모래(나머지 + 밭 구역 = WLSoftDirt 와 같은 둥근+노이즈 경계). // 타일 윗면 렌더러(FI Island 본체)는 이 모드에서 숨긴다(렌더러만 · 콜라이더 유지). // // ■ 상호 배타 (값 · `WLIslandLook.Apply` 분기) // `demoTerrainEnabled 1` 이면 `WLIslandGrass`·`WLShoreSkirt`·`WLSoftDirt`·`SpawnSeabed` 를 만들지 않는다. // `demoTerrainEnabled 0` = 지금 구조 100 % 복귀(이 파일은 아무것도 만들지 않는다). // // ■ 멱등 — 발자국 서명·밭 구역·값이 그대로면 다시 굽지 않는다(재훑기·확장 훅에서 공짜). 바뀌면 높이맵·스플랫만 다시 굽고 // 인스턴서는 `enabled` 를 껐다 켜서 재생성한다(데모 스크립트의 OnDisable/OnEnable = 버퍼 해제/재생성). // // ■ 거리장·둥글림·굽이 = `WLShoreSkirt` 와 **같은 식**(발자국 바깥 테두리 선분 유클리드 거리 · 열기 연산 링 · 값 노이즈 굽이). // 그 파일은 Lead 가 손보는 중이라(816zg/ze) 정적 분리 대신 같은 식을 옮겨 적었다 — 공통화는 후속 부채로 남긴다. // 두 곳만 다르다: ① 침식 집합 E 안쪽(d ≤ −R)의 값을 `d` 그대로 둔다(스커트는 `d + R` → E 경계에서 R 만큼 뛴다 · 여기서는 연속인 쪽). // ② 링 점까지의 거리에 그 점의 깊이를 더한다(`dist + d_q` · 링 점이 E 경계에서 얼마나 안쪽인지 보정) — 링을 타일 가장자리에 맞춘 // 격자에서 모으므로 R 이 격자 칸의 배수(3 = 0.5 × 6)면 직선 변에서 오차 0, 아니어도 격자 위상에 따른 계통 오차(최대 한 칸)가 사라진다. // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업). 데모 스크립트가 LogError 를 내는 조건 // (Settings null · Mesh/Material null · Scale ≤ 0)은 **켜기 전에** 걸러 낸다. // 🔴 asmdef 금지(Assembly-CSharp 단일) · `Environment.Instancing` 은 `global::` 로 고정(WL 네임스페이스와 이름 충돌 방지). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using FIIsland = CryingSnow.FarmingIsland.Island; using FIIslandManager = CryingSnow.FarmingIsland.IslandManager; using DemoInstancer = global::Environment.Instancing.TerrainInstancesBehaviour; using DemoLayer = global::Environment.Instancing.TerrainInstancesBehaviour.TerrainInstancingInput; using DemoEntry = global::Environment.Instancing.InstancingSettings; namespace WL.Look.Farm { [DisallowMultipleComponent] public sealed class WLDemoTerrain : MonoBehaviour { public const string ObjectName = "~WL_DemoTerrain"; public const string TerrainChildName = "Terrain(데모 부품)"; // ── 진단(프로브·보고가 읽는다) public static int Rebuilds, Bakes, LastTiles, LastEdges, LastRingPoints, LastInstancerRestarts; public static float LastBakeMs, LastInstancerMs, LastSizeX, LastSizeZ; public static string LastLog = "꺼짐"; public WLIslandLookSettings cfg; GameObject _child; Terrain _terrain; TerrainData _data; DemoInstancer _inst; int _dataHeightRes, _dataAlphaRes; struct Seg { public float ax, az, bx, bz; } readonly List _tiles = new List(); // x·z = 타일 중심 · y = 렌더러 윗면 실측 readonly List _tileTops = new List(); // 열린 타일 본체 렌더러(숨김 대상) readonly List _hiddenTops = new List(); readonly List _edges = new List(); // 발자국 바깥 테두리 선분 readonly List _ringX = new List(512), _ringZ = new List(512), _ringD = new List(512); // 열기 연산용 침식 집합 테두리 띠(위치 · 원거리 d) // 마지막으로 구운 조건 — 같으면 다시 굽지 않는다(재훑기·확장 훅에서 공짜로 돌게). bool _dirty = true; long _lastTileSig; float _lastValSig = float.NaN; void OnEnable() { _dirty = true; Rebuild(); } void OnDisable() { if (_child != null) _child.SetActive(false); RestoreTops(); } void OnDestroy() { RestoreTops(); if (_data != null) Destroy(_data); // 자식 Terrain 도 같은 파괴에 사라진다(Destroy 는 프레임 끝에 실행) } /// 다시 만든다(섬 확장·밭 재구성·재훑기). 멱등 — 발자국·구역·값이 그대로면 아무것도 하지 않는다. public void Rebuild() { if (cfg == null) cfg = WLIslandLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0) { Hide("꺼짐"); return; } float size = TileSize(); if (size <= 0.01f) { Hide("타일 크기 미확인(IslandManager 없음) — 대기"); return; } float half = size * 0.5f; long tileSig = CollectTiles(out float topMeasured); LastTiles = _tiles.Count; if (_tiles.Count == 0) { Hide("열린 타일 0 — 대기"); return; } if (cfg.demoTerrainMaterial == null || cfg.demoGrassLayer == null || cfg.demoDirtLayer == null) { Hide("🔴 데모 에셋 참조 비어 있음(demoTerrainMaterial · demoGrassLayer · demoDirtLayer) — WLIslandLookSettings 확인"); return; } bool fixedTop = cfg.skirtTopMode != 0 || float.IsNaN(topMeasured); float topY = fixedTop ? cfg.skirtTopY : topMeasured; bool zoneValid = global::WL.Island.WLIslandGateFlow.ZoneBoundsValid; var zone = zoneValid ? global::WL.Island.WLIslandGateFlow.ZoneBounds : new Bounds(); // 값이 바뀌면 다시 굽는다(인스펙터에서 만져도 다음 훅에 반영된다). float valSig = cfg.terrainHeightRes * 0.013f + cfg.terrainAlphaRes * 0.007f + cfg.terrainMargin * 7.1f + cfg.terrainMaxSize * 0.11f + cfg.terrainBlend * 13.3f + cfg.terrainBottomY * 17.7f + cfg.terrainSlope * 19.3f + cfg.terrainHeightSpan * 2.3f + cfg.terrainRoundHeight * 23f + cfg.skirtGrassRound * 29.3f + cfg.skirtWaveAmp * 31.7f + cfg.skirtWaveCell * 37.3f + topY * 41.1f + (zoneValid ? 43f : 0f) + zone.center.x * 47f + zone.center.z * 53f + zone.extents.x * 59f + zone.extents.z * 61f + cfg.softDirtExpand * 67f + cfg.softDirtCornerRadius * 71f + cfg.softDirtNoise * 73f + cfg.softDirtNoiseCell * 79f + cfg.softDirtFeather * 83f + cfg.demoFirstLayerDensity * 89f + cfg.demoSecondLayerDensity * 97f + cfg.demoPositionVariance * 101f + cfg.demoScaleVariance * 103f + ScatterSig(cfg.demoGrass) * 107f + ScatterSig(cfg.demoFlower) * 109f + ScatterSig(cfg.demoGravel) * 113f; bool same = !_dirty && _data != null && _child != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f; HideTops(); // 새로 열린 타일 윗면도 숨긴다(멱등) if (same) { _child.SetActive(true); return; } var sw = System.Diagnostics.Stopwatch.StartNew(); Bake(size, half, topY, zoneValid, zone); LastBakeMs = (float)sw.Elapsed.TotalMilliseconds; sw.Restart(); string instLog = SetupInstancer(); _child.SetActive(true); // → 인스턴서 OnEnable(SetAlphamaps 뒤) · Terrain 표시 LastInstancerMs = (float)sw.Elapsed.TotalMilliseconds; _lastTileSig = tileSig; _lastValSig = valSig; _dirty = false; Rebuilds++; LastLog = "데모 터레인 — 타일 " + _tiles.Count + " · 테두리 변 " + LastEdges + " · 링 " + LastRingPoints + " · 범위 " + LastSizeX.ToString("F0") + "×" + LastSizeZ.ToString("F0") + " m(원점 " + _child.transform.position.x.ToString("F1") + ", " + _child.transform.position.z.ToString("F1") + ")" + " · 높이맵 " + _dataHeightRes + "² · 스플랫 " + _dataAlphaRes + "²" + " · 윗면 y " + topY.ToString("F2") + (fixedTop ? "(고정)" : "(실측)") + " → 바닥 " + cfg.terrainBottomY.ToString("F2") + " · 경사 " + cfg.terrainSlope.ToString("F1") + " m · 잔디 경계 R " + cfg.skirtGrassRound.ToString("F1") + " 굽이 ±" + cfg.skirtWaveAmp.ToString("F1") + " · 밭 " + (zoneValid ? (zone.size.x).ToString("F1") + "×" + (zone.size.z).ToString("F1") + " m" : "미확정") + " · 굽기 " + LastBakeMs.ToString("F1") + " ms · 인스턴서 " + LastInstancerMs.ToString("F1") + " ms(" + instLog + ")" + " · 드로우콜 터레인 1(+그림자 패스) + 인다이렉트 3 · 타일 윗면 숨김 " + _hiddenTops.Count; cfg.Log(LastLog); } static float ScatterSig(WLDemoScatter d) { return d == null ? 0f : d.scatterIndex * 1.7f + d.probability * 0.31f + d.scale * 3.1f + d.normalOffset * 5.3f; } void Hide(string reason) { if (_child != null) _child.SetActive(false); // 인스턴서 OnDisable → 버퍼 해제 · Terrain 숨김 RestoreTops(); LastLog = reason; } // ───────────────────────────────────────────────────────────────── // 입력 수집 — WLShoreSkirt 와 같은 판별 // ───────────────────────────────────────────────────────────────── static float TileSize() { var im = FIIslandManager.Instance; return im != null ? im.IslandSize : 0f; } /// 열린 타일 중심·본체 렌더러를 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다. long CollectTiles(out float topMeasured) { _tiles.Clear(); _tileTops.Clear(); topMeasured = float.NaN; var scene = gameObject.scene; var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); long sig = 17; for (int i = 0; i < islands.Length; i++) { var isl = islands[i]; if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; if (scene.IsValid() && isl.gameObject.scene != scene) continue; var p = isl.transform.position; float top = p.y; var r = isl.GetComponent(); // 섬 본체 렌더러(FIIsland 와 같은 오브젝트 · SwapMaterials 와 같은 판별) if (r != null) { top = r.bounds.max.y; _tileTops.Add(r); } if (float.IsNaN(topMeasured) || top > topMeasured) topMeasured = top; _tiles.Add(new Vector3(p.x, top, p.z)); sig += ((long)Mathf.RoundToInt(p.x * 8f) * 73856093L) ^ ((long)Mathf.RoundToInt(p.z * 8f) * 19349663L); } return sig ^ ((long)_tiles.Count << 40); } /// 타일 윗면(FI Island 본체 렌더러)을 숨긴다 — 터레인이 그 자리를 그린다. 콜라이더는 그대로. void HideTops() { for (int i = 0; i < _tileTops.Count; i++) { var r = _tileTops[i]; if (r == null || !r.enabled) continue; r.enabled = false; _hiddenTops.Add(r); } } void RestoreTops() { for (int i = 0; i < _hiddenTops.Count; i++) if (_hiddenTops[i] != null) _hiddenTops[i].enabled = true; _hiddenTops.Clear(); } // ───────────────────────────────────────────────────────────────── // 거리장 — 발자국 바깥 테두리 선분까지의 유클리드 거리 · 부호 = 타일 안(−)/밖(+) (WLShoreSkirt 와 같은 식) // ───────────────────────────────────────────────────────────────── void BuildEdges(float size, float half) { _edges.Clear(); float tol = size * 0.25f; // 이웃 판정 허용 오차(타일 격자는 정수 배) for (int i = 0; i < _tiles.Count; i++) { float cx = _tiles[i].x, cz = _tiles[i].z; if (!HasTile(cx + size, cz, tol)) _edges.Add(new Seg { ax = cx + half, az = cz - half, bx = cx + half, bz = cz + half }); if (!HasTile(cx - size, cz, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx - half, bz = cz + half }); if (!HasTile(cx, cz + size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz + half, bx = cx + half, bz = cz + half }); if (!HasTile(cx, cz - size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx + half, bz = cz - half }); } LastEdges = _edges.Count; } bool HasTile(float x, float z, float tol) { for (int i = 0; i < _tiles.Count; i++) if (Mathf.Abs(_tiles[i].x - x) <= tol && Mathf.Abs(_tiles[i].z - z) <= tol) return true; return false; } bool Inside(float x, float z, float half) { for (int i = 0; i < _tiles.Count; i++) if (Mathf.Abs(x - _tiles[i].x) <= half && Mathf.Abs(z - _tiles[i].z) <= half) return true; return false; } /// 발자국 테두리까지의 부호 있는 거리(m). **음수 = 타일 안 · 양수 = 바깥(물 쪽)** · 테두리 선 위 = 0. float RawDist(float x, float z, float half) { float best = float.MaxValue; for (int i = 0; i < _edges.Count; i++) { var s = _edges[i]; float ex = s.bx - s.ax, ez = s.bz - s.az; float t = Mathf.Clamp01(((x - s.ax) * ex + (z - s.az) * ez) / (ex * ex + ez * ez)); float dx = x - (s.ax + ex * t), dz = z - (s.az + ez * t); float d2 = dx * dx + dz * dz; if (d2 < best) best = d2; } float d = Mathf.Sqrt(best); return Inside(x, z, half) ? -d : d; } /// 격자 정수 좌표 → 0~1 결정적 해시(굽이 노이즈용 · WLShoreSkirt 와 같은 식). static float Hash01(int x, int z) { uint h = (uint)(x * 73856093) ^ (uint)(z * 19349663) ^ 0x9E3779B9u; h ^= h >> 13; h *= 0x5bd1e995u; h ^= h >> 15; return (h & 0xffffu) / 65535f; } /// 경계 굽이 — 값 노이즈(격자 skirtWaveCell · 겹선형 · 결정적) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식). float Wave(float x, float z) { if (cfg.skirtWaveAmp <= 0f || cfg.skirtWaveCell <= 0.1f) return 0f; float qx = x / cfg.skirtWaveCell, qz = z / cfg.skirtWaveCell; int gx = Mathf.FloorToInt(qx), gz = Mathf.FloorToInt(qz); float fx = qx - gx, fz = qz - gz; fx = fx * fx * (3f - 2f * fx); fz = fz * fz * (3f - 2f * fz); float v00 = Hash01(gx, gz), v10 = Hash01(gx + 1, gz), v01 = Hash01(gx, gz + 1), v11 = Hash01(gx + 1, gz + 1); return (Mathf.Lerp(Mathf.Lerp(v00, v10, fx), Mathf.Lerp(v01, v11, fx), fz) - 0.5f) * 2f * cfg.skirtWaveAmp; } /// /// 열기 연산용 링 — 침식 집합 E{d ≤ −R} 의 테두리 띠(d ∈ [−R − 1.5칸, −R]) 점을 **타일 가장자리에 맞춘 격자**(칸 = 타일 한 변을 나누는 값 ≤ 스플랫 텍셀)에서 모은다. /// 띠는 발자국 안에만 있으므로 발자국 바운즈만 훑는다. /// void GatherRing(float size, float half, float minX, float minZ, float maxX, float maxZ, float texel, float round) { _ringX.Clear(); _ringZ.Clear(); _ringD.Clear(); if (round <= 0.01f) return; int n = Mathf.Max(1, Mathf.CeilToInt(size / Mathf.Max(0.01f, texel))); float cell = size / n; float lo = -round - cell * 1.5f, hi = -round; int nx = Mathf.RoundToInt((maxX - minX) / cell) + 1, nz = Mathf.RoundToInt((maxZ - minZ) / cell) + 1; for (int j = 0; j < nz; j++) { float z = minZ + j * cell; for (int i = 0; i < nx; i++) { float x = minX + i * cell; float d = RawDist(x, z, half); if (d <= hi && d >= lo) { _ringX.Add(x); _ringZ.Add(z); _ringD.Add(d); } } } LastRingPoints = _ringX.Count; } /// /// 잔디 경계까지의 부호 있는 거리 s(m) — 둥글림(열기 연산 = E 까지의 거리 − R ≈ min(링 점까지의 거리 + 그 점의 d)) 뒤 굽이를 뺀다. /// 링이 비었거나(섬이 2R 보다 작음) E 안쪽·먼 바깥(`far` 너머 = 높이·스플랫 모두 상수)이면 d 그대로 쓴다(열기는 d 를 줄이지 않는다). /// float Boundary(float x, float z, float d, float round, float far) { float r = d; if (round > 0.01f && _ringX.Count > 0 && d > -round && d < far) { float best = float.MaxValue; for (int k = 0; k < _ringX.Count; k++) { float dx = x - _ringX[k], dz = z - _ringZ[k]; float e = Mathf.Sqrt(dx * dx + dz * dz) + _ringD[k]; if (e < best) best = e; } r = Mathf.Max(d, best); } return r - Wave(x, z); } static float SmoothStep01(float t) { t = Mathf.Clamp01(t); return t * t * (3f - 2f * t); } /// 높이맵 해상도는 2ⁿ+1(33~4097)만 유효 — 값 이하의 가장 큰 유효값. static int HeightRes(int v) { int p = 32; while (p * 2 + 1 <= v && p < 4096) p *= 2; return p + 1; } /// 스플랫 해상도는 2ⁿ(16~4096) — 값 이하의 가장 큰 유효값. static int AlphaRes(int v) { int p = 16; while (p * 2 <= v && p < 4096) p *= 2; return p; } // ───────────────────────────────────────────────────────────────── // 굽기 — TerrainData 범위·높이맵·스플랫 // ───────────────────────────────────────────────────────────────── void Bake(float size, float half, float topY, bool zoneValid, Bounds zone) { // 1) 범위 = 발자국 + 바깥 여백(한 변 최대 terrainMaxSize · 넘치면 발자국 중심 기준으로 자른다) 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]; if (t.x - half < minX) minX = t.x - half; if (t.x + half > maxX) maxX = t.x + half; if (t.z - half < minZ) minZ = t.z - half; if (t.z + half > maxZ) maxZ = t.z + half; } float margin = Mathf.Max(0f, cfg.terrainMargin); float maxSize = Mathf.Max(size, cfg.terrainMaxSize); float w = Mathf.Min((maxX - minX) + 2f * margin, maxSize); float l = Mathf.Min((maxZ - minZ) + 2f * margin, maxSize); float ox = (minX + maxX) * 0.5f - w * 0.5f, oz = (minZ + maxZ) * 0.5f - l * 0.5f; float bottom = cfg.terrainBottomY; float span = Mathf.Max(cfg.terrainHeightSpan, topY - bottom); // 높이맵 1.0 = bottom + span ≥ 윗면 if (span <= 0.001f) span = 1f; float slope = Mathf.Max(0.01f, cfg.terrainSlope); float blend = Mathf.Max(0.01f, cfg.terrainBlend); int hRes = HeightRes(cfg.terrainHeightRes); int aRes = AlphaRes(cfg.terrainAlphaRes); LastSizeX = w; LastSizeZ = l; // 2) TerrainData·Terrain — 해상도가 바뀌면 새로 만들고, 아니면 크기·위치만 옮긴다 EnsureData(hRes, aRes); _data.size = new Vector3(w, span, l); _child.transform.position = new Vector3(ox, bottom, oz); // 3) 거리장 — 테두리 선분 · 열기 연산 링(타일 가장자리에 맞춘 격자 · 칸 ≤ 스플랫 텍셀) BuildEdges(size, half); float round = Mathf.Max(0f, cfg.skirtGrassRound); float cellA = Mathf.Max(w / aRes, l / aRes); float far = slope + blend + Mathf.Max(0f, cfg.skirtWaveAmp) + cellA * 2f; // 이보다 먼 바깥은 높이·스플랫 모두 상수 GatherRing(size, half, minX, minZ, maxX, maxZ, Mathf.Min(w / aRes, l / aRes), round); // 4) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계) float zcx = 0f, zcz = 0f, zhx = 0f, zhz = 0f, zrad = 0f, znoise = 0f, zcell = 1f, feather = 1f; if (zoneValid) { zcx = zone.center.x; zcz = zone.center.z; zhx = zone.extents.x + cfg.softDirtExpand; zhz = zone.extents.z + cfg.softDirtExpand; zrad = Mathf.Clamp(cfg.softDirtCornerRadius, 0f, Mathf.Min(zhx, zhz)); znoise = Mathf.Max(0f, cfg.softDirtNoise); zcell = Mathf.Max(0.05f, cfg.softDirtNoiseCell); feather = Mathf.Max(0.05f, cfg.softDirtFeather); if (zhx <= 0.01f || zhz <= 0.01f) zoneValid = false; } var alpha = new float[aRes, aRes, 2]; for (int j = 0; j < aRes; j++) { float z = oz + (j + 0.5f) * l / aRes; for (int i = 0; i < aRes; i++) { float x = ox + (i + 0.5f) * w / aRes; float s = Boundary(x, z, RawDist(x, z, half), round, far); float grass = 1f - SmoothStep01(s / blend); // s ≤ 0 잔디 1 → s ≥ blend 잔디 0(모래) float dirt = 0f; if (zoneValid && Mathf.Abs(x - zcx) <= zhx + znoise + feather && Mathf.Abs(z - zcz) <= zhz + znoise + feather) { float dz = WLSoftDirt.ShapeDist(x, z, zcx, zcz, zhx, zhz, zrad, znoise, zcell); dirt = SmoothStep01((feather * 0.5f - dz) / feather); // WLSoftDirt.BakeMask 와 같은 페이드(1 = 흙 · 0 = 풀) } float sand = Mathf.Max(1f - grass, dirt); alpha[j, i, 1] = sand; alpha[j, i, 0] = 1f - sand; } } // 5) 높이맵 — 발자국 안 윗면 → 경계에서 바깥 slope 동안 smoothstep 으로 바닥 → 그 밖 평평 // terrainRoundHeight 1 = 둥글린·굽이친 경계에서 내려간다(발주서) · 0 = 타일 네모 가장자리에서 내려간다(콜라이더와 일치 · 둥글림은 스플랫만) bool roundHeight = cfg.terrainRoundHeight != 0; var heights = new float[hRes, hRes]; float stepX = w / (hRes - 1), stepZ = l / (hRes - 1); for (int j = 0; j < hRes; j++) { float z = oz + j * stepZ; for (int i = 0; i < hRes; i++) { float x = ox + i * stepX; float d = RawDist(x, z, half); float s = roundHeight ? Boundary(x, z, d, round, far) : d; float y = s <= 0f ? topY : s >= slope ? bottom : Mathf.Lerp(topY, bottom, SmoothStep01(s / slope)); heights[j, i] = Mathf.Clamp01((y - bottom) / span); } } _data.SetHeights(0, 0, heights); _data.SetAlphamaps(0, 0, alpha); Bakes++; } void EnsureChild() { if (_child == null) { var t = transform.Find(TerrainChildName); if (t != null) _child = t.gameObject; else { _child = new GameObject(TerrainChildName); _child.SetActive(false); // 값을 다 채운 뒤에 켠다(데모 인스턴서 OnEnable 순서) _child.transform.SetParent(transform, false); } } if (_terrain == null) _terrain = _child.GetComponent(); if (_terrain == null) _terrain = _child.AddComponent(); if (_inst == null) _inst = _child.GetComponent(); } void EnsureData(int hRes, int aRes) { EnsureChild(); if (_data != null && (_dataHeightRes != hRes || _dataAlphaRes != aRes)) { if (_inst != null) _inst.enabled = false; // 옛 스플랫 텍스처를 읽던 버퍼 해제 var old = _data; _data = null; Destroy(old); // 아래에서 새 데이터를 먼저 물린다(Destroy 는 프레임 끝) → Terrain 이 빈 참조를 보는 순간 없음 } if (_data == null) { _data = new TerrainData { name = "WL_DemoTerrainData", hideFlags = HideFlags.DontSave }; _data.heightmapResolution = hRes; _data.alphamapResolution = aRes; _dataHeightRes = hRes; _dataAlphaRes = aRes; } var layers = _data.terrainLayers; if (layers == null || layers.Length != 2 || layers[0] != cfg.demoGrassLayer || layers[1] != cfg.demoDirtLayer) _data.terrainLayers = new[] { cfg.demoGrassLayer, cfg.demoDirtLayer }; // 층0 잔디 · 층1 모래(데모 TerrainLayer 에셋 참조) _terrain.terrainData = _data; _terrain.materialTemplate = cfg.demoTerrainMaterial; // 데모 Terrain.mat 그대로(값 무수정) _terrain.drawInstanced = false; // Demo.unity 1228 m_DrawInstanced 0 · 그 외 Terrain 값은 데모 = 컴포넌트 기본값 _terrain.allowAutoConnect = false; } // ───────────────────────────────────────────────────────────────── // 풀 — 데모 스크립트 TerrainInstancesBehaviour 를 그대로(Demo.unity 1281~1309 값) // ───────────────────────────────────────────────────────────────── string SetupInstancer() { string why; DemoLayer first, second; if (!BuildLayers(out first, out second, out why)) { if (_inst != null) _inst.enabled = false; return "인스턴서 꺼짐 — " + why; } if (_inst == null) { // 🔴 데모 스크립트 OnEnable 은 Settings 가 비어 있으면 LogError 를 낸다 → 비활성 자식에 붙여 값을 채운 뒤 켠다(Rebuild 끝에서 켠다). if (_child.activeSelf) _child.SetActive(false); _inst = _child.AddComponent(); } else _inst.enabled = false; // OnDisable → 버퍼 해제(재생성 준비) _inst.FirstLayer = first; _inst.SecondLayer = second; _inst.ThirdLayer = new DemoLayer { Density = 1f, Settings = new DemoEntry[0] }; // Demo.unity 1302~1307 — 빈 배열 · 밀도 1 _inst.FourthLayer = new DemoLayer { Density = 1f, Settings = new DemoEntry[0] }; _inst.PositionVariance = Mathf.Clamp(cfg.demoPositionVariance, 0f, 0.5f); _inst.ScaleVariance = Mathf.Clamp(cfg.demoScaleVariance, 0f, 0.9f); _inst.enabled = true; // 자식이 켜질 때(Rebuild 끝) OnEnable → 인스턴스 생성 LastInstancerRestarts++; return "풀 " + first.Settings[0].Probability.ToString("F0") + ":꽃 " + first.Settings[1].Probability.ToString("F0") + " 밀도 " + first.Density.ToString("F2") + " · 자갈 밀도 " + second.Density.ToString("F2") + " · 크기 " + first.Settings[0].Scale.ToString("F2") + "/" + first.Settings[1].Scale.ToString("F2") + "/" + second.Settings[0].Scale.ToString("F2"); } bool BuildLayers(out DemoLayer first, out DemoLayer second, out string why) { first = null; second = null; why = null; var g = Entry(cfg.demoGrass, cfg.demoGrassMaterial, "풀", ref why); var f = Entry(cfg.demoFlower, cfg.demoFlowerMaterial, "꽃", ref why); var s = Entry(cfg.demoGravel, cfg.demoGravelMaterial, "자갈", ref why); if (g == null || f == null || s == null) return false; first = new DemoLayer { Density = Mathf.Max(0.01f, cfg.demoFirstLayerDensity), Settings = new[] { g, f } }; second = new DemoLayer { Density = Mathf.Max(0.01f, cfg.demoSecondLayerDensity), Settings = new[] { s } }; return true; } /// 메시 = `scatter[scatterIndex]` 가 이미 참조하는 데모 메시 · 재질 = 데모 원본 재질 필드 · 값 = 데모 값(§0-d). DemoEntry Entry(WLDemoScatter d, Material demoMat, string label, ref string why) { if (d == null) { why = label + " 정의 없음"; return null; } var sc = cfg.scatter; Mesh mesh = sc != null && d.scatterIndex >= 0 && d.scatterIndex < sc.Length && sc[d.scatterIndex] != null ? sc[d.scatterIndex].mesh : null; if (mesh == null) { why = label + " 메시 없음(scatter[" + d.scatterIndex + "])"; return null; } if (demoMat == null) { why = label + " 데모 재질 비어 있음"; return null; } if (d.scale <= 0f) { why = label + " scale ≤ 0"; return null; } return new DemoEntry { Mesh = mesh, Material = demoMat, Probability = d.probability, Scale = d.scale, NormalOffset = d.normalOffset }; } } }