From b23489def941422f0b709fcd75833020248b64e1 Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 15 Sep 2026 02:09:26 +0900 Subject: [PATCH] =?UTF-8?q?[WL-816o]=20=EC=84=AC=20=ED=92=80=EB=B0=AD=20?= =?UTF-8?q?=3D=20=EA=B5=AC=EB=A6=84=20=EA=B2=BD=EA=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PD 「데모와 같은 비주얼 · 잔디를 많이 심지 않는 최적화 로직(구름 그림자 기준 배치)」 - WLCloudShadow.cs(신규) — 데모 Toon 셰이더의 구름 그림자를 CPU 에서 같은 식으로 재현. CloudShadows/ToonLighting/ToonRamp 서브그래프 + CloudNoise.hlsl(6 옥타브 FBM) + SimplexNoise3D.hlsl(Ashima snoise) 1:1 이식. 값은 전부 머티리얼에서 읽는다(C45). 노이즈는 성긴 격자(1 m)에서만 계산하고 판정 격자(0.3 m)는 겹선형 보간 → 굽기 2.2 ms. - WLIslandGrass.cs — 후보를 캐시하고, 띠 밖 후보를 잘라 낸다. 구름이 흐르면 RefreshCloudEdges() 로 「고르기」만 다시 한다(타일·제외 사각형 재수집 없음). - WLIslandLookSettings.cs — §2 값 9개(되돌리기 스위치 cloudEdgeEnabled=0 → 816n 상태). - WLIslandLook.cs — 기존 확장 감시 코루틴에 주기 갱신 1줄 추가. 실측(4타일 · PD 구도 12 m/45°/fov 60): 인스턴스 1,155 → 382(33.1%) · 삼각형 10,380 → 3,435(33.1%) · 드로우콜 +2 유지 · 띠 면적 16.8%(데모 실측 15.7%). 원본(3DPixelArtEnvironment · FarmingIsland · Script · Packages · ProjectSettings) 0줄. Co-Authored-By: Claude Opus 5 (1M context) --- AgentScripts/WL816o_Diag.cs | 172 ++++++++ AgentScripts/WL816o_Kw.cs | 28 ++ AgentScripts/WL816o_Play.cs | 280 +++++++++++++ AgentScripts/WL816o_img.py | 38 ++ Assets/WL/Look/Farm/WLCloudShadow.cs | 424 ++++++++++++++++++++ Assets/WL/Look/Farm/WLCloudShadow.cs.meta | 2 + Assets/WL/Look/Farm/WLIslandGrass.cs | 254 +++++++++--- Assets/WL/Look/Farm/WLIslandLook.cs | 11 + Assets/WL/Look/Farm/WLIslandLookSettings.cs | 41 ++ 9 files changed, 1190 insertions(+), 60 deletions(-) create mode 100644 AgentScripts/WL816o_Diag.cs create mode 100644 AgentScripts/WL816o_Kw.cs create mode 100644 AgentScripts/WL816o_Play.cs create mode 100644 AgentScripts/WL816o_img.py create mode 100644 Assets/WL/Look/Farm/WLCloudShadow.cs create mode 100644 Assets/WL/Look/Farm/WLCloudShadow.cs.meta diff --git a/AgentScripts/WL816o_Diag.cs b/AgentScripts/WL816o_Diag.cs new file mode 100644 index 000000000..e055d16ac --- /dev/null +++ b/AgentScripts/WL816o_Diag.cs @@ -0,0 +1,172 @@ +// WL-816o 진단 — 화면에 그려진 구름 띠 경계 ↔ CPU 가 계산한 띠 경계가 왜 어긋나는가. +public static class WL816o_Diag +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816oDiag"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816oDiag"); + go.AddComponent(); + } +} + +public class WL816o_DiagRunner : UnityEngine.MonoBehaviour +{ + const int TW = 512; + static System.Text.StringBuilder sb; + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + if (UnityEngine.SceneManagement.SceneManager.sceneCount < 2) + { + var op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("Level01", UnityEngine.SceneManagement.LoadSceneMode.Additive); + while (op != null && !op.isDone) yield return null; + } + for (int i = 0; i < 6; i++) yield return new UnityEngine.WaitForSeconds(1f); + + var g = UnityEngine.Object.FindFirstObjectByType(UnityEngine.FindObjectsInactive.Include); + var cfg = WL.Look.Farm.WLIslandLookSettings.Instance; + var mat = cfg.cloudEdgeSourceMaterial != null ? cfg.cloudEdgeSourceMaterial : cfg.islandTopMaterial; + + // 실제로 화면에 보이는 섬 윗면 렌더러가 무슨 머티리얼을 쓰는가 (런타임 교체 후) + var isls = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); + for (int i = 0; i < isls.Length && i < 3; i++) + { + var r = isls[i].GetComponent(); + L("Island[" + i + "] pos=" + isls[i].transform.position + " mats=" + (r ? Names(r) : "-")); + } + + var b = g.CalculateInstancesBounds(); + float size = UnityEngine.Mathf.Max(b.size.x, b.size.z) * 0.5f; + float cx = b.center.x, cz = b.center.z; + float y = isls.Length > 0 ? isls[0].transform.position.y : b.center.y; + L("bounds c=" + b.center + " size=" + b.size + " · ortho size=" + size + " · 윗면 y=" + y); + + bool was = g.enabled; g.enabled = false; + var cam = MakeTop(cx, cz, size); + var tex = GrabCam(cam, TW, TW); + g.enabled = was; + var px = tex.GetPixels32(); + + var f = new WL.Look.Farm.WLCloudShadowField(); + if (!f.Configure(mat)) { L("Configure 실패 " + f.Why); Flush(); yield break; } + L("field: " + f.Dump()); + L("_Time.y(global)=" + UnityEngine.Shader.GetGlobalVector("_Time").y + " timeSinceLevelLoad=" + UnityEngine.Time.timeSinceLevelLoad); + + // 화면 밝기 계단 확인 (초록 픽셀만) + var hist = new System.Collections.Generic.Dictionary(); + for (int i = 0; i < px.Length; i++) + { + var p = px[i]; + if (!(p.g > p.b + 12 && p.g > p.r + 8 && p.g > 60)) continue; + int l = Lum(p); if (!hist.ContainsKey(l)) hist[l] = 0; hist[l]++; + } + var keys = new System.Collections.Generic.List(hist.Keys); keys.Sort(); + var top = new System.Collections.Generic.List(); + for (int i = 0; i < keys.Count; i++) if (hist[keys[i]] > 2000) top.Add(keys[i] + "×" + hist[keys[i]]); + L("초록 밝기 계단(2000픽셀 이상) = " + string.Join(" ", top.ToArray()) + " (고유값 " + keys.Count + ")"); + + // 가운데 줄 — 화면 경계 위치 vs CPU 띠 경계 위치 (월드 X) + for (int r = 0; r < 3; r++) + { + int iy = 128 + r * 128; + float wz = cz + (((iy + 0.5f) / TW) - 0.5f) * 2f * size; + var scr = new System.Collections.Generic.List(); + var cpu = new System.Collections.Generic.List(); + int prevBand = int.MinValue; + for (int ix = 1; ix < TW - 1; ix++) + { + float wx = cx + (((ix + 0.5f) / TW) - 0.5f) * 2f * size; + var A = px[iy * TW + ix - 1]; var B = px[iy * TW + ix]; + if (Green(A) && Green(B)) { int d = Lum(B) - Lum(A); if (d < 0) d = -d; if (d >= 6 && d <= 45) scr.Add(wx.ToString("F2")); } + int bd = f.Band(wx, y, wz); + if (prevBand != int.MinValue && bd != prevBand) cpu.Add(wx.ToString("F2") + "(" + prevBand + "→" + bd + ")"); + prevBand = bd; + } + L("row z=" + wz.ToString("F2") + " 화면경계X=[" + string.Join(" ", scr.ToArray()) + "]"); + L(" CPU경계X=[" + string.Join(" ", cpu.ToArray()) + "] cloudiness(x=" + cx.ToString("F1") + ")=" + + f.Cloudiness(cx, y, wz).ToString("F4")); + } + + // 시간 오프셋 스캔 — 가장 잘 맞는 Δt 를 찾는다 + float t0 = f.TimeValue; + string best = ""; float bestScore = -1f, bestDt = 0f; + for (float dt = -12f; dt <= 12.01f; dt += 0.5f) + { + f.TimeValue = t0 + dt; + float s = Agree(px, f, cx, cz, size, y); + if (s > bestScore) { bestScore = s; bestDt = dt; } + if (System.Math.Abs(dt) < 0.01f) best = "Δt=0 일치율 " + (s * 100f).ToString("F1") + "%"; + } + f.TimeValue = t0; + L(best + " · 최적 Δt=" + bestDt.ToString("F1") + " 일치율 " + (bestScore * 100f).ToString("F1") + "%"); + + UnityEngine.Object.DestroyImmediate(tex); + Flush(); + } + + static float Agree(UnityEngine.Color32[] px, WL.Look.Farm.WLCloudShadowField f, float cx, float cz, float size, float y) + { + int shown = 0, matched = 0; + for (int iy = 8; iy < TW - 8; iy += 4) + { + float wz = cz + (((iy + 0.5f) / TW) - 0.5f) * 2f * size; + for (int ix = 2; ix < TW - 3; ix += 2) + { + var A = px[iy * TW + ix]; var B = px[iy * TW + ix + 1]; + if (!Green(A) || !Green(B)) continue; + int d = Lum(B) - Lum(A); if (d < 0) d = -d; + if (d < 6 || d > 45) continue; + shown++; + float w0 = cx + (((ix + 0.5f) / TW) - 0.5f) * 2f * size; + float w1 = cx + (((ix + 3.5f) / TW) - 0.5f) * 2f * size; + float wm = cx + (((ix - 1.5f) / TW) - 0.5f) * 2f * size; + int b0 = f.Band(wm, y, wz), b1 = f.Band(w1, y, wz); + if (b0 != b1) matched++; + _ = w0; + } + } + return shown == 0 ? 0f : (float)matched / shown; + } + + static bool Green(UnityEngine.Color32 p) { return p.g > p.b + 12 && p.g > p.r + 8 && p.g > 60; } + static int Lum(UnityEngine.Color32 p) { return (p.r * 77 + p.g * 150 + p.b * 29) >> 8; } + static string Names(UnityEngine.Renderer r) + { var m = r.sharedMaterials; var s = ""; for (int i = 0; i < m.Length; i++) s += (m[i] ? m[i].name : "-") + " "; return s; } + + static UnityEngine.Camera MakeTop(float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find("~816oTopD"); + if (go == null) go = new UnityEngine.GameObject("~816oTopD"); + var c = go.GetComponent(); if (c == null) c = go.AddComponent(); + c.orthographic = true; c.orthographicSize = size; + c.nearClipPlane = 0.1f; c.farClipPlane = 200f; + c.transform.rotation = UnityEngine.Quaternion.Euler(90f, 0f, 0f); + c.transform.position = new UnityEngine.Vector3(cx, 40f, cz); + c.enabled = false; + return c; + } + + static UnityEngine.Texture2D GrabCam(UnityEngine.Camera cam, int w, int h) + { + var rt = new UnityEngine.RenderTexture(w, h, 24, UnityEngine.RenderTextureFormat.ARGB32, UnityEngine.RenderTextureReadWrite.sRGB); + rt.Create(); cam.targetTexture = rt; cam.Render(); + var prev = UnityEngine.RenderTexture.active; UnityEngine.RenderTexture.active = rt; + var tex = new UnityEngine.Texture2D(w, h, UnityEngine.TextureFormat.RGB24, false); + tex.ReadPixels(new UnityEngine.Rect(0, 0, w, h), 0, 0); tex.Apply(); + UnityEngine.RenderTexture.active = prev; cam.targetTexture = null; + rt.Release(); UnityEngine.Object.DestroyImmediate(rt); + System.IO.Directory.CreateDirectory("Screenshots_WL/WL816o"); + System.IO.File.WriteAllBytes("Screenshots_WL/WL816o/diag_top.png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + return tex; + } + + static void L(string s) { sb.AppendLine(s); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816o_DIAG.txt", sb.ToString()); + UnityEngine.Debug.Log("[816o diag]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816o_Kw.cs b/AgentScripts/WL816o_Kw.cs new file mode 100644 index 000000000..169a37d45 --- /dev/null +++ b/AgentScripts/WL816o_Kw.cs @@ -0,0 +1,28 @@ +public static class WL816o_Kw +{ + public static void Run() + { + var sb = new System.Text.StringBuilder(); + string[] paths = { + "Assets/WL/Look/Farm/Materials/Farm_IslandTop_Arena.mat", + "Assets/WL/Look/Farm/Materials/Farm_Grass_Arena.mat" }; + for (int k = 0; k < paths.Length; k++) + { + var m = UnityEditor.AssetDatabase.LoadAssetAtPath(paths[k]); + if (m == null) { sb.AppendLine(paths[k] + " = null"); continue; } + sb.AppendLine("== " + m.name); + sb.AppendLine(" IsKeywordEnabled(_CLOUDSENABLED) = " + m.IsKeywordEnabled("_CLOUDSENABLED")); + sb.AppendLine(" HasProperty(_CLOUDSENABLED) = " + m.HasProperty("_CLOUDSENABLED") + + (m.HasProperty("_CLOUDSENABLED") ? (" val=" + m.GetFloat("_CLOUDSENABLED")) : "")); + sb.AppendLine(" shaderKeywords = " + string.Join(",", m.shaderKeywords)); + var ek = m.enabledKeywords; + var names = new System.Collections.Generic.List(); + for (int i = 0; i < ek.Length; i++) names.Add(ek[i].name); + sb.AppendLine(" enabledKeywords = " + string.Join(",", names)); + sb.AppendLine(" _Cloud_Density=" + m.GetFloat("_Cloud_Density") + " _Cloud_Strength=" + m.GetFloat("_Cloud_Strength") + + " _Shades=" + m.GetFloat("_Shades") + " _Brightness=" + m.GetFloat("_Brightness")); + } + System.IO.File.WriteAllText("AgentScripts/WL816o_KW.txt", sb.ToString()); + UnityEngine.Debug.Log("[816o kw]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816o_Play.cs b/AgentScripts/WL816o_Play.cs new file mode 100644 index 000000000..5ca47755e --- /dev/null +++ b/AgentScripts/WL816o_Play.cs @@ -0,0 +1,280 @@ +// WL-816o — 구름 그림자 경계 배치: 전(816n) / 후 실측 + CPU 포팅 검증. +// 에셋은 건드리지 않는다 (cfg 는 Instantiate 복제본 · 816f/816n 방식). +public static class WL816o_Play +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816oPlay"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816oPlay"); + go.AddComponent(); + } +} + +public class WL816o_PlayRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int W = 1080, H = 1920; + const int TW = 512; + + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + if (UnityEngine.SceneManagement.SceneManager.sceneCount < 2) + { + var op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("Level01", UnityEngine.SceneManagement.LoadSceneMode.Additive); + while (op != null && !op.isDone) yield return null; + } + for (int i = 0; i < 7; i++) yield return new UnityEngine.WaitForSeconds(1f); + + var g = UnityEngine.Object.FindFirstObjectByType(UnityEngine.FindObjectsInactive.Include); + if (g == null) { L("🔴 WLIslandGrass 없음 — 중단"); Flush(); yield break; } + var src = WL.Look.Farm.WLIslandLookSettings.Instance; + var rt = UnityEngine.Object.Instantiate(src); // 에셋 무변경 + g.cfg = rt; + + var mat = rt.cloudEdgeSourceMaterial != null ? rt.cloudEdgeSourceMaterial : rt.islandTopMaterial; + L("바닥 머티리얼 = " + (mat ? mat.name : "-") + " · 셰이더 " + (mat && mat.shader ? mat.shader.name : "-")); + L("SO 값 : cell=" + rt.cloudEdgeCell + " maxCells=" + rt.cloudEdgeMaxCells + " dilate=" + rt.cloudEdgeDilate + + " driftCover=" + rt.cloudEdgeDriftCover + " refresh=" + rt.cloudEdgeRefreshSeconds + "s densityScale=" + rt.cloudEdgeDensityScale); + + var cam = MakeCam(); + L("카메라 " + cam.transform.position + " euler=" + cam.transform.eulerAngles + " fov=" + cam.fieldOfView + " (타깃까지 12 m · 45°)"); + + // ── BEFORE (816n 상태 = 균일 격자) ───────────────────────────── + rt.cloudEdgeEnabled = 0; + g.Rebuild(); yield return null; yield return null; + L(Shot(cam, "a_before", "BEFORE 816n(균일 격자)")); + L(" " + Stat() + " · " + Bench(cam, 60).ToString("F3") + " ms/frame"); + int bInst = WL.Look.Farm.WLIslandGrass.Instances; + long bTri = WL.Look.Farm.WLIslandGrass.Triangles; + int bDraw = WL.Look.Farm.WLIslandGrass.DrawnConfigs; + + // ── AFTER (구름 경계 배치) ───────────────────────────────────── + rt.cloudEdgeEnabled = 1; + g.Rebuild(); yield return null; yield return null; + L(Shot(cam, "b_after", "AFTER 816o(구름 경계 배치)")); + L(" " + Stat() + " · " + Bench(cam, 60).ToString("F3") + " ms/frame"); + int aInst = WL.Look.Farm.WLIslandGrass.Instances; + long aTri = WL.Look.Farm.WLIslandGrass.Triangles; + int aDraw = WL.Look.Farm.WLIslandGrass.DrawnConfigs; + L(" 로그: " + WL.Look.Farm.WLIslandGrass.LastLog); + + // 교대 2회 (측정 노이즈 확인) + rt.cloudEdgeEnabled = 0; g.Rebuild(); yield return null; + float b2 = Bench(cam, 60); + rt.cloudEdgeEnabled = 1; g.Rebuild(); yield return null; + float a2 = Bench(cam, 60); + L("[교대 2회] before=" + b2.ToString("F3") + " ms · after=" + a2.ToString("F3") + " ms"); + + L("[예산] 인스턴스 " + bInst + " → " + aInst + " (" + Pct(aInst, bInst) + ")" + + " · 삼각형 " + bTri + " → " + aTri + " (" + Pct((int)aTri, (int)bTri) + ")" + + " · 드로우콜 +" + bDraw + " → +" + aDraw + + " · 후보 " + WL.Look.Farm.WLIslandGrass.Candidates); + + // 주기 갱신 비용 + var sw = System.Diagnostics.Stopwatch.StartNew(); + g.RefreshCloudEdges(); + sw.Stop(); + L("[주기 갱신] RefreshCloudEdges 1회 = " + sw.Elapsed.TotalMilliseconds.ToString("F2") + " ms" + + " (굽기 " + WL.Look.Farm.WLIslandGrass.BakeMs.ToString("F2") + " · 고르기 " + WL.Look.Farm.WLIslandGrass.FilterMs.ToString("F2") + + ") → 인스턴스 " + WL.Look.Farm.WLIslandGrass.Instances); + + // ── CPU 포팅 검증 (셰이더가 그린 띠 경계 ↔ CPU 가 계산한 띠 경계) ── + L(Verify(g, rt, mat)); + + // 최종 상태 = 구름 경계 ON + rt.cloudEdgeEnabled = 1; g.Rebuild(); yield return null; + Shot(cam, "b_after", "final"); + TopShot(g, "t_after"); + rt.cloudEdgeEnabled = 0; g.Rebuild(); yield return null; + TopShot(g, "t_before"); + rt.cloudEdgeEnabled = 1; g.Rebuild(); yield return null; + + Flush(); + } + + // ─────────────────────────────────────────── 검증 + static string Verify(WL.Look.Farm.WLIslandGrass g, WL.Look.Farm.WLIslandLookSettings rt, UnityEngine.Material mat) + { + var f = new WL.Look.Farm.WLCloudShadowField(); + if (!f.Configure(mat)) return "[검증] 실패 — " + f.Why; + + var b = g.CalculateInstancesBounds(); + float size = UnityEngine.Mathf.Max(b.size.x, b.size.z) * 0.5f; + float cx = b.center.x, cz = b.center.z, y = b.center.y; + + bool was = g.enabled; + g.enabled = false; // 풀을 빼고 바닥만 본다 + var cam = MakeTop(cx, cz, size); + var tex = GrabCam(cam, TW, TW); + g.enabled = was; + + var px = tex.GetPixels32(); + // 픽셀 ↔ 월드 (ortho · Euler(90,0,0) → screen +x = world +x, screen +y = world +z) + var band = new int[TW * TW]; + var ok = new bool[TW * TW]; + for (int iy = 0; iy < TW; iy++) + { + float wz = cz + (((iy + 0.5f) / TW) - 0.5f) * 2f * size; + for (int ix = 0; ix < TW; ix++) + { + float wx = cx + (((ix + 0.5f) / TW) - 0.5f) * 2f * size; + int i = iy * TW + ix; + band[i] = f.Band(wx, y, wz); + var p = px[i]; + ok[i] = (p.g > p.b + 12 && p.g > p.r + 8 && p.g > 60); // 풀밭 초록만 + } + } + + // CPU 가 「여기가 경계」라고 한 곳에 화면에도 색 계단이 있는가 (이 방향이 포팅 검증이다) + int cpuEdges = 0, cpuHit = 0, flatTot = 0, flatHit = 0; + for (int iy = 1; iy < TW - 1; iy++) + { + for (int ix = 3; ix < TW - 4; ix++) + { + int i = iy * TW + ix; + if (!ok[i - 3] || !ok[i + 4]) continue; + if (band[i] != band[i + 1]) + { + cpuEdges++; + if (Stepy(px, i - 3, 7)) cpuHit++; + } + else if (band[i - 3] == band[i] && band[i] == band[i + 4]) + { + flatTot++; // CPU 가 평평하다고 한 곳 + if (Stepy(px, i - 3, 7)) flatHit++; // 그런데 화면에 계단이 있으면 오차 + } + } + } + UnityEngine.Object.DestroyImmediate(tex); + + string s = "[검증] CPU 가 띠 경계라 한 " + cpuEdges + "곳 중 화면에도 색 계단이 있는 것 = " + + (cpuEdges > 0 ? (100f * cpuHit / cpuEdges).ToString("F1") : "0") + "%" + + " · CPU 가 평평하다 한 " + flatTot + "곳 중 화면에 계단이 있는 것 = " + + (flatTot > 0 ? (100f * flatHit / flatTot).ToString("F1") : "0") + "% (낮을수록 좋다 · 타일·소품 경계 포함)"; + s += "\n (_Time.y=" + UnityEngine.Shader.GetGlobalVector("_Time").y.ToString("F3") + + " · timeSinceLevelLoad=" + UnityEngine.Time.timeSinceLevelLoad.ToString("F3") + + " · 띠비율=" + (WL.Look.Farm.WLIslandGrass.EdgeFraction * 100f).ToString("F1") + "%" + + " · 칸=" + WL.Look.Farm.WLIslandGrass.EdgeCellSize.ToString("F2") + "m×" + WL.Look.Farm.WLIslandGrass.EdgeCells + + " · 노이즈=" + WL.Look.Farm.WLIslandGrass.EdgeSampleCell.ToString("F2") + "m×" + WL.Look.Farm.WLIslandGrass.EdgeSamples + + " · 넓힘=" + WL.Look.Farm.WLIslandGrass.EdgeDilate + ")"; + return s; + } + + static int Lum(UnityEngine.Color32 p) { return (p.r * 77 + p.g * 150 + p.b * 29) >> 8; } + + /// i 부터 n 픽셀 안에 6~45 크기의 밝기 계단이 있는가. + static bool Stepy(UnityEngine.Color32[] px, int i, int n) + { + for (int k = 0; k < n - 1; k++) + { + int d = Lum(px[i + k + 1]) - Lum(px[i + k]); if (d < 0) d = -d; + if (d >= 6 && d <= 45) return true; + } + return false; + } + + static string Pct(int a, int b) { return b <= 0 ? "-" : ((100f * a / b).ToString("F1") + "%"); } + + static string Stat() + { + return "인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + + " 삼각형=" + WL.Look.Farm.WLIslandGrass.Triangles + + " 드로우콜+" + WL.Look.Farm.WLIslandGrass.DrawnConfigs + + " 타일=" + WL.Look.Farm.WLIslandGrass.Tiles; + } + + // ─────────────────────────────────────────── helpers + static UnityEngine.Camera MakeCam() + { + UnityEngine.Vector3 tgt = UnityEngine.Vector3.zero; + var pc = UnityEngine.GameObject.FindGameObjectWithTag("Player"); + if (pc != null) tgt = pc.transform.position; + var go = UnityEngine.GameObject.Find("~816oCam"); + if (go == null) go = new UnityEngine.GameObject("~816oCam"); + var c = go.GetComponent(); + if (c == null) c = go.AddComponent(); + c.fieldOfView = 60f; c.orthographic = false; + c.nearClipPlane = 0.1f; c.farClipPlane = 300f; + c.transform.rotation = UnityEngine.Quaternion.Euler(45f, 45f, 0f); + c.transform.position = tgt - c.transform.forward * 12f; + c.enabled = false; + return c; + } + + static UnityEngine.Camera MakeTop(float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find("~816oTop"); + if (go == null) go = new UnityEngine.GameObject("~816oTop"); + var c = go.GetComponent(); + if (c == null) c = go.AddComponent(); + c.orthographic = true; c.orthographicSize = size; + c.nearClipPlane = 0.1f; c.farClipPlane = 200f; + c.transform.rotation = UnityEngine.Quaternion.Euler(90f, 0f, 0f); + c.transform.position = new UnityEngine.Vector3(cx, 40f, cz); + c.enabled = false; + return c; + } + + static UnityEngine.Texture2D GrabCam(UnityEngine.Camera cam, int w, int h) + { + var rt = new UnityEngine.RenderTexture(w, h, 24, UnityEngine.RenderTextureFormat.ARGB32, UnityEngine.RenderTextureReadWrite.sRGB); + rt.Create(); cam.targetTexture = rt; cam.Render(); + var prev = UnityEngine.RenderTexture.active; UnityEngine.RenderTexture.active = rt; + var tex = new UnityEngine.Texture2D(w, h, UnityEngine.TextureFormat.RGB24, false); + tex.ReadPixels(new UnityEngine.Rect(0, 0, w, h), 0, 0); tex.Apply(); + UnityEngine.RenderTexture.active = prev; cam.targetTexture = null; + rt.Release(); UnityEngine.Object.DestroyImmediate(rt); + return tex; + } + + static void TopShot(WL.Look.Farm.WLIslandGrass g, string name) + { + var b = g.CalculateInstancesBounds(); + float size = UnityEngine.Mathf.Max(b.size.x, b.size.z) * 0.5f; + var cam = MakeTop(b.center.x, b.center.z, size); + var tex = GrabCam(cam, 768, 768); + System.IO.Directory.CreateDirectory("Screenshots_WL/WL816o"); + System.IO.File.WriteAllBytes("Screenshots_WL/WL816o/" + name + ".png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + UnityEngine.Object.DestroyImmediate(tex); + } + + static string Shot(UnityEngine.Camera cam, string name, string tag) + { + var tex = GrabCam(cam, W, H); + System.IO.Directory.CreateDirectory("Screenshots_WL/WL816o"); + System.IO.File.WriteAllBytes("Screenshots_WL/WL816o/" + name + ".png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + var px = tex.GetPixels32(); + long r = 0, gg = 0, bb = 0; int n = 0; + for (int i = 0; i < px.Length; i++) + { + var p = px[i]; + if (p.g > p.b + 12 && p.g > p.r + 8 && p.g > 60) { r += p.r; gg += p.g; bb += p.b; n++; } + } + UnityEngine.Object.DestroyImmediate(tex); + if (n == 0) return tag + " : 초록 픽셀 0"; + return string.Format("{0} : 풀밭 초록 픽셀 {1} ({2:F1}%) 평균 #{3:X2}{4:X2}{5:X2}", tag, n, 100f * n / px.Length, r / n, gg / n, bb / n); + } + + static float Bench(UnityEngine.Camera cam, int frames) + { + var rt = new UnityEngine.RenderTexture(W, H, 24, UnityEngine.RenderTextureFormat.ARGB32, UnityEngine.RenderTextureReadWrite.sRGB); + rt.Create(); cam.targetTexture = rt; + for (int i = 0; i < 10; i++) cam.Render(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < frames; i++) cam.Render(); + sw.Stop(); cam.targetTexture = null; + rt.Release(); UnityEngine.Object.DestroyImmediate(rt); + return (float)sw.Elapsed.TotalMilliseconds / frames; + } + + static void L(string s) { sb.AppendLine(s); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816o_PLAY.txt", sb.ToString()); + UnityEngine.Debug.Log("[816o play]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816o_img.py b/AgentScripts/WL816o_img.py new file mode 100644 index 000000000..9a272112b --- /dev/null +++ b/AgentScripts/WL816o_img.py @@ -0,0 +1,38 @@ +# WL-816o — 캡처 합성 (같은 높이로 맞춰 나란히) +import os +from PIL import Image, ImageDraw + +BASE = r"E:\NerdNavis\WL_wt\gameplay\Screenshots_WL" +OUT = os.path.join(BASE, "WL816o") +os.makedirs(OUT, exist_ok=True) + + +def strip(items, out, h=900, gap=10): + ims = [] + for path, label in items: + if not os.path.exists(path): + print("MISSING", path) + return + im = Image.open(path).convert("RGB") + w = int(im.width * h / im.height) + ims.append((im.resize((w, h), Image.LANCZOS), label)) + W = sum(i.width for i, _ in ims) + gap * (len(ims) - 1) + canvas = Image.new("RGB", (W, h + 34), (24, 24, 24)) + d = ImageDraw.Draw(canvas) + x = 0 + for im, label in ims: + canvas.paste(im, (x, 34)) + d.text((x + 8, 10), label, fill=(255, 255, 255)) + x += im.width + gap + canvas.save(out) + print("wrote", out, canvas.size) + + +strip([(os.path.join(BASE, "WL816n", "demo_A_baseline.png"), "DEMO (3DPixelArt)"), + (os.path.join(OUT, "a_before.png"), "BEFORE = 816n (uniform grid)"), + (os.path.join(OUT, "b_after.png"), "AFTER = 816o (cloud-edge only)")], + os.path.join(OUT, "a_demo_vs_before_after.png"), h=900) + +strip([(os.path.join(OUT, "t_before.png"), "TOP BEFORE (uniform grid)"), + (os.path.join(OUT, "t_after.png"), "TOP AFTER (cloud-edge only)")], + os.path.join(OUT, "c_top_before_after.png"), h=768) diff --git a/Assets/WL/Look/Farm/WLCloudShadow.cs b/Assets/WL/Look/Farm/WLCloudShadow.cs new file mode 100644 index 000000000..3ad86873b --- /dev/null +++ b/Assets/WL/Look/Farm/WLCloudShadow.cs @@ -0,0 +1,424 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCloudShadow.cs — 데모 Toon 셰이더의 「구름 그림자 띠」를 CPU 에서 **같은 식으로** 재현한다 +// (WL-816o · #816) +// +// ■ 왜 필요한가 (PD 지시 2026-09-15) +// 「데모와 같은 비주얼. 하지만 잔디를 많이 심어서 퍼포먼스에 영향을 주지 않는 방향으로 +// 최적화된 로직(예: 구름 그림자 기준으로만 배치)」 +// 816n 실측 — 데모 풀은 바닥과 거의 같은 색(밝기비 0.933)이라 **구름 그림자 경계에서만** +// 눈에 띈다(보이는 풀의 88 % 가 경계 띠 15.7 % 면적에 몰림). 즉 평평한 색 면 안쪽의 풀은 +// 삼각형만 먹고 화면에는 거의 기여하지 않는다 → **경계 띠에만 심으면 그림은 같고 비용은 준다**. +// +// ■ 무엇을 그대로 옮겼나 (원본 0줄 — 읽기만 했다) +// `Assets/3DPixelArtEnvironment/Shaders/Subgraphs/CloudShadows.shadersubgraph` +// worldPos ─▶ ShadowProjection(SunDir) ─▶ TilingAndOffset(offset = _Time.y × _Cloud_Movement) +// ─▶ CloudNoise(Scale=_Cloud_Density, VerticalSpeed=_Cloud_Change, +// Step=_Cloud_Step, Coverage=_Cloud_Cover, Time=_Time.y) +// ─▶ × _Cloud_Strength = Cloudiness +// `Subgraphs/ToonLighting.shadersubgraph` Lighting = step(ShadowAtten) − Cloudiness +// `Subgraphs/ToonRamp.shadersubgraph` t = saturate(ceil(Lighting × _Shades + _Brightness) / _Shades) +// → 섬 윗면은 **평면**이라 ShadowAtten·법선이 상수다. 그래서 화면에 보이는 색 띠의 경계는 +// 오로지 `ceil((1 − Cloudiness) × _Shades + _Brightness)` 가 바뀌는 지점이다. +// `Includes/CloudNoise.hlsl`(6 옥타브 FBM) · `Includes/SimplexNoise3D.hlsl`(Ashima snoise) 를 +// C# 으로 1:1 이식했다. +// +// ■ C45 — _Cloud_*, _Shades, _Brightness 는 전부 **머티리얼에서 읽는다**(코드 상수 0). +// 셰이더 값이 바뀌면 이 판정도 자동으로 따라간다. +// +// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.Farm +{ + /// + /// 구름 그림자의 「색 띠 경계」를 XZ 격자에 한 번 구워 두고, 점 하나가 경계 근처인지 O(1) 로 답한다. + /// + public sealed class WLCloudShadowField + { + // ── 머티리얼에서 읽은 값 ──────────────────────────────────────── + public bool Ready; + public bool CloudsOn; + public string Why = ""; + + float _scale, _cover, _strength, _change, _shades, _brightness; + float _movX, _movY, _stepX, _stepY; + float _sunX, _sunY, _sunZ; + float _time; + + // ── 구운 격자 ────────────────────────────────────────────────── + int[] _band; + byte[] _edge, _tmp; + float[] _smp; + int _nx, _nz; + float _minX, _minZ, _cell, _inv; + + public int Cells { get { return _nx * _nz; } } + public float CellSize { get { return _cell; } } + public int Samples; // 노이즈를 실제로 계산한 점 수(비용은 여기서만 난다) + public float SampleCellSize; + public float EdgeFraction; // 격자에서 경계로 잡힌 칸의 비율(예산 추정에 쓴다) + + /// 구름 값이 1초에 월드 몇 m 를 흐르는가 — 갱신 주기 ↔ 띠 폭을 맞추는 데 쓴다. + public float DriftPerSecond { get { return Mathf.Sqrt(_movX * _movX + _movY * _movY); } } + + // ───────────────────────────────────────────────────────────── + // ① 셰이더 값 읽기 + // ───────────────────────────────────────────────────────────── + public bool Configure(Material groundMat) + { + Ready = false; Why = ""; + if (groundMat == null) { Why = "바닥 머티리얼이 없다"; return false; } + if (!groundMat.HasProperty("_Cloud_Density")) { Why = "머티리얼에 _Cloud_* 가 없다(Toon 계열이 아니다)"; return false; } + + _scale = groundMat.GetFloat("_Cloud_Density"); + _cover = groundMat.HasProperty("_Cloud_Cover") ? groundMat.GetFloat("_Cloud_Cover") : 0.5f; + _strength = groundMat.HasProperty("_Cloud_Strength") ? groundMat.GetFloat("_Cloud_Strength") : 1f; + _change = groundMat.HasProperty("_Cloud_Change") ? groundMat.GetFloat("_Cloud_Change") : 0f; + + var mv = groundMat.HasProperty("_Cloud_Movement") ? groundMat.GetVector("_Cloud_Movement") : Vector4.zero; + _movX = mv.x; _movY = mv.y; + var st = groundMat.HasProperty("_Cloud_Step") ? groundMat.GetVector("_Cloud_Step") : Vector4.zero; + _stepX = st.x; _stepY = st.y; + + _shades = groundMat.HasProperty("_Shades") ? groundMat.GetFloat("_Shades") : 1f; + _brightness = groundMat.HasProperty("_Brightness") ? groundMat.GetFloat("_Brightness") : 0f; + if (_shades < 1f) _shades = 1f; + + // 🔴 실측(816o) — `_CLOUDSENABLED` 는 **전역 키워드**라 `Material.IsKeywordEnabled`(로컬 공간)로는 + // 항상 false 가 나온다. 머티리얼에 실제로 켜져 있는지는 `shaderKeywords` 배열로 봐야 한다. + CloudsOn = false; + var kws = groundMat.shaderKeywords; + for (int i = 0; i < kws.Length; i++) if (kws[i] == "_CLOUDSENABLED") { CloudsOn = true; break; } + if (!CloudsOn) + CloudsOn = groundMat.IsKeywordEnabled("_CLOUDSENABLED") + || (groundMat.HasProperty("_CLOUDSENABLED") && groundMat.GetFloat("_CLOUDSENABLED") > 0.5f); + if (!CloudsOn) { Why = "이 머티리얼은 구름이 꺼져 있다"; return false; } + if (_strength <= 0f || _scale <= 0f) { Why = "_Cloud_Strength/_Cloud_Density 가 0"; return false; } + + ResolveSun(); + ResolveTime(); + + Ready = true; + return true; + } + + void ResolveSun() + { + // URP `Main Light` = RenderSettings.sun(지정돼 있으면) · Direction = 빛으로 향하는 방향 = −forward + Light sun = RenderSettings.sun; + if (sun == null || !sun.isActiveAndEnabled || sun.type != LightType.Directional) + { + sun = null; + float best = -1f; + var lights = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + for (int i = 0; i < lights.Length; i++) + { + var l = lights[i]; + if (l == null || l.type != LightType.Directional || !l.isActiveAndEnabled) continue; + if (l.intensity > best) { best = l.intensity; sun = l; } + } + } + Vector3 d = sun != null ? -sun.transform.forward : Vector3.up; + _sunX = d.x; _sunY = d.y; _sunZ = d.z; + } + + void ResolveTime() + { + // 셰이더가 실제로 쓰는 _Time.y 를 우선 읽는다. 못 읽으면(0) 레벨 로드 후 경과 시간. + var t = Shader.GetGlobalVector("_Time"); + _time = (t.y != 0f) ? t.y : Time.timeSinceLevelLoad; + } + + /// 시간만 다시 읽는다(주기 갱신용 — 머티리얼·태양은 그대로). + public void Tick() { ResolveSun(); ResolveTime(); } + + /// 셰이더가 쓰는 `_Time.y`. 진단·검증에서 직접 넣어 볼 수 있다. + public float TimeValue { get { return _time; } set { _time = value; } } + + public Vector3 SunDir { get { return new Vector3(_sunX, _sunY, _sunZ); } } + public string Dump() + { + return "scale=" + _scale + " cover=" + _cover + " strength=" + _strength + " change=" + _change + + " mov=(" + _movX + "," + _movY + ") step=(" + _stepX + "," + _stepY + ")" + + " shades=" + _shades + " brightness=" + _brightness + + " sun=(" + _sunX.ToString("F3") + "," + _sunY.ToString("F3") + "," + _sunZ.ToString("F3") + ")" + + " time=" + _time.ToString("F3"); + } + + // ───────────────────────────────────────────────────────────── + // ② 셰이더와 같은 식 + // ───────────────────────────────────────────────────────────── + /// CloudShadows 서브그래프의 최종 출력 `Cloudiness`. + public float Cloudiness(float wx, float wy, float wz) + { + // ShadowProjection.hlsl — 그림자가 바닥에 떨어지는 지점 + float m = (Mathf.Abs(_sunY) > 1e-4f) ? (wy / -_sunY) : 0f; + float px = wx + m * _sunX; + float pz = wz + m * _sunZ; + + // TilingAndOffset (Tiling = 1) — offset = _Time.y × _Cloud_Movement + float ux = px + _time * _movX; + float uy = pz + _time * _movY; + + // CloudNoise.hlsl — 6 옥타브 FBM + float S = _scale; + float tz = _time * _change; + float n = Snoise(ux * S, uy * S, tz); + n += 0.5f * Snoise((ux * 2f - _stepX) * S, (uy * 2f - _stepY) * S, tz); + n += 0.25f * Snoise((ux * 4f - 2f * _stepX) * S, (uy * 4f - 2f * _stepY) * S, tz); + n += 0.125f * Snoise((ux * 8f - 3f * _stepX) * S, (uy * 8f - 3f * _stepY) * S, tz); + n += 0.0625f * Snoise((ux * 16f - 4f * _stepX) * S, (uy * 16f - 4f * _stepY) * S, tz); + n += 0.03125f * Snoise((ux * 32f - 5f * _stepX) * S, (uy * 32f - 5f * _stepY) * S, tz); + + return (_cover + 0.5f * n) * _strength; + } + + /// ToonRamp 가 만드는 색 띠 번호. 이 값이 바뀌는 곳이 화면에서 보이는 경계다. + public int Band(float wx, float wy, float wz) { return BandOf(Cloudiness(wx, wy, wz)); } + + /// 구름 값 하나를 띠 번호로. (보간한 값에도 쓴다) + public int BandOf(float cloudiness) + { + float lighting = 1f - cloudiness; // step(ShadowAtten)=1 (평면·그림자 없음) + int k = Mathf.CeilToInt(lighting * _shades + _brightness); + // ToonRamp 의 saturate — 위아래로 넘친 칸은 한 색으로 뭉친다(=경계가 없다) + int top = Mathf.CeilToInt(_shades); + if (k < 0) k = 0; + if (k > top) k = top; + return k; + } + + // ───────────────────────────────────────────────────────────── + // ③ 격자에 굽기 + // ───────────────────────────────────────────────────────────── + /// + /// 주어진 범위의 XZ 평면(높이 y)에 대해 띠 번호를 구우고, 번호가 바뀌는 칸을 경계로 표시한다. + /// + /// 격자 칸(m). 작을수록 띠가 얇고 정확하다. + /// 칸 수 상한 — 섬이 커지면 칸을 자동으로 키운다(모바일 예산). + /// 경계에서 몇 칸 더 넓힐 것인가(구름이 흐르는 만큼 미리 덮어 둔다). + public void Bake(Bounds b, float y, float cell, int maxCells, int dilate, + float sampleCell, int maxSamples) + { + EdgeFraction = 0f; + if (!Ready) return; + if (cell <= 0.01f) cell = 0.01f; + if (dilate < 0) dilate = 0; + if (sampleCell < cell) sampleCell = cell; + + int pad = dilate + 2; + float w = Mathf.Max(b.size.x, 0.1f); + float h = Mathf.Max(b.size.z, 0.1f); + + // 칸 수 상한 — 넘으면 칸을 키운다 + for (int guard = 0; guard < 24; guard++) + { + int tx = Mathf.CeilToInt(w / cell) + 1 + pad * 2; + int tz = Mathf.CeilToInt(h / cell) + 1 + pad * 2; + if (maxCells <= 0 || (long)tx * tz <= maxCells) { _nx = tx; _nz = tz; break; } + cell *= 1.25f; + _nx = tx; _nz = tz; + } + _cell = cell; + _inv = 1f / cell; + _minX = b.min.x - pad * cell; + _minZ = b.min.z - pad * cell; + + int n = _nx * _nz; + if (_band == null || _band.Length < n) { _band = new int[n]; _edge = new byte[n]; _tmp = new byte[n]; } + + // ── ① 구름 값은 **성긴 격자**에서만 계산한다 (6 옥타브 노이즈 = 유일한 비싼 부분) ── + // 구름의 가장 작은 무늬가 1/(32×_Cloud_Density) ≈ 3 m 라 1 m 간격이면 충분히 따라간다. + float spanX = (_nx - 1) * cell, spanZ = (_nz - 1) * cell; + int sx = 0, sz = 0; + for (int guard = 0; guard < 24; guard++) + { + sx = Mathf.CeilToInt(spanX / sampleCell) + 2; + sz = Mathf.CeilToInt(spanZ / sampleCell) + 2; + if (maxSamples <= 0 || (long)sx * sz <= maxSamples) break; + sampleCell *= 1.25f; + } + SampleCellSize = sampleCell; Samples = sx * sz; + if (_smp == null || _smp.Length < sx * sz) _smp = new float[sx * sz]; + float s0x = _minX + 0.5f * cell - sampleCell; // 가장자리 보간을 위해 한 칸 바깥에서 시작 + float s0z = _minZ + 0.5f * cell - sampleCell; + for (int j = 0; j < sz; j++) + { + float wz = s0z + j * sampleCell; + int row = j * sx; + for (int i2 = 0; i2 < sx; i2++) _smp[row + i2] = Cloudiness(s0x + i2 * sampleCell, y, wz); + } + + // ── ② 촘촘한 격자에서는 **보간한 값**만 띠 번호로 바꾼다(노이즈 재계산 없음) ── + float invS = 1f / sampleCell; + for (int iz = 0; iz < _nz; iz++) + { + float wz = _minZ + (iz + 0.5f) * cell; + float fz = (wz - s0z) * invS; + int jz = (int)fz; if (jz < 0) jz = 0; if (jz > sz - 2) jz = sz - 2; + float tz = fz - jz; + int row = iz * _nx, r0 = jz * sx, r1 = r0 + sx; + for (int ix = 0; ix < _nx; ix++) + { + float wx = _minX + (ix + 0.5f) * cell; + float fx = (wx - s0x) * invS; + int jx = (int)fx; if (jx < 0) jx = 0; if (jx > sx - 2) jx = sx - 2; + float tx = fx - jx; + float a = _smp[r0 + jx] + (_smp[r0 + jx + 1] - _smp[r0 + jx]) * tx; + float c = _smp[r1 + jx] + (_smp[r1 + jx + 1] - _smp[r1 + jx]) * tx; + _band[row + ix] = BandOf(a + (c - a) * tz); + } + } + + for (int i = 0; i < n; i++) _edge[i] = 0; + for (int iz = 1; iz < _nz - 1; iz++) + { + int row = iz * _nx; + for (int ix = 1; ix < _nx - 1; ix++) + { + int i = row + ix; + int c = _band[i]; + if (_band[i - 1] != c || _band[i + 1] != c || _band[i - _nx] != c || _band[i + _nx] != c) + _edge[i] = 1; + } + } + + for (int p = 0; p < dilate; p++) + { + System.Array.Copy(_edge, _tmp, n); + for (int iz = 1; iz < _nz - 1; iz++) + { + int row = iz * _nx; + for (int ix = 1; ix < _nx - 1; ix++) + { + int i = row + ix; + if (_tmp[i] != 0) continue; + if (_tmp[i - 1] != 0 || _tmp[i + 1] != 0 || _tmp[i - _nx] != 0 || _tmp[i + _nx] != 0) + _edge[i] = 1; + } + } + } + + // 패딩을 뺀 안쪽에서만 비율을 센다(예산 추정용) + int cnt = 0, tot = 0; + for (int iz = pad; iz < _nz - pad; iz++) + { + int row = iz * _nx; + for (int ix = pad; ix < _nx - pad; ix++) { tot++; if (_edge[row + ix] != 0) cnt++; } + } + EdgeFraction = tot > 0 ? (float)cnt / tot : 0f; + } + + /// 이 점이 색 띠 경계 근처인가. 격자 밖이면 자르지 않는다(안전 쪽). + public bool IsEdge(float wx, float wz) + { + if (_edge == null) return true; + int cx = (int)((wx - _minX) * _inv); + int cz = (int)((wz - _minZ) * _inv); + if (cx < 0 || cz < 0 || cx >= _nx || cz >= _nz) return true; + return _edge[cz * _nx + cx] != 0; + } + + // ───────────────────────────────────────────────────────────── + // ④ SimplexNoise3D.hlsl `snoise` 이식 (Ashima Arts · MIT · 원본 파일 0줄 수정) + // ───────────────────────────────────────────────────────────── + static float Mod289(float x) { return x - Mathf.Floor(x * (1f / 289f)) * 289f; } + static float Permute(float x) { return Mod289(((x * 34f) + 10f) * x); } + static float TaylorInvSqrt(float r) { return 1.79284291400159f - 0.85373472095314f * r; } + + public static float Snoise(float vx, float vy, float vz) + { + const float Cx = 1f / 6f, Cy = 1f / 3f; + + // First corner + float dv = (vx + vy + vz) * Cy; + float ix = Mathf.Floor(vx + dv), iy = Mathf.Floor(vy + dv), iz = Mathf.Floor(vz + dv); + float di = (ix + iy + iz) * Cx; + float x0x = vx - ix + di, x0y = vy - iy + di, x0z = vz - iz + di; + + // Other corners — g = step(x0.yzx, x0.xyz) + float gx = x0x >= x0y ? 1f : 0f; + float gy = x0y >= x0z ? 1f : 0f; + float gz = x0z >= x0x ? 1f : 0f; + float lx = 1f - gx, ly = 1f - gy, lz = 1f - gz; + float i1x = gx < lz ? gx : lz, i1y = gy < lx ? gy : lx, i1z = gz < ly ? gz : ly; + float i2x = gx > lz ? gx : lz, i2y = gy > lx ? gy : lx, i2z = gz > ly ? gz : ly; + + float x1x = x0x - i1x + Cx, x1y = x0y - i1y + Cx, x1z = x0z - i1z + Cx; + float x2x = x0x - i2x + Cy, x2y = x0y - i2y + Cy, x2z = x0z - i2z + Cy; + float x3x = x0x - 0.5f, x3y = x0y - 0.5f, x3z = x0z - 0.5f; + + // Permutations + ix = Mod289(ix); iy = Mod289(iy); iz = Mod289(iz); + float p0 = Permute(iz + 0f); + float p1 = Permute(iz + i1z); + float p2 = Permute(iz + i2z); + float p3 = Permute(iz + 1f); + p0 = Permute(p0 + iy + 0f); + p1 = Permute(p1 + iy + i1y); + p2 = Permute(p2 + iy + i2y); + p3 = Permute(p3 + iy + 1f); + p0 = Permute(p0 + ix + 0f); + p1 = Permute(p1 + ix + i1x); + p2 = Permute(p2 + ix + i2x); + p3 = Permute(p3 + ix + 1f); + + // Gradients — ns = n_ * D.wyz − D.xzx, D = (0, 0.5, 1, 2) + const float n_ = 0.142857142857f; + const float nsx = n_ * 2f, nsy = n_ * 0.5f - 1f, nsz = n_; + const float nszz = nsz * nsz; + + float j0 = p0 - 49f * Mathf.Floor(p0 * nszz); + float j1 = p1 - 49f * Mathf.Floor(p1 * nszz); + float j2 = p2 - 49f * Mathf.Floor(p2 * nszz); + float j3 = p3 - 49f * Mathf.Floor(p3 * nszz); + + float xa = Mathf.Floor(j0 * nsz), xb = Mathf.Floor(j1 * nsz), xc = Mathf.Floor(j2 * nsz), xd = Mathf.Floor(j3 * nsz); + float ya = Mathf.Floor(j0 - 7f * xa), yb = Mathf.Floor(j1 - 7f * xb), yc = Mathf.Floor(j2 - 7f * xc), yd = Mathf.Floor(j3 - 7f * xd); + + float X0 = xa * nsx + nsy, X1 = xb * nsx + nsy, X2 = xc * nsx + nsy, X3 = xd * nsx + nsy; + float Y0 = ya * nsx + nsy, Y1 = yb * nsx + nsy, Y2 = yc * nsx + nsy, Y3 = yd * nsx + nsy; + float h0 = 1f - Mathf.Abs(X0) - Mathf.Abs(Y0); + float h1 = 1f - Mathf.Abs(X1) - Mathf.Abs(Y1); + float h2 = 1f - Mathf.Abs(X2) - Mathf.Abs(Y2); + float h3 = 1f - Mathf.Abs(X3) - Mathf.Abs(Y3); + + // b0 = (X0, X1, Y0, Y1) · s0 = floor(b0) * 2 + 1 · sh = −step(h, 0) + float sX0 = Mathf.Floor(X0) * 2f + 1f, sX1 = Mathf.Floor(X1) * 2f + 1f; + float sY0 = Mathf.Floor(Y0) * 2f + 1f, sY1 = Mathf.Floor(Y1) * 2f + 1f; + float sX2 = Mathf.Floor(X2) * 2f + 1f, sX3 = Mathf.Floor(X3) * 2f + 1f; + float sY2 = Mathf.Floor(Y2) * 2f + 1f, sY3 = Mathf.Floor(Y3) * 2f + 1f; + float sh0 = h0 <= 0f ? -1f : 0f, sh1 = h1 <= 0f ? -1f : 0f; + float sh2 = h2 <= 0f ? -1f : 0f, sh3 = h3 <= 0f ? -1f : 0f; + + // a0 = b0.xzyw + s0.xzyw * sh.xxyy → (X0, Y0, X1, Y1) + (sX0, sY0, sX1, sY1) * (sh0, sh0, sh1, sh1) + float g0x = X0 + sX0 * sh0, g0y = Y0 + sY0 * sh0, g0z = h0; + float g1x = X1 + sX1 * sh1, g1y = Y1 + sY1 * sh1, g1z = h1; + float g2x = X2 + sX2 * sh2, g2y = Y2 + sY2 * sh2, g2z = h2; + float g3x = X3 + sX3 * sh3, g3y = Y3 + sY3 * sh3, g3z = h3; + + float n0 = TaylorInvSqrt(g0x * g0x + g0y * g0y + g0z * g0z); + float n1 = TaylorInvSqrt(g1x * g1x + g1y * g1y + g1z * g1z); + float n2 = TaylorInvSqrt(g2x * g2x + g2y * g2y + g2z * g2z); + float n3 = TaylorInvSqrt(g3x * g3x + g3y * g3y + g3z * g3z); + g0x *= n0; g0y *= n0; g0z *= n0; + g1x *= n1; g1y *= n1; g1z *= n1; + g2x *= n2; g2y *= n2; g2z *= n2; + g3x *= n3; g3y *= n3; g3z *= n3; + + float m0 = 0.5f - (x0x * x0x + x0y * x0y + x0z * x0z); if (m0 < 0f) m0 = 0f; + float m1 = 0.5f - (x1x * x1x + x1y * x1y + x1z * x1z); if (m1 < 0f) m1 = 0f; + float m2 = 0.5f - (x2x * x2x + x2y * x2y + x2z * x2z); if (m2 < 0f) m2 = 0f; + float m3 = 0.5f - (x3x * x3x + x3y * x3y + x3z * x3z); if (m3 < 0f) m3 = 0f; + m0 *= m0; m1 *= m1; m2 *= m2; m3 *= m3; + m0 *= m0; m1 *= m1; m2 *= m2; m3 *= m3; + + return 105f * (m0 * (g0x * x0x + g0y * x0y + g0z * x0z) + + m1 * (g1x * x1x + g1y * x1y + g1z * x1z) + + m2 * (g2x * x2x + g2y * x2y + g2z * x2z) + + m3 * (g3x * x3x + g3y * x3y + g3z * x3z)); + } + } +} diff --git a/Assets/WL/Look/Farm/WLCloudShadow.cs.meta b/Assets/WL/Look/Farm/WLCloudShadow.cs.meta new file mode 100644 index 000000000..78e1b61a0 --- /dev/null +++ b/Assets/WL/Look/Farm/WLCloudShadow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a2212bf029b1a9341ada8c8c84ebff63 \ No newline at end of file diff --git a/Assets/WL/Look/Farm/WLIslandGrass.cs b/Assets/WL/Look/Farm/WLIslandGrass.cs index 02c7f325f..e01c504a6 100644 --- a/Assets/WL/Look/Farm/WLIslandGrass.cs +++ b/Assets/WL/Look/Farm/WLIslandGrass.cs @@ -38,28 +38,68 @@ namespace WL.Look.Farm public static float UsedDensity; public static string LastLog = ""; + // 816o — 구름 경계 배치 진단 + public static int Candidates, CloudRefreshes, CloudExcluded, EdgeCells, EdgeDilate, EdgeSamples; + public static float EdgeFraction, EdgeCellSize, EdgeSampleCell, BakeMs, FilterMs; + public static bool CloudEdgeOn; + public static string CloudWhy = ""; + public WLIslandLookSettings cfg; Bounds _bounds = new Bounds(Vector3.zero, Vector3.one * 16f); readonly List _blockers = new List(128); + // ── 816o 캐시 — 한 번 뽑은 후보를 들고 있다가, 구름이 흐르면 「고르기」만 다시 한다 ── + struct Cand { public float x, z, y; public int def; public float sc; public float yaw; } + Cand[] _cand; + int _candCount; + InstancingSettings[] _cset; + WLScatterDef[] _cdef; + long[] _ctri; + bool _cacheValid; + float _cacheY; + bool _fastRefresh; + readonly WLCloudShadowField _field = new WLCloudShadowField(); + public override Bounds CalculateInstancesBounds() { return _bounds; } /// 다시 깐다(섬이 확장됐을 때). 멱등 — 몇 번 불러도 안전. public void Rebuild() { + _cacheValid = false; + _fastRefresh = false; if (!isActiveAndEnabled) { enabled = true; return; } // OnEnable 이 알아서 만든다 enabled = false; // OnDisable → 버퍼 해제 enabled = true; // OnEnable → 다시 생성 Rebuilds++; } + /// + /// 816o — 구름이 흐른 만큼 **띠만** 다시 고른다. 타일·제외 사각형은 다시 훑지 않는다 + /// (`FindObjectsByType` 를 타지 않으므로 전체 재생성보다 훨씬 싸다). + /// + public void RefreshCloudEdges() + { + if (!_cacheValid || !isActiveAndEnabled) return; + if (cfg == null || cfg.cloudEdgeEnabled == 0) return; + _fastRefresh = true; + enabled = false; + enabled = true; + _fastRefresh = false; + CloudRefreshes++; + } + // ───────────────────────────────────────────────────────────────── // 점 뽑기 — 데모 `TerrainInstancesBehaviour.GetInstanceData` 와 같은 절차 // ───────────────────────────────────────────────────────────────── public override Dictionary> GetInstanceData() { + if (_fastRefresh && _cacheValid) return BuildFromCache(true); + + _cacheValid = false; Instances = 0; Tiles = 0; Excluded = 0; Triangles = 0; DrawnConfigs = 0; UsedDensity = 0f; + Candidates = 0; EdgeFraction = 1f; EdgeCells = 0; EdgeCellSize = 0f; EdgeDilate = 0; + BakeMs = 0f; FilterMs = 0f; CloudEdgeOn = false; CloudWhy = ""; _edgeOn = false; if (cfg == null) cfg = WLIslandLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || cfg.grassEnabled == 0) { LastLog = "꺼짐"; return null; } @@ -84,37 +124,7 @@ namespace WL.Look.Farm BuildBlockers(tiles); - // 밀도 자동 조절(모바일 예산) - float budgetScale = 1f; - if (cfg.maxInstances > 0) - { - float area = 0f; - for (int i = 0; i < tiles.Count; i++) area += 64f; // 타일 8×8 - float topLayer = 0f; - for (int i = 0; i < cfg.scatter.Length; i++) - { - var s = cfg.scatter[i]; - if (s == null || s.enabled_ == 0) continue; - if (s.density > topLayer) topLayer = s.density; - } - float est = area * topLayer * topLayer * 0.93f; // 윗면은 8×8 의 약 93 % - if (est > cfg.maxInstances) budgetScale = Mathf.Sqrt(cfg.maxInstances / est); - } - - // 같은 density 끼리 한 층으로 묶는다(데모의 FirstLayer/SecondLayer 와 같은 구조) - var layers = new List(4); - for (int i = 0; i < cfg.scatter.Length; i++) - { - var s = cfg.scatter[i]; - if (s == null || s.enabled_ == 0 || s.mesh == null || s.material == null || s.scale <= 0f) continue; - float d = s.density * budgetScale; - if (d <= 0f) continue; - bool found = false; - for (int k = 0; k < layers.Count; k++) if (Mathf.Abs(layers[k] - d) < 1e-5f) { found = true; break; } - if (!found) layers.Add(d); - } - - var result = new Dictionary>(); + // 경계 박스 — 구름 격자를 굽기 전에 정해져야 한다 var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue); for (int i = 0; i < tiles.Count; i++) @@ -124,7 +134,57 @@ namespace WL.Look.Farm bmax = Vector3.Max(bmax, new Vector3(t.center.x + 4f, t.center.y + 3f, t.center.z + 4f)); } _bounds = new Bounds((bmin + bmax) * 0.5f, bmax - bmin); - Vector3 bc = _bounds.center; + _cacheY = tiles[0].center.y; + + // ── 816o — 바닥과 **같은 식**으로 구름 그림자 색 띠의 경계를 굽는다 ── + float coverage = 1f; + if (cfg.cloudEdgeEnabled != 0) + { + var src = cfg.cloudEdgeSourceMaterial != null ? cfg.cloudEdgeSourceMaterial : cfg.islandTopMaterial; + if (_field.Configure(src)) + { + BakeField(); + coverage = Mathf.Clamp(_field.EdgeFraction, 0.02f, 1f); + _edgeOn = true; CloudEdgeOn = true; + } + else CloudWhy = _field.Why; + } + + // 밀도 자동 조절(모바일 예산) — 띠 면적(coverage)을 실측해서 반영한다 + float edgeScale = _edgeOn ? Mathf.Max(0.05f, cfg.cloudEdgeDensityScale) : 1f; + float budgetScale = 1f; + if (cfg.maxInstances > 0) + { + float area = tiles.Count * 64f; // 타일 8×8 + float topLayer = 0f; + for (int i = 0; i < cfg.scatter.Length; i++) + { + var s = cfg.scatter[i]; + if (s == null || s.enabled_ == 0) continue; + if (s.density > topLayer) topLayer = s.density; + } + float d0 = topLayer * edgeScale; + float est = area * d0 * d0 * 0.93f * coverage; // 윗면은 8×8 의 약 93 % + if (est > cfg.maxInstances) budgetScale = Mathf.Sqrt(cfg.maxInstances / est); + } + float densScale = budgetScale * edgeScale; + + // 같은 density 끼리 한 층으로 묶는다(데모의 FirstLayer/SecondLayer 와 같은 구조) + var layers = new List(4); + for (int i = 0; i < cfg.scatter.Length; i++) + { + var s = cfg.scatter[i]; + if (s == null || s.enabled_ == 0 || s.mesh == null || s.material == null || s.scale <= 0f) continue; + float d = s.density * densScale; + if (d <= 0f) continue; + bool found = false; + for (int k = 0; k < layers.Count; k++) if (Mathf.Abs(layers[k] - d) < 1e-5f) { found = true; break; } + if (!found) layers.Add(d); + } + + var allDefs = new List(8); + var allSets = new List(8); + var cands = new List(8192); for (int L = 0; L < layers.Count; L++) { @@ -137,24 +197,23 @@ namespace WL.Look.Farm { var s = cfg.scatter[i]; if (s == null || s.enabled_ == 0 || s.mesh == null || s.material == null || s.scale <= 0f) continue; - if (Mathf.Abs(s.density * budgetScale - density) > 1e-5f) continue; + if (Mathf.Abs(s.density * densScale - density) > 1e-5f) continue; defs.Add(s); wsum += Mathf.Max(0f, s.probability); } if (defs.Count == 0 || wsum <= 0f) continue; - var buckets = new List[defs.Count]; - var iset = new InstancingSettings[defs.Count]; + int baseIdx = allDefs.Count; for (int i = 0; i < defs.Count; i++) { - buckets[i] = new List(1024); - iset[i] = new InstancingSettings + allDefs.Add(defs[i]); + allSets.Add(new InstancingSettings { Mesh = defs[i].mesh, Material = defs[i].material, Probability = defs[i].probability, Scale = defs[i].scale, NormalOffset = defs[i].normalOffset, - }; + }); } float step = 1f / density; @@ -194,36 +253,111 @@ namespace WL.Look.Farm } float sc = 1f + (Frac(h, 3) - 0.5f) * 2f * sv; - var rot = cfg.randomYaw != 0 ? Quaternion.Euler(0f, Frac(h, 4) * 360f, 0f) : Quaternion.identity; - var pos = new Vector3(wx, t.center.y, wz) - bc; + float yaw = cfg.randomYaw != 0 ? Frac(h, 4) * 360f : 0f; - var trs = Matrix4x4.TRS(pos, rot, Vector3.one * sc); - var def = defs[pick]; - trs *= Matrix4x4.TRS(def.normalOffset * Vector3.up, Quaternion.identity, - new Vector3(def.scale, def.scale, def.scale)); - - buckets[pick].Add(new InstanceData { TRS = trs, Normal = Vector3.up }); + cands.Add(new Cand { x = wx, z = wz, y = t.center.y, def = baseIdx + pick, sc = sc, yaw = yaw }); } } } - for (int i = 0; i < defs.Count; i++) - { - if (buckets[i].Count == 0) continue; - result.Add(iset[i], buckets[i]); - Instances += buckets[i].Count; - DrawnConfigs++; - long tri = 0; - for (int s = 0; s < defs[i].mesh.subMeshCount; s++) tri += defs[i].mesh.GetIndexCount(s) / 3; - Triangles += tri * buckets[i].Count; - if (density > UsedDensity) UsedDensity = density; - } + if (density > UsedDensity) UsedDensity = density; } - LastLog = "타일 " + Tiles + " · 인스턴스 " + Instances + " · 드로우콜 " + DrawnConfigs - + " · 삼각형 " + Triangles + " · 제외점 " + Excluded + _cdef = allDefs.ToArray(); + _cset = allSets.ToArray(); + _ctri = new long[_cdef.Length]; + for (int i = 0; i < _cdef.Length; i++) + { + long tri = 0; + var m = _cdef[i].mesh; + for (int s = 0; s < m.subMeshCount; s++) tri += m.GetIndexCount(s) / 3; + _ctri[i] = tri; + } + _cand = cands.ToArray(); + _candCount = _cand.Length; + Candidates = _candCount; + _budgetScale = budgetScale; + _cacheValid = _cdef.Length > 0 && _candCount > 0; + if (!_cacheValid) { LastLog = "후보 0"; cfg.Log(LastLog); return null; } + + return BuildFromCache(false); + } + + // ───────────────────────────────────────────────────────────────── + // 816o — 띠 굽기 + 캐시에서 고르기 + // ───────────────────────────────────────────────────────────────── + float _budgetScale = 1f; + bool _edgeOn; + + void BakeField() + { + float cell = Mathf.Max(0.05f, cfg.cloudEdgeCell); + int dil = Mathf.Max(0, cfg.cloudEdgeDilate); + // 갱신 주기 동안 구름이 흐르는 만큼 미리 덮어 둔다 — 띠가 움직여도 빈틈이 안 생긴다 + if (cfg.cloudEdgeDriftCover != 0 && cfg.cloudEdgeRefreshSeconds > 0f) + dil += Mathf.CeilToInt(_field.DriftPerSecond * cfg.cloudEdgeRefreshSeconds / cell); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + _field.Bake(_bounds, _cacheY, cell, cfg.cloudEdgeMaxCells, dil, + cfg.cloudEdgeSampleCell, cfg.cloudEdgeMaxSamples); + sw.Stop(); + BakeMs = (float)sw.Elapsed.TotalMilliseconds; + EdgeCells = _field.Cells; EdgeCellSize = _field.CellSize; + EdgeSamples = _field.Samples; EdgeSampleCell = _field.SampleCellSize; + EdgeFraction = _field.EdgeFraction; EdgeDilate = dil; + } + + Dictionary> BuildFromCache(bool refresh) + { + if (refresh) + { + _field.Tick(); + BakeField(); + } + + Instances = 0; Triangles = 0; DrawnConfigs = 0; CloudExcluded = 0; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var buckets = new List[_cdef.Length]; + for (int i = 0; i < buckets.Length; i++) buckets[i] = new List(256); + + Vector3 bc = _bounds.center; + bool edge = _edgeOn; + for (int i = 0; i < _candCount; i++) + { + var c = _cand[i]; + if (edge && !_field.IsEdge(c.x, c.z)) { CloudExcluded++; continue; } + + var rot = c.yaw != 0f ? Quaternion.Euler(0f, c.yaw, 0f) : Quaternion.identity; + var trs = Matrix4x4.TRS(new Vector3(c.x, c.y, c.z) - bc, rot, Vector3.one * c.sc); + var def = _cdef[c.def]; + trs *= Matrix4x4.TRS(def.normalOffset * Vector3.up, Quaternion.identity, + new Vector3(def.scale, def.scale, def.scale)); + buckets[c.def].Add(new InstanceData { TRS = trs, Normal = Vector3.up }); + } + sw.Stop(); + FilterMs = (float)sw.Elapsed.TotalMilliseconds; + + var result = new Dictionary>(); + for (int i = 0; i < buckets.Length; i++) + { + if (buckets[i].Count == 0) continue; + result.Add(_cset[i], buckets[i]); + Instances += buckets[i].Count; + DrawnConfigs++; + Triangles += _ctri[i] * buckets[i].Count; + } + + LastLog = "타일 " + Tiles + " · 후보 " + Candidates + " · 인스턴스 " + Instances + + " · 드로우콜 " + DrawnConfigs + " · 삼각형 " + Triangles + " · 제외점 " + Excluded + " · 밀도 " + UsedDensity.ToString("F2") + "(=" + (UsedDensity * UsedDensity).ToString("F2") + "개/㎡)" - + (budgetScale < 1f ? " · 예산으로 밀도 ×" + budgetScale.ToString("F2") : ""); + + (_budgetScale < 1f ? " · 예산으로 밀도 ×" + _budgetScale.ToString("F2") : "") + + (_edgeOn + ? " · 구름띠 " + (EdgeFraction * 100f).ToString("F1") + "%(칸 " + EdgeCellSize.ToString("F2") + + "m×" + EdgeCells + " · 넓힘 " + EdgeDilate + " · 노이즈 " + EdgeSampleCell.ToString("F2") + + "m×" + EdgeSamples + ") · 띠밖 제외 " + CloudExcluded + + " · 굽기 " + BakeMs.ToString("F1") + "ms · 고르기 " + FilterMs.ToString("F1") + "ms" + : " · 구름띠 off" + (CloudWhy.Length > 0 ? "(" + CloudWhy + ")" : "")); cfg.Log(LastLog); return result.Count == 0 ? null : result; } diff --git a/Assets/WL/Look/Farm/WLIslandLook.cs b/Assets/WL/Look/Farm/WLIslandLook.cs index 3070641a1..3ac11ebea 100644 --- a/Assets/WL/Look/Farm/WLIslandLook.cs +++ b/Assets/WL/Look/Farm/WLIslandLook.cs @@ -166,6 +166,7 @@ namespace WL.Look.Farm if (cfg.rebuildPollSeconds > 0f) { int last = CountUnlocked(scene); + float nextCloud = Time.time + Mathf.Max(0f, cfg.cloudEdgeRefreshSeconds); while (scene.isLoaded) { yield return new WaitForSeconds(cfg.rebuildPollSeconds); @@ -177,6 +178,16 @@ namespace WL.Look.Farm last = now; if (cfg.applyLook != 0) SwapMaterials(cfg, scene); RequestGrassRebuild(cfg); + nextCloud = Time.time + Mathf.Max(0f, cfg.cloudEdgeRefreshSeconds); + continue; + } + + // 816o — 구름이 흐른 만큼 띠를 다시 고른다(타일·제외 사각형은 다시 훑지 않는다) + if (cfg.cloudEdgeEnabled != 0 && cfg.cloudEdgeRefreshSeconds > 0f && Time.time >= nextCloud) + { + nextCloud = Time.time + cfg.cloudEdgeRefreshSeconds; + var g = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (g != null) g.RefreshCloudEdges(); } } } diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index 0453268e3..48e724362 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -270,6 +270,47 @@ namespace WL.Look.Farm [Tooltip("확장 애니메이션(DOTween 1초)이 끝나기를 기다리는 초.")] public float rebuildDelaySeconds = 1.4f; + // ───────────────────────────────── §2 구름 경계 배치 (816o) + [Header("§2 — 구름 그림자 경계에만 풀을 심는다 (816o · PD 「모바일 부하 없게」)")] + [Tooltip("🔴 되돌리기 스위치 — 0 이면 816n 상태 100 % 복귀(섬 전체에 균일 격자). " + + "1 이면 바닥 색 띠가 바뀌는 곳(구름 그림자 경계) 근처에만 심는다. " + + "816n 실측: 보이는 풀의 88 % 가 이 띠에 몰려 있다 = 나머지는 삼각형만 먹는다.")] + public int cloudEdgeEnabled = 1; + + [Tooltip("경계 판정 격자 칸(m). 띠 폭의 최소 단위 = 이 값. 여기는 노이즈를 계산하지 않는다(보간).")] + public float cloudEdgeCell = 0.3f; + + [Tooltip("판정 격자 칸 수 상한. 섬이 커지면 칸을 자동으로 키운다(0 = 무제한).")] + public int cloudEdgeMaxCells = 120000; + + [Tooltip("🔴 비용이 나는 유일한 곳 — 구름 노이즈(6 옥타브)를 실제로 계산하는 간격(m). " + + "구름의 가장 작은 무늬가 1/(32×_Cloud_Density) ≈ 3 m 라 1 m 면 충분하다. " + + "사이 값은 겹선형 보간으로 채운다.")] + public float cloudEdgeSampleCell = 1f; + + [Tooltip("노이즈를 계산하는 점의 개수 상한. 섬이 커지면 간격을 자동으로 늘려 비용을 묶어 둔다.")] + public int cloudEdgeMaxSamples = 4096; + + [Tooltip("경계에서 몇 칸 더 넓힐 것인가. 띠 폭 ≈ (1 + 2 × 이 값) × 칸(m). " + + "구름이 흐르는 만큼(`cloudEdgeDriftCover`)은 여기에 자동으로 더해진다.")] + public int cloudEdgeDilate = 1; + + [Tooltip("1 이면 갱신 주기 동안 구름이 흐르는 거리만큼 띠를 자동으로 더 넓힌다 — " + + "띠가 움직여도 풀이 뒤따라오지 못해 생기는 빈틈을 막는다. 0 이면 위 값만 쓴다.")] + public int cloudEdgeDriftCover = 1; + + [Tooltip("구름이 흐르므로 이 주기(초)마다 띠를 다시 고른다. 0 이면 한 번 굽고 고정. " + + "🔴 실제 주기는 `rebuildPollSeconds` 단위로 반올림된다.")] + public float cloudEdgeRefreshSeconds = 1f; + + [Tooltip("띠 안의 밀도 배율. 면적이 줄어든 만큼 띠를 더 촘촘히 해 데모 느낌을 살린다. " + + "1 = 816n 과 같은 밀도(= 인스턴스가 띠 면적 비율만큼 그대로 줄어든다).")] + public float cloudEdgeDensityScale = 1.25f; + + [Tooltip("구름 값을 읽어 올 머티리얼. 비우면 `islandTopMaterial`(바닥)을 쓴다 — " + + "바닥과 **같은 식**이어야 띠가 어긋나지 않는다.")] + public Material cloudEdgeSourceMaterial; + // ───────────────────────────────── 구운 마스크 [Header("§1 — 구운 윗면 마스크 (에디터에서 생성 · 손대지 말 것)")] public WLTileMask[] tileMasks = new WLTileMask[0];