697 lines
38 KiB
C#
697 lines
38 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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;
|
||
}
|
||
}
|
||
}
|