Files
test/Assets/Editor/DDSAtlasSlicerWindow.cs
T

577 lines
18 KiB
C#

#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
public class DDSAtlasSlicerWindow : EditorWindow
{
[SerializeField] private Texture2D _ddsAtlas;
[SerializeField] private TextAsset _txtAsset; // Reference to the .txt in Project
[SerializeField] private bool _txtIsGB2312 = true;
[SerializeField] private bool _trimEmptyNames = true;
[SerializeField] private bool _padIndexIfMissing = true;
[SerializeField] private int _paddingPixels = 0;
[SerializeField] private float _pixelsPerUnit = 100f;
[SerializeField] private bool _exportAsIndividualPngs = false;
[SerializeField] private string _exportFolder = string.Empty; // Project relative (starts with Assets/)
private string _status;
[MenuItem("Tools/Sprites/Slice DDS Atlas From TXT")]
private static void Open()
{
var win = GetWindow<DDSAtlasSlicerWindow>(true, "DDS Atlas Slicer", true);
win.minSize = new Vector2(520, 280);
win.Show();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Input", EditorStyles.boldLabel);
_ddsAtlas = (Texture2D)EditorGUILayout.ObjectField("DDS Atlas", _ddsAtlas, typeof(Texture2D), false);
_txtAsset = (TextAsset)EditorGUILayout.ObjectField("TXT (name list)", _txtAsset, typeof(TextAsset), false);
_txtIsGB2312 = EditorGUILayout.ToggleLeft("Decode TXT with GB2312 (Chinese)", _txtIsGB2312);
_trimEmptyNames = EditorGUILayout.ToggleLeft("Trim empty or whitespace-only names", _trimEmptyNames);
_padIndexIfMissing = EditorGUILayout.ToggleLeft("If name missing, use index as fallback", _padIndexIfMissing);
_paddingPixels = EditorGUILayout.IntField("Sprite Padding (px)", _paddingPixels);
_pixelsPerUnit = EditorGUILayout.FloatField("Pixels Per Unit", _pixelsPerUnit);
GUILayout.Space(6);
EditorGUILayout.LabelField("Output", EditorStyles.boldLabel);
_exportAsIndividualPngs = EditorGUILayout.ToggleLeft("Export each sprite as an individual PNG file", _exportAsIndividualPngs);
if (_exportAsIndividualPngs)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PrefixLabel("Export Folder (Project)");
EditorGUILayout.SelectableLabel(string.IsNullOrEmpty(_exportFolder) ? "(auto: <atlasDir>/<atlasName>_slices)" : _exportFolder, GUILayout.Height(16));
if (GUILayout.Button("Change...", GUILayout.Width(90)))
{
string start = string.IsNullOrEmpty(_exportFolder) ? Application.dataPath : ProjectPathToAbsolute(_exportFolder);
string chosen = EditorUtility.OpenFolderPanel("Choose export folder (inside Assets)", start, "");
if (!string.IsNullOrEmpty(chosen))
{
string projRel = AbsoluteToProjectPath(chosen);
if (!string.IsNullOrEmpty(projRel))
{
_exportFolder = projRel;
}
else
{
EditorUtility.DisplayDialog("Export Folder", "Please choose a folder inside the project's Assets/ directory.", "OK");
}
}
}
EditorGUILayout.EndHorizontal();
}
GUILayout.Space(8);
if (GUILayout.Button("Slice Atlas From TXT", GUILayout.Height(32)))
{
TrySlice();
}
if (!string.IsNullOrEmpty(_status))
{
EditorGUILayout.HelpBox(_status, MessageType.Info);
}
}
private void TrySlice()
{
_status = string.Empty;
if (_ddsAtlas == null)
{
_status = "Please assign a DDS atlas Texture2D asset.";
Repaint();
return;
}
if (_txtAsset == null)
{
_status = "Please assign the TXT definition asset.";
Repaint();
return;
}
string txtPath = AssetDatabase.GetAssetPath(_txtAsset);
string atlasPath = AssetDatabase.GetAssetPath(_ddsAtlas);
if (string.IsNullOrEmpty(txtPath) || string.IsNullOrEmpty(atlasPath))
{
_status = "Invalid asset references.";
Repaint();
return;
}
try
{
var parsed = ParseTxt(txtPath, _txtIsGB2312);
if (parsed == null)
{
_status = "Failed to parse TXT (null).";
Repaint();
return;
}
if (_exportAsIndividualPngs)
{
ExportSpritesAsPNGs(atlasPath, parsed.SpriteWidth, parsed.SpriteHeight, parsed.Columns, parsed.Rows, parsed.Names);
_status = $"Exported {parsed.Columns * parsed.Rows} PNG sprites.";
}
else
{
ApplySlicing(atlasPath, parsed.SpriteWidth, parsed.SpriteHeight, parsed.Columns, parsed.Rows, parsed.Names);
_status = $"Sliced {_ddsAtlas.name} into {parsed.Columns * parsed.Rows} sprites.";
}
}
catch (Exception ex)
{
Debug.LogError($"[DDSAtlasSlicer] Error: {ex}");
_status = "Error. See Console.";
}
finally
{
Repaint();
}
}
private class TxtDefinition
{
public int SpriteWidth;
public int SpriteHeight;
public int Columns;
public int Rows;
public List<string> Names;
}
private TxtDefinition ParseTxt(string path, bool gb2312)
{
Encoding GetEncoding()
{
#if NET_STANDARD_2_1 || NET_6_0_OR_GREATER
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
#endif
return Encoding.GetEncoding("GB2312");
}
string[] lines;
if (gb2312)
{
var enc = GetEncoding();
lines = File.ReadAllLines(path, enc);
}
else
{
lines = File.ReadAllLines(path, Encoding.UTF8);
}
if (lines.Length < 4)
throw new InvalidDataException("TXT must have at least 4 lines: width, height, columns, rows.");
int ReadInt(string s)
{
if (int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) return v;
if (int.TryParse(s.Trim(), out v)) return v;
throw new InvalidDataException($"Cannot parse integer: '{s}'");
}
int spriteWidth = ReadInt(lines[0]);
int spriteHeight = ReadInt(lines[1]);
int columns = ReadInt(lines[3]);
int rows = ReadInt(lines[2]);
var names = new List<string>();
for (int i = 4; i < lines.Length; i++)
{
string raw = lines[i];
string name = _trimEmptyNames ? raw.Trim() : raw;
if (string.IsNullOrEmpty(name))
{
if (_padIndexIfMissing)
{
names.Add((i - 4).ToString());
}
else
{
names.Add(string.Empty);
}
}
else
{
// Usually the list contains ".dds" suffix; strip it
if (name.EndsWith(".dds", StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - 4);
}
names.Add(name);
}
}
return new TxtDefinition
{
SpriteWidth = spriteWidth,
SpriteHeight = spriteHeight,
Columns = columns,
Rows = rows,
Names = names
};
}
private void ApplySlicing(string atlasAssetPath, int spriteWidth, int spriteHeight, int columns, int rows, List<string> names)
{
var importer = AssetImporter.GetAtPath(atlasAssetPath) as TextureImporter;
if (importer == null)
{
// Fallback path for formats like DDS that don't use TextureImporter for sprite slicing
CreateSpritesAsSubAssets(atlasAssetPath, spriteWidth, spriteHeight, columns, rows, names);
return;
}
// Ensure Readable settings and Multiple sprite mode
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Multiple;
importer.isReadable = true;
importer.mipmapEnabled = false;
importer.alphaIsTransparency = true;
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
if (tex == null)
throw new InvalidOperationException("Failed to load Texture2D from path.");
int atlasWidth = tex.width;
int atlasHeight = tex.height;
if (spriteWidth <= 0 || spriteHeight <= 0)
throw new InvalidDataException("Sprite width/height must be > 0.");
if (columns <= 0 || rows <= 0)
throw new InvalidDataException("Columns/Rows must be > 0.");
int expected = columns * rows;
// Align names count
if (names.Count < expected)
{
for (int i = names.Count; i < expected; i++)
{
names.Add(_padIndexIfMissing ? i.ToString() : string.Empty);
}
}
else if (names.Count > expected)
{
names = names.Take(expected).ToList();
}
var metas = new List<SpriteMetaData>(expected);
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < columns; c++)
{
int index = r * columns + c;
int x = c * spriteWidth;
int yFromTop = r * spriteHeight; // DDS starts from top-left
int yFromBottom = atlasHeight - yFromTop - spriteHeight; // Convert to Unity bottom-left origin
var rect = new Rect(x, yFromBottom, spriteWidth, spriteHeight);
var meta = new SpriteMetaData
{
name = SafeSpriteName(names[index], index),
rect = rect,
alignment = (int)SpriteAlignment.Center,
border = Vector4.zero,
pivot = new Vector2(0.5f, 0.5f)
};
metas.Add(meta);
}
}
if (_paddingPixels > 0)
{
for (int i = 0; i < metas.Count; i++)
metas[i] = ApplyPadding(metas[i], _paddingPixels, atlasWidth, atlasHeight);
}
importer.spritesheet = metas.ToArray();
EditorUtility.SetDirty(importer);
importer.SaveAndReimport();
AssetDatabase.Refresh();
}
private void CreateSpritesAsSubAssets(string atlasAssetPath, int spriteWidth, int spriteHeight, int columns, int rows, List<string> names)
{
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
if (tex == null)
throw new InvalidOperationException($"Failed to load Texture2D from path: {atlasAssetPath}");
int atlasWidth = tex.width;
int atlasHeight = tex.height;
if (spriteWidth <= 0 || spriteHeight <= 0)
throw new InvalidDataException("Sprite width/height must be > 0.");
if (columns <= 0 || rows <= 0)
throw new InvalidDataException("Columns/Rows must be > 0.");
if (columns * spriteWidth > atlasWidth || rows * spriteHeight > atlasHeight)
Debug.LogWarning($"[DDSAtlasSlicer] Grid exceeds atlas bounds: grid={columns}x{rows} cell={spriteWidth}x{spriteHeight} atlas={atlasWidth}x{atlasHeight}");
int expected = columns * rows;
if (names.Count < expected)
{
for (int i = names.Count; i < expected; i++)
names.Add(_padIndexIfMissing ? i.ToString() : string.Empty);
}
else if (names.Count > expected)
{
names = names.Take(expected).ToList();
}
// Clear previous Sprite sub-assets under this texture
var all = AssetDatabase.LoadAllAssetsAtPath(atlasAssetPath);
foreach (var obj in all)
{
if (obj is Sprite)
{
UnityEngine.Object.DestroyImmediate(obj, true);
}
}
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < columns; c++)
{
int index = r * columns + c;
int x = c * spriteWidth;
int yFromTop = r * spriteHeight; // DDS starts from top-left
int yFromBottom = atlasHeight - yFromTop - spriteHeight; // Convert to Unity bottom-left origin
var rect = new Rect(x, yFromBottom, spriteWidth, spriteHeight);
if (_paddingPixels > 0)
{
float nx = Mathf.Max(0, rect.x - _paddingPixels);
float ny = Mathf.Max(0, rect.y - _paddingPixels);
float nw = Mathf.Min(atlasWidth - nx, rect.width + 2 * _paddingPixels);
float nh = Mathf.Min(atlasHeight - ny, rect.height + 2 * _paddingPixels);
rect = new Rect(nx, ny, nw, nh);
}
string sprName = SafeSpriteName(names[index], index);
var sprite = Sprite.Create(tex, rect, new Vector2(0.5f, 0.5f), Mathf.Max(1f, _pixelsPerUnit));
sprite.name = sprName;
AssetDatabase.AddObjectToAsset(sprite, tex);
}
}
EditorUtility.SetDirty(tex);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
private void ExportSpritesAsPNGs(string atlasAssetPath, int spriteWidth, int spriteHeight, int columns, int rows, List<string> names)
{
Texture2D atlasTex = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasAssetPath);
if (atlasTex == null)
throw new InvalidOperationException($"Failed to load Texture2D from path: {atlasAssetPath}");
int atlasWidth = atlasTex.width;
int atlasHeight = atlasTex.height;
if (spriteWidth <= 0 || spriteHeight <= 0)
throw new InvalidDataException("Sprite width/height must be > 0.");
if (columns <= 0 || rows <= 0)
throw new InvalidDataException("Columns/Rows must be > 0.");
int expected = columns * rows;
if (names.Count < expected)
{
for (int i = names.Count; i < expected; i++)
names.Add(_padIndexIfMissing ? i.ToString() : string.Empty);
}
else if (names.Count > expected)
{
names = names.Take(expected).ToList();
}
// Resolve export path
string targetFolderProject = _exportFolder;
if (string.IsNullOrEmpty(targetFolderProject))
{
string atlasDir = Path.GetDirectoryName(atlasAssetPath).Replace('\\', '/');
string atlasName = Path.GetFileNameWithoutExtension(atlasAssetPath);
targetFolderProject = $"{atlasDir}/{atlasName}_multisprite.png";
}
else
{
string atlasName = Path.GetFileNameWithoutExtension(atlasAssetPath);
targetFolderProject = $"{targetFolderProject}/{atlasName}_multisprite.png";
}
string targetFolderAbs = ProjectPathToAbsolute(Path.GetDirectoryName(targetFolderProject));
string fileName = Path.GetFileName(targetFolderProject);
string fileAbs = Path.Combine(targetFolderAbs, fileName);
fileAbs = GetUniqueFilePath(fileAbs);
// Create a readable copy of the texture
Texture2D readableTex = new Texture2D(atlasWidth, atlasHeight, TextureFormat.RGBA32, false);
RenderTexture rt = RenderTexture.GetTemporary(atlasWidth, atlasHeight, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
Graphics.Blit(atlasTex, rt);
RenderTexture.active = rt;
readableTex.ReadPixels(new Rect(0, 0, atlasWidth, atlasHeight), 0, 0);
readableTex.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
// Create PNG from readable copy
var png = ImageConversion.EncodeToPNG(readableTex);
File.WriteAllBytes(fileAbs, png);
// Clean up
UnityEngine.Object.DestroyImmediate(readableTex);
string fileProject = AbsoluteToProjectPath(fileAbs).Replace('\\', '/');
if (!string.IsNullOrEmpty(fileProject))
{
AssetDatabase.ImportAsset(fileProject);
var pngImporter = AssetImporter.GetAtPath(fileProject) as TextureImporter;
if (pngImporter != null)
{
pngImporter.textureType = TextureImporterType.Sprite;
pngImporter.spriteImportMode = SpriteImportMode.Multiple;
pngImporter.mipmapEnabled = false;
pngImporter.alphaIsTransparency = true;
pngImporter.spritePixelsPerUnit = Mathf.Max(1f, _pixelsPerUnit);
// Create sprite metadata
var metas = new List<SpriteMetaData>(expected);
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < columns; c++)
{
int index = r * columns + c;
int x = c * spriteWidth;
int yFromTop = r * spriteHeight; // DDS starts from top-left
int yFromBottom = atlasHeight - yFromTop - spriteHeight; // Convert to Unity bottom-left origin
var rect = new Rect(x, yFromBottom, spriteWidth, spriteHeight);
if (_paddingPixels > 0)
{
float nx = Mathf.Max(0, rect.x - _paddingPixels);
float ny = Mathf.Max(0, rect.y - _paddingPixels);
float nw = Mathf.Min(atlasWidth - nx, rect.width + 2 * _paddingPixels);
float nh = Mathf.Min(atlasHeight - ny, rect.height + 2 * _paddingPixels);
rect = new Rect(nx, ny, nw, nh);
}
var meta = new SpriteMetaData
{
name = SafeSpriteName(names[index], index),
rect = rect,
alignment = (int)SpriteAlignment.Center,
border = Vector4.zero,
pivot = new Vector2(0.5f, 0.5f)
};
metas.Add(meta);
}
}
pngImporter.spritesheet = metas.ToArray();
pngImporter.SaveAndReimport();
}
}
AssetDatabase.Refresh();
}
private static void EnsureFolder(string projectFolderPath)
{
projectFolderPath = projectFolderPath.Replace('\\', '/');
if (string.IsNullOrEmpty(projectFolderPath)) return;
if (!projectFolderPath.StartsWith("Assets"))
throw new InvalidOperationException("Export folder must be inside Assets/.");
if (AssetDatabase.IsValidFolder(projectFolderPath)) return;
string[] parts = projectFolderPath.Split('/');
string current = parts[0]; // "Assets"
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, parts[i]);
}
current = next;
}
}
private static string ProjectPathToAbsolute(string projectPath)
{
if (string.IsNullOrEmpty(projectPath)) return string.Empty;
string pp = projectPath.Replace('\\', '/');
if (!pp.StartsWith("Assets")) return string.Empty;
string data = Application.dataPath.Replace('\\', '/');
return data + pp.Substring("Assets".Length);
}
private static string AbsoluteToProjectPath(string absolutePath)
{
if (string.IsNullOrEmpty(absolutePath)) return string.Empty;
string abs = absolutePath.Replace('\\', '/');
string data = Application.dataPath.Replace('\\', '/');
if (abs.StartsWith(data, StringComparison.OrdinalIgnoreCase))
{
return "Assets" + abs.Substring(data.Length);
}
return string.Empty;
}
private static string GetUniqueFilePath(string absolutePath)
{
string dir = Path.GetDirectoryName(absolutePath);
string name = Path.GetFileNameWithoutExtension(absolutePath);
string ext = Path.GetExtension(absolutePath);
string candidate = absolutePath;
int suffix = 1;
while (File.Exists(candidate))
{
candidate = Path.Combine(dir, $"{name}_{suffix}{ext}");
suffix++;
}
return candidate;
}
private static SpriteMetaData ApplyPadding(SpriteMetaData meta, int pad, int atlasWidth, int atlasHeight)
{
var r = meta.rect;
float nx = Mathf.Max(0, r.x - pad);
float ny = Mathf.Max(0, r.y - pad);
float nw = Mathf.Min(atlasWidth - nx, r.width + 2 * pad);
float nh = Mathf.Min(atlasHeight - ny, r.height + 2 * pad);
meta.rect = new Rect(nx, ny, nw, nh);
return meta;
}
private static string SafeSpriteName(string raw, int index)
{
string name = raw;
if (string.IsNullOrWhiteSpace(name)) name = index.ToString();
// Unity dislikes slashes and some symbols in asset names
char[] invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (char ch in name)
{
if (invalid.Contains(ch) || ch == '/' || ch == '\\' || ch == ':' || ch == '*')
{
sb.Append('_');
}
else
{
sb.Append(ch);
}
}
return sb.ToString();
}
}
#endif