diff --git a/AgentScripts/WL816q_Iter.cs b/AgentScripts/WL816q_Iter.cs new file mode 100644 index 000000000..a508d62fa --- /dev/null +++ b/AgentScripts/WL816q_Iter.cs @@ -0,0 +1,140 @@ +// WL-816q 회차 — 풀이 「띠 안쪽에서 바닥과 같은 픽셀」이 되도록 풀 색을 수렴시킨다. +// 바닥만 찍은 기준 이미지와 같은 자리를 비교해 채널별 배율을 뉴턴식으로 갱신. +// 에셋 무변경(런타임 복제본만). +public static class WL816q_Iter +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qIter"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qIter"); + go.AddComponent(); + } +} + +public class WL816q_IterRunner : 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 < 5; i++) yield return new UnityEngine.WaitForSeconds(1f); + + var g = UnityEngine.Object.FindFirstObjectByType(UnityEngine.FindObjectsInactive.Include); + if (g == null) { L("WLIslandGrass 없음"); 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 dif0 = baseMat.GetColor("_DiffuseColor"); + var shd0 = baseMat.GetColor("_ShadowDiffuseColor"); + var clone = new UnityEngine.Material(baseMat); clone.name = baseMat.name + "_iter"; + for (int i = 0; i < arr.Length; i++) if (arr[i].material == baseMat) arr[i].material = clone; + + var b = g.CalculateInstancesBounds(); + var cam = MakeTop(b.center.x, b.center.z, 6f); + + // 시간을 멈춘다 — 구름이 흐르면 회차마다 바닥이 달라져 비교가 무효가 된다 + float ts = UnityEngine.Time.timeScale; + UnityEngine.Time.timeScale = 0f; + yield return null; + + // 기준 = 바닥만 + rt.grassEnabled = 0; g.Rebuild(); yield return null; yield return null; + var A = Grab(cam, TS, TS); + System.IO.File.WriteAllBytes(Dir + "/iter_A_ground.png", UnityEngine.ImageConversion.EncodeToPNG(A)); + var apx = A.GetPixels32(); + + rt.grassEnabled = 1; + float kr = 1f, kg = 1f, kb = 1f; + for (int it = 0; it < 5; it++) + { + clone.SetColor("_DiffuseColor", new UnityEngine.Color(dif0.r * kr, dif0.g * kg, dif0.b * kb, dif0.a)); + clone.SetColor("_ShadowDiffuseColor", new UnityEngine.Color(shd0.r * kr, shd0.g * kg, shd0.b * kb, shd0.a)); + g.Rebuild(); yield return null; yield return null; + var Bt = Grab(cam, TS, TS); + var bpx = Bt.GetPixels32(); + double rr, rg, rb; int cov; + Ratio(apx, bpx, out rr, out rg, out rb, out cov); + L(string.Format("회차{0} k=({1:F4},{2:F4},{3:F4}) → 풀/바닥 비=({4:F4},{5:F4},{6:F4}) 덮임={7:F1}% Dif=({8:F3},{9:F3},{10:F3})", + it, kr, kg, kb, rr, rg, rb, cov * 100.0 / (TS * TS), dif0.r * kr, dif0.g * kg, dif0.b * kb)); + System.IO.File.WriteAllBytes(Dir + "/iter_" + it + ".png", UnityEngine.ImageConversion.EncodeToPNG(Bt)); + UnityEngine.Object.DestroyImmediate(Bt); + if (System.Math.Abs(rr - 1) < 0.004 && System.Math.Abs(rg - 1) < 0.004 && System.Math.Abs(rb - 1) < 0.004) { L("수렴"); break; } + kr = (float)UnityEngine.Mathf.Clamp((float)(kr / System.Math.Max(0.5, rr)), 0.5f, 2.5f); + kg = (float)UnityEngine.Mathf.Clamp((float)(kg / System.Math.Max(0.5, rg)), 0.5f, 2.5f); + kb = (float)UnityEngine.Mathf.Clamp((float)(kb / System.Math.Max(0.5, rb)), 0.5f, 2.5f); + } + L(string.Format("채택 _DiffuseColor = {0:F4} {1:F4} {2:F4}", dif0.r * kr, dif0.g * kg, dif0.b * kb)); + L(string.Format("채택 _ShadowDiffuseColor = {0:F4} {1:F4} {2:F4}", shd0.r * kr, shd0.g * kg, shd0.b * kb)); + UnityEngine.Object.DestroyImmediate(A); + UnityEngine.Time.timeScale = ts; + Flush(); + } + + // 바닥만 이미지와 다른 픽셀 = 풀. 그 자리의 바닥색 대비 평균비. + static void Ratio(UnityEngine.Color32[] a, UnityEngine.Color32[] b, out double rr, out double rg, out double rb, out int cov) + { + double sa = 0, sb = 0, sa2 = 0, sb2 = 0, sa3 = 0, sb3 = 0; int n = 0; + for (int i = 0; i < a.Length; i++) + { + var p = a[i]; var q = b[i]; + if (!(p.g > p.b + 8 && p.g > 45)) continue; // 초록 지면만 + int d = System.Math.Abs(p.r - q.r) + System.Math.Abs(p.g - q.g) + System.Math.Abs(p.b - q.b); + if (d <= 6) continue; // 안 바뀐 = 풀 없음 + if (q.g <= q.b + 4) continue; // 꽃(노랑)·자갈 제외 + sa += p.r; sb += q.r; sa2 += p.g; sb2 += q.g; sa3 += p.b; sb3 += q.b; n++; + } + cov = n; + rr = n == 0 ? 1 : sb / sa; rg = n == 0 ? 1 : sb2 / sa2; rb = n == 0 ? 1 : sb3 / sa3; + } + + 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_ITER.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q iter]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Play.cs b/AgentScripts/WL816q_Play.cs new file mode 100644 index 000000000..32a8c8c91 --- /dev/null +++ b/AgentScripts/WL816q_Play.cs @@ -0,0 +1,268 @@ +// WL-816q — 「그라데이션(구름 띠)이 왜 안 보이나」 요소별 ON/OFF 실측. +// 에셋 무변경(SO·머티리얼은 전부 런타임 복제본). Debug.LogError 금지. +public static class WL816q_Play +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qPlay"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qPlay"); + go.AddComponent(); + } +} + +public class WL816q_PlayRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int W = 1080, H = 1920; + 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 < 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); + // scatter 정의를 새 인스턴스로 깊은 복사 — 에셋 쪽 객체를 절대 만지지 않는다 + if (rt.scatter != null) + { + 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; + + // ── 요소 진단 ──────────────────────────────────────────────── + L("=== 요소 진단 === t=" + UnityEngine.Time.timeSinceLevelLoad); + L("ⓑ WLReferenceLook.IsApplied=" + WL.Look.Arena.WLReferenceLook.IsApplied + + " · Outlined=" + WL.Look.Arena.WLReferenceLook.OutlinedRenderers + + " · Contrast=" + WL.Look.Arena.WLReferenceLook.ContrastMaterials); + int hull = 0, toonR = 0; + var rends = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsSortMode.None); + for (int i = 0; i < rends.Length; i++) + { + var ms = rends[i].sharedMaterials; + for (int m = 0; m < ms.Length; m++) + { + if (ms[m] == null || ms[m].shader == null) continue; + if (ms[m].shader.name == "WL/HullOutline") hull++; + if (ms[m].shader.name.Contains("Toon")) toonR++; + } + } + L("ⓑ 씬 안 HullOutline 머티리얼 슬롯=" + hull + " · Toon 슬롯=" + toonR); + var vols = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsSortMode.None); + L("ⓓ Volume 개수=" + vols.Length + (vols.Length > 0 ? " 첫 프로필=" + (vols[0].sharedProfile ? vols[0].sharedProfile.name : "-") : "")); + var sun = UnityEngine.RenderSettings.sun; + var lights = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsSortMode.None); + for (int i = 0; i < lights.Length; i++) + if (lights[i].type == UnityEngine.LightType.Directional) + L("ⓐ Directional " + lights[i].name + " shadows=" + lights[i].shadows + " int=" + lights[i].intensity + " rot=" + lights[i].transform.eulerAngles); + + var top = rt.islandTopMaterial; + var grassMat = (rt.scatter != null && rt.scatter.Length > 0) ? rt.scatter[0].material : null; + L("ⓒⓔ 바닥=" + (top ? top.name : "-") + " sh=" + (top && top.shader ? top.shader.name : "-") + " " + MatDump(top)); + L("ⓒⓔ 풀 =" + (grassMat ? grassMat.name : "-") + " sh=" + (grassMat && grassMat.shader ? grassMat.shader.name : "-") + " " + MatDump(grassMat)); + L("ⓔ 그림자비(Shadow/Diffuse) 바닥=" + RatioStr(top) + " · 풀=" + RatioStr(grassMat)); + + var b = g.CalculateInstancesBounds(); + L("섬 바운즈 center=" + b.center + " size=" + b.size); + + var camTop = MakeTop(b.center.x, b.center.z, 6f); + var camPd = MakePd(); + L("탑다운 = ortho6 @(" + b.center.x.ToString("F2") + ",60," + b.center.z.ToString("F2") + ") · PD = 12m/45°/fov60"); + + // ── A: 풀 끔 (바닥만) ─────────────────────────────────────── + rt.grassEnabled = 0; g.Rebuild(); yield return null; yield return null; + L("[A 바닥만] " + Shot(camTop, TS, TS, "A_top_groundonly") + " | " + Stat()); + Shot(camPd, W, H, "A_pd_groundonly"); + + // ── B: 현재 풀 (816n 상태) ────────────────────────────────── + rt.grassEnabled = 1; g.Rebuild(); yield return null; yield return null; + L("[B 현재풀] " + Shot(camTop, TS, TS, "B_top_before") + " | " + Stat()); + Shot(camPd, W, H, "B_pd_before"); + + // ── C: 풀 그림자색만 데모 비율로 (다른 값 전부 동일) ──────── + if (grassMat != null) + { + var clone = new UnityEngine.Material(grassMat); + clone.name = grassMat.name + "_816qTest"; + var dif = clone.GetColor("_DiffuseColor"); + clone.SetColor("_ShadowDiffuseColor", new UnityEngine.Color(dif.r * 0.3232f, dif.g * 0.3414f, dif.b * 0.3948f, dif.a)); + for (int i = 0; i < rt.scatter.Length; i++) + if (rt.scatter[i].material == grassMat) rt.scatter[i].material = clone; + g.Rebuild(); yield return null; yield return null; + L("[C 풀그림자=데모비율] " + Shot(camTop, TS, TS, "C_top_shadowfix") + " | " + Stat()); + Shot(camPd, W, H, "C_pd_shadowfix"); + } + + // ── D: 참고 — 데모 기준 캡처에 같은 지표 ──────────────────── + Ref("Screenshots_WL/WL816f/x_demo_ground.png", "D 데모참고(스케일 미상)"); + Ref("Screenshots_WL/WL816f/x_ours_ground.png", "D 우리참고(스케일 미상)"); + + Flush(); + } + + // ─────────────────────────────────────────── 지표 + // LF = 8×8 블록 평균 밝기의 표준편차(= 띠 세기) · HF = 블록 내 편차 평균(= 포기 잡음) + // levels = 블록 밝기를 8단계로 양자화했을 때 면적 0.5% 이상인 단계 수(= 띠 단계) + 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 (!Ground(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 (!Ground(p)) continue; + hf += System.Math.Abs(Lum(p) - mean); hfn++; + } + } + double bs1 = 0, bs2 = 0; int bn = 0; + var lv = new int[40]; + for (int i = 0; i < bm.Length; i++) { if (!bok[i]) continue; bs1 += bm[i]; bs2 += (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 = bs1 / bn; + double lf = System.Math.Sqrt(System.Math.Max(0, bs2 / bn - mu * mu)); + int levels = 0; for (int i = 0; i < 40; i++) if (lv[i] >= bn * 0.005) levels++; + // 에지% — 인접 픽셀 밝기차 8 이상 + 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 (!Ground(p)) continue; + var r = px[y * w + x + 1]; var d = px[(y + 1) * w + x]; + edn++; + if ((Ground(r) && System.Math.Abs(Lum(p) - Lum(r)) > 8) || (Ground(d) && System.Math.Abs(Lum(p) - Lum(d)) > 8)) ed++; + } + return string.Format("지면 {0:F1}% · 띠세기LF={1:F2} · 포기잡음HF={2:F2} · LF/HF={3:F2} · 띠단계={4} · 에지%={5:F2} · 평균밝기={6:F1}", + 100.0 * edn / (w * h), lf, hf / hfn, lf / System.Math.Max(0.01, hf / hfn), levels, 100.0 * ed / System.Math.Max(1, edn), mu); + } + + static bool Ground(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 void Ref(string path, string tag) + { + if (!System.IO.File.Exists(path)) { L(tag + " : 파일 없음 " + path); return; } + var tex = new UnityEngine.Texture2D(2, 2, UnityEngine.TextureFormat.RGB24, false); + if (!UnityEngine.ImageConversion.LoadImage(tex, System.IO.File.ReadAllBytes(path))) { L(tag + " : 읽기 실패"); return; } + L(tag + " " + tex.width + "x" + tex.height + " : " + Metric(tex.GetPixels32(), tex.width, tex.height)); + UnityEngine.Object.DestroyImmediate(tex); + } + + static string MatDump(UnityEngine.Material m) + { + if (m == null) return ""; + return "Shades=" + F(m, "_Shades") + " Bright=" + F(m, "_Brightness") + " MinDark=" + F(m, "_MinimumDarkness") + + " CloudStr=" + F(m, "_Cloud_Strength") + " CloudDens=" + F(m, "_Cloud_Density") + " Cover=" + F(m, "_Cloud_Cover") + + " Change=" + F(m, "_Cloud_Change") + " Move=" + (m.HasProperty("_Cloud_Movement") ? m.GetVector("_Cloud_Movement").ToString() : "-") + + " Dif=" + C(m, "_DiffuseColor") + " Shd=" + C(m, "_ShadowDiffuseColor") + + " kw=[" + string.Join(",", m.shaderKeywords) + "]"; + } + static string F(UnityEngine.Material m, string n) { return m.HasProperty(n) ? m.GetFloat(n).ToString("F4") : "-"; } + static string C(UnityEngine.Material m, string n) { if (!m.HasProperty(n)) return "-"; var c = m.GetColor(n); return "(" + c.r.ToString("F3") + "," + c.g.ToString("F3") + "," + c.b.ToString("F3") + ")"; } + static string RatioStr(UnityEngine.Material m) + { + if (m == null || !m.HasProperty("_DiffuseColor") || !m.HasProperty("_ShadowDiffuseColor")) return "-"; + var d = m.GetColor("_DiffuseColor"); var s = m.GetColor("_ShadowDiffuseColor"); + return "(" + Safe(s.r, d.r) + "," + Safe(s.g, d.g) + "," + Safe(s.b, d.b) + ")"; + } + static string Safe(float a, float b) { return b < 0.001f ? "-" : (a / b).ToString("F3"); } + + static string Stat() + { + return "인스턴스=" + WL.Look.Farm.WLIslandGrass.Instances + " 삼각형=" + WL.Look.Farm.WLIslandGrass.Triangles + + " 드로우콜+" + WL.Look.Farm.WLIslandGrass.DrawnConfigs + " 타일=" + WL.Look.Farm.WLIslandGrass.Tiles; + } + + // ─────────────────────────────────────────── 카메라·캡처 + 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.Camera MakePd() + { + UnityEngine.Vector3 tgt = UnityEngine.Vector3.zero; + var pc = UnityEngine.GameObject.FindGameObjectWithTag("Player"); + if (pc != null) tgt = pc.transform.position; + var go = UnityEngine.GameObject.Find("~816qPd"); + if (go == null) go = new UnityEngine.GameObject("~816qPd"); + var c = go.GetComponent(); + if (c == null) c = go.AddComponent(); + c.orthographic = false; c.fieldOfView = 60f; 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.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 void L(string s) { sb.AppendLine(s); Flush(); } + static void Flush() + { + System.IO.File.WriteAllText("AgentScripts/WL816q_PLAY.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q play]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Scan.cs b/AgentScripts/WL816q_Scan.cs new file mode 100644 index 000000000..57a7b8ffc --- /dev/null +++ b/AgentScripts/WL816q_Scan.cs @@ -0,0 +1,141 @@ +// WL-816q 스캔 — 풀이 바닥과 같은 톤 단계에 들어가는 「고원」의 폭과 중심을 찾는다. +// 구름 위상 3곳에서 재확인해 특정 순간에만 맞는 값을 배제한다. 에셋 무변경. +public static class WL816q_Scan +{ + public static void Start() + { + var go = UnityEngine.GameObject.Find("~WL816qScan"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + go = new UnityEngine.GameObject("~WL816qScan"); + go.AddComponent(); + } +} + +public class WL816q_ScanRunner : UnityEngine.MonoBehaviour +{ + static System.Text.StringBuilder sb; + const int TS = 512; + static string Dir = "Screenshots_WL/WL816q"; + static readonly float[] K0 = { 1.0737f, 1.1189f, 1.1365f }; + + 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 < 5; 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 dif0 = baseMat.GetColor("_DiffuseColor"); + var shd0 = baseMat.GetColor("_ShadowDiffuseColor"); + var clone = new UnityEngine.Material(baseMat); clone.name = baseMat.name + "_scan"; + for (int i = 0; i < arr.Length; i++) if (arr[i].material == baseMat) arr[i].material = clone; + + var b = g.CalculateInstancesBounds(); + var cam = MakeTop(b.center.x, b.center.z, 6f); + + float[] phases = { 0f, 3.5f, 7f }; // 구름이 흘러간 서로 다른 순간 3곳 + float[] ss = { 0.97f, 0.98f, 0.99f, 1.00f, 1.01f, 1.02f, 1.03f, 1.04f, 1.05f, 1.06f, 1.07f, 1.08f }; + var cov = new float[phases.Length, ss.Length]; + + for (int p = 0; p < phases.Length; p++) + { + if (p > 0) { UnityEngine.Time.timeScale = 1f; float t0 = UnityEngine.Time.realtimeSinceStartup; while (UnityEngine.Time.realtimeSinceStartup - t0 < 3.5f) yield return null; } + 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(); + rt.grassEnabled = 1; + for (int i = 0; i < ss.Length; i++) + { + float s = ss[i]; + clone.SetColor("_DiffuseColor", new UnityEngine.Color(dif0.r * K0[0] * s, dif0.g * K0[1] * s, dif0.b * K0[2] * s, dif0.a)); + clone.SetColor("_ShadowDiffuseColor", new UnityEngine.Color(shd0.r * K0[0] * s, shd0.g * K0[1] * s, shd0.b * K0[2] * s, shd0.a)); + g.Rebuild(); yield return null; yield return null; + var Bt = Grab(cam, TS, TS); + cov[p, i] = Cover(apx, Bt.GetPixels32()); + UnityEngine.Object.DestroyImmediate(Bt); + } + UnityEngine.Object.DestroyImmediate(A); + var line = "구름 위상 " + p + " (t=" + UnityEngine.Time.timeSinceLevelLoad.ToString("F1") + "s) 보이는 풀 % : "; + for (int i = 0; i < ss.Length; i++) line += ss[i].ToString("F2") + "=" + cov[p, i].ToString("F2") + " "; + L(line); + } + + // 세 위상 모두에서 최소인 구간의 중심 + int best = 0; float bestv = 999f; + for (int i = 0; i < ss.Length; i++) + { + float m = 0f; for (int p = 0; p < phases.Length; p++) m = UnityEngine.Mathf.Max(m, cov[p, i]); + if (m < bestv) { bestv = m; best = i; } + } + float sBest = ss[best]; + L("최적 s=" + sBest.ToString("F2") + " (세 위상 최대 보이는 풀 " + bestv.ToString("F2") + "%)"); + L(string.Format("채택 _DiffuseColor = {0:F4} {1:F4} {2:F4}", dif0.r * K0[0] * sBest, dif0.g * K0[1] * sBest, dif0.b * K0[2] * sBest)); + L(string.Format("채택 _ShadowDiffuseColor = {0:F4} {1:F4} {2:F4}", shd0.r * K0[0] * sBest, shd0.g * K0[1] * sBest, shd0.b * K0[2] * sBest)); + 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 (!(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 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_SCAN.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q scan]\n" + sb.ToString()); + } +} diff --git a/AgentScripts/WL816q_Stat.cs b/AgentScripts/WL816q_Stat.cs new file mode 100644 index 000000000..3c34eebe8 --- /dev/null +++ b/AgentScripts/WL816q_Stat.cs @@ -0,0 +1,20 @@ +public static class WL816q_Stat +{ + public static void Go() + { + var sb = new System.Text.StringBuilder(); + int n = UnityEngine.SceneManagement.SceneManager.sceneCount; + for (int i = 0; i < n; i++) + { + var s = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i); + sb.Append(s.name + "(loaded=" + s.isLoaded + ") "); + } + var r = UnityEngine.GameObject.Find("~WL816qPlay"); + sb.Append(" | runner=" + (r != null) + " | t=" + UnityEngine.Time.timeSinceLevelLoad); + var g = UnityEngine.Object.FindFirstObjectByType(UnityEngine.FindObjectsInactive.Include); + sb.Append(" | grass=" + (g != null) + " inst=" + WL.Look.Farm.WLIslandGrass.Instances + " tiles=" + WL.Look.Farm.WLIslandGrass.Tiles); + sb.Append(" | files=" + (System.IO.Directory.Exists("Screenshots_WL/WL816q") ? System.IO.Directory.GetFiles("Screenshots_WL/WL816q").Length : -1)); + System.IO.File.WriteAllText("AgentScripts/WL816q_STAT.txt", sb.ToString()); + UnityEngine.Debug.Log("[816q stat] " + sb.ToString()); + } +} diff --git a/Assets/WL/Look/Farm/Materials/Farm_Grass_Arena.mat b/Assets/WL/Look/Farm/Materials/Farm_Grass_Arena.mat index dd0415f5c..5e9c48621 100644 --- a/Assets/WL/Look/Farm/Materials/Farm_Grass_Arena.mat +++ b/Assets/WL/Look/Farm/Materials/Farm_Grass_Arena.mat @@ -109,7 +109,7 @@ Material: - _CLOUDSENABLED: 1 - _ClearCoatMask: 0 - _ClearCoatSmoothness: 0 - - _Cloud_Change: 0.001 + - _Cloud_Change: 0.005 - _Cloud_Cover: 0.5 - _Cloud_Density: 0.01 - _Cloud_Strength: 1 @@ -147,12 +147,12 @@ Material: - _ZWrite: 1 m_Colors: - _BaseColor: {r: 1, g: 1, b: 1, a: 1} - - _Cloud_Movement: {r: 0.2, g: 0.2, b: 0, a: 0} + - _Cloud_Movement: {r: 1, g: 1, b: 0, a: 0} - _Cloud_Step: {r: 13, g: 17, b: 0, a: 0} - _Color: {r: 1, g: 1, b: 1, a: 1} - - _DiffuseColor: {r: 0.60541177, g: 0.82788235, b: 0.5798824, a: 1} + - _DiffuseColor: {r: 0.650031, g: 0.926315, b: 0.659036, a: 1} - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} - - _ShadowDiffuseColor: {r: 0.5434118, g: 0.744, b: 0.5215294, a: 1} + - _ShadowDiffuseColor: {r: 0.58346, g: 0.832462, b: 0.592718, a: 1} - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} - _WindMovement: {r: 6, g: 0, b: 0, a: 0} m_BuildTextureStacks: [] diff --git a/Assets/WL/Look/Farm/Materials/Farm_IslandTop_Arena.mat b/Assets/WL/Look/Farm/Materials/Farm_IslandTop_Arena.mat index 17dd2d1dc..9b22245d7 100644 --- a/Assets/WL/Look/Farm/Materials/Farm_IslandTop_Arena.mat +++ b/Assets/WL/Look/Farm/Materials/Farm_IslandTop_Arena.mat @@ -56,7 +56,7 @@ Material: - _Blend: 0 - _Brightness: 0.25 - _CastShadows: 1 - - _Cloud_Change: 0.001 + - _Cloud_Change: 0.005 - _Cloud_Cover: 0.5 - _Cloud_Density: 0.01 - _Cloud_Strength: 1 @@ -80,7 +80,7 @@ Material: - _ZWrite: 1 - _ZWriteControl: 0 m_Colors: - - _Cloud_Movement: {r: 0.2, g: 0.2, b: 0, a: 0} + - _Cloud_Movement: {r: 1, g: 1, b: 0, a: 0} - _Cloud_Step: {r: 13, g: 17, b: 0, a: 0} - _DiffuseColor: {r: 1, g: 1, b: 1, a: 1} - _NormalBias: {r: 1, g: 1, b: 1, a: 0} diff --git a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset index 67be40b7b..657b9d3f4 100644 --- a/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset +++ b/Assets/WL/Look/Farm/Resources/WL/WLIslandLookSettings.asset @@ -112,6 +112,10 @@ MonoBehaviour: maxInstances: 40000 rebuildPollSeconds: 1 rebuildDelaySeconds: 1.4 + viewCullEnabled: 1 + viewMargin: 8 + viewRebuildStep: 2.5 + viewPollSeconds: 0.25 tileMasks: - meshName: 01 res: 64 diff --git a/Assets/WL/Look/Farm/WLIslandGrass.cs b/Assets/WL/Look/Farm/WLIslandGrass.cs index e01c504a6..de0705afb 100644 --- a/Assets/WL/Look/Farm/WLIslandGrass.cs +++ b/Assets/WL/Look/Farm/WLIslandGrass.cs @@ -38,6 +38,11 @@ namespace WL.Look.Farm public static float UsedDensity; public static string LastLog = ""; + // 816q — 가시 범위 배치 진단 + public static int ViewCulled, ViewRefreshes; + public static bool ViewCullOn; + public static Rect ViewRect; + // 816o — 구름 경계 배치 진단 public static int Candidates, CloudRefreshes, CloudExcluded, EdgeCells, EdgeDilate, EdgeSamples; public static float EdgeFraction, EdgeCellSize, EdgeSampleCell, BakeMs, FilterMs; @@ -74,6 +79,31 @@ namespace WL.Look.Farm Rebuilds++; } + /// + /// 816q — 카메라가 움직인 만큼 **가시 범위만** 다시 고른다(타일 재수집 없음). + /// 좌표는 월드 고정 격자라 이미 보이던 풀은 제자리 그대로다 — 늘고 주는 것은 여유 폭 안에서만. + /// + public void RefreshView() + { + if (!_cacheValid || !isActiveAndEnabled) return; + if (cfg == null || cfg.viewCullEnabled == 0) return; + _fastRefresh = true; + enabled = false; + enabled = true; + _fastRefresh = false; + 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); + } + /// /// 816o — 구름이 흐른 만큼 **띠만** 다시 고른다. 타일·제외 사각형은 다시 훑지 않는다 /// (`FindObjectsByType` 를 타지 않으므로 전체 재생성보다 훨씬 싸다). @@ -289,6 +319,62 @@ namespace WL.Look.Farm float _budgetScale = 1f; bool _edgeOn; + // ── 816q — 가시 범위 ──────────────────────────────────────────── + /// 측정·검증용. 비우면 `Camera.main`(없으면 첫 활성 카메라)을 쓴다. + public Camera viewCameraOverride; + Rect _viewRect; + Vector2 _viewBuiltCenter; + bool _viewOn; + + Camera PickCamera() + { + if (viewCameraOverride != null) return viewCameraOverride; + var c = Camera.main; + if (c != null) return c; + var all = Object.FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < all.Length; i++) if (all[i].isActiveAndEnabled) return all[i]; + return null; + } + + /// 카메라가 바닥 평면(y = 윗면)에서 보는 사각형 + 여유 폭. 못 구하면 false. + bool TryViewRect(out Rect rect) + { + 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; + 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; + } + 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); + return true; + } + void BakeField() { float cell = Mathf.Max(0.05f, cfg.cloudEdgeCell); @@ -315,7 +401,16 @@ namespace WL.Look.Farm BakeField(); } - Instances = 0; Triangles = 0; DrawnConfigs = 0; CloudExcluded = 0; + Instances = 0; Triangles = 0; DrawnConfigs = 0; CloudExcluded = 0; ViewCulled = 0; + + // 816q — 카메라가 보는 범위(+여유 폭)만 남긴다. 좌표는 손대지 않는다(순간이동 0). + _viewOn = false; + if (cfg.viewCullEnabled != 0 && TryViewRect(out _viewRect)) + { + _viewOn = true; + _viewBuiltCenter = _viewRect.center; + } + ViewCullOn = _viewOn; ViewRect = _viewRect; var sw = System.Diagnostics.Stopwatch.StartNew(); var buckets = new List[_cdef.Length]; @@ -326,6 +421,7 @@ namespace WL.Look.Farm 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 (edge && !_field.IsEdge(c.x, c.z)) { CloudExcluded++; continue; } var rot = c.yaw != 0f ? Quaternion.Euler(0f, c.yaw, 0f) : Quaternion.identity; @@ -352,6 +448,10 @@ namespace WL.Look.Farm + " · 드로우콜 " + DrawnConfigs + " · 삼각형 " + Triangles + " · 제외점 " + Excluded + " · 밀도 " + 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 + : " · 가시범위 off") + (_edgeOn ? " · 구름띠 " + (EdgeFraction * 100f).ToString("F1") + "%(칸 " + EdgeCellSize.ToString("F2") + "m×" + EdgeCells + " · 넓힘 " + EdgeDilate + " · 노이즈 " + EdgeSampleCell.ToString("F2") diff --git a/Assets/WL/Look/Farm/WLIslandLook.cs b/Assets/WL/Look/Farm/WLIslandLook.cs index 3ac11ebea..9e9793be6 100644 --- a/Assets/WL/Look/Farm/WLIslandLook.cs +++ b/Assets/WL/Look/Farm/WLIslandLook.cs @@ -167,10 +167,24 @@ namespace WL.Look.Farm { int last = CountUnlocked(scene); float nextCloud = Time.time + Mathf.Max(0f, cfg.cloudEdgeRefreshSeconds); + float nextView = Time.time + Mathf.Max(0f, cfg.viewPollSeconds); + float nextPoll = Time.time + cfg.rebuildPollSeconds; while (scene.isLoaded) { - yield return new WaitForSeconds(cfg.rebuildPollSeconds); + // 816q — 카메라 감시는 확장 감시(1 s)보다 촘촘해야 해서 따로 돈다 + float wait = cfg.viewCullEnabled != 0 && cfg.viewPollSeconds > 0f + ? Mathf.Min(cfg.rebuildPollSeconds, cfg.viewPollSeconds) : cfg.rebuildPollSeconds; + yield return new WaitForSeconds(wait); if (!scene.isLoaded) yield break; + + if (cfg.viewCullEnabled != 0 && cfg.viewPollSeconds > 0f && Time.time >= nextView) + { + nextView = Time.time + cfg.viewPollSeconds; + var gv = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (gv != null && gv.ViewMovedEnough()) gv.RefreshView(); + } + if (Time.time < nextPoll) continue; + nextPoll = Time.time + cfg.rebuildPollSeconds; HookIslands(cfg, scene); int now = CountUnlocked(scene); if (now != last) diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index 48e724362..9d393ccee 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -311,6 +311,25 @@ namespace WL.Look.Farm "바닥과 **같은 식**이어야 띠가 어긋나지 않는다.")] public Material cloudEdgeSourceMaterial; + // ───────────────────────────────── §3 가시 범위 배치 (816q) + [Header("§3 — 카메라가 보는 범위에만 인스턴스를 둔다 (816q · 배치가 아니라 범위로 최적화)")] + [Tooltip("🔴 되돌리기 스위치 — 0 이면 섬 전체에 인스턴스를 둔다(816n 상태). " + + "1 이면 카메라 시야 사각형 + 여유 폭 안에만 둔다. " + + "격자는 월드 고정이라 같은 자리의 풀은 항상 같은 자리다(순간이동 0) — " + + "늘고 주는 것은 **화면 밖 여유 폭 안에서만** 일어난다.")] + public int viewCullEnabled = 1; + + [Tooltip("시야 사각형 바깥으로 더 두는 여유 폭(m). " + + "🔴 `viewRebuildStep` 보다 커야 한다 — 차이만큼이 「화면 밖에서만 바뀐다」는 보장 폭이다.")] + public float viewMargin = 8f; + + [Tooltip("카메라(시야 사각형 중심)가 이만큼(m) 움직였을 때만 다시 고른다. " + + "작을수록 자주 고르고, 클수록 여유 폭을 많이 먹는다.")] + public float viewRebuildStep = 2.5f; + + [Tooltip("시야 사각형을 다시 재는 주기(초). 0 이면 감시 안 함(한 번 고르고 고정).")] + public float viewPollSeconds = 0.25f; + // ───────────────────────────────── 구운 마스크 [Header("§1 — 구운 윗면 마스크 (에디터에서 생성 · 손대지 말 것)")] public WLTileMask[] tileMasks = new WLTileMask[0];