508 lines
15 KiB
GDScript
508 lines
15 KiB
GDScript
@tool
|
|
class_name TerrainGenerator
|
|
extends MeshInstance3D
|
|
|
|
enum PreviewMode { NONE, BIOME, SLOPE, MASK_INDEX, RAIL_DISTANCE, STATION_DISTANCE }
|
|
|
|
signal terrain_generated
|
|
signal props_data_ready(props_data: TerrainPropsData)
|
|
|
|
@export var noise_setting: FastNoiseLite:
|
|
set(value):
|
|
if noise_setting and noise_setting.changed.is_connected(_on_settings_changed):
|
|
noise_setting.changed.disconnect(_on_settings_changed)
|
|
noise_setting = value
|
|
if noise_setting:
|
|
noise_setting.changed.connect(_on_settings_changed)
|
|
_request_update()
|
|
|
|
@export var material_setting: Material:
|
|
set(value):
|
|
material_setting = value
|
|
if mesh:
|
|
mesh.surface_set_material(0, material_setting)
|
|
|
|
@export_group("Terrain Size")
|
|
@export var terrain_size: Vector2 = Vector2(200, 200):
|
|
set(value):
|
|
terrain_size = value
|
|
_request_update()
|
|
|
|
@export_range(2, 512) var subdivision: int = 128:
|
|
set(value):
|
|
subdivision = value
|
|
_request_update()
|
|
|
|
@export var max_height: float = 50.0:
|
|
set(value):
|
|
max_height = value
|
|
_request_update()
|
|
|
|
@export_group("Island Mask")
|
|
@export var island_mask: bool = true:
|
|
set(value):
|
|
island_mask = value
|
|
_request_update()
|
|
|
|
@export_range(0.0, 1.0) var mask_falloff_start: float = 0.5:
|
|
set(value):
|
|
mask_falloff_start = value
|
|
_request_update()
|
|
|
|
@export_range(0.1, 2.0) var mask_power: float = 1.5:
|
|
set(value):
|
|
mask_power = value
|
|
_request_update()
|
|
|
|
@export_group("Control Layers")
|
|
@export var control_layers: TerrainControlLayers:
|
|
set(value):
|
|
if control_layers and control_layers.layers_changed.is_connected(_on_settings_changed):
|
|
control_layers.layers_changed.disconnect(_on_settings_changed)
|
|
control_layers = value
|
|
if control_layers:
|
|
control_layers.layers_changed.connect(_on_settings_changed)
|
|
_request_update()
|
|
|
|
@export_group("Rail Constraints")
|
|
@export var rail_paths: Array[NodePath] = []:
|
|
set(value):
|
|
rail_paths = value
|
|
_request_update()
|
|
|
|
@export var rail_influence_radius: float = 10.0:
|
|
set(value):
|
|
rail_influence_radius = value
|
|
_request_update()
|
|
|
|
@export var rail_flatten_strength: float = 1.0:
|
|
set(value):
|
|
rail_flatten_strength = value
|
|
_request_update()
|
|
|
|
@export_range(0.0, 45.0) var rail_max_slope: float = 5.0:
|
|
set(value):
|
|
rail_max_slope = value
|
|
_request_update()
|
|
|
|
@export_group("Station Constraints")
|
|
@export var station_paths: Array[NodePath] = []:
|
|
set(value):
|
|
station_paths = value
|
|
_request_update()
|
|
|
|
@export var station_influence_radius: float = 25.0:
|
|
set(value):
|
|
station_influence_radius = value
|
|
_request_update()
|
|
|
|
@export var station_flatten_height: float = 0.0:
|
|
set(value):
|
|
station_flatten_height = value
|
|
_request_update()
|
|
|
|
@export var use_station_y_as_height: bool = true:
|
|
set(value):
|
|
use_station_y_as_height = value
|
|
_request_update()
|
|
|
|
@export_group("Preview")
|
|
@export var preview_mode: PreviewMode = PreviewMode.NONE:
|
|
set(value):
|
|
preview_mode = value
|
|
_request_update()
|
|
|
|
@export var preview_mask_index: int = 0:
|
|
set(value):
|
|
preview_mask_index = value
|
|
if preview_mode == PreviewMode.MASK_INDEX:
|
|
_request_update()
|
|
|
|
@export_group("Actions")
|
|
@export var regenerate: bool = false:
|
|
set(_value):
|
|
generate_terrain()
|
|
|
|
@export var bake_collision: bool = false:
|
|
set(_value):
|
|
_bake_collision()
|
|
|
|
var _update_queued: bool = false
|
|
var _rail_spline_cache: Array[Curve3D] = []
|
|
var _station_positions: Array[Vector3] = []
|
|
var _props_data: TerrainPropsData
|
|
|
|
|
|
func _ready() -> void:
|
|
if noise_setting:
|
|
if not noise_setting.changed.is_connected(_on_settings_changed):
|
|
noise_setting.changed.connect(_on_settings_changed)
|
|
generate_terrain()
|
|
|
|
|
|
func _on_settings_changed() -> void:
|
|
_request_update()
|
|
|
|
|
|
func _request_update() -> void:
|
|
if _update_queued:
|
|
return
|
|
_update_queued = true
|
|
call_deferred("_do_update")
|
|
|
|
|
|
func _do_update() -> void:
|
|
_update_queued = false
|
|
generate_terrain()
|
|
|
|
|
|
func generate_terrain() -> void:
|
|
if not noise_setting:
|
|
return
|
|
|
|
_cache_constraints()
|
|
|
|
var st := SurfaceTool.new()
|
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
|
|
|
var step_x: float = terrain_size.x / float(subdivision - 1)
|
|
var step_z: float = terrain_size.y / float(subdivision - 1)
|
|
var half_x: float = terrain_size.x / 2.0
|
|
var half_z: float = terrain_size.y / 2.0
|
|
|
|
var heights: Array[float] = []
|
|
var biome_ids: Array[int] = []
|
|
var slope_values: Array[float] = []
|
|
var rail_distances: Array[float] = []
|
|
var station_distances: Array[float] = []
|
|
var mask_values: Array[Array] = []
|
|
|
|
heights.resize(subdivision * subdivision)
|
|
biome_ids.resize(subdivision * subdivision)
|
|
slope_values.resize(subdivision * subdivision)
|
|
rail_distances.resize(subdivision * subdivision)
|
|
station_distances.resize(subdivision * subdivision)
|
|
|
|
var mask_count := 0
|
|
if control_layers:
|
|
mask_count = control_layers.mask_layers.size()
|
|
for i in range(mask_count):
|
|
var arr: Array[float] = []
|
|
arr.resize(subdivision * subdivision)
|
|
mask_values.append(arr)
|
|
|
|
# Pass 1: Generate base heights
|
|
for z in range(subdivision):
|
|
for x in range(subdivision):
|
|
var world_x: float = x * step_x - half_x
|
|
var world_z: float = z * step_z - half_z
|
|
var idx: int = z * subdivision + x
|
|
var uv := Vector2(float(x) / float(subdivision - 1), float(z) / float(subdivision - 1))
|
|
|
|
var base_noise: float = (noise_setting.get_noise_2d(world_x, world_z) + 1.0) * 0.5
|
|
var height: float = base_noise * max_height
|
|
|
|
# Apply height profile from control layers
|
|
if control_layers and control_layers.height_profile:
|
|
height = control_layers.get_height_profile_value(base_noise) * max_height
|
|
|
|
# Apply island mask
|
|
if island_mask:
|
|
height *= _calculate_island_mask(world_x, world_z, half_x, half_z)
|
|
|
|
# Sample biome
|
|
var biome_color := Color.WHITE
|
|
if control_layers:
|
|
biome_color = control_layers.get_biome_value(uv)
|
|
biome_ids[idx] = _color_to_biome_id(biome_color)
|
|
|
|
# Sample slope limit
|
|
var slope_limit := 45.0
|
|
if control_layers:
|
|
slope_limit = control_layers.get_slope_limit(uv)
|
|
slope_values[idx] = slope_limit
|
|
|
|
# Sample masks
|
|
for m in range(mask_count):
|
|
mask_values[m][idx] = control_layers.get_mask_value(m, uv)
|
|
|
|
heights[idx] = height
|
|
|
|
# Pass 2: Apply rail constraints
|
|
for z in range(subdivision):
|
|
for x in range(subdivision):
|
|
var world_x: float = x * step_x - half_x
|
|
var world_z: float = z * step_z - half_z
|
|
var idx: int = z * subdivision + x
|
|
var world_pos := Vector3(world_x, heights[idx], world_z)
|
|
|
|
var rail_result := _get_rail_constraint(world_pos)
|
|
rail_distances[idx] = rail_result.distance
|
|
|
|
if rail_result.distance < rail_influence_radius and rail_result.distance >= 0.0:
|
|
var t: float = 1.0 - (rail_result.distance / rail_influence_radius)
|
|
t = t * t * rail_flatten_strength
|
|
heights[idx] = lerpf(heights[idx], rail_result.height, t)
|
|
|
|
# Pass 3: Apply station constraints
|
|
for z in range(subdivision):
|
|
for x in range(subdivision):
|
|
var world_x: float = x * step_x - half_x
|
|
var world_z: float = z * step_z - half_z
|
|
var idx: int = z * subdivision + x
|
|
var world_pos := Vector3(world_x, heights[idx], world_z)
|
|
|
|
var station_result := _get_station_constraint(world_pos)
|
|
station_distances[idx] = station_result.distance
|
|
|
|
if station_result.distance < station_influence_radius and station_result.distance >= 0.0:
|
|
var t: float = 1.0 - (station_result.distance / station_influence_radius)
|
|
t = t * t
|
|
heights[idx] = lerpf(heights[idx], station_result.height, t)
|
|
|
|
# Pass 4: Apply slope limits
|
|
_apply_slope_limits(heights, slope_values, step_x, step_z)
|
|
|
|
# Build props data
|
|
_props_data = TerrainPropsData.new()
|
|
_props_data.resolution = Vector2i(subdivision, subdivision)
|
|
_props_data.terrain_size = terrain_size
|
|
_props_data.heights = heights
|
|
_props_data.biome_ids = biome_ids
|
|
_props_data.slope_values = slope_values
|
|
_props_data.rail_distances = rail_distances
|
|
_props_data.station_distances = station_distances
|
|
_props_data.mask_values = mask_values
|
|
|
|
# Build mesh
|
|
for z in range(subdivision - 1):
|
|
for x in range(subdivision - 1):
|
|
var idx00: int = z * subdivision + x
|
|
var idx10: int = z * subdivision + (x + 1)
|
|
var idx01: int = (z + 1) * subdivision + x
|
|
var idx11: int = (z + 1) * subdivision + (x + 1)
|
|
|
|
var x0: float = x * step_x - half_x
|
|
var x1: float = (x + 1) * step_x - half_x
|
|
var z0: float = z * step_z - half_z
|
|
var z1: float = (z + 1) * step_z - half_z
|
|
|
|
var v00 := Vector3(x0, heights[idx00], z0)
|
|
var v10 := Vector3(x1, heights[idx10], z0)
|
|
var v01 := Vector3(x0, heights[idx01], z1)
|
|
var v11 := Vector3(x1, heights[idx11], z1)
|
|
|
|
var uv00 := Vector2(x0 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
|
|
var uv10 := Vector2(x1 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
|
|
var uv01 := Vector2(x0 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
|
|
var uv11 := Vector2(x1 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
|
|
|
|
# Preview mode coloring
|
|
var c00 := _get_preview_color(idx00, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
|
var c10 := _get_preview_color(idx10, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
|
var c01 := _get_preview_color(idx01, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
|
var c11 := _get_preview_color(idx11, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
|
|
|
# Triangle 1: v00, v10, v01 (CCW winding, normal pointing up)
|
|
var normal1: Vector3 = (v01 - v00).cross(v10 - v00).normalized()
|
|
st.set_normal(normal1)
|
|
st.set_color(c00)
|
|
st.set_uv(uv00)
|
|
st.add_vertex(v00)
|
|
st.set_color(c10)
|
|
st.set_uv(uv10)
|
|
st.add_vertex(v10)
|
|
st.set_color(c01)
|
|
st.set_uv(uv01)
|
|
st.add_vertex(v01)
|
|
|
|
# Triangle 2: v10, v11, v01 (CCW winding, normal pointing up)
|
|
var normal2: Vector3 = (v01 - v10).cross(v11 - v10).normalized()
|
|
st.set_normal(normal2)
|
|
st.set_color(c10)
|
|
st.set_uv(uv10)
|
|
st.add_vertex(v10)
|
|
st.set_color(c11)
|
|
st.set_uv(uv11)
|
|
st.add_vertex(v11)
|
|
st.set_color(c01)
|
|
st.set_uv(uv01)
|
|
st.add_vertex(v01)
|
|
|
|
mesh = st.commit()
|
|
|
|
if material_setting:
|
|
mesh.surface_set_material(0, material_setting)
|
|
|
|
terrain_generated.emit()
|
|
props_data_ready.emit(_props_data)
|
|
|
|
|
|
func _cache_constraints() -> void:
|
|
_rail_spline_cache.clear()
|
|
_station_positions.clear()
|
|
|
|
for path in rail_paths:
|
|
if path.is_empty():
|
|
continue
|
|
var node := get_node_or_null(path)
|
|
if node is Path3D:
|
|
var path3d := node as Path3D
|
|
if path3d.curve:
|
|
_rail_spline_cache.append(path3d.curve)
|
|
|
|
for path in station_paths:
|
|
if path.is_empty():
|
|
continue
|
|
var node := get_node_or_null(path)
|
|
if node is Node3D:
|
|
_station_positions.append((node as Node3D).global_position)
|
|
|
|
|
|
func _get_rail_constraint(world_pos: Vector3) -> Dictionary:
|
|
var min_dist := INF
|
|
var rail_height := world_pos.y
|
|
|
|
for curve in _rail_spline_cache:
|
|
var closest := curve.get_closest_point(world_pos)
|
|
var dist := Vector2(world_pos.x - closest.x, world_pos.z - closest.z).length()
|
|
if dist < min_dist:
|
|
min_dist = dist
|
|
rail_height = closest.y
|
|
|
|
return {"distance": min_dist, "height": rail_height}
|
|
|
|
|
|
func _get_station_constraint(world_pos: Vector3) -> Dictionary:
|
|
var min_dist := INF
|
|
var target_height := station_flatten_height
|
|
|
|
for station_pos in _station_positions:
|
|
var dist := Vector2(world_pos.x - station_pos.x, world_pos.z - station_pos.z).length()
|
|
if dist < min_dist:
|
|
min_dist = dist
|
|
if use_station_y_as_height:
|
|
target_height = station_pos.y
|
|
|
|
return {"distance": min_dist, "height": target_height}
|
|
|
|
|
|
func _apply_slope_limits(heights: Array[float], slope_limits: Array[float], step_x: float, step_z: float) -> void:
|
|
var max_iterations := 10
|
|
for _iter in range(max_iterations):
|
|
var changed := false
|
|
for z in range(1, subdivision - 1):
|
|
for x in range(1, subdivision - 1):
|
|
var idx: int = z * subdivision + x
|
|
var slope_limit_rad: float = deg_to_rad(slope_limits[idx])
|
|
var max_delta_x: float = tan(slope_limit_rad) * step_x
|
|
var max_delta_z: float = tan(slope_limit_rad) * step_z
|
|
|
|
var h := heights[idx]
|
|
var h_xm := heights[idx - 1]
|
|
var h_xp := heights[idx + 1]
|
|
var h_zm := heights[idx - subdivision]
|
|
var h_zp := heights[idx + subdivision]
|
|
|
|
var target := h
|
|
if abs(h - h_xm) > max_delta_x:
|
|
target = minf(target, h_xm + max_delta_x) if h > h_xm else maxf(target, h_xm - max_delta_x)
|
|
changed = true
|
|
if abs(h - h_xp) > max_delta_x:
|
|
target = minf(target, h_xp + max_delta_x) if h > h_xp else maxf(target, h_xp - max_delta_x)
|
|
changed = true
|
|
if abs(h - h_zm) > max_delta_z:
|
|
target = minf(target, h_zm + max_delta_z) if h > h_zm else maxf(target, h_zm - max_delta_z)
|
|
changed = true
|
|
if abs(h - h_zp) > max_delta_z:
|
|
target = minf(target, h_zp + max_delta_z) if h > h_zp else maxf(target, h_zp - max_delta_z)
|
|
changed = true
|
|
|
|
heights[idx] = lerpf(h, target, 0.5)
|
|
|
|
if not changed:
|
|
break
|
|
|
|
|
|
func _color_to_biome_id(color: Color) -> int:
|
|
return int(color.r * 255.0) + int(color.g * 255.0) * 256
|
|
|
|
|
|
func _get_preview_color(idx: int, biome_ids: Array[int], slope_values: Array[float],
|
|
rail_distances: Array[float], station_distances: Array[float], mask_values: Array[Array]) -> Color:
|
|
match preview_mode:
|
|
PreviewMode.NONE:
|
|
return Color.WHITE
|
|
PreviewMode.BIOME:
|
|
var biome := biome_ids[idx]
|
|
return Color.from_hsv(fmod(float(biome) * 0.618033988749, 1.0), 0.7, 0.9)
|
|
PreviewMode.SLOPE:
|
|
var slope := slope_values[idx] / 90.0
|
|
return Color(slope, 1.0 - slope, 0.0)
|
|
PreviewMode.MASK_INDEX:
|
|
if preview_mask_index < mask_values.size():
|
|
var v: float = mask_values[preview_mask_index][idx]
|
|
return Color(v, v, v)
|
|
return Color.BLACK
|
|
PreviewMode.RAIL_DISTANCE:
|
|
var d: float = clampf(rail_distances[idx] / (rail_influence_radius * 2.0), 0.0, 1.0)
|
|
return Color(1.0 - d, 0.2, d)
|
|
PreviewMode.STATION_DISTANCE:
|
|
var d: float = clampf(station_distances[idx] / (station_influence_radius * 2.0), 0.0, 1.0)
|
|
return Color(0.2, 1.0 - d, d)
|
|
return Color.WHITE
|
|
|
|
|
|
func get_props_data() -> TerrainPropsData:
|
|
return _props_data
|
|
|
|
|
|
func _calculate_island_mask(x: float, z: float, half_x: float, half_z: float) -> float:
|
|
var nx: float = x / half_x
|
|
var nz: float = z / half_z
|
|
var dist: float = sqrt(nx * nx + nz * nz)
|
|
|
|
if dist < mask_falloff_start:
|
|
return 1.0
|
|
elif dist > 1.0:
|
|
return 0.0
|
|
else:
|
|
var t: float = (dist - mask_falloff_start) / (1.0 - mask_falloff_start)
|
|
return pow(1.0 - t, mask_power)
|
|
|
|
|
|
func _bake_collision() -> void:
|
|
if not mesh:
|
|
push_warning("TerrainGenerator: No mesh to bake collision from")
|
|
return
|
|
|
|
var static_body: StaticBody3D
|
|
var collision_shape: CollisionShape3D
|
|
|
|
for child in get_children():
|
|
if child is StaticBody3D:
|
|
static_body = child
|
|
break
|
|
|
|
if not static_body:
|
|
static_body = StaticBody3D.new()
|
|
static_body.name = "TerrainCollision"
|
|
add_child(static_body)
|
|
if Engine.is_editor_hint():
|
|
static_body.owner = get_tree().edited_scene_root
|
|
|
|
for child in static_body.get_children():
|
|
if child is CollisionShape3D:
|
|
collision_shape = child
|
|
break
|
|
|
|
if not collision_shape:
|
|
collision_shape = CollisionShape3D.new()
|
|
collision_shape.name = "CollisionShape"
|
|
static_body.add_child(collision_shape)
|
|
if Engine.is_editor_hint():
|
|
collision_shape.owner = get_tree().edited_scene_root
|
|
|
|
collision_shape.shape = mesh.create_trimesh_shape()
|
|
print("TerrainGenerator: Collision baked successfully")
|