Render roads on terrain using spatial hashing

This technique enables the rendering of roads without the use of masks or meshes, providing infinite resolution and preventing flickering artifacts.

Result from far:


Closer, the shader remove the central line when it find more than 2 segments close to road_width * 1.5

So you have to generate Vector2 segments first:

1 Send all your segment to the shader (Assume the terrain origin is at 0,0,0):

func create_road_spatial_hash():
	var grid_res = 1024 
	var terrain_size = terrain_node.size
	
	# 1. Initialize the CPU-side grid
	var grid_buckets = []
	grid_buckets.resize(grid_res * grid_res)
	for i in range(grid_buckets.size()):
		grid_buckets[i] = []

	# 2. Sort segments into cells
	for path in roads["curved_paths"]:
		for i in range(path.size() - 1):
			var p1: Vector2 = path[i]
			var p2: Vector2 = path[i + 1]
			
			var norm_a = p1 / terrain_size
			var norm_b = p2 / terrain_size
			
			# Add a small buffer(or margin) based on road width (normalized to 0.0 - 1.0 range)
			var buffer = (road_width * 1.5) / terrain_size # Encrase if you see square glitch
			
			var min_x = clampi(int((min(norm_a.x, norm_b.x) - buffer) * grid_res), 0, grid_res - 1)
			var max_x = clampi(int((max(norm_a.x, norm_b.x) + buffer) * grid_res), 0, grid_res - 1)
			var min_y = clampi(int((min(norm_a.y, norm_b.y) - buffer) * grid_res), 0, grid_res - 1)
			var max_y = clampi(int((max(norm_a.y, norm_b.y) + buffer) * grid_res), 0, grid_res - 1)
			
			for x in range(min_x, max_x + 1):
				for y in range(min_y, max_y + 1):
					grid_buckets[y * grid_res + x].append([p1, p2])

	# 3. Prepare raw float arrays
	var data_raw = PackedFloat32Array()
	var grid_raw = PackedFloat32Array()
	grid_raw.resize(grid_res * grid_res * 2) 
	
	var current_offset = 0
	for i in range(grid_buckets.size()):
		var cell_list = grid_buckets[i]
		grid_raw[i * 2] = float(current_offset)
		grid_raw[i * 2 + 1] = float(cell_list.size())
		
		for s in cell_list:
			data_raw.append(s[0].x)
			data_raw.append(s[0].y)
			data_raw.append(s[1].x)
			data_raw.append(s[1].y)
			current_offset += 2

	# 4. Convert to 2D Square Texture (to avoid hardware width limits)
	var total_vec2_points = data_raw.size() / 2
	var data_side = int(ceil(sqrt(total_vec2_points))) 
	
	# Pad the array so it fits the square exactly
	var required_floats = data_side * data_side * 2
	while data_raw.size() < required_floats:
		data_raw.append(0.0)

	var grid_img = Image.create_from_data(grid_res, grid_res, false, Image.FORMAT_RGF, grid_raw.to_byte_array())
	var data_img = Image.create_from_data(data_side, data_side, false, Image.FORMAT_RGF, data_raw.to_byte_array())
	
	var grid_tex = ImageTexture.create_from_image(grid_img)
	var data_tex = ImageTexture.create_from_image(data_img)

	# 5. Push to Shader
	terrain_material.set_shader_parameter("grid_tex", grid_tex)
	terrain_material.set_shader_parameter("data_tex", data_tex)
	terrain_material.set_shader_parameter("grid_resolution", grid_res)
	terrain_material.set_shader_parameter("data_tex_width", data_side)

The shader: (change terrain size to your terrain size)

shader_type spatial;
//render_mode depth_draw_always; // For road offset

uniform float terrain_size = 200000;

// Road segments
uniform sampler2D grid_tex : filter_nearest;
uniform sampler2D data_tex : filter_nearest;
uniform int data_tex_width;
uniform int grid_resolution = 1024;
uniform float road_width = 20.0;
uniform vec3 road_color : source_color = vec3(1.0, 1.0, 1.0);
uniform vec3 road_line_color : source_color = vec3(1.0, 1.0, 1.0);

varying vec3 world_pos;

void vertex() {
	world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

void fragment() {
	vec3 color = vec3(1.0, 1.0, 1.0);
	vec2 terrain_uv = world_pos.xz / terrain_size;
	
	if (world_pos.y < -2000.0) {
		color = vec3(0.05, 0.08, 0.15); // Dark navy blue
	}
	else if (world_pos.y < -100.0) {
		color = vec3(0.08, 0.15, 0.25); // Deep ocean blue
	}
	else if (world_pos.y < 0.0) {
		color = vec3(0.15, 0.28, 0.38); // Turquoise/aquamarine
	}
	else if (world_pos.y < 20.0) {
		color = vec3(0.88, 0.85, 0.75); // Light sand/beige
	}
	else if (world_pos.y < 300.0) {
		color = vec3(0.38, 0.58, 0.28); // Lush green (crop/forest mix)
	}
	else if (world_pos.y < 2000.0) {
		color = vec3(0.171, 0.274, 0.191); // Deep green/brown forest
	}
	else if (world_pos.y < 4000.0) {
		color = vec3(0.48, 0.44, 0.38); // Grayish brown (scree/rock)
	}
	else {
		color = vec3(0.96, 0.96, 0.98); // Clean snow white
	}
	
	// Road
	int segments_nearby = 0; // For line draw
	float line_width = 0.3;
	float line_softness = 0.05;
	vec2 p = world_pos.xz;
	vec2 norm = p / terrain_size;
	float inside = step(0.0, norm.x) * step(0.0, norm.y) * step(norm.x, 1.0) * step(norm.y, 1.0);
	ivec2 cell = ivec2(clamp(norm, vec2(0.0), vec2(1.0)) * float(grid_resolution));
	cell = clamp(cell, ivec2(0), ivec2(grid_resolution - 1));
	vec2 grid_uv = (vec2(cell) + 0.5) / float(grid_resolution);
	vec2 entry = texture(grid_tex, grid_uv).rg;
	int offset = int(entry.r);
	int count  = int(entry.g);
	float min_dist = 1e20;
	for (int i = 0; i < count; i++) {
		int base = offset + i * 2;
		int x1 = base % data_tex_width;
		int y1 = base / data_tex_width;
		vec2 uv1 = (vec2(float(x1), float(y1)) + 0.5) / float(data_tex_width);
		int x2 = (base + 1) % data_tex_width;
		int y2 = (base + 1) / data_tex_width;
		vec2 uv2 = (vec2(float(x2), float(y2)) + 0.5) / float(data_tex_width);
		vec2 a = texture(data_tex, uv1).rg;
		vec2 b = texture(data_tex, uv2).rg;
		vec2 pa = p - a;
		vec2 ba = b - a;
		float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
		float d = length(pa - ba * h);
		min_dist = min(min_dist, d);
		if (d < road_width * 1.5) {segments_nearby++;} // Draw line logic, count segments
	}
	float mask = smoothstep(road_width, road_width - 1.0, min_dist);
	float line_mask = smoothstep(line_width, line_width - line_softness, min_dist);
	line_mask *= mask; // Ensure the line only appears on the road (multiplied by road mask)
	
	// Intersection, more than 2 segments, dont draw line
	if (segments_nearby > 2) {line_mask = 0.0;}
	
	color = mix(color, road_color, mask);
	color = mix(color, road_line_color, line_mask);
	
	// Apply inside mask
	ALBEDO = color;
}

I also carve the road into the terrain mesh but i dont use any shader for that. Its another part of the process.

4 Likes