Saving a Tile Map as an Image File

Godot Version

Godot 4

Question

I have a very large tilemap of my game level which is a river. I want to create pixel art bridges that go across the river, but I have a reference map of the river that I need to use so the size and position is accurate. Is there a way i can download my tilemap as a PNG so I can open it in my art software? Thanks this will save me at least a few hours of counting tiles pixels.

1 Like

You can write a tool script that iterates through the entire TileMap(Layer?) and saves it as a PNG. It’s also possible that there’s a plugin in the asset library that could do it as well, but I haven’t checked all of the entries there. Or you could zoom out really far in the editor and take a screenshot.

1 Like

I read the documentation on tools that you attached, but I have no clue how to create a tool that will save the tile map as a PNG. I am trying with Claude but its not getting me very far. I read that tools are dangerous because they change things in editor and am a little bit worried about doing this without guidance. Is there a resourse you can point me towards or some advice so that I can impliment this properly.

1 Like

How about just making screenshots and stitching them together in an image editing app?

1 Like

A screen shot didn’t work :thinking:??

I actually just went through this myself a couple of days ago while trying to make a map system for my game. With the help of some online references, I found a simple solution that lets you save your tilemap as an image.

To capture the tilemap image:
**- Create a ReferenceRect in the scene that contains your tilemap

  • Add the script below to it
  • Add your tilemap as a reference to the export variable “tilemap”
  • Click on “Setup Capture Area”. This automatically crops the reference rect to your tilemap
  • Then click capture. It saves the image of the whole tilemap inside the folder where the current scene is located.**

*You can also change the output resolution.

What this does is take multiple images of the tilemap as small chunks and stitch them together in one final image.

Hope it’s helpful.

@tool
extends ReferenceRect

@export_group(“Settings”)
@export var tilemap: TileMapLayer
@export var max_output_dimension: int = 1024


@export_group(“Controls”)
@export_tool_button(“1. Setup Capture Area”) var setup_button = setup_capture_area
@export_tool_button(“2. Capture TileMap”) var capture_button = capture_tilemap

var used_rect: Rect2i
var tile_size: Vector2i

func setup_capture_area():
 if not tilemap:
 printerr(“Capture Tool: Assign a TileMapLayer first!”)
 return

 used_rect = tilemap.get_used_rect()
 tile_size = tilemap.tile_set.tile_size

 global_position = tilemap.global_position + (Vector2(used_rect.position) *  Vector2(tile_size))
 size = Vector2(used_rect.size) * Vector2(tile_size)


func capture_tilemap():
 if not tilemap or size.x <= 0: return

 # 1. Calculate Scaling Logic
 var current_max_dim = max(size.x, size.y)
 var scale_factor: float = 1.0
 if current_max_dim > max_output_dimension:
	scale_factor = float(max_output_dimension) / current_max_dim

 var final_width = int(size.x * scale_factor)
var final_height = int(size.y * scale_factor)

 # 2. Automated Viewport Resolution
 # We use the max_output_dimension as the buffer size, capped at 2048 for   safety
 var buffer_res = Vector2i(
	min(final_width, 2048),
	min(final_height, 2048)
 )

 var sub_viewport = SubViewport.new()
 sub_viewport.size = buffer_res
 sub_viewport.transparent_bg = true
 sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
 EditorInterface.get_base_control().add_child(sub_viewport)

 var camera = Camera2D.new()
 sub_viewport.add_child(camera)
 camera.anchor_mode = Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT
 camera.enabled = true
 camera.zoom = Vector2(scale_factor, scale_factor)
 camera.make_current()

 sub_viewport.world_2d = get_viewport().world_2d

 # 3. Scanning Logic
 var final_image = Image.create(final_width, final_height, false,   Image.FORMAT_RGBA8)

 # World units covered per "stamp"
 var world_step_x = buffer_res.x / scale_factor
 var world_step_y = buffer_res.y / scale_factor

 var steps_x = ceili(size.x / world_step_x)
 var steps_y = ceili(size.y / world_step_y)

 for y in range(steps_y):
	for x in range(steps_x):
		var world_x = global_position.x + (x * world_step_x)
		var world_y = global_position.y + (y * world_step_y)
		camera.global_position = Vector2(world_x, world_y)

		await RenderingServer.frame_post_draw
		
		var capture = sub_viewport.get_texture().get_image()
		
		var dst_x = int(x * buffer_res.x)
		var dst_y = int(y * buffer_res.y)
		var blit_w = min(buffer_res.x, final_width - dst_x)
		var blit_h = min(buffer_res.y, final_height - dst_y)
		
		final_image.blit_rect(
			capture, 
			Rect2(Vector2.ZERO, Vector2(blit_w, blit_h)), 
			Vector2(dst_x, dst_y)
		)

 # 4. Save Logic
 var scene_path = get_tree().edited_scene_root.scene_file_path
 var base_dir = scene_path.get_base_dir() if scene_path != "" else "res://"
 var scene_name = scene_path.get_file().get_basename() if scene_path != "" else  "map"

 var final_path = base_dir + "/" + "tile_map_screenshot.png"

 final_image.save_png(final_path)

 # 5. Cleanup
 sub_viewport.queue_free()
 EditorInterface.get_resource_filesystem().scan()

print("Success! Captured %dx%d image to %s" % [final_width, final_height, final_path])

 
4 Likes

This didn’t work for me, after fixing the indentations and changing the curly quotes to regular ones, it threw the error: @export_tool_button is not allowed at this level. Thanks for trying to help.

What version of Godot are you using? Maybe it doesn’t support tool buttons

I’m just updated it to the most recent version yesterday or the day before

Theres probably an indentation issue somewhere.

The script does work although it is limited to a single TileMapLayer (as far as I can tell).
Here is an edited properly formatted version:

@tool
extends ReferenceRect

@export_group("Settings")
@export var tilemap: TileMapLayer
@export var max_output_dimension: int = 1024


@export_group("Controls")
@export_tool_button("1. Setup Capture Area") var setup_button = setup_capture_area
@export_tool_button("2. Capture TileMap") var capture_button = capture_tilemap
var used_rect: Rect2i
var tile_size: Vector2i

func setup_capture_area():
	if not tilemap:
		printerr("Capture Tool: Assign a TileMapLayer first!")
		return

	used_rect = tilemap.get_used_rect()
	tile_size = tilemap.tile_set.tile_size

	global_position = tilemap.global_position + (Vector2(used_rect.position) *  Vector2(tile_size))
	size = Vector2(used_rect.size) * Vector2(tile_size)


func capture_tilemap():
	if not tilemap or size.x <= 0: return

	# 1. Calculate Scaling Logic
	var current_max_dim = max(size.x, size.y)
	var scale_factor: float = 1.0
	if current_max_dim > max_output_dimension:
		scale_factor = float(max_output_dimension) / current_max_dim

	var final_width = int(size.x * scale_factor)
	var final_height = int(size.y * scale_factor)

 # 2. Automated Viewport Resolution
 # We use the max_output_dimension as the buffer size, capped at 2048 for   safety
	var buffer_res = Vector2i(
		min(final_width, 2048),
		min(final_height, 2048)
	)

	var sub_viewport = SubViewport.new()
	sub_viewport.size = buffer_res
	sub_viewport.transparent_bg = true
	sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
	EditorInterface.get_base_control().add_child(sub_viewport)

	var camera = Camera2D.new()
	sub_viewport.add_child(camera)
	camera.anchor_mode = Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT
	camera.enabled = true
	camera.zoom = Vector2(scale_factor, scale_factor)
	camera.make_current()

	sub_viewport.world_2d = get_viewport().world_2d

 # 3. Scanning Logic
	var final_image = Image.create(final_width, final_height, false,   Image.FORMAT_RGBA8)

	# World units covered per "stamp"
	var world_step_x = buffer_res.x / scale_factor
	var world_step_y = buffer_res.y / scale_factor

	var steps_x = ceili(size.x / world_step_x)
	var steps_y = ceili(size.y / world_step_y)

	for y in range(steps_y):
		for x in range(steps_x):
			var world_x = global_position.x + (x * world_step_x)
			var world_y = global_position.y + (y * world_step_y)
			camera.global_position = Vector2(world_x, world_y)

			await RenderingServer.frame_post_draw
		
			var capture = sub_viewport.get_texture().get_image()
		
			var dst_x = int(x * buffer_res.x)
			var dst_y = int(y * buffer_res.y)
			var blit_w = min(buffer_res.x, final_width - dst_x)
			var blit_h = min(buffer_res.y, final_height - dst_y)
		
			final_image.blit_rect(
				capture, 
				Rect2(Vector2.ZERO, Vector2(blit_w, blit_h)), 
				Vector2(dst_x, dst_y)
			)

	# 4. Save Logic
	var scene_path = get_tree().edited_scene_root.scene_file_path
	var base_dir = scene_path.get_base_dir() if scene_path != "" else "res://"
	var scene_name = scene_path.get_file().get_basename() if scene_path != "" else  "map"

	var final_path = base_dir + "/" + "tile_map_screenshot.png"

	final_image.save_png(final_path)

 # 5. Cleanup
	sub_viewport.queue_free()
	EditorInterface.get_resource_filesystem().scan()

	print("Success! Captured %dx%d image to %s" % [final_width, final_height, final_path])

Thank you all!