// ───────────────────────────────────────────────────────────────────────────── // 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 notes = new List(); 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(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(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(); 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(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(setPath); bool created = set == null; if (created) set = ScriptableObject.CreateInstance(); set.sourcePrefab = prefabPath; set.actorNamePrefix = prefix; set.bakeStamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm") + " · WL-814k"; set.sheet = AssetDatabase.LoadAssetAtPath(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("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(); 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(); fill.type = LightType.Directional; fill.color = cfg.fillColor; fill.intensity = cfg.fillIntensity; fill.shadows = LightShadows.None; fill.transform.rotation = Quaternion.Euler(cfg.fillEuler); } } /// 렌더에 필요 없는 것(콜라이더·에이전트·리지드바디·파티클·게임 스크립트)을 인스턴스에서 뗀다. static void StripNonVisual(GameObject inst) { foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c); foreach (var c in inst.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c.gameObject); foreach (var mb in inst.GetComponentsInChildren(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb); foreach (var sm in inst.GetComponentsInChildren(true)) { if (sm == null) continue; sm.updateWhenOffscreen = true; // 에디트 모드에서 컬링으로 사라지지 않게 sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; sm.receiveShadows = false; } foreach (var mr in inst.GetComponentsInChildren(true)) { if (mr == null) continue; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; } } /// 814c Toon 머티리얼로 교체 — 🔴 인스턴스의 sharedMaterials 배열만 바꾼다(디스크 .mat 무변경). static int SwapToonMaterials(GameObject inst, WLSpriteBakeSettings cfg, Result r) { int hit = 0; var cache = new Dictionary(); foreach (var rend in inst.GetComponentsInChildren(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(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 stateNames = new List(); } static List CollectActions(Animator anim, WLSpriteBakeSettings cfg, Result r) { var list = new List(); var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController; var byState = new Dictionary(StringComparer.Ordinal); var realName = new Dictionary(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(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); } /// 검정 배경 · 흰 배경 두 장에서 알파를 복원하고 도트화한다(이진화 → 양자화). 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; } } } /// 프로브 프레임에서 S×S 를 잘라낸다. (ox, oy) = 잘라낼 좌하단의 프로브 좌표. 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; } /// 알파 경계 **바깥**에 1px 외곽선(색 = SO). static void Outline(Color32[] px, int S, WLSpriteBakeSettings cfg) { var col = (Color32)cfg.outlineColor; col.a = 255; var add = new List(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; } } }