1005 lines
67 KiB
C#
1005 lines
67 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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
|
||
+ 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;
|
||
}
|
||
|
||
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 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 = 풀)
|
||
}
|
||
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;
|
||
public static string LastRiseLog = "";
|
||
readonly List<FIIsland> _rising = new List<FIIsland>(4);
|
||
FIIsland[] _watch; float _watchNext;
|
||
bool _deferInstancer, _riseCancel;
|
||
Coroutine _riseCo;
|
||
|
||
void Update()
|
||
{
|
||
if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0 || cfg.expandHideOriginal == 0) return;
|
||
if (_watch == null || Time.unscaledTime >= _watchNext)
|
||
{
|
||
_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 || isl.IsUnlocked || isl.IsBridge || !isl.gameObject.activeInHierarchy) continue;
|
||
if (isl.transform.localScale.x >= 0.999f) continue; // 잠긴 채 아직 비활성화 전(로드 직후)은 scale 1 → 해당 없음
|
||
if (_rising.Contains(isl)) continue;
|
||
var mf = isl.GetComponent<MeshFilter>(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외
|
||
if (gameObject.scene.IsValid() && isl.gameObject.scene != gameObject.scene) continue;
|
||
StartRise(isl);
|
||
}
|
||
for (int i = _rising.Count - 1; i >= 0; i--) if (_rising[i] == null || _rising[i].IsUnlocked) _rising.RemoveAt(i);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|