[WL-816o] 섬 풀밭 = 구름 경계 기준 최적화 배치 (#816)

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) <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-15 02:09:26 +09:00
parent 2fb0b8489e
commit b23489def9
9 changed files with 1190 additions and 60 deletions

172
AgentScripts/WL816o_Diag.cs Normal file
View File

@ -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<WL816o_DiagRunner>();
}
}
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<WL.Look.Farm.WLIslandGrass>(UnityEngine.FindObjectsInactive.Include);
var cfg = WL.Look.Farm.WLIslandLookSettings.Instance;
var mat = cfg.cloudEdgeSourceMaterial != null ? cfg.cloudEdgeSourceMaterial : cfg.islandTopMaterial;
// 실제로 화면에 보이는 섬 윗면 렌더러가 무슨 머티리얼을 쓰는가 (런타임 교체 후)
var isls = UnityEngine.Object.FindObjectsByType<CryingSnow.FarmingIsland.Island>(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None);
for (int i = 0; i < isls.Length && i < 3; i++)
{
var r = isls[i].GetComponent<UnityEngine.Renderer>();
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<int, int>();
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<int>(hist.Keys); keys.Sort();
var top = new System.Collections.Generic.List<string>();
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<string>();
var cpu = new System.Collections.Generic.List<string>();
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<UnityEngine.Camera>(); if (c == null) c = go.AddComponent<UnityEngine.Camera>();
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());
}
}

28
AgentScripts/WL816o_Kw.cs Normal file
View File

@ -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<UnityEngine.Material>(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<string>();
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());
}
}

280
AgentScripts/WL816o_Play.cs Normal file
View File

@ -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<WL816o_PlayRunner>();
}
}
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<WL.Look.Farm.WLIslandGrass>(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; }
/// <summary>i 부터 n 픽셀 안에 6~45 크기의 밝기 계단이 있는가.</summary>
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<UnityEngine.Camera>();
if (c == null) c = go.AddComponent<UnityEngine.Camera>();
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<UnityEngine.Camera>();
if (c == null) c = go.AddComponent<UnityEngine.Camera>();
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());
}
}

View File

@ -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)

View File

@ -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
{
/// <summary>
/// 구름 그림자의 「색 띠 경계」를 XZ 격자에 한 번 구워 두고, 점 하나가 경계 근처인지 O(1) 로 답한다.
/// </summary>
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; // 격자에서 경계로 잡힌 칸의 비율(예산 추정에 쓴다)
/// <summary>구름 값이 1초에 월드 몇 m 를 흐르는가 — 갱신 주기 ↔ 띠 폭을 맞추는 데 쓴다.</summary>
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<Light>(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;
}
/// <summary>시간만 다시 읽는다(주기 갱신용 — 머티리얼·태양은 그대로).</summary>
public void Tick() { ResolveSun(); ResolveTime(); }
/// <summary>셰이더가 쓰는 `_Time.y`. 진단·검증에서 직접 넣어 볼 수 있다.</summary>
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");
}
// ─────────────────────────────────────────────────────────────
// ② 셰이더와 같은 식
// ─────────────────────────────────────────────────────────────
/// <summary>CloudShadows 서브그래프의 최종 출력 `Cloudiness`.</summary>
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;
}
/// <summary>ToonRamp 가 만드는 색 띠 번호. 이 값이 바뀌는 곳이 화면에서 보이는 경계다.</summary>
public int Band(float wx, float wy, float wz) { return BandOf(Cloudiness(wx, wy, wz)); }
/// <summary>구름 값 하나를 띠 번호로. (보간한 값에도 쓴다)</summary>
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;
}
// ─────────────────────────────────────────────────────────────
// ③ 격자에 굽기
// ─────────────────────────────────────────────────────────────
/// <summary>
/// 주어진 범위의 XZ 평면(높이 y)에 대해 띠 번호를 구우고, 번호가 바뀌는 칸을 경계로 표시한다.
/// </summary>
/// <param name="cell">격자 칸(m). 작을수록 띠가 얇고 정확하다.</param>
/// <param name="maxCells">칸 수 상한 — 섬이 커지면 칸을 자동으로 키운다(모바일 예산).</param>
/// <param name="dilate">경계에서 몇 칸 더 넓힐 것인가(구름이 흐르는 만큼 미리 덮어 둔다).</param>
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;
}
/// <summary>이 점이 색 띠 경계 근처인가. 격자 밖이면 자르지 않는다(안전 쪽).</summary>
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));
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2212bf029b1a9341ada8c8c84ebff63

View File

@ -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<Rect> _blockers = new List<Rect>(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; }
/// <summary>다시 깐다(섬이 확장됐을 때). 멱등 — 몇 번 불러도 안전.</summary>
public void Rebuild()
{
_cacheValid = false;
_fastRefresh = false;
if (!isActiveAndEnabled) { enabled = true; return; } // OnEnable 이 알아서 만든다
enabled = false; // OnDisable → 버퍼 해제
enabled = true; // OnEnable → 다시 생성
Rebuilds++;
}
/// <summary>
/// 816o — 구름이 흐른 만큼 **띠만** 다시 고른다. 타일·제외 사각형은 다시 훑지 않는다
/// (`FindObjectsByType` 를 타지 않으므로 전체 재생성보다 훨씬 싸다).
/// </summary>
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<InstancingSettings, List<InstanceData>> 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<float>(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<InstancingSettings, List<InstanceData>>();
// 경계 박스 — 구름 격자를 굽기 전에 정해져야 한다
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<float>(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<WLScatterDef>(8);
var allSets = new List<InstancingSettings>(8);
var cands = new List<Cand>(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<InstanceData>[defs.Count];
var iset = new InstancingSettings[defs.Count];
int baseIdx = allDefs.Count;
for (int i = 0; i < defs.Count; i++)
{
buckets[i] = new List<InstanceData>(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;
}
_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);
}
LastLog = "타일 " + Tiles + " · 인스턴스 " + Instances + " · 드로우콜 " + DrawnConfigs
+ " · 삼각형 " + Triangles + " · 제외점 " + Excluded
// ─────────────────────────────────────────────────────────────────
// 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<InstancingSettings, List<InstanceData>> 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<InstanceData>[_cdef.Length];
for (int i = 0; i < buckets.Length; i++) buckets[i] = new List<InstanceData>(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<InstancingSettings, List<InstanceData>>();
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;
}

View File

@ -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<WLIslandGrass>(FindObjectsInactive.Include);
if (g != null) g.RefreshCloudEdges();
}
}
}

View File

@ -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];