Tilemap unique per tile "visual" offset/displace

Godot Version

Godot version 4.2.1

Question

Greetings I’m prototyping a 2d isometric rpg and am trying to solve a “visual only” effect and make use of the TileMap node. In sort I wish to add a visual flare by randomly adding a slight 0-4 pixel height offset per tile to break the uniform look rather than making extra artwork.

Any guidance would be appreciated, I’m unsure if this is something achievable within the TileMap node or if it would make more sense to make my own implementation. I’d just would like to avoid making my own collision system and data structures etc…

Showing is better than telling at 4:50 of this youtube video is in essence the effect I wish to control.

https://youtu.be/04oQ2jOUjkU?t=293

I do currently have a working version of the effect in which I’ve manually created an isometric map without the use of a TileMap node but instead placing a bunch of sprites.

I’d like to use Godot’s pre existing node/system however within the TileMap I cannot see a way to recreate the effect, the closest I’ve found is making use of TileData.texture_origin however this is not unique/instanced for the tile position and applies this change to all matching tiles. (hopefully the wording made sense there)

Here’s some example code of the manual sprite veresion.

const BLOCK_SCENE   = preload("res://blocks/block.tscn")
const SPRITE_WIDTH  = 64
const SPRITE_HEIGHT = 64
var BLOCK_Y_OFFSET_RAND_RANGE = 3
var BLOCK_Y_OFFSET_RAND_SIZE = 256
var BLOCK_Y_OFFSET_RAND = []

func process_block_y_rand():
	var random = RandomNumberGenerator.new()
	random.randomize()
	for cell_x in range(BLOCK_Y_OFFSET_RAND_SIZE):
		BLOCK_Y_OFFSET_RAND.append([])
		for cell_y in range(BLOCK_Y_OFFSET_RAND_SIZE):
			var _i = random.randi_range(-BLOCK_Y_OFFSET_RAND_RANGE, BLOCK_Y_OFFSET_RAND_RANGE)
			BLOCK_Y_OFFSET_RAND[cell_x].append(Vector2i(0, _i))

func coords_to_rand(v : Vector2i):
	return BLOCK_Y_OFFSET_RAND[v.x % BLOCK_Y_OFFSET_RAND_SIZE][v.y % BLOCK_Y_OFFSET_RAND_SIZE]

func coords_to_map(v : Vector2i) -> Vector2i: 
	return  Vector2i(
		v.x*(0.5*SPRITE_WIDTH) + v.y * (-0.5*SPRITE_WIDTH), 
		v.x * (0.25*SPRITE_HEIGHT) + v.y * (0.25*SPRITE_HEIGHT)
		)

func coords_to_fullmap(v : Vector2i) -> Vector2i:
	return coords_to_map(v) + coords_to_rand(v)

func process_map_test_gen():
	var block_instance
	var coord_z = 0
	var instances = -1
	var mapped_coords : Vector2i
	for coord_x in range(20):
		for coord_y in range(20):
			instances += 1
			block_instance = BLOCK_SCENE.instantiate()
			block_instance.map_coords = Vector3i(coord_x, coord_y, coord_z)
			
			var coords = Vector2i(coord_x, coord_y)
			mapped_coords = coords_to_fullmap(coords)
			block_instance.position.x = mapped_coords.x
			block_instance.position.y = mapped_coords.y
			add_child(block_instance)

func _ready():
	process_block_y_rand()
	process_map_test_gen()

I plan to have this a “visual only” effect and have no baring on collision etc. as mentioned before any hints or guidance would be appreciated.

Unfortunately, I believe that there’s no way to change the texture offset per-cell.

You could create “alternative tiles” in the TileSet, which can each have their own
texture origin while inheriting all other properties from the source tile.
But this would be extremely tedious to set up manually, so you’ll definitely want to script it.
You could either do this with a tool script and save the alternative tiles to the resource, or you could generate the TileSet at runtime.

Edit: :sweat:

You can change individual TileData information by implementing both TileMap._use_tile_data_runtime_update() and TileMap._tile_data_runtime_update()

For example:

extends TileMap


func _use_tile_data_runtime_update(layer: int, coords: Vector2i) -> bool:
	return true


func _tile_data_runtime_update(layer: int, coords: Vector2i, tile_data: TileData) -> void:
	tile_data.texture_origin += Vector2i(0, randi_range(-10, 10))


func _process(delta: float) -> void:
	notify_runtime_tile_data_update(0)

I didn’t take any measures to avoid updating tiles that don’t need to be updated, you should do that if possible.

Result:

2 Likes

Thank you @mrcdk, I was preparing myself to do what @apples suggested and create offset versions of tiles programmatically at runtime. But this is something I was hopping for and opens the door too other possibilities I was hopping to explore. :grin:

I was expecting to reply sooner but I was having login issues on mobile.
Also thanks @apples for the fast work around if it wasn’t possible. :grinning:

For sake of history @mrcdk’s example should be all you need, but here is my test using the code they provided.

extends TileMap

var BLOCK_RAND_OFFSET_RANGE = 6
var BLOCK_RAND_OFFSET_SIZE = 256
var BLOCK_RAND_OFFSET_DATA = {}

func _process_block_rand_offset():
	var random = RandomNumberGenerator.new()
	var pos : Vector2i
	var rand : Vector2i
	random.randomize()
	for cell_x in range(BLOCK_RAND_OFFSET_SIZE):
		for cell_y in range(BLOCK_RAND_OFFSET_SIZE):
			pos = Vector2i(cell_x,cell_y)
			rand = Vector2i(0, random.randi_range(0, BLOCK_RAND_OFFSET_RANGE))
			BLOCK_RAND_OFFSET_DATA[pos] = rand
			
func get_block_rand_offset_at_coords(coords : Vector2i) -> Vector2i:
	return BLOCK_RAND_OFFSET_DATA.get(Vector2i(coords.x % BLOCK_RAND_OFFSET_SIZE, coords.y % BLOCK_RAND_OFFSET_SIZE), Vector2i.ZERO)

func _use_tile_data_runtime_update(layer: int, coords: Vector2i) -> bool:
	return true

func _tile_data_runtime_update(layer: int, coords: Vector2i, tile_data: TileData) -> void:
	tile_data.texture_origin +=  get_block_rand_offset_at_coords(coords)

func _ready():
	_process_block_rand_offset()
	notify_runtime_tile_data_update(0)

2 Likes

If the tiles will remain static you don’t need to call TileMap.notify_runtime_tile_data_update() in the _process() function. Just call it when you want to update them like after _process_block_rand_offset() in _ready().

1 Like

Ah yes thanks for pointing that out, I’ve updated the code in the example.

1 Like

I’m trying to achieve this same effect using the new Tile node but I’d like to apply the visual offset to tile layers that do not have scripts attached to them, but are children of another tile layer instead (which has a script attached that controls all the children).

Am I supposed to somehow direct the

func child_tile._use_tile_data_runtime_update(layer: int, coords: Vector2i) -> bool:
	return true

to a target child tile layer?

I might be mistaken as I havent kept up with the recent changes, but the script is attached to the tilemap node and not the individual layers within. The “notify_runtime_tile_data_update(0)” the zero paramater is actually the target layer number if i recall correctly. So all you would need to do in my example code is change the _ready function to.

func _ready()
  _process_block_rand_offset()
  for x in range(32): # set to you're desired max layer hight.
    notify_runtime_tile_data_update(x)

Unfortunatly I’m typing this reply on my phone I won’t be able to verrify if I’m rembering this all correctly untill I much later on in the day. But hopfully this gets you on the right path.

You’re talking about the old TileMap, but sadly some random dude decided to replace it (and break all my old projects… you can imagine what I think about it) in 4.3. So all available examples, documentation and tutorials are now not working (as well as some parts of Godot IDE). notify_runtime_tile_data_update() doesn’t take any arguments and “notify_runtime_tile_data_update(0)” produces a “Too many arguments” error if typed.

The new TileLayer doesn’t have a concept of layers at all since it’s a layer on its own.

Bummer are you programmatically creating the layers? I’d have to imagine now you’ll be required to extend the tilemaplayer node and attach a script for each layer. It doesn’t seem too bad they can all share the same script and the offset-map could be an exported variable in the parent node then call the update to all its children.

I have to admit I do like the change (at least at first glance) but it does mean that for instances like this we’ll need to do some extra work. That is ofc as long as the functionality is still there it’s just reorganized.

You need to attach an script to the TileMapLayers that implements those functions. Calling them yourself won’t work as they are callbacks called within the engine.