// ───────────────────────────────────────────────────────────────────────────── // 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"); } } } }