From a8d624420805b8f14955ee531e19cb27f95b4d38 Mon Sep 17 00:00:00 2001 From: swrring Date: Sat, 12 Sep 2026 18:11:13 +0900 Subject: [PATCH] =?UTF-8?q?[WL-814j]=20B=EC=95=88=201=EC=B0=A8:=20?= =?UTF-8?q?=ED=8C=94=EB=A0=88=ED=8A=B8=20=EC=96=91=EC=9E=90=ED=99=94=20+?= =?UTF-8?q?=20Bayer=20=EB=94=94=EB=8D=94=20=EC=85=B0=EC=9D=B4=EB=8D=94?= =?UTF-8?q?=C2=B7=EB=A0=8C=EB=8D=94=EB=9F=AC=20=ED=94=BC=EC=B2=98=C2=B7SO?= =?UTF-8?q?=C2=B7=EB=A3=A9=ED=97=88=EB=B8=8C=20=EC=97=B0=EA=B2=B0=20(#814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- AgentScripts/WL814j_Probe.cs | 745 ++++++++++++++++++ AgentScripts/WL814j_Setup.cs | 154 ++++ Assets/WL/Look/Palette.meta | 8 + Assets/WL/Look/Palette/PaletteDither.cs | 217 +++++ Assets/WL/Look/Palette/PaletteDither.cs.meta | 2 + .../WL/Look/Palette/PaletteDitherFeature.cs | 226 ++++++ .../Look/Palette/PaletteDitherFeature.cs.meta | 2 + Assets/WL/Look/Palette/WLPaletteDither.shader | 146 ++++ .../Look/Palette/WLPaletteDither.shader.meta | 9 + Assets/WL/Look/Palette/WLPaletteSettings.cs | 119 +++ .../WL/Look/Palette/WLPaletteSettings.cs.meta | 2 + 11 files changed, 1630 insertions(+) create mode 100644 AgentScripts/WL814j_Probe.cs create mode 100644 AgentScripts/WL814j_Setup.cs create mode 100644 Assets/WL/Look/Palette.meta create mode 100644 Assets/WL/Look/Palette/PaletteDither.cs create mode 100644 Assets/WL/Look/Palette/PaletteDither.cs.meta create mode 100644 Assets/WL/Look/Palette/PaletteDitherFeature.cs create mode 100644 Assets/WL/Look/Palette/PaletteDitherFeature.cs.meta create mode 100644 Assets/WL/Look/Palette/WLPaletteDither.shader create mode 100644 Assets/WL/Look/Palette/WLPaletteDither.shader.meta create mode 100644 Assets/WL/Look/Palette/WLPaletteSettings.cs create mode 100644 Assets/WL/Look/Palette/WLPaletteSettings.cs.meta diff --git a/AgentScripts/WL814j_Probe.cs b/AgentScripts/WL814j_Probe.cs new file mode 100644 index 000000000..483f57b34 --- /dev/null +++ b/AgentScripts/WL814j_Probe.cs @@ -0,0 +1,745 @@ +// WL-814j 프로브 — 팔레트 양자화 + Bayer 디더(저해상도 게임 카메라 한정) 실측 (에디트 모드 · Play 0 · 로그인 0) +// probe: unity command run_script --file AgentScripts/WL814j_Probe.cs --entry WL814j_Probe.RunAll +// cap: unity command run_script --file AgentScripts/WL814j_Probe.cs --entry WL814j_Probe.Capture +// 산출물: AgentScripts/WL814j_PROBE.txt · WL814j_CAPTURE.txt · 캡처 PNG 4장 +// 🔴 씬 저장 0 · 에셋 쓰기 0(AssetDatabase.SaveAssets 호출 0 = URP 에셋 재저장 방지) · 임시 오브젝트는 DontSave + DestroyImmediate. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using UnityEngine.SceneManagement; +using WL.Look.Env; +using WL.Look.Palette; +using WL.Look.Toggle; +using WL.PixelArt; + +public static class WL814j_Probe +{ + const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814j"; + const string kTxt = "AgentScripts/WL814j_PROBE.txt"; + const string kTxtCap = "AgentScripts/WL814j_CAPTURE.txt"; + const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; + const string kCamPrefab = "Assets/ResWork/Prefabs/Ingame/Main Camera.prefab"; + const string kRendererPath = "Assets/Settings/URP-Balanced-Renderer.asset"; + + // 도트A 프리셋 = ortho 3.4 · pixelHeight 240 → 9:16 → 135×240 · 화면 1080×1920 = 정수 8배 + const int kLowW = 135, kLowH = 240, kScale = 8; + const int kHiW = kLowW * kScale, kHiH = kLowH * kScale; + + static StringBuilder s_sb; + static int s_pass, s_fail; + static void L(string s) { s_sb.AppendLine(s); } + static void H(string s) { s_sb.AppendLine(); s_sb.AppendLine("── " + 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("[WL814j] → " + p + " · " + (s_fail == 0 ? "PASS" : "FAIL") + " " + s_pass + "/" + (s_pass + s_fail)); } + + // ═════════════════════════════════════════════════════════════════════════ + // RunAll + // ═════════════════════════════════════════════════════════════════════════ + public static void RunAll() + { + s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; + L("# WL-814j 프로브 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); + L("playMode=" + Application.isPlaying + " · colorSpace=" + QualitySettings.activeColorSpace); + + bool savedRtDisable = WLPaletteSettings.RuntimeDisabled; + try + { + A_Setup(); + B_TargetOnly(); + C_DitherGrid(); + D_Levels(); + E_Gc(); + F_C8(); + G_ModeCycle(); + } + catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } + finally + { + WLPaletteSettings.RuntimeDisabled = savedRtDisable; + Z_Cleanup(); + } + + L(""); + L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); + Write(kTxt); + } + + // ─────────────────────────────────────────── A. 전제 + static void A_Setup() + { + H("A. 전제 — SO · 셰이더 · 렌더러 피처"); + WLPaletteSettings.ClearCache(); + var c = WLPaletteSettings.Instance; + Chk("A1 SO", c != null, c != null ? "Resources/WL/WLPaletteSettings · enabled " + c.enabled_ + " · levels " + c.levels + " · dither " + c.ditherStrength : "없음"); + + var sh = Shader.Find(PaletteDitherFeature.ShaderName); + Chk("A2 셰이더", sh != null && sh.isSupported, sh != null ? PaletteDitherFeature.ShaderName + " · isSupported " + sh.isSupported + " · passCount " + sh.passCount : "없음"); + + var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); + Chk("A3 렌더러 에셋", rd != null, kRendererPath); + if (rd != null) + { + int pal = 0; + L(" 피처 " + rd.rendererFeatures.Count + "개(순서대로):"); + for (int i = 0; i < rd.rendererFeatures.Count; i++) + { + var f = rd.rendererFeatures[i]; + L(" [" + i + "] " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive)); + if (f is PaletteDitherFeature) pal++; + } + Chk("A4 팔레트 피처 1건만", pal == 1, "WLPaletteDither " + pal + "개"); + } + + PaletteDither.ClearFeatureCache(); + var feat = PaletteDither.SetFeature(true); + Chk("A5 피처 찾기·켜기", feat && PaletteDither.FeatureActiveNow, "isActive=" + PaletteDither.FeatureActiveNow); + var pf = PaletteDither.Feature as PaletteDitherFeature; + Chk("A6 머티리얼 생성", pf != null && pf.ProbeMaterial != null, pf != null && pf.ProbeMaterial != null ? pf.ProbeMaterial.shader.name : "없음"); + } + + // ─────────────────────────────────────────── B. 대상 카메라에서만 패스 + static Camera _camA, _camB, _camPrev; + static GameObject _goA, _goB, _goPrev; + + static void B_TargetOnly() + { + H("B. 🔴 대상 카메라에서만 패스 삽입 (뷰·UI·프리뷰 0)"); + _goA = MakeCam("__WL814j_Target", out _camA); + _goB = MakeCam("__WL814j_Other", out _camB); + _goPrev = MakeCam("__WL814j_Preview", out _camPrev); + _camPrev.cameraType = CameraType.Preview; + + PaletteDitherFeature.TargetCamera = _camA; + PaletteDither.SetFeature(true); + + PaletteDitherFeature.ResetCounters(); + RenderToLow(_camA, null); + int a = PaletteDitherFeature.PassesAdded; + Chk("B1 대상 카메라 = 패스 1건", a == 1, "PassesAdded " + a + " · 대상 '" + PaletteDitherFeature.LastTargetName + "' · 이벤트 " + PaletteDitherFeature.LastEvent); + + PaletteDitherFeature.ResetCounters(); + RenderToLow(_camB, null); + Chk("B2 다른 카메라 = 패스 0", PaletteDitherFeature.PassesAdded == 0, + "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); + + PaletteDitherFeature.ResetCounters(); + RenderToLow(_camPrev, null); + Chk("B3 프리뷰 카메라 = 패스 0", PaletteDitherFeature.PassesAdded == 0, + "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); + + // 대상 미지정 + 리그 미조립 → 어느 카메라에서도 0 + PaletteDitherFeature.TargetCamera = null; + PaletteDitherFeature.ResetCounters(); + RenderToLow(_camA, null); + Chk("B4 대상 없음 = 패스 0", PaletteDitherFeature.PassesAdded == 0, + "리그조립 " + PixelCameraRig.IsAssembled + " · skip=" + PaletteDitherFeature.LastSkipReason); + PaletteDitherFeature.TargetCamera = _camA; + + // 렌더 순서 실측 — 814c 외곽선 피처와의 상대 위치 + var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); + string order = ""; + if (rd != null) + foreach (var f in rd.rendererFeatures) + if (f != null) order += (order.Length > 0 ? " → " : "") + f.name; + var c = WLPaletteSettings.Instance; + L(" 렌더러 피처 배열 순서 = " + order); + L(" 팔레트 패스 이벤트 = " + PaletteDitherFeature.LastEvent + "(" + (int)PaletteDitherFeature.LastEvent + ")" + + " · SO afterPostProcessing=" + (c != null && c.afterPostProcessing)); + L(" 814c PixelOutlineSetup 이벤트 = AfterRenderingOpaques(" + (int)RenderPassEvent.AfterRenderingOpaques + ") — 빈 패스로 깊이·노멀·색 텍스처만 켠다."); + L(" → 외곽선 자체는 Critter Toon 머티리얼 안에서 **불투명 지오메트리와 함께** 그려지므로 항상 이 패스보다 앞이다 = 외곽선까지 양자화된다."); + Chk("B5 팔레트가 외곽선보다 뒤", (int)PaletteDitherFeature.LastEvent > (int)RenderPassEvent.AfterRenderingOpaques, + (int)PaletteDitherFeature.LastEvent + " > " + (int)RenderPassEvent.AfterRenderingOpaques); + } + + // ─────────────────────────────────────────── C. 디더 격자 = RT 픽셀 1:1 + static void C_DitherGrid() + { + H("C. 🔴 Bayer 디더 격자가 RT 픽셀과 1:1 (단색 화면 · 4×4 주기 실측)"); + var c = WLPaletteSettings.Instance; + if (c == null || _camA == null) { Chk("C0", false, "전제 없음"); return; } + + float savedD = c.ditherStrength; bool saved8 = c.dither8x8; int savedL = c.levels; + try + { + _camA.cullingMask = 0; + _camA.clearFlags = CameraClearFlags.SolidColor; + _camA.backgroundColor = new Color(0.52f, 0.31f, 0.66f, 1f); // 세 채널 모두 양자화 경계 근처 + PaletteDitherFeature.TargetCamera = _camA; + PaletteDither.SetFeature(true); + + // ① 디더 off = 완전 단색 + c.levels = 6; c.ditherStrength = 0f; c.dither8x8 = false; + var off = ReadLow(_camA); + int offColors = Distinct(off); + Chk("C1 디더 0 → 단색 1종", offColors == 1, "고유 색 " + offColors + "종 · " + Rgb(off[0])); + + // ② 디더 4×4 + c.ditherStrength = 0.6f; + var d4 = ReadLow(_camA); + int d4Colors = Distinct(d4); + int bad4x = Period(d4, 4, 0), bad4y = Period(d4, 0, 4); + int bad2x = Period(d4, 2, 0), bad2y = Period(d4, 0, 2); + Chk("C2 디더 4×4 → 색 2종 이상", d4Colors >= 2, "고유 색 " + d4Colors + "종"); + Chk("C3 x 주기 4 정확", bad4x == 0, "p(x,y) != p(x+4,y) 인 픽셀 " + bad4x + "개 / " + ((kLowW - 4) * kLowH)); + Chk("C4 y 주기 4 정확", bad4y == 0, "p(x,y) != p(x,y+4) 인 픽셀 " + bad4y + "개 / " + (kLowW * (kLowH - 4))); + Chk("C5 주기 2 아님(= 진짜 4×4)", bad2x > 0 || bad2y > 0, "x " + bad2x + " · y " + bad2y + " 개 불일치"); + + // ③ 디더 8×8 + c.dither8x8 = true; + var d8 = ReadLow(_camA); + int bad8x = Period(d8, 8, 0), bad8y = Period(d8, 0, 8); + int bad84x = Period(d8, 4, 0); + Chk("C6 8×8 x 주기 8 정확", bad8x == 0, "불일치 " + bad8x + "개"); + Chk("C7 8×8 y 주기 8 정확", bad8y == 0, "불일치 " + bad8y + "개"); + Chk("C8g 8×8 는 주기 4 아님", bad84x > 0, "주기 4 불일치 " + bad84x + "개"); + + L(" → 디더 점 1개 = 저해상도 RT 1픽셀(135×240). 화면(1080×1920)에서는 Point 확대로 8×8 화면픽셀 블록이 된다."); + } + finally + { + c.ditherStrength = savedD; c.dither8x8 = saved8; c.levels = savedL; + _camA.cullingMask = -1; _camA.clearFlags = CameraClearFlags.Skybox; + } + } + + // ─────────────────────────────────────────── D. 레벨별 색 수 상한 + static void D_Levels() + { + H("D. 채널 레벨별 색 수 (그라데이션 화면 · levels^3 상한 실측)"); + var c = WLPaletteSettings.Instance; + if (c == null || _camA == null) { Chk("D0", false, "전제 없음"); return; } + + int savedL = c.levels; float savedD = c.ditherStrength; + GameObject quad = null; + try + { + // 화면을 꽉 채우는 그라데이션 = 스카이박스(하늘 그라데이션)로 대신한다 — 색이 충분히 다양하다. + _camA.cullingMask = 0; + _camA.clearFlags = CameraClearFlags.Skybox; + PaletteDitherFeature.TargetCamera = _camA; + + PaletteDither.SetFeature(false); + var baseline = ReadLow(_camA); + int n0 = Distinct(baseline); + + PaletteDither.SetFeature(true); + L(""); + L(" | levels | dither | 고유 색 수 | 상한 levels^3 |"); + L(" |---|---|---|---|"); + L(" | (패스 없음) | — | " + n0 + " | — |"); + int[] ls = { 4, 6, 8 }; + for (int i = 0; i < ls.Length; i++) + { + c.levels = ls[i]; c.ditherStrength = 0.6f; + var px = ReadLow(_camA); + int n = Distinct(px); + int cap = ls[i] * ls[i] * ls[i]; + L(" | " + ls[i] + " | 0.6 | " + n + " | " + cap + " |"); + Chk("D" + (i + 1) + " levels " + ls[i] + " 상한 준수", n <= cap, n + " ≤ " + cap); + } + c.levels = 6; + var q6 = ReadLow(_camA); + Chk("D4 색 수 감소", Distinct(q6) < n0, "패스 없음 " + n0 + "종 → levels 6 " + Distinct(q6) + "종"); + } + finally + { + c.levels = savedL; c.ditherStrength = savedD; + if (quad != null) UnityEngine.Object.DestroyImmediate(quad); + _camA.cullingMask = -1; + } + } + + // ─────────────────────────────────────────── E. GC + static void E_Gc() + { + H("E. GC — 프레임당 할당 0"); + var pf = PaletteDither.Feature as PaletteDitherFeature; + PaletteDither.Tick(); // 안정화 + + Gc("PaletteDither.Tick ×200k", delegate { PaletteDither.Tick(); }, 200000); + Gc("PaletteDither.Want ×200k", delegate { PaletteDither.Want(); }, 200000); + if (pf != null) Gc("피처 값 푸시(SO→머티리얼) ×200k", delegate { pf.ProbePushValues(); }, 200000); + + // 대조군 — 계기 감도 + long b2 = GC.GetTotalMemory(false); + var junk = new byte[16 * 1024 * 1024]; junk[0] = 1; + long a2 = GC.GetTotalMemory(false); + Chk("E9 대조군 16 MB 감지", a2 - b2 > 15000000, "Δ" + (a2 - b2) + " B = 계기 감도 확인"); + junk = null; + } + + static void Gc(string name, Action a, int n) + { + a(); + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long mono0 = GC.GetTotalMemory(false); + long total0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + int g0 = GC.CollectionCount(0); + for (int i = 0; i < n; i++) a(); + long mono1 = GC.GetTotalMemory(false); + long total1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + Chk("E " + name, mono1 - mono0 == 0 && GC.CollectionCount(0) == g0, + "mono Δ" + (mono1 - mono0) + " B · profiler Δ" + (total1 - total0) + " B · gen0 " + g0 + "→" + GC.CollectionCount(0)); + } + + // ─────────────────────────────────────────── F. C8 + static void F_C8() + { + H("F. C8 — SO off / 피처 off 면 저해상도 RT 가 원본과 픽셀 단위로 같다"); + var c = WLPaletteSettings.Instance; + if (c == null || _camA == null) { Chk("F0", false, "전제 없음"); return; } + + _camA.cullingMask = 0; + _camA.clearFlags = CameraClearFlags.Skybox; + PaletteDitherFeature.TargetCamera = _camA; + + PaletteDither.SetFeature(false); + var orig = ReadLow(_camA); + long hOrig = Hash(orig); + + // ① 피처 on + SO off + PaletteDither.SetFeature(true); + WLPaletteSettings.RuntimeDisabled = true; + PaletteDitherFeature.ResetCounters(); + var so_off = ReadLow(_camA); + Chk("F1 SO off → 패스 0", PaletteDitherFeature.PassesAdded == 0, "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); + Chk("F2 SO off → 픽셀 동일", Hash(so_off) == hOrig, "해시 " + Hash(so_off) + " vs " + hOrig); + WLPaletteSettings.RuntimeDisabled = false; + + // ② 피처 off + PaletteDither.SetFeature(false); + PaletteDitherFeature.ResetCounters(); + var f_off = ReadLow(_camA); + Chk("F3 피처 off → AddRenderPasses 호출 0", PaletteDitherFeature.PassesAdded == 0 && PaletteDitherFeature.PassesSkipped == 0, + "added " + PaletteDitherFeature.PassesAdded + " · skipped " + PaletteDitherFeature.PassesSkipped + "(URP 가 비활성 피처를 아예 안 부른다)"); + Chk("F4 피처 off → 픽셀 동일", Hash(f_off) == hOrig, "해시 " + Hash(f_off) + " vs " + hOrig); + + // ③ 다시 켜면 달라진다(대조군) + PaletteDither.SetFeature(true); + var on = ReadLow(_camA); + Chk("F5 대조군 — 켜면 달라진다", Hash(on) != hOrig, "해시 " + Hash(on) + " != " + hOrig); + + _camA.cullingMask = -1; + } + + // ─────────────────────────────────────────── G. 도트 모드 전환 3회 왕복 + static GameObject _mainCamGo, _infoGo; + static Camera _srcCam; + + static void G_ModeCycle() + { + H("G. 도트 모드 전환 3회 왕복 — 예외 0 · 대상 카메라가 리그 게임 카메라로 따라온다"); + PaletteDitherFeature.TargetCamera = null; // 🔴 리그 폴백 경로를 실제로 태운다 + + var pf = AssetDatabase.LoadAssetAtPath(kCamPrefab); + if (pf == null) { Chk("G0 원본 카메라 프리팹", false, kCamPrefab); return; } + _mainCamGo = UnityEngine.Object.Instantiate(pf); + _mainCamGo.name = "__WL814j_MainCamera"; + _mainCamGo.hideFlags = HideFlags.DontSave; + _srcCam = _mainCamGo.GetComponent(); + Chk("G1 Camera.main", Camera.main == _srcCam, Camera.main != null ? Camera.main.name : "null"); + + // 가짜 InGameInfo — 로비 판정만 인게임으로(에디트 모드 Awake 미실행 · Ins 는 순수 static) + _infoGo = new GameObject("__WL814j_InGameInfo"); + _infoGo.hideFlags = HideFlags.DontSave; + var info = _infoGo.AddComponent(); + InGameInfo.Ins = info; + var fld = typeof(InGameInfo).GetField("m_GameMode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (fld != null) fld.SetValue(info, eGameMode.Stage); + Chk("G2 인게임 판정", !LookModeHub.InLobby, "InLobby=" + LookModeHub.InLobby); + + LookModeHub.ResetAll(); + PaletteDither.ClearFeatureCache(); + PaletteDither.ResetForProbe(); + PaletteDither.EnsureSubscribed(); + + int ex = 0; + L(""); + L(" | 왕복 | 모드 | 리그조립 | 대상 카메라 | RT | 팔레트 on | 피처 active |"); + L(" |---|---|---|---|---|---|---|"); + for (int round = 1; round <= 3; round++) + { + for (int m = 1; m >= 0; m--) + { + try + { + LookModeHub.SetCameraMode(m); + LookModeHub.Tick(); + PaletteDither.Tick(); + } + catch (Exception e) { ex++; L(" EXCEPTION mode " + m + " — " + e.GetType().Name + " " + e.Message); } + + var t = PaletteDitherFeature.ResolvedTarget; + var mgr = PixelCameraRig.Manager; + string rt = mgr != null ? mgr.GameResolution.x + "×" + mgr.GameResolution.y : "—"; + L(" | " + round + " | " + m + " | " + (PixelCameraRig.IsAssembled ? "O" : "-") + + " | " + (t != null ? t.name : "(없음)") + " | " + rt + + " | " + PaletteDither.IsOn + " | " + PaletteDither.FeatureActiveNow + " |"); + + if (m == 1) + { + Chk("G" + round + "a 도트 모드 → on", PaletteDither.IsOn && PaletteDither.FeatureActiveNow, + "on=" + PaletteDither.IsOn + " 피처=" + PaletteDither.FeatureActiveNow); + Chk("G" + round + "b 대상 = 리그 게임 카메라", t != null && ReferenceEquals(t, PixelCameraRig.GameCamera), + t != null ? t.name : "(없음)"); + } + else + { + Chk("G" + round + "c 기본 모드 → off", !PaletteDither.IsOn && !PaletteDither.FeatureActiveNow, + "on=" + PaletteDither.IsOn + " 피처=" + PaletteDither.FeatureActiveNow); + } + } + } + Chk("G9 예외 0", ex == 0, ex + "건"); + L(" " + PaletteDither.Dump()); + + // 로비 강등 = off + if (fld != null) fld.SetValue(info, eGameMode.Lobby); + LookModeHub.SetCameraMode(1); + LookModeHub.Tick(); + PaletteDither.Tick(); + Chk("G10 로비 강등 → off", !PaletteDither.IsOn, "AppliedMode " + LookModeHub.AppliedMode + " · on " + PaletteDither.IsOn); + if (fld != null) fld.SetValue(info, eGameMode.Stage); + } + + // ─────────────────────────────────────────── Z. 정리 + static void Z_Cleanup() + { + H("Z. 정리"); + try + { + PaletteDitherFeature.TargetCamera = null; + PaletteDither.SetFeature(false); + PaletteDither.ResetForProbe(); + LookModeHub.ResetAll(); + PixelCameraRig.Teardown("814j probe end"); + InGameInfo.Ins = null; + DestroyGo(ref _goA); DestroyGo(ref _goB); DestroyGo(ref _goPrev); + DestroyGo(ref _mainCamGo); DestroyGo(ref _infoGo); + _camA = _camB = _camPrev = _srcCam = null; + var strays = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + L(" 남은 카메라 " + strays.Length + "개 · 리그조립 " + PixelCameraRig.IsAssembled + " · 피처 active " + PaletteDither.FeatureActiveNow); + L(" AssetDatabase.SaveAssets 호출 0 · 씬 저장 0"); + } + catch (Exception e) { L(" 정리 중 예외 " + e.GetType().Name + " " + e.Message); } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Capture — 임시 씬(저장 0) · 도트A(ortho 3.4 · px 240) · 135×240 → 1080×1920 Point(CPU 정수 8배) · 4장 + // ═════════════════════════════════════════════════════════════════════════ + public static void Capture() + { + s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; + L("# WL-814j 팔레트 캡처 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + L("도트A 기준 = ortho 3.4 · pixelHeight 240 → RT " + kLowW + "×" + kLowH + " → CPU 정수 " + kScale + "배 = " + kHiW + "×" + kHiH + " PNG"); + + var pc = WLPaletteSettings.Instance; + var ec = WLEnvLookSettings.Instance; + Scene temp = default(Scene); + try + { + Directory.CreateDirectory(kOutDir); + temp = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); + foreach (var go in temp.GetRootGameObjects()) + if (go.GetComponent() != null) UnityEngine.Object.DestroyImmediate(go); + + var map = Inst(kMapPrefab, Vector3.zero); + Chk("K0 맵", map != null, "WL_Nature"); + if (map == null) return; + + // 814c 와 같은 지점·같은 구도(811o h 76 · yaw 233) — 나무·물이 있는 곳 + const float h = 76f, yaw = 233f, dUp = 2.4f, ortho = 3.4f, camBack = 25f, farClip = 120f; + Vector3 dir = (Quaternion.Euler(h, yaw, 0f) * Vector3.one).normalized; + Vector3 fwd0 = new Vector3(dir.x, 0f, dir.z).normalized; + + string aname; + Vector3 scenic = ScenicSpot(map, out aname); + Vector3 pcPos = scenic - fwd0 * 11f; + RaycastHit hit; + if (Physics.Raycast(pcPos + Vector3.up * 300f, Vector3.down, out hit, 700f)) pcPos = hit.point; + L(" 기준점(" + aname + ") " + scenic.ToString("0.0") + " → PC " + pcPos.ToString("0.0")); + + var camGo = new GameObject("__WL814j_OrthoCam"); + camGo.hideFlags = HideFlags.DontSave; + var cam = camGo.AddComponent(); + var acd = camGo.AddComponent(); + acd.renderShadows = true; + acd.renderPostProcessing = false; + acd.antialiasing = AntialiasingMode.None; + cam.orthographic = true; + cam.orthographicSize = ortho; + cam.nearClipPlane = 0.1f; + cam.farClipPlane = farClip; + cam.clearFlags = CameraClearFlags.Skybox; + Vector3 lookAt = pcPos + new Vector3(0f, 1.06f, 0f) + fwd0 * 2f; + cam.transform.position = pcPos + Vector3.up * dUp - dir * camBack; + cam.transform.LookAt(lookAt); + Vector3 fwd = pcPos - cam.transform.position; fwd.y = 0f; fwd.Normalize(); + Vector3 right = Vector3.Cross(Vector3.up, fwd); + L(" 카메라 직교 size " + ortho + " · pos " + cam.transform.position.ToString("0.0") + " · look " + lookAt.ToString("0.0") + " · near 0.1 / far " + farClip); + + var pcGo = Inst("Assets/Res_Addr/PC/Ai01.prefab", pcPos); + if (pcGo != null) pcGo.transform.rotation = Quaternion.LookRotation(fwd, Vector3.up); + string[] mobs = { "Batty_A", "Batty_B", "Batty_C" }; + Vector2[] off = { new Vector2(2.4f, 1.2f), new Vector2(3.4f, -1.5f), new Vector2(4.4f, 0.5f) }; + var mobGos = new List(); + for (int i = 0; i < mobs.Length; i++) + { + Vector3 p = pcPos + fwd * off[i].x + right * off[i].y; + if (Physics.Raycast(p + Vector3.up * 50f, Vector3.down, out hit, 200f)) p = hit.point; + var m = Inst("Assets/Res_Addr/Mobs/Mob/" + mobs[i] + ".prefab", p); + if (m != null) { m.transform.localScale = Vector3.one * 1.5f; m.transform.rotation = Quaternion.LookRotation(-fwd, Vector3.up); mobGos.Add(m); } + } + Chk("K1 PC · 잡몹", pcGo != null && mobGos.Count == 3, "PC " + (pcGo != null) + " · 몹 " + mobGos.Count + "/3"); + Pose(pcGo); foreach (var m in mobGos) Pose(m); + + // A안(814i) 상태 재현 = 배경 룩(Critter Toon · _Shades 3) 적용 — 도트A 프리셋은 envLookOn 1 이다 + EnvLook.ResetForProbe(); + EnvLook.BeginForProbe(); + int swMap = EnvLook.SweepRoot(map.transform); + EnvLook.SetFeature(true); + L(" 배경 룩(814c/814i) 적용 — 맵 렌더러 " + swMap + "개 스왑 · 외곽선 피처 on · Toon _Shades = " + (ec != null ? ec.shades.ToString() : "?")); + + PaletteDither.ClearFeatureCache(); + PaletteDitherFeature.TargetCamera = cam; + + int savedLevels = pc != null ? pc.levels : 6; + float savedDither = pc != null ? pc.ditherStrength : 0.6f; + bool saved8 = pc != null && pc.dither8x8; + + var rows = new List(); + + // ① 디더 off = A안만 (팔레트 패스 자체를 넣지 않는다) + PaletteDither.SetFeature(false); + rows.Add(Shot(cam, kOutDir + @"\1_dither_off.png", "① A안만(팔레트 패스 0)", "—", "—")); + + // ②~④ 팔레트 on + PaletteDither.SetFeature(true); + int[] ls = { 6, 4, 8 }; + float[] ds = { 0.6f, 0.8f, 0.3f }; + string[] files = { @"\2_levels6_dither0.6.png", @"\3_levels4_dither0.8.png", @"\4_levels8_dither0.3.png" }; + string[] labels = { "② 권장 기본", "③ 더 도트답게", "④ 약하게" }; + for (int i = 0; i < 3; i++) + { + if (pc != null) { pc.levels = ls[i]; pc.ditherStrength = ds[i]; pc.dither8x8 = false; } + rows.Add(Shot(cam, kOutDir + files[i], labels[i], ls[i].ToString(), ds[i].ToString("0.0"))); + } + + if (pc != null) { pc.levels = savedLevels; pc.ditherStrength = savedDither; pc.dither8x8 = saved8; } + + L(""); + L(" | 파일 | 무엇 | levels | dither | 고유 색 수(전수) | 814c 방식 표본(1/331) | 크기 |"); + L(" |---|---|---|---|---|---|---|"); + foreach (var r in rows) L(" " + r); + + Chk("K9 캡처 4장", Directory.GetFiles(kOutDir, "*.png").Length >= 4, Directory.GetFiles(kOutDir, "*.png").Length + "장"); + } + catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } + finally + { + try { PaletteDitherFeature.TargetCamera = null; PaletteDither.SetFeature(false); } catch { } + try { EnvLook.SetFeature(false); EnvLook.RestoreNow("814j capture end"); } catch { } + try { EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); } catch { } + } + L(""); + L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); + Write(kTxtCap); + } + + // ═════════════════════════════════════════════════════════════════════════ + // 렌더 · 색 세기 + // ═════════════════════════════════════════════════════════════════════════ + + /// 저해상도 RT 로 1프레임 렌더하고 RGB24 픽셀을 돌려준다(Blit 0 = 색 왜곡 0). + static Color32[] ReadLow(Camera cam) + { + var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); + var prev = cam.targetTexture; + cam.targetTexture = low; + cam.Render(); + cam.targetTexture = prev; + + var active = RenderTexture.active; + RenderTexture.active = low; + var tex = new Texture2D(kLowW, kLowH, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, kLowW, kLowH), 0, 0); + tex.Apply(); + RenderTexture.active = active; + var px = tex.GetPixels32(); + UnityEngine.Object.DestroyImmediate(tex); + low.Release(); UnityEngine.Object.DestroyImmediate(low); + RenderTexture.active = null; + return px; + } + + static void RenderToLow(Camera cam, RenderTexture unused) + { + var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); + var prev = cam.targetTexture; + cam.targetTexture = low; + cam.Render(); + cam.targetTexture = prev; + low.Release(); UnityEngine.Object.DestroyImmediate(low); + } + + /// 렌더 → PNG(정수 8배 Point 확대 · CPU) + 고유 색 수. 표 1줄을 돌려준다. + static string Shot(Camera cam, string file, string label, string levels, string dither) + { + var low = ReadLow(cam); + int distinct = Distinct(low); + + // CPU 정수 배율 확대 = Point · 색 왜곡 0(Graphics.Blit 를 쓰면 sRGB 왕복에서 ±1 이 생길 수 있다) + var hi = new Color32[kHiW * kHiH]; + for (int y = 0; y < kHiH; y++) + { + int sy = y / kScale; + int srow = sy * kLowW, drow = y * kHiW; + for (int x = 0; x < kHiW; x++) hi[drow + x] = low[srow + (x / kScale)]; + } + var tex = new Texture2D(kHiW, kHiH, TextureFormat.RGB24, false); + tex.SetPixels32(hi); + tex.Apply(); + File.WriteAllBytes(file, tex.EncodeToPNG()); + + // 814c 와 같은 방법(1080×1920 을 331 간격으로 표본) — 이전 보고서 숫자와 비교하려고 같이 센다 + var seen = new HashSet(); + for (int i = 0; i < hi.Length; i += 331) seen.Add((hi[i].r << 16) | (hi[i].g << 8) | hi[i].b); + int sampled = seen.Count; + + UnityEngine.Object.DestroyImmediate(tex); + var fi = new FileInfo(file); + Chk("K " + Path.GetFileName(file), fi.Exists && fi.Length > 2048 && distinct > 2, + label + " · 고유 색 " + distinct + "종(표본 " + sampled + ") · " + fi.Length + " B"); + return "| `" + Path.GetFileName(file) + "` | " + label + " | " + levels + " | " + dither + " | **" + distinct + "** | " + sampled + " | " + (fi.Length / 1024) + " KB |"; + } + + static int Distinct(Color32[] px) + { + var seen = new HashSet(); + for (int i = 0; i < px.Length; i++) seen.Add((px[i].r << 16) | (px[i].g << 8) | px[i].b); + return seen.Count; + } + + static long Hash(Color32[] px) + { + long h = 1469598103934665603L; + for (int i = 0; i < px.Length; i++) + { + h = (h ^ px[i].r) * 1099511628211L; + h = (h ^ px[i].g) * 1099511628211L; + h = (h ^ px[i].b) * 1099511628211L; + } + return h; + } + + static string Rgb(Color32 c) { return "rgb(" + c.r + "," + c.g + "," + c.b + ")"; } + + /// p(x,y) 와 p(x+dx, y+dy) 가 다른 픽셀 수. + static int Period(Color32[] px, int dx, int dy) + { + int bad = 0; + for (int y = 0; y + dy < kLowH; y++) + for (int x = 0; x + dx < kLowW; x++) + { + var a = px[y * kLowW + x]; + var b = px[(y + dy) * kLowW + (x + dx)]; + if (a.r != b.r || a.g != b.g || a.b != b.b) bad++; + } + return bad; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + static GameObject MakeCam(string name, out Camera cam) + { + var go = new GameObject(name); + go.hideFlags = HideFlags.DontSave; + cam = go.AddComponent(); + var acd = go.AddComponent(); + acd.renderPostProcessing = false; + acd.antialiasing = AntialiasingMode.None; + cam.orthographic = true; + cam.orthographicSize = 3.4f; + cam.clearFlags = CameraClearFlags.Skybox; + cam.nearClipPlane = 0.1f; + cam.farClipPlane = 120f; + cam.transform.position = new Vector3(0f, 5f, -10f); + cam.transform.rotation = Quaternion.Euler(20f, 0f, 0f); + return go; + } + + static void DestroyGo(ref GameObject go) + { + if (go != null) { UnityEngine.Object.DestroyImmediate(go); go = null; } + } + + static GameObject Inst(string path, Vector3 pos) + { + var src = AssetDatabase.LoadAssetAtPath(path); + if (src == null) { L(" 프리팹 없음 " + path); return null; } + var go = (GameObject)PrefabUtility.InstantiatePrefab(src); + go.transform.position = pos; + return go; + } + + static void Pose(GameObject go) + { + if (go == null) return; + foreach (var an in go.GetComponentsInChildren(true)) + { + if (an.runtimeAnimatorController == null) continue; + AnimationClip idle = null; + foreach (var cl in an.runtimeAnimatorController.animationClips) + { + if (cl == null) continue; + var n = cl.name.ToLowerInvariant(); + if (n.Contains("idle")) { idle = cl; break; } + if (idle == null) idle = cl; + } + if (idle != null) idle.SampleAnimation(an.gameObject, 0.5f); + } + } + + /// 814c 와 같은 「나무·수풀이 촘촘하고 물이 가까운」 지점 선정. + static Vector3 ScenicSpot(GameObject map, out string why) + { + var pts = new List(); + var waters = new List(); + foreach (var r in map.GetComponentsInChildren(true)) + { + if (r == null || r is ParticleSystemRenderer) continue; + var m = r.sharedMaterial; + if (m == null) continue; + if (m.name == "Trees" || m.name == "Vegetation" || m.name == "Trees_Toon" || m.name == "Vegetation_Toon") pts.Add(r.bounds.center); + else if (m.name.IndexOf("Water", StringComparison.OrdinalIgnoreCase) >= 0) waters.Add(r.bounds); + } + if (pts.Count == 0) { why = "(나무 0 · 원점)"; return Vector3.zero; } + + int step = Mathf.Max(1, pts.Count / 300); + Vector3 best = pts[0]; float bestScore = -1f; int bestN = 0; float bestWater = 9999f; + for (int i = 0; i < pts.Count; i += step) + { + var p = pts[i]; + int n = 0; + for (int j = 0; j < pts.Count; j += step) + if ((pts[j] - p).sqrMagnitude < 25f * 25f) n++; + float wd = 9999f; + for (int w = 0; w < waters.Count; w++) wd = Mathf.Min(wd, Vector3.Distance(waters[w].ClosestPoint(p), p)); + float score = n + (wd < 40f ? 12f : 0f) + (wd < 20f ? 12f : 0f); + if (score > bestScore) { bestScore = score; best = p; bestN = n; bestWater = wd; } + } + best.y = 0f; + RaycastHit hit; + if (Physics.Raycast(new Vector3(best.x, 400f, best.z), Vector3.down, out hit, 900f)) best = hit.point; + why = "나무·수풀 군락 " + bestN + "그루/25 m · 물까지 " + (bestWater > 9000f ? "없음" : bestWater.ToString("0") + " m"); + return best; + } +} diff --git a/AgentScripts/WL814j_Setup.cs b/AgentScripts/WL814j_Setup.cs new file mode 100644 index 000000000..7eb83abfd --- /dev/null +++ b/AgentScripts/WL814j_Setup.cs @@ -0,0 +1,154 @@ +// WL-814j 셋업 (게임 코드 아님 · 에디트 모드 전용) +// asset: unity command run_script --file AgentScripts/WL814j_Setup.cs --entry WL814j_Setup.MakeSettings +// feature: unity command run_script --file AgentScripts/WL814j_Setup.cs --entry WL814j_Setup.AddFeature +// 🔴 URP 에셋 디스크 수정은 「인게임 렌더러 피처 1건」만(814c AddOutlineFeature 선례 그대로 · 디스크 기본 m_Active: 0). + +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using WL.Look.Palette; + +public static class WL814j_Setup +{ + const string kSoDir = "Assets/WL/Look/Palette/Resources/WL"; + const string kSoPath = kSoDir + "/WLPaletteSettings.asset"; + const string kRendererPath = "Assets/Settings/URP-Balanced-Renderer.asset"; + + static StringBuilder s_sb; + static int s_pass, s_fail; + static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814j] " + 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("[WL814j] → " + p); } + + // ═════════════════════════════════════════════════════════════════════════ + // ① 값 SO 생성 (발주서 기본값 · C8 enabled_ = 1) + // ═════════════════════════════════════════════════════════════════════════ + public static void MakeSettings() + { + s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; + L("WL-814j 팔레트 설정 SO 생성 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + try + { + Directory.CreateDirectory(kSoDir); + AssetDatabase.Refresh(); + + var so = AssetDatabase.LoadAssetAtPath(kSoPath); + bool made = false; + if (so == null) + { + so = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(so, kSoPath); + made = true; + } + + // 발주서 §1 기본값 — 권장 기본 = levels 6 · dither 0.6 · 4×4 · 보정 전부 무보정 + so.enabled_ = true; + so.verboseLog = false; + so.minCameraMode = 1; + so.requirePixelRig = true; + so.mode = WLPaletteMode.ChannelLevels; + so.levels = 6; + so.paletteTex = null; + so.paletteCount = 0; + so.ditherStrength = 0.6f; + so.dither8x8 = false; + so.saturation = 1f; + so.contrast = 1f; + so.tintColor = Color.white; + so.tintStrength = 0f; + so.afterPostProcessing = true; + so.quantizeInGammaSpace = true; + EditorUtility.SetDirty(so); + AssetDatabase.SaveAssetIfDirty(so); + AssetDatabase.ImportAsset(kSoPath, ImportAssetOptions.ForceUpdate); + + WLPaletteSettings.ClearCache(); + var re = WLPaletteSettings.Instance; + Chk("S SO 로드", re != null, (made ? "새로 만듦 " : "기존 갱신 ") + kSoPath); + Chk("S Resources 경로", re != null, "Resources.Load(\"" + WLPaletteSettings.ResourcesPath + "\") = " + (re != null ? re.name : "null")); + if (re != null) + L(" 값: enabled " + re.enabled_ + " · minCameraMode " + re.minCameraMode + " · levels " + re.levels + + " · dither " + re.ditherStrength + " (" + (re.dither8x8 ? "8x8" : "4x4") + ")" + + " · sat " + re.saturation + " · contrast " + re.contrast + " · tint " + re.tintStrength + + " · afterPost " + re.afterPostProcessing + " · gamma " + re.quantizeInGammaSpace); + } + 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/WL814j_SETUP.txt"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // ② 렌더러 피처 추가 (🔴 URP 에셋 디스크 수정은 이 1건만) + // ═════════════════════════════════════════════════════════════════════════ + public static void AddFeature() + { + s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; + L("WL-814j 팔레트 렌더러 피처 추가 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + try + { + var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + L(" 활성 URP 에셋 = " + (urp != null ? AssetDatabase.GetAssetPath(urp) : "(없음)")); + var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); + Chk("F 렌더러 에셋", rd != null, kRendererPath); + if (rd == null) return; + + L(" 추가 전 피처 " + rd.rendererFeatures.Count + "개:"); + foreach (var f in rd.rendererFeatures) + L(" - " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive)); + + var type = typeof(PaletteDitherFeature); + foreach (var f in rd.rendererFeatures) + if (f != null && f.GetType() == type) + { Chk("F 이미 있음(추가 0)", true, f.name + " active=" + f.isActive); Write("AgentScripts/WL814j_FEATURE.txt"); return; } + + var feature = (ScriptableRendererFeature)ScriptableObject.CreateInstance(type); + feature.name = "WLPaletteDither"; + 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(kRendererPath, ImportAssetOptions.ForceUpdate); + + var reload = AssetDatabase.LoadAssetAtPath(kRendererPath); + int n = 0; + foreach (var f in reload.rendererFeatures) if (f != null && f.GetType() == type) n++; + Chk("F 추가 확인", n == 1, "피처 " + reload.rendererFeatures.Count + "개 · WLPaletteDither " + 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)); + foreach (var f in reload.rendererFeatures) + if (f != null && f.GetType() == type) Chk("F 디스크 기본 off", !f.isActive, "m_Active=" + (f.isActive ? 1 : 0)); + } + 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/WL814j_FEATURE.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(); + } +} diff --git a/Assets/WL/Look/Palette.meta b/Assets/WL/Look/Palette.meta new file mode 100644 index 000000000..cb744ac5f --- /dev/null +++ b/Assets/WL/Look/Palette.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f2f76a74f36f9b47afec7a6f95dc38e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Palette/PaletteDither.cs b/Assets/WL/Look/Palette/PaletteDither.cs new file mode 100644 index 000000000..ed924db84 --- /dev/null +++ b/Assets/WL/Look/Palette/PaletteDither.cs @@ -0,0 +1,217 @@ +// ───────────────────────────────────────────────────────────────────────────── +// PaletteDither.cs — 팔레트 양자화 + 디더 후처리를 **도트 모드에서만** 켜는 연결 코드 (WL-814j · #814) +// +// PD 지시 #814 「도트같은 느낌이 전혀 나지 않고 해상도만 저하된 느낌」 → B안 · 발주서 WL-814j §1-3 +// +// ■ 이 파일이 하는 것 — 원본 훅 0 · 새 Manager/Singleton 0(정적 + 러너 GameObject 1개) +// ① 814d `LookModeHub.CameraModeChanged` 를 구독해 모드가 바뀌는 순간 즉시 반영한다. +// ② 그리고 매 프레임 「지금 켜져 있어야 하는가」를 비교만 해서(참조·정수 비교 = GC 0) 맞춘다. +// — 허브 이벤트는 **PD 가 고른 값이 바뀔 때만** 오고, 로비 강등·맵 이탈·리그 재조립은 알려 주지 않는다. +// 리그가 다시 조립되면 대상 카메라 참조가 바뀌므로 폴링이 필요하다(814d 보고 §5 계약). +// ③ 켜짐 = URP 렌더러의 PaletteDitherFeature 를 SetActive(true). 꺼짐 = 원래 값으로 되돌린다. +// (814c EnvLook.SetFeature 와 같은 방식 — 디스크 에셋에는 쓰지 않는다 · SetDirty 0) +// +// ■ 켜지는 조건(전부 AND) +// · WLPaletteSettings.Enabled (C8) +// · LookModeHub.AppliedMode >= minCameraMode (기본 1) — 로비·기본 모드에서는 AppliedMode 가 0 이라 꺼진다 +// · requirePixelRig 이면 814b 리그가 조립돼 있고 게임 카메라가 살아 있을 것 +// +// 🔴 814b/814d 파일은 **읽기·구독만** 한다(수정 0). +// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z · ErrorLogHookManager 팝업). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.Rendering.Universal; +using WL.Look.Toggle; +using WL.PixelArt; + +namespace WL.Look.Palette +{ + /// 팔레트 후처리 on/off. 정적 · 전용 러너 1개. + public static class PaletteDither + { + // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── + public static int Ons, Offs, HubEvents, FeatureMisses; + public static string LastLog = ""; + + /// 지금 후처리가 켜져 있는가. + public static bool IsOn { get { return s_on; } } + + /// 찾아 둔 렌더러 피처(진단). 못 찾으면 null. + public static ScriptableRendererFeature Feature { get { return s_feature; } } + + /// 피처가 지금 활성인가(진단). + public static bool FeatureActiveNow { get { return s_feature != null && s_feature.isActive; } } + + // ── 내부 상태 ───────────────────────────────────────────────────────── + static ScriptableRendererFeature s_feature; + static int s_featureLookups; + static bool s_featureOrig, s_featureChanged; + static bool s_on; + static bool s_subscribed; + static GameObject s_runner; + + static WLPaletteSettings Cfg { get { return WLPaletteSettings.Instance; } } + + // ───────────────────────────────────────────────────────────────────── + // 틱 · 판정 + // ───────────────────────────────────────────────────────────────────── + + /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). 비교뿐 = GC 0. + public static void Tick() + { + bool want = Want(); + if (want != s_on) Apply(want); + } + + /// 지금 켜져 있어야 하는가(참조·정수 비교만). + public static bool Want() + { + if (!WLPaletteSettings.Enabled) return false; + var c = Cfg; + if (c == null) return false; + if (LookModeHub.AppliedMode < c.minCameraMode) return false; + if (c.requirePixelRig) + { + if (!PixelCameraRig.IsAssembled) return false; + if (PixelCameraRig.GameCamera == null) return false; + } + return true; + } + + /// on/off 를 실제로 적용한다. 같은 값이면 무동작. + public static bool Apply(bool on) + { + if (on == s_on && (!on || FeatureActiveNow)) return s_on; + s_on = on; + if (on) Ons++; else Offs++; + bool ok = SetFeature(on); + var c = Cfg; + if (c != null && c.verboseLog) + LastLog = Log("팔레트 후처리 " + (on ? "ON" : "OFF") + + " · 모드 " + LookModeHub.AppliedMode + + " · 리그 " + PixelCameraRig.IsAssembled + + " · 피처 " + ok); + return s_on; + } + + // ───────────────────────────────────────────────────────────────────── + // 렌더러 피처 + // ───────────────────────────────────────────────────────────────────── + + /// URP 렌더러의 팔레트 피처를 켜고 끈다. 없으면 아무 것도 하지 않는다(후처리만 안 걸린다). + public static bool SetFeature(bool on) + { + var f = FindFeature(); + if (f == null) { FeatureMisses++; return false; } + if (on) + { + if (!s_featureChanged) { s_featureOrig = f.isActive; s_featureChanged = true; } + if (!f.isActive) f.SetActive(true); // 🔴 SetDirty 0 (디스크 에셋 무변경) + return true; + } + if (!s_featureChanged) return false; + f.SetActive(s_featureOrig); + s_featureChanged = false; + return true; + } + + const int kMaxLookups = 8; + + static ScriptableRendererFeature FindFeature() + { + if (s_feature != null) return s_feature; + // 🔴 배열을 새로 만드는 호출이라 전환 때만 · 최대 kMaxLookups 회만 찾는다(프레임당 반복 호출 0). + if (s_featureLookups >= kMaxLookups) return null; + s_featureLookups++; + var all = Resources.FindObjectsOfTypeAll(); + if (all != null && all.Length > 0) s_feature = all[0]; + return s_feature; + } + + /// 프로브: 피처 캐시를 비운다(에디트 모드 재실행). + public static void ClearFeatureCache() { s_feature = null; s_featureLookups = 0; } + + /// 프로브: 상태·카운터를 시작값으로 되돌리고 피처도 원복한다. + public static string ResetForProbe() + { + SetFeature(false); + s_on = false; + Ons = Offs = HubEvents = FeatureMisses = 0; + LastLog = ""; + return "리셋"; + } + + public static string Dump() + { + var c = Cfg; + return "[Palette] on=" + s_on + " 피처활성=" + FeatureActiveNow + + " 대상=" + (PaletteDitherFeature.ResolvedTarget != null ? PaletteDitherFeature.ResolvedTarget.name : "(없음)") + + " · 모드 " + LookModeHub.AppliedMode + " 리그 " + PixelCameraRig.IsAssembled + + " · levels " + (c != null ? c.levels : 0) + " dither " + (c != null ? c.ditherStrength : 0f) + + " (" + (c != null && c.dither8x8 ? "8x8" : "4x4") + ")" + + " · 패스 추가 " + PaletteDitherFeature.PassesAdded + " 건너뜀 " + PaletteDitherFeature.PassesSkipped + + " · on " + Ons + " off " + Offs + " 허브이벤트 " + HubEvents; + } + + static string Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[WL814j] " + msg); // 🔴 LogError 금지(813z) + return msg; + } + + // ───────────────────────────────────────────────────────────────────── + // 허브 구독 · 러너 + // ───────────────────────────────────────────────────────────────────── + + static void OnCameraModeChanged(int mode) + { + HubEvents++; + Tick(); // 모드 전환 프레임에 바로 반영(폴링 1프레임을 기다리지 않는다) + } + + /// 프로브: 구독을 보장한다(에디트 모드에서도 부를 수 있다). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + s_subscribed = true; + LookModeHub.CameraModeChanged += OnCameraModeChanged; + } + + static void Unsubscribe() + { + if (!s_subscribed) return; + s_subscribed = false; + LookModeHub.CameraModeChanged -= OnCameraModeChanged; // 허브는 구독자를 모른다 — 스스로 뗀다 + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (s_runner != null) return; + if (!WLPaletteSettings.Enabled) return; // C8 — 러너 자체가 뜨지 않는다 + EnsureSubscribed(); + s_runner = new GameObject("[WL814j] PaletteDitherRunner"); + s_runner.hideFlags = HideFlags.HideAndDontSave; + s_runner.AddComponent(); + Object.DontDestroyOnLoad(s_runner); + } + + internal static void OnRunnerGone() + { + Unsubscribe(); + Apply(false); + PaletteDitherFeature.TargetCamera = null; + } + } + + /// 팔레트 후처리의 시간 축. GameObject 1개 · 코루틴 0. + internal sealed class PaletteDitherRunner : MonoBehaviour + { + void Update() { PaletteDither.Tick(); } + void OnApplicationQuit() { PaletteDither.OnRunnerGone(); } + void OnDestroy() { PaletteDither.OnRunnerGone(); } + } +} diff --git a/Assets/WL/Look/Palette/PaletteDither.cs.meta b/Assets/WL/Look/Palette/PaletteDither.cs.meta new file mode 100644 index 000000000..b72343bb9 --- /dev/null +++ b/Assets/WL/Look/Palette/PaletteDither.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37c8e1e0af30fdb4daa1240d3d4c84cc \ No newline at end of file diff --git a/Assets/WL/Look/Palette/PaletteDitherFeature.cs b/Assets/WL/Look/Palette/PaletteDitherFeature.cs new file mode 100644 index 000000000..5bba28d7d --- /dev/null +++ b/Assets/WL/Look/Palette/PaletteDitherFeature.cs @@ -0,0 +1,226 @@ +// ───────────────────────────────────────────────────────────────────────────── +// PaletteDitherFeature.cs — 팔레트 양자화 + Bayer 디더 풀스크린 패스를 **저해상도 게임 카메라에만** 넣는 렌더러 피처 +// +// PD 지시 #814 「도트같은 느낌이 전혀 나지 않고 해상도만 저하된 느낌」 → B안 · 발주서 WL-814j §1-2 +// +// ■ 🔴 왜 카메라를 골라야 하나 (이 태스크의 핵심 제약) +// 814b 리그 구조(실측) = WL_PixelCameraRig → **게임 카메라**(저해상도 RT 135×240 등) → UpscaledDisplay +// → ViewCamera(Overlay · 픽셀 쿼드만) + QuadCanvas. UI 는 원본 카메라 스택의 Overlay 다. +// 양자화·디더를 화면(업스케일 뒤)이나 뷰/UI 카메라에 걸면 **디더 점 1개가 1 게임픽셀이 아니라 1 화면픽셀**이 +// 되어 도트가 아니라 노이즈로 보인다. → 대상 카메라가 아니면 **패스를 아예 넣지 않는다**(씬뷰·프리뷰·반사 포함). +// +// ■ 대상 카메라 결정 순서 +// ① TargetCamera(정적 · 프로브/캡처가 직접 지정) → ② PixelCameraRig.GameCamera(리그가 조립하며 알려 준 것) +// 둘 다 없으면 패스 0. 814b 파일은 **읽기만** 한다(수정 0). +// +// ■ 렌더 순서 +// 기본 AfterRenderingPostProcessing — 814c 픽셀 외곽선은 Toon 머티리얼 안에서 **불투명 지오메트리와 함께** +// 그려지므로 이보다 앞이다 → 외곽선까지 같이 양자화된다. SO 로 AfterRenderingTransparents 로 내릴 수 있다. +// +// ■ C8 — WLPaletteSettings.enabled_ = 0 이면 패스 0건(피처가 활성이어도). 디스크 기본 m_Active: 0(814c 선례). +// +// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z · ErrorLogHookManager 팝업). +// 🔴 Assets/WL/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.RenderGraphModule; +using UnityEngine.Rendering.RenderGraphModule.Util; +using UnityEngine.Rendering.Universal; +using WL.PixelArt; + +namespace WL.Look.Palette +{ + /// 저해상도 게임 카메라의 RT 에만 팔레트 양자화 + Bayer 디더를 거는 URP 렌더러 피처. + public sealed class PaletteDitherFeature : ScriptableRendererFeature + { + public const string ShaderName = "WL/Look/PaletteDither"; + + // ── 대상 카메라 ─────────────────────────────────────────────────────── + /// 프로브·캡처가 직접 지정하는 대상 카메라(우선). null 이면 814b 리그의 게임 카메라를 쓴다. + public static Camera TargetCamera; + + /// 지금 패스가 들어갈 카메라(없으면 null). 814b 파일은 읽기만 한다. + public static Camera ResolvedTarget + { + get + { + if (TargetCamera != null) return TargetCamera; + var c = WLPaletteSettings.Instance; + if (c != null && !c.requirePixelRig) return null; // 리그 없이 쓰려면 TargetCamera 를 직접 지정해야 한다 + return PixelCameraRig.GameCamera; + } + } + + // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── + public static int PassesAdded, PassesSkipped, MaterialFaults; + public static string LastSkipReason = ""; + /// 마지막으로 패스를 넣은 카메라 이름(진단). + public static string LastTargetName = ""; + /// 마지막으로 실제로 쓴 렌더 이벤트(진단). + public static RenderPassEvent LastEvent; + + /// 프로브: 카운터를 비운다. + public static void ResetCounters() + { + PassesAdded = PassesSkipped = MaterialFaults = 0; + LastSkipReason = ""; LastTargetName = ""; + } + + // ── 셰이더 프로퍼티 ID (문자열 → ID 는 1회만 = 프레임당 GC 0) ───────── + static readonly int kLevels = Shader.PropertyToID("_WLLevels"); + static readonly int kDitherStrength = Shader.PropertyToID("_WLDitherStrength"); + static readonly int kDitherSize = Shader.PropertyToID("_WLDitherSize"); + static readonly int kSaturation = Shader.PropertyToID("_WLSaturation"); + static readonly int kContrast = Shader.PropertyToID("_WLContrast"); + static readonly int kTintColor = Shader.PropertyToID("_WLTintColor"); + static readonly int kTintStrength = Shader.PropertyToID("_WLTintStrength"); + static readonly int kGammaEncode = Shader.PropertyToID("_WLGammaEncode"); + static readonly int kPaletteCount = Shader.PropertyToID("_WLPaletteCount"); + static readonly int kPaletteTex = Shader.PropertyToID("_WLPaletteTex"); + const string kPaletteKeyword = "_WLPALETTE_TEX"; + + Material m_Material; + Shader m_Shader; + PaletteDitherPass m_Pass; + + public override void Create() + { + if (m_Pass == null) m_Pass = new PaletteDitherPass(); + m_Pass.renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing; + } + + public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) + { + var cfg = WLPaletteSettings.Instance; + if (cfg == null || !WLPaletteSettings.Enabled) { PassesSkipped++; LastSkipReason = "SO off"; return; } + + var cam = renderingData.cameraData.camera; + if (cam == null) { PassesSkipped++; LastSkipReason = "camera null"; return; } + + // 🔴 씬뷰·프리뷰·반사는 무조건 제외(대상 비교 전에 잘라 낸다 — 에디터 오염 0) + var ct = renderingData.cameraData.cameraType; + if (ct != CameraType.Game) { PassesSkipped++; LastSkipReason = "cameraType " + ct; return; } + + var target = ResolvedTarget; + if (target == null) { PassesSkipped++; LastSkipReason = "대상 카메라 없음"; return; } + if (!ReferenceEquals(cam, target)) { PassesSkipped++; LastSkipReason = "대상 아님"; return; } + + if (!EnsureMaterial()) { PassesSkipped++; LastSkipReason = "머티리얼 없음"; return; } + + PushValues(cfg); + + if (m_Pass == null) m_Pass = new PaletteDitherPass(); + m_Pass.renderPassEvent = cfg.afterPostProcessing + ? RenderPassEvent.AfterRenderingPostProcessing + : RenderPassEvent.AfterRenderingTransparents; + m_Pass.Material = m_Material; + + renderer.EnqueuePass(m_Pass); + PassesAdded++; + LastEvent = m_Pass.renderPassEvent; + LastTargetName = cam.name; + } + + /// 셰이더 → 머티리얼(1회). 셰이더가 없으면 false(패스 0 · 로그 1줄). + bool EnsureMaterial() + { + if (m_Material != null) return true; + if (m_Shader == null) m_Shader = Shader.Find(ShaderName); + if (m_Shader == null || !m_Shader.isSupported) + { + MaterialFaults++; + var c = WLPaletteSettings.Instance; + if (c != null && c.verboseLog) Debug.Log("[WL814j] 셰이더 없음/미지원 — " + ShaderName); + return false; + } + m_Material = CoreUtils.CreateEngineMaterial(m_Shader); + return m_Material != null; + } + + /// SO 값을 머티리얼에 얹는다. SetFloat/SetColor(int) 는 관리 힙 할당 0. + void PushValues(WLPaletteSettings c) + { + m_Material.SetFloat(kLevels, c.levels); + m_Material.SetFloat(kDitherStrength, c.ditherStrength); + m_Material.SetFloat(kDitherSize, c.dither8x8 ? 8f : 4f); + m_Material.SetFloat(kSaturation, c.saturation); + m_Material.SetFloat(kContrast, c.contrast); + m_Material.SetColor(kTintColor, c.tintColor); + m_Material.SetFloat(kTintStrength, c.tintStrength); + m_Material.SetFloat(kGammaEncode, c.quantizeInGammaSpace ? 1f : 0f); + + bool usePalette = c.mode == WLPaletteMode.PaletteTexture && c.paletteTex != null; + if (usePalette) + { + int n = c.paletteCount > 0 ? c.paletteCount : c.paletteTex.width; + if (n < 1) n = 1; + if (n > 64) n = 64; + m_Material.SetTexture(kPaletteTex, c.paletteTex); + m_Material.SetFloat(kPaletteCount, n); + if (!m_Material.IsKeywordEnabled(kPaletteKeyword)) m_Material.EnableKeyword(kPaletteKeyword); + } + else + { + m_Material.SetFloat(kPaletteCount, 0f); + if (m_Material.IsKeywordEnabled(kPaletteKeyword)) m_Material.DisableKeyword(kPaletteKeyword); + } + } + + /// 프로브: 머티리얼 준비 + SO 값 얹기 경로만 실행한다(프레임당 할당 측정용). + public bool ProbePushValues() + { + var c = WLPaletteSettings.Instance; + if (c == null) return false; + if (!EnsureMaterial()) return false; + PushValues(c); + return true; + } + + /// 프로브: 지금 피처가 들고 있는 머티리얼(없으면 만든다). + public Material ProbeMaterial { get { return EnsureMaterial() ? m_Material : null; } } + + protected override void Dispose(bool disposing) + { + CoreUtils.Destroy(m_Material); + m_Material = null; + m_Pass = null; + } + + // ═════════════════════════════════════════════════════════════════════ + // 패스 — Render Graph 전용(Unity 6000.3 은 Compatibility Mode 가 없다) + // URP FullScreenPassRendererFeature 와 같은 방식: 활성 색을 임시 텍스처로 복사 → 머티리얼로 되돌려 그린다. + // ═════════════════════════════════════════════════════════════════════ + sealed class PaletteDitherPass : ScriptableRenderPass + { + public Material Material; + + public PaletteDitherPass() + { + profilingSampler = new ProfilingSampler("WL814j PaletteDither"); + requiresIntermediateTexture = true; // 백버퍼 직행이면 읽을 수 없다 → 중간 텍스처를 요구한다 + } + + public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) + { + if (Material == null) return; + + var res = frameData.Get(); + if (res == null || !res.cameraColor.IsValid()) return; + if (res.isActiveTargetBackBuffer) return; // 백버퍼는 읽기 불가 — 조용히 건너뛴다 + + var copyDesc = renderGraph.GetTextureDesc(res.cameraColor); + copyDesc.name = "_WL814jPaletteCopy"; + copyDesc.clearBuffer = false; + + var source = res.activeColorTexture; + var copy = renderGraph.CreateTexture(copyDesc); + renderGraph.AddBlitPass(source, copy, Vector2.one, Vector2.zero, passName: "WL814j Copy Color"); + + var p = new RenderGraphUtils.BlitMaterialParameters(copy, res.activeColorTexture, Material, 0); + renderGraph.AddBlitPass(p, passName: "WL814j Palette Dither"); + } + } + } +} diff --git a/Assets/WL/Look/Palette/PaletteDitherFeature.cs.meta b/Assets/WL/Look/Palette/PaletteDitherFeature.cs.meta new file mode 100644 index 000000000..c2298db2b --- /dev/null +++ b/Assets/WL/Look/Palette/PaletteDitherFeature.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 789b6e9fc1082d84abc7fbd9d3a69032 \ No newline at end of file diff --git a/Assets/WL/Look/Palette/WLPaletteDither.shader b/Assets/WL/Look/Palette/WLPaletteDither.shader new file mode 100644 index 000000000..9126f1e3c --- /dev/null +++ b/Assets/WL/Look/Palette/WLPaletteDither.shader @@ -0,0 +1,146 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLPaletteDither.shader — 팔레트 양자화 + Bayer 순서 디더링 풀스크린 1패스 (WL-814j · #814) +// +// PD 지시 #814 「도트같은 느낌이 전혀 나지 않고 해상도만 저하된 느낌」 → B안 = 색 수 제한 + 디더링. +// +// 🔴 이 패스는 **저해상도 게임 카메라의 RT** 에만 걸린다(PaletteDitherFeature 가 대상 카메라를 고른다). +// 업스케일 뒤 화면이나 뷰/UI 카메라에 걸면 디더 점이 1 게임픽셀이 아니라 1 화면픽셀이 되어 +// 도트가 아니라 노이즈로 보인다. +// +// ■ 왜 감마 공간에서 양자화하나 +// 프로젝트는 Linear 색공간(ProjectSettings m_ActiveColorSpace: 1 · 실측)이라 RT 를 샘플하면 선형 값이 온다. +// 선형 값을 그대로 6단계로 나누면 밝은 쪽에만 단계가 몰려 도트 팔레트처럼 안 보인다. +// → sRGB 로 바꿔서 양자화·디더하고 다시 선형으로 되돌린다(_WLGammaEncode 1). 8비트 PNG 의 고유 색 수와 +// levels^3 이 정확히 맞아떨어지는 것도 이 때문이다(levels 6 → 상한 216색). +// +// ■ 디더 좌표 = SV_POSITION(= 렌더 타깃 픽셀 좌표) → 격자가 RT 픽셀과 1:1 로 맞는다. +// _ScreenParams 를 쓰지 않는다(카메라가 RT 로 그릴 때 값이 흔들릴 수 있다). +// +// ■ 양자화 두 방식 +// ⓐ 채널 레벨(기본) — R/G/B 각 _WLLevels 단계로 반올림. levels^3 색 상한. +// ⓑ 팔레트 텍스처(_WLPALETTE_TEX 키워드 · 배선만) — N×1 텍스처에서 최근접 색을 찾는다. +// ───────────────────────────────────────────────────────────────────────────── + +Shader "WL/Look/PaletteDither" +{ + Properties + { + _WLLevels ("Levels per channel", Float) = 6 + _WLDitherStrength ("Dither strength", Float) = 0.6 + _WLDitherSize ("Dither matrix (4 or 8)", Float) = 4 + _WLSaturation ("Saturation", Float) = 1 + _WLContrast ("Contrast", Float) = 1 + _WLTintColor ("Tint color", Color) = (1,1,1,1) + _WLTintStrength ("Tint strength", Float) = 0 + _WLGammaEncode ("Quantize in sRGB (1) or render space (0)", Float) = 1 + _WLPaletteCount ("Palette entry count", Float) = 0 + [NoScaleOffset] _WLPaletteTex ("Palette texture (N x 1)", 2D) = "white" {} + } + + SubShader + { + Tags { "RenderPipeline" = "UniversalPipeline" } + ZWrite Off ZTest Always Blend Off Cull Off + + Pass + { + Name "WLPaletteDither" + + HLSLPROGRAM + #pragma target 3.0 + #pragma vertex Vert + #pragma fragment Frag + #pragma multi_compile_local_fragment _ _WLPALETTE_TEX + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" + + TEXTURE2D(_WLPaletteTex); + SAMPLER(sampler_WLPaletteTex); + + float _WLLevels; + float _WLDitherStrength; + float _WLDitherSize; + float _WLSaturation; + float _WLContrast; + float4 _WLTintColor; + float _WLTintStrength; + float _WLGammaEncode; + float _WLPaletteCount; + + // ── Bayer 순서 디더 (재귀 구성 · 배열 인덱싱 0 = 모바일에서 분기·레지스터 비용 최소) + // Bayer2 = [[0, 0.5], [0.75, 0.25]] · Bayer4/Bayer8 은 그 위에 쌓는다. 값 범위 [0, 1). + float WLBayer2(float2 a) + { + a = floor(a); + return frac(a.x * 0.5 + a.y * a.y * 0.75); + } + float WLBayer4(float2 a) { return WLBayer2(a * 0.5) * 0.25 + WLBayer2(a); } + float WLBayer8(float2 a) { return WLBayer4(a * 0.5) * 0.25 + WLBayer2(a); } + + float3 WLApplyTone(float3 c) + { + // 대비 — 중간값 0.5 기준 + c = (c - 0.5) * _WLContrast + 0.5; + // 채도 — Rec.601 휘도 기준 + float l = dot(c, float3(0.299, 0.587, 0.114)); + c = lerp(l.xxx, c, _WLSaturation); + // 색조 — tintStrength 0 이면 무보정 + c = lerp(c, c * _WLTintColor.rgb, _WLTintStrength); + return c; + } + + float4 Frag(Varyings input) : SV_Target + { + UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); + + float4 src = SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_PointClamp, input.texcoord.xy, _BlitMipLevel); + float3 c = src.rgb; + + // 선형 → sRGB (감마 공간에서 양자화해야 사람 눈 기준으로 고르게 나뉜다) + if (_WLGammaEncode > 0.5) c = LinearToSRGB(max(c, 0.0)); + c = saturate(c); + + c = saturate(WLApplyTone(c)); + + // ── 디더 오프셋: SV_POSITION = 렌더 타깃 픽셀 좌표 → 격자가 RT 픽셀과 1:1 + float2 px = floor(input.positionCS.xy); + float b = (_WLDitherSize >= 8.0) ? WLBayer8(px) : WLBayer4(px); + float d = (b - 0.5) * _WLDitherStrength; // strength 1 = 양자화 1단계를 꽉 채운다 + + #ifdef _WLPALETTE_TEX + // ⓑ 팔레트 텍스처 — N×1 에서 최근접 색(배선만 · 기본 미사용) + int n = (int)min(max(_WLPaletteCount, 1.0), 64.0); + float pStep = 1.0 / max(1.0, (float)(n - 1)); + float3 probe = saturate(c + d * pStep); + float3 best = probe; + float bestD = 1e9; + [loop] + for (int i = 0; i < n; i++) + { + float3 p = SAMPLE_TEXTURE2D_LOD(_WLPaletteTex, sampler_WLPaletteTex, + float2(((float)i + 0.5) / (float)n, 0.5), 0).rgb; + float3 e = p - probe; + float dist = dot(e, e); + if (dist < bestD) { bestD = dist; best = p; } + } + c = best; + #else + // ⓐ 채널 레벨 — levels^3 색 상한 + float L = max(2.0, _WLLevels); + float3 v = c * (L - 1.0); + v = floor(v + d + 0.5); + c = saturate(v / (L - 1.0)); + #endif + + // sRGB → 선형 (하드웨어가 저장할 때 다시 sRGB 로 인코딩한다) + if (_WLGammaEncode > 0.5) c = SRGBToLinear(c); + + return float4(c, src.a); + } + ENDHLSL + } + } + + Fallback Off +} diff --git a/Assets/WL/Look/Palette/WLPaletteDither.shader.meta b/Assets/WL/Look/Palette/WLPaletteDither.shader.meta new file mode 100644 index 000000000..5fdcf8386 --- /dev/null +++ b/Assets/WL/Look/Palette/WLPaletteDither.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: e92f59943c6ae374fa6ef20fcef18428 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Palette/WLPaletteSettings.cs b/Assets/WL/Look/Palette/WLPaletteSettings.cs new file mode 100644 index 000000000..7b27546e2 --- /dev/null +++ b/Assets/WL/Look/Palette/WLPaletteSettings.cs @@ -0,0 +1,119 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLPaletteSettings.cs — 팔레트 양자화 + Bayer 디더 값의 SOT (C45) · WL-814j · #814 +// +// 값의 SOT = Assets/WL/Look/Palette/Resources/WL/WLPaletteSettings.asset +// → Resources.Load("WL/WLPaletteSettings") 1회 로드 후 캐시(기존 WL 설정 SO 관례). +// +// 🔴 C8 — enabled = 0 (또는 RuntimeDisabled = true) 이면 렌더러 피처가 꺼지고 패스도 0건이다 = 원본 100 %. +// 🔴 코드 상수 0 — 레벨·디더 강도·모드 범위까지 전부 이 에셋에서 바꾼다. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Look.Palette +{ + /// 팔레트 양자화 방식. + public enum WLPaletteMode + { + /// R/G/B 각 levels 단계로 반올림(levels^3 색 상한). 기본. + ChannelLevels = 0, + /// N×1 팔레트 텍스처에서 최근접 색을 찾는다(배선만 · 텍스처 미설정이면 ChannelLevels 로 되돌아간다). + PaletteTexture = 1, + } + + /// 팔레트 양자화 + 디더 후처리 값. 전부 여기서만 바꾼다. + [CreateAssetMenu(fileName = "WLPaletteSettings", menuName = "WL/Palette Dither Settings", order = 814)] + public sealed class WLPaletteSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLPaletteSettings"; + + static WLPaletteSettings s_cached; + static bool s_lookupDone; + + /// Resources 에서 1회 로드 후 캐시. 없으면 null(= 기능 off). + public static WLPaletteSettings Instance + { + get + { + if (!s_lookupDone) + { + s_lookupDone = true; + s_cached = Resources.Load(ResourcesPath); + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(WLPixelCameraSettings 선례). 에셋을 안 건드리고 이 기능만 끈다. + public static bool RuntimeDisabled; + + /// 이 기능이 살아 있는가(에셋 존재 + enabled + !RuntimeDisabled). + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled_ && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ───────────────────────────────────────── 마스터 + [Header("마스터 (C8)")] + [Tooltip("0 이면 렌더러 피처를 켜지 않고 패스도 넣지 않는다 = 저해상도 RT 가 원본 그대로.")] + public bool enabled_ = true; + + [Tooltip("1 이면 on/off·대상 카메라 전환마다 Console 에 1줄 남긴다. LogError 는 쓰지 않는다(813z).")] + public bool verboseLog = false; + + // ───────────────────────────────────────── 켜지는 조건 + [Header("켜지는 조건 (LookModeHub 구독)")] + [Tooltip("이 값 이상의 룩 모드(도트 1~3)에서만 켠다. 기본 모드 0 · 로비 강등 0 에서는 꺼진다.")] + [Range(0, 8)] public int minCameraMode = 1; + + [Tooltip("814b 리그가 조립돼 있을 때만 켠다(대상 = 저해상도 게임 카메라). 0 이면 리그 없이도 켜려고 시도한다 = 권장하지 않음.")] + public bool requirePixelRig = true; + + // ───────────────────────────────────────── 양자화 + [Header("① 팔레트 양자화")] + [Tooltip("ChannelLevels = R/G/B 각 levels 단계(기본) · PaletteTexture = N×1 텍스처 최근접(배선만).")] + public WLPaletteMode mode = WLPaletteMode.ChannelLevels; + + [Tooltip("채널당 단계 수. 6 = 216색 상한(권장) · 4 = 64색(더 도트답게) · 8 = 512색(약하게).")] + [Range(2, 32)] public int levels = 6; + + [Tooltip("PaletteTexture 모드에서 쓸 N×1 RGBA 텍스처(Point · 압축 없음 권장). 비어 있으면 ChannelLevels 로 돈다.")] + public Texture2D paletteTex; + + [Tooltip("팔레트 텍스처에서 실제로 읽을 색 개수(0 = 텍스처 가로 크기 그대로 · 셰이더 상한 64).")] + [Range(0, 64)] public int paletteCount = 0; + + // ───────────────────────────────────────── 디더 + [Header("② Bayer 순서 디더링")] + [Tooltip("디더 강도. 0 = 끔 · 0.6 = 권장 기본 · 1 = 양자화 1단계를 꽉 채운다.")] + [Range(0f, 1.5f)] public float ditherStrength = 0.6f; + + [Tooltip("디더 행렬 크기. 4 = 4×4(기본 · 도트 느낌) · 8 = 8×8(더 곱다).")] + public bool dither8x8 = false; + + // ───────────────────────────────────────── 선택 옵션(기본 무보정) + [Header("③ 선택 옵션 (기본값 = 무보정)")] + [Tooltip("채도. 1 = 그대로 · 1보다 크면 색이 진해진다.")] + [Range(0f, 2f)] public float saturation = 1f; + + [Tooltip("명도 대비(0.5 기준). 1 = 그대로.")] + [Range(0.2f, 2f)] public float contrast = 1f; + + [Tooltip("색조 보정색. 흰색 = 무보정. PD 참고 이미지(밤·보라/파랑)를 근사하려면 보랏빛을 넣고 아래 강도를 올린다.")] + public Color tintColor = Color.white; + + [Tooltip("색조 보정 강도. 0 = 무보정(기본).")] + [Range(0f, 1f)] public float tintStrength = 0f; + + // ───────────────────────────────────────── 렌더 순서 + [Header("④ 렌더 순서")] + [Tooltip("1 = AfterRenderingPostProcessing(기본 · 외곽선·포스트 뒤라 전부 같이 양자화된다) · 0 = AfterRenderingTransparents.")] + public bool afterPostProcessing = true; + + [Tooltip("1 = 선형 색을 sRGB 로 바꿔서 양자화한다(프로젝트가 Linear 이므로 기본 1). 0 이면 렌더 색 그대로 나눈다.")] + public bool quantizeInGammaSpace = true; + } +} diff --git a/Assets/WL/Look/Palette/WLPaletteSettings.cs.meta b/Assets/WL/Look/Palette/WLPaletteSettings.cs.meta new file mode 100644 index 000000000..facc1f169 --- /dev/null +++ b/Assets/WL/Look/Palette/WLPaletteSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 890e98040718afa49b2baee0a4ca6c48 \ No newline at end of file