Procedural city

Its a astar grid with random id connected to other random id.
For every segment I place a random scene on both side of the segment.
I get the aabb and build a polygon for every asset so i can check for collision.
There are 2 types of settlement, villages with only houses, and city with big buildings in the center.

@tool
extends Node3D

@export_category("Skip")
@export var skip = false

# Input
@onready var debug_node = $"../Debug"
@onready var terrain_node = $"../Terrain"
@onready var settlement_node = $"../Settlement"
var terrain_noise : TerrainNoise
var settlements : Dictionary

# Internal
@export_category(" ")
@export var city_size = 2000
@export var village_size = 500
@export var city_street_length = 200
@export var village_street_length = 100 # Target length
@export_range(0.0, 0.49) var jitter = 0.4
@export var road_width = 20.0
#var astar_2d_village = AStar2D.new()
#var astar_2d_city = AStar3D.new()
@export var city_points = 10
@export var village_points = 5
@export var distance_to_generate = 100000
var camera : Camera3D
var last_camera_pos : Vector3
var is_ready = false
var assets = {
	"house" = ["res://asset/urban/house_1/house_1.glb", "res://asset/urban/house_2/house_2.glb", "res://asset/urban/house_3/house_3.glb"],
	"apartment" = ["res://asset/settlement/soviet_apartment_0.glb", "res://asset/urban/office_tower_1/office_tower_1.glb", "res://asset/urban/warehouse_0/warehouse_0.glb", "res://asset/urban/warehouse_1/warehouse_1.glb"]
}

# Output
@export_category("Output")
@export var settlements_output : Dictionary

func _ready() -> void:
	if skip:
		push_warning("settlement_building.gd skipped")
		return
	bootstrap()

func _process(delta: float) -> void:
	if not is_ready:
		return
	if camera.global_position.distance_to(last_camera_pos) > 300:
		update_settlements()

func bootstrap():
	input()
	if Engine.is_editor_hint():
		camera = EditorInterface.get_editor_viewport_3d().get_camera_3d()
	else:
		get_viewport().get_camera_3d()
	last_camera_pos = camera.global_position
	update_settlements()
	is_ready = true
	
func input():
	terrain_noise = terrain_node.terrain_noise
	settlements = settlement_node.settlements # WARNING Any change to settlement will also change the value in settlement_node, even with duplicate()
	for grid_pos in settlements:
		var settlement = settlements[grid_pos]
		settlement["generated"] = false

func update_settlements():
	for grid_pos in settlements:
		var settlement = settlements[grid_pos]
		var world_pos_3d = settlement.world_pos_3d
		var world_pos_2d = settlement.world_pos_2d
		var type = settlement.type
		if world_pos_3d.distance_to(camera.global_position) < distance_to_generate and not settlement["generated"] == true:
			settlement["generated"] = true
			var data = generate_segments(world_pos_2d, type)
			var segments = data[0]
			var rect = data[1]
			place_building(segments, type, rect)

func setup_astar(size: float, street_length : float) -> AStar2D:
	var astar = AStar2D.new()
	var grid_res = int(size / street_length)
	var grid_step = size / float(grid_res)
	var point_ids = {}
	var id = 0
	
	var center = (grid_res - 1) / 2.0
	var max_dist = (grid_res - 1) / 2.0
	# Prevent division by zero if grid_res is 1
	if max_dist == 0:
		max_dist = 1 

	for y in range(grid_res):
		for x in range(grid_res):
			# 1. Calculate distance from center (0.0 at center, 1.0 at edge)
			var dist_x = abs(x - center)
			var dist_y = abs(y - center)
			# Use max() for a square falloff (edges are 1.0)
			# Use Vector2(dist_x, dist_y).length() for circular falloff (corners are 1.0)
			var t = max(dist_x, dist_y) / max_dist
			# Clamp to ensure 0.0 - 1.0 range
			t = clamp(t, 0.0, 1.0)
			# 2. Apply the scale to the jitter
			var current_jitter = t * jitter
			# 3. Generate position using the calculated jitter
			var grid_pos = Vector2(x + 0.5 + randf_range(-current_jitter, current_jitter), y + 0.5 + randf_range(-current_jitter, current_jitter)) * grid_step
			astar.add_point(id, grid_pos)
			point_ids[Vector2i(x, y)] = id
			id += 1
			
	for y in range(grid_res):
		for x in range(grid_res):
			var current_id = point_ids[Vector2i(x, y)]
			for dy in range(-1, 2):
				for dx in range(-1, 2):
					if not point_ids.has(Vector2i(x + dx, y + dy)):
						continue
					if dy == 0 and dx == 0:
						continue
					if abs(dy) == 1 and abs(dx) == 1:
						continue
					var neighbor_id = point_ids[Vector2i(x + dx, y + dy)]
					if not astar.are_points_connected(current_id, neighbor_id):
						astar.connect_points(current_id, neighbor_id)
						#debug_node.draw_line_2d([astar.get_point_position(current_id), astar.get_point_position(neighbor_id)])
	return astar

func generate_segments(world_pos_2d : Vector2, type : String) -> Array:
	var size = city_size if type == "city" else village_size
	var num_point = city_points if type == "city" else village_points
	var street_length = city_street_length if type == "city" else village_street_length
	var astar = setup_astar(size, street_length)
	var rect = Rect2(Vector2.ZERO, Vector2.ONE * size)
	rect.position = world_pos_2d - Vector2.ONE * size / 2
	debug_node.draw_rect2(rect)
	
	var all_ids = astar.get_point_ids().duplicate() as Array
	all_ids.shuffle()
	var selected_ids = []
	for i in range(min(num_point, all_ids.size())):
		selected_ids.append(all_ids[i])
	
	var all_segments = []
	var segments_seen = {}
	var ids_seen = {}
	for id in selected_ids:
		var other_id = -1
		var retries = 0
		while retries < 10:
			other_id = selected_ids.pick_random()
			if not other_id == id:
				break
		if retries >= 10:
			printerr("settlement_building.gd: failed to connect point")
			continue
		if ids_seen.has([other_id, id]):
			continue
		ids_seen[[other_id, id]] = true
		
		var path = astar.get_point_path(id, other_id)
		for i in range(path.size() - 1):
			var seg_start = path[i] + rect.position
			var seg_end = path[i + 1] + rect.position
			if segments_seen.has([seg_start, seg_end]) or segments_seen.has([seg_end, seg_start]):
				continue
			segments_seen[[seg_start, seg_end]] = true
			all_segments.append([seg_start, seg_end])
			debug_node.draw_line_2d([seg_start, seg_end])
			
	return [all_segments, rect]

func place_building(segments: Array, type: String, rect: Rect2):
	var all_polygons: Array = []
	var half_road = road_width / 2.0
	var gap_margin = 10.0

	# =========================
	# PHASE 1: Build ALL road polygons first (so every building checks the entire road network)
	# =========================
	for segment in segments:
		var seg_start: Vector2 = segment[0]
		var seg_end: Vector2 = segment[1]

		var direction = (seg_end - seg_start).normalized()
		var perp_left = Vector2(-direction.y, direction.x)
		var perp_right = Vector2(direction.y, -direction.x)

		# Road polygon (full width)
		var road_polygon = [
			seg_start + perp_left * half_road,
			seg_start + perp_right * half_road,
			seg_end + perp_right * half_road,
			seg_end + perp_left * half_road,
		]

		draw_polygon(road_polygon)
		all_polygons.append(road_polygon)

	# =========================
	# PHASE 2: Place buildings (collision checks now see EVERY road + previously placed buildings)
	# =========================
	for segment in segments:
		var seg_start: Vector2 = segment[0]
		var seg_end: Vector2 = segment[1]
		var seg_length = seg_start.distance_to(seg_end)
		var direction = (seg_end - seg_start).normalized()
		var perp_left = Vector2(-direction.y, direction.x)
		var perp_right = Vector2(direction.y, -direction.x)

		# =========================
		# ASSET SELECTION (per segment)
		# =========================
		var seg_center = seg_start.lerp(seg_end, 0.5)
		var distance = seg_center.distance_to(rect.get_center())
		var asset_type = "house"
		if type == "city" and distance < 500:
			asset_type = "apartment"
		var array: Array = assets[asset_type]

		# =========================
		# BOTH SIDES OF ROAD
		# =========================
		var sides = [perp_left, perp_right]
		for side_dir in sides:
			var current_dist_along = 0.0
			var target_angle = side_dir.angle() + PI / 2.0

			while true:
				# =========================
				# PICK & LOAD ASSET (temp instance only to read mesh/AABB)
				# =========================
				var asset = array.pick_random()
				var resource = load(asset)
				var temp_instance = resource.instantiate()

				var mesh_instance = temp_instance.get_child(0)
				var mesh: Mesh = mesh_instance.mesh
				var aabb = mesh.get_aabb()

				var scale_factor = 1.5
				var building_width = aabb.size.x * scale_factor
				var building_depth = aabb.size.z * scale_factor

				# Free temp instance immediately (we only needed its mesh data)
				temp_instance.queue_free()

				# =========================
				# SPACE CHECK ALONG SEGMENT
				# =========================
				var space_needed = (building_width / 2.0) + gap_margin
				if (current_dist_along + space_needed) > seg_length:
					break

				# =========================
				# WORLD POSITION
				# =========================
				var along_offset = direction * (current_dist_along + building_width / 2.0)
				var away_offset = side_dir * (half_road + (building_depth / 2.0) + gap_margin)
				var world_pos = seg_start + along_offset + away_offset

				# =========================
				# BUILD ROTATED BUILDING POLYGON
				# =========================
				var scaled_size = aabb.size * scale_factor
				var local_points = [
					Vector2(-scaled_size.x / 2, -scaled_size.z / 2),
					Vector2(-scaled_size.x / 2, scaled_size.z / 2),
					Vector2(scaled_size.x / 2, scaled_size.z / 2),
					Vector2(scaled_size.x / 2, -scaled_size.z / 2),
				]

				var building_polygon: Array = []
				for point in local_points:
					var rotated = point.rotated(target_angle)
					building_polygon.append(Vector2(
						world_pos.x + rotated.x,
						world_pos.y + rotated.y
					))

				# =========================
				# COLLISION CHECK (now against ALL roads + already placed buildings)
				# =========================
				var has_collision = false
				for existing_poly in all_polygons:
					var result = Geometry2D.intersect_polygons(building_polygon, existing_poly)
					if result.size() > 0:
						has_collision = true
						break

				if has_collision:
					current_dist_along += building_width + gap_margin
					continue

				# =========================
				# PLACE BUILDING
				# =========================
				var placed_instance = MeshInstance3D.new()
				placed_instance.mesh = mesh
				placed_instance.position = Vector3(world_pos.x, 0, world_pos.y)
				placed_instance.position.y = terrain_noise.get_altitude(placed_instance.position)
				placed_instance.rotation.y = -target_angle
				add_child(placed_instance)

				# Store polygon for future collision checks
				all_polygons.append(building_polygon)

				# Debug draw
				draw_polygon(building_polygon)

				# Move forward
				current_dist_along += building_width + gap_margin

func draw_polygon(polygon: Array):
	if polygon.size() < 2:
		return
	for i in range(polygon.size()):
		var next_i = (i + 1) % polygon.size()  # Wrap around to close the loop
		debug_node.draw_line_2d([polygon[i], polygon[next_i]])
2 Likes

I would be interested in hearing or reading about the parameters, perhaps modification tl the code.