Project_WL/AgentScripts/WL814j_Probe.cs

749 lines
38 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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