Project_WL/AgentScripts/WL783_GrassBF.cs

1028 lines
56 KiB
C#
Raw Permalink Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// 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();
}
}