2026-09-16 05:45:47 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLDemoTerrain.cs — 섬 바닥을 데모와 「똑같은」 부품으로 조립 (WL-816zj · #816)
/ /
// PD(2026-09-16) 「데모와 가깝게가 아니라 **똑같은** 결과를 원한다」.
// 같은 카메라로 찍은 데모와 우리 섬의 남은 차이 = ① 지형 셰이더(데모 `Terrain.mat` 스플랫·지형 법선 음영)
// ② 풀 배치 스크립트. 값 흉내를 그만두고 **데모 부품 그대로** 조립한다.
/ /
// ■ 만드는 것 (전부 런타임 · 씬 파일 0줄 · 원본 에셋 0줄 · FI 코드 0줄)
// `~WL_DemoTerrain`(이 컴포넌트) ─ 자식 `Terrain(데모 부품)`:
// · `TerrainData` — 높이맵(`terrainHeightRes 129`) + 스플랫(`terrainAlphaRes 256` · 2층 = 데모 `Demo_Grass`·`Demo_Dirt` TerrainLayer)
// · `Terrain` — `materialTemplate` = 데모 `Terrain.mat` **그대로**(값 무수정) · 콜라이더 없음(걷기·밭·수확은 FI 콜라이더 그대로)
// · `TerrainInstancesBehaviour`(데모 스크립트) — Demo.unity 1281~1309 값 그대로(풀 100 : 꽃 1 · 밀도 2.5 · 자갈 밀도 0.05 …)
// 범위 = 열린 타일 발자국 + 바깥 `terrainMargin`(한 변 최대 `terrainMaxSize`).
// 높이 = 발자국 안 타일 윗면(y 0) → 둥글린 경계(`skirtGrassRound` 열기 연산 + `skirtWaveAmp/Cell` 굽이)에서
// 바깥 `terrainSlope` m 동안 smoothstep 으로 `terrainBottomY`(− 1.8) → 그 밖은 평평(바다 바닥 겸함).
// 스플랫 = 층0 잔디(둥글린 발자국 안 · 경계 `terrainBlend` 에 걸쳐 1→0) · 층1 모래(나머지 + 밭 구역 = WLSoftDirt 와 같은 둥근+노이즈 경계).
// 타일 윗면 렌더러(FI Island 본체)는 이 모드에서 숨긴다(렌더러만 · 콜라이더 유지).
/ /
// ■ 상호 배타 (값 · `WLIslandLook.Apply` 분기)
// `demoTerrainEnabled 1` 이면 `WLIslandGrass`·`WLShoreSkirt`·`WLSoftDirt`·`SpawnSeabed` 를 만들지 않는다.
// `demoTerrainEnabled 0` = 지금 구조 100 % 복귀(이 파일은 아무것도 만들지 않는다).
/ /
// ■ 멱등 — 발자국 서명·밭 구역·값이 그대로면 다시 굽지 않는다(재훑기·확장 훅에서 공짜). 바뀌면 높이맵·스플랫만 다시 굽고
// 인스턴서는 `enabled` 를 껐다 켜서 재생성한다(데모 스크립트의 OnDisable/OnEnable = 버퍼 해제/재생성).
/ /
// ■ 거리장·둥글림·굽이 = `WLShoreSkirt` 와 **같은 식**(발자국 바깥 테두리 선분 유클리드 거리 · 열기 연산 링 · 값 노이즈 굽이).
// 그 파일은 Lead 가 손보는 중이라(816zg/ze) 정적 분리 대신 같은 식을 옮겨 적었다 — 공통화는 후속 부채로 남긴다.
// 두 곳만 다르다: ① 침식 집합 E 안쪽(d ≤ − R)의 값을 `d` 그대로 둔다(스커트는 `d + R` → E 경계에서 R 만큼 뛴다 · 여기서는 연속인 쪽).
// ② 링 점까지의 거리에 그 점의 깊이를 더한다(`dist + d_q` · 링 점이 E 경계에서 얼마나 안쪽인지 보정) — 링을 타일 가장자리에 맞춘
// 격자에서 모으므로 R 이 격자 칸의 배수(3 = 0.5 × 6)면 직선 변에서 오차 0, 아니어도 격자 위상에 따른 계통 오차(최대 한 칸)가 사라진다.
/ /
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업). 데모 스크립트가 LogError 를 내는 조건
// (Settings null · Mesh/Material null · Scale ≤ 0)은 **켜기 전에** 걸러 낸다.
// 🔴 asmdef 금지(Assembly-CSharp 단일) · `Environment.Instancing` 은 `global::` 로 고정(WL 네임스페이스와 이름 충돌 방지).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using UnityEngine ;
using FIIsland = CryingSnow . FarmingIsland . Island ;
using FIIslandManager = CryingSnow . FarmingIsland . IslandManager ;
using DemoInstancer = global :: Environment . Instancing . TerrainInstancesBehaviour ;
using DemoLayer = global :: Environment . Instancing . TerrainInstancesBehaviour . TerrainInstancingInput ;
using DemoEntry = global :: Environment . Instancing . InstancingSettings ;
namespace WL.Look.Farm
{
[DisallowMultipleComponent]
public sealed class WLDemoTerrain : MonoBehaviour
{
public const string ObjectName = "~WL_DemoTerrain" ;
public const string TerrainChildName = "Terrain(데모 부품)" ;
// ── 진단(프로브·보고가 읽는다)
public static int Rebuilds , Bakes , LastTiles , LastEdges , LastRingPoints , LastInstancerRestarts ;
public static float LastBakeMs , LastInstancerMs , LastSizeX , LastSizeZ ;
public static string LastLog = "꺼짐" ;
public WLIslandLookSettings cfg ;
2026-09-16 12:44:59 +00:00
/// <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 ; } }
2026-09-16 05:45:47 +00:00
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 는 프레임 끝에 실행)
2026-09-16 12:44:59 +00:00
if ( _reliefMesh ! = null ) Destroy ( _reliefMesh ) ;
foreach ( var kv in _tuned ) if ( kv . Value ! = null ) Destroy ( kv . Value ) ; // 런타임 재질 복사본(DontSave)은 섬을 나갈 때 함께 버린다(Play 마다 쌓이지 않게)
_tuned . Clear ( ) ;
2026-09-16 05:45:47 +00:00
}
/// <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 ( ) ;
2026-09-16 14:13:15 +00:00
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 ;
}
2026-09-16 05:45:47 +00:00
// 값이 바뀌면 다시 굽는다(인스펙터에서 만져도 다음 훅에 반영된다).
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
2026-09-16 12:44:59 +00:00
+ 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
2026-09-16 13:35:36 +00:00
+ cfg . terrainReliefFeather * 179f + cfg . terrainReliefCollider * 181f + CollectFlatSpots ( cfg ) * 191f
+ cfg . terrainBlendInward * 193f + cfg . terrainEdgeNoiseAmp * 197f + cfg . terrainEdgeNoiseCell * 199f + cfg . terrainBumpWaterNotch * 211f + WaterY ( ) * 223f ;
2026-09-16 05:45:47 +00:00
bool same = ! _dirty & & _data ! = null & & _child ! = null & & tileSig = = _lastTileSig & & Mathf . Abs ( _lastValSig - valSig ) < 0.0001f ;
2026-09-16 14:13:15 +00:00
{
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 ( ) ;
}
2026-09-16 05:45:47 +00:00
HideTops ( ) ; // 새로 열린 타일 윗면도 숨긴다(멱등)
2026-09-16 12:44:59 +00:00
if ( same ) { _child . SetActive ( true ) ; if ( _relief ! = null ) _relief . SetActive ( true ) ; return ; }
2026-09-16 05:45:47 +00:00
var sw = System . Diagnostics . Stopwatch . StartNew ( ) ;
Bake ( size , half , topY , zoneValid , zone ) ;
LastBakeMs = ( float ) sw . Elapsed . TotalMilliseconds ;
2026-09-16 14:13:15 +00:00
// 🔴 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 ;
}
2026-09-16 05:45:47 +00:00
sw . Restart ( ) ;
string instLog = SetupInstancer ( ) ;
_child . SetActive ( true ) ; // → 인스턴서 OnEnable(SetAlphamaps 뒤) · Terrain 표시
LastInstancerMs = ( float ) sw . Elapsed . TotalMilliseconds ;
_lastTileSig = tileSig ; _lastValSig = valSig ; _dirty = false ;
Rebuilds + + ;
2026-09-16 12:44:59 +00:00
if ( Baked ! = null ) Baked ( ) ;
2026-09-16 05:45:47 +00:00
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" )
2026-09-16 12:44:59 +00:00
+ " · 경사 " + 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 + "△" : "없음" ) + ")"
2026-09-16 13:35:36 +00:00
+ " · 잔디 경계 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" )
2026-09-16 05:45:47 +00:00
+ " · 밭 " + ( 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 숨김
2026-09-16 12:44:59 +00:00
if ( _relief ! = null ) _relief . SetActive ( false ) ;
2026-09-16 05:45:47 +00:00
RestoreTops ( ) ;
LastLog = reason ;
}
2026-09-16 12:44:59 +00:00
// ─────────────────────────────────────────────────────────────────
// 🔴 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 )
{
2026-09-16 14:13:15 +00:00
// 0.5 m 격자로 고정 — 밭 중심·자리가 재훑기마다 cm 단위로 흔들려 둔덕이 매번 미세하게 달라지며 재굽기를 부르던 것을 막는다
x = Mathf . Round ( x * 2f ) * 0.5f ; z = Mathf . Round ( z * 2f ) * 0.5f ;
2026-09-16 12:44:59 +00:00
_flatX . Add ( x ) ; _flatZ . Add ( z ) ; _flatR . Add ( r ) ;
2026-09-16 14:13:15 +00:00
sig + = x * 0.37f + z * 0.59f + r * 0.11f ;
2026-09-16 12:44:59 +00:00
}
/// <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 ;
}
2026-09-16 05:45:47 +00:00
// ─────────────────────────────────────────────────────────────────
// 입력 수집 — WLShoreSkirt 와 같은 판별
// ─────────────────────────────────────────────────────────────────
static float TileSize ( )
{
var im = FIIslandManager . Instance ;
return im ! = null ? im . IslandSize : 0f ;
}
/// <summary>열린 타일 중심·본체 렌더러를 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다.</summary>
long CollectTiles ( out float topMeasured )
{
_tiles . Clear ( ) ; _tileTops . Clear ( ) ;
topMeasured = float . NaN ;
var scene = gameObject . scene ;
var islands = Object . FindObjectsByType < FIIsland > ( FindObjectsInactive . Exclude , FindObjectsSortMode . None ) ;
long sig = 17 ;
for ( int i = 0 ; i < islands . Length ; i + + )
{
var isl = islands [ i ] ;
if ( isl = = null | | ! isl . IsUnlocked | | ! isl . gameObject . activeInHierarchy ) continue ;
if ( scene . IsValid ( ) & & isl . gameObject . scene ! = scene ) continue ;
var p = isl . transform . position ;
float top = p . y ;
var r = isl . GetComponent < Renderer > ( ) ; // 섬 본체 렌더러(FIIsland 와 같은 오브젝트 · SwapMaterials 와 같은 판별)
if ( r ! = null ) { top = r . bounds . max . y ; _tileTops . Add ( r ) ; }
if ( float . IsNaN ( topMeasured ) | | top > topMeasured ) topMeasured = top ;
_tiles . Add ( new Vector3 ( p . x , top , p . z ) ) ;
sig + = ( ( long ) Mathf . RoundToInt ( p . x * 8f ) * 73856093L ) ^ ( ( long ) Mathf . RoundToInt ( p . z * 8f ) * 19349663L ) ;
}
return sig ^ ( ( long ) _tiles . Count < < 40 ) ;
}
/// <summary>타일 윗면(FI Island 본체 렌더러)을 숨긴다 — 터레인이 그 자리를 그린다. 콜라이더는 그대로.</summary>
void HideTops ( )
{
for ( int i = 0 ; i < _tileTops . Count ; i + + )
{
var r = _tileTops [ i ] ;
if ( r = = null | | ! r . enabled ) continue ;
r . enabled = false ;
2026-09-16 14:13:15 +00:00
if ( ! _hiddenTops . Contains ( r ) ) _hiddenTops . Add ( r ) ; // FI 가 다시 켠 렌더러를 또 숨겨도 목록은 한 번만
2026-09-16 05:45:47 +00:00
}
}
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 ) ^ 0x9E3779B9 u ; h ^ = h > > 13 ; h * = 0x5bd1e995 u ; h ^ = h > > 15 ;
return ( h & 0xffff u ) / 65535f ;
}
2026-09-16 13:35:36 +00:00
/// <summary>
/// 그라디언트(펄린) 노이즈 − 1~+1(격자 cell · 5차 보간 · 결정적). seed 마다 격자를 옮겨 독립된 무늬.
/// 🔴 2026-09-16 PD 「가장자리가 각졌다」 실측: 값 노이즈(격자 값 겹선형)는 등고선이 **격자에 맞춘 둥근 네모**라 토온 단계·물 깊이 색이
/// 그 등고선을 그대로 드러내 계단·다각형이 됐다(거품·그림자·굴곡 끔 A/B 로 배제). 펄린은 등고선이 유기적 곡선.
/// </summary>
static float GradNoise ( float x , float z , float cell , int seed )
2026-09-16 05:45:47 +00:00
{
2026-09-16 12:44:59 +00:00
if ( cell < = 0.1f ) return 0f ;
float qx = x / cell , qz = z / cell ;
2026-09-16 13:35:36 +00:00
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 근처로
2026-09-16 12:44:59 +00:00
}
2026-09-16 13:35:36 +00:00
// 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>
2026-09-16 12:44:59 +00:00
float Wave ( float x , float z )
{
2026-09-16 13:35:36 +00:00
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 ;
2026-09-16 05:45:47 +00:00
}
/// <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 12:44:59 +00:00
// 🔴 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 ;
2026-09-16 05:45:47 +00:00
int hRes = HeightRes ( cfg . terrainHeightRes ) ;
int aRes = AlphaRes ( cfg . terrainAlphaRes ) ;
LastSizeX = w ; LastSizeZ = l ;
// 2) TerrainData·Terrain — 해상도가 바뀌면 새로 만들고, 아니면 크기·위치만 옮긴다
EnsureData ( hRes , aRes ) ;
2026-09-16 10:55:01 +00:00
var newSize = new Vector3 ( w , span , l ) ;
if ( ( _data . size - newSize ) . sqrMagnitude > 1e-6f ) _data . size = newSize ; // 바뀔 때만(재대입 = 내부 재생성 → 프레임 빔)
2026-09-16 12:44:59 +00:00
var newPos = new Vector3 ( ox , baseY , oz ) ;
2026-09-16 10:55:01 +00:00
if ( ( _child . transform . position - newPos ) . sqrMagnitude > 1e-6f ) _child . transform . position = newPos ;
2026-09-16 05:45:47 +00:00
// 3) 거리장 — 테두리 선분 · 열기 연산 링(타일 가장자리에 맞춘 격자 · 칸 ≤ 스플랫 텍셀)
BuildEdges ( size , half ) ;
float round = Mathf . Max ( 0f , cfg . skirtGrassRound ) ;
float cellA = Mathf . Max ( w / aRes , l / aRes ) ;
2026-09-16 13:35:36 +00:00
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 09:33:36 +00:00
// 🔴 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 ) ;
2026-09-16 05:45:47 +00:00
2026-09-16 09:33:36 +00:00
// 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 ) ;
2026-09-16 12:44:59 +00:00
// 밭 구역(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 ) ;
2026-09-16 09:33:36 +00:00
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 ;
2026-09-16 12:44:59 +00:00
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 ) ;
2026-09-16 13:35:36 +00:00
if ( env > 0f ) y + = env * reliefAmp * ( GradNoise ( x , z , reliefCell , 4 ) * 0.5f + 0.5f ) ;
2026-09-16 12:44:59 +00:00
}
}
else
{
// 경사 폭을 자리마다 ±slopeVar 로 흔든다 → 해안이 넓어졌다 좁아졌다 한다(굽이와 별개의 무늬).
2026-09-16 13:35:36 +00:00
float slopeL = slope * ( 1f + slopeVar * GradNoise ( x , z , varCell , 1 ) ) ;
2026-09-16 12:44:59 +00:00
if ( slopeL < 0.5f ) slopeL = 0.5f ;
float t = s / slopeL ;
if ( t < 1f )
{
y = Mathf . Lerp ( topY , bottom , SmoothStep01 ( t ) ) ;
// 능선·골 — sin(πt) 로 양 끝(윗면·바닥)에서 0 → 걷는 면 위로 솟지 않고 바닥과도 이어진다.
2026-09-16 13:35:36 +00:00
// 🔴 물가선(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 ) ;
}
2026-09-16 12:44:59 +00:00
}
else
{
y = bottom ;
// 바다 바닥 — 경사 끝에서 seaFade 에 걸쳐 굴곡이 자란다(끝점 연속).
2026-09-16 13:35:36 +00:00
if ( seaAmp > 0f ) y + = SmoothStep01 ( ( s - slopeL ) / seaFade ) * seaAmp * GradNoise ( x , z , seaCell , 3 ) ;
2026-09-16 12:44:59 +00:00
}
}
heights [ j , i ] = Mathf . Clamp01 ( ( y - baseY ) / span ) ;
2026-09-16 09:33:36 +00:00
}
}
// 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계)
2026-09-16 05:45:47 +00:00
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 ;
2026-09-16 09:33:36 +00:00
// 경계 거리 = 높이맵 격자의 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 ) ;
2026-09-16 13:35:36 +00:00
float grass = 1f - SmoothStep01 ( ( s + blendIn ) / blend ) ; // s ≤ − blendIn 잔디 1 → s ≥ blend − blendIn 잔디 0(모래) · 풀잎 선(50 %) = blend/2 − blendIn
2026-09-16 05:45:47 +00:00
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 ;
}
}
2026-09-16 14:13:15 +00:00
// 6) 내용 비교 — 같은 배열이면 지형에 다시 쓰지 않는다(재대입 = 패치 재생성 = 화면 조각 빔)
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 ;
2026-09-16 05:45:47 +00:00
Bakes + + ;
}
2026-09-16 14:13:15 +00:00
float [ , ] _lastHeights ;
float [ , , ] _lastAlpha ;
Bounds _lastZone ; bool _lastZoneValid ;
public static bool LastBakeUnchanged ;
public static int SkippedRebuilds ;
public static string LastInputs = "" ; // 진단 — 재굽기 원인 추적(평평 자리 · 밭 구역 · 서명)
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 ;
}
2026-09-16 05:45:47 +00:00
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 ;
2026-09-16 14:13:15 +00:00
_lastHeights = null ; _lastAlpha = null ; // 새 데이터 = 반드시 다시 쓴다
2026-09-16 05:45:47 +00:00
}
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 10:55:01 +00:00
// 🔴 2026-09-16 PD 「상인 생성 때 맵이 잠시 사라진 것처럼 보임」 — 같은 값을 다시 대입해도 Terrain 이 내부 상태를 다시 만들어 한 프레임 비는 것을 막는다(바뀔 때만 대입).
if ( _terrain . terrainData ! = _data ) _terrain . terrainData = _data ;
2026-09-16 10:59:01 +00:00
var tmat = Tuned ( cfg . demoTerrainMaterial ) ; // 데모 Terrain.mat 그대로(값 무수정) · 구름 강도만 섬 크기에 맞춘 런타임 복사본
if ( _terrain . materialTemplate ! = tmat ) _terrain . materialTemplate = tmat ;
2026-09-16 10:55:01 +00:00
if ( _terrain . drawInstanced ) _terrain . drawInstanced = false ; // Demo.unity 1228 m_DrawInstanced 0 · 그 외 Terrain 값은 데모 = 컴포넌트 기본값
if ( _terrain . allowAutoConnect ) _terrain . allowAutoConnect = false ;
2026-09-16 13:35:36 +00:00
// 🔴 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 ;
2026-09-16 05:45:47 +00:00
}
// ─────────────────────────────────────────────────────────────────
// 풀 — 데모 스크립트 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 ; }
2026-09-16 10:59:01 +00:00
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 ;
2026-09-16 12:44:59 +00:00
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 ;
2026-09-16 10:59:01 +00:00
Material m ;
if ( ! _tuned . TryGetValue ( src , out m ) | | m = = null )
{
2026-09-16 12:44:59 +00:00
m = new Material ( src ) { name = src . name + "(WL cloud)" , hideFlags = HideFlags . DontSave } ;
2026-09-16 10:59:01 +00:00
_tuned [ src ] = m ;
}
2026-09-16 12:44:59 +00:00
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 ) ;
2026-09-16 10:59:01 +00:00
return m ;
2026-09-16 05:45:47 +00:00
}
}
}