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

1083 lines
73 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;
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();
if (zoneValid)
{
// 밭 구역도 0.25 m 격자로 고정(렌더러 바운즈가 cm 단위로 흔들려도 스플랫이 안 바뀌게)
var c = zone.center; var e = zone.extents;
zone = new Bounds(new Vector3(Mathf.Round(c.x * 4f) * 0.25f, c.y, Mathf.Round(c.z * 4f) * 0.25f),
new Vector3(Mathf.Round(e.x * 4f) * 0.5f, e.y * 2f, Mathf.Round(e.z * 4f) * 0.5f));
_lastZone = zone; _lastZoneValid = true;
}
else if (_lastZoneValid)
{
// 🔴 밭 구역 판정이 잠깐 비면(설치물 생성 뒤 재훑기) 흙 자리가 사라졌다 돌아오며 재굽기·인스턴서 재시작을 부른다 → 마지막 유효 구역을 쓴다
zone = _lastZone; zoneValid = true;
}
// 값이 바뀌면 다시 굽는다(인스펙터에서 만져도 다음 훅에 반영된다).
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 + CollectPadSpots() * 227f
+ cfg.terrainBlendInward * 193f + cfg.terrainEdgeNoiseAmp * 197f + cfg.terrainEdgeNoiseCell * 199f + cfg.terrainBumpWaterNotch * 211f + WaterY() * 223f;
bool same = !_dirty && _data != null && _child != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f;
{
var sbI = new System.Text.StringBuilder();
sbI.Append("tiles=").Append(_tiles.Count).Append(" tileSig=").Append(tileSig).Append(" valSig=").Append(valSig.ToString("F3"))
.Append(" zone=").Append(zoneValid ? zone.center.ToString("F2") + "/" + zone.size.ToString("F2") : "none").Append(" flats=");
for (int i = 0; i < _flatX.Count; i++) sbI.Append("(").Append(_flatX[i].ToString("F1")).Append(",").Append(_flatZ[i].ToString("F1")).Append(",r").Append(_flatR[i].ToString("F1")).Append(")");
sbI.Append(" water=").Append(WaterY().ToString("F2")).Append(" topY=").Append(topY.ToString("F2"));
LastInputs = sbI.ToString();
}
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;
// 🔴 2026-09-16 PD 「상인이 생길 때 섬 일부가 사라졌다 나타남」 — 서명이 바뀌어도 **구운 내용이 같으면** 지형에 다시 쓰지 않고
// 인스턴서도 껐다 켜지 않는다(SetHeights = 패치 메시 재생성 → 한 프레임 조각 빔 · 인스턴서 재시작 = 풀 한 프레임 소실).
if (LastBakeUnchanged)
{
_child.SetActive(true);
if (_relief != null) _relief.SetActive(true);
_lastTileSig = tileSig; _lastValSig = valSig; _dirty = false;
SkippedRebuilds++;
LastLog = "데모 터레인 — 값 서명은 바뀌었으나 높이·스플랫 동일 → 재대입·인스턴서 재시작 생략(" + LastBakeMs.ToString("F0") + " ms)";
return;
}
sw.Restart();
string instLog = _deferInstancer ? "인스턴서 미룸(솟아오르기 뒤)" : SetupInstancer();
_child.SetActive(true); // → 인스턴서 OnEnable(SetAlphamaps 뒤) · Terrain 표시
LastInstancerMs = (float)sw.Elapsed.TotalMilliseconds;
_lastTileSig = tileSig; _lastValSig = valSig; _dirty = false;
Rebuilds++;
if (!_deferInstancer && 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") + "+잔굽이 ±" + cfg.terrainEdgeNoiseAmp.ToString("F2")
+ " · 페이드 " + cfg.terrainBlend.ToString("F1") + " m(안쪽 " + (cfg.terrainBlendInward * 100f).ToString("F0") + " %) · 픽셀 오차 " + cfg.terrainPixelError.ToString("F0")
+ " · 밭 " + (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;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 풀에 가려지지 않게 · 캐릭터보다는 아래」 — 판 자리(확장 판 = 잠긴 칸의 가격판 자리 · 현재 단계 발판)에
// 모래 원(padClearRadius)을 깔아 풀잎을 비운다(인스턴서는 잔디 우세 칸에만 심는다). 판 자체는 깊이 검사로 그려 캐릭터가 가린다.
// 자리는 `WLIslandExpansionGate.ComputePadSpot`(가격판 배치와 같은 식)에서 온다 → 판이 생기기 전에도 자리가 정해진다(같은 굽기에서).
// ─────────────────────────────────────────────────────────────────
readonly List<Vector3> _padSpots = new List<Vector3>(8);
public static int LastPadSpots;
float CollectPadSpots()
{
_padSpots.Clear();
var icfg = global::WL.Island.WLIslandSettings.Instance;
if (icfg == null || icfg.padClearRadius <= 0.05f) { LastPadSpots = 0; return 0f; }
global::WL.Island.WLIslandExpansionGate.CollectPadSpots(icfg, _padSpots);
if (global::WL.Island.WLIslandFlowV2.Active(icfg))
{
int step = global::WL.Island.WLIslandFlowV2.Step(icfg), total = global::WL.Island.WLIslandFlowV2.StepCount(icfg);
if (step < total) _padSpots.Add(global::WL.Island.WLIslandFlowV2.PosFor(icfg, step)); // 지금 서 있는(설) 발판 자리
}
float sig = 0f;
for (int i = 0; i < _padSpots.Count; i++)
{
var p = _padSpots[i];
p.x = Mathf.Round(p.x * 2f) * 0.5f; p.z = Mathf.Round(p.z * 2f) * 0.5f; _padSpots[i] = p;
sig += p.x * 0.41f + p.z * 0.67f + 1f;
}
LastPadSpots = _padSpots.Count;
return sig + icfg.padClearRadius * 3f;
}
void AddFlat(float x, float z, float r, ref float sig)
{
// 0.5 m 격자로 고정 — 밭 중심·자리가 재훑기마다 cm 단위로 흔들려 둔덕이 매번 미세하게 달라지며 재굽기를 부르던 것을 막는다
x = Mathf.Round(x * 2f) * 0.5f; z = Mathf.Round(z * 2f) * 0.5f;
_flatX.Add(x); _flatZ.Add(z); _flatR.Add(r);
sig += x * 0.37f + z * 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.gameObject.activeInHierarchy) continue;
if (scene.IsValid() && isl.gameObject.scene != scene) continue;
// 🔴 2026-09-16 PD 「다리 칸은 물과 섬이 이어지는 모습이어야(낚시)」 — 다리 칸은 지형 발자국에서 뺀다 → 그 칸은 바다·해변으로 남고 원본 다리 메시가 그대로 보인다.
if (isl.IsBridge) continue;
var mfT = isl.GetComponent<MeshFilter>();
if (mfT == null || mfT.sharedMesh == null) continue; // 더미(발판 타깃 Island) 제외
// 🔴 열리는 중(FI Activate: 켜지고 scale 0 → 1 · 1 s)인 칸도 지금부터 발자국에 넣는다 → 원본 네모 판이 튀어오르기 전에 우리 지형이 그 칸을 덮는다(§0-f).
bool activating = !isl.IsUnlocked && isl.transform.localScale.x < 0.999f;
if (!isl.IsUnlocked && !activating) 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;
if (!_hiddenTops.Contains(r)) _hiddenTops.Add(r); // FI 가 다시 켠 렌더러를 또 숨겨도 목록은 한 번만
}
}
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 · 5차 보간 · 결정적). seed 마다 격자를 옮겨 독립된 무늬.
/// 🔴 2026-09-16 PD 「가장자리가 각졌다」 실측: 값 노이즈(격자 값 겹선형)는 등고선이 **격자에 맞춘 둥근 네모**라 토온 단계·물 깊이 색이
/// 그 등고선을 그대로 드러내 계단·다각형이 됐다(거품·그림자·굴곡 끔 A/B 로 배제). 펄린은 등고선이 유기적 곡선.
/// </summary>
static float GradNoise(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), gz = Mathf.FloorToInt(qz);
float fx = qx - gx, fz = qz - gz;
int ox = seed * 1013, oz = seed * 2027;
float n00 = Grad(gx + ox, gz + oz, fx, fz), n10 = Grad(gx + 1 + ox, gz + oz, fx - 1f, fz);
float n01 = Grad(gx + ox, gz + 1 + oz, fx, fz - 1f), n11 = Grad(gx + 1 + ox, gz + 1 + oz, fx - 1f, fz - 1f);
float ux = fx * fx * fx * (fx * (fx * 6f - 15f) + 10f), uz = fz * fz * fz * (fz * (fz * 6f - 15f) + 10f);
float v = Mathf.Lerp(Mathf.Lerp(n00, n10, ux), Mathf.Lerp(n01, n11, ux), uz);
return Mathf.Clamp(v * 1.6f, -1f, 1f); // 2D 펄린 범위 ≈ ±0.7 → ±1 근처로
}
// 16방향 단위 벡터 표(해시로 고른다 · cos/sin 호출 없음 — 굽기 133 → ≈100 ms)
static readonly float[] s_gx = { 1f, 0.9239f, 0.7071f, 0.3827f, 0f, -0.3827f, -0.7071f, -0.9239f, -1f, -0.9239f, -0.7071f, -0.3827f, 0f, 0.3827f, 0.7071f, 0.9239f };
static readonly float[] s_gz = { 0f, 0.3827f, 0.7071f, 0.9239f, 1f, 0.9239f, 0.7071f, 0.3827f, 0f, -0.3827f, -0.7071f, -0.9239f, -1f, -0.9239f, -0.7071f, -0.3827f };
static float Grad(int ix, int iz, float dx, float dz)
{
int k = (int)(Hash01(ix, iz) * 15.999f);
return s_gx[k] * dx + s_gz[k] * dz;
}
/// <summary>경계 굽이 — 큰 굽이(격자 skirtWaveCell × skirtWaveAmp · WLShoreSkirt 와 같은 식) + 잔 굽이(terrainEdgeNoiseCell × Amp · 붓 자국). 직선 변을 굽이치게 한다.</summary>
float Wave(float x, float z)
{
float w = 0f;
if (cfg.skirtWaveAmp > 0f && cfg.skirtWaveCell > 0.1f) w += GradNoise(x, z, cfg.skirtWaveCell, 0) * cfg.skirtWaveAmp;
if (cfg.terrainEdgeNoiseAmp > 0f && cfg.terrainEdgeNoiseCell > 0.1f) w += GradNoise(x, z, cfg.terrainEdgeNoiseCell, 5) * cfg.terrainEdgeNoiseAmp;
return w;
}
/// <summary>물 평면 높이 — 섬 씬의 `Water`(루트/자식) · 없으면 shoreFoamFallbackY.</summary>
float WaterY()
{
var scene = gameObject.scene;
if (scene.IsValid())
{
var roots = scene.GetRootGameObjects();
for (int i = 0; i < roots.Length; i++)
{
if (roots[i].name == "Water") return roots[i].transform.position.y;
var t = roots[i].transform.Find("Water");
if (t != null) return t.position.y;
}
}
return cfg.shoreFoamFallbackY;
}
/// <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) + Mathf.Max(0f, cfg.terrainEdgeNoiseAmp) + cellA * 2f; // 이보다 먼 바깥은 둥글림이 높이·스플랫에 영향 없음
float blendIn = blend * Mathf.Clamp(cfg.terrainBlendInward, 0f, 0.5f); // 페이드가 잔디 경계 안쪽으로 들어오는 길이
float waterY = WaterY(), notch = Mathf.Max(0f, cfg.terrainBumpWaterNotch);
// 🔴 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 * (GradNoise(x, z, reliefCell, 4) * 0.5f + 0.5f);
}
}
else
{
// 경사 폭을 자리마다 ±slopeVar 로 흔든다 → 해안이 넓어졌다 좁아졌다 한다(굽이와 별개의 무늬).
float slopeL = slope * (1f + slopeVar * GradNoise(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 → 걷는 면 위로 솟지 않고 바닥과도 이어진다.
// 🔴 물가선(y = 물 높이) 근처에서는 0 — 굴곡이 물가선에 걸치면 선이 지그재그·계단이 된다(2026-09-16 PD 「각졌다」 실측).
if (bumpAmp > 0f)
{
float env = Mathf.Sin(t * Mathf.PI);
if (notch > 0f) env *= SmoothStep01((Mathf.Abs(y - waterY) - 0.05f) / notch);
y += env * bumpAmp * GradNoise(x, z, bumpCell, 2);
}
}
else
{
y = bottom;
// 바다 바닥 — 경사 끝에서 seaFade 에 걸쳐 굴곡이 자란다(끝점 연속).
if (seaAmp > 0f) y += SmoothStep01((s - slopeL) / seaFade) * seaAmp * GradNoise(x, z, seaCell, 3);
}
}
heights[j, i] = Mathf.Clamp01((y - baseY) / span);
}
}
// 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계) + 가격판 자리 원
var icfgPad = global::WL.Island.WLIslandSettings.Instance;
float padR = icfgPad != null ? Mathf.Max(0f, icfgPad.padClearRadius) : 0f;
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 + blendIn) / blend); // s ≤ blendIn 잔디 1 → s ≥ blend blendIn 잔디 0(모래) · 풀잎 선(50 %) = blend/2 blendIn
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 = 풀)
}
// 가격판 자리 모래 원(풀 없음) — 반지름 padClearRadius · 가장자리 0.6 m 페이드
for (int q = 0; q < _padSpots.Count; q++)
{
float dxp = x - _padSpots[q].x, dzp = z - _padSpots[q].z;
float dp = Mathf.Sqrt(dxp * dxp + dzp * dzp) - padR;
if (dp < 0.3f) dirt = Mathf.Max(dirt, SmoothStep01((0.3f - dp) / 0.6f));
}
float sand = Mathf.Max(1f - grass, dirt);
alpha[j, i, 1] = sand;
alpha[j, i, 0] = 1f - sand;
}
}
// 6) 내용 비교 — 같은 배열이면 지형에 다시 쓰지 않는다(재대입 = 패치 재생성 = 화면 조각 빔)
if (_riseCo != null) _riseCancel = true; // 솟아오르는 중에 새로 구우면 그 연출은 즉시 끝낸다(최종 높이로)
_lastOx = ox; _lastOz = oz; _lastW = w; _lastL = l; _lastBaseY = baseY; _lastSpan = span;
bool heightsSame = _lastHeights != null && SameArray(_lastHeights, heights);
bool alphaSame = _lastAlpha != null && SameArray(_lastAlpha, alpha);
if (!heightsSame) _data.SetHeights(0, 0, heights);
if (!alphaSame) _data.SetAlphamaps(0, 0, alpha);
if (!heightsSame || _relief == null) BuildReliefCollider(reliefAmp > 0f && cfg.terrainReliefCollider != 0, ox, oz, w, l, baseY, span, heights, hRes, half, stepX, stepZ);
_lastHeights = heights; _lastAlpha = alpha;
LastBakeUnchanged = heightsSame && alphaSame;
Bakes++;
}
float[,] _lastHeights;
float[,,] _lastAlpha;
float _lastOx, _lastOz, _lastW, _lastL, _lastBaseY, _lastSpan; // 마지막으로 구운 격자(솟아오르기 보간이 옛 격자를 읽는다)
Bounds _lastZone; bool _lastZoneValid;
public static bool LastBakeUnchanged;
public static int SkippedRebuilds;
public static string LastInputs = ""; // 진단 — 재굽기 원인 추적(평평 자리 · 밭 구역 · 서명)
// ─────────────────────────────────────────────────────────────────
// 🔴 §0-f 섬 확장 연출 (2026-09-16 PD 「원본 네모 판 없이 자연스럽게 확장」)
// FI `Island.Activate()` = 켜기 + scale 0 → 1(점프·바운스 1 s) → 그 뒤 IsUnlocked·OnActivated. 원본 네모 판은 이 1 s 동안 튀어오른다.
// → 매 프레임 「켜졌는데 아직 잠김·scale<1」인 칸을 잡아 그 프레임에 렌더러를 숨기고, 우리 지형을 그 칸까지 구운 뒤(최종 높이)
// 새 칸 둘레만 이전 높이 → 새 높이로 `expandRiseSeconds` 동안 보간(SetHeightsDelayLOD · 부분 사각형). 풀 인스턴서·거품은 다 솟은 뒤.
// FI 의 OnActivated(1 s 뒤) → 우리 훅의 재굽기는 내용이 같아 생략된다(§6 비교).
// ─────────────────────────────────────────────────────────────────
public static int Rises, RiseFrames, Reactivated, Recovered, EarlyUnlocked;
public static string LastRiseLog = "", LastGuardLog = "";
readonly List<FIIsland> _rising = new List<FIIsland>(4);
readonly List<FIIsland> _activating = new List<FIIsland>(4); // 활성화 애니 중인 칸(다리 포함) — 경쟁으로 꺼지면 다시 켠다
FIIsland[] _watch; float _watchNext;
bool _deferInstancer, _riseCancel;
Coroutine _riseCo;
static System.Reflection.MethodInfo s_initProps;
void Update()
{
if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0) return;
bool rescan = _watch == null || Time.unscaledTime >= _watchNext;
if (rescan)
{
_watch = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Include, FindObjectsSortMode.None);
_watchNext = Time.unscaledTime + 5f;
}
for (int i = 0; i < _watch.Length; i++)
{
var isl = _watch[i];
if (isl == null) continue;
var mf = isl.GetComponent<MeshFilter>(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외
if (gameObject.scene.IsValid() && isl.gameObject.scene != gameObject.scene) continue;
// 🔴 2026-09-17 PD 화면 실측 「Coroutine couldn't be started because the game object 'Bridge (0)' is inactive!」
// = 원본 경쟁: 칸 A 의 해금 갱신(OnIslandUnlocked → 잠긴 칸 전부 RefreshIsland → SetActive(false))이
// 활성화 애니(1 s) 중인 칸 B 를 「잠김」으로 보고 꺼 버림 → B 의 애니 끝에 IsUnlocked·InitializeProps 코루틴이 꺼진 오브젝트에서 실패
// → B 는 해금인데 비활성(안 보이고 못 걷는 다리). 가격판을 가장자리로 옮긴 뒤 두 판을 잇달아 밟기 쉬워져 드러났다.
// ① 이미 깨진 칸(해금인데 비활성) = 5 s 스캔 때 복구 ② 활성화 중인 칸을 기억해 두었다가 꺼지면 그 프레임에 다시 켠다
if (rescan && isl.IsUnlocked && !isl.gameObject.activeSelf && isl.transform.localScale.x >= 0.999f) { Recover(isl, "스캔"); continue; }
if (_activating.Contains(isl) || isl.IsUnlocked) continue;
if (!isl.gameObject.activeInHierarchy || isl.transform.localScale.x >= 0.999f) continue; // 잠긴 채 아직 비활성화 전(로드 직후)은 scale 1 → 해당 없음
_activating.Add(isl);
// ③ 🔴 해금 표식을 **애니 첫 프레임에 미리** 세운다(원본은 1 s 뒤 OnComplete 에서 세운다) → 그 사이 다른 칸의 해금 갱신이 이 칸을
// 「잠김」으로 보고 끄는 일이 아예 없다(같은 프레임에 두 칸이 끝나는 경우도 방어). 원본이 1 s 뒤 같은 값을 다시 쓰고 OnActivated 를 낸다.
isl.IsUnlocked = true; EarlyUnlocked++;
if (cfg.expandHideOriginal == 0 || isl.IsBridge || _rising.Contains(isl)) continue;
StartRise(isl);
}
for (int i = _activating.Count - 1; i >= 0; i--)
{
var a = _activating[i];
if (a == null) { _activating.RemoveAt(i); continue; }
bool done = a.transform.localScale.x >= 0.999f; // 원본 DOScale 완료
if (!a.gameObject.activeSelf) { a.gameObject.SetActive(true); Reactivated++; LastGuardLog = "활성화 중 꺼진 칸 다시 켬 — " + a.name + "@" + a.transform.position.ToString("F0"); cfg.Log(LastGuardLog); }
if (done) _activating.RemoveAt(i);
}
for (int i = _rising.Count - 1; i >= 0; i--) if (_rising[i] == null || _rising[i].IsUnlocked) _rising.RemoveAt(i);
}
/// <summary>해금인데 비활성으로 남은 칸을 살린다 — 켜고 원본 `InitializeProps(true)`(private · 리플렉션)를 돌려 소품·낚시터를 초기화한다.</summary>
void Recover(FIIsland isl, string why)
{
isl.gameObject.SetActive(true);
isl.transform.localScale = Vector3.one;
if (s_initProps == null) s_initProps = typeof(FIIsland).GetMethod("InitializeProps", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (s_initProps != null)
{
var co = s_initProps.Invoke(isl, new object[] { true }) as IEnumerator;
if (co != null) isl.StartCoroutine(co);
}
Recovered++;
LastGuardLog = "해금·비활성 칸 복구(" + why + ") — " + isl.name + "@" + isl.transform.position.ToString("F0") + (isl.IsBridge ? " 다리" : "");
cfg.Log(LastGuardLog);
_dirty = true; Rebuild();
}
void StartRise(FIIsland isl)
{
_rising.Add(isl);
var r = isl.GetComponent<Renderer>();
if (r != null && r.enabled) { r.enabled = false; if (!_hiddenTops.Contains(r)) _hiddenTops.Add(r); } // 네모 판은 첫 프레임부터 안 보인다
var oldH = _lastHeights; float oOx = _lastOx, oOz = _lastOz, oW = _lastW, oL = _lastL, oBase = _lastBaseY, oSpan = _lastSpan;
bool animate = cfg.expandRiseSeconds > 0f && oldH != null && _data != null;
_deferInstancer = animate;
_dirty = true;
Rebuild(); // 새 칸 포함 · 최종 높이·스플랫·콜라이더
if (!animate || LastBakeUnchanged || _lastHeights == null) { FinishRise(); return; }
if (_riseCo != null) { _riseCancel = true; StopCoroutine(_riseCo); }
_riseCancel = false;
_riseCo = StartCoroutine(Co_Rise(isl.transform.position, oldH, oOx, oOz, oW, oL, oBase, oSpan, cfg.expandRiseSeconds));
Rises++;
}
void FinishRise()
{
_deferInstancer = false;
_riseCo = null;
if (_child == null || _data == null) return;
SetupInstancer();
_child.SetActive(true);
if (Baked != null) Baked();
}
IEnumerator Co_Rise(Vector3 tilePos, float[,] oldH, float oOx, float oOz, float oW, float oL, float oBase, float oSpan, float seconds)
{
int hRes = _dataHeightRes;
float stepX = _lastW / (hRes - 1), stepZ = _lastL / (hRes - 1);
float ext = TileSize() * 0.5f + Mathf.Max(0.01f, cfg.terrainSlope) * (1f + Mathf.Clamp(cfg.terrainSlopeVar, 0f, 0.8f)) + 3f;
int i0 = Mathf.Clamp(Mathf.FloorToInt((tilePos.x - ext - _lastOx) / stepX), 0, hRes - 1), i1 = Mathf.Clamp(Mathf.CeilToInt((tilePos.x + ext - _lastOx) / stepX), 0, hRes - 1);
int j0 = Mathf.Clamp(Mathf.FloorToInt((tilePos.z - ext - _lastOz) / stepZ), 0, hRes - 1), j1 = Mathf.Clamp(Mathf.CeilToInt((tilePos.z + ext - _lastOz) / stepZ), 0, hRes - 1);
int nx = i1 - i0 + 1, nz = j1 - j0 + 1;
if (nx < 2 || nz < 2) { FinishRise(); yield break; }
var final = new float[nz, nx]; var start = new float[nz, nx]; var cur = new float[nz, nx];
int oRes = oldH.GetLength(0); float oStepX = oW / (oRes - 1), oStepZ = oL / (oRes - 1);
for (int j = 0; j < nz; j++)
for (int i = 0; i < nx; i++)
{
final[j, i] = _lastHeights[j0 + j, i0 + i];
float x = _lastOx + (i0 + i) * stepX, z = _lastOz + (j0 + j) * stepZ;
float gx = (x - oOx) / oStepX, gz = (z - oOz) / oStepZ;
float oldNorm = 0f; // 옛 격자 밖 = 옛 바다 바닥
if (gx >= 0f && gz >= 0f && gx <= oRes - 1 && gz <= oRes - 1)
{
int gi = Mathf.Min((int)gx, oRes - 2), gj = Mathf.Min((int)gz, oRes - 2); float tx = gx - gi, tz = gz - gj;
oldNorm = Mathf.Lerp(Mathf.Lerp(oldH[gj, gi], oldH[gj, gi + 1], tx), Mathf.Lerp(oldH[gj + 1, gi], oldH[gj + 1, gi + 1], tx), tz);
}
float oldY = oBase + oldNorm * oSpan;
start[j, i] = Mathf.Clamp01((oldY - _lastBaseY) / _lastSpan);
}
float t = 0f; int frames = 0;
while (t < seconds && !_riseCancel)
{
float k = SmoothStep01(t / seconds);
for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) cur[j, i] = Mathf.Lerp(start[j, i], final[j, i], k);
_data.SetHeightsDelayLOD(i0, j0, cur);
frames++;
yield return null;
t += Time.deltaTime;
}
_data.SetHeightsDelayLOD(i0, j0, final);
_data.SyncHeightmap();
RiseFrames = frames;
LastRiseLog = "솟아오르기 — 칸 " + tilePos.ToString("F0") + " · 부분 " + nx + "×" + nz + " · " + frames + " 프레임 / " + seconds.ToString("F1") + " s" + (_riseCancel ? "(중단)" : "");
cfg.Log(LastRiseLog);
FinishRise();
}
static bool SameArray(float[,] a, float[,] b)
{
if (a.GetLength(0) != b.GetLength(0) || a.GetLength(1) != b.GetLength(1)) return false;
int n0 = a.GetLength(0), n1 = a.GetLength(1);
for (int j = 0; j < n0; j++) for (int i = 0; i < n1; i++) if (Mathf.Abs(a[j, i] - b[j, i]) > 1e-5f) return false;
return true;
}
static bool SameArray(float[,,] a, float[,,] b)
{
if (a.GetLength(0) != b.GetLength(0) || a.GetLength(1) != b.GetLength(1) || a.GetLength(2) != b.GetLength(2)) return false;
int n0 = a.GetLength(0), n1 = a.GetLength(1), n2 = a.GetLength(2);
for (int j = 0; j < n0; j++) for (int i = 0; i < n1; i++) for (int c = 0; c < n2; c++) if (Mathf.Abs(a[j, i, c] - b[j, i, c]) > 1e-4f) return false;
return true;
}
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;
_lastHeights = null; _lastAlpha = null; // 새 데이터 = 반드시 다시 쓴다
}
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;
// 🔴 2026-09-16 PD 「가장자리가 각졌다」 — 데모(5)와 같은 픽셀 오차는 200 m 언덕용. 32 m 섬을 12 m 에서 보면 경사가 큰 삼각형으로 뭉개져 토온 음영이 각진 면이 된다 → 1.
float pe = Mathf.Clamp(cfg.terrainPixelError, 0.05f, 200f); // API 는 1 미만도 받는다(실측 0.05 반영) — 인스펙터 슬라이더 하한과 다름
if (!Mathf.Approximately(_terrain.heightmapPixelError, pe)) _terrain.heightmapPixelError = pe;
}
// ─────────────────────────────────────────────────────────────────
// 풀 — 데모 스크립트 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;
}
}
}