// ───────────────────────────────────────────────────────────────────────────── // WLShoreFoam.cs — 섬 **둘레**를 따라 흰 거품 띠를 두른다 (WL-816j2 · #816) // // 왜 필요한가 (816j 실측) // 셰이더의 깊이 기반 Foam 은 **카메라가 보는 쪽 가장자리에만** 거품이 생긴다 // (섬이 수직 벽 덩어리라 시선이 물을 뚫고 잠긴 벽에 닿는 쪽만 「얕은 물」로 읽힌다). // PD 가 「둘레에 띠를 둘러줘」로 결정 → 섬 윗면 footprint 의 **바깥 테두리**를 따라 // 메시 띠를 하나 만들어 물 위에 깐다. 사방 전부에 생기고, 카메라 방향과 무관하다. // // 비용 // 메시 1장 · 머티리얼 1개 · **드로우콜 +1**. 섬 타일 4칸 기준 삼각형 1천 대. // 비싸면 `shoreFoamCell`(격자 간격)을 키우거나 `shoreFoamWidth`(폭)를 줄인다. // // 섬 확장 // `WLIslandLook.RequestIslandRebuild` 가 풀과 함께 이 띠도 다시 만든다 // (816e 확장 훅 = `FIIsland.OnActivated` + 잠금 해제 수 폴링) → 새 가장자리 자동. // // 🔴 FI 코드 0줄 · 씬 파일 0줄 · 원본 셰이더 0줄. 런타임에 메시만 만든다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using FIIsland = CryingSnow.FarmingIsland.Island; namespace WL.Look.Farm { [DisallowMultipleComponent] public sealed class WLShoreFoam : MonoBehaviour { public const string ObjectName = "~WL_ShoreFoam"; public static int Rebuilds, Verts, Tris, Tiles; public static string LastLog = ""; public WLIslandLookSettings cfg; // 검증용 런타임 오버라이드 — 0/음수면 설정값을 쓴다. 🔴 에셋을 고치지 않고 3단 비교를 하기 위한 것. [System.NonSerialized] public float widthOverride = 0f; [System.NonSerialized] public float innerOverride = -1f; [System.NonSerialized] public float cellOverride = 0f; MeshFilter _mf; MeshRenderer _mr; Mesh _mesh; float _scroll; 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() { if (_mf == null) _mf = GetComponent(); if (_mf == null) _mf = gameObject.AddComponent(); if (_mr == null) _mr = GetComponent(); if (_mr == null) _mr = gameObject.AddComponent(); _mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; _mr.receiveShadows = false; _mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; _mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; _mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; if (_mesh == null) { _mesh = new Mesh { name = "WL_ShoreFoamMesh" }; _mesh.MarkDynamic(); _mesh.hideFlags = HideFlags.DontSave; } _mf.sharedMesh = _mesh; } /// 거품이 해안을 따라 천천히 흐른다. 🔴 에셋이 아니라 **런타임 복사본**의 오프셋만 만진다. void Update() { if (cfg == null || _mr == null || _inst == null) return; float sp = cfg.shoreFoamScroll; if (sp == 0f) return; _scroll += Time.deltaTime * sp; if (_scroll > 1f) _scroll -= 1f; // 🔴 2026-09-16 PD 「각진 부분 사이의 사선」 이분 실측: v(띠 가로) 방향 스크롤이 물가선 알파 프로파일을 띠 밖으로 밀어 모래 위에 계단·사선 // 모양의 흰 덩어리를 만들었다(스크롤 0 이면 즉시 매끈). 물가선 모드에서는 **해안 방향(u)만** 흐른다. 네모 띠(816j2)는 예전 그대로. bool waterline = cfg.demoTerrainEnabled != 0; _inst.SetTextureOffset(IdBase, new Vector2(_scroll, waterline ? 0f : _scroll * 0.37f)); } Material _inst; static readonly int IdBase = Shader.PropertyToID("_BaseMap"); // ───────────────────────────────────────────────────────────────── public void Rebuild() { Ensure(); if (cfg == null) cfg = WLIslandLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || cfg.shoreFoamEnabled == 0) { _mr.enabled = false; LastLog = "꺼짐"; return; } if (cfg.shoreFoamMaterial == null) { _mr.enabled = false; LastLog = "🔴 머티리얼 없음"; return; } if (_inst == null || _inst.shader != cfg.shoreFoamMaterial.shader) { if (_inst != null) Destroy(_inst); _inst = new Material(cfg.shoreFoamMaterial) { name = "WL_ShoreFoam(runtime)", hideFlags = HideFlags.DontSave }; } _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; } float cell = Mathf.Max(0.15f, cellOverride > 0f ? cellOverride : cfg.shoreFoamCell); float w = Mathf.Max(0.1f, widthOverride > 0f ? widthOverride : cfg.shoreFoamWidth); float inner = Mathf.Max(0f, innerOverride >= 0f ? innerOverride : cfg.shoreFoamInner); float pad = w + inner + cell * 2f; // 섬 전체를 덮는 격자 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]; float h = t.half; minX = Mathf.Min(minX, t.center.x - h); maxX = Mathf.Max(maxX, t.center.x + h); minZ = Mathf.Min(minZ, t.center.z - h); maxZ = Mathf.Max(maxZ, t.center.z + h); } minX -= pad; minZ -= pad; maxX += pad; maxZ += pad; int nx = Mathf.Clamp(Mathf.CeilToInt((maxX - minX) / cell) + 1, 2, 1024); int nz = Mathf.Clamp(Mathf.CeilToInt((maxZ - minZ) / cell) + 1, 2, 1024); // ① 안/밖 var inside = new bool[nx * nz]; for (int j = 0; j < nz; j++) { float z = minZ + j * cell; for (int i = 0; i < nx; i++) { float x = minX + i * cell; inside[j * nx + i] = OnAnyTile(tiles, x, z); } } // ② 부호 있는 거리(챔퍼 2패스) — 밖은 +, 안은 − var d = Chamfer(inside, nx, nz, cell, false); // 밖 → 가장 가까운 안까지 var e = Chamfer(inside, nx, nz, cell, true); // 안 → 가장 가까운 밖까지 var sd = new float[nx * nz]; for (int k = 0; k < sd.Length; k++) sd[k] = inside[k] ? -e[k] : d[k]; // ③ 띠 구간([-inner, w])에 걸치는 칸만 삼각형으로 float y = WaterY(tiles) + cfg.shoreFoamY; float tileLen = Mathf.Max(0.5f, cfg.shoreFoamTileLength); var vmap = new int[nx * nz]; for (int k = 0; k < vmap.Length; k++) vmap[k] = -1; var verts = new List(4096); var uvs = new List(4096); var tris = new List(8192); 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(sd[a], sd[b]), Mathf.Min(sd[c], sd[dd])); float hi = Mathf.Max(Mathf.Max(sd[a], sd[b]), Mathf.Max(sd[c], sd[dd])); if (lo > w || hi < -inner) 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 = minX + i * cell, z = minZ + j * cell; verts.Add(new Vector3(x, y, z)); float v = Mathf.Clamp01((sd[k] + inner) / (w + inner)); 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"; 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; LastLog = "타일 " + Tiles + " · 띠 삼각형 " + Tris + " · 정점 " + Verts + " · 폭 " + w.ToString("F2") + " m · 격자 " + cell.ToString("F2") + " m · 드로우콜 +1"; cfg.Log("둘레 거품 띠 — " + LastLog); } // ───────────────────────────────────────────────────────────────── // 데모 터레인 물가선 띠 — 격자마다 f = 물 높이 − 지형 높이(+ = 물속 깊이 · − = 모래 위 높이). // 띠 = f ∈ [−shoreFoamUp, +shoreFoamDown] 에 걸치는 칸. 정점 높이 = max(지형, 물) + shoreFoamY → 모래 위에서는 모래를 덮고 물 위에서는 물을 덮는다. // UV v = 0(모래 끝) → 1(바다 끝) — 기존 띠 텍스처와 같은 방향. u = 해안 방향 반복(shoreFoamTileLength). // ───────────────────────────────────────────────────────────────── public static int TerrainNodes, WaterlinePoints; 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 under = new bool[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; under[k] = h < waterY; } } TerrainNodes = nx * nz; // 🔴 2026-09-16 PD 「섬 가장자리가 각졌다」 실측: 높이 차(f)로 띠를 정하면 물 높이 근처가 평평한 곳에서 띠가 통째로 넓어져 // 흰 계단 덩어리가 됐다. → 물가선까지의 **수평 거리**(격자 챔퍼 · + 물 쪽 / − 모래 쪽)로 띠를 정한다 = 어디서나 같은 폭. var dToWater = Chamfer(under, nx, nz, cell, false); // 모래 노드 → 가장 가까운 물속 노드까지(m · 거친 선별용) var dToSand = Chamfer(under, nx, nz, cell, true); // 물속 노드 → 가장 가까운 모래 노드까지(m · 거친 선별용) // 🔴 2026-09-16 PD 「각진 부분 사이의 사선」 실측: 챔퍼 거리의 등고선은 축 방향 + 45° 사선의 **팔각형**이라 띠 가장자리가 그 모양 그대로 // 계단·사선으로 보였다(물 셰이더 교체·높이맵 257 로도 불변 → 거품 띠가 원인). 물가선 교점(격자 변에서 물 높이를 가로지르는 점)을 모아 // 띠 근처 노드만 **진짜 유클리드 거리**로 다시 잰다 → 등고선이 매끈한 곡선. var wlx = new List(1024); var wlz = new List(1024); for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) { int k = j * nx + i; float x = tp.x + i * stepX, z = tp.z + j * stepZ; if (i + 1 < nx && under[k] != under[k + 1]) { float t = Mathf.Clamp01((waterY - hgt[k]) / (hgt[k + 1] - hgt[k])); wlx.Add(x + t * stepX); wlz.Add(z); } if (j + 1 < nz && under[k] != under[k + nx]) { float t = Mathf.Clamp01((waterY - hgt[k]) / (hgt[k + nx] - hgt[k])); wlx.Add(x); wlz.Add(z + t * stepZ); } } WaterlinePoints = wlx.Count; float reach = Mathf.Max(up, down) + cell * 2f; var f = new float[nx * nz]; for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) { int k = j * nx + i; float dc = under[k] ? dToSand[k] : dToWater[k]; if (dc > reach || wlx.Count == 0) { f[k] = under[k] ? reach + 1f : -(reach + 1f); continue; } float x = tp.x + i * stepX, z = tp.z + j * stepZ, best = float.MaxValue; for (int q = 0; q < wlx.Count; q++) { float dx = x - wlx[q], dz = z - wlz[q]; float d2 = dx * dx + dz * dz; if (d2 < best) best = d2; } float d = Mathf.Sqrt(best); f[k] = under[k] ? d : -d; } 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") + " · 물가선 점 " + WaterlinePoints + " · 모래 쪽 " + 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]; // 🔴 2026-09-16 실측(남서 물가): 바다 쪽 끝까지 알파가 남아 메시 경계(격자 칸)가 네모 계단으로 보였다 → 바다 쪽은 제곱으로 빨리 꺼지고 // (띠 폭의 60 % 지점에서 ≈ 5 %) 물가선 최대 알파도 0.75 로 낮춘다(데모의 옅은 물보라). 메시 끝(v=1)은 0. for (int y = 0; y < H; y++) { float v = (y + 0.5f) / H; float a = v < vWl ? Mathf.Pow(Mathf.Clamp01(v / vWl), 2f) : Mathf.Pow(1f - SmoothStep01((v - vWl) / (1f - vWl)), 2.2f); 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.8f + 0.9f * n)) : 1f; float alpha = Mathf.Clamp01(a * edge * (0.7f + 0.3f * n)) * 0.75f; 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); List CollectTiles() { _tiles.Clear(); var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); for (int i = 0; i < islands.Length; i++) { var isl = islands[i]; if (isl == null || !isl.IsUnlocked) continue; if (!isl.gameObject.activeInHierarchy) continue; if (isl.transform.localScale.x < 0.99f) continue; // 확장 애니메이션 중 var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; var mask = cfg.FindTileMask(mf.sharedMesh.name); if (mask == null) continue; _tiles.Add(new TileRef { tf = isl.transform, center = isl.transform.position, mask = mask, half = mask.half }); } return _tiles; } static bool OnAnyTile(List tiles, float wx, float wz) { for (int i = 0; i < tiles.Count; i++) { var t = tiles[i]; var l = t.tf.InverseTransformPoint(new Vector3(wx, t.center.y, wz)); if (t.mask.At(l.x, l.z)) return true; } return false; } /// 물 평면의 높이 — 섬 씬의 `Water` 를 찾고 없으면 설정값. float WaterY(List tiles) { var scene = tiles.Count > 0 ? tiles[0].tf.gameObject.scene : gameObject.scene; var roots = scene.GetRootGameObjects(); for (int i = 0; i < roots.Length; i++) { var t = roots[i].transform.Find("Water"); if (t != null) return t.position.y; if (roots[i].name == "Water") return roots[i].transform.position.y; } return cfg.shoreFoamFallbackY; } /// /// 챔퍼 거리 변환 — seed 가 true 인 칸에서의 거리(m). /// 816zd 가 풀 배치의 둥근 윤곽(침식→팽창)에도 그대로 쓴다 — 거리 정의를 한 곳에만 둔다. /// public static float[] Chamfer(bool[] inside, int nx, int nz, float cell, bool seedOutside) { const float BIG = 1e9f; var d = new float[nx * nz]; for (int k = 0; k < d.Length; k++) { bool seed = seedOutside ? !inside[k] : inside[k]; d[k] = seed ? 0f : BIG; } float a = cell, b = cell * 1.41421356f; for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) { int k = j * nx + i; float m = d[k]; if (i > 0) m = Mathf.Min(m, d[k - 1] + a); if (j > 0) m = Mathf.Min(m, d[k - nx] + a); if (i > 0 && j > 0) m = Mathf.Min(m, d[k - nx - 1] + b); if (i < nx - 1 && j > 0) m = Mathf.Min(m, d[k - nx + 1] + b); d[k] = m; } for (int j = nz - 1; j >= 0; j--) for (int i = nx - 1; i >= 0; i--) { int k = j * nx + i; float m = d[k]; if (i < nx - 1) m = Mathf.Min(m, d[k + 1] + a); if (j < nz - 1) m = Mathf.Min(m, d[k + nx] + a); if (i < nx - 1 && j < nz - 1) m = Mathf.Min(m, d[k + nx + 1] + b); if (i > 0 && j < nz - 1) m = Mathf.Min(m, d[k + nx - 1] + b); d[k] = m; } return d; } } }