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

697 lines
38 KiB
C#
Raw 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.

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