// WL-814c — Critter Environment 룩 패스: ① 머티리얼 전수 실측 ② Toon 머티리얼 생성 ③ 렌더러 피처 추가 // survey : unity command run_script --file AgentScripts/WL814c_Convert.cs --entry WL814c_Convert.Survey // convert: unity command run_script --file AgentScripts/WL814c_Convert.cs --entry WL814c_Convert.Convert // feature: unity command run_script --file AgentScripts/WL814c_Convert.cs --entry WL814c_Convert.AddOutlineFeature // 🔴 원본 .mat / 프리팹 / 씬 수정 0 · Assets/3DPixelArtEnvironment 수정 0 · 새 파일은 Assets/WL/Look/Env/ 아래만. // 🔴 `Environment` 는 Critter 가 쓰는 전역 네임스페이스라 System.Environment 와 충돌한다 → global:: 로 명시. using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public static class WL814c_Convert { const string kSurveyTxt = "AgentScripts/WL814c_SURVEY.txt"; const string kConvertTxt = "AgentScripts/WL814c_CONVERT.txt"; const string kMatDir = "Assets/WL/Look/Env/Materials"; const string kToonShader = "Assets/3DPixelArtEnvironment/Shaders/Toon.shadergraph"; const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; static StringBuilder s_sb; static int s_pass, s_fail; static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814c] " + s); } static void Chk(string n, bool ok, string d) { if (ok) s_pass++; else s_fail++; L((ok ? " PASS " : " FAIL ") + n + " — " + d); } static void Write(string p) { File.WriteAllText(p, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814c] → " + p); } // ═════════════════════════════════════════════════════════════════════════ // ① 실측 표 — 머티리얼 전수 // ═════════════════════════════════════════════════════════════════════════ sealed class MatInfo { public Material mat; public string path; public string shader; public int rendererSlots; public bool hasBaseMap, hasMainTex; public string renderType; public int renderQueue; public bool isParticle; public bool anyMeshHasVertexColor; public string sampleRenderer; } public static void Survey() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 머티리얼 전수 실측 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion); try { // ── URP 렌더러 에셋 var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; L(""); L("## A. 활성 URP 에셋 · 렌더러 에셋"); L(" currentRenderPipeline = " + (urp != null ? AssetDatabase.GetAssetPath(urp) : "null")); var rdPath = RendererDataPath(urp); L(" m_RendererDataList[0] = " + rdPath); var rd = string.IsNullOrEmpty(rdPath) ? null : AssetDatabase.LoadAssetAtPath(rdPath); if (rd != null) { L(" 현재 피처 " + rd.rendererFeatures.Count + "개:"); foreach (var f in rd.rendererFeatures) L(" - " + (f == null ? "(null)" : f.name + " [" + f.GetType().FullName + "] active=" + f.isActive)); } Chk("A 렌더러 에셋", rd != null, rdPath); // ── 맵 머티리얼 var map = AssetDatabase.LoadAssetAtPath(kMapPrefab); Chk("B 맵 프리팹", map != null, kMapPrefab); if (map == null) return; var table = new Dictionary(); int rendererCount = 0, nullSlots = 0; CollectFromPrefab(map, table, ref rendererCount, ref nullSlots); L(""); L("## B. WL_Nature.prefab 머티리얼 전수 (렌더러 " + rendererCount + " · 빈 슬롯 " + nullSlots + ")"); DumpTable(table); // ── 지면 후보 · 물 후보 L(""); L("## C. 지면(Ground) 후보 · 물(Water) 후보 · 메시 읽기 가능 여부"); L(" | 오브젝트 | 메시 | 면적(m²) | isReadable | 바운드 | 머티리얼 |"); L(" |---|---|---|---|---|---|"); var groundRows = new List(); foreach (var mf in map.GetComponentsInChildren(true)) { var mr = mf.GetComponent(); if (mr == null || mf.sharedMesh == null) continue; var b = mr.bounds; float footprint = b.size.x * b.size.z; if (footprint < 100f) continue; // 지면 후보 = 수평 투영 100 m² 이상 float area = -1f; try { if (mf.sharedMesh.isReadable) area = global::Environment.Utilities.MeshUtilities.GetMeshArea(mf.sharedMesh); } catch { area = -2f; } string mats = ""; foreach (var m in mr.sharedMaterials) mats += (m != null ? m.name : "(null)") + " "; groundRows.Add(" | " + Path(mf.transform) + " | " + mf.sharedMesh.name + " | " + (area >= 0 ? area.ToString("0") : (area == -2f ? "예외" : "읽기불가")) + " | " + mf.sharedMesh.isReadable + " | " + b.size.ToString("0.0") + " | " + mats.Trim() + " |"); } groundRows.Sort(); foreach (var r in groundRows) L(r); L(" 지면 후보 " + groundRows.Count + "개"); L(""); L(" 물 후보(이름 또는 셰이더에 water):"); int water = 0; foreach (var r in map.GetComponentsInChildren(true)) { bool nameHit = r.name.ToLowerInvariant().Contains("water"); bool shaderHit = false; foreach (var m in r.sharedMaterials) if (m != null && m.shader != null && m.shader.name.ToLowerInvariant().Contains("water")) shaderHit = true; if (nameHit || shaderHit) { water++; L(" - " + Path(r.transform) + " · " + (r.sharedMaterial != null ? r.sharedMaterial.name : "-")); } } L(" 합계 " + water + "개"); var terrains = map.GetComponentsInChildren(true); L(" Unity Terrain 컴포넌트: " + terrains.Length + "개"); // ── 캐릭터 L(""); L("## D. 캐릭터 프리팹 머티리얼"); var charPaths = new List(); charPaths.Add("Assets/Res_Addr/PC/Ai01.prefab"); foreach (var g in AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" })) { var p = AssetDatabase.GUIDToAssetPath(g); if (p.EndsWith(".prefab")) charPaths.Add(p); } var charTable = new Dictionary(); int cr = 0, cn = 0; int charPrefabs = 0; foreach (var p in charPaths) { var go = AssetDatabase.LoadAssetAtPath(p); if (go == null) continue; charPrefabs++; CollectFromPrefab(go, charTable, ref cr, ref cn); } L(" 캐릭터 프리팹 " + charPrefabs + "개 · 렌더러 " + cr + " · 빈 슬롯 " + cn); DumpTable(charTable); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write(kSurveyTxt); } static void DumpTable(Dictionary table) { var list = new List(table.Values); list.Sort((a, b) => string.CompareOrdinal(a.shader + a.mat.name, b.shader + b.mat.name)); L(" | 머티리얼 | 셰이더 | 슬롯 | RenderType | queue | BaseMap | MainTex | 정점색메시 | 경로 |"); L(" |---|---|---|---|---|---|---|---|---|"); foreach (var i in list) L(" | " + i.mat.name + " | " + i.shader + " | " + i.rendererSlots + " | " + i.renderType + " | " + i.renderQueue + " | " + (i.hasBaseMap ? "O" : "-") + " | " + (i.hasMainTex ? "O" : "-") + " | " + (i.anyMeshHasVertexColor ? "O" : "-") + " | " + i.path + " |"); var byShader = new Dictionary(); foreach (var i in list) { int c; byShader.TryGetValue(i.shader, out c); byShader[i.shader] = c + 1; } L(" 셰이더별 개수:"); foreach (var kv in byShader) L(" " + kv.Key + " = " + kv.Value); L(" 머티리얼 합계 " + list.Count + "종"); } static void CollectFromPrefab(GameObject root, Dictionary table, ref int rendererCount, ref int nullSlots) { foreach (var r in root.GetComponentsInChildren(true)) { rendererCount++; bool vtxColor = false; var mf = r.GetComponent(); if (mf != null && mf.sharedMesh != null && mf.sharedMesh.isReadable) { try { vtxColor = mf.sharedMesh.colors32 != null && mf.sharedMesh.colors32.Length > 0; } catch { } } var smr = r as SkinnedMeshRenderer; if (smr != null && smr.sharedMesh != null && smr.sharedMesh.isReadable) { try { vtxColor = smr.sharedMesh.colors32 != null && smr.sharedMesh.colors32.Length > 0; } catch { } } foreach (var m in r.sharedMaterials) { if (m == null) { nullSlots++; continue; } MatInfo info; if (!table.TryGetValue(m, out info)) { info = new MatInfo { mat = m, path = AssetDatabase.GetAssetPath(m), shader = m.shader != null ? m.shader.name : "(null)", renderType = m.GetTag("RenderType", false, "(none)"), renderQueue = m.renderQueue, hasBaseMap = m.HasProperty("_BaseMap") && m.GetTexture("_BaseMap") != null, hasMainTex = m.HasProperty("_MainTex") && m.GetTexture("_MainTex") != null, sampleRenderer = Path(r.transform) }; info.isParticle = r is ParticleSystemRenderer || info.shader.ToLowerInvariant().Contains("particle"); table[m] = info; } info.rendererSlots++; if (vtxColor) info.anyMeshHasVertexColor = true; } } } static string Path(Transform t) { var sb = new StringBuilder(t.name); var p = t.parent; int guard = 0; while (p != null && guard++ < 12) { sb.Insert(0, p.name + "/"); p = p.parent; } return sb.ToString(); } static string RendererDataPath(UniversalRenderPipelineAsset urp) { if (urp == null) return ""; var so = new SerializedObject(urp); var list = so.FindProperty("m_RendererDataList"); if (list == null || list.arraySize == 0) return ""; int idx = 0; var di = so.FindProperty("m_DefaultRendererIndex"); if (di != null) idx = Mathf.Clamp(di.intValue, 0, list.arraySize - 1); var el = list.GetArrayElementAtIndex(idx).objectReferenceValue; return el != null ? AssetDatabase.GetAssetPath(el) : ""; } // ═════════════════════════════════════════════════════════════════════════ // ② Toon 머티리얼 생성 // ═════════════════════════════════════════════════════════════════════════ public static void Convert() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c Toon 머티리얼 생성 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); try { var toon = AssetDatabase.LoadAssetAtPath(kToonShader); Chk("S 셰이더", toon != null, kToonShader); if (toon == null) return; var cfg = LoadSettings(); Chk("S SO", cfg != null, cfg != null ? AssetDatabase.GetAssetPath(cfg) : "없음"); if (cfg == null) return; Directory.CreateDirectory(kMatDir); // 원본 후보 수집: 맵 + (SO convertCharacters 면) 캐릭터 var table = new Dictionary(); int rc = 0, ns = 0; var map = AssetDatabase.LoadAssetAtPath(kMapPrefab); if (map != null) CollectFromPrefab(map, table, ref rc, ref ns); int mapMats = table.Count; var charPaths = new List(); charPaths.Add("Assets/Res_Addr/PC/Ai01.prefab"); foreach (var g in AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" })) { var p = AssetDatabase.GUIDToAssetPath(g); if (p.EndsWith(".prefab")) charPaths.Add(p); } var charTable = new Dictionary(); foreach (var p in charPaths) { var go = AssetDatabase.LoadAssetAtPath(p); if (go != null) CollectFromPrefab(go, charTable, ref rc, ref ns); } foreach (var kv in charTable) if (!table.ContainsKey(kv.Key)) table[kv.Key] = kv.Value; L(" 후보 = 맵 " + mapMats + "종 + 캐릭터 " + charTable.Count + "종 → 중복 제거 " + table.Count + "종"); var origs = new List(); var toons = new List(); var skipped = new List(); var rows = new List(); foreach (var kv in table) { var src = kv.Key; var info = kv.Value; string why; if (!IsConvertible(info, out why)) { skipped.Add(" | " + src.name + " | " + info.shader + " | " + why + " |"); continue; } bool isChar = charTable.ContainsKey(src) && !IsInMap(src, map); string dst = kMatDir + "/" + Sanitize(src.name) + "_Toon.mat"; var m = AssetDatabase.LoadAssetAtPath(dst); bool created = false; if (m == null) { m = new Material(toon); AssetDatabase.CreateAsset(m, dst); created = true; } if (m.shader != toon) m.shader = toon; ApplyToonValues(m, src, cfg, isChar); EditorUtility.SetDirty(m); origs.Add(src); toons.Add(m); var bm = m.GetTexture("_BaseMap"); rows.Add(" | " + src.name + " | " + info.shader + " | " + m.name + " | " + (bm != null ? bm.name : "(없음)") + " | " + ColorStr(m.GetColor("_DiffuseColor")) + " | " + m.GetFloat("_Shades").ToString("0") + " | " + (isChar ? "캐릭터" : "맵") + " | " + (created ? "신규" : "갱신") + " |"); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); // SO 테이블 채우기 var soo = new SerializedObject(cfg); var pO = soo.FindProperty("originals"); var pT = soo.FindProperty("toons"); pO.arraySize = origs.Count; pT.arraySize = toons.Count; for (int i = 0; i < origs.Count; i++) { pO.GetArrayElementAtIndex(i).objectReferenceValue = origs[i]; pT.GetArrayElementAtIndex(i).objectReferenceValue = toons[i]; } soo.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(cfg); AssetDatabase.SaveAssets(); L(""); L("## 생성 머티리얼 표 (" + toons.Count + "종)"); L(" | 원본 | 원본 셰이더 | 생성 | BaseMap | DiffuseColor | Shades | 구분 | 상태 |"); L(" |---|---|---|---|---|---|---|---|"); rows.Sort(); foreach (var r in rows) L(r); L(""); L("## 제외 (" + skipped.Count + "종)"); L(" | 머티리얼 | 셰이더 | 이유 |"); L(" |---|---|---|"); skipped.Sort(); foreach (var r in skipped) L(r); Chk("C 생성", toons.Count > 0, toons.Count + "종 → " + kMatDir); Chk("C SO 테이블", cfg.originals != null && cfg.originals.Length == toons.Count, cfg.originals != null ? cfg.originals.Length + "쌍" : "null"); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write(kConvertTxt); } static bool IsInMap(Material m, GameObject map) { if (map == null) return false; foreach (var r in map.GetComponentsInChildren(true)) foreach (var x in r.sharedMaterials) if (x == m) return true; return false; } /// 불투명 서피스 머티리얼만 변환. 파티클·투명·이펙트·UI·스카이박스·물은 제외(룩 유지). static bool IsConvertible(MatInfo i, out string why) { why = ""; string sh = i.shader.ToLowerInvariant(); string nm = i.mat.name.ToLowerInvariant(); if (i.isParticle) { why = "파티클"; return false; } if (sh.Contains("particle") || sh.Contains("ui/") || sh.Contains("skybox") || sh.Contains("sprite") || sh.Contains("textmeshpro") || sh.Contains("unlit") || sh.Contains("universal render pipeline/2d")) { why = "파티클/UI/폰트/Unlit"; return false; } if (sh.Contains("water") || nm.Contains("water")) { why = "물(별도 Critter Water 스왑)"; return false; } if (i.renderType == "Transparent" || i.renderType == "TransparentCutout") { why = "투명(" + i.renderType + ")"; return false; } if (i.renderQueue >= 2450) { why = "queue " + i.renderQueue + "(투명)"; return false; } if (sh.Contains("shader graphs/toon") && !sh.Contains("water")) { why = "이미 Critter Toon"; return false; } bool surface = i.mat.HasProperty("_BaseMap") || i.mat.HasProperty("_MainTex") || i.mat.HasProperty("_BaseColor") || i.mat.HasProperty("_Color"); if (!surface) { why = "서피스 머티리얼 아님(_BaseMap/_MainTex/_Color 0)"; return false; } return true; } // ═════════════════════════════════════════════════════════════════════════ // ⓪ 설정 SO 생성 + Critter 에셋 참조 채우기 // ═════════════════════════════════════════════════════════════════════════ public static void CreateSettings() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 설정 SO 생성 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); try { const string dir = "Assets/WL/Look/Env/Resources/WL"; const string path = dir + "/WLEnvLookSettings.asset"; Directory.CreateDirectory(dir); var cfg = AssetDatabase.LoadAssetAtPath(path); bool created = false; if (cfg == null) { cfg = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(cfg, path); created = true; } cfg.grassMesh = LoadSub("Assets/3DPixelArtEnvironment/Meshes/Instanced/Grass_Leaf.fbx"); cfg.flowerMesh = LoadSub("Assets/3DPixelArtEnvironment/Meshes/Instanced/Flower_Leaf.fbx"); cfg.grassMaterial = AssetDatabase.LoadAssetAtPath("Assets/3DPixelArtEnvironment/Materials/Instanced_Grass.mat"); cfg.flowerMaterial = AssetDatabase.LoadAssetAtPath("Assets/3DPixelArtEnvironment/Materials/Instanced_Flower.mat"); cfg.waterMaterial = AssetDatabase.LoadAssetAtPath("Assets/3DPixelArtEnvironment/Materials/Water.mat"); EditorUtility.SetDirty(cfg); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Chk("S0 SO", cfg != null, (created ? "신규 " : "갱신 ") + path); Chk("S0 잔디 메시", cfg.grassMesh != null, cfg.grassMesh != null ? cfg.grassMesh.name : "null"); Chk("S0 잔디 머티리얼", cfg.grassMaterial != null, cfg.grassMaterial != null ? cfg.grassMaterial.name : "null"); Chk("S0 꽃 메시", cfg.flowerMesh != null, cfg.flowerMesh != null ? cfg.flowerMesh.name : "null"); Chk("S0 꽃 머티리얼", cfg.flowerMaterial != null, cfg.flowerMaterial != null ? cfg.flowerMaterial.name : "null"); Chk("S0 물 머티리얼", cfg.waterMaterial != null, cfg.waterMaterial != null ? cfg.waterMaterial.name : "null"); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write("AgentScripts/WL814c_SETTINGS.txt"); } static T LoadSub(string path) where T : UnityEngine.Object { var all = AssetDatabase.LoadAllAssetsAtPath(path); foreach (var a in all) { var t = a as T; if (t != null) return t; } return null; } static void ApplyToonValues(Material m, Material src, WL.Look.Env.WLEnvLookSettings c, bool isChar) { // BaseMap / DiffuseColor 이관 — 원본 _BaseMap → _MainTex 순 Texture tex = null; if (src.HasProperty("_BaseMap")) tex = src.GetTexture("_BaseMap"); if (tex == null && src.HasProperty("_MainTex")) tex = src.GetTexture("_MainTex"); Vector2 sc = Vector2.one, of = Vector2.zero; if (src.HasProperty("_BaseMap")) { sc = src.GetTextureScale("_BaseMap"); of = src.GetTextureOffset("_BaseMap"); } else if (src.HasProperty("_MainTex")) { sc = src.GetTextureScale("_MainTex"); of = src.GetTextureOffset("_MainTex"); } Color baseCol = Color.white; if (src.HasProperty("_BaseColor")) baseCol = src.GetColor("_BaseColor"); else if (src.HasProperty("_Color")) baseCol = src.GetColor("_Color"); m.SetTexture("_BaseMap", tex); m.SetTextureScale("_BaseMap", sc); m.SetTextureOffset("_BaseMap", of); m.SetTexture("_ShadowBaseMap", tex); m.SetTextureScale("_ShadowBaseMap", sc); m.SetTextureOffset("_ShadowBaseMap", of); m.SetColor("_DiffuseColor", baseCol); // 그림자 색 = 원본 색을 어둡게 + 살짝 차갑게(데모 머티리얼의 관계를 따름) Color sh = new Color(baseCol.r * c.shadowTint.r, baseCol.g * c.shadowTint.g, baseCol.b * c.shadowTint.b, baseCol.a); m.SetColor("_ShadowDiffuseColor", sh); // 툰 램프 m.SetFloat("_Shades", c.shades); m.SetFloat("_Brightness", c.brightness); m.SetFloat("_MinimumDarkness", c.minimumDarkness); if (m.HasProperty("_AmbientStrength")) m.SetFloat("_AmbientStrength", c.ambientStrength); // 외곽선 m.SetFloat("_DepthThreshold", c.depthThreshold); m.SetFloat("_NormalThreshold", c.normalThreshold); m.SetVector("_NormalBias", c.normalBias); m.SetFloat("_DepthEdgeStrength", c.depthEdgeStrength); m.SetFloat("_NormalEdgeStrength", c.normalEdgeStrength); if (m.HasProperty("_Outline")) m.SetColor("_Outline", c.outlineColor); // 🔴 캐릭터(스킨드 메시)는 폴리곤이 촘촘해 270px 에서 노멀 외곽선이 노이즈가 된다 → SO 로 따로 끈다. SetKw(m, "_OUTLINESENABLED", c.outlinesEnabled && (!isChar || c.characterOutlines)); // 구름 그림자 // 🔴 실측: 이 셰이더에 `_CLOUDSENABLED` 키워드는 **없다**(keywordSpace 36개에 없음 · 데모 .mat 의 // `_CLOUDSENABLED` 는 구버전 잔재라 m_InvalidKeywords 로 들어가 있다). 구름 on/off 는 `_Cloud_Strength` 로 한다. m.SetFloat("_Cloud_Density", c.cloudDensity); m.SetVector("_Cloud_Movement", c.cloudMovement); m.SetFloat("_Cloud_Strength", c.cloudShadows ? c.cloudStrength : 0f); m.SetFloat("_Cloud_Cover", c.cloudCover); m.SetFloat("_Cloud_Change", c.cloudChange); m.SetVector("_Cloud_Step", c.cloudStep); m.DisableKeyword("_CLOUDSENABLED"); // 구버전 잔재 키워드가 m_InvalidKeywords 로 남지 않게 m.enableInstancing = src.enableInstancing; m.doubleSidedGI = src.doubleSidedGI; if (src.HasProperty("_Cull") && m.HasProperty("_Cull")) m.SetFloat("_Cull", src.GetFloat("_Cull")); m.renderQueue = -1; } static void SetKw(Material m, string kw, bool on) { if (on) m.EnableKeyword(kw); else m.DisableKeyword(kw); if (m.HasProperty(kw)) m.SetFloat(kw, on ? 1f : 0f); } static string ColorStr(Color c) { return "(" + c.r.ToString("0.00") + "," + c.g.ToString("0.00") + "," + c.b.ToString("0.00") + ")"; } static string Sanitize(string n) { var sb = new StringBuilder(); foreach (var ch in n) sb.Append(char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' || ch == '.' ? ch : '_'); return sb.ToString(); } static WL.Look.Env.WLEnvLookSettings LoadSettings() { var all = AssetDatabase.FindAssets("t:WLEnvLookSettings"); foreach (var g in all) { var p = AssetDatabase.GUIDToAssetPath(g); var a = AssetDatabase.LoadAssetAtPath(p); if (a != null) return a; } return null; } // ═════════════════════════════════════════════════════════════════════════ // ③ 렌더러 피처 추가 (🔴 URP 에셋 디스크 수정은 이 1건만 허용) // ═════════════════════════════════════════════════════════════════════════ public static void AddOutlineFeature() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 픽셀 외곽선 렌더러 피처 추가 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); try { var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; string rdPath = RendererDataPath(urp); var rd = AssetDatabase.LoadAssetAtPath(rdPath); Chk("F 렌더러 에셋", rd != null, rdPath); if (rd == null) return; var type = typeof(global::Environment.PixelOutlineSetupFeature); foreach (var f in rd.rendererFeatures) if (f != null && f.GetType() == type) { Chk("F 이미 있음(추가 0)", true, f.name); Write(kConvertTxt.Replace("CONVERT", "FEATURE")); return; } var feature = (ScriptableRendererFeature)ScriptableObject.CreateInstance(type); feature.name = "PixelOutlineSetup"; feature.hideFlags = HideFlags.HideInHierarchy; feature.SetActive(false); // 🔴 디스크 기본값 = off → 타이틀/로비 비용 0 · 런타임이 인게임에서만 켠다 AssetDatabase.AddObjectToAsset(feature, rd); var so = new SerializedObject(rd); var feats = so.FindProperty("m_RendererFeatures"); var map = so.FindProperty("m_RendererFeatureMap"); feats.arraySize++; feats.GetArrayElementAtIndex(feats.arraySize - 1).objectReferenceValue = feature; // m_RendererFeatureMap = 각 피처 localFileId 를 8바이트 리틀엔디언으로 이어붙인 hex 문자열 long id; string guid; AssetDatabase.TryGetGUIDAndLocalFileIdentifier(feature, out guid, out id); map.stringValue = map.stringValue + Hex8(id); so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(rd); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(rdPath, ImportAssetOptions.ForceUpdate); var reload = AssetDatabase.LoadAssetAtPath(rdPath); int n = 0; foreach (var f in reload.rendererFeatures) if (f != null && f.GetType() == type) n++; Chk("F 추가 확인", n == 1, "피처 " + reload.rendererFeatures.Count + "개 · PixelOutlineSetup " + n + "개 · map=" + new SerializedObject(reload).FindProperty("m_RendererFeatureMap").stringValue); foreach (var f in reload.rendererFeatures) L(" - " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive)); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write("AgentScripts/WL814c_FEATURE.txt"); } /// m_RendererFeatureMap(List<long>) 을 현재 피처 목록의 localFileId 로 다시 만든다. public static void FixFeatureMap() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 렌더러 피처 맵 재작성 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); try { var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; string rdPath = RendererDataPath(urp); var rd = AssetDatabase.LoadAssetAtPath(rdPath); Chk("M 렌더러 에셋", rd != null, rdPath); if (rd == null) return; var so = new SerializedObject(rd); var map = so.FindProperty("m_RendererFeatureMap"); Chk("M 맵 프로퍼티", map != null && map.isArray, map != null ? "isArray=" + map.isArray : "null"); if (map == null || !map.isArray) return; map.arraySize = rd.rendererFeatures.Count; for (int i = 0; i < rd.rendererFeatures.Count; i++) { long id = 0; string guid; if (rd.rendererFeatures[i] != null) AssetDatabase.TryGetGUIDAndLocalFileIdentifier(rd.rendererFeatures[i], out guid, out id); map.GetArrayElementAtIndex(i).longValue = id; L(" [" + i + "] " + (rd.rendererFeatures[i] != null ? rd.rendererFeatures[i].name : "(null)") + " localId=" + id); } so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(rd); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(rdPath, ImportAssetOptions.ForceUpdate); var re = AssetDatabase.LoadAssetAtPath(rdPath); var so2 = new SerializedObject(re); var m2 = so2.FindProperty("m_RendererFeatureMap"); Chk("M 맵 길이", m2.arraySize == re.rendererFeatures.Count, m2.arraySize + " / 피처 " + re.rendererFeatures.Count); bool nulls = false; foreach (var f in re.rendererFeatures) if (f == null) nulls = true; Chk("M null 피처 0", !nulls, nulls ? "있음" : "없음"); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write("AgentScripts/WL814c_FEATUREMAP.txt"); } static string Hex8(long id) { var b = BitConverter.GetBytes(id); var sb = new StringBuilder(16); for (int i = 0; i < 8; i++) sb.Append(b[i].ToString("x2")); return sb.ToString(); } }