Merge branch 'wl/gameplay/WL-814j-palette-dither'
This commit is contained in:
commit
7966c980f1
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3f2f76a74f36f9b47afec7a6f95dc38e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 37c8e1e0af30fdb4daa1240d3d4c84cc
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 789b6e9fc1082d84abc7fbd9d3a69032
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 434d545cb82166e4fb9f386e2641029a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f7156e4f12a8a3e4db9d6a2762413aea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 30c9f4c13b472f743810e7b3251620ea
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e92f59943c6ae374fa6ef20fcef18428
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 890e98040718afa49b2baee0a4ca6c48
|
||||
Loading…
Reference in New Issue