[WL] 섬 룩 폴리싱 6건+1 — 풀 얼룩 원인 = SSAO(섬 동안만 끔) · 해변 경사 5 m 굴곡 · 물가선 거품 띠 · 발판 글자 비용 제거·세움 · 태양 1.15 · 구름 0.5/밝기 0.12/둔덕 0.5(콜라이더) (#816)

- WLSkyScope: SSAO 스코프(islandSsaoOff · ssaoOffScenes) — A/B 국소 질감 0.70→0.07 · 나갈 때/Play 종료 복원 · 렌더러 에셋 0줄
- WLDemoTerrain: 경사 폭 ±35 % · 경사면 굴곡 ±0.25 · 바다 바닥 ±0.12 · 섬 윗면 둔덕 0~0.5(평평 자리 제외) + 발자국 안 MeshCollider · Baked 이벤트 · Tuned(밀도·밝기) · 복사본 파괴
- WLShoreFoam: 터레인 물가선 모드(shoreFoamUp/Down · 런타임 텍스처)
- WLIslandLook: 태양 값(그림자 강도 · 없으면 생성) · WLIslandUi: 글자 upright · 설정: flowLabelFormat "{0}"

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-16 21:44:59 +09:00
parent 0ce4133dc8
commit e66bb838f6
8 changed files with 603 additions and 59 deletions

View File

@ -351,12 +351,16 @@ namespace WL.Island
"🔴 0 이면 던전 입구 더미와 같은 URP/Lit 만 쓴다(안 보이면 0 으로 되돌릴 것).")]
public int platformUseToon = 1;
[Tooltip("안내 글 — {0} = 지금 내야 하는 골드.")]
public string platformLabelFormat = "던전 열기\n{0} 골드";
[Tooltip("안내 글 — {0} = 지금 내야 하는 골드. 🔴 2026-09-16 PD 「발판 글자에서 비용은 빼라(바닥 가격판이 이미 보여 준다)」 → 가격 없이 이름만.")]
public string platformLabelFormat = "던전 열기";
[Tooltip("안내 글 높이(m).")]
public float platformLabelHeight = 2.2f;
[Tooltip("1 = 글자를 바닥 판과 90° 로 **세워서** 카메라(플레이어 정면) 쪽 방향만 따라 돈다(기울이지 않음 · 2026-09-16 PD). " +
"0 = 예전처럼 카메라를 정면으로 바라보게 기울인다.")]
public int platformLabelUpright = 1;
[Tooltip("발판 트리거 한 변(m) — FI Purchaser 원본은 섬 1칸(8 m)을 덮는 十자라 발판 크기로 줄인다.")]
public float platformTriggerSize = 3.4f;
@ -544,8 +548,8 @@ namespace WL.Island
[Tooltip("어디까지 치렀는지 세는 PlayerPrefs 키 1개(시작마다 초기화 대상).")]
public string flowStepPrefsKey = "WL_Island_FlowStep";
[Tooltip("발판 문구 서식 — {0} = 단계 문구 · {1} = 가격.")]
public string flowLabelFormat = "{0}\n{1} 골드";
[Tooltip("발판 문구 서식 — {0} = 단계 문구 · {1} = 가격. 🔴 2026-09-16 PD 「비용(예: 60 골드)은 빼라 — 바닥 가격판이 이미 보여 준다」 → 「{0}」 만.")]
public string flowLabelFormat = "{0}";
[Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")]
public WLFlowStepDef[] flowSteps = new WLFlowStepDef[]

View File

@ -107,18 +107,28 @@ namespace WL.Island
var rt = tmp.GetComponent<RectTransform>();
if (rt != null) rt.sizeDelta = new Vector2(6f, 1.4f);
go.AddComponent<WLIslandBillboard>();
var bb = go.AddComponent<WLIslandBillboard>();
bb.upright = cfg == null || cfg.platformLabelUpright != 0;
}
}
/// <summary>이름표가 항상 카메라를 본다.</summary>
/// <summary>
/// 이름표가 항상 카메라를 본다.
/// 🔴 2026-09-16 PD 「글자를 바닥 판과 90° 로 세워 플레이어 기준 정면으로」 → `upright` 면 **세운 채** 카메라 쪽 방향(수평)만 따라 돈다.
/// </summary>
public sealed class WLIslandBillboard : MonoBehaviour
{
[Tooltip("1 = 세운 채 수평 방향만 카메라를 따른다 · 0 = 카메라를 정면으로 보게 기울인다.")]
public bool upright = true;
void LateUpdate()
{
var cam = Camera.main;
if (cam == null) return;
transform.rotation = Quaternion.LookRotation(transform.position - cam.transform.position, Vector3.up);
var dir = transform.position - cam.transform.position;
if (upright) dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return;
transform.rotation = Quaternion.LookRotation(dir, Vector3.up);
}
}

View File

@ -49,9 +49,15 @@ MonoBehaviour:
ambientGround: {r: 0.047, g: 0.043, b: 0.035, a: 1}
ambientIntensity: 1
skybox: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
dirLightColor: {r: 1, g: 1, b: 1, a: 1}
dirLightIntensity: 1
dirLightColor: {r: 1, g: 0.97, b: 0.9, a: 1}
dirLightIntensity: 1.15
dirLightSoftShadows: 1
dirLightShadowStrength: 0.45
dirLightEnsure: 1
islandSsaoOff: 1
ssaoOffScenes:
- Level01
- WL_FarmLook
fogOff: 1
disableFiGraphicsManager: 1
rescanAtSeconds:
@ -68,7 +74,9 @@ MonoBehaviour:
waterMode: 1
waterFrom: {fileID: 2100000, guid: ce0f8fd131f36964dbc918b8c63f838b, type: 2}
waterTo: {fileID: 2100000, guid: 7c41d0e5b2a94f6d8e3517c0aa96b41d, type: 2}
shoreFoamEnabled: 0
shoreFoamEnabled: 1
shoreFoamUp: 0.08
shoreFoamDown: 0.25
shoreFoamMaterial: {fileID: 2100000, guid: d24a4c5ce16646c409631c112b57f2cf, type: 2}
shoreFoamWidth: 1.6
shoreFoamInner: 0.5
@ -394,13 +402,28 @@ MonoBehaviour:
- Level01
- WL_FarmLook
demoTerrainEnabled: 1
demoCloudStrength: 0.5
demoCloudDensity: 0.025
terrainHeightRes: 129
terrainAlphaRes: 256
terrainMargin: 8
terrainMaxSize: 128
terrainBlend: 1
terrainBottomY: -1.8
terrainSlope: 3
terrainSlope: 5
terrainSlopeVar: 0.35
terrainSlopeVarCell: 5
terrainBumpAmp: 0.25
terrainBumpCell: 2.5
terrainSeabedBumpAmp: 0.12
terrainSeabedBumpCell: 3.5
terrainReliefAmp: 0.5
terrainReliefCell: 4.5
terrainReliefEdgeFade: 2.5
terrainReliefFlatRadius: 3
terrainReliefFeather: 1.5
terrainReliefCollider: 1
demoTerrainBrightness: 0.12
terrainHeightSpan: 10
terrainRoundHeight: 1
demoTerrainMaterial: {fileID: 2100000, guid: 719df871080742445b86937d1b9977c9, type: 2}

View File

@ -56,6 +56,12 @@ namespace WL.Look.Farm
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;
@ -81,6 +87,9 @@ namespace WL.Look.Farm
{
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>
@ -114,12 +123,16 @@ namespace WL.Look.Farm
+ 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;
+ ScatterSig(cfg.demoGrass) * 107f + ScatterSig(cfg.demoFlower) * 109f + ScatterSig(cfg.demoGravel) * 113f
+ cfg.terrainSlopeVar * 127f + cfg.terrainSlopeVarCell * 131f + cfg.terrainBumpAmp * 137f + cfg.terrainBumpCell * 139f
+ cfg.terrainSeabedBumpAmp * 149f + cfg.terrainSeabedBumpCell * 151f
+ cfg.terrainReliefAmp * 157f + cfg.terrainReliefCell * 163f + cfg.terrainReliefEdgeFade * 167f + cfg.terrainReliefFlatRadius * 173f
+ cfg.terrainReliefFeather * 179f + cfg.terrainReliefCollider * 181f + CollectFlatSpots(cfg) * 191f;
bool same = !_dirty && _data != null && _child != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f;
HideTops(); // 새로 열린 타일 윗면도 숨긴다(멱등)
if (same) { _child.SetActive(true); return; }
if (same) { _child.SetActive(true); if (_relief != null) _relief.SetActive(true); return; }
var sw = System.Diagnostics.Stopwatch.StartNew();
Bake(size, half, topY, zoneValid, zone);
@ -132,13 +145,16 @@ namespace WL.Look.Farm
_lastTileSig = tileSig; _lastValSig = valSig; _dirty = false;
Rebuilds++;
if (Baked != null) Baked();
LastLog = "데모 터레인 — 타일 " + _tiles.Count + " · 테두리 변 " + LastEdges + " · 링 " + LastRingPoints
+ " · 범위 " + LastSizeX.ToString("F0") + "×" + LastSizeZ.ToString("F0") + " m(원점 "
+ _child.transform.position.x.ToString("F1") + ", " + _child.transform.position.z.ToString("F1") + ")"
+ " · 높이맵 " + _dataHeightRes + "² · 스플랫 " + _dataAlphaRes + "²"
+ " · 윗면 y " + topY.ToString("F2") + (fixedTop ? "(고정)" : "(실측)") + " → 바닥 " + cfg.terrainBottomY.ToString("F2")
+ " · 경사 " + cfg.terrainSlope.ToString("F1") + " m · 잔디 경계 R " + cfg.skirtGrassRound.ToString("F1") + " 굽이 ±" + cfg.skirtWaveAmp.ToString("F1")
+ " · 경사 " + cfg.terrainSlope.ToString("F1") + " m(폭 ±" + (cfg.terrainSlopeVar * 100f).ToString("F0") + " % · 굴곡 ±" + cfg.terrainBumpAmp.ToString("F2") + " m · 바닥 굴곡 ±" + cfg.terrainSeabedBumpAmp.ToString("F2") + " m)"
+ " · 둔덕 0~" + cfg.terrainReliefAmp.ToString("F2") + " m(격자 " + cfg.terrainReliefCell.ToString("F1") + " · 평평 자리 " + _flatX.Count + " · 콜라이더 " + (LastReliefTris > 0 ? LastReliefTris + "△" : "없음") + ")"
+ " · 잔디 경계 R " + cfg.skirtGrassRound.ToString("F1") + " 굽이 ±" + cfg.skirtWaveAmp.ToString("F1")
+ " · 밭 " + (zoneValid ? (zone.size.x).ToString("F1") + "×" + (zone.size.z).ToString("F1") + " m" : "미확정")
+ " · 굽기 " + LastBakeMs.ToString("F1") + " ms · 인스턴서 " + LastInstancerMs.ToString("F1") + " ms(" + instLog + ")"
+ " · 드로우콜 터레인 1(+그림자 패스) + 인다이렉트 3 · 타일 윗면 숨김 " + _hiddenTops.Count;
@ -153,10 +169,151 @@ namespace WL.Look.Farm
void Hide(string reason)
{
if (_child != null) _child.SetActive(false); // 인스턴서 OnDisable → 버퍼 해제 · Terrain 숨김
if (_relief != null) _relief.SetActive(false);
RestoreTops();
LastLog = reason;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「데모 같은 그라데이션 · 그림자 단계」 — 섬 윗면 둔덕
// 평평하게 둘 자리 = 플로우 단계 자리(캠프파이어·상인·사과나무·던전 · 캠프 자리는 천막 반지름까지) + 던전 입구.
// 자리는 값(설정)에서 오고 입구는 씬에서 읽는다 → 서명에 넣어 입구가 새로 생기면 한 번 다시 굽는다(≈100 ms · 화면 유지).
// ─────────────────────────────────────────────────────────────────
readonly List<float> _flatX = new List<float>(8), _flatZ = new List<float>(8), _flatR = new List<float>(8);
GameObject _relief;
MeshCollider _reliefCol;
Mesh _reliefMesh;
public static int LastReliefTris;
float CollectFlatSpots(WLIslandLookSettings cfg)
{
_flatX.Clear(); _flatZ.Clear(); _flatR.Clear();
float sig = 0f;
if (cfg.terrainReliefAmp <= 0f) return 0f;
float r0 = Mathf.Max(0f, cfg.terrainReliefFlatRadius);
var icfg = global::WL.Island.WLIslandSettings.Instance;
if (icfg != null && global::WL.Island.WLIslandFlowV2.Active(icfg))
{
int n = global::WL.Island.WLIslandFlowV2.StepCount(icfg);
for (int i = 0; i < n; i++)
{
var p = global::WL.Island.WLIslandFlowV2.PosFor(icfg, i);
float r = r0;
if (i == 0 && icfg.campDecorEnabled != 0) r = Mathf.Max(r, icfg.campTentRadius + 1.5f); // 캠프 천막(campTentRadius)까지 평평
AddFlat(p.x, p.z, r, ref sig);
}
}
var gates = Object.FindObjectsByType<global::WL.Island.WLDungeonGate>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < gates.Length; i++)
{
if (gates[i] == null) continue;
var p = gates[i].transform.position;
AddFlat(p.x, p.z, r0, ref sig);
}
return sig;
}
void AddFlat(float x, float z, float r, ref float sig)
{
_flatX.Add(x); _flatZ.Add(z); _flatR.Add(r);
sig += Mathf.Round(x * 4f) * 0.37f + Mathf.Round(z * 4f) * 0.59f + r * 0.11f;
}
/// <summary>둔덕 봉투 0~1 — 잔디 경계 근처 · 밭 구역 · 평평 자리 둘레에서 0.</summary>
float ReliefEnv(float x, float z, float s, float edgeFade, float feather, bool zoneValid, float zcx, float zcz, float zhx, float zhz, float zrad, float znoise, float zcell)
{
float env = SmoothStep01(-s / edgeFade);
if (env <= 0f) return 0f;
if (zoneValid)
{
float dz = WLSoftDirt.ShapeDist(x, z, zcx, zcz, zhx, zhz, zrad, znoise, zcell); // = 밭 안 · + = 밖(m)
env *= SmoothStep01((dz - 0.5f) / feather);
if (env <= 0f) return 0f;
}
for (int q = 0; q < _flatX.Count; q++)
{
float dx = x - _flatX[q], dzq = z - _flatZ[q];
env *= SmoothStep01((Mathf.Sqrt(dx * dx + dzq * dzq) - _flatR[q]) / feather);
if (env <= 0f) return 0f;
}
return env;
}
/// <summary>
/// 발자국 안(타일 네모 · s ≤ 0 인 격자점만)의 둔덕 높이를 따르는 MeshCollider — 플레이어가 둔덕 위를 걷는다.
/// 격자 ≥ 0.5 m(16 m 섬 ≈ 2천 삼각형). 레이어는 FI 타일과 같게(FI 광선 판정 불변). 발자국 밖(해변)은 콜라이더 없음 = 가장자리 동작 불변.
/// </summary>
void BuildReliefCollider(bool on, float ox, float oz, float w, float l, float baseY, float span, float[,] heights, int hRes, float half, float stepX, float stepZ)
{
LastReliefTris = 0;
if (!on)
{
if (_relief != null) _relief.SetActive(false);
return;
}
if (_relief == null)
{
var t = transform.Find("~WL_ReliefCollider");
_relief = t != null ? t.gameObject : new GameObject("~WL_ReliefCollider");
_relief.transform.SetParent(transform, false);
_relief.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); // 메시는 월드 좌표
_reliefCol = _relief.GetComponent<MeshCollider>();
if (_reliefCol == null) _reliefCol = _relief.AddComponent<MeshCollider>();
}
if (_tileTops.Count > 0 && _tileTops[0] != null) _relief.layer = _tileTops[0].gameObject.layer;
if (_reliefMesh == null) _reliefMesh = new Mesh { name = "WL_ReliefCollider", hideFlags = HideFlags.DontSave };
float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue;
for (int i = 0; i < _tiles.Count; i++)
{
var t = _tiles[i];
if (t.x - half < minX) minX = t.x - half;
if (t.x + half > maxX) maxX = t.x + half;
if (t.z - half < minZ) minZ = t.z - half;
if (t.z + half > maxZ) maxZ = t.z + half;
}
float cell = Mathf.Max(0.5f, Mathf.Max(stepX, stepZ));
int nx = Mathf.Clamp(Mathf.RoundToInt((maxX - minX) / cell) + 1, 2, 1025), nz = Mathf.Clamp(Mathf.RoundToInt((maxZ - minZ) / cell) + 1, 2, 1025);
float cx = (maxX - minX) / (nx - 1), cz = (maxZ - minZ) / (nz - 1);
var verts = new List<Vector3>(nx * nz);
var vmap = new int[nx * nz];
var tris = new List<int>(nx * nz * 6);
for (int k = 0; k < vmap.Length; k++) vmap[k] = -1;
for (int j = 0; j < nz; j++)
{
float z = minZ + j * cz;
for (int i = 0; i < nx; i++)
{
float x = minX + i * cx;
if (!Inside(x, z, half + 0.001f)) continue;
// 높이맵 겹선형(정규화 → 월드)
float gx = Mathf.Clamp((x - ox) / stepX, 0f, hRes - 1.0001f), gz = Mathf.Clamp((z - oz) / stepZ, 0f, hRes - 1.0001f);
int gi = (int)gx, gj = (int)gz; float tx = gx - gi, tz = gz - gj;
float h = Mathf.Lerp(Mathf.Lerp(heights[gj, gi], heights[gj, gi + 1], tx), Mathf.Lerp(heights[gj + 1, gi], heights[gj + 1, gi + 1], tx), tz);
vmap[j * nx + i] = verts.Count;
verts.Add(new Vector3(x, baseY + h * span, z));
}
}
for (int j = 0; j < nz - 1; j++)
for (int i = 0; i < nx - 1; i++)
{
int a = vmap[j * nx + i], b = vmap[j * nx + i + 1], c = vmap[(j + 1) * nx + i], d = vmap[(j + 1) * nx + i + 1];
if (a < 0 || b < 0 || c < 0 || d < 0) continue;
tris.Add(a); tris.Add(c); tris.Add(b);
tris.Add(b); tris.Add(c); tris.Add(d);
}
_reliefMesh.Clear();
if (tris.Count == 0) { _relief.SetActive(false); return; }
_reliefMesh.indexFormat = verts.Count > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
_reliefMesh.SetVertices(verts);
_reliefMesh.SetTriangles(tris, 0);
_reliefMesh.RecalculateBounds();
_reliefCol.sharedMesh = null; // 같은 메시 재대입은 다시 굽지 않는다 → 비웠다가 물린다
_reliefCol.sharedMesh = _reliefMesh;
_relief.SetActive(true);
LastReliefTris = tris.Count / 3;
}
// ─────────────────────────────────────────────────────────────────
// 입력 수집 — WLShoreSkirt 와 같은 판별
// ─────────────────────────────────────────────────────────────────
@ -264,15 +421,22 @@ namespace WL.Look.Farm
return (h & 0xffffu) / 65535f;
}
/// <summary>경계 굽이 — 값 노이즈(격자 skirtWaveCell · 겹선형 · 결정적) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식).</summary>
/// <summary>값 노이즈 1~+1(격자 cell · 겹선형 · 결정적). seed 0 = 경계 굽이(WLShoreSkirt 와 같은 식) · 다른 seed 는 격자를 옮겨 독립된 무늬.</summary>
static float ValueNoise(float x, float z, float cell, int seed)
{
if (cell <= 0.1f) return 0f;
float qx = x / cell, qz = z / cell;
int gx = Mathf.FloorToInt(qx) + seed * 1013, gz = Mathf.FloorToInt(qz) + seed * 2027; float fx = qx - Mathf.Floor(qx), fz = qz - Mathf.Floor(qz);
fx = fx * fx * (3f - 2f * fx); fz = fz * fz * (3f - 2f * fz);
float v00 = Hash01(gx, gz), v10 = Hash01(gx + 1, gz), v01 = Hash01(gx, gz + 1), v11 = Hash01(gx + 1, gz + 1);
return (Mathf.Lerp(Mathf.Lerp(v00, v10, fx), Mathf.Lerp(v01, v11, fx), fz) - 0.5f) * 2f;
}
/// <summary>경계 굽이 — 값 노이즈(격자 skirtWaveCell) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식).</summary>
float Wave(float x, float z)
{
if (cfg.skirtWaveAmp <= 0f || cfg.skirtWaveCell <= 0.1f) return 0f;
float qx = x / cfg.skirtWaveCell, qz = z / cfg.skirtWaveCell;
int gx = Mathf.FloorToInt(qx), gz = Mathf.FloorToInt(qz); float fx = qx - gx, fz = qz - gz;
fx = fx * fx * (3f - 2f * fx); fz = fz * fz * (3f - 2f * fz);
float v00 = Hash01(gx, gz), v10 = Hash01(gx + 1, gz), v01 = Hash01(gx, gz + 1), v11 = Hash01(gx + 1, gz + 1);
return (Mathf.Lerp(Mathf.Lerp(v00, v10, fx), Mathf.Lerp(v01, v11, fx), fz) - 0.5f) * 2f * cfg.skirtWaveAmp;
return ValueNoise(x, z, cfg.skirtWaveCell, 0) * cfg.skirtWaveAmp;
}
/// <summary>
@ -350,10 +514,16 @@ namespace WL.Look.Farm
float l = Mathf.Min((maxZ - minZ) + 2f * margin, maxSize);
float ox = (minX + maxX) * 0.5f - w * 0.5f, oz = (minZ + maxZ) * 0.5f - l * 0.5f;
float bottom = cfg.terrainBottomY;
float span = Mathf.Max(cfg.terrainHeightSpan, topY - bottom); // 높이맵 1.0 = bottom + span ≥ 윗면
if (span <= 0.001f) span = 1f;
float slope = Mathf.Max(0.01f, cfg.terrainSlope);
float blend = Mathf.Max(0.01f, cfg.terrainBlend);
// 🔴 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;
@ -362,14 +532,14 @@ namespace WL.Look.Farm
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, bottom, oz);
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 + blend + Mathf.Max(0f, cfg.skirtWaveAmp) + cellA * 2f; // 이보다 먼 바깥은 높이·스플랫 모두 상수
float far = slope * (1f + slopeVar) + seaFade + blend + Mathf.Max(0f, cfg.skirtWaveAmp) + cellA * 2f; // 이보다 먼 바깥은 둥글림이 높이·스플랫에 영향 없음
// 🔴 2026-09-16 Lead 실측: 링 0.125 m 간격(632점) × 스플랫 65k 텍셀 = 굽기 851 ms → 상인 설치 때 화면이 멎어 「지형이 사라졌다 보임」.
// 링은 0.5 m 간격(≈1/4)으로, 경계 거리 s 는 높이맵 격자(0.25 m)에서 한 번만 계산해 스플랫은 겹선형으로 읽는다(blend 1 m 에 충분).
GatherRing(size, half, minX, minZ, maxX, maxZ, Mathf.Max(0.5f, Mathf.Min(w / aRes, l / aRes)), round);
@ -380,22 +550,8 @@ namespace WL.Look.Farm
var heights = new float[hRes, hRes];
var sGrid = new float[hRes * hRes]; // 둥글림+굽이 경계 거리(스플랫이 읽는다)
float stepX = w / (hRes - 1), stepZ = l / (hRes - 1);
for (int j = 0; j < hRes; j++)
{
float z = oz + j * stepZ;
for (int i = 0; i < hRes; i++)
{
float x = ox + i * stepX;
float d = RawDist(x, z, half);
float sb = Boundary(x, z, d, round, far);
sGrid[j * hRes + i] = sb;
float s = roundHeight ? sb : d;
float y = s <= 0f ? topY : s >= slope ? bottom : Mathf.Lerp(topY, bottom, SmoothStep01(s / slope));
heights[j, i] = Mathf.Clamp01((y - bottom) / span);
}
}
// 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계)
// 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계) — 스플랫(모래)과 둔덕 봉투(평평)가 함께 읽는다
float zcx = 0f, zcz = 0f, zhx = 0f, zhz = 0f, zrad = 0f, znoise = 0f, zcell = 1f, feather = 1f;
if (zoneValid)
{
@ -408,6 +564,54 @@ namespace WL.Look.Farm
feather = Mathf.Max(0.05f, cfg.softDirtFeather);
if (zhx <= 0.01f || zhz <= 0.01f) zoneValid = false;
}
// 🔴 2026-09-16 PD 「데모 같은 그라데이션 · 그림자 단계」 — 섬 윗면 둔덕(위로만 0~reliefAmp · 값 노이즈 · 평평 자리 제외)
float reliefAmp = Mathf.Max(0f, cfg.terrainReliefAmp), reliefCell = Mathf.Max(1f, cfg.terrainReliefCell);
float reliefFade = Mathf.Max(0.1f, cfg.terrainReliefEdgeFade), reliefFeather = Mathf.Max(0.1f, cfg.terrainReliefFeather);
for (int j = 0; j < hRes; j++)
{
float z = oz + j * stepZ;
for (int i = 0; i < hRes; i++)
{
float x = ox + i * stepX;
float d = RawDist(x, z, half);
float sb = Boundary(x, z, d, round, far);
sGrid[j * hRes + i] = sb;
float s = roundHeight ? sb : d;
float y;
if (s <= 0f)
{
y = topY; // 발자국 안 = FI 콜라이더 윗면(둔덕은 그 위로만)
if (reliefAmp > 0f)
{
float env = ReliefEnv(x, z, s, reliefFade, reliefFeather, zoneValid, zcx, zcz, zhx, zhz, zrad, znoise, zcell);
if (env > 0f) y += env * reliefAmp * (ValueNoise(x, z, reliefCell, 4) * 0.5f + 0.5f);
}
}
else
{
// 경사 폭을 자리마다 ±slopeVar 로 흔든다 → 해안이 넓어졌다 좁아졌다 한다(굽이와 별개의 무늬).
float slopeL = slope * (1f + slopeVar * ValueNoise(x, z, varCell, 1));
if (slopeL < 0.5f) slopeL = 0.5f;
float t = s / slopeL;
if (t < 1f)
{
y = Mathf.Lerp(topY, bottom, SmoothStep01(t));
// 능선·골 — sin(πt) 로 양 끝(윗면·바닥)에서 0 → 걷는 면 위로 솟지 않고 바닥과도 이어진다.
if (bumpAmp > 0f) y += Mathf.Sin(t * Mathf.PI) * bumpAmp * ValueNoise(x, z, bumpCell, 2);
}
else
{
y = bottom;
// 바다 바닥 — 경사 끝에서 seaFade 에 걸쳐 굴곡이 자란다(끝점 연속).
if (seaAmp > 0f) y += SmoothStep01((s - slopeL) / seaFade) * seaAmp * ValueNoise(x, z, seaCell, 3);
}
}
heights[j, i] = Mathf.Clamp01((y - baseY) / span);
}
}
// 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계)
var alpha = new float[aRes, aRes, 2];
for (int j = 0; j < aRes; j++)
{
@ -435,6 +639,7 @@ namespace WL.Look.Farm
_data.SetHeights(0, 0, heights);
_data.SetAlphamaps(0, 0, alpha);
BuildReliefCollider(reliefAmp > 0f && cfg.terrainReliefCollider != 0, ox, oz, w, l, baseY, span, heights, hRes, half, stepX, stepZ);
Bakes++;
}
@ -549,14 +754,19 @@ namespace WL.Look.Farm
Material Tuned(Material src)
{
if (src == null) return null;
if (cfg == null || cfg.demoCloudStrength <= 0f || !src.HasProperty("_Cloud_Strength")) return src;
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 " + cfg.demoCloudStrength.ToString("F2") + ")", hideFlags = HideFlags.DontSave };
m = new Material(src) { name = src.name + "(WL cloud)", hideFlags = HideFlags.DontSave };
_tuned[src] = m;
}
if (!Mathf.Approximately(m.GetFloat("_Cloud_Strength"), cfg.demoCloudStrength)) m.SetFloat("_Cloud_Strength", cfg.demoCloudStrength);
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;
}
}

View File

@ -563,18 +563,40 @@ namespace WL.Look.Farm
}
var lights = Object.FindObjectsByType<Light>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
int suns = 0;
for (int i = 0; i < lights.Length; i++)
{
var l = lights[i];
if (l == null || l.gameObject.scene != scene) continue;
if (l.type != LightType.Directional) continue;
if (l.type != LightType.Directional || !l.enabled) continue;
ApplySun(cfg, l);
suns++;
}
// 🔴 2026-09-16 PD 「자연스러운 광원(태양)을 배치」 — 섬 씬에 켜진 태양이 없을 때만 하나 만든다(있으면 그 값만 맞춘다 · 씬 파일 0줄).
if (suns == 0 && cfg.dirLightEnsure != 0)
{
var go = new GameObject("~WL_Sun");
if (scene.IsValid()) SceneManager.MoveGameObjectToScene(go, scene);
go.transform.rotation = Quaternion.Euler(50f, 330f, 0f); // 데모 Demo.unity 의 태양 회전
var l = go.AddComponent<Light>();
l.type = LightType.Directional;
l.shadows = LightShadows.Soft;
ApplySun(cfg, l);
SunCreated++;
}
}
public static int SunCreated;
static void ApplySun(WLIslandLookSettings cfg, Light l)
{
l.color = cfg.dirLightColor;
l.intensity = cfg.dirLightIntensity;
if (cfg.dirLightSoftShadows != 0 && l.shadows != LightShadows.None) l.shadows = LightShadows.Soft;
if (cfg.dirLightShadowStrength >= 0f) l.shadowStrength = Mathf.Clamp01(cfg.dirLightShadowStrength);
RenderSettings.sun = l;
LightingApplied++;
}
}
// ─────────────────────────────────────────────────────────────────
// ②-b 밭(Soil) 칸 표시 — **기능은 그대로 두고 색만** 데모 톤으로 (816f · PD 지시)

View File

@ -189,10 +189,23 @@ namespace WL.Look.Farm
public float ambientIntensity = 1f;
[Tooltip("빌트인 Default-Skybox. 비우면 스카이박스는 안 건드린다.")]
public Material skybox;
public Color dirLightColor = Color.white;
public float dirLightIntensity = 1f;
[Tooltip("태양(디렉셔널) 색. 2026-09-16 PD 「광원이 부족 — 자연스러운 태양으로 화사하게」 → 살짝 따뜻한 흰색.")]
public Color dirLightColor = new Color(1f, 0.97f, 0.9f, 1f);
[Tooltip("태양 세기. 데모 1.0 → PD 「화사하게」 1.15(SSAO 를 끈 뒤 기준 · 잔디 하이라이트가 흰색으로 날리지 않는 상한 근처).")]
public float dirLightIntensity = 1.15f;
[Tooltip("1 = Soft(데모) · 0 = 원본(Hard) 유지.")]
public int dirLightSoftShadows = 1;
[Tooltip("태양 그림자 진하기(0~1). 음수 = 씬 값 그대로(FI 원본 0.5). 2026-09-16 PD 「화사하게」 → 0.45.")]
public float dirLightShadowStrength = 0.45f;
[Tooltip("섬 씬에 켜진 디렉셔널 라이트가 하나도 없으면 `~WL_Sun` 을 만들어 위 값으로 둔다(회전 = 데모 50/330/0). 0 = 만들지 않음.")]
public int dirLightEnsure = 1;
// ── 2026-09-16 PD 「풀이 얼룩덜룩 — 원인을 더 찾아라」 → A/B 실측: SSAO 가 풀잎마다 발밑 그늘을 만든 것(국소 질감 0.70 → 0.07)
[Header("§0-e — 섬에서는 SSAO 를 끈다 (렌더러 에셋 0줄 · 나갈 때 복원)")]
[Tooltip("1 = 아래 씬이 떠 있는 동안 URP 렌더러의 SSAO 피처를 끈다(전투 맵은 그대로). 0 = 손대지 않음.")]
public int islandSsaoOff = 1;
[Tooltip("SSAO 를 끌 씬 이름(섬만 · 던전은 아레나 룩 그대로).")]
public string[] ssaoOffScenes = new[] { "Level01", "WL_FarmLook" };
[Tooltip("1 이면 안개를 끈다(데모 = off).")]
public int fogOff = 1;
@ -259,6 +272,13 @@ namespace WL.Look.Farm
[Tooltip("거품이 흐르는 속도(1 = 초당 무늬 1칸). 0 이면 정지.")]
public float shoreFoamScroll = 0.06f;
// ── 2026-09-16 PD 「물이 섬 경계에 닿을 때 자연스러운 물보라가 안 보인다」 — 데모 터레인 모드에서는 타일 발자국 대신 **터레인 물가선**(지형 높이 = 물 높이)을 따른다
[Tooltip("데모 터레인 모드의 물가 띠 — 물가선에서 **모래 쪽**으로 지형이 물보다 이만큼(m · 높이) 높은 곳까지 거품이 올라온다(완만한 곳은 넓게 · 가파른 곳은 좁게 = 자연스러운 물보라).")]
public float shoreFoamUp = 0.08f;
[Tooltip("데모 터레인 모드의 물가 띠 — 물가선에서 **바다 쪽**으로 수심 이만큼(m)까지 거품이 퍼진다.")]
public float shoreFoamDown = 0.25f;
// ───────────────────────────────── §816zf 부드러운 흙 데칼
[Header("§816zf — 흙 자리를 데모처럼 「둥글게 번지게」 (PD 2026-09-15)")]
[Tooltip("0 = 지금(각진 1×1 흙 타일 · 100 % 복귀) · 1 = 밭 구역을 덮는 **부드러운 흙 데칼 1장**" +
@ -402,7 +422,11 @@ namespace WL.Look.Farm
[Tooltip("데모 지형·풀·꽃 재질의 구름 그림자 강도를 이 값으로 덮어쓴 런타임 복사본을 쓴다(원본 무수정). " +
"2026-09-16 PD 「풀이 얼룩덜룩」 A/B 실측: 데모 원값 1.0 은 200 m 지형용이라 32 m 섬이 통째로 어두워졌다 → 0.3. 0 이하 = 원본 그대로.")]
public float demoCloudStrength = 0.3f;
public float demoCloudStrength = 0.5f; // 2026-09-16 PD 「데모 같은 그라데이션」 A/B: 0.25 는 안 보이고 0.5 가 데모의 구름 그늘 무늬(0.7 은 과함)
[Tooltip("데모 지형·풀·꽃 재질의 구름 무늬 밀도(월드 m 당 UV). 데모 원값 0.01 = 구름 한 덩이 100 m 라 32 m 섬은 절반이 통째로 덮인다 → 0.025(한 덩이 40 m · 섬 위에 밝음/그늘이 한 번씩). " +
"2026-09-16 PD 「섬 크기를 고려해 구름 그림자 조절」. 0 이하 = 원본 그대로.")]
public float demoCloudDensity = 0.025f;
[Tooltip("높이맵 해상도(2ⁿ+1 · 33~4097). 129 = 한 변 128 m 일 때 1 m 간격, 작은 섬이면 더 촘촘해진다.")]
public int terrainHeightRes = 129;
@ -422,8 +446,53 @@ namespace WL.Look.Farm
[Tooltip("경사 바깥 끝 = 바다 바닥 높이(월드 y · m). 물(1.0) 아래.")]
public float terrainBottomY = -1.8f;
[Tooltip("잔디 경계에서 바닥까지 내려가는 경사의 폭(m). smoothstep — 윗면에서도 바닥에서도 접선이 수평.")]
public float terrainSlope = 3f;
[Tooltip("잔디 경계에서 바닥까지 내려가는 경사의 폭(m). smoothstep — 윗면에서도 바닥에서도 접선이 수평. " +
"2026-09-16 PD 「가장자리 → 바다를 자연스러운 경사면으로」 → 3 → 5(물(1)까지 약 2.6 m 의 완만한 해변).")]
public float terrainSlope = 5f;
// ── 2026-09-16 PD 「섬 가장자리와 바다로 이어지는 부분을 자연스러운 경사면으로 · 굴곡이 보이게」
[Tooltip("경사 폭을 자리마다 이 비율(±)로 흔든다(값 노이즈 · 격자 terrainSlopeVarCell). 0.35 = 폭 ×0.65~1.35 → 해안이 넓어졌다 좁아졌다 한다. 0 = 균일.")]
[Range(0f, 0.8f)] public float terrainSlopeVar = 0.35f;
[Tooltip("경사 폭 흔들기 노이즈 격자(m).")]
public float terrainSlopeVarCell = 5f;
[Tooltip("경사면 위에 얹는 능선·골 굴곡 진폭(m · 경사 양 끝에서 0 이라 윗면·바닥과 이어진다). 데모 지형처럼 법선 음영이 굴곡을 드러낸다. 0 = 매끈한 smoothstep.")]
public float terrainBumpAmp = 0.25f;
[Tooltip("굴곡 노이즈 격자(m).")]
public float terrainBumpCell = 2.5f;
[Tooltip("경사 밖 바다 바닥의 완만한 굴곡 진폭(m · 경사 끝에서 2 m 에 걸쳐 서서히 커진다). 물이 깊이로 색을 정하므로 얕은 곳/깊은 곳 얼룩으로 보인다. 0 = 평평.")]
public float terrainSeabedBumpAmp = 0.12f;
[Tooltip("바다 바닥 굴곡 격자(m).")]
public float terrainSeabedBumpCell = 3.5f;
// ── 2026-09-16 PD 「잔디 얼룩은 사라졌지만 데모 같은 그라데이션이 안 보이고 그림자가 1단계뿐」 → 섬 윗면 둔덕(데모 언덕 음영)
[Tooltip("섬 윗면에 얹는 완만한 둔덕의 최대 높이(m · **위로만** 0~값). 데모 지형의 언덕처럼 토온 램프가 밝음/그늘 단계를 만든다. " +
"잔디 경계 근처(terrainReliefEdgeFade) · 밭 구역 · 설치 자리(플로우 단계 · 던전 입구 · 캠프 천막) 주변은 0(평평). 0 = 평지(지금 구조).")]
public float terrainReliefAmp = 0.5f;
[Tooltip("둔덕 노이즈 격자(m). 작을수록 잔둔덕이 많아지고 경사가 가팔라진다.")]
public float terrainReliefCell = 4.5f;
[Tooltip("잔디 경계에서 안쪽으로 이만큼(m)에 걸쳐 둔덕이 0 에서 자란다(해변 경사와 매끈하게 이어진다).")]
public float terrainReliefEdgeFade = 2.5f;
[Tooltip("설치 자리(플로우 단계 위치 · 던전 입구) 둘레 이 반지름(m) 안은 평평. 캠프파이어 자리는 천막 반지름 + 1.5 m 로 넓힌다.")]
public float terrainReliefFlatRadius = 3f;
[Tooltip("평평한 곳 → 둔덕으로 넘어가는 완충 폭(m).")]
public float terrainReliefFeather = 1.5f;
[Tooltip("1 = 발자국 안(타일 네모)에만 둔덕 높이를 따르는 MeshCollider 를 깐다 → 플레이어(CharacterController)가 둔덕 위를 걷는다. " +
"FI 타일 콜라이더는 그대로(가장자리 동작 불변). 0 = 콜라이더 없음(둔덕에 발이 묻힌다).")]
public int terrainReliefCollider = 1;
[Tooltip("데모 지형·풀 재질의 토온 램프 밝기 오프셋(`_Brightness`)을 이 값으로 덮어쓴 런타임 복사본을 쓴다(원본 0.25). " +
"0 이하 = 원본 그대로. 낮출수록 평지가 램프 단계 경계 가까이 내려와 작은 경사에도 단계가 갈린다(데모 언덕 음영이 잘 보인다).")]
public float demoTerrainBrightness = 0.12f; // 2026-09-16 A/B: 0.25(원본) 는 구름 그늘이 1단계 · 0.12 면 그늘 안에 2~3단계 띠 + 둔덕 경사면 음영이 드러난다
[Tooltip("높이맵 size.y(m) — 바닥(terrainBottomY)에서 위로 이만큼까지 표현한다(높이값 1.0 = 바닥 + 이 값). 타일 윗면(0)이 들어가면 된다.")]
public float terrainHeightSpan = 10f;

View File

@ -43,8 +43,11 @@ namespace WL.Look.Farm
Mesh _mesh;
float _scroll;
void OnEnable() { Ensure(); Rebuild(); }
void OnDisable() { if (_mr != null) _mr.enabled = false; }
void OnEnable() { Ensure(); WLDemoTerrain.Baked += OnTerrainBaked; Rebuild(); }
void OnDisable() { WLDemoTerrain.Baked -= OnTerrainBaked; if (_mr != null) _mr.enabled = false; }
/// <summary>데모 터레인이 실제로 다시 구워졌을 때(확장·밭 변화) 물가선을 다시 딴다.</summary>
void OnTerrainBaked() { if (isActiveAndEnabled) Rebuild(); }
void Ensure()
{
@ -95,6 +98,17 @@ namespace WL.Look.Farm
}
_mr.sharedMaterial = _inst;
// 🔴 2026-09-16 PD 「물이 섬 경계에 닿을 때 자연스러운 물보라」 — 데모 터레인 모드면 타일 발자국(네모) 대신
// **터레인 물가선**(지형 높이 = 물 높이)을 따라 띠를 두른다. 경사가 완만한 곳은 넓고 가파른 곳은 좁아 자연스럽다.
if (cfg.demoTerrainEnabled != 0)
{
var dt = Object.FindFirstObjectByType<WLDemoTerrain>(FindObjectsInactive.Include);
var terrain = dt != null ? dt.TerrainComponent : null;
if (terrain == null) { _mesh.Clear(); _mr.enabled = false; LastLog = "터레인 대기"; return; }
BuildFromTerrain(terrain);
return;
}
var tiles = CollectTiles();
Tiles = tiles.Count;
if (tiles.Count == 0) { _mesh.Clear(); _mr.enabled = false; LastLog = "타일 0"; return; }
@ -184,6 +198,139 @@ namespace WL.Look.Farm
}
// ─────────────────────────────────────────────────────────────────
// 데모 터레인 물가선 띠 — 격자마다 f = 물 높이 지형 높이(+ = 물속 깊이 · = 모래 위 높이).
// 띠 = f ∈ [shoreFoamUp, +shoreFoamDown] 에 걸치는 칸. 정점 높이 = max(지형, 물) + shoreFoamY → 모래 위에서는 모래를 덮고 물 위에서는 물을 덮는다.
// UV v = 0(모래 끝) → 1(바다 끝) — 기존 띠 텍스처와 같은 방향. u = 해안 방향 반복(shoreFoamTileLength).
// ─────────────────────────────────────────────────────────────────
public static int TerrainNodes;
void BuildFromTerrain(Terrain terrain)
{
float waterY = WaterYOfScene(terrain.gameObject.scene);
float up = Mathf.Max(0.01f, cfg.shoreFoamUp), down = Mathf.Max(0.01f, cfg.shoreFoamDown);
float cell = Mathf.Max(0.15f, cellOverride > 0f ? cellOverride : cfg.shoreFoamCell);
float lift = cfg.shoreFoamY;
float tileLen = Mathf.Max(0.5f, cfg.shoreFoamTileLength);
var tp = terrain.transform.position;
var sz = terrain.terrainData.size;
// 띠 텍스처 = 런타임 생성(물가선에서 진하고 모래 쪽은 짧게 · 바다 쪽은 길게 들쭉날쭉 사라진다) — 에셋 텍스처는 네모 띠(816j2)용이라 가장자리가 딱 끊긴다
var tex = FoamTexture(up / (up + down));
_inst.SetTexture(IdBase, tex);
if (_inst.HasProperty("_MainTex")) _inst.SetTexture("_MainTex", tex);
int nx = Mathf.Clamp(Mathf.CeilToInt(sz.x / cell) + 1, 2, 1024);
int nz = Mathf.Clamp(Mathf.CeilToInt(sz.z / cell) + 1, 2, 1024);
float stepX = sz.x / (nx - 1), stepZ = sz.z / (nz - 1);
var hgt = new float[nx * nz];
var f = new float[nx * nz];
var probe = new Vector3();
for (int j = 0; j < nz; j++)
{
probe.z = tp.z + j * stepZ;
for (int i = 0; i < nx; i++)
{
probe.x = tp.x + i * stepX;
float h = terrain.SampleHeight(probe) + tp.y;
int k = j * nx + i;
hgt[k] = h; f[k] = waterY - h;
}
}
TerrainNodes = nx * nz;
var vmap = new int[nx * nz];
for (int k = 0; k < vmap.Length; k++) vmap[k] = -1;
var verts = new List<Vector3>(2048);
var uvs = new List<Vector2>(2048);
var tris = new List<int>(4096);
for (int j = 0; j < nz - 1; j++)
for (int i = 0; i < nx - 1; i++)
{
int a = j * nx + i, b = a + 1, c = a + nx, dd = c + 1;
float lo = Mathf.Min(Mathf.Min(f[a], f[b]), Mathf.Min(f[c], f[dd]));
float hi = Mathf.Max(Mathf.Max(f[a], f[b]), Mathf.Max(f[c], f[dd]));
if (lo > down || hi < -up) continue;
int va = V(a), vb = V(b), vc = V(c), vd = V(dd);
tris.Add(va); tris.Add(vc); tris.Add(vb);
tris.Add(vb); tris.Add(vc); tris.Add(vd);
}
int V(int k)
{
if (vmap[k] >= 0) return vmap[k];
int i = k % nx, j = k / nx;
float x = tp.x + i * stepX, z = tp.z + j * stepZ;
verts.Add(new Vector3(x, Mathf.Max(hgt[k], waterY) + lift, z));
float v = Mathf.Clamp01((f[k] + up) / (down + up));
uvs.Add(new Vector2((x * 0.7071f + z * 0.7071f) / tileLen, v));
vmap[k] = verts.Count - 1;
return vmap[k];
}
_mesh.Clear();
if (verts.Count == 0 || tris.Count == 0) { _mr.enabled = false; LastLog = "물가선 띠 0(물 높이 " + waterY.ToString("F2") + ")"; return; }
_mesh.indexFormat = verts.Count > 65000
? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
_mesh.SetVertices(verts);
_mesh.SetUVs(0, uvs);
_mesh.SetTriangles(tris, 0);
_mesh.RecalculateBounds();
_mr.enabled = true;
Rebuilds++; Verts = verts.Count; Tris = tris.Count / 3; Tiles = 0;
LastLog = "터레인 물가선 띠 — 삼각형 " + Tris + " · 정점 " + Verts + " · 물 y " + waterY.ToString("F2")
+ " · 모래 쪽 " + up.ToString("F2") + " m ↑ · 바다 쪽 수심 " + down.ToString("F2") + " m · 격자 " + cell.ToString("F2") + " m · 드로우콜 +1";
cfg.Log("둘레 거품 띠 — " + LastLog);
}
static float SmoothStep01(float t) { t = Mathf.Clamp01(t); return t * t * (3f - 2f * t); }
// 물가선 띠 텍스처(런타임 · 에셋 0줄): u = 해안 방향(주기 1 · 반복) · v = 모래 끝(0) → 물가선(vWl) → 바다 끝(1).
// 알파 = 물가선 1 · 모래 쪽으로 짧게(제곱) · 바다 쪽으로 길게, 바다 쪽 끝은 해안 방향 파형으로 들쭉날쭉.
static Texture2D s_foamTex;
static float s_foamTexKey = float.NaN;
static Texture2D FoamTexture(float vWl)
{
vWl = Mathf.Clamp(vWl, 0.05f, 0.95f);
if (s_foamTex != null && Mathf.Abs(s_foamTexKey - vWl) < 0.001f) return s_foamTex;
const int W = 256, H = 64;
if (s_foamTex == null)
s_foamTex = new Texture2D(W, H, TextureFormat.RGBA32, false)
{ name = "WL_ShoreFoam(runtime)", hideFlags = HideFlags.DontSave, wrapMode = TextureWrapMode.Repeat, filterMode = FilterMode.Bilinear };
var px = new Color32[W * H];
for (int y = 0; y < H; y++)
{
float v = (y + 0.5f) / H;
float a = v < vWl ? Mathf.Pow(Mathf.Clamp01(v / vWl), 2f) : 1f - SmoothStep01((v - vWl) / (1f - vWl));
for (int x = 0; x < W; x++)
{
float u = (float)x / W;
float n = 0.5f + 0.5f * Mathf.Sin(u * 6.2832f * 3f + Mathf.Sin(u * 6.2832f * 7f + v * 3f) * 1.3f);
float edge = v > vWl ? Mathf.Clamp01(1f - (v - vWl) / (1f - vWl) * (0.6f + 0.8f * n)) : 1f;
float alpha = Mathf.Clamp01(a * edge * (0.75f + 0.25f * n));
px[y * W + x] = new Color32(255, 255, 255, (byte)(alpha * 255f));
}
}
s_foamTex.SetPixels32(px);
s_foamTex.Apply(false, false);
s_foamTexKey = vWl;
return s_foamTex;
}
/// <summary>물 평면의 높이(씬 기준) — `Water` 루트/자식을 찾고 없으면 설정값.</summary>
float WaterYOfScene(UnityEngine.SceneManagement.Scene scene)
{
if (!scene.IsValid()) return cfg.shoreFoamFallbackY;
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;
}
struct TileRef { public Transform tf; public Vector3 center; public WLTileMask mask; public float half; }
readonly List<TileRef> _tiles = new List<TileRef>(96);

View File

@ -70,6 +70,10 @@ namespace WL.Look.Farm
SceneManager.sceneLoaded += OnSceneLoaded;
SceneManager.sceneUnloaded += OnSceneUnloaded;
SceneManager.activeSceneChanged += OnActiveSceneChanged;
#if UNITY_EDITOR
// 🔴 SSAO 는 렌더러 **에셋**의 값이라 섬에서 Play 를 끄면 「꺼진 채」 메모리에 남는다 → Play 를 나갈 때 반드시 되돌린다.
UnityEditor.EditorApplication.playModeStateChanged += st => { if (st == UnityEditor.PlayModeStateChange.ExitingPlayMode) RestoreSsao(); };
#endif
// 🔴 아직 아무 맵도 안 붙은 지금이 「활성 씬의 원래 하늘」이다 — 여기서 한 번만 뜬다.
CaptureIfNeeded();
@ -77,7 +81,9 @@ namespace WL.Look.Farm
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var sc = SceneManager.GetSceneAt(i);
if (sc.isLoaded && IsScoped(cfg, sc.name)) Acquire(cfg, sc.name);
if (!sc.isLoaded) continue;
if (IsSsaoScoped(cfg, sc.name)) AcquireSsao(cfg, sc.name);
if (IsScoped(cfg, sc.name)) Acquire(cfg, sc.name);
}
}
@ -101,6 +107,7 @@ namespace WL.Look.Farm
{
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.skyScopeEnabled == 0) return;
if (IsSsaoScoped(cfg, scene.name)) AcquireSsao(cfg, scene.name);
if (!IsScoped(cfg, scene.name)) return;
Acquire(cfg, scene.name);
}
@ -109,7 +116,7 @@ namespace WL.Look.Farm
{
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.skyScopeEnabled == 0) return;
if (!Held) return;
if (!Held && !SsaoHeld) return;
// 🔴 맵을 바꿀 때 `SceneInfo` 는 **같은 프레임에** 내리기와 올리기를 함께 시작한다.
// 그래서 바로 되돌리면 「섬 → 던전」 에서 잘못 되돌아간다. 두 프레임 기다렸다
// **그때도 범위 맵이 하나도 없을 때만** 되돌린다.
@ -131,12 +138,64 @@ namespace WL.Look.Farm
static void ReleaseIfEmpty(WLIslandLookSettings cfg)
{
bool sky = false, ssao = false;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var sc = SceneManager.GetSceneAt(i);
if (sc.isLoaded && IsScoped(cfg, sc.name)) return; // 아직 섬/던전에 있다
if (!sc.isLoaded) continue;
if (IsScoped(cfg, sc.name)) sky = true; // 아직 섬/던전에 있다
if (IsSsaoScoped(cfg, sc.name)) ssao = true; // 아직 섬에 있다
}
Release(cfg);
if (!ssao) RestoreSsao();
if (!sky) Release(cfg);
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「풀이 얼룩덜룩 — 원인을 더 찾아라」 A/B 실측(같은 카메라 · 풀밭 450×290 px):
// SSAO(렌더러 피처 · 반지름 0.25 m) 가 풀잎 6만 장마다 발밑 그늘을 만들어 얼룩이 됐다 — 끄면 국소 질감 0.70 → 0.07(1/10),
// 구름 강도 0/0.15 · 풀잎 세로 명암 0 · 카메라 각도 35/65 는 전부 ±0.05 안(원인 아님).
// 렌더러 에셋은 전투 맵과 공유라(파일 0줄) **섬 씬이 떠 있는 동안만** 피처를 끄고 나갈 때 켠다.
// ─────────────────────────────────────────────────────────────────
public static bool SsaoHeld;
public static int SsaoOffCount, SsaoRestores;
static readonly System.Collections.Generic.List<UnityEngine.Rendering.Universal.ScriptableRendererFeature> s_ssaoOff =
new System.Collections.Generic.List<UnityEngine.Rendering.Universal.ScriptableRendererFeature>(2);
public static bool IsSsaoScoped(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.islandSsaoOff == 0 || cfg.ssaoOffScenes == null) return false;
for (int i = 0; i < cfg.ssaoOffScenes.Length; i++)
if (cfg.ssaoOffScenes[i] == sceneName) return true;
return false;
}
/// <summary>섬에 들어갈 때 — 켜져 있는 SSAO 피처(이름 · 형식명으로 판별 · URP 클래스는 internal)를 끄고 기억한다.</summary>
public static void AcquireSsao(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.islandSsaoOff == 0) return;
var feats = Resources.FindObjectsOfTypeAll<UnityEngine.Rendering.Universal.ScriptableRendererFeature>();
for (int i = 0; i < feats.Length; i++)
{
var f = feats[i];
if (f == null || !f.isActive) continue;
if (f.GetType().Name != "ScreenSpaceAmbientOcclusion" && f.name != "SSAO") continue;
if (s_ssaoOff.Contains(f)) continue;
f.SetActive(false);
s_ssaoOff.Add(f);
SsaoOffCount++;
}
SsaoHeld = s_ssaoOff.Count > 0;
if (SsaoHeld) cfg.Log("SSAO 끔 — " + sceneName + " 동안 · 피처 " + s_ssaoOff.Count + "개(나갈 때 복원)");
}
/// <summary>섬을 나갈 때(또는 에디터 Play 종료) — 우리가 끈 피처만 다시 켠다.</summary>
public static void RestoreSsao()
{
if (s_ssaoOff.Count == 0) { SsaoHeld = false; return; }
for (int i = 0; i < s_ssaoOff.Count; i++) if (s_ssaoOff[i] != null) s_ssaoOff[i].SetActive(true);
SsaoRestores += s_ssaoOff.Count;
s_ssaoOff.Clear();
SsaoHeld = false;
}
// ─────────────────────────────────────────────────────────────────