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

774 lines
50 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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;
/// <summary>굽기가 끝난 직후(값이 바뀌어 실제로 다시 구운 때만) — 물가 거품 띠(WLShoreFoam)가 물가선을 다시 딴다.</summary>
public static event System.Action Baked;
/// <summary>지금 표시 중인 터레인(없거나 숨김이면 null). 물가 거품 띠가 높이를 읽는다.</summary>
public Terrain TerrainComponent { get { return _child != null && _child.activeSelf && _terrain != null && _terrain.terrainData != null ? _terrain : null; } }
GameObject _child;
Terrain _terrain;
TerrainData _data;
DemoInstancer _inst;
int _dataHeightRes, _dataAlphaRes;
struct Seg { public float ax, az, bx, bz; }
readonly List<Vector3> _tiles = new List<Vector3>(); // x·z = 타일 중심 · y = 렌더러 윗면 실측
readonly List<Renderer> _tileTops = new List<Renderer>(); // 열린 타일 본체 렌더러(숨김 대상)
readonly List<Renderer> _hiddenTops = new List<Renderer>();
readonly List<Seg> _edges = new List<Seg>(); // 발자국 바깥 테두리 선분
readonly List<float> _ringX = new List<float>(512), _ringZ = new List<float>(512), _ringD = new List<float>(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 는 프레임 끝에 실행)
if (_reliefMesh != null) Destroy(_reliefMesh);
foreach (var kv in _tuned) if (kv.Value != null) Destroy(kv.Value); // 런타임 재질 복사본(DontSave)은 섬을 나갈 때 함께 버린다(Play 마다 쌓이지 않게)
_tuned.Clear();
}
/// <summary>다시 만든다(섬 확장·밭 재구성·재훑기). 멱등 — 발자국·구역·값이 그대로면 아무것도 하지 않는다.</summary>
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
+ cfg.terrainSlopeVar * 127f + cfg.terrainSlopeVarCell * 131f + cfg.terrainBumpAmp * 137f + cfg.terrainBumpCell * 139f
+ cfg.terrainSeabedBumpAmp * 149f + cfg.terrainSeabedBumpCell * 151f
+ cfg.terrainReliefAmp * 157f + cfg.terrainReliefCell * 163f + cfg.terrainReliefEdgeFade * 167f + cfg.terrainReliefFlatRadius * 173f
+ cfg.terrainReliefFeather * 179f + cfg.terrainReliefCollider * 181f + CollectFlatSpots(cfg) * 191f;
bool same = !_dirty && _data != null && _child != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f;
HideTops(); // 새로 열린 타일 윗면도 숨긴다(멱등)
if (same) { _child.SetActive(true); if (_relief != null) _relief.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++;
if (Baked != null) Baked();
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(폭 ±" + (cfg.terrainSlopeVar * 100f).ToString("F0") + " % · 굴곡 ±" + cfg.terrainBumpAmp.ToString("F2") + " m · 바닥 굴곡 ±" + cfg.terrainSeabedBumpAmp.ToString("F2") + " m)"
+ " · 둔덕 0~" + cfg.terrainReliefAmp.ToString("F2") + " m(격자 " + cfg.terrainReliefCell.ToString("F1") + " · 평평 자리 " + _flatX.Count + " · 콜라이더 " + (LastReliefTris > 0 ? LastReliefTris + "△" : "없음") + ")"
+ " · 잔디 경계 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 숨김
if (_relief != null) _relief.SetActive(false);
RestoreTops();
LastLog = reason;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「데모 같은 그라데이션 · 그림자 단계」 — 섬 윗면 둔덕
// 평평하게 둘 자리 = 플로우 단계 자리(캠프파이어·상인·사과나무·던전 · 캠프 자리는 천막 반지름까지) + 던전 입구.
// 자리는 값(설정)에서 오고 입구는 씬에서 읽는다 → 서명에 넣어 입구가 새로 생기면 한 번 다시 굽는다(≈100 ms · 화면 유지).
// ─────────────────────────────────────────────────────────────────
readonly List<float> _flatX = new List<float>(8), _flatZ = new List<float>(8), _flatR = new List<float>(8);
GameObject _relief;
MeshCollider _reliefCol;
Mesh _reliefMesh;
public static int LastReliefTris;
float CollectFlatSpots(WLIslandLookSettings cfg)
{
_flatX.Clear(); _flatZ.Clear(); _flatR.Clear();
float sig = 0f;
if (cfg.terrainReliefAmp <= 0f) return 0f;
float r0 = Mathf.Max(0f, cfg.terrainReliefFlatRadius);
var icfg = global::WL.Island.WLIslandSettings.Instance;
if (icfg != null && global::WL.Island.WLIslandFlowV2.Active(icfg))
{
int n = global::WL.Island.WLIslandFlowV2.StepCount(icfg);
for (int i = 0; i < n; i++)
{
var p = global::WL.Island.WLIslandFlowV2.PosFor(icfg, i);
float r = r0;
if (i == 0 && icfg.campDecorEnabled != 0) r = Mathf.Max(r, icfg.campTentRadius + 1.5f); // 캠프 천막(campTentRadius)까지 평평
AddFlat(p.x, p.z, r, ref sig);
}
}
var gates = Object.FindObjectsByType<global::WL.Island.WLDungeonGate>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < gates.Length; i++)
{
if (gates[i] == null) continue;
var p = gates[i].transform.position;
AddFlat(p.x, p.z, r0, ref sig);
}
return sig;
}
void AddFlat(float x, float z, float r, ref float sig)
{
_flatX.Add(x); _flatZ.Add(z); _flatR.Add(r);
sig += Mathf.Round(x * 4f) * 0.37f + Mathf.Round(z * 4f) * 0.59f + r * 0.11f;
}
/// <summary>둔덕 봉투 0~1 — 잔디 경계 근처 · 밭 구역 · 평평 자리 둘레에서 0.</summary>
float ReliefEnv(float x, float z, float s, float edgeFade, float feather, bool zoneValid, float zcx, float zcz, float zhx, float zhz, float zrad, float znoise, float zcell)
{
float env = SmoothStep01(-s / edgeFade);
if (env <= 0f) return 0f;
if (zoneValid)
{
float dz = WLSoftDirt.ShapeDist(x, z, zcx, zcz, zhx, zhz, zrad, znoise, zcell); // = 밭 안 · + = 밖(m)
env *= SmoothStep01((dz - 0.5f) / feather);
if (env <= 0f) return 0f;
}
for (int q = 0; q < _flatX.Count; q++)
{
float dx = x - _flatX[q], dzq = z - _flatZ[q];
env *= SmoothStep01((Mathf.Sqrt(dx * dx + dzq * dzq) - _flatR[q]) / feather);
if (env <= 0f) return 0f;
}
return env;
}
/// <summary>
/// 발자국 안(타일 네모 · s ≤ 0 인 격자점만)의 둔덕 높이를 따르는 MeshCollider — 플레이어가 둔덕 위를 걷는다.
/// 격자 ≥ 0.5 m(16 m 섬 ≈ 2천 삼각형). 레이어는 FI 타일과 같게(FI 광선 판정 불변). 발자국 밖(해변)은 콜라이더 없음 = 가장자리 동작 불변.
/// </summary>
void BuildReliefCollider(bool on, float ox, float oz, float w, float l, float baseY, float span, float[,] heights, int hRes, float half, float stepX, float stepZ)
{
LastReliefTris = 0;
if (!on)
{
if (_relief != null) _relief.SetActive(false);
return;
}
if (_relief == null)
{
var t = transform.Find("~WL_ReliefCollider");
_relief = t != null ? t.gameObject : new GameObject("~WL_ReliefCollider");
_relief.transform.SetParent(transform, false);
_relief.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); // 메시는 월드 좌표
_reliefCol = _relief.GetComponent<MeshCollider>();
if (_reliefCol == null) _reliefCol = _relief.AddComponent<MeshCollider>();
}
if (_tileTops.Count > 0 && _tileTops[0] != null) _relief.layer = _tileTops[0].gameObject.layer;
if (_reliefMesh == null) _reliefMesh = new Mesh { name = "WL_ReliefCollider", hideFlags = HideFlags.DontSave };
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 cell = Mathf.Max(0.5f, Mathf.Max(stepX, stepZ));
int nx = Mathf.Clamp(Mathf.RoundToInt((maxX - minX) / cell) + 1, 2, 1025), nz = Mathf.Clamp(Mathf.RoundToInt((maxZ - minZ) / cell) + 1, 2, 1025);
float cx = (maxX - minX) / (nx - 1), cz = (maxZ - minZ) / (nz - 1);
var verts = new List<Vector3>(nx * nz);
var vmap = new int[nx * nz];
var tris = new List<int>(nx * nz * 6);
for (int k = 0; k < vmap.Length; k++) vmap[k] = -1;
for (int j = 0; j < nz; j++)
{
float z = minZ + j * cz;
for (int i = 0; i < nx; i++)
{
float x = minX + i * cx;
if (!Inside(x, z, half + 0.001f)) continue;
// 높이맵 겹선형(정규화 → 월드)
float gx = Mathf.Clamp((x - ox) / stepX, 0f, hRes - 1.0001f), gz = Mathf.Clamp((z - oz) / stepZ, 0f, hRes - 1.0001f);
int gi = (int)gx, gj = (int)gz; float tx = gx - gi, tz = gz - gj;
float h = Mathf.Lerp(Mathf.Lerp(heights[gj, gi], heights[gj, gi + 1], tx), Mathf.Lerp(heights[gj + 1, gi], heights[gj + 1, gi + 1], tx), tz);
vmap[j * nx + i] = verts.Count;
verts.Add(new Vector3(x, baseY + h * span, z));
}
}
for (int j = 0; j < nz - 1; j++)
for (int i = 0; i < nx - 1; i++)
{
int a = vmap[j * nx + i], b = vmap[j * nx + i + 1], c = vmap[(j + 1) * nx + i], d = vmap[(j + 1) * nx + i + 1];
if (a < 0 || b < 0 || c < 0 || d < 0) continue;
tris.Add(a); tris.Add(c); tris.Add(b);
tris.Add(b); tris.Add(c); tris.Add(d);
}
_reliefMesh.Clear();
if (tris.Count == 0) { _relief.SetActive(false); return; }
_reliefMesh.indexFormat = verts.Count > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
_reliefMesh.SetVertices(verts);
_reliefMesh.SetTriangles(tris, 0);
_reliefMesh.RecalculateBounds();
_reliefCol.sharedMesh = null; // 같은 메시 재대입은 다시 굽지 않는다 → 비웠다가 물린다
_reliefCol.sharedMesh = _reliefMesh;
_relief.SetActive(true);
LastReliefTris = tris.Count / 3;
}
// ─────────────────────────────────────────────────────────────────
// 입력 수집 — WLShoreSkirt 와 같은 판별
// ─────────────────────────────────────────────────────────────────
static float TileSize()
{
var im = FIIslandManager.Instance;
return im != null ? im.IslandSize : 0f;
}
/// <summary>열린 타일 중심·본체 렌더러를 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다.</summary>
long CollectTiles(out float topMeasured)
{
_tiles.Clear(); _tileTops.Clear();
topMeasured = float.NaN;
var scene = gameObject.scene;
var islands = Object.FindObjectsByType<FIIsland>(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<Renderer>(); // 섬 본체 렌더러(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);
}
/// <summary>타일 윗면(FI Island 본체 렌더러)을 숨긴다 — 터레인이 그 자리를 그린다. 콜라이더는 그대로.</summary>
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;
}
/// <summary>발자국 테두리까지의 부호 있는 거리(m). **음수 = 타일 안 · 양수 = 바깥(물 쪽)** · 테두리 선 위 = 0.</summary>
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;
}
/// <summary>격자 정수 좌표 → 0~1 결정적 해시(굽이 노이즈용 · WLShoreSkirt 와 같은 식).</summary>
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;
}
/// <summary>값 노이즈 1~+1(격자 cell · 겹선형 · 결정적). seed 0 = 경계 굽이(WLShoreSkirt 와 같은 식) · 다른 seed 는 격자를 옮겨 독립된 무늬.</summary>
static float ValueNoise(float x, float z, float cell, int seed)
{
if (cell <= 0.1f) return 0f;
float qx = x / cell, qz = z / cell;
int gx = Mathf.FloorToInt(qx) + seed * 1013, gz = Mathf.FloorToInt(qz) + seed * 2027; float fx = qx - Mathf.Floor(qx), fz = qz - Mathf.Floor(qz);
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;
}
/// <summary>경계 굽이 — 값 노이즈(격자 skirtWaveCell) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식).</summary>
float Wave(float x, float z)
{
if (cfg.skirtWaveAmp <= 0f || cfg.skirtWaveCell <= 0.1f) return 0f;
return ValueNoise(x, z, cfg.skirtWaveCell, 0) * cfg.skirtWaveAmp;
}
/// <summary>
/// 열기 연산용 링 — 침식 집합 E{d ≤ R} 의 테두리 띠(d ∈ [R 1.5칸, R]) 점을 **타일 가장자리에 맞춘 격자**(칸 = 타일 한 변을 나누는 값 ≤ 스플랫 텍셀)에서 모은다.
/// 띠는 발자국 안에만 있으므로 발자국 바운즈만 훑는다.
/// </summary>
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;
}
/// <summary>
/// 잔디 경계까지의 부호 있는 거리 s(m) — 둥글림(열기 연산 = E 까지의 거리 R ≈ min(링 점까지의 거리 + 그 점의 d)) 뒤 굽이를 뺀다.
/// 링이 비었거나(섬이 2R 보다 작음) E 안쪽·먼 바깥(`far` 너머 = 높이·스플랫 모두 상수)이면 d 그대로 쓴다(열기는 d 를 줄이지 않는다).
/// </summary>
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); }
/// <summary>높이맵 해상도는 2ⁿ+1(33~4097)만 유효 — 값 이하의 가장 큰 유효값.</summary>
static int HeightRes(int v) { int p = 32; while (p * 2 + 1 <= v && p < 4096) p *= 2; return p + 1; }
/// <summary>스플랫 해상도는 2ⁿ(16~4096) — 값 이하의 가장 큰 유효값.</summary>
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 slope = Mathf.Max(0.01f, cfg.terrainSlope);
float blend = Mathf.Max(0.01f, cfg.terrainBlend);
// 🔴 2026-09-16 PD 「가장자리 → 바다를 자연스러운 경사면으로 · 굴곡이 보이게」 — 경사 폭 흔들기 + 경사면 능선/골 + 바다 바닥 굴곡(값 노이즈 · 결정적)
float slopeVar = Mathf.Clamp(cfg.terrainSlopeVar, 0f, 0.8f), varCell = Mathf.Max(0.5f, cfg.terrainSlopeVarCell);
float bumpAmp = Mathf.Max(0f, cfg.terrainBumpAmp), bumpCell = Mathf.Max(0.5f, cfg.terrainBumpCell);
float seaAmp = Mathf.Max(0f, cfg.terrainSeabedBumpAmp), seaCell = Mathf.Max(0.5f, cfg.terrainSeabedBumpCell);
const float seaFade = 2f; // 경사 끝에서 바다 굴곡이 자라는 길이(m)
float baseY = bottom - seaAmp - bumpAmp; // 높이맵 0 = 가장 깊은 골보다 아래
float span = Mathf.Max(cfg.terrainHeightSpan, topY - baseY + 0.5f); // 높이맵 1.0 = baseY + span ≥ 윗면
if (span <= 0.001f) span = 1f;
int hRes = HeightRes(cfg.terrainHeightRes);
int aRes = AlphaRes(cfg.terrainAlphaRes);
LastSizeX = w; LastSizeZ = l;
// 2) TerrainData·Terrain — 해상도가 바뀌면 새로 만들고, 아니면 크기·위치만 옮긴다
EnsureData(hRes, aRes);
var newSize = new Vector3(w, span, l);
if ((_data.size - newSize).sqrMagnitude > 1e-6f) _data.size = newSize; // 바뀔 때만(재대입 = 내부 재생성 → 프레임 빔)
var newPos = new Vector3(ox, baseY, oz);
if ((_child.transform.position - newPos).sqrMagnitude > 1e-6f) _child.transform.position = newPos;
// 3) 거리장 — 테두리 선분 · 열기 연산 링(타일 가장자리에 맞춘 격자 · 칸 ≤ 스플랫 텍셀)
BuildEdges(size, half);
float round = Mathf.Max(0f, cfg.skirtGrassRound);
float cellA = Mathf.Max(w / aRes, l / aRes);
float far = slope * (1f + slopeVar) + seaFade + blend + Mathf.Max(0f, cfg.skirtWaveAmp) + cellA * 2f; // 이보다 먼 바깥은 둥글림이 높이·스플랫에 영향 없음
// 🔴 2026-09-16 Lead 실측: 링 0.125 m 간격(632점) × 스플랫 65k 텍셀 = 굽기 851 ms → 상인 설치 때 화면이 멎어 「지형이 사라졌다 보임」.
// 링은 0.5 m 간격(≈1/4)으로, 경계 거리 s 는 높이맵 격자(0.25 m)에서 한 번만 계산해 스플랫은 겹선형으로 읽는다(blend 1 m 에 충분).
GatherRing(size, half, minX, minZ, maxX, maxZ, Mathf.Max(0.5f, Mathf.Min(w / aRes, l / aRes)), round);
// 4) 높이맵 + 경계 거리 격자 — 발자국 안 윗면 → 경계에서 바깥 slope 동안 smoothstep 으로 바닥 → 그 밖 평평
// terrainRoundHeight 1 = 둥글린·굽이친 경계에서 내려간다(발주서) · 0 = 타일 네모 가장자리에서 내려간다(콜라이더와 일치 · 둥글림은 스플랫만)
bool roundHeight = cfg.terrainRoundHeight != 0;
var heights = new float[hRes, hRes];
var sGrid = new float[hRes * hRes]; // 둥글림+굽이 경계 거리(스플랫이 읽는다)
float stepX = w / (hRes - 1), stepZ = l / (hRes - 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;
}
// 🔴 2026-09-16 PD 「데모 같은 그라데이션 · 그림자 단계」 — 섬 윗면 둔덕(위로만 0~reliefAmp · 값 노이즈 · 평평 자리 제외)
float reliefAmp = Mathf.Max(0f, cfg.terrainReliefAmp), reliefCell = Mathf.Max(1f, cfg.terrainReliefCell);
float reliefFade = Mathf.Max(0.1f, cfg.terrainReliefEdgeFade), reliefFeather = Mathf.Max(0.1f, cfg.terrainReliefFeather);
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 sb = Boundary(x, z, d, round, far);
sGrid[j * hRes + i] = sb;
float s = roundHeight ? sb : d;
float y;
if (s <= 0f)
{
y = topY; // 발자국 안 = FI 콜라이더 윗면(둔덕은 그 위로만)
if (reliefAmp > 0f)
{
float env = ReliefEnv(x, z, s, reliefFade, reliefFeather, zoneValid, zcx, zcz, zhx, zhz, zrad, znoise, zcell);
if (env > 0f) y += env * reliefAmp * (ValueNoise(x, z, reliefCell, 4) * 0.5f + 0.5f);
}
}
else
{
// 경사 폭을 자리마다 ±slopeVar 로 흔든다 → 해안이 넓어졌다 좁아졌다 한다(굽이와 별개의 무늬).
float slopeL = slope * (1f + slopeVar * ValueNoise(x, z, varCell, 1));
if (slopeL < 0.5f) slopeL = 0.5f;
float t = s / slopeL;
if (t < 1f)
{
y = Mathf.Lerp(topY, bottom, SmoothStep01(t));
// 능선·골 — sin(πt) 로 양 끝(윗면·바닥)에서 0 → 걷는 면 위로 솟지 않고 바닥과도 이어진다.
if (bumpAmp > 0f) y += Mathf.Sin(t * Mathf.PI) * bumpAmp * ValueNoise(x, z, bumpCell, 2);
}
else
{
y = bottom;
// 바다 바닥 — 경사 끝에서 seaFade 에 걸쳐 굴곡이 자란다(끝점 연속).
if (seaAmp > 0f) y += SmoothStep01((s - slopeL) / seaFade) * seaAmp * ValueNoise(x, z, seaCell, 3);
}
}
heights[j, i] = Mathf.Clamp01((y - baseY) / span);
}
}
// 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계)
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;
// 경계 거리 = 높이맵 격자의 s 를 겹선형으로(0.25 m 격자 · blend 1 m 에 충분 · 링 재탐색 0)
float gx = Mathf.Clamp((x - ox) / stepX, 0f, hRes - 1.0001f), gz = Mathf.Clamp((z - oz) / stepZ, 0f, hRes - 1.0001f);
int gi = (int)gx, gj = (int)gz; float tx = gx - gi, tz = gz - gj;
float s00 = sGrid[gj * hRes + gi], s10 = sGrid[gj * hRes + gi + 1], s01 = sGrid[(gj + 1) * hRes + gi], s11 = sGrid[(gj + 1) * hRes + gi + 1];
float s = Mathf.Lerp(Mathf.Lerp(s00, s10, tx), Mathf.Lerp(s01, s11, tx), tz);
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;
}
}
_data.SetHeights(0, 0, heights);
_data.SetAlphamaps(0, 0, alpha);
BuildReliefCollider(reliefAmp > 0f && cfg.terrainReliefCollider != 0, ox, oz, w, l, baseY, span, heights, hRes, half, stepX, stepZ);
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<Terrain>();
if (_terrain == null) _terrain = _child.AddComponent<Terrain>();
if (_inst == null) _inst = _child.GetComponent<DemoInstancer>();
}
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 에셋 참조)
// 🔴 2026-09-16 PD 「상인 생성 때 맵이 잠시 사라진 것처럼 보임」 — 같은 값을 다시 대입해도 Terrain 이 내부 상태를 다시 만들어 한 프레임 비는 것을 막는다(바뀔 때만 대입).
if (_terrain.terrainData != _data) _terrain.terrainData = _data;
var tmat = Tuned(cfg.demoTerrainMaterial); // 데모 Terrain.mat 그대로(값 무수정) · 구름 강도만 섬 크기에 맞춘 런타임 복사본
if (_terrain.materialTemplate != tmat) _terrain.materialTemplate = tmat;
if (_terrain.drawInstanced) _terrain.drawInstanced = false; // Demo.unity 1228 m_DrawInstanced 0 · 그 외 Terrain 값은 데모 = 컴포넌트 기본값
if (_terrain.allowAutoConnect) _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<DemoInstancer>();
}
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;
}
/// <summary>메시 = `scatter[scatterIndex]` 가 이미 참조하는 데모 메시 · 재질 = 데모 원본 재질 필드 · 값 = 데모 값(§0-d).</summary>
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 = Tuned(demoMat), Probability = d.probability, Scale = d.scale, NormalOffset = d.normalOffset };
}
// ─────────────────────────────────────────────────────────────────
// 2026-09-16 PD 「풀이 얼룩덜룩」 실측(A/B): 데모 원값 `_Cloud_Strength 1.0` 은 200 m 지형에 맞춘 값이라 32 m 섬은 절반이 통째로 50 % 어둡게 덮여
// 얼룩처럼 보였다. 원본 재질은 손대지 않고 **런타임 복사본**에 `demoCloudStrength` 만 덮어쓴다(0 이하 = 원본 그대로).
// ─────────────────────────────────────────────────────────────────
readonly Dictionary<Material, Material> _tuned = new Dictionary<Material, Material>();
Material Tuned(Material src)
{
if (src == null) return null;
bool strength = cfg != null && cfg.demoCloudStrength > 0f && src.HasProperty("_Cloud_Strength");
bool density = cfg != null && cfg.demoCloudDensity > 0f && src.HasProperty("_Cloud_Density");
bool bright = cfg != null && cfg.demoTerrainBrightness > 0f && src.HasProperty("_Brightness");
if (!strength && !density && !bright) return src;
Material m;
if (!_tuned.TryGetValue(src, out m) || m == null)
{
m = new Material(src) { name = src.name + "(WL cloud)", hideFlags = HideFlags.DontSave };
_tuned[src] = m;
}
if (strength && !Mathf.Approximately(m.GetFloat("_Cloud_Strength"), cfg.demoCloudStrength)) m.SetFloat("_Cloud_Strength", cfg.demoCloudStrength);
if (density && !Mathf.Approximately(m.GetFloat("_Cloud_Density"), cfg.demoCloudDensity)) m.SetFloat("_Cloud_Density", cfg.demoCloudDensity);
if (bright && !Mathf.Approximately(m.GetFloat("_Brightness"), cfg.demoTerrainBrightness)) m.SetFloat("_Brightness", cfg.demoTerrainBrightness);
return m;
}
}
}