// ───────────────────────────────────────────────────────────────────────────── // 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 }