Would re-writing parts of my code in c# noticibly boost performance?

Godot Version

4.6.1

Question

I’m currently working on a procedural cave generator, and i have been running into some stutters when loading “chunks” (16x16 tile sections in a TileMapLayer node). I have found that an area of my code that makes the tile’s textures connect with each other is a large contributor to this issue, it basically runs through 80 lines of nested if statements for each case of how a tile could look based on the tiles around it. (this method itself could be an issue, i did try using godot’s built in terrain system but i found that it’s runtime performance was awful).

I am wonder if having this function in c# or even all of my chunk loading code in c# could increase performance. if there are other things that could help performance, please let me know as well.

1 Like

In my experience, most of the performance problems come from design flaws in a system. If you know C# very well, then maybe you’ll be able to squeeze a bit more performance out of it, but if you don’t have a lot of experience with it, you can rewrite the same system and in some cases it can perform even worse than the GDScript version. If you can, try to optimize your GDScript code first before committing to C#

7 Likes

This is likely your problem, and as @tibaverus said, switching languages isn’t going to solve it. The fact that building a 16x16 set of tiles isn’t performant is the problem.

5 Likes

do you know of any alternate methods that may run better? I am struggling to think of any.

Can’t really help you without seeing the code.

Show your code. Calculating tiles for 16x16 area should not be a performance problem. But 80 lines of nested ifs doesn’t sound good.

I probably should have attached this with the original post sorry about that.

the main process here is to first, check if a tile is one that needs to be connected. then, in the connect_tile_to_adj function, it first checks where the tiles adjacent to it are and saves it as either a 1 for yes tile or 0 for no tile in an array (ordered left to right, top left corner is position 0 and bottom right is position 8), it also checks if the tile has any adjacent tiles in a cardinal direction to it, this is used to skip sections of the code where diagonal tiles would not affect the texture of the central tile.

finally, using the gathered info, it looks through the mess of if statements for the exact case found above, and sets the cell at coords to the correct texture.

func smooth_tiles(x, y): ## smooths all the tiles in the chunk and in a 1 tile border of the chunk at (x, y)
	for i in range(-1, 17):
		for j in range(-1, 17):
			if map.get_cell_source_id(Vector2i(i+16*x, j+16*y)) == 4 or map.get_cell_source_id(Vector2i(i+16*x, j+16*y)) == 0:
				connect_tile_to_adj(Vector2i(i+16*x, j+16*y))


func connect_tile_to_adj(coords : Vector2i): ## goes through every case of what a tile could look like and sets said tile to the correct texture
	var tile_config = []
	var crdnl = 0
	var id = map.get_cell_source_id(coords)
	for i in range(-1, 2):
		for j in range(-1, 2):
			#checks how many tiles are adjacent to the one being modified
			if map.get_cell_source_id(Vector2i(coords.x+i, coords.y+j)) == 4 or  map.get_cell_source_id(Vector2i(coords.x+i, coords.y+j)) == 0:
				tile_config.append(1)
				if i == 0 or j == 0:
					crdnl += 1
					
			else:
				tile_config.append(0)
	
	if map.get_cell_tile_data(coords) != null:
		crdnl -= 1
	

	if crdnl == 0: map.set_cell(coords, id, Vector2i(0, 3))
	elif crdnl == 1:
		if tile_config[1] == 1: map.set_cell(coords, id, Vector2i(3, 3))
		elif tile_config[3] == 1: map.set_cell(coords, id, Vector2i(0, 2))
		elif tile_config[5] == 1: map.set_cell(coords, id, Vector2i(0, 0))
		elif tile_config[7] == 1: map.set_cell(coords, id, Vector2i(1, 3))
	elif crdnl == 2:
		if tile_config[1] == 1: 
			if tile_config[3] == 1: 
				if tile_config[0] == 1: map.set_cell(coords, id, Vector2i(11, 3))
				else: map.set_cell(coords, id, Vector2i(3, 2))
			elif tile_config[5] == 1: 
				if tile_config[2] == 1: map.set_cell(coords, id, Vector2i(11, 0))
				else: map.set_cell(coords, id, Vector2i(3, 0))
			elif tile_config[7] == 1: map.set_cell(coords, id, Vector2i(2, 3))
		elif tile_config[3] == 1: 
			if tile_config[5] == 1: map.set_cell(coords, id, Vector2i(0, 1))
			elif tile_config[7] == 1: 
				if tile_config[6] == 1: map.set_cell(coords, id, Vector2i(8, 3))
				else: map.set_cell(coords, id, Vector2i(1, 2))
		elif tile_config[5] == 1: 
			if tile_config[7] == 1: 
				if tile_config[8] == 1: map.set_cell(coords, id, Vector2i(8, 0))
				else: map.set_cell(coords, id, Vector2i(1, 0))
	elif crdnl == 3:
		if tile_config[1] == 1:
			if tile_config[3] == 1:
				if tile_config[5] == 1:
					if tile_config[0] == 1 and tile_config[2] == 1: map.set_cell(coords, id, Vector2i(11, 2))
					elif tile_config[0] == 1: map.set_cell(coords, id, Vector2i(7, 2))
					elif tile_config[2] == 1: map.set_cell(coords, id, Vector2i(7, 1))
					else: map.set_cell(coords, id, Vector2i(3, 1))
				elif tile_config[7] == 1:
					if tile_config[0] == 1 and tile_config[6] == 1: map.set_cell(coords, id, Vector2i(9, 3))
					elif tile_config[0] == 1: map.set_cell(coords, id, Vector2i(6, 3))
					elif tile_config[6] == 1: map.set_cell(coords, id, Vector2i(5, 3))
					else: map.set_cell(coords, id, Vector2i(2, 2))
			elif tile_config[5] == 1:
				if tile_config[7] == 1:
					if tile_config[2] == 1 and tile_config[8] == 1: map.set_cell(coords, id, Vector2i(10, 0))
					elif tile_config[2] == 1: map.set_cell(coords, id, Vector2i(6, 0))
					elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(5, 0))
					else: map.set_cell(coords, id, Vector2i(2, 0))
		elif tile_config[3] == 1:
			if tile_config[5] == 1:
				if tile_config[7] == 1:
					if tile_config[6] == 1 and tile_config[8] == 1: map.set_cell(coords, id, Vector2i(8, 1))
					elif tile_config[6] == 1: map.set_cell(coords, id, Vector2i(4, 2))
					elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(4, 1))
					else: map.set_cell(coords, id, Vector2i(1, 1))
	elif crdnl == 4:
		if tile_config[0] == 1 and tile_config[2] == 1 and tile_config[6] == 1 and tile_config[8] == 1: map.set_cell(coords, id, Vector2i(9, 2))
		elif tile_config[0] == 1:
			if tile_config[2] == 1:
				if tile_config[6] == 1: map.set_cell(coords, id, Vector2i(6, 2))
				elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(6, 1))
				else: map.set_cell(coords, id, Vector2i(11, 1))
			elif tile_config[6] == 1:
				if tile_config[8] == 1: map.set_cell(coords, id, Vector2i(5, 2))
				else: map.set_cell(coords, id, Vector2i(10, 3))
			elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(10, 2))
			else: map.set_cell(coords, id, Vector2i(4, 0))
		elif tile_config[2] == 1:
			if tile_config[6] == 1:
				if tile_config[8] == 1: map.set_cell(coords, id, Vector2i(5, 1))
				else: map.set_cell(coords, id, Vector2i(9, 1))
			elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(9, 0))
			else: map.set_cell(coords, id, Vector2i(4, 3))
		elif tile_config[6] == 1:
			if tile_config[8] == 1: map.set_cell(coords, id, Vector2i(8, 2))
			else: map.set_cell(coords, id, Vector2i(7, 0))
		elif tile_config[8] == 1: map.set_cell(coords, id, Vector2i(7, 3))
		else: map.set_cell(coords, id, Vector2i(2, 1))
	else:map.set_cell(coords, 1, Vector2i(2, 0))

I’ve never worked with TIleMaps, but made random dungeon generators myself. You can use threads to run the expensive code to generate stuff in the background. You could look into WorkerThreadPool.

Edit: Something like:

func _expensive_function_to_calculate_stuff() -> void:
    # lots of code...


func calulate_stuff_in_background() -> void:
	var task_id: int = WorkerThreadPool.add_task(_expensive_function_to_calculate_stuff)

	while not WorkerThreadPool.is_task_completed(task_id):
		await get_tree().process_frame

	WorkerThreadPool.wait_for_task_completion(task_id)

	print("Done calculating stuff in the background..")
3 Likes

That huge if-thing looks very much like it could be quite easily replaced with an array (or arrays). I didn’t go though all of your code, but something like this could work:

Build an integer index from the tile_config data. Use that index to get the correct tile set coordinate from an array. You need to calculate actual working values by yourself.

var tiles: Array[int, Vector2i] = [Vector2i(0,0), Vector2i(1,0), ...]

for ...iterate map coordinates...

    # calculate tile_config here

    var tile_index: int = 0
    for i in 8:
        tile_index += tile_config[i] << i
    map.set_cell(coords, id, tiles[tile_index])

I use a very similar system like in my tilemap based game to calculate wall tiles.

3 Likes

I’m a bit confused on how exactly this works, how does tile_index work with the tiles array when it is such large value from the bit shifting?

Your code is obfuscated, either intentionally or unintentionally. crdnl is not a good variable name. It is not descriptive. Abbreviated variable names are a relic of the past, and haven’t really been necessary since the 80s. And while i and j work, one must assume that i is x and j is y. when in fact you could use x and y or col and row as more descriptive variables.

Another issue that is likely plaguing your runtime is the use of Variants everywhere. i and j, which are used liberally everywhere are declared as Variant instead of int. tile_config likewise is declared as an Array of Variants instead of an Array of whatever it actually is - ints I think? id likewise is declared as a Variant instead of an int.

Statically typed variables are smaller in memory, as well as not needing implicit conversions every time you want to do math on them.

for i: int in range(-1, 17):
var tile_config: Arrant[int] = []
var crdnl: int = 0
var id: int = map.get_cell_source_id(coords)

I’d recommend a lookup table, like what @Dizzy_Caterpillar is suggesting. Basically build the map as some sort of data structure, then just map each value to the correct tile using a lookup table.

Let’s say you have 9 basic tile options. (I assume you have more, but I do not know your dataset.)

0 1 2
3 4 5
6 7 8

Let’s say for each location you have 5 tile options. So I have an array of arrays.

var tileset = Array[Array[Vector2i]]

In tile generation we start at say, the top left, and start generating. booleans either there’s a tile there or there is not.

1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1
1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

Iterate through and determine which of the nine inner arrays each position should match. From that array, use pick_random() to get cell coords. Then apply those to the position in the TileMapLayer.

5 Likes

The largest tile_config index I see is 8, so the array size would be 512. If most of the indeces would be used, thats fine, array of size 512 is not too big, But if the array would be very sparse, you can use Dictionary instead, although it is a bit slower.

var tiles: Dictionary[int, Vector2i] = [0: Vector2i(0,0), 42: Vector2i(1,0), ...]
2 Likes

Are you sure it’s Godot’s code that performs awful?

it took a really long time, but with suggestions from both you and @Dizzy_Caterpillar i was able to get the tile smoothing to work quite a bit faster.

i was able to come up with this which replaces all of the code i posted previously

func smooth_tiles(x, y): ## smooths all the tiles in the chunk at (x, y)
	for i : int in range(-1, 17):
		for j : int in range(-1, 17):
			if map.get_cell_source_id(Vector2i(i+16*x, j+16*y)) == 4 or map.get_cell_source_id(Vector2i(i+16*x, j+16*y)) == 0:
				connect_tile_to_adj(Vector2i(i+16*x, j+16*y))


func connect_tile_to_adj(coords : Vector2i): ## goes through every case of what a tile could look like and sets said tile to the correct texture
	var id : int = map.get_cell_source_id(coords)
	var tile_config : Array[int] = []
	
    for i : int in range(-1, 2):
		for j : int in range(-1, 2):
			if map.get_cell_source_id(Vector2i(coords.x+i, coords.y+j)) == 4 or map.get_cell_source_id(Vector2i(coords.x+i, coords.y+j)) == 0:
				tile_config.append(1)
			else:
				tile_config.append(0)
	
	var tile_index : int = 0
	for i : int in 9:
		tile_index += tile_config[i] << i
	map.set_cell(coords, id, tile_connection_dict.tiles[tile_index]) #tiles dictionary saved in an autoload as a constant
		

i still need to look at my other generation code, i just thought i should give an update

2 Likes

I made some additional optimizations:

- Type everything possible, typed variables are considerably faster than untyped. Changed smooth_tiles’s arguments (x, y) to ints.

- Do calculations only once. Added coord and source_id variables.

func smooth_tiles(x: int, y: int): ## smooths all the tiles in the chunk at (x, y)
	var xt := x * 16
	var yt := y * 16
	for i : int in range(-1, 17):
		for j : int in range(-1, 17):
			var coord := Vector2i(i+xt, j+yt)
			var source_id := map.get_cell_source_id(coord)
			if source_id == 4 or source_id == 0:
				connect_tile_to_adj(coord)


func connect_tile_to_adj(coords : Vector2i): ## goes through every case of what a tile could look like and sets said tile to the correct texture
	var id : int = map.get_cell_source_id(coords)
	var tile_config : Array[int] = []
	
    for i : int in range(-1, 2):
		for j : int in range(-1, 2):
			var coord := Vector2i(coords.x+i, coords.y+j)
			var source_id := map.get_cell_source_id(coord)
			if source_id == 4 or source_id == 0:
				tile_config.append(1)
			else:
				tile_config.append(0)
	
	var tile_index : int = 0
	for i : int in 9:
		tile_index += tile_config[i] << i
	map.set_cell(coords, id, tile_connection_dict.tiles[tile_index]) #tiles dictionary saved in an autoload as a constant
4 Likes

Your initial code almost gave me heart attack :D. Don’t take it personally but I haven’t seen something so bad in a long time. Yeah, it’s a pure mapping operation and can be done without a single if. Always approach the problem by thinking about how it reflects in data structures. Remember that quote from Torvalds: “Bad programmers worry about the code. Good programmers worry about data structures and their relationships.”

Since that is re-done now, you still have a lot of room for further optimization.

  • You’re calling get_cell_source_id() 6804 times in the worst case, while could get away with 324 times. Get source id of all cells beforehand and store them in a packed array.
  • You’re creating an array 324 times (tile_config at each connect_tile_to_adj() call) while you could get away creating it only once. Also use fixed size packed array and just fill it with zeros at each call. Avoid appending because that may trigger costly re-allocation of the array.
  • You’re constructing Vector2i(i+16*x, j+16*y) 972 times while you could get away with 324 times.
  • And finally, you can get rid of connect_tile_to_adj() completely and move its code to smooth_tiles() to get rid of 324 function calls.
6 Likes

Thanks to @normalized ‘s comment, I removed the tile_config array completely:

func connect_tile_to_adj(coords : Vector2i): ## goes through every case of what a tile could look like and sets said tile to the correct texture
	var id : int = map.get_cell_source_id(coords)
	var tile_config : Array[int] = []

	var tile_index : int = 0
	var tile_index_i : int = 0
	
    for i : int in range(-1, 2):
		for j : int in range(-1, 2):
			var coord := Vector2i(coords.x+i, coords.y+j)
			var source_id := map.get_cell_source_id(coord)
			if source_id == 4 or source_id == 0:
				tile_index += 1 << tile_index_i
			tile_index_i += 1
	
	map.set_cell(coords, id, tile_connection_dict.tiles[tile_index]) #tiles dictionary saved in an autoload as a constant

Remember to measure the time impact of every optimization. It wouldn’t be first time in world history if an “optimization” doesn’t work.

4 Likes

Thank you for the suggestions, the reason i have connect_tile_to_adj as its own function is because it is used elsewhere, otherwise i would combine them.

So what. You can always allow for some code repetition, especially if it’s done to improve performance.

And you certainly didn’t have problems repeating all those elifs and set_cell() calls in your initial code :wink:

2 Likes

That initial if/else atrocity was so enormous that it actually makes me wonder how big the resulting GDScript bytecode would turn out to be and if a small, tight loop would be faster even though calling a subfunction, because it doesn’t shred the cache as much. :laughing: