From e66bb838f676dc87191734fff446bc3c71c19065 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 16 Sep 2026 21:44:59 +0900 Subject: [PATCH] =?UTF-8?q?[WL]=20=EC=84=AC=20=EB=A3=A9=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=8B=B1=206=EA=B1=B4+1=20=E2=80=94=20=ED=92=80=20?= =?UTF-8?q?=EC=96=BC=EB=A3=A9=20=EC=9B=90=EC=9D=B8=20=3D=20SSAO(=EC=84=AC?= =?UTF-8?q?=20=EB=8F=99=EC=95=88=EB=A7=8C=20=EB=81=94)=20=C2=B7=20?= =?UTF-8?q?=ED=95=B4=EB=B3=80=20=EA=B2=BD=EC=82=AC=205=20m=20=EA=B5=B4?= =?UTF-8?q?=EA=B3=A1=20=C2=B7=20=EB=AC=BC=EA=B0=80=EC=84=A0=20=EA=B1=B0?= =?UTF-8?q?=ED=92=88=20=EB=9D=A0=20=C2=B7=20=EB=B0=9C=ED=8C=90=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EB=B9=84=EC=9A=A9=20=EC=A0=9C=EA=B1=B0=C2=B7?= =?UTF-8?q?=EC=84=B8=EC=9B=80=20=C2=B7=20=ED=83=9C=EC=96=91=201.15=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EB=A6=84=200.5/=EB=B0=9D=EA=B8=B0=200.12/?= =?UTF-8?q?=EB=91=94=EB=8D=95=200.5(=EC=BD=9C=EB=9D=BC=EC=9D=B4=EB=8D=94)?= =?UTF-8?q?=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Assets/WL/Island/WLIslandSettings.cs | 12 +- Assets/WL/Island/WLIslandUi.cs | 16 +- .../Resources/WL/WLIslandLookSettings.asset | 31 +- Assets/WL/Look/Farm/WLDemoTerrain.cs | 272 ++++++++++++++++-- Assets/WL/Look/Farm/WLIslandLook.cs | 34 ++- Assets/WL/Look/Farm/WLIslandLookSettings.cs | 79 ++++- Assets/WL/Look/Farm/WLShoreFoam.cs | 151 +++++++++- Assets/WL/Look/Farm/WLSkyScope.cs | 67 ++++- 8 files changed, 603 insertions(+), 59 deletions(-) diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs index 01edda2ab..1bb7d35c7 100644 --- a/Assets/WL/Island/WLIslandSettings.cs +++ b/Assets/WL/Island/WLIslandSettings.cs @@ -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[] diff --git a/Assets/WL/Island/WLIslandUi.cs b/Assets/WL/Island/WLIslandUi.cs index ce61bc989..63f643d8a 100644 --- a/Assets/WL/Island/WLIslandUi.cs +++ b/Assets/WL/Island/WLIslandUi.cs @@ -107,18 +107,28 @@ namespace WL.Island var rt = tmp.GetComponent(); if (rt != null) rt.sizeDelta = new Vector2(6f, 1.4f); - go.AddComponent(); + var bb = go.AddComponent(); + bb.upright = cfg == null || cfg.platformLabelUpright != 0; } } - /// 이름표가 항상 카메라를 본다. + /// + /// 이름표가 항상 카메라를 본다. + /// 🔴 2026-09-16 PD 「글자를 바닥 판과 90° 로 세워 플레이어 기준 정면으로」 → `upright` 면 **세운 채** 카메라 쪽 방향(수평)만 따라 돈다. + /// 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); } } diff --git a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset index 45052219b..e1cb91117 100644 --- a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset +++ b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset @@ -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} diff --git a/Assets/WL/Look/Farm/WLDemoTerrain.cs b/Assets/WL/Look/Farm/WLDemoTerrain.cs index d9d486990..3c0d943e0 100644 --- a/Assets/WL/Look/Farm/WLDemoTerrain.cs +++ b/Assets/WL/Look/Farm/WLDemoTerrain.cs @@ -56,6 +56,12 @@ namespace WL.Look.Farm public WLIslandLookSettings cfg; + /// 굽기가 끝난 직후(값이 바뀌어 실제로 다시 구운 때만) — 물가 거품 띠(WLShoreFoam)가 물가선을 다시 딴다. + public static event System.Action Baked; + + /// 지금 표시 중인 터레인(없거나 숨김이면 null). 물가 거품 띠가 높이를 읽는다. + 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(); } /// 다시 만든다(섬 확장·밭 재구성·재훑기). 멱등 — 발자국·구역·값이 그대로면 아무것도 하지 않는다. @@ -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 _flatX = new List(8), _flatZ = new List(8), _flatR = new List(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(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; + } + + /// 둔덕 봉투 0~1 — 잔디 경계 근처 · 밭 구역 · 평평 자리 둘레에서 0. + 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; + } + + /// + /// 발자국 안(타일 네모 · s ≤ 0 인 격자점만)의 둔덕 높이를 따르는 MeshCollider — 플레이어가 둔덕 위를 걷는다. + /// 격자 ≥ 0.5 m(16 m 섬 ≈ 2천 삼각형). 레이어는 FI 타일과 같게(FI 광선 판정 불변). 발자국 밖(해변)은 콜라이더 없음 = 가장자리 동작 불변. + /// + 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(); + if (_reliefCol == null) _reliefCol = _relief.AddComponent(); + } + 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(nx * nz); + var vmap = new int[nx * nz]; + var tris = new List(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; } - /// 경계 굽이 — 값 노이즈(격자 skirtWaveCell · 겹선형 · 결정적) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식). + /// 값 노이즈 −1~+1(격자 cell · 겹선형 · 결정적). seed 0 = 경계 굽이(WLShoreSkirt 와 같은 식) · 다른 seed 는 격자를 옮겨 독립된 무늬. + 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; + } + + /// 경계 굽이 — 값 노이즈(격자 skirtWaveCell) × 진폭. 직선 변을 파도처럼 굽이치게 한다(WLShoreSkirt 와 같은 식). 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; } /// @@ -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; } } diff --git a/Assets/WL/Look/Farm/WLIslandLook.cs b/Assets/WL/Look/Farm/WLIslandLook.cs index 2e86ca8f6..1a7146bd3 100644 --- a/Assets/WL/Look/Farm/WLIslandLook.cs +++ b/Assets/WL/Look/Farm/WLIslandLook.cs @@ -563,17 +563,39 @@ namespace WL.Look.Farm } var lights = Object.FindObjectsByType(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; - l.color = cfg.dirLightColor; - l.intensity = cfg.dirLightIntensity; - if (cfg.dirLightSoftShadows != 0 && l.shadows != LightShadows.None) l.shadows = LightShadows.Soft; - RenderSettings.sun = l; - LightingApplied++; + 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(); + 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++; } // ───────────────────────────────────────────────────────────────── diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index 3aee9e568..f18602380 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -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; diff --git a/Assets/WL/Look/Farm/WLShoreFoam.cs b/Assets/WL/Look/Farm/WLShoreFoam.cs index e77d5c4f0..44380b3a4 100644 --- a/Assets/WL/Look/Farm/WLShoreFoam.cs +++ b/Assets/WL/Look/Farm/WLShoreFoam.cs @@ -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; } + + /// 데모 터레인이 실제로 다시 구워졌을 때(확장·밭 변화) 물가선을 다시 딴다. + 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(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(2048); + var uvs = new List(2048); + var tris = new List(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; + } + + /// 물 평면의 높이(씬 기준) — `Water` 루트/자식을 찾고 없으면 설정값. + 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 _tiles = new List(96); diff --git a/Assets/WL/Look/Farm/WLSkyScope.cs b/Assets/WL/Look/Farm/WLSkyScope.cs index eabb16837..49eef2422 100644 --- a/Assets/WL/Look/Farm/WLSkyScope.cs +++ b/Assets/WL/Look/Farm/WLSkyScope.cs @@ -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 s_ssaoOff = + new System.Collections.Generic.List(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; + } + + /// 섬에 들어갈 때 — 켜져 있는 SSAO 피처(이름 · 형식명으로 판별 · URP 클래스는 internal)를 끄고 기억한다. + public static void AcquireSsao(WLIslandLookSettings cfg, string sceneName) + { + if (cfg == null || cfg.islandSsaoOff == 0) return; + var feats = Resources.FindObjectsOfTypeAll(); + 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 + "개(나갈 때 복원)"); + } + + /// 섬을 나갈 때(또는 에디터 Play 종료) — 우리가 끈 피처만 다시 켠다. + 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; } // ─────────────────────────────────────────────────────────────────