reduced coloring objects
This commit is contained in:
8
Assets/Darkmatter/Code/Features/ImageCombiner.meta
Normal file
8
Assets/Darkmatter/Code/Features/ImageCombiner.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 000c0b9528ead0b43af7cdc19fb38f0e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 932d925aaafb08242b1fd65d3860ef40
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Features.ImageCombiner.Editor",
|
||||
"rootNamespace": "Darkmatter.Features.ImageCombiner.Editor",
|
||||
"references": [
|
||||
"GUID:bcae41f3415403d4da1f7aafb6d9a728"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cba93615156299e4482e6b41e7db0199
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,633 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.ImageCombiner.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for <see cref="UIImageCombiner"/>. Draws the configuration (save folder,
|
||||
/// the two "after combining" checkboxes, resolution options) and performs the actual flatten:
|
||||
/// it composites every child Image into one texture that matches the children's combined
|
||||
/// bounds / position / aspect ratio, writes it as a PNG and (optionally) assigns it back.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(UIImageCombiner))]
|
||||
public sealed class UIImageCombinerEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty _saveFolder;
|
||||
private SerializedProperty _fileName;
|
||||
private SerializedProperty _destroyChildren;
|
||||
private SerializedProperty _disableChildren;
|
||||
private SerializedProperty _recurse;
|
||||
private SerializedProperty _includeInactive;
|
||||
private SerializedProperty _pixelsPerUnit;
|
||||
private SerializedProperty _maxDimension;
|
||||
private SerializedProperty _trimTransparent;
|
||||
private SerializedProperty _alphaThreshold;
|
||||
private SerializedProperty _assignToSelf;
|
||||
|
||||
/// <summary>Relative aspect-ratio difference above which a stretched child is reported.</summary>
|
||||
private const float AspectMismatchTolerance = 0.01f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_saveFolder = serializedObject.FindProperty("_saveFolder");
|
||||
_fileName = serializedObject.FindProperty("_fileName");
|
||||
_destroyChildren = serializedObject.FindProperty("_destroyChildrenAfterCombine");
|
||||
_disableChildren = serializedObject.FindProperty("_disableChildrenAfterCombine");
|
||||
_recurse = serializedObject.FindProperty("_recurseIntoChildren");
|
||||
_includeInactive = serializedObject.FindProperty("_includeInactiveChildren");
|
||||
_pixelsPerUnit = serializedObject.FindProperty("_pixelsPerUnit");
|
||||
_maxDimension = serializedObject.FindProperty("_maxDimension");
|
||||
_trimTransparent = serializedObject.FindProperty("_trimTransparent");
|
||||
_alphaThreshold = serializedObject.FindProperty("_alphaThreshold");
|
||||
_assignToSelf = serializedObject.FindProperty("_assignCombinedToSelf");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
var combiner = (UIImageCombiner)target;
|
||||
|
||||
EditorGUILayout.LabelField("Output location", EditorStyles.boldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.PropertyField(_saveFolder, new GUIContent("Save Folder"));
|
||||
if (GUILayout.Button("Browse", GUILayout.Width(64)))
|
||||
BrowseForFolder(_saveFolder);
|
||||
}
|
||||
EditorGUILayout.PropertyField(_fileName, new GUIContent("File Name (opt.)"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("After combining", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_destroyChildren, new GUIContent("Destroy Children"));
|
||||
using (new EditorGUI.DisabledScope(_destroyChildren.boolValue))
|
||||
EditorGUILayout.PropertyField(_disableChildren, new GUIContent("Disable Children"));
|
||||
if (_destroyChildren.boolValue && _disableChildren.boolValue)
|
||||
EditorGUILayout.HelpBox("Both ticked: children will be destroyed (destroy wins).", MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("What to combine", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_recurse, new GUIContent("Recurse Into Children"));
|
||||
EditorGUILayout.PropertyField(_includeInactive, new GUIContent("Include Inactive"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Resolution", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_pixelsPerUnit, new GUIContent("Pixels Per Unit (0 = auto)"));
|
||||
EditorGUILayout.PropertyField(_maxDimension, new GUIContent("Max Dimension"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Trimming", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_trimTransparent, new GUIContent("Trim Transparent"));
|
||||
using (new EditorGUI.DisabledScope(!_trimTransparent.boolValue))
|
||||
EditorGUILayout.PropertyField(_alphaThreshold, new GUIContent("Alpha Threshold"));
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.LabelField("Assignment", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_assignToSelf, new GUIContent("Assign Combined To Self"));
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
using (new EditorGUI.DisabledScope(Application.isPlaying))
|
||||
{
|
||||
if (GUILayout.Button("Combine Images", GUILayout.Height(32)))
|
||||
Combine(combiner);
|
||||
}
|
||||
if (Application.isPlaying)
|
||||
EditorGUILayout.HelpBox("Exit play mode to combine images.", MessageType.Warning);
|
||||
}
|
||||
|
||||
private static void BrowseForFolder(SerializedProperty folderProp)
|
||||
{
|
||||
string start = folderProp.stringValue;
|
||||
if (string.IsNullOrEmpty(start)) start = "Assets";
|
||||
string abs = EditorUtility.OpenFolderPanel("Choose save folder", start, "");
|
||||
if (string.IsNullOrEmpty(abs)) return;
|
||||
|
||||
// Convert to a project-relative "Assets/..." path when the chosen folder is inside the project.
|
||||
string dataPath = Application.dataPath.Replace('\\', '/');
|
||||
abs = abs.Replace('\\', '/');
|
||||
if (abs == dataPath)
|
||||
folderProp.stringValue = "Assets";
|
||||
else if (abs.StartsWith(dataPath + "/"))
|
||||
folderProp.stringValue = "Assets" + abs.Substring(dataPath.Length);
|
||||
else
|
||||
folderProp.stringValue = abs; // outside the project – keep the absolute path
|
||||
folderProp.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ combine
|
||||
|
||||
private static void Combine(UIImageCombiner combiner)
|
||||
{
|
||||
var self = (RectTransform)combiner.transform;
|
||||
|
||||
var images = CollectImages(combiner, self);
|
||||
if (images.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("UI Image Combiner",
|
||||
"No child Image components found to combine.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Combined world bounds from every child's rect corners.
|
||||
if (!TryComputeBounds(images, out Vector2 boundsMin, out Vector2 boundsMax))
|
||||
{
|
||||
EditorUtility.DisplayDialog("UI Image Combiner",
|
||||
"The combined bounds are empty (zero size).", "OK");
|
||||
return;
|
||||
}
|
||||
Vector2 boundsSize = boundsMax - boundsMin;
|
||||
|
||||
// 2) Output resolution.
|
||||
float ppu = combiner.PixelsPerUnit > 0f
|
||||
? combiner.PixelsPerUnit
|
||||
: AutoPixelsPerUnit(images);
|
||||
int width = Mathf.Max(1, Mathf.RoundToInt(boundsSize.x * ppu));
|
||||
int height = Mathf.Max(1, Mathf.RoundToInt(boundsSize.y * ppu));
|
||||
int maxDim = combiner.MaxDimension;
|
||||
int longest = Mathf.Max(width, height);
|
||||
if (longest > maxDim)
|
||||
{
|
||||
float s = maxDim / (float)longest;
|
||||
width = Mathf.Max(1, Mathf.RoundToInt(width * s));
|
||||
height = Mathf.Max(1, Mathf.RoundToInt(height * s));
|
||||
}
|
||||
|
||||
bool rotationWarned = false;
|
||||
var pixelCache = new Dictionary<Texture, (Color32[] px, int w, int h)>();
|
||||
var output = new Color[width * height]; // straight alpha, (0,0) = bottom-left
|
||||
float worldPerPxX = boundsSize.x / width;
|
||||
float worldPerPxY = boundsSize.y / height;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < images.Count; i++)
|
||||
{
|
||||
Image img = images[i];
|
||||
EditorUtility.DisplayProgressBar("UI Image Combiner",
|
||||
$"Compositing {img.name} ({i + 1}/{images.Count})", (i + 1f) / images.Count);
|
||||
|
||||
if (!TryGetAxisAlignedRect(img, out Vector2 imgMin, out Vector2 imgMax, out bool rotated))
|
||||
continue;
|
||||
if (rotated && !rotationWarned)
|
||||
{
|
||||
Debug.LogWarning("[UIImageCombiner] One or more images are rotated/skewed; " +
|
||||
"they are composited using their axis-aligned bounds and may " +
|
||||
"look distorted.", img);
|
||||
rotationWarned = true;
|
||||
}
|
||||
|
||||
Color tint = img.color;
|
||||
Sprite sprite = img.sprite;
|
||||
|
||||
// Rect the sprite is actually drawn into (accounts for Preserve Aspect).
|
||||
Vector2 drawMin = imgMin;
|
||||
Vector2 drawSize = imgMax - imgMin;
|
||||
if (sprite != null && drawSize.x > 0f && drawSize.y > 0f)
|
||||
{
|
||||
if (img.preserveAspect)
|
||||
{
|
||||
FitPreserveAspect(sprite, ref drawMin, ref drawSize);
|
||||
}
|
||||
else if (sprite.rect.height > 0f)
|
||||
{
|
||||
// Preserve Aspect is off, so the whole sprite is stretched to fill the
|
||||
// RectTransform. If their aspect ratios differ, the content (and any
|
||||
// transparent padding) gets squashed/stretched — warn and name the child.
|
||||
float spriteAspect = sprite.rect.width / sprite.rect.height;
|
||||
float rectAspect = drawSize.x / drawSize.y;
|
||||
if (spriteAspect > 0f &&
|
||||
Mathf.Abs(rectAspect - spriteAspect) > spriteAspect * AspectMismatchTolerance)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[UIImageCombiner] \"{img.name}\" will be stretched: sprite is " +
|
||||
$"{sprite.rect.width:0}x{sprite.rect.height:0} (aspect {spriteAspect:0.###}) " +
|
||||
$"but its RectTransform aspect is {rectAspect:0.###} and Preserve Aspect is " +
|
||||
"off. Enable \"Preserve Aspect\" on the Image, or match the RectTransform to " +
|
||||
"the sprite's aspect ratio, to avoid distortion.", img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A trimmed / tight-mesh sprite reports its FULL frame as sprite.rect but only
|
||||
// draws the opaque texels described by GetOuterUV. A UI Image maps that outer UV
|
||||
// onto the padding-inset sub-rect of its RectTransform and leaves the transparent
|
||||
// border empty — it does NOT stretch the artwork to fill the frame. Shrink the draw
|
||||
// rect to that same sub-rect so each child keeps its real size/position and only its
|
||||
// visible pixels are composited (the transparent background is dropped).
|
||||
if (sprite != null && drawSize.x > 0f && drawSize.y > 0f)
|
||||
InsetBySpritePadding(sprite, ref drawMin, ref drawSize);
|
||||
|
||||
if (drawSize.x <= 0f || drawSize.y <= 0f)
|
||||
continue;
|
||||
|
||||
(Color32[] px, int tw, int th) src = default;
|
||||
Rect texRect = default;
|
||||
if (sprite != null)
|
||||
{
|
||||
src = GetPixels(sprite.texture, pixelCache);
|
||||
// Use the sprite's OUTER UV rect (the full quad, transparent padding
|
||||
// included) — the same region a UI Image maps onto its RectTransform when
|
||||
// "Use Sprite Mesh" is off. sprite.textureRect is the tight, transparent-
|
||||
// trimmed rect (Sprite Mesh Type = Tight), which would map only the drawn
|
||||
// pixels across the whole rect and stretch the artwork to fill it.
|
||||
Vector4 uv = UnityEngine.Sprites.DataUtility.GetOuterUV(sprite);
|
||||
texRect = new Rect(uv.x * src.tw, uv.y * src.th,
|
||||
(uv.z - uv.x) * src.tw, (uv.w - uv.y) * src.th);
|
||||
}
|
||||
|
||||
// Pixel range this image can touch.
|
||||
int px0 = Mathf.Clamp(Mathf.FloorToInt((drawMin.x - boundsMin.x) / worldPerPxX), 0, width);
|
||||
int px1 = Mathf.Clamp(Mathf.CeilToInt((drawMin.x + drawSize.x - boundsMin.x) / worldPerPxX), 0, width);
|
||||
int py0 = Mathf.Clamp(Mathf.FloorToInt((drawMin.y - boundsMin.y) / worldPerPxY), 0, height);
|
||||
int py1 = Mathf.Clamp(Mathf.CeilToInt((drawMin.y + drawSize.y - boundsMin.y) / worldPerPxY), 0, height);
|
||||
|
||||
for (int y = py0; y < py1; y++)
|
||||
{
|
||||
float worldY = boundsMin.y + (y + 0.5f) * worldPerPxY;
|
||||
float v = (worldY - drawMin.y) / drawSize.y;
|
||||
if (v < 0f || v > 1f) continue;
|
||||
int row = y * width;
|
||||
|
||||
for (int x = px0; x < px1; x++)
|
||||
{
|
||||
float worldX = boundsMin.x + (x + 0.5f) * worldPerPxX;
|
||||
float u = (worldX - drawMin.x) / drawSize.x;
|
||||
if (u < 0f || u > 1f) continue;
|
||||
|
||||
Color srcColor;
|
||||
if (sprite != null)
|
||||
{
|
||||
float texPx = texRect.x + u * texRect.width;
|
||||
float texPy = texRect.y + v * texRect.height;
|
||||
srcColor = SampleBilinear(src.px, src.tw, src.th, texPx, texPy) * tint;
|
||||
}
|
||||
else
|
||||
{
|
||||
srcColor = tint; // Image with no sprite = solid colour quad.
|
||||
}
|
||||
|
||||
if (srcColor.a <= 0f) continue;
|
||||
output[row + x] = Over(srcColor, output[row + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
}
|
||||
|
||||
// 2b) Alpha-trim: crop away transparent margins and shrink the bounds to the real content
|
||||
// so the output PNG (and the resized parent) tightly wrap the visible pixels.
|
||||
if (combiner.TrimTransparent)
|
||||
{
|
||||
if (TryTrim(output, width, height, combiner.AlphaThreshold,
|
||||
out Color[] trimmed, out int tw, out int th,
|
||||
out int offsetX, out int offsetY))
|
||||
{
|
||||
boundsMin = new Vector2(boundsMin.x + offsetX * worldPerPxX,
|
||||
boundsMin.y + offsetY * worldPerPxY);
|
||||
boundsMax = new Vector2(boundsMin.x + tw * worldPerPxX,
|
||||
boundsMin.y + th * worldPerPxY);
|
||||
boundsSize = boundsMax - boundsMin;
|
||||
output = trimmed;
|
||||
width = tw;
|
||||
height = th;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[UIImageCombiner] The combined image is fully transparent; " +
|
||||
"nothing to trim.", combiner);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Build the PNG.
|
||||
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
|
||||
texture.SetPixels(output);
|
||||
texture.Apply();
|
||||
byte[] png = texture.EncodeToPNG();
|
||||
Object.DestroyImmediate(texture);
|
||||
|
||||
// 4) Save it.
|
||||
string relFolder = combiner.SaveFolder.Replace('\\', '/').TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(relFolder)) relFolder = "Assets";
|
||||
string fileName = SanitizeFileName(combiner.FileName);
|
||||
if (!fileName.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase))
|
||||
fileName += ".png";
|
||||
|
||||
string projectRoot = Directory.GetParent(Application.dataPath).FullName;
|
||||
string absFolder = Path.IsPathRooted(relFolder)
|
||||
? relFolder
|
||||
: Path.Combine(projectRoot, relFolder);
|
||||
Directory.CreateDirectory(absFolder);
|
||||
string absFile = Path.Combine(absFolder, fileName);
|
||||
File.WriteAllBytes(absFile, png);
|
||||
|
||||
// 5) If it is inside the project, import as a Sprite and assign it back.
|
||||
string assetPath = ToAssetPath(absFile, projectRoot);
|
||||
Sprite combinedSprite = null;
|
||||
if (assetPath != null)
|
||||
{
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer)
|
||||
{
|
||||
importer.textureType = TextureImporterType.Sprite;
|
||||
importer.spriteImportMode = SpriteImportMode.Single;
|
||||
importer.alphaIsTransparency = true;
|
||||
importer.mipmapEnabled = false;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
combinedSprite = AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
|
||||
}
|
||||
|
||||
// 6) Assign to self + resize to the combined bounds.
|
||||
Undo.RegisterFullObjectHierarchyUndo(self.gameObject, "Combine Images");
|
||||
if (combiner.AssignCombinedToSelf && combinedSprite != null)
|
||||
{
|
||||
var image = self.GetComponent<Image>();
|
||||
if (image == null) image = Undo.AddComponent<Image>(self.gameObject);
|
||||
image.sprite = combinedSprite;
|
||||
image.type = Image.Type.Simple;
|
||||
image.preserveAspect = false;
|
||||
image.color = Color.white;
|
||||
image.enabled = true;
|
||||
|
||||
Vector3 lossy = self.lossyScale;
|
||||
self.anchorMin = self.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
self.pivot = new Vector2(0.5f, 0.5f);
|
||||
self.sizeDelta = new Vector2(
|
||||
boundsSize.x / (Mathf.Approximately(lossy.x, 0f) ? 1f : lossy.x),
|
||||
boundsSize.y / (Mathf.Approximately(lossy.y, 0f) ? 1f : lossy.y));
|
||||
Vector2 center = (boundsMin + boundsMax) * 0.5f;
|
||||
self.position = new Vector3(center.x, center.y, self.position.z);
|
||||
EditorUtility.SetDirty(image);
|
||||
}
|
||||
|
||||
// 7) Destroy or disable the combined children.
|
||||
var childObjects = images.Select(im => im.gameObject).Distinct().ToList();
|
||||
if (combiner.DestroyChildrenAfterCombine)
|
||||
{
|
||||
foreach (var go in childObjects)
|
||||
if (go != null && go != self.gameObject)
|
||||
Undo.DestroyObjectImmediate(go);
|
||||
}
|
||||
else if (combiner.DisableChildrenAfterCombine)
|
||||
{
|
||||
foreach (var go in childObjects)
|
||||
if (go != null && go != self.gameObject)
|
||||
go.SetActive(false);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(combiner);
|
||||
if (!Application.isPlaying)
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(combiner.gameObject.scene);
|
||||
|
||||
if (combinedSprite != null)
|
||||
{
|
||||
EditorGUIUtility.PingObject(combinedSprite);
|
||||
Selection.activeObject = combinedSprite;
|
||||
}
|
||||
Debug.Log($"[UIImageCombiner] Combined {images.Count} image(s) into {width}x{height} " +
|
||||
$"PNG at \"{(assetPath ?? absFile)}\".", combiner);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static List<Image> CollectImages(UIImageCombiner combiner, RectTransform self)
|
||||
{
|
||||
var result = new List<Image>();
|
||||
if (combiner.RecurseIntoChildren)
|
||||
{
|
||||
// GetComponentsInChildren returns parent-first, depth-first (== UI draw order).
|
||||
foreach (var img in self.GetComponentsInChildren<Image>(combiner.IncludeInactiveChildren))
|
||||
{
|
||||
if (img.gameObject == self.gameObject) continue;
|
||||
if (!img.enabled) continue;
|
||||
result.Add(img);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int c = 0; c < self.childCount; c++)
|
||||
{
|
||||
var child = self.GetChild(c);
|
||||
if (!combiner.IncludeInactiveChildren && !child.gameObject.activeInHierarchy) continue;
|
||||
var img = child.GetComponent<Image>();
|
||||
if (img != null && img.enabled) result.Add(img);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryComputeBounds(List<Image> images, out Vector2 min, out Vector2 max)
|
||||
{
|
||||
min = new Vector2(float.MaxValue, float.MaxValue);
|
||||
max = new Vector2(float.MinValue, float.MinValue);
|
||||
var corners = new Vector3[4];
|
||||
bool any = false;
|
||||
foreach (var img in images)
|
||||
{
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
min.x = Mathf.Min(min.x, corners[k].x);
|
||||
min.y = Mathf.Min(min.y, corners[k].y);
|
||||
max.x = Mathf.Max(max.x, corners[k].x);
|
||||
max.y = Mathf.Max(max.y, corners[k].y);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
return any && max.x > min.x && max.y > min.y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the tight bounding box of pixels whose alpha exceeds <paramref name="threshold"/> and
|
||||
/// returns a cropped copy plus the bottom-left offset (in pixels) of that box within the source.
|
||||
/// </summary>
|
||||
private static bool TryTrim(Color[] src, int width, int height, float threshold,
|
||||
out Color[] cropped, out int newW, out int newH, out int offsetX, out int offsetY)
|
||||
{
|
||||
int minX = width, minY = height, maxX = -1, maxY = -1;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
int row = y * width;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
if (src[row + x].a > threshold)
|
||||
{
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxX < minX || maxY < minY)
|
||||
{
|
||||
cropped = null;
|
||||
newW = newH = offsetX = offsetY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
newW = maxX - minX + 1;
|
||||
newH = maxY - minY + 1;
|
||||
offsetX = minX;
|
||||
offsetY = minY;
|
||||
cropped = new Color[newW * newH];
|
||||
for (int y = 0; y < newH; y++)
|
||||
{
|
||||
int srcRow = (minY + y) * width + minX;
|
||||
int dstRow = y * newW;
|
||||
for (int x = 0; x < newW; x++)
|
||||
cropped[dstRow + x] = src[srcRow + x];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Axis-aligned world rect of an image, plus whether it looked rotated/skewed.</summary>
|
||||
private static bool TryGetAxisAlignedRect(Image img, out Vector2 min, out Vector2 max, out bool rotated)
|
||||
{
|
||||
var corners = new Vector3[4]; // 0=BL 1=TL 2=TR 3=BR
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
min = new Vector2(
|
||||
Mathf.Min(Mathf.Min(corners[0].x, corners[1].x), Mathf.Min(corners[2].x, corners[3].x)),
|
||||
Mathf.Min(Mathf.Min(corners[0].y, corners[1].y), Mathf.Min(corners[2].y, corners[3].y)));
|
||||
max = new Vector2(
|
||||
Mathf.Max(Mathf.Max(corners[0].x, corners[1].x), Mathf.Max(corners[2].x, corners[3].x)),
|
||||
Mathf.Max(Mathf.Max(corners[0].y, corners[1].y), Mathf.Max(corners[2].y, corners[3].y)));
|
||||
|
||||
// Rotated if bottom edge is not horizontal or left edge is not vertical.
|
||||
const float eps = 1e-3f;
|
||||
rotated = Mathf.Abs(corners[0].y - corners[3].y) > eps ||
|
||||
Mathf.Abs(corners[0].x - corners[1].x) > eps;
|
||||
return (max.x - min.x) > 0f && (max.y - min.y) > 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shrinks a draw rect from the sprite's full frame (<see cref="Sprite.rect"/>) to the tight
|
||||
/// sub-rect its opaque texels actually occupy. Trimmed / tight-mesh sprites report the full
|
||||
/// frame as their rect but only render the region returned by GetOuterUV; a UI Image maps that
|
||||
/// region onto the padding-inset part of the RectTransform and leaves the border transparent.
|
||||
/// Applying the same inset here keeps the artwork at its true size/position (no stretching) and
|
||||
/// means only the visible pixels — not the transparent background — get composited.
|
||||
/// </summary>
|
||||
private static void InsetBySpritePadding(Sprite sprite, ref Vector2 min, ref Vector2 size)
|
||||
{
|
||||
float rw = sprite.rect.width, rh = sprite.rect.height;
|
||||
if (rw <= 0f || rh <= 0f) return;
|
||||
// GetPadding returns the empty border (left, bottom, right, top) in pixels of sprite.rect.
|
||||
Vector4 pad = UnityEngine.Sprites.DataUtility.GetPadding(sprite);
|
||||
if (pad == Vector4.zero) return;
|
||||
min = new Vector2(min.x + (pad.x / rw) * size.x, min.y + (pad.y / rh) * size.y);
|
||||
size = new Vector2(size.x * (1f - (pad.x + pad.z) / rw),
|
||||
size.y * (1f - (pad.y + pad.w) / rh));
|
||||
}
|
||||
|
||||
private static void FitPreserveAspect(Sprite sprite, ref Vector2 min, ref Vector2 size)
|
||||
{
|
||||
float spriteAspect = sprite.rect.width / sprite.rect.height;
|
||||
float rectAspect = size.x / size.y;
|
||||
Vector2 drawn;
|
||||
if (rectAspect > spriteAspect) // rect wider than sprite -> fit height
|
||||
drawn = new Vector2(size.y * spriteAspect, size.y);
|
||||
else
|
||||
drawn = new Vector2(size.x, size.x / spriteAspect);
|
||||
min += (size - drawn) * 0.5f;
|
||||
size = drawn;
|
||||
}
|
||||
|
||||
private static float AutoPixelsPerUnit(List<Image> images)
|
||||
{
|
||||
float ppu = 0f;
|
||||
var corners = new Vector3[4];
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img.sprite == null) continue;
|
||||
((RectTransform)img.transform).GetWorldCorners(corners);
|
||||
float worldW = Vector2.Distance(corners[0], corners[3]);
|
||||
float worldH = Vector2.Distance(corners[0], corners[1]);
|
||||
if (worldW > 0f) ppu = Mathf.Max(ppu, img.sprite.rect.width / worldW);
|
||||
if (worldH > 0f) ppu = Mathf.Max(ppu, img.sprite.rect.height / worldH);
|
||||
}
|
||||
return ppu > 0f ? ppu : 100f; // fallback for all-solid-colour cases
|
||||
}
|
||||
|
||||
/// <summary>Reads a texture's pixels via a Blit (works even when the texture is not marked readable).</summary>
|
||||
private static (Color32[] px, int w, int h) GetPixels(
|
||||
Texture tex, Dictionary<Texture, (Color32[], int, int)> cache)
|
||||
{
|
||||
if (tex == null) return (new Color32[] { new Color32(255, 255, 255, 255) }, 1, 1);
|
||||
if (cache.TryGetValue(tex, out var cached)) return cached;
|
||||
|
||||
int w = tex.width, h = tex.height;
|
||||
RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32);
|
||||
RenderTexture prev = RenderTexture.active;
|
||||
Graphics.Blit(tex, rt);
|
||||
RenderTexture.active = rt;
|
||||
var tmp = new Texture2D(w, h, TextureFormat.RGBA32, false);
|
||||
tmp.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
tmp.Apply();
|
||||
RenderTexture.active = prev;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
Color32[] px = tmp.GetPixels32();
|
||||
Object.DestroyImmediate(tmp);
|
||||
|
||||
var entry = (px, w, h);
|
||||
cache[tex] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static Color SampleBilinear(Color32[] px, int w, int h, float x, float y)
|
||||
{
|
||||
x = Mathf.Clamp(x - 0.5f, 0f, w - 1f);
|
||||
y = Mathf.Clamp(y - 0.5f, 0f, h - 1f);
|
||||
int x0 = (int)x, y0 = (int)y;
|
||||
int x1 = Mathf.Min(x0 + 1, w - 1);
|
||||
int y1 = Mathf.Min(y0 + 1, h - 1);
|
||||
float fx = x - x0, fy = y - y0;
|
||||
|
||||
Color c00 = px[y0 * w + x0];
|
||||
Color c10 = px[y0 * w + x1];
|
||||
Color c01 = px[y1 * w + x0];
|
||||
Color c11 = px[y1 * w + x1];
|
||||
return Color.Lerp(Color.Lerp(c00, c10, fx), Color.Lerp(c01, c11, fx), fy);
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha "source over destination".</summary>
|
||||
private static Color Over(Color src, Color dst)
|
||||
{
|
||||
float outA = src.a + dst.a * (1f - src.a);
|
||||
if (outA <= 0f) return new Color(0f, 0f, 0f, 0f);
|
||||
float inv = 1f / outA;
|
||||
return new Color(
|
||||
(src.r * src.a + dst.r * dst.a * (1f - src.a)) * inv,
|
||||
(src.g * src.a + dst.g * dst.a * (1f - src.a)) * inv,
|
||||
(src.b * src.a + dst.b * dst.a * (1f - src.a)) * inv,
|
||||
outA);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "CombinedImage";
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
name = name.Replace(c, '_');
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string ToAssetPath(string absFile, string projectRoot)
|
||||
{
|
||||
string full = Path.GetFullPath(absFile).Replace('\\', '/');
|
||||
string root = Path.GetFullPath(projectRoot).Replace('\\', '/');
|
||||
if (!full.StartsWith(root + "/")) return null;
|
||||
string rel = full.Substring(root.Length + 1);
|
||||
return rel.StartsWith("Assets/") || rel == "Assets" ? rel : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ff6d3964378b694ba9c7ba67b3af897
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Features.ImageCombiner",
|
||||
"rootNamespace": "Darkmatter.Features.ImageCombiner",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcae41f3415403d4da1f7aafb6d9a728
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,85 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Darkmatter.Features.ImageCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// Attach to a parent UI object that holds several child <see cref="Image"/> objects
|
||||
/// (each with a transparent background). The custom inspector's "Combine Images" button
|
||||
/// flattens every child Image into a single PNG that has the exact combined bounds,
|
||||
/// position and aspect ratio of the children, saves it to the folder you specify, and
|
||||
/// assigns it back to this object as one Image.
|
||||
///
|
||||
/// All of the actual combining/saving lives in the editor-only inspector
|
||||
/// (Editor/UIImageCombinerEditor.cs); this component only stores the configuration so it
|
||||
/// still compiles into player builds.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
[AddComponentMenu("Darkmatter/UI/UI Image Combiner")]
|
||||
public sealed class UIImageCombiner : MonoBehaviour
|
||||
{
|
||||
[Header("Output location")]
|
||||
[Tooltip("Folder the combined PNG is written to. Use a project-relative path that starts " +
|
||||
"with \"Assets/\" (e.g. Assets/Darkmatter/CombinedImages) so it can be imported as a " +
|
||||
"Sprite and assigned back automatically. An absolute path outside the project just " +
|
||||
"writes the file to disk.")]
|
||||
[SerializeField] private string _saveFolder = "Assets/Darkmatter/CombinedImages";
|
||||
|
||||
[Tooltip("File name for the combined PNG (without extension). Leave empty to use this " +
|
||||
"GameObject's name.")]
|
||||
[SerializeField] private string _fileName = "";
|
||||
|
||||
[Header("After combining")]
|
||||
[Tooltip("Destroy the child GameObjects whose images were combined. Takes priority over " +
|
||||
"\"Disable Children\" when both are ticked.")]
|
||||
[SerializeField] private bool _destroyChildrenAfterCombine = false;
|
||||
|
||||
[Tooltip("Disable (SetActive false) the child GameObjects whose images were combined.")]
|
||||
[SerializeField] private bool _disableChildrenAfterCombine = false;
|
||||
|
||||
[Header("What to combine")]
|
||||
[Tooltip("Also combine Images found on nested (grand-)children, not just direct children.")]
|
||||
[SerializeField] private bool _recurseIntoChildren = true;
|
||||
|
||||
[Tooltip("Include children that are currently inactive in the hierarchy.")]
|
||||
[SerializeField] private bool _includeInactiveChildren = false;
|
||||
|
||||
[Header("Resolution")]
|
||||
[Tooltip("Pixels per world unit for the output texture. 0 = auto: matched to the highest " +
|
||||
"resolution source sprite so no detail is lost.")]
|
||||
[SerializeField] private float _pixelsPerUnit = 0f;
|
||||
|
||||
[Tooltip("Hard cap for the longest side of the output texture (keeps huge layouts from " +
|
||||
"producing enormous PNGs). Aspect ratio is preserved.")]
|
||||
[SerializeField] private int _maxDimension = 4096;
|
||||
|
||||
[Header("Trimming")]
|
||||
[Tooltip("Crop the final combined image to the tight bounding box of non-transparent pixels, " +
|
||||
"so empty/transparent margins are NOT included. The assigned Image is repositioned " +
|
||||
"and resized to match the trimmed content, keeping each child at its real size.")]
|
||||
[SerializeField] private bool _trimTransparent = true;
|
||||
|
||||
[Tooltip("Pixels with alpha at or below this value are treated as empty when trimming (0..1). " +
|
||||
"Leave at 0 to keep soft/anti-aliased edges; raise slightly to also trim faint fringes.")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] private float _alphaThreshold = 0f;
|
||||
|
||||
[Header("Assignment")]
|
||||
[Tooltip("Add/reuse an Image on THIS object, assign the combined sprite to it, and resize " +
|
||||
"this RectTransform to the combined bounds so it lands at the same place and size.")]
|
||||
[SerializeField] private bool _assignCombinedToSelf = true;
|
||||
|
||||
public string SaveFolder => _saveFolder;
|
||||
public string FileName => string.IsNullOrWhiteSpace(_fileName) ? name : _fileName.Trim();
|
||||
public bool DestroyChildrenAfterCombine => _destroyChildrenAfterCombine;
|
||||
public bool DisableChildrenAfterCombine => _disableChildrenAfterCombine;
|
||||
public bool RecurseIntoChildren => _recurseIntoChildren;
|
||||
public bool IncludeInactiveChildren => _includeInactiveChildren;
|
||||
public float PixelsPerUnit => _pixelsPerUnit;
|
||||
public int MaxDimension => Mathf.Max(1, _maxDimension);
|
||||
public bool TrimTransparent => _trimTransparent;
|
||||
public float AlphaThreshold => _alphaThreshold;
|
||||
public bool AssignCombinedToSelf => _assignCombinedToSelf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85c9da43c395b794fb2180a6da9f6511
|
||||
Reference in New Issue
Block a user