Project_WL/Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs

697 lines
38 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// WLSpriteBaker.cs — 3D 캐릭터 프리팹 → 「도트 스프라이트 시트」 굽기 (에디터 전용 · WL-814k · #814)
//
// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」
//
// ■ 🔴 이 툴은 **프리팹 무관**하다 — 대상 프리팹은 인자로 받는다. 캐릭터 이름이 코드에 0개.
// (PD 가 캐릭터 에셋 교체를 검토 중 → 특정 프리팹에 하드코딩하면 툴이 통째로 버려진다.)
//
// ■ 절차
// ① 임시 씬(Empty · Single)에 프리팹을 인스턴스화하고 **프리팹 링크를 끊는다**(Unpack).
// → 이후 무슨 짓을 해도 원본 프리팹 에셋에 한 글자도 안 쓴다. 씬 저장 0.
// ② 814c Toon 머티리얼(`<이름>_Toon.mat`)이 있으면 **인스턴스에만** 갈아끼운다(원본 .mat 무변경).
// ③ 인게임과 같은 **내림각**(811o 궤도 수식 · 실측 43.609°)의 **직교 카메라**를 만들고,
// 픽셀 1개의 월드 크기를 도트 프리셋과 같게 맞춘다(= 스프라이트 1px 이 화면 1px).
// ④ `AnimationMode.SampleAnimationClip` 로 포즈를 고정(Generic·Humanoid 둘 다 실측 OK) →
// 방향(피벗 Y 회전) × 동작 × 프레임마다 **2번** 렌더한다(배경 검정 / 배경 흰색).
// 두 장의 차이로 **알파를 정확히 복원**한다 — URP 가 불투명 패스에서 알파를 어떻게 쓰든 안 흔들린다.
// ⑤ 도트화를 **굽는 시점에 강제**: 알파 이진화(0.5) → 팔레트 양자화(채널당 6단계) → 1px 검정 외곽선.
// ⑥ 전 프레임의 실루엣을 합쳐 **꽉 차는 프레임 크기**를 8의 배수로 자동 산출하고, 그 크기로 잘라
// 시트 1장(PNG)에 격자로 붙인다 + 메타 SO(WLSpriteSet)를 만든다.
//
// ■ 왜 서브 스프라이트(spriteImportMode Multiple)가 아니라 「단일 시트 + UV 산술」인가
// 런타임 빌보드는 `Sprite` 객체가 아니라 **쿼드 메시 + MaterialPropertyBlock 의 `_BaseMap_ST`** 로 그린다.
// Multiple 로 자르면 프레임 수만큼(시범 2종 = 384개) 서브 에셋과 .meta 줄이 생기고 런타임 이득은 0이다.
// 시트 1장 + `UvRect()` 산술이면 서브 에셋 0 · 프레임 전환 시 할당 0.
//
// 🔴 금지 준수: 원본 프리팹/머티리얼/애니메이션 **쓰기 0**. `AssetDatabase.SaveAssets()` 는
// 이 툴이 만든 에셋에만 쓰고, 임포트 설정은 대상 PNG 하나에만 건다.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace WL.Look.SpriteBake.EditorTools
{
public static class WLSpriteBaker
{
public sealed class Result
{
public bool ok;
public string prefabPath = "";
public string pngPath = "";
public string setPath = "";
public int frameSize, columns, rows, sheetW, sheetH;
public int dirCount, totalFrames;
public long pngBytes;
public double seconds;
public float worldPerPixel, pitchDeg, bakeScale;
public float charPixelHeight;
public bool clipped;
public readonly List<string> notes = new List<string>();
public readonly StringBuilder log = new StringBuilder();
public string Summary
{
get
{
return string.Format("{0} · {1}×{1} px · {2}방향 × {3}프레임 · 시트 {4}×{5} · {6:N0} KB · {7:0.0}초",
Path.GetFileNameWithoutExtension(prefabPath), frameSize, dirCount, totalFrames / Mathf.Max(1, dirCount),
sheetW, sheetH, pngBytes / 1024.0, seconds);
}
}
}
// ═════════════════════════════════════════════════════════════════════
// 진입점 — 🔴 대상 프리팹은 **인자**
// ═════════════════════════════════════════════════════════════════════
public static Result Bake(string prefabPath, WLSpriteBakeSettings cfg,
string outputDirOverride = null, string actorNamePrefixOverride = null,
float rootScale = 0f)
{
var r = new Result { prefabPath = prefabPath };
var sw = System.Diagnostics.Stopwatch.StartNew();
if (cfg == null) { r.log.AppendLine("FAIL — WLSpriteBakeSettings 없음"); return r; }
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (src == null) { r.log.AppendLine("FAIL — 프리팹 로드 실패: " + prefabPath); return r; }
string outDir = string.IsNullOrEmpty(outputDirOverride) ? cfg.outputDir : outputDirOverride;
string baseName = Path.GetFileNameWithoutExtension(prefabPath);
string prefix = string.IsNullOrEmpty(actorNamePrefixOverride) ? baseName : actorNamePrefixOverride;
if (!Application.isBatchMode && !EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
{ r.log.AppendLine("취소 — 열린 씬 저장 거부"); return r; }
Scene temp = default(Scene);
GameObject pivot = null, inst = null, camGo = null;
RenderTexture rt = null;
Texture2D readTex = null;
bool animMode = false;
try
{
temp = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
SetupWorld(cfg);
// ── ① 인스턴스 (프리팹 링크 완전 절단 → 원본 에셋 쓰기 0) ────
pivot = new GameObject("[WL814k] Pivot");
inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
inst.transform.SetParent(pivot.transform, false);
inst.transform.localPosition = Vector3.zero;
inst.transform.localRotation = Quaternion.identity;
// 🔴 인게임에서 실제로 쓰는 크기로 굽는다(인자) — 0 이면 프리팹 자체 스케일.
// 런타임 스케일과 같게 구워야 스프라이트 1px = 화면 1px 이 유지된다.
if (rootScale > 0.0001f) inst.transform.localScale = Vector3.one * rootScale;
StripNonVisual(inst);
r.bakeScale = Mathf.Abs(inst.transform.lossyScale.y);
if (r.bakeScale < 0.0001f) r.bakeScale = 1f;
int toonHit = cfg.useToonMaterials ? SwapToonMaterials(inst, cfg, r) : 0;
r.log.AppendLine("Toon 머티리얼 교체 = " + toonHit + "슬롯 (인스턴스에만 · 원본 .mat 무변경)");
Vector3 srcLocalPos = inst.transform.localPosition;
Quaternion srcLocalRot = inst.transform.localRotation;
// ── ② 동작 수집 (이름 규약 · 프리팹 무관) ────────────────────
var anim = inst.GetComponentInChildren<Animator>(true);
if (anim == null || anim.runtimeAnimatorController == null)
{ r.log.AppendLine("FAIL — Animator/Controller 없음"); return r; }
var plan = CollectActions(anim, cfg, r);
if (plan.Count == 0) { r.log.AppendLine("FAIL — 구울 동작을 하나도 못 찾았다"); return r; }
// 🔴 샘플링 경로 — 기본은 **진짜 Animator 구동**(anim.Play + anim.Update).
// 실측(814k): AnimationMode.SampleAnimationClip 은 **루트 모션을 포즈에 얹는다**
// (Ai01 Run 이 2.0 m, death 가 3.0 m 밀려 프레임 밖으로 나갔다) — 그런데 게임의
// Animator 는 applyRootMotion=false 라 그 이동을 버린다. 즉 AnimationMode 로 구우면
// **게임에 안 나오는 그림**이 구워진다. Animator 구동은 컨트롤러·리타깃까지 게임과 같다.
anim.applyRootMotion = false;
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
anim.Rebind();
// ── ③ 카메라 ────────────────────────────────────────────────
float pitch = cfg.PitchDeg;
float wpp = cfg.WorldPerPixel;
r.pitchDeg = pitch; r.worldPerPixel = wpp;
int P = Mathf.Max(32, cfg.probeSize);
camGo = new GameObject("[WL814k] BakeCam");
var cam = camGo.AddComponent<Camera>();
cam.orthographic = true;
cam.orthographicSize = P * wpp * 0.5f;
cam.nearClipPlane = 0.01f;
cam.farClipPlane = 200f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.allowHDR = false;
cam.allowMSAA = false;
cam.useOcclusionCulling = false;
camGo.transform.rotation = Quaternion.Euler(pitch, 0f, 0f);
// 월드 원점이 (P/2, P/2) 픽셀의 **중심**에 찍히도록 반 픽셀 민다.
Vector3 aim = Vector3.zero
- camGo.transform.right * (wpp * 0.5f)
- camGo.transform.up * (wpp * 0.5f);
camGo.transform.position = aim - camGo.transform.forward * 50f;
rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point;
rt.antiAliasing = 1;
rt.Create();
readTex = new Texture2D(P, P, TextureFormat.RGBA32, false, false);
// ── ④ 전 프레임 렌더 (프로브 해상도) ─────────────────────────
int dirN = Mathf.Max(1, cfg.dirCount);
r.dirCount = dirN;
float step = 360f / dirN;
var frames = new List<Color32[]>(256);
int minX = int.MaxValue, maxX = int.MinValue, minY = int.MaxValue, maxY = int.MinValue;
if (cfg.sampleWithAnimationMode) { AnimationMode.StartAnimationMode(); animMode = true; }
var black = new Color32[P * P];
var white = new Color32[P * P];
for (int ai = 0; ai < plan.Count; ai++)
{
var pa = plan[ai];
for (int d = 0; d < dirN; d++)
{
pivot.transform.rotation = Quaternion.Euler(0f, d * step, 0f);
for (int f = 0; f < pa.frames; f++)
{
float nt = pa.loop
? (float)f / pa.frames
: (pa.frames > 1 ? (float)f / (pa.frames - 1) : 0f);
if (animMode)
{
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, pa.clip, pa.clip.length * nt);
AnimationMode.EndSampling();
}
else
{
anim.Play(pa.stateHash, 0, nt);
anim.Update(0f);
}
// 제자리로(빌보드는 액터 위치에 붙으므로 스프라이트는 제자리 애니메이션이어야 한다)
inst.transform.localPosition = srcLocalPos;
inst.transform.localRotation = srcLocalRot;
RenderInto(cam, rt, readTex, Color.black, black);
RenderInto(cam, rt, readTex, Color.white, white);
var px = Compose(black, white, P, cfg);
frames.Add(px);
TightBox(px, P, ref minX, ref maxX, ref minY, ref maxY);
}
}
}
if (animMode) { AnimationMode.StopAnimationMode(); animMode = false; }
if (minX > maxX) { r.log.AppendLine("FAIL — 렌더 결과가 전부 비었다(알파 0)"); return r; }
// ── ⑤ 프레임 크기 · 발밑 픽셀 산출 ──────────────────────────
int origin = P / 2; // 월드 원점이 찍힌 픽셀 인덱스
int margin = Mathf.Max(0, cfg.marginPx);
int needW = 2 * Mathf.Max(origin - minX, maxX - origin) + 2 * margin;
int needH = (maxY - minY + 1) + 2 * margin;
int need = Mathf.Max(needW, needH);
int S = cfg.autoFrameSize
? Mathf.Clamp(RoundUp8(need), Mathf.Max(8, cfg.frameSizeMin), Mathf.Max(8, cfg.frameSizeMax))
: Mathf.Max(8, cfg.frameSize);
int below = (origin - minY) + margin;
int above = (maxY - origin) + margin + 1;
int fpxI = S / 2;
int fpyI;
if (below + above <= S) fpyI = below;
else { fpyI = Mathf.RoundToInt(S * (float)below / (below + above)); r.clipped = true; r.notes.Add("프레임 상한 초과 — 실루엣이 잘렸다(needed " + need + " px)"); }
fpyI = Mathf.Clamp(fpyI, 0, S);
r.frameSize = S;
r.charPixelHeight = (maxY - minY + 1);
r.log.AppendLine(string.Format("실루엣 실측(프로브 {0}px) — x [{1}..{2}] y [{3}..{4}] · 캐릭터 높이 {5} px · 폭 {6} px → 프레임 {7}×{7} · 발밑 픽셀 ({8}.5, {9}.5)",
P, minX, maxX, minY, maxY, maxY - minY + 1, maxX - minX + 1, S, fpxI, fpyI));
// ── ⑥ 자르기 + 외곽선 → 시트 ────────────────────────────────
int total = frames.Count;
r.totalFrames = total;
int cols = cfg.sheetColumns > 0 ? cfg.sheetColumns : Mathf.CeilToInt(Mathf.Sqrt(total));
while (cols > 1 && cols * S > Mathf.Max(S, cfg.sheetMaxWidth)) cols--;
int rows = Mathf.CeilToInt(total / (float)cols);
r.columns = cols; r.rows = rows;
r.sheetW = cols * S; r.sheetH = rows * S;
var sheet = new Color32[r.sheetW * r.sheetH]; // 기본 = 완전 투명(0,0,0,0)
for (int i = 0; i < total; i++)
{
var cropped = Crop(frames[i], P, S, origin - fpxI, origin - fpyI);
if (cfg.outline) Outline(cropped, S, cfg);
int col = i % cols, row = i / cols;
Blit(cropped, S, sheet, r.sheetW, col * S, r.sheetH - (row + 1) * S);
}
// ── ⑦ PNG + 임포트 설정 ────────────────────────────────────
if (!Directory.Exists(outDir)) Directory.CreateDirectory(outDir);
string png = (outDir.TrimEnd('/') + "/" + baseName + ".png").Replace('\\', '/');
var tex = new Texture2D(r.sheetW, r.sheetH, TextureFormat.RGBA32, false, false);
tex.SetPixels32(sheet); tex.Apply(false, false);
File.WriteAllBytes(png, tex.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(tex);
AssetDatabase.ImportAsset(png, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
ApplyImportSettings(png, Mathf.Max(r.sheetW, r.sheetH));
r.pngPath = png;
r.pngBytes = new FileInfo(png).Length;
// ── ⑧ 메타 SO ─────────────────────────────────────────────
string setPath = (outDir.TrimEnd('/') + "/" + baseName + "_SpriteSet.asset").Replace('\\', '/');
var set = AssetDatabase.LoadAssetAtPath<WLSpriteSet>(setPath);
bool created = set == null;
if (created) set = ScriptableObject.CreateInstance<WLSpriteSet>();
set.sourcePrefab = prefabPath;
set.actorNamePrefix = prefix;
set.bakeStamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm") + " · WL-814k";
set.sheet = AssetDatabase.LoadAssetAtPath<Texture2D>(png);
set.frameWidth = S; set.frameHeight = S;
set.columns = cols; set.rows = rows;
set.dirCount = dirN;
set.worldPerPixel = wpp;
set.bakeScale = r.bakeScale;
set.footPixelX = fpxI + 0.5f;
set.footPixelY = fpyI + 0.5f;
set.pitchDeg = pitch;
var acts = new WLSpriteAction[plan.Count];
int start = 0;
for (int i = 0; i < plan.Count; i++)
{
var pa = plan[i];
acts[i] = new WLSpriteAction
{
key = pa.key,
stateNames = pa.stateNames.ToArray(),
clipName = pa.clip.name,
startIndex = start,
frameCount = pa.frames,
fps = pa.frames / Mathf.Max(0.0001f, pa.clip.length),
loop = pa.loop,
clipLength = pa.clip.length,
};
start += pa.frames * dirN;
}
set.actions = acts;
if (created) AssetDatabase.CreateAsset(set, setPath);
else EditorUtility.SetDirty(set);
AssetDatabase.SaveAssetIfDirty(set);
r.setPath = setPath;
r.ok = true;
}
catch (Exception ex)
{
r.log.AppendLine("EXCEPTION " + ex);
}
finally
{
if (animMode && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode();
if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (readTex != null) UnityEngine.Object.DestroyImmediate(readTex);
if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo);
if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot);
// 🔴 임시 씬은 저장하지 않는다. 빈 씬으로 갈아 끼워 흔적 0.
if (temp.IsValid()) EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
sw.Stop();
r.seconds = sw.Elapsed.TotalSeconds;
return r;
}
// ═════════════════════════════════════════════════════════════════════
// 월드(조명 · 환경광)
// ═════════════════════════════════════════════════════════════════════
static void SetupWorld(WLSpriteBakeSettings cfg)
{
RenderSettings.fog = false;
if (cfg.useSkyboxAmbient)
{
// 🔴 인게임 `Assets/Scenes/Ingame.unity` 실측 = m_AmbientMode 0(Skybox) + 기본 스카이박스.
// Flat 0.212 회색으로 구우면 게임보다 어둡게 구워진다(실측으로 확인).
RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource<Material>("Default-Skybox.mat");
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox;
RenderSettings.ambientIntensity = cfg.ambientIntensity;
RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Skybox;
DynamicGI.UpdateEnvironment();
}
else
{
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
RenderSettings.ambientLight = cfg.ambient;
RenderSettings.skybox = null;
RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom;
RenderSettings.customReflectionTexture = null;
}
var sun = new GameObject("[WL814k] Sun").AddComponent<Light>();
sun.type = LightType.Directional;
sun.color = cfg.sunColor;
sun.intensity = cfg.sunIntensity;
sun.shadows = LightShadows.None; // 🔴 그림자 off (시트에 그림자가 구워지면 안 된다)
sun.transform.rotation = Quaternion.Euler(cfg.sunEuler);
if (cfg.fillIntensity > 0.0001f)
{
var fill = new GameObject("[WL814k] Fill").AddComponent<Light>();
fill.type = LightType.Directional;
fill.color = cfg.fillColor;
fill.intensity = cfg.fillIntensity;
fill.shadows = LightShadows.None;
fill.transform.rotation = Quaternion.Euler(cfg.fillEuler);
}
}
/// <summary>렌더에 필요 없는 것(콜라이더·에이전트·리지드바디·파티클·게임 스크립트)을 인스턴스에서 뗀다.</summary>
static void StripNonVisual(GameObject inst)
{
foreach (var c in inst.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshObstacle>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in inst.GetComponentsInChildren<MonoBehaviour>(true))
if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in inst.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (sm == null) continue;
sm.updateWhenOffscreen = true; // 에디트 모드에서 컬링으로 사라지지 않게
sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
sm.receiveShadows = false;
}
foreach (var mr in inst.GetComponentsInChildren<MeshRenderer>(true))
{
if (mr == null) continue;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
}
}
/// <summary>814c Toon 머티리얼로 교체 — 🔴 인스턴스의 sharedMaterials 배열만 바꾼다(디스크 .mat 무변경).</summary>
static int SwapToonMaterials(GameObject inst, WLSpriteBakeSettings cfg, Result r)
{
int hit = 0;
var cache = new Dictionary<string, Material>();
foreach (var rend in inst.GetComponentsInChildren<Renderer>(true))
{
if (rend == null || rend is ParticleSystemRenderer) continue;
var mats = rend.sharedMaterials;
bool any = false;
for (int i = 0; i < mats.Length; i++)
{
var m = mats[i];
if (m == null) continue;
Material toon;
if (!cache.TryGetValue(m.name, out toon))
{
string p = cfg.toonMaterialDir.TrimEnd('/') + "/" + m.name + cfg.toonMaterialSuffix + ".mat";
toon = AssetDatabase.LoadAssetAtPath<Material>(p);
cache[m.name] = toon;
}
if (toon != null) { mats[i] = toon; any = true; hit++; }
}
if (any) rend.sharedMaterials = mats;
}
return hit;
}
// ═════════════════════════════════════════════════════════════════════
// 동작 수집 — 이름 규약(SO)만 본다. 캐릭터 이름 0.
// ═════════════════════════════════════════════════════════════════════
sealed class PlanAction
{
public string key;
public AnimationClip clip;
public string stateName;
public int stateHash;
public int frames;
public bool loop;
public readonly List<string> stateNames = new List<string>();
}
static List<PlanAction> CollectActions(Animator anim, WLSpriteBakeSettings cfg, Result r)
{
var list = new List<PlanAction>();
var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController;
var byState = new Dictionary<string, AnimationClip>(StringComparer.Ordinal);
var realName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // 후보(대소문자 무시) → 실제 상태 이름
if (ac != null && ac.layers.Length > 0 && ac.layers[0].stateMachine != null)
{
foreach (var ch in ac.layers[0].stateMachine.states)
{
var clip = FirstClip(ch.state.motion);
if (clip == null) continue;
if (!byState.ContainsKey(ch.state.name)) byState[ch.state.name] = clip;
if (!realName.ContainsKey(ch.state.name)) realName[ch.state.name] = ch.state.name;
}
}
else
{
// AnimatorOverrideController 등 — 클립 이름으로만 맞춘다
foreach (var c in anim.runtimeAnimatorController.animationClips)
if (c != null && !byState.ContainsKey(c.name)) { byState[c.name] = c; realName[c.name] = c.name; }
r.notes.Add("AnimatorController 가 아니어서 상태 이름 대신 클립 이름으로 맞췄다");
}
r.log.AppendLine("레이어0 상태 " + byState.Count + "개: " + string.Join(", ", new List<string>(byState.Keys).ToArray()));
foreach (var ba in cfg.actions)
{
if (ba == null) continue;
AnimationClip clip = null; string matched = null;
foreach (var n in ba.stateNames)
{
if (string.IsNullOrEmpty(n)) continue;
string real;
if (!realName.TryGetValue(n, out real)) continue;
if (byState.TryGetValue(real, out clip)) { matched = real; break; }
}
if (clip == null || matched == null || clip.length <= 0.0001f)
{
r.notes.Add("동작 '" + ba.key + "' — 상태를 못 찾음(후보: " + string.Join("/", ba.stateNames) + ") → 「미확인」 · 건너뜀");
continue;
}
var pa = new PlanAction { key = ba.key, clip = clip, frames = Mathf.Max(1, ba.frames), loop = ba.loop,
stateName = matched, stateHash = Animator.StringToHash(matched) };
if (ac != null && !anim.HasState(0, pa.stateHash))
{ r.notes.Add("동작 '" + ba.key + "' — 상태 '" + matched + "' 가 레이어0 에 없다 → 건너뜀"); continue; }
// 실제로 컨트롤러에 있는 상태 이름만 메타에 넣는다(런타임 해시 비교용)
pa.stateNames.Add(matched);
foreach (var n in ba.stateNames) { string real2; if (realName.TryGetValue(n, out real2) && !pa.stateNames.Contains(real2)) pa.stateNames.Add(real2); }
foreach (var n in ba.alsoMapStateNames) { string real3; if (realName.TryGetValue(n, out real3) && !pa.stateNames.Contains(real3)) pa.stateNames.Add(real3); }
list.Add(pa);
r.log.AppendLine(string.Format(" 동작 {0,-7} ← 상태 [{1}] · 클립 {2} ({3:0.000}s · {4}프레임 · loop {5})",
ba.key, string.Join("/", pa.stateNames.ToArray()), clip.name, clip.length, pa.frames, pa.loop));
}
return list;
}
static AnimationClip FirstClip(UnityEngine.Motion m)
{
var c = m as AnimationClip;
if (c != null) return c;
var bt = m as UnityEditor.Animations.BlendTree;
if (bt == null) return null;
foreach (var ch in bt.children)
{
var r = FirstClip(ch.motion);
if (r != null) return r;
}
return null;
}
// ═════════════════════════════════════════════════════════════════════
// 렌더 · 도트화
// ═════════════════════════════════════════════════════════════════════
static void RenderInto(Camera cam, RenderTexture rt, Texture2D read, Color bg, Color32[] outPx)
{
cam.backgroundColor = bg;
var prev = cam.targetTexture;
cam.targetTexture = rt;
cam.Render();
cam.targetTexture = prev;
var active = RenderTexture.active;
RenderTexture.active = rt;
read.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
read.Apply(false, false);
RenderTexture.active = active;
var src = read.GetPixels32();
Array.Copy(src, outPx, outPx.Length);
}
/// <summary>검정 배경 · 흰 배경 두 장에서 알파를 복원하고 도트화한다(이진화 → 양자화).</summary>
static Color32[] Compose(Color32[] black, Color32[] white, int P, WLSpriteBakeSettings cfg)
{
var outPx = new Color32[P * P];
int levels = cfg.paletteLevels;
bool quant = levels >= 2 && levels <= 256;
float cut = Mathf.Clamp01(cfg.alphaCutoff);
for (int i = 0; i < outPx.Length; i++)
{
Color32 b = black[i], w = white[i];
// 배경만 다르다 → 차이가 곧 「덜 덮인 정도」
float cov = 1f - (((w.r - b.r) + (w.g - b.g) + (w.b - b.b)) / (3f * 255f));
if (cov < cut) { outPx[i] = new Color32(0, 0, 0, 0); continue; }
// 검정 배경 위 값 = cov × 색 → 색 = 값 ÷ cov
float inv = cov > 0.001f ? 1f / cov : 1f;
float fr = Mathf.Clamp(b.r * inv, 0f, 255f);
float fg = Mathf.Clamp(b.g * inv, 0f, 255f);
float fb = Mathf.Clamp(b.b * inv, 0f, 255f);
if (quant && cfg.paletteValueBands)
{
// 밝기만 단계로 끊는다(색상·채도 유지) → 도트 특유의 평평한 명암, 색 얼룩 0
float v = Mathf.Max(fr, Mathf.Max(fg, fb));
if (v > 0.5f)
{
float n = levels - 1;
float qv = Mathf.Round(v / 255f * n) / n * 255f;
float k = qv / v;
fr *= k; fg *= k; fb *= k;
}
outPx[i] = new Color32((byte)Mathf.Clamp(Mathf.RoundToInt(fr), 0, 255),
(byte)Mathf.Clamp(Mathf.RoundToInt(fg), 0, 255),
(byte)Mathf.Clamp(Mathf.RoundToInt(fb), 0, 255), 255);
}
else outPx[i] = new Color32(Q(fr, levels, quant), Q(fg, levels, quant), Q(fb, levels, quant), 255);
}
return outPx;
}
static byte Q(float v, int levels, bool quant)
{
v = Mathf.Clamp(v, 0f, 255f);
if (!quant) return (byte)Mathf.RoundToInt(v);
float n = levels - 1;
float q = Mathf.Round(v / 255f * n) / n;
return (byte)Mathf.RoundToInt(q * 255f);
}
static void TightBox(Color32[] px, int P, ref int minX, ref int maxX, ref int minY, ref int maxY)
{
for (int y = 0; y < P; y++)
{
int row = y * P;
for (int x = 0; x < P; x++)
{
if (px[row + x].a == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
/// <summary>프로브 프레임에서 S×S 를 잘라낸다. (ox, oy) = 잘라낼 좌하단의 프로브 좌표.</summary>
static Color32[] Crop(Color32[] src, int P, int S, int ox, int oy)
{
var dst = new Color32[S * S];
for (int y = 0; y < S; y++)
{
int sy = oy + y;
if (sy < 0 || sy >= P) continue;
int srow = sy * P, drow = y * S;
for (int x = 0; x < S; x++)
{
int sx = ox + x;
if (sx < 0 || sx >= P) continue;
dst[drow + x] = src[srow + sx];
}
}
return dst;
}
/// <summary>알파 경계 **바깥**에 1px 외곽선(색 = SO).</summary>
static void Outline(Color32[] px, int S, WLSpriteBakeSettings cfg)
{
var col = (Color32)cfg.outlineColor;
col.a = 255;
var add = new List<int>(S * 4);
for (int y = 0; y < S; y++)
{
int row = y * S;
for (int x = 0; x < S; x++)
{
int i = row + x;
if (px[i].a != 0) continue;
if (Solid(px, S, x - 1, y) || Solid(px, S, x + 1, y) || Solid(px, S, x, y - 1) || Solid(px, S, x, y + 1)
|| (cfg.outlineDiagonal && (Solid(px, S, x - 1, y - 1) || Solid(px, S, x + 1, y - 1) || Solid(px, S, x - 1, y + 1) || Solid(px, S, x + 1, y + 1))))
add.Add(i);
}
}
for (int i = 0; i < add.Count; i++) px[add[i]] = col;
}
static bool Solid(Color32[] px, int S, int x, int y)
{
if (x < 0 || y < 0 || x >= S || y >= S) return false;
return px[y * S + x].a != 0;
}
static void Blit(Color32[] src, int S, Color32[] dst, int dstW, int dx, int dy)
{
for (int y = 0; y < S; y++)
{
int srow = y * S, drow = (dy + y) * dstW + dx;
Array.Copy(src, srow, dst, drow, S);
}
}
static int RoundUp8(int v) { return ((v + 7) / 8) * 8; }
// ═════════════════════════════════════════════════════════════════════
// 임포트 설정 — 🔴 코드로 지정(Point · 압축 없음 · mipmap off)
// ═════════════════════════════════════════════════════════════════════
public static void ApplyImportSettings(string pngPath, int maxSize)
{
var ti = AssetImporter.GetAtPath(pngPath) as TextureImporter;
if (ti == null) return;
ti.textureType = TextureImporterType.Default; // 🔴 Sprite(Multiple) 가 아니다 — UV 는 런타임 산술
ti.npotScale = TextureImporterNPOTScale.None;
ti.filterMode = FilterMode.Point;
ti.mipmapEnabled = false;
ti.wrapMode = TextureWrapMode.Clamp;
ti.alphaIsTransparency = true;
ti.alphaSource = TextureImporterAlphaSource.FromInput;
ti.sRGBTexture = true;
ti.isReadable = false;
ti.textureCompression = TextureImporterCompression.Uncompressed;
ti.maxTextureSize = NextPow2AtLeast(maxSize);
var def = ti.GetDefaultPlatformTextureSettings();
def.format = TextureImporterFormat.RGBA32;
def.textureCompression = TextureImporterCompression.Uncompressed;
def.maxTextureSize = ti.maxTextureSize;
ti.SetPlatformTextureSettings(def);
var android = ti.GetPlatformTextureSettings("Android");
android.overridden = true;
android.format = TextureImporterFormat.RGBA32;
android.textureCompression = TextureImporterCompression.Uncompressed;
android.maxTextureSize = ti.maxTextureSize;
ti.SetPlatformTextureSettings(android);
ti.SaveAndReimport();
}
static int NextPow2AtLeast(int v)
{
int p = 32;
while (p < v && p < 8192) p <<= 1;
return p;
}
}
}