// MIT License - Copyright (c) 2024 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.Sprites
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Serialization;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Utils;
using WallstopStudios.UnityHelpers.Utils;
using Object = UnityEngine.Object;
///
/// ScriptableWizard to batch resize textures by a computed delta using a chosen algorithm.
/// Useful for adjusting imported assets to target pixel density or scale without external tools.
///
///
///
/// How it works: for each selected texture (or those discovered under provided directories), the
/// tool ensures readability, clones the texture, computes a size increment from
/// pixelsPerUnit and the width/height multipliers, resizes via bilinear or point, and
/// writes the PNG back to the original asset path. It refreshes the AssetDatabase between
/// passes for multi-iteration resizing.
///
///
/// Pros: fast iteration inside Unity, supports multiple discovery paths, preserves import
/// settings, and can be run multiple times (numResizes ) for step changes.
///
///
/// Caveats: overwrites files in-place; ensure version control. If textures are non-readable,
/// importer is temporarily toggled which may dirties the asset. Consider backing up.
///
///
///
///
public sealed class TextureResizerWizard : ScriptableWizard
{
public enum ResizeAlgorithm
{
Bilinear,
Point,
}
public List textures = new();
[FormerlySerializedAs("animationSources")]
[Tooltip(
"Drag a folder from Unity here to apply the configuration to all textures under it. No textures are modified if no directories are provided."
)]
public List textureSourcePaths = new();
public int numResizes = 1;
[Tooltip("Resize algorithm to use for scaling.")]
public ResizeAlgorithm scalingResizeAlgorithm = ResizeAlgorithm.Bilinear;
public int pixelsPerUnit = 100;
public float widthMultiplier = 0.54f;
public float heightMultiplier = 0.245f;
[Tooltip("If true, only simulates the operation without writing files.")]
public bool dryRun;
[Tooltip(
"Optional output folder (Unity project relative). If set, resized PNGs are written here instead of overwriting originals."
)]
public DefaultAsset outputFolder;
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Texture Resizer")]
public static void ResizeTextures()
{
_ = DisplayWizard("Texture Resizer", "Resize");
}
internal void OnWizardCreate()
{
textures ??= new List();
textureSourcePaths ??= new List();
// Discover textures from provided folders using a pooled set to avoid duplicates.
using PooledResource> sourcePathsResource = Buffers.HashSet.Get(
out HashSet sourcePaths
);
{
foreach (Object pathObj in textureSourcePaths)
{
string p = AssetDatabase.GetAssetPath(pathObj);
if (!string.IsNullOrEmpty(p))
{
_ = sourcePaths.Add(p);
}
}
if (sourcePaths.Count > 0)
{
foreach (
string guid in AssetDatabase.FindAssets(
"t:texture2D",
sourcePaths.ToArray()
)
)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(path))
{
continue;
}
Texture2D texture = AssetDatabase.LoadAssetAtPath(path);
if (texture != null)
{
textures.Add(texture);
}
}
}
}
// Remove nulls, de-dupe, and order by name without LINQ
using PooledResource> distinctResource =
Buffers.HashSet.Get(out HashSet distinct);
using PooledResource> orderedResource = Buffers.List.Get(
out List ordered
);
for (int i = 0; i < textures.Count; i++)
{
Texture2D t = textures[i];
if (t == null)
{
continue;
}
if (distinct.Add(t))
{
ordered.Add(t);
}
}
ordered.Sort(static (a, b) => string.Compare(a.name, b.name, StringComparison.Ordinal));
textures.Clear();
textures.AddRange(ordered);
if (textures.Count <= 0 || numResizes <= 0)
{
return;
}
int processed = 0;
int resized = 0;
int skippedWrongExt = 0;
int skippedZeroDelta = 0;
int errors = 0;
bool anyChanges = false;
string outputDirAssetPath =
outputFolder != null ? AssetDatabase.GetAssetPath(outputFolder) : null;
// Batch edits for performance
using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false))
{
for (int idx = 0; idx < textures.Count; ++idx)
{
Texture2D texture = textures[idx];
string assetPath = AssetDatabase.GetAssetPath(texture);
if (string.IsNullOrEmpty(assetPath))
{
continue;
}
// Only process PNGs by default to avoid corrupting non-PNG assets.
if (
!string.Equals(
Path.GetExtension(assetPath),
".png",
StringComparison.OrdinalIgnoreCase
)
)
{
++skippedWrongExt;
continue;
}
// Progress/cancel UI
bool cancel = Utils.EditorUi.CancelableProgress(
"Resizing Textures",
$"Processing {texture.name} ({idx + 1}/{textures.Count})",
(float)(idx + 1) / textures.Count
);
if (cancel)
{
break;
}
++processed;
TextureImporter tImporter =
AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (tImporter == null)
{
continue;
}
bool originalReadable = tImporter.isReadable;
Texture2D working = texture;
try
{
if (!originalReadable)
{
// Temporarily exit batch scope to guarantee reimport applies immediately.
// We need to pause the batch since SaveAndReimport requires the AssetDatabase
// to not be in editing mode to process the import.
// Use a nested batch scope to properly track the exit and re-entry.
using (AssetDatabaseBatchHelper.PauseBatch())
{
tImporter.isReadable = true;
tImporter.SaveAndReimport();
// Reload to ensure readability state reflects on the instance
working = AssetDatabase.LoadAssetAtPath(assetPath);
}
}
int origW = working.width;
int origH = working.height;
// Compute final target size by simulating numResizes passes.
(int targetW, int targetH) = ComputeFinalSize(
origW,
origH,
numResizes,
pixelsPerUnit,
widthMultiplier,
heightMultiplier
);
// Clamp to sane limits
targetW = Mathf.Clamp(targetW, 1, 16384);
targetH = Mathf.Clamp(targetH, 1, 16384);
if (targetW == working.width && targetH == working.height)
{
++skippedZeroDelta;
continue;
}
// If writing to separate folder, avoid mutating the original asset in memory.
Texture2D resizeSource = working;
Texture2D scratch = null;
bool useScratch = !string.IsNullOrEmpty(outputDirAssetPath);
if (useScratch)
{
// Create a scratch texture and copy pixels; avoids modifying the original in memory.
scratch = new Texture2D(
working.width,
working.height,
TextureFormat.RGBA32,
false
);
scratch.SetPixels(working.GetPixels());
scratch.Apply(false);
resizeSource = scratch;
}
try
{
switch (scalingResizeAlgorithm)
{
case ResizeAlgorithm.Bilinear:
TextureScale.Bilinear(resizeSource, targetW, targetH);
break;
case ResizeAlgorithm.Point:
TextureScale.Point(resizeSource, targetW, targetH);
break;
default:
throw new InvalidEnumArgumentException(
nameof(scalingResizeAlgorithm),
(int)scalingResizeAlgorithm,
typeof(ResizeAlgorithm)
);
}
if (dryRun)
{
this.Log(
$"[DryRun] Would resize {texture.name} to [{targetW}x{targetH}]"
);
++resized; // counts as a planned resize
continue;
}
// Encode and atomically write the PNG
byte[] bytes = resizeSource.EncodeToPNG();
string finalAssetPath = assetPath;
if (!string.IsNullOrEmpty(outputDirAssetPath))
{
string fileName = Path.GetFileName(assetPath);
finalAssetPath = Path.Combine(outputDirAssetPath, fileName)
.SanitizePath();
EnsureDirectory(finalAssetPath);
}
string fullDest = ToFullPath(finalAssetPath);
string tempPath = fullDest + ".tmp";
File.WriteAllBytes(tempPath, bytes);
if (File.Exists(fullDest))
{
string backupPath = fullDest + ".bak";
File.Replace(tempPath, fullDest, backupPath, true);
// Best-effort cleanup of backup to avoid clutter in VCS; keep if replace failed.
try
{
File.Delete(backupPath);
}
catch
{ /* ignore */
}
}
else
{
// New file write
File.Move(tempPath, fullDest);
}
anyChanges = true;
++resized;
this.Log(
$"Resized {texture.name} from [{origW}x{origH}] to [{targetW}x{targetH}]"
);
}
finally
{
if (scratch != null)
{
DestroyImmediate(scratch);
}
}
}
catch (Exception e)
{
++errors;
this.LogError($"Failed to resize {texture.name}.", e);
}
finally
{
// Restore importer readability to original state
if (tImporter.isReadable != originalReadable)
{
// Exit batch temporarily to restore importer reliably.
// Use PauseBatch to properly track the exit and re-entry.
using (AssetDatabaseBatchHelper.PauseBatch())
{
try
{
tImporter.isReadable = originalReadable;
tImporter.SaveAndReimport();
}
catch
{ /* ignore restore errors */
}
}
}
}
}
}
Utils.EditorUi.ClearProgress();
if (anyChanges)
{
AssetDatabase.Refresh();
}
this.Log(
$"Summary: processed={processed}, resized={(dryRun ? "planned:" : string.Empty)}{resized}, skippedExt={skippedWrongExt}, skippedNoChange={skippedZeroDelta}, errors={errors}"
);
}
private static (int width, int height) ComputeFinalSize(
int startWidth,
int startHeight,
int passes,
int pixelsPerUnit,
float widthMultiplier,
float heightMultiplier
)
{
int w = startWidth;
int h = startHeight;
for (int i = 0; i < passes; ++i)
{
int extraWidth = (int)Math.Round(w / (pixelsPerUnit * widthMultiplier));
int extraHeight = (int)Math.Round(h / (pixelsPerUnit * heightMultiplier));
// If both are zero, further passes won’t change the size; break early.
if (extraWidth == 0 && extraHeight == 0)
{
break;
}
w += extraWidth;
h += extraHeight;
}
return (w, h);
}
private static string ToFullPath(string assetPath)
{
// Convert "Assets/..." to full system path
string projectRoot = Application.dataPath.Substring(
0,
Application.dataPath.Length - "Assets".Length
);
return Path.Combine(projectRoot, assetPath).SanitizePath();
}
private static void EnsureDirectory(string assetPath)
{
string dirAsset = Path.GetDirectoryName(assetPath)?.SanitizePath();
if (string.IsNullOrEmpty(dirAsset))
{
return;
}
// Create physical directory if missing
string fullDir = ToFullPath(dirAsset);
if (!Directory.Exists(fullDir))
{
_ = Directory.CreateDirectory(fullDir);
}
// Ensure Unity knows about folders
string[] parts = dirAsset.Split('/');
string cur = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = cur + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(cur, parts[i]);
}
cur = next;
}
}
}
#endif
}