Project_WL/AgentScripts/WL814j_Probe.cs

749 lines
38 KiB
C#
Raw Normal View History

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