reduced coloring objects

This commit is contained in:
Mausham
2026-07-07 15:52:58 +05:45
parent c2d3ff4dd9
commit ecc2dd3dff
303 changed files with 38611 additions and 26461 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 000c0b9528ead0b43af7cdc19fb38f0e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 932d925aaafb08242b1fd65d3860ef40
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cba93615156299e4482e6b41e7db0199
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ff6d3964378b694ba9c7ba67b3af897

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bcae41f3415403d4da1f7aafb6d9a728
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85c9da43c395b794fb2180a6da9f6511