From cdb324a57d9f8799f90a2ee24ad3c1236298e9da Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 15 Sep 2026 08:31:20 +0900 Subject: [PATCH] =?UTF-8?q?[WL-816q]=20=ED=92=80=20=EB=86=92=EC=9D=B4=200.?= =?UTF-8?q?35=E2=86=920.7(=EB=9D=A0=20=EA=B0=80=EC=9E=A5=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=ED=86=B1=EB=8B=88=20=ED=8F=AD)=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=8B=9C=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=B0=94=EB=8B=A5=20?= =?UTF-8?q?=EC=82=AC=EB=8B=A4=EB=A6=AC=EA=BC=B4=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=A0=95=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 톱니 윤곽 폭 = 풀 높이 × cot(태양고도). scale 스윕 실측(같은 카메라·같은 구름 위상): 0.35 에지% 1.05 / 0.50 1.20 / 0.70 1.39 / 0.90 1.43 (데모 참고 1.22) · 인스턴스·삼각형·드로우콜 불변. 0.70 = 데모의 띠 가장자리 밀도(≈4개/m)와 같은 폭 → 채택. - 가시 범위: 절두체 평면을 그대로 밀면 위쪽 평면이 바닥과 15° 라 8 m 가 바닥에서 31 m 로 벌어졌다 (실측 618 → 3,731 ㎡). **바닥 사다리꼴을 2 차원으로** 미는 방식으로 교정 → 여유 4 m 에서 643 → 1,151 ㎡. viewMargin 8→4 · viewRebuildStep 2.5→1.5 · viewMaxDistance 40 · viewRebuildAngle 3 신설. - 팝 0 실측: 보폭 1.5 m 이동 후 [다시 고르기 전] vs [고른 뒤] 화면 차이 **0 픽셀**. 0/2/4 s 좌표 지문 동일(75934715234309) = 생성·소멸·이동 0. Co-Authored-By: Claude Opus 5 (1M context) --- AgentScripts/WL816q_Final.cs | 267 ++++++++++++++++++ AgentScripts/WL816q_Fringe.cs | 153 ++++++++++ AgentScripts/WL816q_Sweep.cs | 166 +++++++++++ AgentScripts/WL816q_Verify.cs | 236 ++++++++++++++++ .../Resources/WL/WLIslandLookSettings.asset | 8 +- Assets/WL/Look/Farm/WLIslandGrass.cs | 107 ++++--- Assets/WL/Look/Farm/WLIslandLookSettings.cs | 13 +- 7 files changed, 901 insertions(+), 49 deletions(-) create mode 100644 AgentScripts/WL816q_Final.cs create mode 100644 AgentScripts/WL816q_Fringe.cs create mode 100644 AgentScripts/WL816q_Sweep.cs create mode 100644 AgentScripts/WL816q_Verify.cs diff --git a/AgentScripts/WL816q_Final.cs b/AgentScripts/WL816q_Final.cs new file mode 100644 index 000000000..998cc2a34 --- /dev/null +++ b/AgentScripts/WL816q_Final.cs @@ -0,0 +1,267 @@ +// WL-816q 최종 검증 — 전/후 · 예산 · 0/2/4 s 좌표 불변 · 가시 범위(절두체) 팝 0 증명 · 79타일 환산. +// 에셋 무변경(런타임 복제본). Debug.LogError 금지. +public static class WL816q_Final +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qFinal"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qFinal"); + go.AddComponent(); + } +} + +public class WL816q_FinalRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int W = 1080, H = 1920, TS = 768; + static string Dir = "Screenshots_WL/WL816q"; + static readonly UnityEngine.Color BeforeDif = new UnityEngine.Color(0.60541177f, 0.82788235f, 0.5798824f, 1f); + static readonly UnityEngine.Color BeforeShd = new UnityEngine.Color(0.5434118f, 0.744f, 0.5215294f, 1f); + const float BeforeScale = 0.35f; + + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + System.IO.Directory.CreateDirectory(Dir); + 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); + if (g == null) { L("grass 없음"); Flush(); yield break; } + var rt = UnityEngine.Object.Instantiate(WL.Look.Farm.WLIslandLookSettings.Instance); + var arr = new WL.Look.Farm.WLScatterDef[rt.scatter.Length]; + for (int i = 0; i < arr.Length; i++) + { + var s = rt.scatter[i]; + arr[i] = new WL.Look.Farm.WLScatterDef { enabled_ = s.enabled_, label = s.label, mesh = s.mesh, material = s.material, + probability = s.probability, scale = s.scale, normalOffset = s.normalOffset, density = s.density }; + } + rt.scatter = arr; g.cfg = rt; + float afterScale = arr[0].scale; + var baseMat = arr[0].material; + var afterDif = baseMat.GetColor("_DiffuseColor"); + var afterShd = baseMat.GetColor("_ShadowDiffuseColor"); + var clone = new UnityEngine.Material(baseMat); clone.name = baseMat.name + "_final"; + for (int i = 0; i < arr.Length; i++) if (arr[i].material == baseMat) arr[i].material = clone; + L("채택값 : 풀 Dif=" + afterDif + " Shd=" + afterShd + " scale=" + afterScale + + " density=" + arr[0].density + " · 구름 Change=" + baseMat.GetFloat("_Cloud_Change") + " Move=" + baseMat.GetVector("_Cloud_Movement")); + + var b = g.CalculateInstancesBounds(); + var camTop = MakeCam("~816qTop", true, b.center.x, b.center.z, 6f); + var camPd = MakeCam("~816qPd", false, 0, 0, 0); + UnityEngine.Time.timeScale = 0f; yield return null; + + // ── 전 (816n 색 · scale 0.35) ──────────────────────────────── + Set(clone, BeforeDif, BeforeShd); SetScale(arr, BeforeScale); + rt.viewCullEnabled = 0; g.viewCameraOverride = camTop; g.Rebuild(); yield return null; yield return null; + L("[전 탑다운] " + Shot(camTop, TS, TS, "f_before_top")); + g.viewCameraOverride = camPd; g.Rebuild(); yield return null; yield return null; + L("[전 PD ] " + Shot(camPd, W, H, "f_before_pd") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + + // ── 후 ──────────────────────────────────────────────────────── + Set(clone, afterDif, afterShd); SetScale(arr, afterScale); + g.viewCameraOverride = camTop; g.Rebuild(); yield return null; yield return null; + L("[후 탑다운] " + Shot(camTop, TS, TS, "f_after_top")); + g.viewCameraOverride = camPd; g.Rebuild(); yield return null; yield return null; + L("[후 PD 범위off] " + Shot(camPd, W, H, "f_after_pd_nocull") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + rt.viewCullEnabled = 1; g.Rebuild(); yield return null; yield return null; + L("[후 PD 범위on ] " + Shot(camPd, W, H, "f_after_pd") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + L(" " + WL.Look.Farm.WLIslandGrass.LastLog); + + // ── 절두체 + 여유 폭이 덮는 바닥 넓이(몬테카를로) → 79타일 환산 ── + float area = GroundArea(camPd, rt.viewMargin, b.center.y, rt.viewMaxDistance, 80f, 400000); + float area0 = GroundArea(camPd, 0f, b.center.y, rt.viewMaxDistance, 80f, 400000); + float d2 = arr[0].density * arr[0].density; + L(string.Format("PD 절두체 바닥 넓이 = {0:F0} ㎡(여유 0) / {1:F0} ㎡(여유 {2:F1} m) · 밀도 {3:F2}개/㎡ → 큰 섬 환산 인스턴스 ≈ {4:F0}개 · 삼각형 ≈ {5:F0}", + area0, area, rt.viewMargin, d2, area * d2 * 0.93f, area * d2 * 0.93f * 9f)); + L(" (4타일 섬 = 16×16 m 라 절두체보다 작아 잘릴 것이 없다 — 실측 범위밖 제외 " + WL.Look.Farm.WLIslandGrass.ViewCulled + ")"); + + // ── 가시 범위 팝 0 증명 : P 에서 고른 집합이 P+보폭 에서도 그대로인가 ── + var p0 = camPd.transform.position; + g.RefreshView(); yield return null; yield return null; + ulong kA = WL.Look.Farm.WLIslandGrass.InstanceKey; int nA = WL.Look.Farm.WLIslandGrass.Instances; + var right = camPd.transform.right; + camPd.transform.position = p0 + right * rt.viewRebuildStep; // 보폭만큼 이동(아직 안 고름) + var I2 = Grab(camPd, W, H); + g.RefreshView(); yield return null; yield return null; // 그 자리에서 다시 고름 + var I3 = Grab(camPd, W, H); + int diff = PixDiff(I2.GetPixels32(), I3.GetPixels32()); + L(string.Format("팝 0 증명 : 보폭 {0:F1} m 이동 후 [다시 고르기 전] vs [다시 고른 뒤] 화면 차이 픽셀 = {1} ({2:F4}%) · 인스턴스 {3} → {4}", + rt.viewRebuildStep, diff, 100.0 * diff / (W * H), nA, WL.Look.Farm.WLIslandGrass.Instances)); + System.IO.File.WriteAllBytes(Dir + "/f_pop_before_refresh.png", UnityEngine.ImageConversion.EncodeToPNG(I2)); + System.IO.File.WriteAllBytes(Dir + "/f_pop_after_refresh.png", UnityEngine.ImageConversion.EncodeToPNG(I3)); + UnityEngine.Object.DestroyImmediate(I2); UnityEngine.Object.DestroyImmediate(I3); + camPd.transform.position = p0; g.RefreshView(); yield return null; yield return null; + L(" 제자리 복귀 후 지문 = " + WL.Look.Farm.WLIslandGrass.InstanceKey + (WL.Look.Farm.WLIslandGrass.InstanceKey == kA ? " → 처음과 동일(격자 월드 고정)" : " 🔴 다름")); + + // ── 0 / 2 / 4 s ─────────────────────────────────────────────── + UnityEngine.Time.timeScale = 1f; + ulong k0 = 0; int n0 = 0; + for (int f = 0; f < 3; f++) + { + if (f > 0) { float t0 = UnityEngine.Time.realtimeSinceStartup; while (UnityEngine.Time.realtimeSinceStartup - t0 < 2f) yield return null; } + g.RefreshView(); yield return null; yield return null; + var tex = Grab(camPd, W, H); + System.IO.File.WriteAllBytes(Dir + "/f_t" + (f * 2) + "s.png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + UnityEngine.Object.DestroyImmediate(tex); + ulong k = WL.Look.Farm.WLIslandGrass.InstanceKey; int n = WL.Look.Farm.WLIslandGrass.Instances; + if (f == 0) { k0 = k; n0 = n; } + L("t=" + (f * 2) + "s 인스턴스=" + n + " 좌표지문=" + k + (f == 0 ? "" : (k == k0 && n == n0 ? " → 0 s 와 동일(생성·소멸·이동 0)" : " 🔴 달라졌다"))); + } + g.viewCameraOverride = null; + Flush(); + } + + static void Set(UnityEngine.Material m, UnityEngine.Color d, UnityEngine.Color s) { m.SetColor("_DiffuseColor", d); m.SetColor("_ShadowDiffuseColor", s); } + static void SetScale(WL.Look.Farm.WLScatterDef[] arr, float s) + { + for (int i = 0; i < arr.Length; i++) if (arr[i].label != null && arr[i].label.Contains("풀")) arr[i].scale = s; + } + + // 게임 코드(TryViewPlanes/InView)와 **같은 식**으로 바닥 사다리꼴 + 여유 폭 넓이를 몬테카를로로 잰다 + static float GroundArea(UnityEngine.Camera cam, float margin, float y, float maxD, float extent, int n) + { + var poly = new UnityEngine.Vector2[4]; + for (int i = 0; i < 4; i++) + { + float vx = (i == 1 || i == 2) ? 1f : 0f, vy = (i >= 2) ? 1f : 0f; + var ray = cam.ViewportPointToRay(new UnityEngine.Vector3(vx, vy, 0f)); + float t = maxD; + if (ray.direction.y < -1e-4f) t = UnityEngine.Mathf.Min((y - ray.origin.y) / ray.direction.y, maxD); + var h = ray.origin + ray.direction * t; + poly[i] = new UnityEngine.Vector2(h.x, h.z); + } + var c2 = UnityEngine.Vector2.zero; for (int i = 0; i < 4; i++) c2 += poly[i]; c2 *= 0.25f; + var nn = new UnityEngine.Vector2[4]; var dd = new float[4]; + for (int i = 0; i < 4; i++) + { + var a = poly[i]; var bb = poly[(i + 1) & 3]; var e = bb - a; + var nv = new UnityEngine.Vector2(e.y, -e.x); + if (nv.sqrMagnitude < 1e-8f) nv = new UnityEngine.Vector2(1f, 0f); + nv.Normalize(); + if (UnityEngine.Vector2.Dot(nv, a - c2) < 0f) nv = -nv; + nn[i] = nv; dd[i] = UnityEngine.Vector2.Dot(nv, a); + } + int hit = 0; var rnd = new System.Random(12345); + for (int i = 0; i < n; i++) + { + float x = c2.x + (float)(rnd.NextDouble() * 2 - 1) * extent; + float z = c2.y + (float)(rnd.NextDouble() * 2 - 1) * extent; + var p = new UnityEngine.Vector2(x, z); + bool ok = true; + for (int k = 0; k < 4; k++) if (UnityEngine.Vector2.Dot(nn[k], p) - dd[k] > margin) { ok = false; break; } + if (ok) hit++; + } + return 4f * extent * extent * hit / n; + } + + static int PixDiff(UnityEngine.Color32[] a, UnityEngine.Color32[] b) + { + int n = 0; + for (int i = 0; i < a.Length; i++) + if (System.Math.Abs(a[i].r - b[i].r) + System.Math.Abs(a[i].g - b[i].g) + System.Math.Abs(a[i].b - b[i].b) > 6) n++; + return n; + } + + static string Metric(UnityEngine.Color32[] px, int w, int h) + { + int bs = 8, bw = w / bs, bh = h / bs; + var bm = new float[bw * bh]; var bok = new bool[bw * bh]; + double hf = 0; int hfn = 0; + for (int by = 0; by < bh; by++) for (int bx = 0; bx < bw; bx++) + { + double s = 0; int n = 0; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; s += Lum(p); n++; } + if (n < bs * bs / 2) continue; + float mean = (float)(s / n); bm[by * bw + bx] = mean; bok[by * bw + bx] = true; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; hf += System.Math.Abs(Lum(p) - mean); hfn++; } + } + double s1 = 0, s2 = 0; int bn = 0; var lv = new int[40]; + for (int i = 0; i < bm.Length; i++) { if (!bok[i]) continue; s1 += bm[i]; s2 += (double)bm[i] * bm[i]; bn++; int q = (int)(bm[i] / 8f); if (q >= 0 && q < 40) lv[q]++; } + if (bn == 0 || hfn == 0) return "지면 0"; + double mu = s1 / bn, lf = System.Math.Sqrt(System.Math.Max(0, s2 / bn - mu * mu)); + int levels = 0; for (int i = 0; i < 40; i++) if (lv[i] >= bn * 0.005) levels++; + int ed = 0, edn = 0; + for (int y = 0; y < h - 1; y++) for (int x = 0; x < w - 1; x++) + { + var p = px[y * w + x]; if (!Gr(p)) continue; var r = px[y * w + x + 1]; var d = px[(y + 1) * w + x]; edn++; + if ((Gr(r) && System.Math.Abs(Lum(p) - Lum(r)) > 8) || (Gr(d) && System.Math.Abs(Lum(p) - Lum(d)) > 8)) ed++; + } + return string.Format("띠단계={0} LF={1:F2} HF={2:F2} 에지%={3:F2} 밝기={4:F1}", levels, lf, hf / hfn, 100.0 * ed / System.Math.Max(1, edn), mu); + } + static bool Gr(UnityEngine.Color32 p) { return p.g > p.b + 8 && p.g > 45; } + static float Lum(UnityEngine.Color32 p) { return 0.299f * p.r + 0.587f * p.g + 0.114f * p.b; } + static string Stat() + { + return "인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 삼각형=" + WL.Look.Farm.WLIslandGrass.Triangles + + " 드로우콜+" + WL.Look.Farm.WLIslandGrass.DrawnConfigs; + } + static UnityEngine.Camera MakeCam(string name, bool top, float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find(name); + if (go == null) go = new UnityEngine.GameObject(name); + var c = go.GetComponent(); + if (c == null) c = go.AddComponent(); + c.nearClipPlane = 0.1f; c.farClipPlane = top ? 200f : 300f; + if (top) + { + c.orthographic = true; c.orthographicSize = size; + c.transform.rotation = UnityEngine.Quaternion.Euler(90f, 0f, 0f); + c.transform.position = new UnityEngine.Vector3(cx, 60f, cz); + } + else + { + UnityEngine.Vector3 tgt = UnityEngine.Vector3.zero; + var pc = UnityEngine.GameObject.FindGameObjectWithTag("Player"); + if (pc != null) tgt = pc.transform.position; + c.orthographic = false; c.fieldOfView = 60f; + c.transform.rotation = UnityEngine.Quaternion.Euler(45f, 45f, 0f); + c.transform.position = tgt - c.transform.forward * 12f; + } + c.enabled = false; return c; + } + static UnityEngine.Texture2D Grab(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 string Shot(UnityEngine.Camera cam, int w, int h, string name) + { + var tex = Grab(cam, w, h); + System.IO.File.WriteAllBytes(Dir + "/" + name + ".png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + string s = Metric(tex.GetPixels32(), w, h); + UnityEngine.Object.DestroyImmediate(tex); + return s; + } + 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); Flush(); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816q_FINAL.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q final]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Fringe.cs b/AgentScripts/WL816q_Fringe.cs new file mode 100644 index 000000000..4f0c86071 --- /dev/null +++ b/AgentScripts/WL816q_Fringe.cs @@ -0,0 +1,153 @@ +// WL-816q 3회차 — 띠 가장자리 「톱니 윤곽」의 촘촘함을 데모 수준으로. +// 지렛대 = 풀 높이(scale) · 밀도. 인스턴스 수를 늘리지 않는 쪽(scale)부터 본다. 에셋 무변경. +public static class WL816q_Fringe +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qFringe"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qFringe"); + go.AddComponent(); + } +} + +public class WL816q_FringeRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int TS = 768; + static string Dir = "Screenshots_WL/WL816q"; + + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + System.IO.Directory.CreateDirectory(Dir); + 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); + if (g == null) { L("grass 없음"); Flush(); yield break; } + var rt = UnityEngine.Object.Instantiate(WL.Look.Farm.WLIslandLookSettings.Instance); + var arr = new WL.Look.Farm.WLScatterDef[rt.scatter.Length]; + for (int i = 0; i < arr.Length; i++) + { + var s = rt.scatter[i]; + arr[i] = new WL.Look.Farm.WLScatterDef { enabled_ = s.enabled_, label = s.label, mesh = s.mesh, material = s.material, + probability = s.probability, scale = s.scale, normalOffset = s.normalOffset, density = s.density }; + } + rt.scatter = arr; g.cfg = rt; rt.viewCullEnabled = 0; + + var b = g.CalculateInstancesBounds(); + var cam = MakeTop(b.center.x, b.center.z, 6f); + UnityEngine.Time.timeScale = 0f; yield return null; + + rt.grassEnabled = 0; g.Rebuild(); yield return null; yield return null; + var A = Grab(cam, TS, TS); var apx = A.GetPixels32(); + System.IO.File.WriteAllBytes(Dir + "/fringe_ground.png", UnityEngine.ImageConversion.EncodeToPNG(A)); + L("바닥만 " + Metric(apx, TS, TS)); + L("데모 참고 : 단계 8 LF 16.27 HF 0.33 에지% 1.22 · 이 카메라 = 64 px/m"); + rt.grassEnabled = 1; + + float[] scales = { 0.35f, 0.5f, 0.7f, 0.9f, 1.2f }; + float[] dens = { 2.5f, 3.5f }; + for (int di = 0; di < dens.Length; di++) + { + for (int si = 0; si < scales.Length; si++) + { + for (int i = 0; i < arr.Length; i++) + { + if (arr[i].label != null && arr[i].label.Contains("자갈")) continue; // 자갈은 그대로 + arr[i].scale = scales[si] * (arr[i].label != null && arr[i].label.Contains("꽃") ? 1f : 1f); + arr[i].density = dens[di]; + } + g.Rebuild(); yield return null; yield return null; + var B = Grab(cam, TS, TS); var bpx = B.GetPixels32(); + L(string.Format("scale={0:F2} density={1:F1} | {2} | 보이는 풀 {3:F2}% | 인스턴스 {4} 삼각형 {5}", + scales[si], dens[di], Metric(bpx, TS, TS), Cover(apx, bpx), + WL.Look.Farm.WLIslandGrass.Instances, WL.Look.Farm.WLIslandGrass.Triangles)); + System.IO.File.WriteAllBytes(Dir + "/fringe_s" + scales[si].ToString("F2") + "_d" + dens[di].ToString("F1") + ".png", + UnityEngine.ImageConversion.EncodeToPNG(B)); + UnityEngine.Object.DestroyImmediate(B); + } + } + UnityEngine.Object.DestroyImmediate(A); + UnityEngine.Time.timeScale = 1f; + Flush(); + } + + static float Cover(UnityEngine.Color32[] a, UnityEngine.Color32[] b) + { + int n = 0, tot = 0; + for (int i = 0; i < a.Length; i++) + { + var p = a[i]; if (!Gr(p)) continue; tot++; + var q = b[i]; if (q.g <= q.b + 4) continue; + if (System.Math.Abs(p.r - q.r) + System.Math.Abs(p.g - q.g) + System.Math.Abs(p.b - q.b) > 6) n++; + } + return tot == 0 ? 0f : 100f * n / tot; + } + static string Metric(UnityEngine.Color32[] px, int w, int h) + { + int bs = 8, bw = w / bs, bh = h / bs; + var bm = new float[bw * bh]; var bok = new bool[bw * bh]; + double hf = 0; int hfn = 0; + for (int by = 0; by < bh; by++) for (int bx = 0; bx < bw; bx++) + { + double s = 0; int n = 0; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; s += Lum(p); n++; } + if (n < bs * bs / 2) continue; + float mean = (float)(s / n); bm[by * bw + bx] = mean; bok[by * bw + bx] = true; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; hf += System.Math.Abs(Lum(p) - mean); hfn++; } + } + double s1 = 0, s2 = 0; int bn = 0; var lv = new int[40]; + for (int i = 0; i < bm.Length; i++) { if (!bok[i]) continue; s1 += bm[i]; s2 += (double)bm[i] * bm[i]; bn++; int q = (int)(bm[i] / 8f); if (q >= 0 && q < 40) lv[q]++; } + if (bn == 0 || hfn == 0) return "지면 0"; + double mu = s1 / bn, lf = System.Math.Sqrt(System.Math.Max(0, s2 / bn - mu * mu)); + int levels = 0; for (int i = 0; i < 40; i++) if (lv[i] >= bn * 0.005) levels++; + int ed = 0, edn = 0; + for (int y = 0; y < h - 1; y++) for (int x = 0; x < w - 1; x++) + { + var p = px[y * w + x]; if (!Gr(p)) continue; var r = px[y * w + x + 1]; var d = px[(y + 1) * w + x]; edn++; + if ((Gr(r) && System.Math.Abs(Lum(p) - Lum(r)) > 8) || (Gr(d) && System.Math.Abs(Lum(p) - Lum(d)) > 8)) ed++; + } + return string.Format("단계={0} LF={1:F2} HF={2:F2} 에지%={3:F2} 밝기={4:F1}", levels, lf, hf / hfn, 100.0 * ed / System.Math.Max(1, edn), mu); + } + static bool Gr(UnityEngine.Color32 p) { return p.g > p.b + 8 && p.g > 45; } + static float Lum(UnityEngine.Color32 p) { return 0.299f * p.r + 0.587f * p.g + 0.114f * p.b; } + + static UnityEngine.Camera MakeTop(float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find("~816qTop"); + if (go == null) go = new UnityEngine.GameObject("~816qTop"); + 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, 60f, cz); + c.enabled = false; return c; + } + static UnityEngine.Texture2D Grab(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 L(string s) { sb.AppendLine(s); Flush(); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816q_FRINGE.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q fringe]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Sweep.cs b/AgentScripts/WL816q_Sweep.cs new file mode 100644 index 000000000..89aab8244 --- /dev/null +++ b/AgentScripts/WL816q_Sweep.cs @@ -0,0 +1,166 @@ +// WL-816q 2회차 — 띠 단계 수·대비를 데모 수준(띠단계 8 · LF 16.3)까지 올린다. +// 바닥·풀 **양쪽** 구름 값을 똑같이 바꾼다(같아야 풀이 띠 안에서 녹아든다). 에셋 무변경(복제본 교체). +public static class WL816q_Sweep +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qSweep"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qSweep"); + go.AddComponent(); + } +} + +public class WL816q_SweepRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int TS = 768; + static string Dir = "Screenshots_WL/WL816q"; + + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + System.IO.Directory.CreateDirectory(Dir); + 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); + if (g == null) { L("grass 없음"); Flush(); yield break; } + var rt = UnityEngine.Object.Instantiate(WL.Look.Farm.WLIslandLookSettings.Instance); + var arr = new WL.Look.Farm.WLScatterDef[rt.scatter.Length]; + for (int i = 0; i < arr.Length; i++) + { + var s = rt.scatter[i]; + arr[i] = new WL.Look.Farm.WLScatterDef { enabled_ = s.enabled_, label = s.label, mesh = s.mesh, material = s.material, + probability = s.probability, scale = s.scale, normalOffset = s.normalOffset, density = s.density }; + } + rt.scatter = arr; g.cfg = rt; rt.viewCullEnabled = 0; + + // 바닥 머티리얼을 쓰는 렌더러를 전부 복제본으로 바꿔 둔다(에셋 무변경) + var topAsset = rt.islandTopMaterial; + var topClone = new UnityEngine.Material(topAsset); topClone.name = topAsset.name + "_sweep"; + int swapped = 0; + var rends = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsSortMode.None); + for (int i = 0; i < rends.Length; i++) + { + var ms = rends[i].sharedMaterials; bool hit = false; + for (int m = 0; m < ms.Length; m++) if (ms[m] == topAsset) { ms[m] = topClone; hit = true; } + if (hit) { rends[i].sharedMaterials = ms; swapped++; } + } + var grassAsset = arr[0].material; + var grassClone = new UnityEngine.Material(grassAsset); grassClone.name = grassAsset.name + "_sweep"; + for (int i = 0; i < arr.Length; i++) if (arr[i].material == grassAsset) arr[i].material = grassClone; + L("바닥 복제 교체 렌더러=" + swapped + " · 풀 복제 교체 완료"); + + var b = g.CalculateInstancesBounds(); + var cam = MakeTop(b.center.x, b.center.z, 6f); + UnityEngine.Time.timeScale = 0f; yield return null; + + L("목표(데모 참고 x_demo_ground) : 띠단계 8 · LF 16.27 · HF 0.33 · 에지% 1.22"); + float[] strs = { 1f, 1.5f, 2f, 2.5f, 3f, 4f }; + float[] covs = { 0.35f, 0.5f, 0.65f }; + for (int ci = 0; ci < covs.Length; ci++) + { + for (int si = 0; si < strs.Length; si++) + { + Set(topClone, strs[si], covs[ci]); Set(grassClone, strs[si], covs[ci]); + // 바닥만 + rt.grassEnabled = 0; g.Rebuild(); yield return null; yield return null; + var A = Grab(cam, TS, TS); var apx = A.GetPixels32(); + string mg = Metric(apx, TS, TS); + // 풀까지 + rt.grassEnabled = 1; g.Rebuild(); yield return null; yield return null; + var B = Grab(cam, TS, TS); var bpx = B.GetPixels32(); + L(string.Format("Strength={0:F1} Cover={1:F2} | 바닥만 {2} | 풀포함 {3} | 보이는 풀 {4:F2}%", + strs[si], covs[ci], mg, Metric(bpx, TS, TS), Cover(apx, bpx))); + if (System.Math.Abs(strs[si] - 2f) < 0.01f) + System.IO.File.WriteAllBytes(Dir + "/sweep_s2_c" + covs[ci].ToString("F2") + ".png", UnityEngine.ImageConversion.EncodeToPNG(B)); + UnityEngine.Object.DestroyImmediate(A); UnityEngine.Object.DestroyImmediate(B); + } + } + UnityEngine.Time.timeScale = 1f; + Flush(); + } + + static void Set(UnityEngine.Material m, float str, float cov) + { + m.SetFloat("_Cloud_Strength", str); m.SetFloat("_Cloud_Cover", cov); + } + + static float Cover(UnityEngine.Color32[] a, UnityEngine.Color32[] b) + { + int n = 0, tot = 0; + for (int i = 0; i < a.Length; i++) + { + var p = a[i]; if (!Gr(p)) continue; tot++; + var q = b[i]; if (q.g <= q.b + 4) continue; + if (System.Math.Abs(p.r - q.r) + System.Math.Abs(p.g - q.g) + System.Math.Abs(p.b - q.b) > 6) n++; + } + return tot == 0 ? 0f : 100f * n / tot; + } + static string Metric(UnityEngine.Color32[] px, int w, int h) + { + int bs = 8, bw = w / bs, bh = h / bs; + var bm = new float[bw * bh]; var bok = new bool[bw * bh]; + double hf = 0; int hfn = 0; + for (int by = 0; by < bh; by++) for (int bx = 0; bx < bw; bx++) + { + double s = 0; int n = 0; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; s += Lum(p); n++; } + if (n < bs * bs / 2) continue; + float mean = (float)(s / n); bm[by * bw + bx] = mean; bok[by * bw + bx] = true; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; hf += System.Math.Abs(Lum(p) - mean); hfn++; } + } + double s1 = 0, s2 = 0; int bn = 0; var lv = new int[40]; + for (int i = 0; i < bm.Length; i++) { if (!bok[i]) continue; s1 += bm[i]; s2 += (double)bm[i] * bm[i]; bn++; int q = (int)(bm[i] / 8f); if (q >= 0 && q < 40) lv[q]++; } + if (bn == 0 || hfn == 0) return "지면 0"; + double mu = s1 / bn, lf = System.Math.Sqrt(System.Math.Max(0, s2 / bn - mu * mu)); + int levels = 0; for (int i = 0; i < 40; i++) if (lv[i] >= bn * 0.005) levels++; + int ed = 0, edn = 0; + for (int y = 0; y < h - 1; y++) for (int x = 0; x < w - 1; x++) + { + var p = px[y * w + x]; if (!Gr(p)) continue; var r = px[y * w + x + 1]; var d = px[(y + 1) * w + x]; edn++; + if ((Gr(r) && System.Math.Abs(Lum(p) - Lum(r)) > 8) || (Gr(d) && System.Math.Abs(Lum(p) - Lum(d)) > 8)) ed++; + } + return string.Format("단계={0} LF={1:F2} HF={2:F2} 에지%={3:F2} 밝기={4:F1}", levels, lf, hf / hfn, 100.0 * ed / System.Math.Max(1, edn), mu); + } + static bool Gr(UnityEngine.Color32 p) { return p.g > p.b + 8 && p.g > 45; } + static float Lum(UnityEngine.Color32 p) { return 0.299f * p.r + 0.587f * p.g + 0.114f * p.b; } + + static UnityEngine.Camera MakeTop(float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find("~816qTop"); + if (go == null) go = new UnityEngine.GameObject("~816qTop"); + 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, 60f, cz); + c.enabled = false; return c; + } + static UnityEngine.Texture2D Grab(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 L(string s) { sb.AppendLine(s); Flush(); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816q_SWEEP.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q sweep]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Verify.cs b/AgentScripts/WL816q_Verify.cs new file mode 100644 index 000000000..1d805a45e --- /dev/null +++ b/AgentScripts/WL816q_Verify.cs @@ -0,0 +1,236 @@ +// WL-816q 검증 — 전/후 · 예산 · 0/2/4 s 좌표 불변(순간이동 0). 에셋 무변경(런타임 복제본). +public static class WL816q_Verify +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qVerify"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qVerify"); + go.AddComponent(); + } +} + +public class WL816q_VerifyRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int W = 1080, H = 1920, TS = 768; + static string Dir = "Screenshots_WL/WL816q"; + // 816n 이 쓰던 값 = 「전」 + static readonly UnityEngine.Color BeforeDif = new UnityEngine.Color(0.60541177f, 0.82788235f, 0.5798824f, 1f); + static readonly UnityEngine.Color BeforeShd = new UnityEngine.Color(0.5434118f, 0.744f, 0.5215294f, 1f); + + void Start() { StartCoroutine(Co()); } + + System.Collections.IEnumerator Co() + { + sb = new System.Text.StringBuilder(); + System.IO.Directory.CreateDirectory(Dir); + 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); + if (g == null) { L("grass 없음"); Flush(); yield break; } + var rt = UnityEngine.Object.Instantiate(WL.Look.Farm.WLIslandLookSettings.Instance); + var arr = new WL.Look.Farm.WLScatterDef[rt.scatter.Length]; + for (int i = 0; i < arr.Length; i++) + { + var s = rt.scatter[i]; + arr[i] = new WL.Look.Farm.WLScatterDef { enabled_ = s.enabled_, label = s.label, mesh = s.mesh, material = s.material, + probability = s.probability, scale = s.scale, normalOffset = s.normalOffset, density = s.density }; + } + rt.scatter = arr; g.cfg = rt; + + var baseMat = arr[0].material; + var clone = new UnityEngine.Material(baseMat); clone.name = baseMat.name + "_verify"; + for (int i = 0; i < arr.Length; i++) if (arr[i].material == baseMat) arr[i].material = clone; + L("풀 머티리얼 채택값 Dif=" + baseMat.GetColor("_DiffuseColor") + " Shd=" + baseMat.GetColor("_ShadowDiffuseColor") + + " Change=" + baseMat.GetFloat("_Cloud_Change") + " Move=" + baseMat.GetVector("_Cloud_Movement")); + + var b = g.CalculateInstancesBounds(); + var camTop = MakeCam("~816qTop", true, b.center.x, b.center.z, 6f); + var camPd = MakeCam("~816qPd", false, 0, 0, 0); + L("섬 " + b.center + " " + b.size + " · 탑다운 ortho6 · PD 12m/45°/fov60"); + + UnityEngine.Time.timeScale = 0f; yield return null; + + // ── 전 ──────────────────────────────────────────────────────── + SetGrass(clone, BeforeDif, BeforeShd); + g.viewCameraOverride = camTop; rt.viewCullEnabled = 0; g.Rebuild(); yield return null; yield return null; + L("[전 top] " + Shot(camTop, TS, TS, "iter1_before_top")); + g.viewCameraOverride = camPd; g.Rebuild(); yield return null; yield return null; + L("[전 PD ] " + Shot(camPd, W, H, "iter1_before_pd") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + + // ── 후 ──────────────────────────────────────────────────────── + SetGrass(clone, baseMat.GetColor("_DiffuseColor"), baseMat.GetColor("_ShadowDiffuseColor")); + g.viewCameraOverride = camTop; rt.viewCullEnabled = 0; g.Rebuild(); yield return null; yield return null; + L("[후 top 범위off] " + Shot(camTop, TS, TS, "iter1_after_top")); + g.viewCameraOverride = camPd; g.Rebuild(); yield return null; yield return null; + L("[후 PD 범위off] " + Shot(camPd, W, H, "iter1_after_pd_nocull") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + rt.viewCullEnabled = 1; g.Rebuild(); yield return null; yield return null; + L("[후 PD 범위on ] " + Shot(camPd, W, H, "iter1_after_pd") + " | " + Stat() + " | " + Bench(camPd, 60).ToString("F3") + " ms/frame"); + L(" " + WL.Look.Farm.WLIslandGrass.LastLog); + + // ── 띠 안쪽 vs 가장자리 ─────────────────────────────────────── + rt.grassEnabled = 0; g.Rebuild(); yield return null; yield return null; + g.viewCameraOverride = camTop; + var A = Grab(camTop, TS, TS); var apx = A.GetPixels32(); + rt.grassEnabled = 1; rt.viewCullEnabled = 0; g.Rebuild(); yield return null; yield return null; + var B = Grab(camTop, TS, TS); + L("[후] 보이는 풀 = " + Cover(apx, B.GetPixels32()).ToString("F2") + "% of 지면 (띠 안쪽에서 바닥과 같은 픽셀이면 낮다)"); + SetGrass(clone, BeforeDif, BeforeShd); g.Rebuild(); yield return null; yield return null; + var B0 = Grab(camTop, TS, TS); + L("[전] 보이는 풀 = " + Cover(apx, B0.GetPixels32()).ToString("F2") + "%"); + SetGrass(clone, baseMat.GetColor("_DiffuseColor"), baseMat.GetColor("_ShadowDiffuseColor")); + UnityEngine.Object.DestroyImmediate(A); UnityEngine.Object.DestroyImmediate(B); UnityEngine.Object.DestroyImmediate(B0); + + // ── 0 / 2 / 4 s 연속 — 좌표 집합이 그대로인가 ───────────────── + rt.viewCullEnabled = 1; g.viewCameraOverride = camPd; g.Rebuild(); + UnityEngine.Time.timeScale = 1f; + ulong k0 = 0; int n0 = 0; + for (int f = 0; f < 3; f++) + { + if (f > 0) { float t0 = UnityEngine.Time.realtimeSinceStartup; while (UnityEngine.Time.realtimeSinceStartup - t0 < 2f) yield return null; } + g.RefreshView(); // 카메라가 멈춰 있어도 강제로 다시 골라 본다 + yield return null; yield return null; + var tex = Grab(camPd, W, H); + System.IO.File.WriteAllBytes(Dir + "/iter1_t" + (f * 2) + "s.png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + UnityEngine.Object.DestroyImmediate(tex); + ulong k = WL.Look.Farm.WLIslandGrass.InstanceKey; int n = WL.Look.Farm.WLIslandGrass.Instances; + if (f == 0) { k0 = k; n0 = n; } + L("t=" + (f * 2) + "s 인스턴스=" + n + " 좌표지문=" + k + (f == 0 ? "" : (k == k0 && n == n0 ? " → 0 s 와 동일(생성·소멸·이동 0)" : " 🔴 달라졌다"))); + } + + // ── 카메라를 움직였을 때만 바뀌는가 ─────────────────────────── + var p0 = camPd.transform.position; + L("카메라 이동 전 인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 지문=" + WL.Look.Farm.WLIslandGrass.InstanceKey); + camPd.transform.position = p0 + new UnityEngine.Vector3(6f, 0f, 6f); + L(" 움직임 판정(2.5 m 보폭) = " + g.ViewMovedEnough()); + g.RefreshView(); yield return null; yield return null; + L(" 이동 후 인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 지문=" + WL.Look.Farm.WLIslandGrass.InstanceKey + " · " + WL.Look.Farm.WLIslandGrass.LastLog); + camPd.transform.position = p0; g.RefreshView(); yield return null; yield return null; + L(" 되돌린 뒤 인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 지문=" + WL.Look.Farm.WLIslandGrass.InstanceKey + (WL.Look.Farm.WLIslandGrass.InstanceKey == k0 ? " → 처음과 동일(격자 고정 확인)" : "")); + + g.viewCameraOverride = null; + Flush(); + } + + static void SetGrass(UnityEngine.Material m, UnityEngine.Color dif, UnityEngine.Color shd) + { + m.SetColor("_DiffuseColor", dif); m.SetColor("_ShadowDiffuseColor", shd); + } + + static float Cover(UnityEngine.Color32[] a, UnityEngine.Color32[] b) + { + int n = 0, tot = 0; + for (int i = 0; i < a.Length; i++) + { + var p = a[i]; if (!(p.g > p.b + 8 && p.g > 45)) continue; tot++; + var q = b[i]; if (q.g <= q.b + 4) continue; + if (System.Math.Abs(p.r - q.r) + System.Math.Abs(p.g - q.g) + System.Math.Abs(p.b - q.b) > 6) n++; + } + return tot == 0 ? 0f : 100f * n / tot; + } + + static string Metric(UnityEngine.Color32[] px, int w, int h) + { + int bs = 8, bw = w / bs, bh = h / bs; + var bm = new float[bw * bh]; var bok = new bool[bw * bh]; + double hf = 0; int hfn = 0; + for (int by = 0; by < bh; by++) + for (int bx = 0; bx < bw; bx++) + { + double s = 0; int n = 0; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; s += Lum(p); n++; } + if (n < bs * bs / 2) continue; + float mean = (float)(s / n); bm[by * bw + bx] = mean; bok[by * bw + bx] = true; + for (int y = 0; y < bs; y++) for (int x = 0; x < bs; x++) + { var p = px[(by * bs + y) * w + (bx * bs + x)]; if (!Gr(p)) continue; hf += System.Math.Abs(Lum(p) - mean); hfn++; } + } + double s1 = 0, s2 = 0; int bn = 0; var lv = new int[40]; + for (int i = 0; i < bm.Length; i++) { if (!bok[i]) continue; s1 += bm[i]; s2 += (double)bm[i] * bm[i]; bn++; int q = (int)(bm[i] / 8f); if (q >= 0 && q < 40) lv[q]++; } + if (bn == 0 || hfn == 0) return "지면 0"; + double mu = s1 / bn, lf = System.Math.Sqrt(System.Math.Max(0, s2 / bn - mu * mu)); + int levels = 0; for (int i = 0; i < 40; i++) if (lv[i] >= bn * 0.005) levels++; + int ed = 0, edn = 0; + for (int y = 0; y < h - 1; y++) for (int x = 0; x < w - 1; x++) + { + var p = px[y * w + x]; if (!Gr(p)) continue; var r = px[y * w + x + 1]; var d = px[(y + 1) * w + x]; edn++; + if ((Gr(r) && System.Math.Abs(Lum(p) - Lum(r)) > 8) || (Gr(d) && System.Math.Abs(Lum(p) - Lum(d)) > 8)) ed++; + } + return string.Format("띠세기LF={0:F2} 포기잡음HF={1:F2} LF/HF={2:F2} 띠단계={3} 에지%={4:F2} 평균밝기={5:F1}", + lf, hf / hfn, lf / System.Math.Max(0.01, hf / hfn), levels, 100.0 * ed / System.Math.Max(1, edn), mu); + } + static bool Gr(UnityEngine.Color32 p) { return p.g > p.b + 8 && p.g > 45; } + static float Lum(UnityEngine.Color32 p) { return 0.299f * p.r + 0.587f * p.g + 0.114f * p.b; } + + static string Stat() + { + return "인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 삼각형=" + WL.Look.Farm.WLIslandGrass.Triangles + + " 드로우콜+" + WL.Look.Farm.WLIslandGrass.DrawnConfigs; + } + + static UnityEngine.Camera MakeCam(string name, bool top, float cx, float cz, float size) + { + var go = UnityEngine.GameObject.Find(name); + if (go == null) go = new UnityEngine.GameObject(name); + var c = go.GetComponent(); + if (c == null) c = go.AddComponent(); + c.nearClipPlane = 0.1f; c.farClipPlane = top ? 200f : 300f; + if (top) + { + c.orthographic = true; c.orthographicSize = size; + c.transform.rotation = UnityEngine.Quaternion.Euler(90f, 0f, 0f); + c.transform.position = new UnityEngine.Vector3(cx, 60f, cz); + } + else + { + UnityEngine.Vector3 tgt = UnityEngine.Vector3.zero; + var pc = UnityEngine.GameObject.FindGameObjectWithTag("Player"); + if (pc != null) tgt = pc.transform.position; + c.orthographic = false; c.fieldOfView = 60f; + c.transform.rotation = UnityEngine.Quaternion.Euler(45f, 45f, 0f); + c.transform.position = tgt - c.transform.forward * 12f; + } + c.enabled = false; return c; + } + static UnityEngine.Texture2D Grab(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 string Shot(UnityEngine.Camera cam, int w, int h, string name) + { + var tex = Grab(cam, w, h); + System.IO.File.WriteAllBytes(Dir + "/" + name + ".png", UnityEngine.ImageConversion.EncodeToPNG(tex)); + string s = Metric(tex.GetPixels32(), w, h); + UnityEngine.Object.DestroyImmediate(tex); + return s; + } + 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); Flush(); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816q_VERIFY.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q verify]\n" + sb.ToString()); + } +} diff --git a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset index 657b9d3f4..953497735 100644 --- a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset +++ b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset @@ -82,7 +82,7 @@ MonoBehaviour: mesh: {fileID: -4413095505993930501, guid: 1f298817fdd2a184480e8af5b21278bc, type: 3} material: {fileID: 2100000, guid: dc21f047088dee14f9b080798ebe8560, type: 2} probability: 100 - scale: 0.35 + scale: 0.7 normalOffset: 0.04 density: 2.5 - enabled_: 1 @@ -113,8 +113,10 @@ MonoBehaviour: rebuildPollSeconds: 1 rebuildDelaySeconds: 1.4 viewCullEnabled: 1 - viewMargin: 8 - viewRebuildStep: 2.5 + viewMargin: 4 + viewRebuildStep: 1.5 + viewRebuildAngle: 3 + viewMaxDistance: 40 viewPollSeconds: 0.25 tileMasks: - meshName: 01 diff --git a/Assets/WL/Look/Farm/WLIslandGrass.cs b/Assets/WL/Look/Farm/WLIslandGrass.cs index de0705afb..4739a3af1 100644 --- a/Assets/WL/Look/Farm/WLIslandGrass.cs +++ b/Assets/WL/Look/Farm/WLIslandGrass.cs @@ -41,7 +41,8 @@ namespace WL.Look.Farm // 816q — 가시 범위 배치 진단 public static int ViewCulled, ViewRefreshes; public static bool ViewCullOn; - public static Rect ViewRect; + /// 실제로 그린 인스턴스 좌표 집합의 지문. 두 프레임이 같으면 생성·소멸·이동이 0 이다. + public static ulong InstanceKey; // 816o — 구름 경계 배치 진단 public static int Candidates, CloudRefreshes, CloudExcluded, EdgeCells, EdgeDilate, EdgeSamples; @@ -94,14 +95,15 @@ namespace WL.Look.Farm ViewRefreshes++; } - /// 바뀐 시야 사각형 중심이 직전 것과 이만큼 떨어졌는지(= 다시 골라야 하는지). + /// 카메라가 「다시 골라야 할 만큼」 움직였는지(위치 보폭 또는 회전 각). public bool ViewMovedEnough() { - if (cfg == null || cfg.viewCullEnabled == 0 || !_cacheValid) return false; - Rect r; - if (!TryViewRect(out r)) return false; - return Mathf.Abs(r.center.x - _viewBuiltCenter.x) > Mathf.Max(0.05f, cfg.viewRebuildStep) - || Mathf.Abs(r.center.y - _viewBuiltCenter.y) > Mathf.Max(0.05f, cfg.viewRebuildStep); + if (cfg == null || cfg.viewCullEnabled == 0 || !_cacheValid || !_viewOn) return false; + var cam = PickCamera(); + if (cam == null) return false; + if ((cam.transform.position - _viewBuiltPos).sqrMagnitude + > Mathf.Max(0.05f, cfg.viewRebuildStep) * Mathf.Max(0.05f, cfg.viewRebuildStep)) return true; + return Vector3.Angle(cam.transform.forward, _viewBuiltFwd) > Mathf.Max(0.1f, cfg.viewRebuildAngle); } /// @@ -322,8 +324,11 @@ namespace WL.Look.Farm // ── 816q — 가시 범위 ──────────────────────────────────────────── /// 측정·검증용. 비우면 `Camera.main`(없으면 첫 활성 카메라)을 쓴다. public Camera viewCameraOverride; - Rect _viewRect; - Vector2 _viewBuiltCenter; + readonly Vector2[] _poly = new Vector2[4]; // 카메라가 바닥에서 보는 사다리꼴 + readonly Vector2[] _polyN = new Vector2[4]; // 변의 바깥 법선 + readonly float[] _polyD = new float[4]; + Vector3 _viewCamPos, _viewCamFwd; // 지금 심어 둔 절두체를 만든 카메라 자세 + Vector3 _viewBuiltPos, _viewBuiltFwd; // 마지막으로 **다시 고른** 시점의 카메라 자세 bool _viewOn; Camera PickCamera() @@ -336,42 +341,53 @@ namespace WL.Look.Farm return null; } - /// 카메라가 바닥 평면(y = 윗면)에서 보는 사각형 + 여유 폭. 못 구하면 false. - bool TryViewRect(out Rect rect) + /// + /// 카메라가 **바닥 평면에서** 보는 사다리꼴을 구해 각 변을 여유 폭만큼 밖으로 민다. + /// 🔴 절두체 평면을 그대로 밀면 안 된다 — 위쪽 평면은 바닥과 거의 나란해서(15°) + /// 8 m 밀면 바닥에서는 8/sin15° ≈ 31 m 나 뻗어 면적이 6 배가 된다(실측 618 → 3,731 ㎡). + /// 바닥 평면에서 2 차원으로 밀면 넓이가 목적대로 「둘레 × 여유」 만큼만 는다. + /// + bool TryViewPlanes() { - rect = default(Rect); var cam = PickCamera(); if (cam == null) return false; - float y = _cacheY; - float far = cam.farClipPlane; - float minX = float.MaxValue, maxX = float.MinValue, minZ = float.MaxValue, maxZ = float.MinValue; + float maxD = Mathf.Max(1f, cfg.viewMaxDistance); for (int i = 0; i < 4; i++) { - var vp = new Vector3((i & 1) == 0 ? 0f : 1f, (i & 2) == 0 ? 0f : 1f, 0f); - var ray = cam.ViewportPointToRay(vp); - Vector3 hit; - // 평면을 향하지 않는 모서리(하늘로 나가는 방향)는 far 까지 뻗은 점으로 대신한다 - if (ray.direction.y < -1e-4f) - { - float t = (y - ray.origin.y) / ray.direction.y; - hit = ray.origin + ray.direction * Mathf.Min(t, far); - } - else hit = ray.origin + ray.direction * far; - - if (hit.x < minX) minX = hit.x; if (hit.x > maxX) maxX = hit.x; - if (hit.z < minZ) minZ = hit.z; if (hit.z > maxZ) maxZ = hit.z; + // 뷰포트 모서리를 (0,0) → (1,0) → (1,1) → (0,1) 순서로 돈다(사다리꼴이 꼬이지 않게) + float vx = (i == 1 || i == 2) ? 1f : 0f; + float vy = (i >= 2) ? 1f : 0f; + var ray = cam.ViewportPointToRay(new Vector3(vx, vy, 0f)); + float t = maxD; + if (ray.direction.y < -1e-4f) t = Mathf.Min((y - ray.origin.y) / ray.direction.y, maxD); + var h = ray.origin + ray.direction * t; + _poly[i] = new Vector2(h.x, h.z); } + // 변마다 바깥 법선. 사다리꼴의 감김 방향은 카메라 자세에 따라 뒤집히므로 부호를 맞춘다 + var c2 = Vector2.zero; + for (int i = 0; i < 4; i++) c2 += _poly[i]; + c2 *= 0.25f; + for (int i = 0; i < 4; i++) + { + var a = _poly[i]; var bb = _poly[(i + 1) & 3]; + var e = bb - a; + var n = new Vector2(e.y, -e.x); + if (n.sqrMagnitude < 1e-8f) n = new Vector2(1f, 0f); + n.Normalize(); + if (Vector2.Dot(n, a - c2) < 0f) n = -n; // 중심 반대쪽 = 바깥 + _polyN[i] = n; _polyD[i] = Vector2.Dot(n, a); + } + _viewCamPos = cam.transform.position; + _viewCamFwd = cam.transform.forward; + return true; + } + + bool InView(float x, float z) + { float m = Mathf.Max(0f, cfg.viewMargin); - minX -= m; maxX += m; minZ -= m; maxZ += m; - - // 섬 밖은 어차피 후보가 없다 — 사각형을 섬 경계로 잘라 중심 판정이 튀지 않게 한다 - var bmin = _bounds.min; var bmax = _bounds.max; - minX = Mathf.Max(minX, bmin.x); maxX = Mathf.Min(maxX, bmax.x); - minZ = Mathf.Max(minZ, bmin.z); maxZ = Mathf.Min(maxZ, bmax.z); - if (maxX <= minX || maxZ <= minZ) return false; - - rect = Rect.MinMaxRect(minX, minZ, maxX, maxZ); + var p = new Vector2(x, z); + for (int i = 0; i < 4; i++) if (Vector2.Dot(_polyN[i], p) - _polyD[i] > m) return false; return true; } @@ -405,12 +421,12 @@ namespace WL.Look.Farm // 816q — 카메라가 보는 범위(+여유 폭)만 남긴다. 좌표는 손대지 않는다(순간이동 0). _viewOn = false; - if (cfg.viewCullEnabled != 0 && TryViewRect(out _viewRect)) + if (cfg.viewCullEnabled != 0 && TryViewPlanes()) { _viewOn = true; - _viewBuiltCenter = _viewRect.center; + _viewBuiltPos = _viewCamPos; _viewBuiltFwd = _viewCamFwd; } - ViewCullOn = _viewOn; ViewRect = _viewRect; + ViewCullOn = _viewOn; var sw = System.Diagnostics.Stopwatch.StartNew(); var buckets = new List[_cdef.Length]; @@ -418,10 +434,11 @@ namespace WL.Look.Farm Vector3 bc = _bounds.center; bool edge = _edgeOn; + ulong key = 0UL; for (int i = 0; i < _candCount; i++) { var c = _cand[i]; - if (_viewOn && (c.x < _viewRect.xMin || c.x > _viewRect.xMax || c.z < _viewRect.yMin || c.z > _viewRect.yMax)) { ViewCulled++; continue; } + if (_viewOn && !InView(c.x, c.z)) { ViewCulled++; continue; } if (edge && !_field.IsEdge(c.x, c.z)) { CloudExcluded++; continue; } var rot = c.yaw != 0f ? Quaternion.Euler(0f, c.yaw, 0f) : Quaternion.identity; @@ -430,7 +447,12 @@ namespace WL.Look.Farm 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 }); + // 좌표 지문 — 순서에 무관하게 더한다(집합이 같으면 값도 같다) + key += (ulong)Mathf.RoundToInt(c.x * 1000f) * 73856093UL + ^ (ulong)Mathf.RoundToInt(c.z * 1000f) * 19349663UL + ^ (ulong)(c.def + 1) * 83492791UL; } + InstanceKey = key; sw.Stop(); FilterMs = (float)sw.Elapsed.TotalMilliseconds; @@ -449,8 +471,7 @@ namespace WL.Look.Farm + " · 밀도 " + UsedDensity.ToString("F2") + "(=" + (UsedDensity * UsedDensity).ToString("F2") + "개/㎡)" + (_budgetScale < 1f ? " · 예산으로 밀도 ×" + _budgetScale.ToString("F2") : "") + (_viewOn - ? " · 가시범위 " + _viewRect.width.ToString("F1") + "×" + _viewRect.height.ToString("F1") - + "m(여유 " + cfg.viewMargin.ToString("F1") + "m) · 범위밖 제외 " + ViewCulled + ? " · 가시범위 절두체(여유 " + cfg.viewMargin.ToString("F1") + "m) · 범위밖 제외 " + ViewCulled : " · 가시범위 off") + (_edgeOn ? " · 구름띠 " + (EdgeFraction * 100f).ToString("F1") + "%(칸 " + EdgeCellSize.ToString("F2") diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index 9d393ccee..c5459a8d5 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -321,11 +321,18 @@ namespace WL.Look.Farm [Tooltip("시야 사각형 바깥으로 더 두는 여유 폭(m). " + "🔴 `viewRebuildStep` 보다 커야 한다 — 차이만큼이 「화면 밖에서만 바뀐다」는 보장 폭이다.")] - public float viewMargin = 8f; + public float viewMargin = 4f; - [Tooltip("카메라(시야 사각형 중심)가 이만큼(m) 움직였을 때만 다시 고른다. " + + [Tooltip("카메라가 이만큼(m) 움직였을 때만 다시 고른다. " + "작을수록 자주 고르고, 클수록 여유 폭을 많이 먹는다.")] - public float viewRebuildStep = 2.5f; + public float viewRebuildStep = 1.5f; + + [Tooltip("카메라가 이만큼(°) 돌았을 때도 다시 고른다. 회전은 시야를 통째로 돌리므로 위치보다 민감하다.")] + public float viewRebuildAngle = 3f; + + [Tooltip("시야 사다리꼴의 먼 쪽을 이 거리(m)에서 끊는다. 하늘을 향하는 모서리도 여기로 자른다. " + + "PD 구도(12 m·45°)에서 화면 위끝의 바닥은 약 32 m 라 40 이면 남는다.")] + public float viewMaxDistance = 40f; [Tooltip("시야 사각형을 다시 재는 주기(초). 0 이면 감시 안 함(한 번 고르고 고정).")] public float viewPollSeconds = 0.25f;