Project_WL/AgentScripts/WL783_GrassBF.cs

1028 lines
56 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Bounds> s_water;
static List<Vector3> 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<GameObject>(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<Bounds>();
s_feature = new List<Vector3>();
Bounds all = new Bounds(); bool has = false;
foreach (var r in go.GetComponentsInChildren<Renderer>(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<Renderer>();
if (r == null) r = h.collider.GetComponentInParent<Renderer>();
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<float[]>(); // 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<Texture2D>(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<Mesh> BuildCarpet(out long baseTris, out int cellsKept, out int cellsTried)
{
var meshes = new List<Mesh>();
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<Vector3>(); var ns = new List<Vector3>(); var uv = new List<Vector2>(); var tri = new List<int>();
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<Material>(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<Material>(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<Texture2D>(kBFTexDir + "LongGrassPattern.png");
if (grassTex == null) grassTex = UnityEditor.AssetDatabase.LoadAssetAtPath<Texture2D>(kBFTexDir + "GrassPattern.png");
var noiseTex = UnityEditor.AssetDatabase.LoadAssetAtPath<Texture2D>(kBFTexDir + "NoiseCloud.png");
var distTex = UnityEditor.AssetDatabase.LoadAssetAtPath<Texture2D>(kBFTexDir + "PerlinNoise02.png");
var demo = UnityEditor.AssetDatabase.LoadAssetAtPath<Material>(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<Texture2D>(kMaskPath);
var colTex = ColorTex(seed);
var white = UnityEditor.AssetDatabase.LoadAssetAtPath<Texture2D>(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<RenderTexture>(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<GameObject>(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<Camera>();
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<Component>().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<BF_SetInteractiveShaderEffects>();
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<GameObject>(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<ParticleSystem>();
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<ParticleSystemRenderer>();
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<WL_GrassInteract>();
if (camGo != null) { bind.rtCamera = camGo.GetComponent<Camera>(); bind.rtBinder = camGo.GetComponent<BF_SetInteractiveShaderEffects>(); }
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<Light>(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<Texture2D>(kMaskPath) == null)
return "마스크 없음 — 먼저 PaintMask 를 실행할 것";
int fxLayer = EnsureLayer(kFxLayerName);
if (fxLayer < 0) L("[경고] 빈 레이어 없음 → 상호작용 레이어 확보 실패");
// 1) 메시
List<Mesh> 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<Mesh>()
.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<MeshFilter>().sharedMesh = meshes[i];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterials = new[] { mat };
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = true;
var ex = go.AddComponent<BF_MeshExtrusion>();
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<MeshFilter>().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<Texture2D>(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<Transform>();
foreach (Transform t in cont) kids.Add(t);
before = kids.Count;
var pass = new List<Transform>();
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<string>();
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<GameObject>(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<MeshFilter>(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<Material>(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<Material>(kMatPath);
if (m == null) return "mat 없음";
var tm = UnityEditor.AssetDatabase.LoadAssetAtPath<Material>(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<Material>(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<Texture2D>(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<Material>(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<Material>(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<Transform>(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<BF_SetInteractiveShaderEffects>(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<WL_GrassInteract>(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<ParticleSystem>();
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();
}
}