Skip to content

Instantly share code, notes, and snippets.

@bsimser
Created March 26, 2026 14:12
Show Gist options
  • Select an option

  • Save bsimser/00812a0077afef0e5aabfb9c4137ae49 to your computer and use it in GitHub Desktop.

Select an option

Save bsimser/00812a0077afef0e5aabfb9c4137ae49 to your computer and use it in GitHub Desktop.
Unity Tilemap to PNG Exporter
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Tilemaps;
public class TilemapExporter : EditorWindow
{
private string outputPath = "";
private int pixelsPerCell = 16;
private bool autoDetectPPC = true;
private bool exportObjects = true;
private Vector2 scrollPos;
[MenuItem("Tools/Tilemap Exporter")]
public static void ShowWindow()
{
GetWindow<TilemapExporter>("Tilemap Exporter");
}
private void OnEnable()
{
if (string.IsNullOrEmpty(outputPath))
outputPath = Path.Combine(Application.dataPath, "TilemapExport");
}
private void OnGUI()
{
// Setup the editor layout
EditorGUILayout.LabelField("Tilemap to PNG Exporter", EditorStyles.boldLabel);
EditorGUILayout.Space();
EditorGUILayout.BeginHorizontal();
outputPath = EditorGUILayout.TextField("Output Folder", outputPath);
if (GUILayout.Button("Browse...", GUILayout.Width(80)))
{
string selected = EditorUtility.OpenFolderPanel("Select Output Folder", outputPath, "");
if (!string.IsNullOrEmpty(selected))
outputPath = selected;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
autoDetectPPC = EditorGUILayout.Toggle("Auto-Detect Pixels/Cell", autoDetectPPC);
GUI.enabled = !autoDetectPPC;
pixelsPerCell = EditorGUILayout.IntField("Pixels Per Cell", pixelsPerCell);
GUI.enabled = true;
exportObjects = EditorGUILayout.Toggle("Export Sprite Objects", exportObjects);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Scene Tilemaps", EditorStyles.boldLabel);
// Get the tilemaps in the scene
var tilemaps = FindObjectsOfType<Tilemap>();
SortByRenderOrder(tilemaps);
// Collect standalone SpriteRenderers (not part of tilemaps) for sprite export
var spriteRenderers = GetStandaloneSpriteRenderers(tilemaps);
if (tilemaps.Length == 0)
{
EditorGUILayout.HelpBox("No Tilemaps found in the current scene.", MessageType.Warning);
return;
}
// Figure out the extent of the tilemap bounds and display the information to the user
scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.MaxHeight(300));
foreach (var tm in tilemaps)
{
tm.CompressBounds();
var bounds = tm.cellBounds;
var renderer = tm.GetComponent<TilemapRenderer>();
string sortInfo = renderer != null
? string.Format("Layer: {0}, Order: {1}", renderer.sortingLayerName, renderer.sortingOrder)
: "No Renderer";
int tileCount = CountTiles(tm);
EditorGUILayout.LabelField(
string.Format(" {0} [{1}] Bounds: {2}x{3} Tiles: {4}",
tm.gameObject.name, sortInfo, bounds.size.x, bounds.size.y, tileCount));
}
EditorGUILayout.EndScrollView();
// Show sprite objects
if (exportObjects && spriteRenderers.Length > 0)
{
EditorGUILayout.Space();
EditorGUILayout.LabelField(
string.Format("Sprite Objects: {0}", spriteRenderers.Length), EditorStyles.boldLabel);
}
// Compute and display full bounds
int ppc = autoDetectPPC ? DetectPixelsPerCell(tilemaps) : pixelsPerCell;
BoundsInt fullBounds = ComputeUnionBounds(tilemaps, exportObjects ? spriteRenderers : null, ppc);
EditorGUILayout.Space();
EditorGUILayout.LabelField(
string.Format("Union Bounds: ({0},{1}) to ({2},{3}) Size: {4}x{5}",
fullBounds.xMin, fullBounds.yMin, fullBounds.xMax, fullBounds.yMax,
fullBounds.size.x, fullBounds.size.y));
int texW = fullBounds.size.x * ppc;
int texH = fullBounds.size.y * ppc;
EditorGUILayout.LabelField(string.Format("Output Size: {0}x{1} px ({2} px/cell)", texW, texH, ppc));
if (texW <= 0 || texH <= 0)
{
EditorGUILayout.HelpBox("Computed texture size is zero. Are the tilemaps empty?", MessageType.Warning);
return;
}
if (texW > 16384 || texH > 16384)
{
EditorGUILayout.HelpBox(
string.Format("Output would be {0}x{1} which exceeds the maximum size of 16384 pixels. Consider reducing the tilemap size or pixels/cell.", texW, texH),
MessageType.Error);
}
EditorGUILayout.Space();
if (GUILayout.Button("Export All", GUILayout.Height(30)))
{
ExportAll(tilemaps, exportObjects ? spriteRenderers : new SpriteRenderer[0], fullBounds, ppc);
}
}
private void ExportAll(Tilemap[] tilemaps, SpriteRenderer[] spriteRenderers, BoundsInt fullBounds, int ppc)
{
if (!Directory.Exists(outputPath))
Directory.CreateDirectory(outputPath);
int texW = fullBounds.size.x * ppc;
int texH = fullBounds.size.y * ppc;
// Cache readable copies of source textures to avoid re-blitting
var readableCache = new Dictionary<Texture2D, Texture2D>();
try
{
for (int i = 0; i < tilemaps.Length; i++)
{
var tilemap = tilemaps[i];
var renderer = tilemap.GetComponent<TilemapRenderer>();
string layerName = renderer != null ? renderer.sortingLayerName : "Default";
int order = renderer != null ? renderer.sortingOrder : 0;
string progressLabel = string.Format("Exporting {0} ({1}/{2})", tilemap.gameObject.name, i + 1, tilemaps.Length);
if (EditorUtility.DisplayCancelableProgressBar("Tilemap Export", progressLabel, (float)i / tilemaps.Length))
{
Debug.Log("Tilemap export cancelled by user.");
break;
}
var outputTex = new Texture2D(texW, texH, TextureFormat.RGBA32, false);
outputTex.filterMode = FilterMode.Point;
// Fill with transparent
var clearPixels = new Color32[texW * texH];
outputTex.SetPixels32(clearPixels);
tilemap.CompressBounds();
var bounds = tilemap.cellBounds;
int totalCells = bounds.size.x * bounds.size.y;
int processedCells = 0;
for (int y = bounds.yMin; y < bounds.yMax; y++)
{
for (int x = bounds.xMin; x < bounds.xMax; x++)
{
processedCells++;
if (processedCells % 500 == 0)
{
float cellProgress = (float)processedCells / totalCells;
float totalProgress = (i + cellProgress) / tilemaps.Length;
if (EditorUtility.DisplayCancelableProgressBar("Tilemap Export",
string.Format("{0}: {1}/{2} cells", tilemap.gameObject.name, processedCells, totalCells),
totalProgress))
{
Debug.Log("Tilemap export cancelled by user.");
DestroyImmediate(outputTex);
return;
}
}
var cellPos = new Vector3Int(x, y, 0);
Sprite sprite = tilemap.GetSprite(cellPos);
if (sprite == null)
continue;
// Get sprite pixels
Color32[] spritePixels = GetSpritePixels(sprite, readableCache);
if (spritePixels == null)
continue;
int spriteW = Mathf.RoundToInt(sprite.rect.width);
int spriteH = Mathf.RoundToInt(sprite.rect.height);
// Handle tile transforms (flips, rotations)
Matrix4x4 matrix = tilemap.GetTransformMatrix(cellPos);
spritePixels = ApplyTransform(spritePixels, spriteW, spriteH, matrix, out spriteW, out spriteH);
// Position in output texture (cell coords relative to full bounds origin)
int px = (x - fullBounds.xMin) * ppc;
int py = (y - fullBounds.yMin) * ppc;
// Blit sprite pixels onto output, respecting alpha
BlitPixels(outputTex, px, py, spritePixels, spriteW, spriteH, ppc);
}
}
outputTex.Apply();
// Save PNG
string safeName = SanitizeFileName(tilemap.gameObject.name);
string fileName = string.Format("{0}_{1}_{2}.png", layerName, order, safeName);
string filePath = Path.Combine(outputPath, fileName);
byte[] pngData = outputTex.EncodeToPNG();
File.WriteAllBytes(filePath, pngData);
Debug.LogFormat("Exported: {0} ({1} bytes)", filePath, pngData.Length);
DestroyImmediate(outputTex);
}
// Export sprite objects layer
if (spriteRenderers.Length > 0)
{
EditorUtility.DisplayProgressBar("Tilemap Export", "Exporting sprite objects...", 0.95f);
ExportSpriteObjects(spriteRenderers, fullBounds, ppc, texW, texH, readableCache);
}
}
finally
{
// Cleanup cached textures
foreach (var kvp in readableCache)
DestroyImmediate(kvp.Value);
readableCache.Clear();
EditorUtility.ClearProgressBar();
}
int totalFiles = tilemaps.Length + (spriteRenderers.Length > 0 ? 1 : 0);
Debug.LogFormat("Export complete. {0} files written to: {1}", totalFiles, outputPath);
EditorUtility.RevealInFinder(outputPath);
}
private void ExportSpriteObjects(SpriteRenderer[] renderers, BoundsInt fullBounds, int ppc,
int texW, int texH, Dictionary<Texture2D, Texture2D> readableCache)
{
var outputTex = new Texture2D(texW, texH, TextureFormat.RGBA32, false);
outputTex.filterMode = FilterMode.Point;
// Fill with transparent
var clearPixels = new Color32[texW * texH];
outputTex.SetPixels32(clearPixels);
for (int i = 0; i < renderers.Length; i++)
{
var sr = renderers[i];
if (sr.sprite == null)
continue;
if (i % 50 == 0)
{
EditorUtility.DisplayProgressBar("Tilemap Export",
string.Format("Sprite objects: {0}/{1}", i + 1, renderers.Length),
(float)i / renderers.Length);
}
Sprite sprite = sr.sprite;
Color32[] spritePixels = GetSpritePixels(sprite, readableCache);
if (spritePixels == null)
continue;
int spriteW = Mathf.RoundToInt(sprite.rect.width);
int spriteH = Mathf.RoundToInt(sprite.rect.height);
// Apply SpriteRenderer flips
if (sr.flipX)
spritePixels = FlipHorizontal(spritePixels, spriteW, spriteH);
if (sr.flipY)
spritePixels = FlipVertical(spritePixels, spriteW, spriteH);
// Apply transform scale flips (negative scale = flip)
Vector3 lossyScale = sr.transform.lossyScale;
if (lossyScale.x < 0)
spritePixels = FlipHorizontal(spritePixels, spriteW, spriteH);
if (lossyScale.y < 0)
spritePixels = FlipVertical(spritePixels, spriteW, spriteH);
// Convert world position to pixel position
// The sprite's pivot determines the anchor point
Vector2 pivot = sprite.pivot; // in pixels from bottom-left of sprite
Vector3 worldPos = sr.transform.position;
// World to pixel: (worldPos - boundsOrigin) * ppc, then offset by pivot
float px = (worldPos.x - fullBounds.xMin) * ppc - pivot.x;
float py = (worldPos.y - fullBounds.yMin) * ppc - pivot.y;
BlitPixelsDirect(outputTex, Mathf.RoundToInt(px), Mathf.RoundToInt(py),
spritePixels, spriteW, spriteH);
}
outputTex.Apply();
string filePath = Path.Combine(outputPath, "objects.png");
byte[] pngData = outputTex.EncodeToPNG();
File.WriteAllBytes(filePath, pngData);
Debug.LogFormat("Exported: {0} ({1} bytes, {2} sprites)", filePath, pngData.Length, renderers.Length);
DestroyImmediate(outputTex);
}
private static Color32[] GetSpritePixels(Sprite sprite, Dictionary<Texture2D, Texture2D> cache)
{
Texture2D sourceTex = sprite.texture;
if (sourceTex == null)
return null;
// Get or create a readable copy of the source texture
if (!cache.TryGetValue(sourceTex, out Texture2D readable))
{
readable = MakeReadable(sourceTex);
cache[sourceTex] = readable;
}
// Extract sprite rect - textureRect can throw for tightly-packed sprites
Rect spriteRect;
try
{
spriteRect = sprite.textureRect;
}
catch
{
spriteRect = sprite.rect;
}
int rx = Mathf.RoundToInt(spriteRect.x);
int ry = Mathf.RoundToInt(spriteRect.y);
int rw = Mathf.RoundToInt(spriteRect.width);
int rh = Mathf.RoundToInt(spriteRect.height);
// Clamp to texture bounds
if (rx < 0) rx = 0;
if (ry < 0) ry = 0;
if (rx + rw > readable.width) rw = readable.width - rx;
if (ry + rh > readable.height) rh = readable.height - ry;
if (rw <= 0 || rh <= 0)
return null;
// Extract only the sprite's region from the full texture
Color32[] fullPixels = readable.GetPixels32(0);
Color32[] spritePixels = new Color32[rw * rh];
for (int y = 0; y < rh; y++)
{
for (int x = 0; x < rw; x++)
{
spritePixels[y * rw + x] = fullPixels[(ry + y) * readable.width + (rx + x)];
}
}
return spritePixels;
}
private static Texture2D MakeReadable(Texture2D source)
{
RenderTexture rt = RenderTexture.GetTemporary(source.width, source.height, 0, RenderTextureFormat.ARGB32);
rt.filterMode = FilterMode.Point;
Graphics.Blit(source, rt);
RenderTexture prev = RenderTexture.active;
RenderTexture.active = rt;
Texture2D readable = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false);
readable.filterMode = FilterMode.Point;
readable.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0);
readable.Apply();
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(rt);
return readable;
}
private static void BlitPixels(Texture2D dest, int destX, int destY, Color32[] src, int srcW, int srcH, int cellSize)
{
// Center the sprite within the cell if sizes differ
int offsetX = (cellSize - srcW) / 2;
int offsetY = (cellSize - srcH) / 2;
BlitPixelsDirect(dest, destX + offsetX, destY + offsetY, src, srcW, srcH);
}
private static void BlitPixelsDirect(Texture2D dest, int destX, int destY, Color32[] src, int srcW, int srcH)
{
for (int sy = 0; sy < srcH; sy++)
{
int dy = destY + sy;
if (dy < 0 || dy >= dest.height)
continue;
for (int sx = 0; sx < srcW; sx++)
{
int dx = destX + sx;
if (dx < 0 || dx >= dest.width)
continue;
Color32 srcColor = src[sy * srcW + sx];
// Skip fully transparent pixels
if (srcColor.a == 0)
continue;
// Alpha composite onto destination
if (srcColor.a == 255)
{
dest.SetPixel(dx, dy, srcColor);
}
else
{
Color existing = dest.GetPixel(dx, dy);
Color blended = AlphaBlend(existing, srcColor);
dest.SetPixel(dx, dy, blended);
}
}
}
}
private static Color AlphaBlend(Color bg, Color fg)
{
float a = fg.a + bg.a * (1f - fg.a);
if (a < 0.001f)
return Color.clear;
float r = (fg.r * fg.a + bg.r * bg.a * (1f - fg.a)) / a;
float g = (fg.g * fg.a + bg.g * bg.a * (1f - fg.a)) / a;
float b = (fg.b * fg.a + bg.b * bg.a * (1f - fg.a)) / a;
return new Color(r, g, b, a);
}
private static Color32[] ApplyTransform(Color32[] pixels, int w, int h, Matrix4x4 m, out int outW, out int outH)
{
// Identity or near-identity - no transform needed
if (IsIdentity(m))
{
outW = w;
outH = h;
return pixels;
}
// Extract flip and 90-degree rotation from the matrix
bool flipX = m.m00 < -0.5f;
bool flipY = m.m11 < -0.5f;
// 90 degree rotation: m00≈0, m01≈-1, m10≈1, m11≈0 (or inverse)
bool rotate90 = Mathf.Abs(m.m00) < 0.5f && Mathf.Abs(m.m01) > 0.5f;
Color32[] result = (Color32[])pixels.Clone();
if (flipX)
{
result = FlipHorizontal(result, w, h);
}
if (flipY)
{
result = FlipVertical(result, w, h);
}
if (rotate90)
{
bool clockwise = m.m01 < 0;
result = Rotate90(result, w, h, clockwise);
outW = h;
outH = w;
}
else
{
outW = w;
outH = h;
}
return result;
}
private static bool IsIdentity(Matrix4x4 m)
{
return Mathf.Abs(m.m00 - 1f) < 0.01f
&& Mathf.Abs(m.m11 - 1f) < 0.01f
&& Mathf.Abs(m.m01) < 0.01f
&& Mathf.Abs(m.m10) < 0.01f;
}
private static Color32[] FlipHorizontal(Color32[] pixels, int w, int h)
{
var result = new Color32[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
result[y * w + x] = pixels[y * w + (w - 1 - x)];
return result;
}
private static Color32[] FlipVertical(Color32[] pixels, int w, int h)
{
var result = new Color32[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
result[y * w + x] = pixels[(h - 1 - y) * w + x];
return result;
}
private static Color32[] Rotate90(Color32[] pixels, int w, int h, bool clockwise)
{
var result = new Color32[w * h];
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
if (clockwise)
result[x * h + (h - 1 - y)] = pixels[y * w + x];
else
result[(w - 1 - x) * h + y] = pixels[y * w + x];
}
}
return result;
}
private static SpriteRenderer[] GetStandaloneSpriteRenderers(Tilemap[] tilemaps)
{
var allSR = FindObjectsOfType<SpriteRenderer>();
var tilemapObjects = new HashSet<GameObject>();
foreach (var tm in tilemaps)
{
// Exclude SpriteRenderers that are on the same GameObject as a Tilemap or TilemapRenderer
tilemapObjects.Add(tm.gameObject);
}
return allSR.Where(sr => sr.sprite != null && !tilemapObjects.Contains(sr.gameObject)
&& sr.GetComponent<Tilemap>() == null && sr.GetComponent<TilemapRenderer>() == null)
.OrderBy(sr => sr.sortingLayerID)
.ThenBy(sr => sr.sortingOrder)
.ToArray();
}
private static BoundsInt ComputeUnionBounds(Tilemap[] tilemaps, SpriteRenderer[] spriteRenderers, int ppc)
{
int xMin = int.MaxValue, yMin = int.MaxValue;
int xMax = int.MinValue, yMax = int.MinValue;
foreach (var tm in tilemaps)
{
tm.CompressBounds();
var b = tm.cellBounds;
if (b.size.x == 0 || b.size.y == 0)
continue;
if (b.xMin < xMin) xMin = b.xMin;
if (b.yMin < yMin) yMin = b.yMin;
if (b.xMax > xMax) xMax = b.xMax;
if (b.yMax > yMax) yMax = b.yMax;
}
// Expand bounds to include sprite objects
if (spriteRenderers != null)
{
foreach (var sr in spriteRenderers)
{
if (sr.sprite == null) continue;
Bounds wb = sr.bounds;
int sxMin = Mathf.FloorToInt(wb.min.x);
int syMin = Mathf.FloorToInt(wb.min.y);
int sxMax = Mathf.CeilToInt(wb.max.x);
int syMax = Mathf.CeilToInt(wb.max.y);
if (sxMin < xMin) xMin = sxMin;
if (syMin < yMin) yMin = syMin;
if (sxMax > xMax) xMax = sxMax;
if (syMax > yMax) yMax = syMax;
}
}
if (xMin == int.MaxValue)
return new BoundsInt(0, 0, 0, 0, 0, 0);
return new BoundsInt(xMin, yMin, 0, xMax - xMin, yMax - yMin, 1);
}
private static int DetectPixelsPerCell(Tilemap[] tilemaps)
{
foreach (var tm in tilemaps)
{
tm.CompressBounds();
var bounds = tm.cellBounds;
foreach (var pos in bounds.allPositionsWithin)
{
Sprite sprite = tm.GetSprite(pos);
if (sprite != null)
{
// Use sprite rect width as pixels per cell
int ppc = Mathf.RoundToInt(sprite.rect.width);
if (ppc > 0)
return ppc;
}
}
}
return 16; // fallback
}
private static int CountTiles(Tilemap tm)
{
int count = 0;
var bounds = tm.cellBounds;
foreach (var pos in bounds.allPositionsWithin)
{
if (tm.HasTile(pos))
count++;
}
return count;
}
private static void SortByRenderOrder(Tilemap[] tilemaps)
{
System.Array.Sort(tilemaps, (a, b) =>
{
var ra = a.GetComponent<TilemapRenderer>();
var rb = b.GetComponent<TilemapRenderer>();
int layerA = ra != null ? ra.sortingLayerID : 0;
int layerB = rb != null ? rb.sortingLayerID : 0;
if (layerA != layerB)
return layerA.CompareTo(layerB);
int orderA = ra != null ? ra.sortingOrder : 0;
int orderB = rb != null ? rb.sortingOrder : 0;
return orderA.CompareTo(orderB);
});
}
private static string SanitizeFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
foreach (char c in invalid)
name = name.Replace(c, '_');
return name;
}
}
@bsimser
Copy link
Copy Markdown
Author

bsimser commented Mar 26, 2026

Simple tool to export all tilemaps (and optionallly sprites) in a scene into separate PNG files. Maybe you have an old Unity project with a tilemap you want to move to Godot or another engine? This will create one PNG for each tilemap it finds in the scene, along with a file named "objects.png" that will contain any non-tilemap sprites it finds. Just add this to an Editor folder in your project and find the window in the Tools menu. Enjoy!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment