Ss13 based atmospheric system, need help to optimize

Godot Version

Godot 4.3 Stable

Question

Soo i’ve been working on a 64x64 tile atmospheric system heavily inspired by the one seem in space station 13, i made it in only a few hours but i would like tips and feedback on my code to see how i can optimize it, currently i ran into the issue that the gas after a few ticks of spreading the game crashes crashes due to the crazy amount of loops

you can see the gas spreading, theres currently functions to: create gas, remove gas, move gas, spread gas, convert position to nearest “index” (basically snappedi with 64), and stuff, but the issue is that crashes

i would like help with optimizing this code as when i finish it i plan on releasing it completely for free, because its such an interesting and easy concept that ive never seen another game do except for space station 13, and i would love to see other games using the mechanic itself, it brings life and freedom to the game

anyways yapping aside, here is the project if anyone would like to test it and help me improve it

and heres the script if lazy to download and do all the stuff:

edit 1: fixed the formatting and changed to the new code

extends Node

var atmos = {
	cfg = {
		snap = 64,
		tickrate = 1.0,
		tickupdate = false,
		maxgas = 10000
	},
	
	gas = {
		
	},
	
	gas_properties = {
		"Air" = {
			spread = 0.5
		}
	}
}

@onready var gases = $Gas
@onready var solid = $Solid
@onready var unittexture = $Unit

func _ready() -> void:
	create_gas(Vector2(0,0),"Air",5000.0)
	await get_tree().create_timer(atmos.cfg.tickrate).timeout
	#spread_all_gas("Air",0.5)
	#await get_tree().create_timer(atmos.cfg.tickrate).timeout
	#spread_all_gas("Air",0.5)

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
	if atmos.cfg.tickupdate == false:
		atmos.cfg.tickupdate = true
		await get_tree().create_timer(atmos.cfg.tickrate).timeout
		atmos.cfg.tickupdate = false
		
		atmostick()

func atmostick():
	var agpdupe = atmos.gas_properties.duplicate(true)
	
	for gas_prop in agpdupe:
		spread_all_gas(gas_prop,atmos.gas_properties[gas_prop].spread)
	
	agpdupe = null
	
	atmosupdate()

func atmosupdate():
	pass
	for unit in gases.get_children():
		unit.modulate.a = (gas_total_count(unit.name)/atmos.cfg.maxgas)

func create_gas(pos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
	
	var newgasindex = pos_to_nearest_index(pos)
	var totalcount = gas_total_count(newgasindex)
	var foundgas = find_gas(newgasindex, type)
	
	if atmos.gas.has(newgasindex):
		if atmos.gas[newgasindex].content.has(type):
			if totalcount + amount > atmos.cfg.maxgas:
				var subbedamount = (totalcount + amount) - atmos.cfg.maxgas
				foundgas[type] = clamp(foundgas[type] + subbedamount,0,atmos.cfg.maxgas)
			else:
				foundgas[type] = clamp(foundgas[type] + amount,0,atmos.cfg.maxgas)
		else:
			foundgas[type] = clamp(amount,0,atmos.cfg.maxgas)
	else:
		atmos.gas[newgasindex] = {
			content = {
				
			}
		}
		atmos.gas[newgasindex].content[type] = amount
	
	var newunit = unittexture.duplicate()
	newunit.name = newgasindex
	newunit.show()
	newunit.position = pos_to_nearest_pos(pos)
	newunit.modulate.a = (gas_total_count(newgasindex)/atmos.cfg.maxgas)
	gases.add_child(newunit)

func spread_all_gas(type: String, multi: float):
	if multi <= 0:
		return
	
	var directions = [
		# up down left right
		Vector2(0,64),
		Vector2(0,-64),
		Vector2(64,0),
		Vector2(-64,0),
		# diagonal
		Vector2(64,64),
		Vector2(-64,-64),
		Vector2(-64,64),
		Vector2(64,-64)
	]
	
	var ttdir = directions.size()
	var atmosgas_dupe = atmos.gas.duplicate(true)
	
	for unitindex in atmosgas_dupe:
		var foundgas = find_gas(unitindex, type)
		if foundgas != null:
			var splitamount = (foundgas[type]/ttdir)*multi
			var cpos = index_to_pos(unitindex)
			for dir in directions:
				#print(cpos)
				var dirgas = find_gas(pos_to_nearest_index(cpos + dir), type)
				if dirgas != null:
					if dirgas[type] < foundgas[type]:
						move_gas(cpos,cpos + dir,type,splitamount)
	
	atmosgas_dupe = null
	#print(atmos)

func move_gas(oldpos: Vector2, newpos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
	
	var gasindex = pos_to_nearest_index(oldpos)
	var foundgas = find_gas(gasindex, type)
	if foundgas != null:
		var foundgasamount = foundgas[type]
		if foundgasamount <= amount:
			destroy_gas(oldpos, type, foundgasamount)
			create_gas(newpos, type, foundgasamount)
		else:
			destroy_gas(oldpos, type, amount)
			create_gas(newpos, type, amount)
	#print(atmos)

func destroy_gas(pos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
		
	var gasindex = pos_to_nearest_index(pos)
	var foundgas = find_gas(gasindex, type)
	if foundgas != null:
		foundgas[type] = clamp(foundgas[type] - amount,0,atmos.cfg.maxgas)
		var findnode = gases.get_node(gasindex)
		if findnode != null:
			if foundgas[type] <= 0:
				findnode.queue_free()
			else:
				findnode.modulate.a = (gas_total_count(gasindex)/atmos.cfg.maxgas)
	#print(atmos)

func gas_total_count(gasindex: String):
	var total_count = 0
	if atmos.gas.has(gasindex):
		for unit in atmos.gas[gasindex].content:
			total_count += atmos.gas[gasindex].content[unit]
	return total_count

func find_gas(gasindex: String, type: String):
	if atmos.gas.has(gasindex):
		if atmos.gas[gasindex].content.has(type):
			return atmos.gas[gasindex].content
		else:
			return null
	else:
		return null

func pos_to_nearest_pos(pos: Vector2):
	pos.x = snappedi(pos.x,atmos.cfg.snap)
	pos.y = snappedi(pos.y,atmos.cfg.snap)
	return pos

func pos_to_nearest_index(pos: Vector2):
	pos.x = snappedi(pos.x,atmos.cfg.snap)
	pos.y = snappedi(pos.y,atmos.cfg.snap)
	return pos_to_idxformat(pos)

func pos_to_idxformat(pos: Vector2):
	return "x" + str(pos.x) + " y" + str(pos.y)

func index_to_pos(index: String):
	var posplit = index.split(" ", false, 2)
	var newpos_x = int(posplit[0])
	var newpos_y = int(posplit[1])
	var newpos = Vector2(newpos_x, newpos_y)
	#print(newpos)
	return newpos

Instead of quoting your code use the </> button or ctrl+e to create three ticks like so

```
    type or paste code here
```

Results in:

    type or paste code here

adding children to the scene is rather expensive, you should try to find a data oriented way to simulate gases.

Instead of quoting your code use the </> button or ctrl+e to create three ticks like so

thanks for the tip i was searching for a alternative like this

adding children to the scene is rather expensive, you should try to find a data oriented way to simulate gases.

yea thats why i made everything simulated on dictionary but i see what you mean i just realized it creates alot of packed scenes, but what should i do to replace the visual?, i was thinking polygon but maybe theres a better way

about the other parts of the code though like the loops, do you think there is a way to optimize it, i think the loops are also draining performance quite alot

Polygon sounds great, it’s a single child that can be updated. Particle systems could be good too, similarly a MultiMeshInstance2D if you want more control.

It is really hard to read the loops in the current formatting. Calling functions like “spread_all_gas” every frame is probably a bad idea, you could stagger your updates and this is easier with a data oriented approach.

how do i quote with the persons name (im new to the forum)

Polygon sounds great, it’s a single child that can be updated. Particle systems could be good too, similarly a MultiMeshInstance2D if you want more control.

thanks i will try multimeshinstance2D

It is really hard to read the loops in the current formatting. Calling functions like “spread_all_gas” every frame is probably a bad idea, you could stagger your updates and this is easier with a data oriented approach.

sorry i fixed the formatting, btw ive been improving the code a little bit and i managed to improve performance, heres what i came with:

extends Node

var atmos = {
	cfg = {
		snap = 64,
		tickrate = 1.0,
		tickupdate = false,
		maxgas = 10000
	},
	
	gas = {
		
	},
	
	gas_properties = {
		"Air" = {
			spread = 0.5
		}
	}
}

@onready var gases = $Gas
@onready var solid = $Solid
@onready var unittexture = $Unit

func _ready() -> void:
	create_gas(Vector2(0,0),"Air",5000.0)
	await get_tree().create_timer(atmos.cfg.tickrate).timeout
	#spread_all_gas("Air",0.5)
	#await get_tree().create_timer(atmos.cfg.tickrate).timeout
	#spread_all_gas("Air",0.5)

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
	if atmos.cfg.tickupdate == false:
		atmos.cfg.tickupdate = true
		await get_tree().create_timer(atmos.cfg.tickrate).timeout
		atmos.cfg.tickupdate = false
		
		atmostick()

func atmostick():
	var agpdupe = atmos.gas_properties.duplicate(true)
	
	for gas_prop in agpdupe:
		spread_all_gas(gas_prop,atmos.gas_properties[gas_prop].spread)
	
	agpdupe = null
	
	atmosupdate()

func atmosupdate():
	pass
	for unit in gases.get_children():
		unit.modulate.a = (gas_total_count(unit.name)/atmos.cfg.maxgas)

func create_gas(pos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
	
	var newgasindex = pos_to_nearest_index(pos)
	var totalcount = gas_total_count(newgasindex)
	var foundgas = find_gas(newgasindex, type)
	
	if atmos.gas.has(newgasindex):
		if atmos.gas[newgasindex].content.has(type):
			if totalcount + amount > atmos.cfg.maxgas:
				var subbedamount = (totalcount + amount) - atmos.cfg.maxgas
				foundgas[type] = clamp(foundgas[type] + subbedamount,0,atmos.cfg.maxgas)
			else:
				foundgas[type] = clamp(foundgas[type] + amount,0,atmos.cfg.maxgas)
		else:
			foundgas[type] = clamp(amount,0,atmos.cfg.maxgas)
	else:
		atmos.gas[newgasindex] = {
			content = {
				
			}
		}
		atmos.gas[newgasindex].content[type] = amount
	
	var newunit = unittexture.duplicate()
	newunit.name = newgasindex
	newunit.show()
	newunit.position = pos_to_nearest_pos(pos)
	newunit.modulate.a = (gas_total_count(newgasindex)/atmos.cfg.maxgas)
	gases.add_child(newunit)

func spread_all_gas(type: String, multi: float):
	if multi <= 0:
		return
	
	var directions = [
		# up down left right
		Vector2(0,64),
		Vector2(0,-64),
		Vector2(64,0),
		Vector2(-64,0),
		# diagonal
		Vector2(64,64),
		Vector2(-64,-64),
		Vector2(-64,64),
		Vector2(64,-64)
	]
	
	var ttdir = directions.size()
	var atmosgas_dupe = atmos.gas.duplicate(true)
	
	for unitindex in atmosgas_dupe:
		var foundgas = find_gas(unitindex, type)
		if foundgas != null:
			var splitamount = (foundgas[type]/ttdir)*multi
			var cpos = index_to_pos(unitindex)
			for dir in directions:
				#print(cpos)
				var dirgas = find_gas(pos_to_nearest_index(cpos + dir), type)
				if dirgas != null:
					if dirgas[type] < foundgas[type]:
						move_gas(cpos,cpos + dir,type,splitamount)
	
	atmosgas_dupe = null
	#print(atmos)

func move_gas(oldpos: Vector2, newpos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
	
	var gasindex = pos_to_nearest_index(oldpos)
	var foundgas = find_gas(gasindex, type)
	if foundgas != null:
		var foundgasamount = foundgas[type]
		if foundgasamount <= amount:
			destroy_gas(oldpos, type, foundgasamount)
			create_gas(newpos, type, foundgasamount)
		else:
			destroy_gas(oldpos, type, amount)
			create_gas(newpos, type, amount)
	#print(atmos)

func destroy_gas(pos: Vector2, type: String, amount: float):
	if amount <= 0:
		return
		
	var gasindex = pos_to_nearest_index(pos)
	var foundgas = find_gas(gasindex, type)
	if foundgas != null:
		foundgas[type] = clamp(foundgas[type] - amount,0,atmos.cfg.maxgas)
		var findnode = gases.get_node(gasindex)
		if findnode != null:
			if foundgas[type] <= 0:
				findnode.queue_free()
			else:
				findnode.modulate.a = (gas_total_count(gasindex)/atmos.cfg.maxgas)
	#print(atmos)

func gas_total_count(gasindex: String):
	var total_count = 0
	if atmos.gas.has(gasindex):
		for unit in atmos.gas[gasindex].content:
			total_count += atmos.gas[gasindex].content[unit]
	return total_count

func find_gas(gasindex: String, type: String):
	if atmos.gas.has(gasindex):
		if atmos.gas[gasindex].content.has(type):
			return atmos.gas[gasindex].content
		else:
			return null
	else:
		return null

func pos_to_nearest_pos(pos: Vector2):
	pos.x = snappedi(pos.x,atmos.cfg.snap)
	pos.y = snappedi(pos.y,atmos.cfg.snap)
	return pos

func pos_to_nearest_index(pos: Vector2):
	pos.x = snappedi(pos.x,atmos.cfg.snap)
	pos.y = snappedi(pos.y,atmos.cfg.snap)
	return pos_to_idxformat(pos)

func pos_to_idxformat(pos: Vector2):
	return "x" + str(pos.x) + " y" + str(pos.y)

func index_to_pos(index: String):
	var posplit = index.split(" ", false, 2)
	var newpos_x = int(posplit[0])
	var newpos_y = int(posplit[1])
	var newpos = Vector2(newpos_x, newpos_y)
	#print(newpos)
	return newpos

i call it after x seconds using a manual debounce but what do you mean with “data oriented approach”, is there a way to do it without loops, these loops have been draining my mental health because it still causes crashes but takes as little longer now

edit: currently its bugged but i would like tips to improve performance still