Created
May 31, 2026 20:36
-
-
Save adrenak/ac84b316d3168d1f669ba8ba555cea70 to your computer and use it in GitHub Desktop.
UVIslandExporterWindow (tags:unity,util) (about:easily export UV maps from SkinnedMeshRenderer and MeshFilter in Unity)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEditor; | |
| using UnityEngine; | |
| using System.IO; | |
| public class UVIslandExporterWindow : EditorWindow { | |
| [SerializeField] | |
| private Object[] sourceObjects = new Object[0]; | |
| private int textureSize = 2048; | |
| private int lineThickness = 2; | |
| private Color lineColor = Color.black; | |
| private Color backgroundColor = Color.white; | |
| private Texture2D previewTexture; | |
| private const double PreviewRefreshDelay = 1.0; | |
| private double scheduledPreviewRefreshTime; | |
| private bool isPreviewRefreshScheduled; | |
| private readonly int[] sizeOptions = { 1024, 2048, 4096 }; | |
| private readonly string[] sizeLabels = { "1024", "2048", "4096" }; | |
| [MenuItem("Tools/UV Island Exporter")] | |
| public static void Open() { | |
| UVIslandExporterWindow window = | |
| GetWindow<UVIslandExporterWindow>("UV Island Exporter"); | |
| window.minSize = new Vector2(700, 850); | |
| window.Show(); | |
| } | |
| [MenuItem("CONTEXT/MeshFilter/Export UV Islands")] | |
| private static void ExportFromMeshFilter(MenuCommand command) { | |
| UVIslandExporterWindow window = | |
| GetWindow<UVIslandExporterWindow>("UV Island Exporter"); | |
| window.minSize = new Vector2(700, 850); | |
| window.AddSourceObject(command.context as Object); | |
| window.Show(); | |
| } | |
| [MenuItem("CONTEXT/SkinnedMeshRenderer/Export UV Islands")] | |
| private static void ExportFromSkinnedMeshRenderer(MenuCommand command) { | |
| UVIslandExporterWindow window = | |
| GetWindow<UVIslandExporterWindow>("UV Island Exporter"); | |
| window.minSize = new Vector2(700, 850); | |
| window.AddSourceObject(command.context as Object); | |
| window.Show(); | |
| } | |
| private void OnEnable() { | |
| EditorApplication.update -= EditorUpdate; | |
| EditorApplication.update += EditorUpdate; | |
| } | |
| private void OnDisable() { | |
| EditorApplication.update -= EditorUpdate; | |
| ClearPreviewTexture(); | |
| } | |
| private void OnDestroy() { | |
| EditorApplication.update -= EditorUpdate; | |
| ClearPreviewTexture(); | |
| } | |
| private void AddSourceObject(Object obj) { | |
| if (obj == null) | |
| return; | |
| if (sourceObjects == null) | |
| sourceObjects = new Object[0]; | |
| foreach (Object existingObject in sourceObjects) { | |
| if (existingObject == obj) | |
| return; | |
| } | |
| Object[] newArray = new Object[sourceObjects.Length + 1]; | |
| for (int i = 0; i < sourceObjects.Length; i++) | |
| newArray[i] = sourceObjects[i]; | |
| newArray[sourceObjects.Length] = obj; | |
| sourceObjects = newArray; | |
| SchedulePreviewRefresh(); | |
| Repaint(); | |
| } | |
| private void EditorUpdate() { | |
| if (!isPreviewRefreshScheduled) | |
| return; | |
| if (EditorApplication.timeSinceStartup < scheduledPreviewRefreshTime) | |
| return; | |
| isPreviewRefreshScheduled = false; | |
| GeneratePreviewTexture(); | |
| } | |
| private void SchedulePreviewRefresh() { | |
| scheduledPreviewRefreshTime = | |
| EditorApplication.timeSinceStartup + PreviewRefreshDelay; | |
| isPreviewRefreshScheduled = true; | |
| Repaint(); | |
| } | |
| private void CancelScheduledPreviewRefresh() { | |
| isPreviewRefreshScheduled = false; | |
| } | |
| private void ClearPreviewTexture() { | |
| if (previewTexture == null) | |
| return; | |
| DestroyImmediate(previewTexture); | |
| previewTexture = null; | |
| } | |
| private void OnGUI() { | |
| EditorGUILayout.LabelField("Export UV Lines", EditorStyles.boldLabel); | |
| EditorGUI.BeginChangeCheck(); | |
| SerializedObject serializedObject = new SerializedObject(this); | |
| SerializedProperty sourceObjectsProperty = | |
| serializedObject.FindProperty("sourceObjects"); | |
| EditorGUILayout.PropertyField( | |
| sourceObjectsProperty, | |
| new GUIContent("Mesh Sources"), | |
| true | |
| ); | |
| serializedObject.ApplyModifiedProperties(); | |
| int selectedIndex = System.Array.IndexOf(sizeOptions, textureSize); | |
| if (selectedIndex < 0) | |
| selectedIndex = 1; | |
| selectedIndex = EditorGUILayout.Popup( | |
| "Texture Size", | |
| selectedIndex, | |
| sizeLabels | |
| ); | |
| textureSize = sizeOptions[selectedIndex]; | |
| lineThickness = EditorGUILayout.IntSlider( | |
| "Line Thickness", | |
| lineThickness, | |
| 1, | |
| 5 | |
| ); | |
| lineColor = EditorGUILayout.ColorField( | |
| "Line Color", | |
| lineColor | |
| ); | |
| backgroundColor = EditorGUILayout.ColorField( | |
| "Background Color", | |
| backgroundColor | |
| ); | |
| if (EditorGUI.EndChangeCheck()) { | |
| SchedulePreviewRefresh(); | |
| } | |
| EditorGUILayout.Space(10); | |
| GUI.enabled = sourceObjects != null && sourceObjects.Length > 0; | |
| if (GUILayout.Button("Refresh Preview", GUILayout.Height(28))) { | |
| CancelScheduledPreviewRefresh(); | |
| GeneratePreviewTexture(); | |
| } | |
| if (GUILayout.Button("Export UV Islands", GUILayout.Height(35))) { | |
| ExportUVIslands(); | |
| } | |
| GUI.enabled = true; | |
| EditorGUILayout.Space(10); | |
| DrawPreview(); | |
| } | |
| private void GeneratePreviewTexture() { | |
| ClearPreviewTexture(); | |
| previewTexture = CreateUVTexture(false); | |
| Repaint(); | |
| } | |
| private Texture2D CreateUVTexture(bool showDialogs) { | |
| if (sourceObjects == null || sourceObjects.Length == 0) { | |
| if (showDialogs) { | |
| EditorUtility.DisplayDialog( | |
| "No Mesh Sources", | |
| "Please assign at least one MeshFilter or SkinnedMeshRenderer.", | |
| "OK" | |
| ); | |
| } | |
| return null; | |
| } | |
| Texture2D texture = new Texture2D( | |
| textureSize, | |
| textureSize, | |
| TextureFormat.RGBA32, | |
| false | |
| ); | |
| Color32 background = backgroundColor; | |
| Color32 exportColor = lineColor; | |
| Color32[] pixels = new Color32[textureSize * textureSize]; | |
| for (int i = 0; i < pixels.Length; i++) | |
| pixels[i] = background; | |
| texture.SetPixels32(pixels); | |
| int exportedMeshCount = 0; | |
| foreach (Object sourceObject in sourceObjects) { | |
| Mesh mesh = GetMeshFromObject(sourceObject); | |
| if (mesh == null) | |
| continue; | |
| if (mesh.uv == null || mesh.uv.Length == 0) | |
| continue; | |
| DrawMeshUVs(texture, mesh, exportColor); | |
| exportedMeshCount++; | |
| } | |
| if (exportedMeshCount == 0) { | |
| if (showDialogs) { | |
| EditorUtility.DisplayDialog( | |
| "No Valid UVs Found", | |
| "None of the assigned objects had valid mesh UVs.", | |
| "OK" | |
| ); | |
| } | |
| DestroyImmediate(texture); | |
| return null; | |
| } | |
| texture.Apply(); | |
| return texture; | |
| } | |
| private void DrawPreview() { | |
| EditorGUILayout.LabelField( | |
| isPreviewRefreshScheduled | |
| ? "UV Preview (Refreshing)" | |
| : "UV Preview", | |
| EditorStyles.boldLabel | |
| ); | |
| Rect previewRect = GUILayoutUtility.GetRect( | |
| 100, | |
| 100, | |
| GUILayout.ExpandWidth(true), | |
| GUILayout.ExpandHeight(true) | |
| ); | |
| EditorGUI.DrawRect(previewRect, new Color(0.35f, 0.35f, 0.35f)); | |
| if (previewTexture == null) { | |
| GUI.Label( | |
| previewRect, | |
| "Add mesh sources or click Refresh Preview.", | |
| EditorStyles.centeredGreyMiniLabel | |
| ); | |
| return; | |
| } | |
| float size = Mathf.Min(previewRect.width, previewRect.height); | |
| Rect imageRect = new Rect( | |
| previewRect.x + (previewRect.width - size) * 0.5f, | |
| previewRect.y + (previewRect.height - size) * 0.5f, | |
| size, | |
| size | |
| ); | |
| GUI.DrawTexture( | |
| imageRect, | |
| previewTexture, | |
| ScaleMode.ScaleToFit, | |
| false | |
| ); | |
| } | |
| private void ExportUVIslands() { | |
| string path = EditorUtility.SaveFilePanel( | |
| "Export UV Islands", | |
| Application.dataPath, | |
| "Combined_UV_Islands.png", | |
| "png" | |
| ); | |
| if (string.IsNullOrEmpty(path)) | |
| return; | |
| Texture2D texture = CreateUVTexture(true); | |
| if (texture == null) | |
| return; | |
| byte[] pngBytes = texture.EncodeToPNG(); | |
| File.WriteAllBytes(path, pngBytes); | |
| EditorUtility.RevealInFinder(path); | |
| AssetDatabase.Refresh(); | |
| DestroyImmediate(texture); | |
| EditorUtility.DisplayDialog( | |
| "Export Complete", | |
| "UV island texture exported successfully.", | |
| "OK" | |
| ); | |
| } | |
| private void DrawMeshUVs(Texture2D texture, Mesh mesh, Color32 color) { | |
| Vector2[] uvs = mesh.uv; | |
| int[] triangles = mesh.triangles; | |
| for (int i = 0; i < triangles.Length; i += 3) { | |
| Vector2 uv0 = uvs[triangles[i]]; | |
| Vector2 uv1 = uvs[triangles[i + 1]]; | |
| Vector2 uv2 = uvs[triangles[i + 2]]; | |
| DrawUVLine(texture, uv0, uv1, color); | |
| DrawUVLine(texture, uv1, uv2, color); | |
| DrawUVLine(texture, uv2, uv0, color); | |
| } | |
| } | |
| private Mesh GetMeshFromObject(Object obj) { | |
| if (obj == null) | |
| return null; | |
| if (obj is MeshFilter meshFilter) | |
| return meshFilter.sharedMesh; | |
| if (obj is SkinnedMeshRenderer skinnedMeshRenderer) | |
| return skinnedMeshRenderer.sharedMesh; | |
| if (obj is GameObject gameObject) { | |
| MeshFilter meshFilterFromObject = | |
| gameObject.GetComponent<MeshFilter>(); | |
| if (meshFilterFromObject != null) | |
| return meshFilterFromObject.sharedMesh; | |
| SkinnedMeshRenderer skinnedMeshRendererFromObject = | |
| gameObject.GetComponent<SkinnedMeshRenderer>(); | |
| if (skinnedMeshRendererFromObject != null) | |
| return skinnedMeshRendererFromObject.sharedMesh; | |
| } | |
| return null; | |
| } | |
| private void DrawUVLine( | |
| Texture2D texture, | |
| Vector2 uvA, | |
| Vector2 uvB, | |
| Color32 color | |
| ) { | |
| int x0 = UVToPixelX(uvA.x); | |
| int y0 = UVToPixelY(uvA.y); | |
| int x1 = UVToPixelX(uvB.x); | |
| int y1 = UVToPixelY(uvB.y); | |
| DrawLine(texture, x0, y0, x1, y1, color); | |
| } | |
| private int UVToPixelX(float u) { | |
| u = Mathf.Repeat(u, 1f); | |
| return Mathf.Clamp( | |
| Mathf.RoundToInt(u * (textureSize - 1)), | |
| 0, | |
| textureSize - 1 | |
| ); | |
| } | |
| private int UVToPixelY(float v) { | |
| v = Mathf.Repeat(v, 1f); | |
| return Mathf.Clamp( | |
| Mathf.RoundToInt(v * (textureSize - 1)), | |
| 0, | |
| textureSize - 1 | |
| ); | |
| } | |
| private void DrawLine( | |
| Texture2D texture, | |
| int x0, | |
| int y0, | |
| int x1, | |
| int y1, | |
| Color32 color | |
| ) { | |
| int dx = Mathf.Abs(x1 - x0); | |
| int dy = Mathf.Abs(y1 - y0); | |
| int sx = x0 < x1 ? 1 : -1; | |
| int sy = y0 < y1 ? 1 : -1; | |
| int err = dx - dy; | |
| while (true) { | |
| DrawThickPixel(texture, x0, y0, color); | |
| if (x0 == x1 && y0 == y1) | |
| break; | |
| int e2 = 2 * err; | |
| if (e2 > -dy) { | |
| err -= dy; | |
| x0 += sx; | |
| } | |
| if (e2 < dx) { | |
| err += dx; | |
| y0 += sy; | |
| } | |
| } | |
| } | |
| private void DrawThickPixel( | |
| Texture2D texture, | |
| int x, | |
| int y, | |
| Color32 color | |
| ) { | |
| int radius = Mathf.Max(0, lineThickness - 1); | |
| for (int offsetX = -radius; offsetX <= radius; offsetX++) { | |
| for (int offsetY = -radius; offsetY <= radius; offsetY++) { | |
| int px = x + offsetX; | |
| int py = y + offsetY; | |
| if (px < 0 || px >= textureSize || py < 0 || py >= textureSize) | |
| continue; | |
| texture.SetPixel(px, py, color); | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment