Merge branch 'wl/gameplay/WL-814j-palette-dither'

This commit is contained in:
깃 관리자 2026-09-12 18:56:16 +09:00
commit 7966c980f1
21 changed files with 1894 additions and 1 deletions

View File

@ -0,0 +1,21 @@
# WL-814j 팔레트 캡처 2026-09-12 18:30:59
도트A 기준 = ortho 3.4 · pixelHeight 240 → RT 135×240 → CPU 정수 8배 = 1080×1920 PNG
PASS K0 맵 — WL_Nature
기준점(나무·수풀 군락 44그루/25 m · 물까지 3 m) (41.2, 0.0, 85.2) → PC (52.2, 0.1, 84.7)
카메라 직교 size 3.4 · pos (74.8, 13.0, 83.7) · look (50.2, 1.2, 84.8) · near 0.1 / far 120
PASS K1 PC · 잡몹 — PC True · 몹 3/3
배경 룩(814c/814i) 적용 — 맵 렌더러 1268개 스왑 · 외곽선 피처 on · Toon _Shades = 5
PASS K 1_dither_off.png — ① A안만(팔레트 패스 0) · 고유 색 1139종(표본 254) · 47789 B
PASS K 2_levels6_dither0.6.png — ② 권장 기본 · 고유 색 53종(표본 41) · 48425 B
PASS K 3_levels4_dither0.8.png — ③ 더 도트답게 · 고유 색 23종(표본 19) · 53698 B
PASS K 4_levels8_dither0.3.png — ④ 약하게 · 고유 색 76종(표본 54) · 52344 B
| 파일 | 무엇 | levels | dither | 고유 색 수(전수) | 814c 방식 표본(1/331) | 크기 |
|---|---|---|---|---|---|---|
| `1_dither_off.png` | ① A안만(팔레트 패스 0) | — | — | **1139** | 254 | 46 KB |
| `2_levels6_dither0.6.png` | ② 권장 기본 | 6 | 0.6 | **53** | 41 | 47 KB |
| `3_levels4_dither0.8.png` | ③ 더 도트답게 | 4 | 0.8 | **23** | 19 | 52 KB |
| `4_levels8_dither0.3.png` | ④ 약하게 | 8 | 0.3 | **76** | 54 | 51 KB |
PASS K9 캡처 4장 — 4장
RESULT PASS · 7/7 · FAIL 0

View File

@ -0,0 +1,13 @@
WL-814j 팔레트 렌더러 피처 추가 · 2026-09-12 18:21:56
활성 URP 에셋 = Assets/Settings/URP-Balanced.asset
PASS F 렌더러 에셋 — Assets/Settings/URP-Balanced-Renderer.asset
추가 전 피처 2개:
- SSAO [ScreenSpaceAmbientOcclusion] active=True
- PixelOutlineSetup [PixelOutlineSetupFeature] active=False
PASS F 추가 확인 — 피처 3개 · WLPaletteDither 1개 · map=
- SSAO [ScreenSpaceAmbientOcclusion] active=True
- PixelOutlineSetup [PixelOutlineSetupFeature] active=False
- WLPaletteDither [PaletteDitherFeature] active=False
PASS F 디스크 기본 off — m_Active=0
RESULT PASS · 3/3 · FAIL 0

View File

@ -0,0 +1,93 @@
# WL-814j 프로브 2026-09-12 18:34:01 (edit mode · Play 0)
playMode=True · colorSpace=Linear
── A. 전제 — SO · 셰이더 · 렌더러 피처
PASS A1 SO — Resources/WL/WLPaletteSettings · enabled True · levels 6 · dither 0.6
PASS A2 셰이더 — WL/Look/PaletteDither · isSupported True · passCount 1
PASS A3 렌더러 에셋 — Assets/Settings/URP-Balanced-Renderer.asset
피처 3개(순서대로):
[0] SSAO [ScreenSpaceAmbientOcclusion] active=True
[1] PixelOutlineSetup [PixelOutlineSetupFeature] active=False
[2] WLPaletteDither [PaletteDitherFeature] active=False
PASS A4 팔레트 피처 1건만 — WLPaletteDither 1개
PASS A5 피처 찾기·켜기 — isActive=True
PASS A6 머티리얼 생성 — WL/Look/PaletteDither
── B. 🔴 대상 카메라에서만 패스 삽입 (뷰·UI·프리뷰 0)
PASS B1 대상 카메라 = 패스 1건 — PassesAdded 1 · 대상 '__WL814j_Target' · 이벤트 AfterRenderingPostProcessing
PASS B2 다른 카메라 = 패스 0 — PassesAdded 0 · skip=대상 아님
PASS B3 프리뷰 카메라 = 패스 0 — PassesAdded 0 · skip=cameraType Preview
PASS B4 대상 없음 = 패스 0 — 리그조립 False · skip=대상 카메라 없음
렌더러 피처 배열 순서 = SSAO → PixelOutlineSetup → WLPaletteDither
팔레트 패스 이벤트 = AfterRenderingPostProcessing(600) · SO afterPostProcessing=True
814c PixelOutlineSetup 이벤트 = AfterRenderingOpaques(300) — 빈 패스로 깊이·노멀·색 텍스처만 켠다.
→ 외곽선 자체는 Critter Toon 머티리얼 안에서 **불투명 지오메트리와 함께** 그려지므로 항상 이 패스보다 앞이다 = 외곽선까지 양자화된다.
PASS B5 팔레트가 외곽선보다 뒤 — 600 > 300
── C. 🔴 Bayer 디더 격자가 RT 픽셀과 1:1 (단색 화면 · 4×4 주기 실측)
PASS C1 디더 0 → 단색 1종 — 고유 색 1종 · rgb(153,102,153)
PASS C2 디더 4×4 → 색 2종 이상 — 고유 색 4종
PASS C3 x 주기 4 정확 — p(x,y) != p(x+4,y) 인 픽셀 0개 / 31440
PASS C4 y 주기 4 정확 — p(x,y) != p(x,y+4) 인 픽셀 0개 / 31860
PASS C5 주기 2 아님(= 진짜 4×4) — x 15960 · y 16065 개 불일치
PASS C6 8×8 x 주기 8 정확 — 불일치 0개
PASS C7 8×8 y 주기 8 정확 — 불일치 0개
PASS C8g 8×8 는 주기 4 아님 — 주기 4 불일치 2910개
→ 디더 점 1개 = 저해상도 RT 1픽셀(135×240). 화면(1080×1920)에서는 Point 확대로 8×8 화면픽셀 블록이 된다.
── D. 채널 레벨별 색 수 (그라데이션 화면 · levels^3 상한 실측)
| levels | dither | 고유 색 수 | 상한 levels^3 |
|---|---|---|---|
| (패스 없음) | — | 1828 | — |
| 4 | 0.6 | 9 | 64 |
PASS D1 levels 4 상한 준수 — 9 ≤ 64
| 6 | 0.6 | 13 | 216 |
PASS D2 levels 6 상한 준수 — 13 ≤ 216
| 8 | 0.6 | 22 | 512 |
PASS D3 levels 8 상한 준수 — 22 ≤ 512
PASS D4 색 수 감소 — 패스 없음 1828종 → levels 6 13종
── E. GC — 프레임당 할당 0
PASS E PaletteDither.Tick ×200k — mono Δ0 B · profiler Δ0 B · gen0 244→244
PASS E PaletteDither.Want ×200k — mono Δ0 B · profiler Δ0 B · gen0 246→246
PASS E 피처 값 푸시(SO→머티리얼) ×200k — mono Δ0 B · profiler Δ0 B · gen0 248→248
PASS E9 대조군 16 MB 감지 — Δ16781312 B = 계기 감도 확인
── F. C8 — SO off / 피처 off 면 저해상도 RT 가 원본과 픽셀 단위로 같다
PASS F1 SO off → 패스 0 — PassesAdded 0 · skip=SO off
PASS F2 SO off → 픽셀 동일 — 해시 -5621533481151061762 vs -5621533481151061762
PASS F3 피처 off → AddRenderPasses 호출 0 — added 0 · skipped 0(URP 가 비활성 피처를 아예 안 부른다)
PASS F4 피처 off → 픽셀 동일 — 해시 -5621533481151061762 vs -5621533481151061762
PASS F5 대조군 — 켜면 달라진다 — 해시 1679459652402882898 != -5621533481151061762
── G. 도트 모드 전환 3회 왕복 — 예외 0 · 대상 카메라가 리그 게임 카메라로 따라온다
PASS G1 Camera.main 존재 — Camera.main = Main Camera · 프로브가 넣은 것 = __WL814j_MainCamera
PASS G2 인게임 판정 — InLobby=False
| 왕복 | 모드 | 리그조립 | 대상 카메라 | RT | 팔레트 on | 피처 active |
|---|---|---|---|---|---|---|
| 1 | 1 | O | PixelCamera | 135×240 | True | True |
PASS G1a 도트 모드 → on — on=True 피처=True
PASS G1b 대상 = 리그 게임 카메라 — PixelCamera
| 1 | 0 | - | (없음) | — | False | False |
PASS G1c 기본 모드 → off — on=False 피처=False
| 2 | 1 | O | PixelCamera | 135×240 | True | True |
PASS G2a 도트 모드 → on — on=True 피처=True
PASS G2b 대상 = 리그 게임 카메라 — PixelCamera
| 2 | 0 | - | (없음) | — | False | False |
PASS G2c 기본 모드 → off — on=False 피처=False
| 3 | 1 | O | PixelCamera | 135×240 | True | True |
PASS G3a 도트 모드 → on — on=True 피처=True
PASS G3b 대상 = 리그 게임 카메라 — PixelCamera
| 3 | 0 | - | (없음) | — | False | False |
PASS G3c 기본 모드 → off — on=False 피처=False
PASS G9 예외 0 — 0건
[Palette] on=False 피처활성=False 대상=(없음) · 모드 0 리그 False · levels 6 dither 0.6 (4x4) · 패스 추가 1 건너뜀 0 · on 3 off 3 허브이벤트 6
PASS G10 로비 강등 → off — AppliedMode 0 · on False
── Z. 정리
남은 카메라 8개 · 리그조립 False · 피처 active False
AssetDatabase.SaveAssets 호출 0 · 씬 저장 0
RESULT PASS · 45/45 · FAIL 0

View File

@ -0,0 +1,748 @@
// 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<ScriptableRendererData>(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<ScriptableRendererData>(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<GameObject>(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<Camera>();
// 리그는 Camera.main 을 원본으로 쓴다. 임시 씬에 이미 MainCamera 태그 카메라가 있으면 그쪽이 뽑힐 수 있는데,
// 검증 대상은 「도트 모드에서 리그 게임 카메라가 대상이 되는가」이므로 Camera.main 이 있기만 하면 된다.
Chk("G1 Camera.main 존재", Camera.main != null,
"Camera.main = " + (Camera.main != null ? Camera.main.name : "null") + " · 프로브가 넣은 것 = " + (_srcCam != null ? _srcCam.name : "null"));
// 가짜 InGameInfo — 로비 판정만 인게임으로(에디트 모드 Awake 미실행 · Ins 는 순수 static)
_infoGo = new GameObject("__WL814j_InGameInfo");
_infoGo.hideFlags = HideFlags.DontSave;
var info = _infoGo.AddComponent<InGameInfo>();
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<Camera>(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<Camera>() != 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<Camera>();
var acd = camGo.AddComponent<UniversalAdditionalCameraData>();
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<GameObject>();
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<string>();
// ① 디더 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);
}
// ═════════════════════════════════════════════════════════════════════════
// 렌더 · 색 세기
// ═════════════════════════════════════════════════════════════════════════
/// <summary>저해상도 RT 로 1프레임 렌더하고 RGB24 픽셀을 돌려준다(Blit 0 = 색 왜곡 0).</summary>
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);
}
/// <summary>렌더 → PNG(정수 8배 Point 확대 · CPU) + 고유 색 수. 표 1줄을 돌려준다.</summary>
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<int>();
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<int>();
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 + ")"; }
/// <summary>p(x,y) 와 p(x+dx, y+dy) 가 다른 픽셀 수.</summary>
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<Camera>();
var acd = go.AddComponent<UniversalAdditionalCameraData>();
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<GameObject>(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<Animator>(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);
}
}
/// <summary>814c 와 같은 「나무·수풀이 촘촘하고 물이 가까운」 지점 선정.</summary>
static Vector3 ScenicSpot(GameObject map, out string why)
{
var pts = new List<Vector3>();
var waters = new List<Bounds>();
foreach (var r in map.GetComponentsInChildren<Renderer>(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;
}
}

View File

@ -0,0 +1,15 @@
WL-814j 렌더러 에셋 재임포트 확인 · 2026-09-12 18:29:07
PASS R 로드 — Assets/Settings/URP-Balanced-Renderer.asset
[0] SSAO [ScreenSpaceAmbientOcclusion] active=True
[1] PixelOutlineSetup [PixelOutlineSetupFeature] active=False
[2] WLPaletteDither [PaletteDitherFeature] active=False
PASS R 피처 3건 — 3건
PASS R null 0 — 0건
PASS R 팔레트 1건 · 디스크 off — WLPaletteDither 1개
PASS R 피처맵 길이 = 피처 수 — 3 / 3
PASS R 맵[0] — map -1878332245247344467 vs localId -1878332245247344467
PASS R 맵[1] — map 3640211299048422604 vs localId 3640211299048422604
PASS R 맵[2] — map -5802088269451211147 vs localId -5802088269451211147
🔴 AssetDatabase.SaveAssets 호출 0 — 디스크 m_AssetVersion 은 2 그대로.
RESULT PASS · 8/8 · FAIL 0

View File

@ -0,0 +1,6 @@
WL-814j 팔레트 설정 SO 생성 · 2026-09-12 18:21:46
PASS S SO 로드 — 새로 만듦 Assets/WL/Look/Palette/Resources/WL/WLPaletteSettings.asset
PASS S Resources 경로 — Resources.Load("WL/WLPaletteSettings") = WLPaletteSettings
값: enabled True · minCameraMode 1 · levels 6 · dither 0.6 (4x4) · sat 1 · contrast 1 · tint 0 · afterPost True · gamma True
RESULT PASS · 2/2 · FAIL 0

View File

@ -0,0 +1,198 @@
// 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<WLPaletteSettings>(kSoPath);
bool made = false;
if (so == null)
{
so = ScriptableObject.CreateInstance<WLPaletteSettings>();
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<ScriptableRendererData>(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<ScriptableRendererData>(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");
}
// ═════════════════════════════════════════════════════════════════════════
// ③ 손으로 넣은 YAML 을 다시 읽어 확인 (🔴 SaveAssets 0 — 저장하면 Unity 가 m_AssetVersion 2→3 으로 올린다)
// ═════════════════════════════════════════════════════════════════════════
public static void Reimport()
{
s_sb = new StringBuilder(); s_pass = 0; s_fail = 0;
L("WL-814j 렌더러 에셋 재임포트 확인 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
try
{
AssetDatabase.ImportAsset(kRendererPath, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
var rd = AssetDatabase.LoadAssetAtPath<ScriptableRendererData>(kRendererPath);
Chk("R 로드", rd != null, kRendererPath);
if (rd == null) return;
int pal = 0, nulls = 0;
for (int i = 0; i < rd.rendererFeatures.Count; i++)
{
var f = rd.rendererFeatures[i];
if (f == null) nulls++;
if (f is PaletteDitherFeature) pal++;
L(" [" + i + "] " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive));
}
Chk("R 피처 3건", rd.rendererFeatures.Count == 3, rd.rendererFeatures.Count + "건");
Chk("R null 0", nulls == 0, nulls + "건");
Chk("R 팔레트 1건 · 디스크 off", pal == 1, "WLPaletteDither " + pal + "개");
var so = new SerializedObject(rd);
var mp = so.FindProperty("m_RendererFeatureMap");
Chk("R 피처맵 길이 = 피처 수", mp != null && mp.arraySize == rd.rendererFeatures.Count,
(mp != null ? mp.arraySize.ToString() : "?") + " / " + rd.rendererFeatures.Count);
if (mp != null)
for (int i = 0; i < mp.arraySize; i++)
{
long id = 0; string guid;
if (rd.rendererFeatures[i] != null) AssetDatabase.TryGetGUIDAndLocalFileIdentifier(rd.rendererFeatures[i], out guid, out id);
Chk("R 맵[" + i + "]", mp.GetArrayElementAtIndex(i).longValue == id,
"map " + mp.GetArrayElementAtIndex(i).longValue + " vs localId " + id);
}
L(" 🔴 AssetDatabase.SaveAssets 호출 0 — 디스크 m_AssetVersion 은 2 그대로.");
}
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_REIMPORT.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();
}
}

View File

@ -54,7 +54,8 @@ MonoBehaviour:
m_RendererFeatures:
- {fileID: -1878332245247344467}
- {fileID: 3640211299048422604}
m_RendererFeatureMap: adc0de57c6d2eee5ccfc24cbe3a08432
- {fileID: -5802088269451211147}
m_RendererFeatureMap: adc0de57c6d2eee5ccfc24cbe3a08432754ee6af75d77aaf
m_UseNativeRenderPass: 0
postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2}
xrSystemData: {fileID: 11400000, guid: 60e1133243b97e347b653163a8c01b64, type: 2}
@ -110,3 +111,16 @@ MonoBehaviour:
m_Name: PixelOutlineSetup
m_EditorClassIdentifier: Assembly-CSharp::Environment.PixelOutlineSetupFeature
m_Active: 0
--- !u!114 &-5802088269451211147
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 789b6e9fc1082d84abc7fbd9d3a69032, type: 3}
m_Name: WLPaletteDither
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.Palette.PaletteDitherFeature
m_Active: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3f2f76a74f36f9b47afec7a6f95dc38e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>팔레트 후처리 on/off. 정적 · 전용 러너 1개.</summary>
public static class PaletteDither
{
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
public static int Ons, Offs, HubEvents, FeatureMisses;
public static string LastLog = "";
/// <summary>지금 후처리가 켜져 있는가.</summary>
public static bool IsOn { get { return s_on; } }
/// <summary>찾아 둔 렌더러 피처(진단). 못 찾으면 null.</summary>
public static ScriptableRendererFeature Feature { get { return s_feature; } }
/// <summary>피처가 지금 활성인가(진단).</summary>
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; } }
// ─────────────────────────────────────────────────────────────────────
// 틱 · 판정
// ─────────────────────────────────────────────────────────────────────
/// <summary>러너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). 비교뿐 = GC 0.</summary>
public static void Tick()
{
bool want = Want();
if (want != s_on) Apply(want);
}
/// <summary>지금 켜져 있어야 하는가(참조·정수 비교만).</summary>
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;
}
/// <summary>on/off 를 실제로 적용한다. 같은 값이면 무동작.</summary>
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;
}
// ─────────────────────────────────────────────────────────────────────
// 렌더러 피처
// ─────────────────────────────────────────────────────────────────────
/// <summary>URP 렌더러의 팔레트 피처를 켜고 끈다. 없으면 아무 것도 하지 않는다(후처리만 안 걸린다).</summary>
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<PaletteDitherFeature>();
if (all != null && all.Length > 0) s_feature = all[0];
return s_feature;
}
/// <summary>프로브: 피처 캐시를 비운다(에디트 모드 재실행).</summary>
public static void ClearFeatureCache() { s_feature = null; s_featureLookups = 0; }
/// <summary>프로브: 상태·카운터를 시작값으로 되돌리고 피처도 원복한다.</summary>
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프레임을 기다리지 않는다)
}
/// <summary>프로브: 구독을 보장한다(에디트 모드에서도 부를 수 있다).</summary>
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<PaletteDitherRunner>();
Object.DontDestroyOnLoad(s_runner);
}
internal static void OnRunnerGone()
{
Unsubscribe();
Apply(false);
PaletteDitherFeature.TargetCamera = null;
}
}
/// <summary>팔레트 후처리의 시간 축. GameObject 1개 · 코루틴 0.</summary>
internal sealed class PaletteDitherRunner : MonoBehaviour
{
void Update() { PaletteDither.Tick(); }
void OnApplicationQuit() { PaletteDither.OnRunnerGone(); }
void OnDestroy() { PaletteDither.OnRunnerGone(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 37c8e1e0af30fdb4daa1240d3d4c84cc

View File

@ -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
{
/// <summary>저해상도 게임 카메라의 RT 에만 팔레트 양자화 + Bayer 디더를 거는 URP 렌더러 피처.</summary>
public sealed class PaletteDitherFeature : ScriptableRendererFeature
{
public const string ShaderName = "WL/Look/PaletteDither";
// ── 대상 카메라 ───────────────────────────────────────────────────────
/// <summary>프로브·캡처가 직접 지정하는 대상 카메라(우선). null 이면 814b 리그의 게임 카메라를 쓴다.</summary>
public static Camera TargetCamera;
/// <summary>지금 패스가 들어갈 카메라(없으면 null). 814b 파일은 읽기만 한다.</summary>
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 = "";
/// <summary>마지막으로 패스를 넣은 카메라 이름(진단).</summary>
public static string LastTargetName = "";
/// <summary>마지막으로 실제로 쓴 렌더 이벤트(진단).</summary>
public static RenderPassEvent LastEvent;
/// <summary>프로브: 카운터를 비운다.</summary>
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;
}
/// <summary>셰이더 → 머티리얼(1회). 셰이더가 없으면 false(패스 0 · 로그 1줄).</summary>
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;
}
/// <summary>SO 값을 머티리얼에 얹는다. SetFloat/SetColor(int) 는 관리 힙 할당 0.</summary>
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);
}
}
/// <summary>프로브: 머티리얼 준비 + SO 값 얹기 경로만 실행한다(프레임당 할당 측정용).</summary>
public bool ProbePushValues()
{
var c = WLPaletteSettings.Instance;
if (c == null) return false;
if (!EnsureMaterial()) return false;
PushValues(c);
return true;
}
/// <summary>프로브: 지금 피처가 들고 있는 머티리얼(없으면 만든다).</summary>
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<UniversalResourceData>();
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");
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 789b6e9fc1082d84abc7fbd9d3a69032

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 434d545cb82166e4fb9f386e2641029a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f7156e4f12a8a3e4db9d6a2762413aea
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 890e98040718afa49b2baee0a4ca6c48, type: 3}
m_Name: WLPaletteSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.Palette.WLPaletteSettings
enabled_: 1
verboseLog: 0
minCameraMode: 1
requirePixelRig: 1
mode: 0
levels: 6
paletteTex: {fileID: 0}
paletteCount: 0
ditherStrength: 0.6
dither8x8: 0
saturation: 1
contrast: 1
tintColor: {r: 1, g: 1, b: 1, a: 1}
tintStrength: 0
afterPostProcessing: 1
quantizeInGammaSpace: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 30c9f4c13b472f743810e7b3251620ea
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: e92f59943c6ae374fa6ef20fcef18428
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,119 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLPaletteSettings.cs — 팔레트 양자화 + Bayer 디더 값의 SOT (C45) · WL-814j · #814
//
// 값의 SOT = Assets/WL/Look/Palette/Resources/WL/WLPaletteSettings.asset
// → Resources.Load<WLPaletteSettings>("WL/WLPaletteSettings") 1회 로드 후 캐시(기존 WL 설정 SO 관례).
//
// 🔴 C8 — enabled = 0 (또는 RuntimeDisabled = true) 이면 렌더러 피처가 꺼지고 패스도 0건이다 = 원본 100 %.
// 🔴 코드 상수 0 — 레벨·디더 강도·모드 범위까지 전부 이 에셋에서 바꾼다.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Look.Palette
{
/// <summary>팔레트 양자화 방식.</summary>
public enum WLPaletteMode
{
/// <summary>R/G/B 각 levels 단계로 반올림(levels^3 색 상한). 기본.</summary>
ChannelLevels = 0,
/// <summary>N×1 팔레트 텍스처에서 최근접 색을 찾는다(배선만 · 텍스처 미설정이면 ChannelLevels 로 되돌아간다).</summary>
PaletteTexture = 1,
}
/// <summary>팔레트 양자화 + 디더 후처리 값. 전부 여기서만 바꾼다.</summary>
[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;
/// <summary>Resources 에서 1회 로드 후 캐시. 없으면 null(= 기능 off).</summary>
public static WLPaletteSettings Instance
{
get
{
if (!s_lookupDone)
{
s_lookupDone = true;
s_cached = Resources.Load<WLPaletteSettings>(ResourcesPath);
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(WLPixelCameraSettings 선례). 에셋을 안 건드리고 이 기능만 끈다.</summary>
public static bool RuntimeDisabled;
/// <summary>이 기능이 살아 있는가(에셋 존재 + enabled + !RuntimeDisabled).</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled_ && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
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;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 890e98040718afa49b2baee0a4ca6c48