How to optimize tile terrain

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By russiniet

First of all, simple question. Would grouping nodes and changing the visibility of the group be more optimized in any way than changing visibility of each node individually?

Now, if you have enough dedication and nerves to look through my terrible code, then it would be greatly appreciated if you gave me some tips. So, I am making a 3d project, where you need to build on a 2d grid. First the map is generated. It’s just a two-dimensional array, no optimization needed, but the problem comes later on when I actually need to render the tiles. The map itself is 128x128, so there are 16384 nodes that I need to render, which is obviously a lot. So, first I instantiate the tiles in _ready() function, which adds all the tiles under a Tile Manager node. Tiles are nothing more than cubes with material on them. Then every frame I pick a part of the whole map (I can’t update the whole map in one frame, as it created lag spikes) and either hide or show the tiles that i selected, based on the distance to the camera in order to take weight off the GPU. It works, I get around 90 - 120 FPS, but it’s not so good, considering that it’s only several polygons (I have RX 580). So, is there a way to optimize it somehow? This is how the first question relates to my project - I can split the terrain into chunks and hide the whole chunk instead of looping through each node individually. Would that help?

If you need additional context, here are the scripts.

Generation script:

func generate_map(size_x, size_y):

for x in range(size_x):
	map.append([])
	map[x] = []
	map[x].resize(size_y)
	
	for y in range(size_y):
		map[x][y] = {
			"elevation": [UNIMPORTANT STUFF],
			"reference": null,
			"tree": [UNIMPORTANT STUFF]
		}

Initial tile instantiation script:

func generate_tiles():
for x in range(global.map.size()):
	for y in range(global.map[x].size()):
		var new_tile = tile.instance()
		new_tile.translate(Vector3(x*2, global.map[x][y].elevation, y*2))
		new_tile.visible = false
		if global.map[x][y].tree && global.map[x][y].elevation > 0:
			new_tile.get_child(2).visible = true
		if global.map[x][y].elevation < 1:
			new_tile.get_child(0).set_surface_material(0, sand_mat)
		add_child(new_tile)
		global.map[x][y].reference = get_child(get_child_count()-1)

Tile render function:

func render_tiles(camera_pos, dist, rsx, rsy):
var rex = rsx+16
var rey = rsy+16
if rsx == global.map_size-16: rex = 63
if rsy == global.map_size-16: rsy = 63
for x in range(rsx, rex):
	for y in range(rsy, rey):
		if x*2 < camera_pos.x+dist && x*2 > camera_pos.x-dist && y*2 < camera_pos.z+dist && y*2 > camera_pos.z-dist:
			if not global.map[x][y].reference.visible:
				global.map[x][y].reference.visible = true
		elif global.map[x][y].reference.visible:
			global.map[x][y].reference.visible = false

_process() loop:

frame += 1
#Update only at certain times
if frame > 3:
	frame = 0
	#Update different "chunk" every frame to distribute the load
	render_start_x += 16
	if (render_start_x >= global.map_size-16):
		render_start_x = -16
		render_start_y += 16
	render_tiles(get_node("/root/Spatial/Player").translation, render_range, render_start_x, render_start_y)
	if (render_start_y >= global.map_size-16):
		render_start_y = -16
:bust_in_silhouette: Reply From: Wallace99

If your terrain is a 2d grid, is there any need to have individual cubes for the ground? It could be worth checking out Zylann’s terrain plugin which handles things like LOD for you so you don’t need to try iterating over a tonne of nodes in gdscript. He also has a voxel plugin but I haven’t tried that.

In answer to your question checking the visibility of each node individually is almost certainly going to be very bad for performance, especially if you plan on making your terrain any larger. I’ve implemented basic LOD for trees in my game in which there are several thousand trees grouped in about 12 multimeshes (highly recommend you look into multimeshes) by comparing the distance from the camera of the 12 multimeshes instead of the thousands of individual trees and it runs smoothly whereas trying to run a process script for each tree (let alone render each tree as a mesh instance) would bring it grinding to a halt

https://github.com/Zylann/godot_heightmap_plugin/blob/master/addons/zylann.hterrain/doc/main.md

:bust_in_silhouette: Reply From: archeron

I realize this is a late answer, but maybe someone else will profit from it.

The other answer suggested Zylanns terrain plugin. I don’t think its a good fit (not the heightmap terrain plugin, anyway; the voxel plugin might do everything you need, though), but the technology behind it is.

My suggestion is to use a single mesh that covers the tiles you need. This mesh can be quite large, vertex count is secondary; you should be able to render 129x129 vertices without any problems (thats assuming your map is flat - from your code it looks like elevation is involved, too, so that complicates things because if your tiles behave like Minecraft - discrete voxel cubes, you can’t use a “flat” mesh and displace its vertices with a heightmap texture in the vertex shader like Zylann does for a continuous terrain) Then define an image texture of size 128x128 whose pixels aren’t used as colors but as material descriptors, one pixel per tile. Then in the fragment shader for the mesh you can do a texture lookup with the tile coordinates to figure out which material should be used, and use the texel as an index into an array of material textures etc. It means you have to write your own fragment shader that can handle all of your tile materials, but it will be much more performant than trying to render thousands of small meshes with a different material each, and it should enable you to easily draw the whole map each frame.

If you need to change a tile material, you simply write a new pixel value to the image texture at the given xoordinates and upload the changed area to a GPU texture, Zylann actually created a VisualServer function just for this, but if changes don’t happen in every single frame, simply recreating the whole texture from the Image containing the material values will likely be fast enough. Caveat: Godot insists on transforming the color space of RGB and RGBA Images, so make sure you either don’t use these formats or take the conversion into account (if anyone knows how to disable the auto-conversion, please enlighten me).

Even if your terrain is a voxellike Minecraft terrain with elevation, I think this approach is doable as long as you don’t have multiple blocks with different materials stacked on top of each other. You’ll just have to find a way to replace the flat mesh with a blocky mesh, but that can be algorithmically regenerated every time a change is made to the map, since it’s geometry is fairly simple (e.g. not much logic will be involved in creating the mesh, so likely fast enough even in GdScript).