// WL783_GrassBF.cs — PD 지시 #783 「BruteForce-GrassShader 의 기능을 제대로 써서」 WL_Nature 잔디 재구성 // // #781 이 쓰지 않았던 BF 기능을 전부 켠다. // K1 연속 카펫 메시 (기하 구멍 0 — 1 m 격자 계단 = 타일 느낌의 원인) → BuildCarpet // K2 _NoGrassTex 브러시 마스크 페인팅 (FBM 패치 + 붓 스트로크 + 소프트 엣지) → PaintMask // K3 _GrassCut 로 베이스 셸까지 알파 클립 → 잎 사이로 실제 지형이 보인다 → BuildMat // (+ _Color/_GroundColor/_SelfShadowColor 를 지면 머티리얼 색에서 역산) // K4 _TilingN1 로 잎 주기 0.4~0.6 m · 셸 6 · _FadeDistanceStart/End // K5 _UseRT + CameraEffectURP + PEMouseURP + WL_GrassInteract → 발밑 눌림 // K6 _WindMovement/_WindForce/_Distortion 은은한 바람 // K7 다발(#781 ③) 을 마스크 밀집 패치에만 남기고 크게 → TuftsFilter // // 실행 // unity command run_script --file AgentScripts/WL783_GrassBF.cs --entry WL783_GrassBF.PaintMask --args '[20260906, 56, 3.0]' // unity command run_script --file AgentScripts/WL783_GrassBF.cs --entry WL783_GrassBF.ApplyBF --args '[20260906, 6, 0.5, 6, 30, 1.0]' // unity command run_script --file AgentScripts/WL783_GrassBF.cs --entry WL783_GrassBF.RemoveAll // // 원칙: 수치는 전부 인자/머티리얼 프로퍼티(C45) · BF/LMHPOLY 원본 무접촉(전부 사본) · 콜라이더 0 · NavMesh 무영향 using System.Collections.Generic; using System.Linq; using System.Text; using UnityEngine; public static class WL783_GrassBF { // ── 경로 ──────────────────────────────────────────────────── const string kPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kTerrainMat = "Assets/LMHPOLY/Low Poly Nature Bundle/Modular Terrain/Terrain_Assets/Materials/MT_Terrain_01.mat"; const string kBFTexDir = "Assets/BruteForce-GrassShader/Materials/A_Textures/"; const string kBFDemoMat = "Assets/BruteForce-GrassShader/Materials/URP/MacMobile/URPMacMobileGrass01.mat"; const string kBFCamPrefab = "Assets/BruteForce-GrassShader/Prefab/CameraEffect/CameraEffectURP.prefab"; const string kBFTrailPrefab = "Assets/BruteForce-GrassShader/Prefab/URP/ParticleEffects/PEMouseURP.prefab"; const string kWLMat = "Assets/WL/Materials"; const string kWLMesh = "Assets/WL/Meshes"; const string kWLTex = "Assets/WL/Textures"; const string kMaskPath = kWLTex + "/WL_GrassMask.asset"; const string kColorPath = kWLTex + "/WL_GrassColor.asset"; const string kWhitePath = kWLTex + "/WL_White4.asset"; const string kCarpetPath = kWLMesh + "/WL_GrassCarpetBF.asset"; const string kMatPath = kWLMat + "/WL_Grass_BF.mat"; const string kRTPath = kWLTex + "/WL_GrassRT.renderTexture"; const string kOut = @"E:\NerdNavis\nn_himminji\AgentScripts\staging\WL_Grass4\out"; // ── 컨테이너 ──────────────────────────────────────────────── const string kGo = "WL_Grass_BF"; // 셸 카펫 const string kGoRT = "WL_Grass_RT"; // 상호작용 (RT 카메라 + 눌림 파티클) const string kGoOldA = "WL_Grass_A_Shell"; const string kGoC = "WL_Grass_C_Tufts"; // ── 플레이 영역 / 좌표계 ──────────────────────────────────── const float kCX = 115f, kCZ = 10f, kHalf = 100f; // #781 실측 플레이 영역 (200×200 m) const float kWorldScale = 256f; // USE_WC: mainUV = worldXZ / 256 → 마스크 1타일 = 256 m const int kMaskN = 1024; // 0.25 m/texel const int kColorN = 256; // 1.0 m/texel const float kChunk = 50f; // 청크 한 변 (UInt16 인덱스 여유) const float kCell = 1.5f; // 카펫 격자 (지형 컨폼 · 셸 삼각형 예산) const float kProbe = 0.5f; // 지형 실측 격자 const string kFxLayerName = "GrassFX"; // 눌림 이펙트 전용 레이어 static StringBuilder s_log; static void L(string s) { if (s_log != null) s_log.AppendLine(s); } static string V(Vector3 v) { return string.Format("({0:F2},{1:F2},{2:F2})", v.x, v.y, v.z); } static void Flush(string name) { try { System.IO.Directory.CreateDirectory(kOut); System.IO.File.WriteAllText(System.IO.Path.Combine(kOut, name), s_log.ToString()); } catch { } } // ═══════════════════════════════════════════════════════════ // 임시 씬 · 지형 실측 // ═══════════════════════════════════════════════════════════ static Vector3 s_start; static List s_water; static List s_feature; // 나무·바위 (붓 스트로크 시작점 편향용) static float s_topY = 200f, s_rayLen = 500f; static readonly RaycastHit[] s_hits = new RaycastHit[24]; static GameObject OpenTemp(out UnityEditor.SceneManagement.SceneSetup[] setup) { setup = UnityEditor.SceneManagement.EditorSceneManager.GetSceneManagerSetup(); UnityEditor.SceneManagement.EditorSceneManager.NewScene( UnityEditor.SceneManagement.NewSceneSetup.EmptyScene, UnityEditor.SceneManagement.NewSceneMode.Single); var src = UnityEditor.AssetDatabase.LoadAssetAtPath(kPrefab); var go = (GameObject)UnityEditor.PrefabUtility.InstantiatePrefab(src); go.transform.position = Vector3.zero; Physics.SyncTransforms(); var portal = go.transform.Find("Portal_Start"); s_start = portal != null ? portal.position + Vector3.forward : new Vector3(kCX, 0f, kCZ); s_water = new List(); s_feature = new List(); Bounds all = new Bounds(); bool has = false; foreach (var r in go.GetComponentsInChildren(true)) { if (!has) { all = r.bounds; has = true; } else all.Encapsulate(r.bounds); string ln = r.name.ToLowerInvariant(); bool w = ln.Contains("water"); if (!w) foreach (var m in r.sharedMaterials) { if (m == null) continue; if (m.name.ToLowerInvariant().Contains("water")) { w = true; break; } if (m.shader != null && m.shader.name.ToLowerInvariant().Contains("water")) { w = true; break; } } if (w) { s_water.Add(r.bounds); continue; } if (ln.Contains("tree") || ln.Contains("rock") || ln.Contains("bush") || ln.Contains("stone") || ln.Contains("stump")) { var c = r.bounds.center; if (Mathf.Abs(c.x - kCX) <= kHalf && Mathf.Abs(c.z - kCZ) <= kHalf) s_feature.Add(c); } } s_topY = all.max.y + 50f; s_rayLen = all.size.y + 300f; L("임시 씬: 시작=" + V(s_start) + " 수역=" + s_water.Count + " 특징점(나무/바위)=" + s_feature.Count + " rayTop=" + s_topY.ToString("F1")); return go; } static void CloseTemp(UnityEditor.SceneManagement.SceneSetup[] setup) { try { if (setup != null && setup.Length > 0) UnityEditor.SceneManagement.EditorSceneManager.RestoreSceneManagerSetup(setup); } catch { } } // 한 점 실측: 최상단 히트 → 초록 지면 여부 · 경사 · 높이 · 수면 위 여부 static bool Sample(float x, float z, out Vector3 p, out Vector3 n, out bool green, out bool dry) { p = Vector3.zero; n = Vector3.up; green = false; dry = false; int cnt = Physics.RaycastNonAlloc(new Vector3(x, s_topY, z), Vector3.down, s_hits, s_rayLen); if (cnt <= 0) return false; float bestD = float.MaxValue; int best = -1; for (int i = 0; i < cnt; i++) if (s_hits[i].distance < bestD) { bestD = s_hits[i].distance; best = i; } if (best < 0) return false; var h = s_hits[best]; p = h.point; n = h.normal; var r = h.collider.GetComponent(); if (r == null) r = h.collider.GetComponentInParent(); green = (r != null && r.sharedMaterial != null && r.sharedMaterial.name == "MT_Terrain_01"); dry = true; for (int i = 0; i < s_water.Count; i++) { var b = s_water[i]; if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue; if (p.y < b.max.y + 0.35f) { dry = false; break; } } return true; } // 수면까지의 대략 거리 (XZ · 12 m 에서 포화) static float WaterDist(float x, float z) { float best = 12f; for (int i = 0; i < s_water.Count; i++) { var b = s_water[i]; float dx = Mathf.Max(b.min.x - x, 0f, x - b.max.x); float dz = Mathf.Max(b.min.z - z, 0f, z - b.max.z); float d = Mathf.Sqrt(dx * dx + dz * dz); if (d < best) best = d; } return best; } // ── 값 노이즈 ────────────────────────────────────────────── static float Hash(int x, int y, int seed) { int h = x * 374761393 + y * 668265263 + seed * 1274126177; h = (h ^ (h >> 13)) * 1274126177; return ((h ^ (h >> 16)) & 0x7fffffff) / 2147483647f; } static float VN(float x, float y, float scale, int seed) { float fx = x / scale, fy = y / scale; int ix = Mathf.FloorToInt(fx), iy = Mathf.FloorToInt(fy); float tx = fx - ix, ty = fy - iy; tx = tx * tx * (3f - 2f * tx); ty = ty * ty * (3f - 2f * ty); float a = Mathf.Lerp(Hash(ix, iy, seed), Hash(ix + 1, iy, seed), tx); float b = Mathf.Lerp(Hash(ix, iy + 1, seed), Hash(ix + 1, iy + 1, seed), tx); return Mathf.Lerp(a, b, ty); } // GLSL 식 smoothstep. Unity 의 Mathf.SmoothStep(from,to,t) 는 from~to 보간이라 문턱값 용도로 쓸 수 없다(실측 · 마스크 전멸 원인). static float SS(float e0, float e1, float x) { float t = Mathf.Clamp01((x - e0) / Mathf.Max(1e-6f, e1 - e0)); return t * t * (3f - 2f * t); } // ═══════════════════════════════════════════════════════════ // K2 — 브러시 마스크 페인팅 (_NoGrassTex) // args: [seed, strokes, feather(m)] // R = 잔디 밀도 (0 = 지면 그대로 / 1 = 잔디 가득) // ═══════════════════════════════════════════════════════════ public static object PaintMask(float seedF, float strokesF, float featherM) { s_log = new StringBuilder(); if (Application.isPlaying) return "PLAYING - 중단"; int seed = Mathf.RoundToInt(seedF); int nStroke = Mathf.Clamp(Mathf.RoundToInt(strokesF), 0, 400); float feather = Mathf.Clamp(featherM, 0.5f, 12f); float clearR = 8f; // 시작 개활지 반경 (#781 실측과 동일) L(string.Format("=== PaintMask seed={0} strokes={1} feather={2} m · {3}² · 1타일={4} m", seed, nStroke, feather, kMaskN, kWorldScale)); int pn = Mathf.RoundToInt(kHalf * 2f / kProbe) + 1; // 401 var cov = new float[pn * pn]; var wdist = new float[pn * pn]; float ox = kCX - kHalf, oz = kCZ - kHalf; UnityEditor.SceneManagement.SceneSetup[] setup; OpenTemp(out setup); int nGreen = 0; try { for (int j = 0; j < pn; j++) for (int i = 0; i < pn; i++) { float wx = ox + i * kProbe, wz = oz + j * kProbe; Vector3 p, n; bool green, dry; float v = 0f; if (Sample(wx, wz, out p, out n, out green, out dry) && green && dry) { // 경사: 18° 이하 = 1, 30° 이상 = 0 (기하가 아니라 마스크로 제외 — K1) v = SS(Mathf.Cos(30f * Mathf.Deg2Rad), Mathf.Cos(18f * Mathf.Deg2Rad), n.y); if (v > 0f) nGreen++; } cov[j * pn + i] = v; wdist[j * pn + i] = WaterDist(wx, wz); } } finally { CloseTemp(setup); } L(string.Format("지형 실측: {0}² 점 · 초록/완경사 {1} ({2:P1})", pn, nGreen, (float)nGreen / (pn * pn))); // 소프트 엣지 — 박스 블러 2회(≈가우시안) 후 (b-0.5)*2 로 「경계 바깥 침범 0 · 안쪽으로만 램프」 int rb = Mathf.Max(1, Mathf.RoundToInt(feather / kProbe)); var b1 = Blur(cov, pn, rb); var b2 = Blur(b1, pn, rb); for (int k = 0; k < b2.Length; k++) b2[k] = Mathf.Clamp01((b2[k] - 0.5f) * 2f); // 붓 스트로크 (타원/캡슐) — 밀집/성김 리듬. 화가의 손처럼 방향에 전역 편향을 준다. var rnd = new System.Random(seed); float brushDir = (float)rnd.NextDouble() * Mathf.PI * 2f; var strokes = new List(); // x0,z0,x1,z1,halfWidth,amp for (int s = 0; s < nStroke; s++) { float sx, sz; if (s_feature.Count > 0 && rnd.NextDouble() < 0.55) { var f = s_feature[rnd.Next(s_feature.Count)]; sx = f.x + ((float)rnd.NextDouble() - 0.5f) * 8f; sz = f.z + ((float)rnd.NextDouble() - 0.5f) * 8f; } else { sx = kCX - kHalf + (float)rnd.NextDouble() * kHalf * 2f; sz = kCZ - kHalf + (float)rnd.NextDouble() * kHalf * 2f; } float ang = brushDir + ((float)rnd.NextDouble() - 0.5f) * 1.4f; // ±40° float len = 5f + (float)rnd.NextDouble() * 16f; float hw = 1.2f + (float)rnd.NextDouble() * 2.6f; bool neg = rnd.NextDouble() < 0.22; // 22 % 는 맨땅 스크래치 float amp = neg ? -(0.30f + (float)rnd.NextDouble() * 0.35f) : (0.22f + (float)rnd.NextDouble() * 0.38f); strokes.Add(new[] { sx, sz, sx + Mathf.Cos(ang) * len, sz + Mathf.Sin(ang) * len, hw, amp }); } var stroke = new float[kMaskN * kMaskN]; float mPerTex = kWorldScale / kMaskN; for (int s = 0; s < strokes.Count; s++) { var st = strokes[s]; float minX = Mathf.Min(st[0], st[2]) - st[4], maxX = Mathf.Max(st[0], st[2]) + st[4]; float minZ = Mathf.Min(st[1], st[3]) - st[4], maxZ = Mathf.Max(st[1], st[3]) + st[4]; int i0 = Mathf.Clamp(Mathf.FloorToInt(WorldToU(minX) * kMaskN), 0, kMaskN - 1); int i1 = Mathf.Clamp(Mathf.CeilToInt(WorldToU(maxX) * kMaskN), 0, kMaskN - 1); int j0 = Mathf.Clamp(Mathf.FloorToInt(WorldToV(minZ) * kMaskN), 0, kMaskN - 1); int j1 = Mathf.Clamp(Mathf.CeilToInt(WorldToV(maxZ) * kMaskN), 0, kMaskN - 1); for (int j = j0; j <= j1; j++) for (int i = i0; i <= i1; i++) { float wx = (i + 0.5f) * mPerTex; float wz = (j + 0.5f) * mPerTex; if (wz > kWorldScale * 0.5f) wz -= kWorldScale; float d = SegDist(wx, wz, st[0], st[1], st[2], st[3]); if (d >= st[4]) continue; float t = 1f - d / st[4]; stroke[j * kMaskN + i] += st[5] * t * t; } } // 최종 합성 var px = new Color[kMaskN * kMaskN]; double sum = 0; int nz = 0; for (int j = 0; j < kMaskN; j++) for (int i = 0; i < kMaskN; i++) { float wx = (i + 0.5f) * mPerTex; float wz = (j + 0.5f) * mPerTex; if (wz > kWorldScale * 0.5f) wz -= kWorldScale; float r = 0f; float fi = (wx - ox) / kProbe, fj = (wz - oz) / kProbe; if (fi >= 0f && fj >= 0f && fi <= pn - 1.001f && fj <= pn - 1.001f) { float c = Bilinear(b2, pn, fi, fj); if (c > 0.0005f) { // FBM 패치 (22 / 9 / 3.5 m) float p = VN(wx, wz, 22f, seed) * 0.55f + VN(wx, wz, 9f, seed + 31) * 0.30f + VN(wx, wz, 3.5f, seed + 57) * 0.15f; float dens = Mathf.Lerp(0.42f, 1.0f, SS(0.28f, 0.72f, p)); dens *= SS(0.12f, 0.26f, p); // 드문 맨땅 // 강가 둔덕은 짙게 float wd = Bilinear(wdist, pn, fi, fj); dens += 0.25f * (1f - SS(1.5f, 8f, wd)); dens += stroke[j * kMaskN + i]; // 시작 개활지 float dx = wx - s_start.x, dz = wz - s_start.z; float dd = Mathf.Sqrt(dx * dx + dz * dz); c *= SS(clearR - feather * 0.5f, clearR + feather, dd); r = Mathf.Clamp01(c * Mathf.Clamp01(dens)); } } px[j * kMaskN + i] = new Color(r, r, r, 1f); sum += r; if (r > 0.02f) nz++; } var tex = SaveTex(kMaskPath, kMaskN, px, true); L(string.Format("마스크 저장: {0} · 평균 R={1:F3} · 유효 텍셀 {2:N0} ({3:P1}) · 스트로크 {4}(음수 {5})", kMaskPath, sum / px.Length, nz, (float)nz / px.Length, strokes.Count, strokes.Count(a => a[5] < 0))); UnityEditor.AssetDatabase.SaveAssets(); Flush("paintmask.txt"); return string.Format("마스크 생성 완료 {0}² · 평균 {1:F3} · 유효 {2:P1} · 스트로크 {3}", kMaskN, sum / px.Length, (float)nz / px.Length, strokes.Count); } static float WorldToU(float x) { float u = x / kWorldScale; return u - Mathf.Floor(u); } static float WorldToV(float z) { float v = z / kWorldScale; return v - Mathf.Floor(v); } static float SegDist(float px, float pz, float ax, float az, float bx, float bz) { float vx = bx - ax, vz = bz - az; float wx = px - ax, wz = pz - az; float dd = vx * vx + vz * vz; float t = dd > 1e-6f ? Mathf.Clamp01((wx * vx + wz * vz) / dd) : 0f; float cx = ax + vx * t - px, cz = az + vz * t - pz; return Mathf.Sqrt(cx * cx + cz * cz); } static float[] Blur(float[] src, int n, int r) { var tmp = new float[n * n]; var dst = new float[n * n]; for (int j = 0; j < n; j++) for (int i = 0; i < n; i++) { float s = 0f; int c = 0; for (int k = -r; k <= r; k++) { int x = i + k; if (x < 0 || x >= n) continue; s += src[j * n + x]; c++; } tmp[j * n + i] = s / Mathf.Max(1, c); } for (int j = 0; j < n; j++) for (int i = 0; i < n; i++) { float s = 0f; int c = 0; for (int k = -r; k <= r; k++) { int y = j + k; if (y < 0 || y >= n) continue; s += tmp[y * n + i]; c++; } dst[j * n + i] = s / Mathf.Max(1, c); } return dst; } static float Bilinear(float[] a, int n, float fi, float fj) { int i0 = Mathf.Clamp((int)fi, 0, n - 2), j0 = Mathf.Clamp((int)fj, 0, n - 2); float tx = Mathf.Clamp01(fi - i0), ty = Mathf.Clamp01(fj - j0); float p0 = Mathf.Lerp(a[j0 * n + i0], a[j0 * n + i0 + 1], tx); float p1 = Mathf.Lerp(a[(j0 + 1) * n + i0], a[(j0 + 1) * n + i0 + 1], tx); return Mathf.Lerp(p0, p1, ty); } // 코드 생성 Texture2D 는 .asset 저장 시 sRGB 플래그가 살아남지 않는다 → 선형 데이터 그대로 기록 (#781 실측) static Texture2D SaveTex(string path, int n, Color[] px, bool mip) { var t = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (t == null || t.width != n || t.height != n) { System.IO.Directory.CreateDirectory(kWLTex); if (t != null) UnityEditor.AssetDatabase.DeleteAsset(path); t = new Texture2D(n, n, TextureFormat.RGBA32, mip, true); UnityEditor.AssetDatabase.CreateAsset(t, path); } t.SetPixels(px); t.wrapMode = TextureWrapMode.Repeat; t.filterMode = FilterMode.Bilinear; t.anisoLevel = 4; t.Apply(mip, false); UnityEditor.EditorUtility.SetDirty(t); return t; } // ═══════════════════════════════════════════════════════════ // 색 변주 텍스처 (_MainTex) — 지면색 계열의 완만한 얼룩 // ═══════════════════════════════════════════════════════════ static Texture2D ColorTex(int seed) { var px = new Color[kColorN * kColorN]; float mPerTex = kWorldScale / kColorN; for (int j = 0; j < kColorN; j++) for (int i = 0; i < kColorN; i++) { float wx = (i + 0.5f) * mPerTex, wz = (j + 0.5f) * mPerTex; float v = VN(wx, wz, 34f, seed + 11) * 0.6f + VN(wx, wz, 13f, seed + 71) * 0.4f; float lum = 0.86f + 0.16f * v; // 0.86 ~ 1.02 float warm = (VN(wx, wz, 26f, seed + 137) - 0.5f); // 누런/푸른 변주 px[j * kColorN + i] = new Color( Mathf.Clamp01(lum * (1f + 0.07f * warm)), Mathf.Clamp01(lum), Mathf.Clamp01(lum * (1f - 0.10f * warm)), 1f); } return SaveTex(kColorPath, kColorN, px, true); } // ═══════════════════════════════════════════════════════════ // K1 — 연속 카펫 메시 (기하 구멍 없음 · 초록 지면 밖으로 1셀 확장 → 경계는 마스크가 처리) // ═══════════════════════════════════════════════════════════ static List BuildCarpet(out long baseTris, out int cellsKept, out int cellsTried) { var meshes = new List(); baseTris = 0; cellsKept = 0; cellsTried = 0; int nChunk = Mathf.CeilToInt(kHalf * 2f / kChunk); int n = Mathf.Max(2, Mathf.RoundToInt(kChunk / kCell)); float cell = kChunk / n; L(string.Format("카펫: 청크 {0}×{0} · 셀 {1:F3} m ({2}×{2}/청크)", nChunk, cell, n)); for (int cz = 0; cz < nChunk; cz++) for (int cxi = 0; cxi < nChunk; cxi++) { float x0 = kCX - kHalf + cxi * kChunk, z0 = kCZ - kHalf + cz * kChunk; int gp = (n + 1) * (n + 1); var pos = new Vector3[gp]; var nrm = new Vector3[gp]; var hit = new bool[gp]; var good = new bool[gp]; for (int j = 0; j <= n; j++) for (int i = 0; i <= n; i++) { int k = j * (n + 1) + i; Vector3 p, nn; bool green, dry; hit[k] = Sample(x0 + i * cell, z0 + j * cell, out p, out nn, out green, out dry); pos[k] = p; nrm[k] = Vector3.Lerp(nn, Vector3.up, 0.6f).normalized; good[k] = hit[k] && green && dry; } var map = new int[gp]; for (int i = 0; i < gp; i++) map[i] = -1; var vs = new List(); var ns = new List(); var uv = new List(); var tri = new List(); for (int j = 0; j < n; j++) for (int i = 0; i < n; i++) { cellsTried++; int a = j * (n + 1) + i, b = a + 1, c = a + (n + 1), d = c + 1; if (!hit[a] || !hit[b] || !hit[c] || !hit[d]) continue; // 네 꼭지점 중 하나라도 초록 지면이면 채택 = 경계를 1셀 확장(dilate). // 확장분은 마스크가 0 이라 클립되어 보이지 않는다 → 1 m 격자 계단이 사라진다. if (!good[a] && !good[b] && !good[c] && !good[d]) continue; cellsKept++; foreach (int k in new[] { a, b, c, d }) if (map[k] < 0) { map[k] = vs.Count; vs.Add(pos[k] + nrm[k] * 0.02f); ns.Add(nrm[k]); uv.Add(new Vector2(pos[k].x / kWorldScale, pos[k].z / kWorldScale)); } tri.Add(map[a]); tri.Add(map[c]); tri.Add(map[d]); tri.Add(map[a]); tri.Add(map[d]); tri.Add(map[b]); } if (tri.Count == 0) continue; var mesh = new Mesh(); mesh.name = string.Format("BFCarpet_{0}_{1}", cxi, cz); mesh.SetVertices(vs); mesh.SetNormals(ns); mesh.SetUVs(0, uv); mesh.SetTriangles(tri, 0); mesh.RecalculateBounds(); meshes.Add(mesh); baseTris += tri.Count / 3; L(string.Format(" {0} verts={1} tris={2}", mesh.name, vs.Count, tri.Count / 3)); } return meshes; } // ═══════════════════════════════════════════════════════════ // K3/K4/K6 — 머티리얼 // ═══════════════════════════════════════════════════════════ static Material BuildMat(float leafPeriod, float fadeStart, float fadeEnd, float colorMul, int seed, Color sunLit) { var sh = Shader.Find("BruteForceURP/InteractiveGrassMobileURP"); var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m == null) { System.IO.Directory.CreateDirectory(kWLMat); m = new Material(sh); UnityEditor.AssetDatabase.CreateAsset(m, kMatPath); } if (m.shader != sh) m.shader = sh; // ── 색: 지면 머티리얼(SOT) 에서 역산. 셰이더 최종색 = 4·C²·(_GrassSaturation=1)·shading·mainLight var tm = UnityEditor.AssetDatabase.LoadAssetAtPath(kTerrainMat); Color pal = tm != null ? tm.GetColor("_BaseColor") : new Color(0.5725f, 0.7725f, 0.3490f, 1f); Color C = new Color( Mathf.Sqrt(Mathf.Max(0f, pal.r * colorMul / (4f * Mathf.Max(0.01f, sunLit.r)))), Mathf.Sqrt(Mathf.Max(0f, pal.g * colorMul / (4f * Mathf.Max(0.01f, sunLit.g)))), Mathf.Sqrt(Mathf.Max(0f, pal.b * colorMul / (4f * Mathf.Max(0.01f, sunLit.b)))), 1f); Color G = new Color( Mathf.Sqrt(Mathf.Max(0f, pal.r * colorMul / (2f * Mathf.Max(0.01f, sunLit.r)))), Mathf.Sqrt(Mathf.Max(0f, pal.g * colorMul / (2f * Mathf.Max(0.01f, sunLit.g)))), Mathf.Sqrt(Mathf.Max(0f, pal.b * colorMul / (2f * Mathf.Max(0.01f, sunLit.b)))), 1f); float mx = Mathf.Max(pal.r, Mathf.Max(pal.g, pal.b)); Color palN = new Color(pal.r / mx, pal.g / mx, pal.b / mx, 1f); Color SS = Color.Lerp(Color.white, palN, 0.30f) * 0.82f; // 밑동 그늘 = 지면색 계열 × 0.82 // ── 텍스처: 팩 원본 사용(무변경) + WL 전용 생성물 var grassTex = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFTexDir + "LongGrassPattern.png"); if (grassTex == null) grassTex = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFTexDir + "GrassPattern.png"); var noiseTex = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFTexDir + "NoiseCloud.png"); var distTex = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFTexDir + "PerlinNoise02.png"); var demo = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFDemoMat); if (grassTex == null && demo != null) grassTex = demo.GetTexture("_GrassTex") as Texture2D; if (noiseTex == null && demo != null) noiseTex = demo.GetTexture("_Noise") as Texture2D; if (distTex == null && demo != null) distTex = demo.GetTexture("_Distortion") as Texture2D; var mask = UnityEditor.AssetDatabase.LoadAssetAtPath(kMaskPath); var colTex = ColorTex(seed); var white = UnityEditor.AssetDatabase.LoadAssetAtPath(kWhitePath); if (white == null) { var wp = new Color[16]; for (int i = 0; i < 16; i++) wp[i] = Color.white; white = SaveTex(kWhitePath, 4, wp, false); } m.SetTexture("_GrassTex", grassTex); m.SetTexture("_Noise", noiseTex); m.SetTexture("_Distortion", distTex); m.SetTexture("_MainTex", colTex); m.SetTexture("_GroundTex", white); m.SetTexture("_NoGrassTex", mask); m.SetTextureScale("_MainTex", Vector2.one); m.SetTextureOffset("_MainTex", Vector2.zero); m.SetColor("_Color", C); m.SetColor("_GroundColor", G); m.SetColor("_SelfShadowColor", SS); m.SetColor("_ProjectedShadowColor", new Color(0.62f, 0.66f, 0.72f, 1f)); m.SetFloat("_GrassSaturation", 1.0f); // 1 이 색 보존 (#780 형광의 원인은 1.6) m.SetFloat("_GrassShading", 0.18f); // ── 잎 크기 / 좌표계: USE_WC → mainUV = worldXZ / _WorldScale m.SetFloat("_WorldScale", kWorldScale); m.SetFloat("_WorldRotation", 0f); m.SetFloat("_TilingN1", kWorldScale / Mathf.Max(0.05f, leafPeriod)); // 잎 주기 = leafPeriod m m.SetFloat("_TilingN2", kWorldScale / 25f); // 색 노이즈 주기 25 m m.SetFloat("_TilingN3", kWorldScale / 4f); // 바람 왜곡 주기 4 m m.SetFloat("_GrassThinness", 0.80f); // 굵직한 잎 m.SetFloat("_GrassThinnessIntersection", 0.16f); m.SetFloat("_NoisePower", 0.15f); // 🔴 _GrassCut > 0 이면 베이스 셸(color.r=0)까지 알파 클립된다 → // 잎 사이로 실제 지형(URP/Lit)이 그대로 보여 색 이질감이 원천적으로 사라지고, // 페이드 거리 밖에서 셸이 불투명 판이 되는 #780 현상도 함께 막힌다. m.SetFloat("_GrassCut", 0.90f); m.SetFloat("_FadeDistanceStart", fadeStart); m.SetFloat("_FadeDistanceEnd", fadeEnd); // ── 바람 (K6) m.SetFloat("_WindMovement", 0.35f); m.SetFloat("_WindForce", 0.10f); SetKw(m, "_UseRT", "USE_RT", 1f); // K5 상호작용 ON SetKw(m, "_UseShadow", "USE_S", 1f); // 나무 그림자를 받는다 SetKw(m, "_UseShadowCast", "USE_SC", 0f); // 그림자 캐스팅 패스 OFF (모바일) SetKw(m, "_UseWC", "USE_WC", 1f); // 월드 좌표 UV → 청크 이음새 0 SetKw(m, "_UseVP", "USE_VP", 0f); SetKw(m, "_UsePR", "USE_PR", 0f); // 확률적 타일링은 모바일 비용 ↑ SetKw(m, "_UseTP", "USE_TP", 0f); SetKw(m, "_UseVR", "USE_VR", 0f); SetKw(m, "_UseAmbientLight", "USE_AL", 0f); m.enableInstancing = true; UnityEditor.EditorUtility.SetDirty(m); L(string.Format("mat: pal={0} sun={1} → _Color={2} _GroundColor={3} _SelfShadow={4}", pal.ToString("F3"), sunLit.ToString("F3"), C.ToString("F4"), G.ToString("F4"), SS.ToString("F3"))); L(string.Format(" leaf={0} m (_TilingN1={1:F1}) fade={2}/{3} cut={4} grassTex={5}", leafPeriod, kWorldScale / leafPeriod, fadeStart, fadeEnd, 0.90f, grassTex != null ? grassTex.name : "null")); return m; } static void SetKw(Material m, string prop, string kw, float v) { if (m.HasProperty(prop)) m.SetFloat(prop, v); if (v > 0.5f) m.EnableKeyword(kw); else m.DisableKeyword(kw); } static Transform ResetChild(GameObject root, string name) { var old = root.transform.Find(name); if (old != null) Object.DestroyImmediate(old.gameObject); var go = new GameObject(name); go.transform.SetParent(root.transform, false); UnityEditor.GameObjectUtility.SetStaticEditorFlags(go, 0); return go.transform; } // ═══════════════════════════════════════════════════════════ // K5 — 상호작용 레이어 확보 // ═══════════════════════════════════════════════════════════ static int EnsureLayer(string name) { int idx = LayerMask.NameToLayer(name); if (idx >= 0) return idx; var assets = UnityEditor.AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset"); if (assets == null || assets.Length == 0) return -1; var so = new UnityEditor.SerializedObject(assets[0]); var layers = so.FindProperty("layers"); for (int i = 8; i < layers.arraySize; i++) { var e = layers.GetArrayElementAtIndex(i); if (string.IsNullOrEmpty(e.stringValue)) { e.stringValue = name; so.ApplyModifiedProperties(); so.Update(); L("레이어 생성: " + i + " = " + name); return i; } } return -1; } static RenderTexture EnsureRT(int res) { var rt = UnityEditor.AssetDatabase.LoadAssetAtPath(kRTPath); if (rt != null && rt.width != res) { UnityEditor.AssetDatabase.DeleteAsset(kRTPath); rt = null; } if (rt == null) { System.IO.Directory.CreateDirectory(kWLTex); rt = new RenderTexture(res, res, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear); rt.name = "WL_GrassRT"; rt.useMipMap = false; rt.autoGenerateMips = false; rt.filterMode = FilterMode.Bilinear; rt.wrapMode = TextureWrapMode.Clamp; rt.antiAliasing = 1; UnityEditor.AssetDatabase.CreateAsset(rt, kRTPath); } return rt; } static void BuildInteraction(GameObject root, float orthoSize, int rtRes, int fxLayer) { var cont = ResetChild(root, kGoRT); var rt = EnsureRT(rtRes); // BF 데모 프리팹(CameraEffectURP) 사본 — 원본 무변경 var camSrc = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFCamPrefab); GameObject camGo = null; if (camSrc != null) { camGo = Object.Instantiate(camSrc); camGo.name = "GrassRTCam"; camGo.transform.SetParent(cont, false); camGo.transform.localPosition = new Vector3(0f, 20f, 0f); camGo.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); var cam = camGo.GetComponent(); if (cam != null) { cam.orthographic = true; cam.orthographicSize = orthoSize; cam.targetTexture = rt; cam.cullingMask = 1 << fxLayer; cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0f, 0f, 0f, 0f); cam.depth = -20f; cam.nearClipPlane = 0.3f; cam.farClipPlane = 60f; cam.allowHDR = false; cam.allowMSAA = false; cam.useOcclusionCulling = false; } var uacd = camGo.GetComponents().FirstOrDefault(c => c != null && c.GetType().Name == "UniversalAdditionalCameraData"); if (uacd != null) { var t = uacd.GetType(); var pShadow = t.GetProperty("renderShadows"); if (pShadow != null) pShadow.SetValue(uacd, false); var pPost = t.GetProperty("renderPostProcessing"); if (pPost != null) pPost.SetValue(uacd, false); var pAA = t.GetProperty("antialiasing"); if (pAA != null) pAA.SetValue(uacd, System.Enum.ToObject(pAA.PropertyType, 0)); } var binder = camGo.GetComponent(); if (binder != null) { binder.rt = rt; binder.transformToFollow = null; } L("RT 카메라: BF CameraEffectURP 사본 · ortho=" + orthoSize + " rt=" + rtRes + "² cullingMask=layer" + fxLayer); } else L("[경고] BF CameraEffectURP 프리팹을 찾지 못했다 — 상호작용 비활성"); // 눌림 이펙트 = BF PEMouseURP 사본 (월드 시뮬레이션 → 지나간 자리에 자국이 남는다) GameObject trailGo = null; var trailSrc = UnityEditor.AssetDatabase.LoadAssetAtPath(kBFTrailPrefab); if (trailSrc != null) { trailGo = Object.Instantiate(trailSrc); trailGo.name = "GrassTrail"; trailGo.transform.SetParent(cont, false); trailGo.transform.localPosition = Vector3.zero; SetLayerRecursive(trailGo, fxLayer); var ps = trailGo.GetComponent(); if (ps != null) { var main = ps.main; main.simulationSpace = ParticleSystemSimulationSpace.World; main.playOnAwake = true; main.loop = true; main.startLifetime = 2.6f; main.startSize = 1.5f; main.maxParticles = 220; var em = ps.emission; em.enabled = true; em.rateOverTime = 16f; var sp = ps.shape; sp.enabled = true; var psr = trailGo.GetComponent(); if (psr != null) { psr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; psr.receiveShadows = false; } } L("눌림 이펙트: BF PEMouseURP 사본 · World 시뮬 · rate=16 · life=2.6 s · layer" + fxLayer); } else L("[경고] BF PEMouseURP 프리팹을 찾지 못했다"); var bind = cont.gameObject.AddComponent(); if (camGo != null) { bind.rtCamera = camGo.GetComponent(); bind.rtBinder = camGo.GetComponent(); } if (trailGo != null) bind.trail = trailGo.transform; bind.playerTypeName = "MyActor"; } static void SetLayerRecursive(GameObject go, int layer) { go.layer = layer; foreach (Transform t in go.transform) SetLayerRecursive(t.gameObject, layer); } // 프리팹 안 태양광 실측 (색 역산 입력 · 하드코딩 금지) static Color SunLit(GameObject root) { Light best = null; float bi = -1f; foreach (var l in root.GetComponentsInChildren(true)) if (l.type == LightType.Directional && l.intensity > bi) { bi = l.intensity; best = l; } if (best == null) { L("[미확인] 프리팹 안에 Directional Light 없음 → mainLight=(1,1,1) 가정"); return Color.white; } var c = best.color * best.intensity; L(string.Format("태양광 실측: {0} color={1} int={2:F2} → mainLight={3}", best.name, best.color.ToString("F3"), best.intensity, c.ToString("F3"))); return c; } // ═══════════════════════════════════════════════════════════ // 메인 — ApplyBF [seed, shells, leafPeriod, fadeStart, fadeEnd, colorMul] // ═══════════════════════════════════════════════════════════ public static object ApplyBF(float seedF, float shellsF, float leafPeriod, float fadeStart, float fadeEnd, float colorMul) { s_log = new StringBuilder(); if (Application.isPlaying) return "PLAYING - 중단"; int seed = Mathf.RoundToInt(seedF); int nShell = Mathf.Clamp(Mathf.RoundToInt(shellsF), 2, 12); float height = 0.36f; // 무릎 이하 L(string.Format("=== ApplyBF seed={0} shells={1} leaf={2} m fade={3}/{4} colorMul={5}", seed, nShell, leafPeriod, fadeStart, fadeEnd, colorMul)); if (UnityEditor.AssetDatabase.LoadAssetAtPath(kMaskPath) == null) return "마스크 없음 — 먼저 PaintMask 를 실행할 것"; int fxLayer = EnsureLayer(kFxLayerName); if (fxLayer < 0) L("[경고] 빈 레이어 없음 → 상호작용 레이어 확보 실패"); // 1) 메시 List meshes; long baseTris; int kept, tried; UnityEditor.SceneManagement.SceneSetup[] setup; OpenTemp(out setup); try { meshes = BuildCarpet(out baseTris, out kept, out tried); System.IO.Directory.CreateDirectory(kWLMesh); if (System.IO.File.Exists(kCarpetPath)) UnityEditor.AssetDatabase.DeleteAsset(kCarpetPath); if (meshes.Count > 0) { UnityEditor.AssetDatabase.CreateAsset(meshes[0], kCarpetPath); for (int i = 1; i < meshes.Count; i++) UnityEditor.AssetDatabase.AddObjectToAsset(meshes[i], kCarpetPath); UnityEditor.AssetDatabase.SaveAssets(); } } finally { CloseTemp(setup); } if (meshes.Count == 0) { Flush("apply.txt"); return "청크 0개"; } L(string.Format("셀 {0}/{1} · 기본 tris {2:N0} · 셸 {3} → 총 {4:N0}", kept, tried, baseTris, nShell, baseTris * nShell)); UnityEditor.AssetDatabase.ImportAsset(kCarpetPath); meshes = UnityEditor.AssetDatabase.LoadAllAssetsAtPath(kCarpetPath).OfType() .OrderBy(x => x.name, System.StringComparer.Ordinal).ToList(); // 2) 프리팹 배치 var root = UnityEditor.PrefabUtility.LoadPrefabContents(kPrefab); Material mat = null; try { var old = root.transform.Find(kGoOldA); if (old != null) { Object.DestroyImmediate(old.gameObject); L("#781 " + kGoOldA + " 제거"); } mat = BuildMat(leafPeriod, fadeStart, fadeEnd, colorMul, seed, SunLit(root)); var cont = ResetChild(root, kGo); for (int i = 0; i < meshes.Count; i++) { var go = new GameObject("BFCarpet_" + i.ToString("00")); go.SetActive(false); // BF_MeshExtrusion 은 [ExecuteInEditMode] — 비활성 상태에서 컴포넌트를 붙인다 go.transform.SetParent(cont, false); go.transform.localPosition = Vector3.zero; go.AddComponent().sharedMesh = meshes[i]; var mr = go.AddComponent(); mr.sharedMaterials = new[] { mat }; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = true; var ex = go.AddComponent(); ex.originalMesh = meshes[i]; ex.numberOfStacks = nShell; ex.offsetValue = height * 100f / Mathf.Max(1, nShell - 1); // BF: offsetValue*0.01*i (cm) UnityEditor.GameObjectUtility.SetStaticEditorFlags(go, 0); go.GetComponent().sharedMesh = meshes[i]; go.SetActive(true); } if (fxLayer >= 0) BuildInteraction(root, 25f, 512, fxLayer); UnityEditor.PrefabUtility.SaveAsPrefabAsset(root, kPrefab); } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(root); } UnityEditor.AssetDatabase.SaveAssets(); Flush("apply.txt"); return string.Format("ApplyBF 완료: 청크 {0} · 기본tris {1:N0} · 셸 {2} · 총tris {3:N0} · 잎주기 {4} m · fade {5}/{6}", meshes.Count, baseTris, nShell, baseTris * nShell, leafPeriod, fadeStart, fadeEnd); } // ═══════════════════════════════════════════════════════════ // K7 — 다발 정리: 마스크 밀집 패치에만 남기고 크게 // args: [minMask, maxKeep, scaleMul] // ═══════════════════════════════════════════════════════════ public static object TuftsFilter(float minMask, float maxKeepF, float scaleMul) { s_log = new StringBuilder(); if (Application.isPlaying) return "PLAYING - 중단"; var mask = UnityEditor.AssetDatabase.LoadAssetAtPath(kMaskPath); if (mask == null) return "마스크 없음"; int maxKeep = Mathf.Max(1, Mathf.RoundToInt(maxKeepF)); var root = UnityEditor.PrefabUtility.LoadPrefabContents(kPrefab); int before = 0, kept = 0; try { var cont = root.transform.Find(kGoC); if (cont == null) { return kGoC + " 없음 — 먼저 WL781_Grass.ApplyC 실행"; } var kids = new List(); foreach (Transform t in cont) kids.Add(t); before = kids.Count; var pass = new List(); foreach (var t in kids) { var p = t.position; float u = WorldToU(p.x), v = WorldToV(p.z); float r = mask.GetPixelBilinear(u, v).r; if (r >= minMask) pass.Add(t); else Object.DestroyImmediate(t.gameObject); } // 상한 초과분은 결정적으로 솎아낸다 if (pass.Count > maxKeep) { var ordered = pass.OrderBy(t => Hash(Mathf.RoundToInt(t.position.x * 7f), Mathf.RoundToInt(t.position.z * 7f), 783)).ToList(); for (int i = maxKeep; i < ordered.Count; i++) Object.DestroyImmediate(ordered[i].gameObject); pass = ordered.Take(maxKeep).ToList(); } foreach (var t in pass) { t.localScale = t.localScale * scaleMul; kept++; } UnityEditor.PrefabUtility.SaveAsPrefabAsset(root, kPrefab); } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(root); } UnityEditor.AssetDatabase.SaveAssets(); Flush("tufts.txt"); return string.Format("다발: {0} → {1} (마스크 ≥ {2} · 크기 ×{3})", before, kept, minMask, scaleMul); } // ═══════════════════════════════════════════════════════════ // 제거 / 상태 / 튜닝 / 성능 // ═══════════════════════════════════════════════════════════ public static object RemoveAll() { if (Application.isPlaying) return "PLAYING - 중단"; var root = UnityEditor.PrefabUtility.LoadPrefabContents(kPrefab); var removed = new List(); try { foreach (var n in new[] { kGo, kGoRT, kGoOldA }) { var t = root.transform.Find(n); if (t != null) { Object.DestroyImmediate(t.gameObject); removed.Add(n); } } UnityEditor.PrefabUtility.SaveAsPrefabAsset(root, kPrefab); } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(root); } UnityEditor.AssetDatabase.SaveAssets(); return "제거: " + (removed.Count == 0 ? "(없음)" : string.Join(", ", removed)); } public static object State() { var sb = new StringBuilder(); var src = UnityEditor.AssetDatabase.LoadAssetAtPath(kPrefab); if (src == null) return "프리팹 없음"; foreach (var n in new[] { kGo, kGoRT, kGoOldA, kGoC }) { var t = src.transform.Find(n); if (t == null) { sb.AppendLine(n + " : 없음"); continue; } int kids = t.childCount; long tris = 0; int rend = 0; foreach (var mf in t.GetComponentsInChildren(true)) if (mf.sharedMesh != null) { tris += mf.sharedMesh.triangles.Length / 3; rend++; } sb.AppendLine(string.Format("{0} : 자식 {1} · MeshFilter {2} · 기본 tris {3:N0}", n, kids, rend, tris)); } var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m != null) { sb.AppendLine("mat " + kMatPath); foreach (var p in new[] { "_GrassSaturation", "_GrassShading", "_GrassThinness", "_TilingN1", "_TilingN2", "_TilingN3", "_GrassCut", "_FadeDistanceStart", "_FadeDistanceEnd", "_WindMovement", "_WindForce", "_WorldScale", "_UseRT", "_UseWC", "_UseShadow", "_NoisePower" }) if (m.HasProperty(p)) sb.AppendLine(" " + p + " = " + m.GetFloat(p).ToString("F3")); foreach (var p in new[] { "_Color", "_GroundColor", "_SelfShadowColor" }) if (m.HasProperty(p)) sb.AppendLine(" " + p + " = " + m.GetColor(p).ToString("F4")); sb.AppendLine(" keywords = " + string.Join(",", m.shaderKeywords)); foreach (var p in new[] { "_MainTex", "_NoGrassTex", "_GrassTex", "_Noise", "_Distortion", "_GroundTex" }) if (m.HasProperty(p)) { var t = m.GetTexture(p); sb.AppendLine(" " + p + " = " + (t == null ? "null" : t.name + " " + t.width + "²")); } } return sb.ToString(); } // Play 중 즉시 반영 (룩 튜닝) — 값 0 이하는 무시 public static object Tune(float colorMul, float leafPeriod, float thinness, float grassCut, float shading, float windForce) { var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m == null) return "mat 없음"; var tm = UnityEditor.AssetDatabase.LoadAssetAtPath(kTerrainMat); Color pal = tm != null ? tm.GetColor("_BaseColor") : new Color(0.5725f, 0.7725f, 0.3490f, 1f); if (colorMul > 0f) { Color sun = s_lastSun.maxColorComponent > 0.01f ? s_lastSun : Color.white; m.SetColor("_Color", new Color( Mathf.Sqrt(pal.r * colorMul / (4f * sun.r)), Mathf.Sqrt(pal.g * colorMul / (4f * sun.g)), Mathf.Sqrt(pal.b * colorMul / (4f * sun.b)), 1f)); } if (leafPeriod > 0f) m.SetFloat("_TilingN1", kWorldScale / leafPeriod); if (thinness > 0f) m.SetFloat("_GrassThinness", thinness); if (grassCut >= 0f) m.SetFloat("_GrassCut", grassCut); if (shading >= 0f) m.SetFloat("_GrassShading", shading); if (windForce >= 0f) m.SetFloat("_WindForce", windForce); UnityEditor.EditorUtility.SetDirty(m); return string.Format("Tune: _Color={0} leaf={1} thin={2} cut={3} shading={4} wind={5}", m.GetColor("_Color").ToString("F4"), kWorldScale / m.GetFloat("_TilingN1"), m.GetFloat("_GrassThinness"), m.GetFloat("_GrassCut"), m.GetFloat("_GrassShading"), m.GetFloat("_WindForce")); } static Color s_lastSun = new Color(1.2f, 1.104f, 0.84f, 1f); // 채널별 미세 보정 (캡처 실측 → 배율) public static object TuneRGB(float mr, float mg, float mb) { var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m == null) return "mat 없음"; var c = m.GetColor("_Color"); // 최종색 ∝ C² 이므로 밝기 배율의 제곱근을 곱한다 var nc = new Color(c.r * Mathf.Sqrt(Mathf.Max(0.01f, mr)), c.g * Mathf.Sqrt(Mathf.Max(0.01f, mg)), c.b * Mathf.Sqrt(Mathf.Max(0.01f, mb)), 1f); m.SetColor("_Color", nc); UnityEditor.EditorUtility.SetDirty(m); return "TuneRGB _Color " + c.ToString("F4") + " → " + nc.ToString("F4"); } // 마스크를 PNG 로 뽑아 붓 패턴을 눈으로 확인 (Assets 밖) public static object DumpMask() { var t = UnityEditor.AssetDatabase.LoadAssetAtPath(kMaskPath); if (t == null) return "mask 없음"; var src = t.GetPixels(); var o = new Texture2D(t.width, t.height, TextureFormat.RGBA32, false, true); // 플레이 영역 경계선을 겹쳐 그린다 for (int j = 0; j < t.height; j++) for (int i = 0; i < t.width; i++) { float v = src[j * t.width + i].r; o.SetPixel(i, j, new Color(v, v, v, 1f)); } o.Apply(); System.IO.Directory.CreateDirectory(kOut); var path = System.IO.Path.Combine(kOut, "mask_preview.png"); System.IO.File.WriteAllBytes(path, o.EncodeToPNG()); Object.DestroyImmediate(o); double sum = 0; float mx = 0; for (int i = 0; i < src.Length; i++) { sum += src[i].r; if (src[i].r > mx) mx = src[i].r; } return string.Format("{0} · mean={1:F3} max={2:F3} mip={3}", path, sum / src.Length, mx, t.mipmapCount); } // ── Play 중 즉석 튜닝 (머티리얼 에셋 공유 → 즉시 반영) ── public static object SetF(string prop, float v) { var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m == null || !m.HasProperty(prop)) return "no prop " + prop; m.SetFloat(prop, v); UnityEditor.EditorUtility.SetDirty(m); return prop + " = " + m.GetFloat(prop).ToString("F4"); } public static object SetLeaf(float periodM) { return SetF("_TilingN1", kWorldScale / Mathf.Max(0.05f, periodM)) + string.Format(" (주기 {0} m)", periodM); } public static object SetC(string prop, float r, float g, float b) { var m = UnityEditor.AssetDatabase.LoadAssetAtPath(kMatPath); if (m == null || !m.HasProperty(prop)) return "no prop " + prop; m.SetColor(prop, new Color(r, g, b, 1f)); UnityEditor.EditorUtility.SetDirty(m); return prop + " = " + m.GetColor(prop).ToString("F4"); } // 카펫/다발 표시 토글 (전/후 비교 캡처용) public static object Show(string which, float on) { int n = 0; foreach (var t in Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { if (t.name != which) continue; t.gameObject.SetActive(on > 0.5f); n++; } return which + " active=" + (on > 0.5f) + " (" + n + "개)"; } public static object PerfStats() { return string.Format("tris={0} verts={1} setPass={2} batches={3} drawCalls={4} dyn={5} static={6} inst={7} | fps={8:F1} t={9:F2}", UnityEditor.UnityStats.triangles, UnityEditor.UnityStats.vertices, UnityEditor.UnityStats.setPassCalls, UnityEditor.UnityStats.batches, UnityEditor.UnityStats.drawCalls, UnityEditor.UnityStats.dynamicBatchedDrawCalls, UnityEditor.UnityStats.staticBatchedDrawCalls, UnityEditor.UnityStats.instancedBatchedDrawCalls, 1f / Mathf.Max(0.0001f, Time.smoothDeltaTime), Time.time); } // Play 중 상호작용 상태 확인 public static object RTState() { if (!Application.isPlaying) return "not playing"; var sb = new StringBuilder(); var binder = Object.FindFirstObjectByType(FindObjectsInactive.Include); sb.AppendLine("binder=" + (binder == null ? "null" : binder.name + " follow=" + (binder.transformToFollow == null ? "null" : binder.transformToFollow.name) + " rt=" + (binder.rt == null ? "null" : binder.rt.name + " " + binder.rt.width + "²"))); var it = Object.FindFirstObjectByType(FindObjectsInactive.Include); sb.AppendLine("interact=" + (it == null ? "null" : it.name + " trail=" + (it.trail == null ? "null" : it.trail.name + " pos=" + V(it.trail.position)))); if (it != null && it.trail != null) { var ps = it.trail.GetComponent(); sb.AppendLine("particles=" + (ps == null ? "null" : ps.particleCount + " playing=" + ps.isPlaying + " space=" + ps.main.simulationSpace + " layer=" + it.trail.gameObject.layer)); } var cams = Camera.allCameras; foreach (var c in cams) sb.AppendLine("cam " + c.name + " depth=" + c.depth + " mask=0x" + c.cullingMask.ToString("X") + " target=" + (c.targetTexture == null ? "screen" : c.targetTexture.name)); return sb.ToString(); } }