Make some test with terrain generator
BIN
proc-terrain/Dirt_baseColor.tga
Normal file
|
After Width: | Height: | Size: 12 MiB |
BIN
proc-terrain/Grass_baseColor.tga
Normal file
|
After Width: | Height: | Size: 12 MiB |
100
proc-terrain/TERRAIN_CONTROL_SPEC.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Terrain Control System Specification
|
||||
|
||||
## Summary
|
||||
Provide artists with deterministic, in-editor control over procedural terrain generation by
|
||||
introducing control layers (biomes, height profiles, masks, slope limits) and hard scene
|
||||
constraints (bel vedere stations and rail splines). The generator must combine these inputs
|
||||
to produce a terrain mesh and mask/biome outputs suitable for materials and prop placement.
|
||||
|
||||
## Goals
|
||||
- Allow artists to explicitly control biomes, height profiles, masks, and slope limits.
|
||||
- Ensure rails and stations act as hard constraints that shape/flatten the terrain.
|
||||
- Support a procedural rail spline (Path3D + Curve3D) with known points near stations.
|
||||
- Provide real-time preview and regeneration in-editor.
|
||||
- Expose data for props placement (density/exclusion by biome/slope/rail distance).
|
||||
|
||||
## Non-Goals
|
||||
- Final art-quality shader/material authoring.
|
||||
- Runtime streaming or terrain LOD.
|
||||
- Full track generation algorithm (assumed to exist elsewhere).
|
||||
|
||||
## Inputs
|
||||
### Scene Inputs (Hard Constraints)
|
||||
- Bel vedere stations: placed as scene nodes (Node3D).
|
||||
- Rail spline: Path3D with Curve3D (procedural). Only points near stations are known.
|
||||
|
||||
### Artist Inputs (Control Layers)
|
||||
- Biome map (Texture2D): defines biome IDs/weights across terrain.
|
||||
- Height profile (Curve): maps base noise height to final height per biome.
|
||||
- Mask layers (Texture2D array): painted/assigned regions (forest, village, rock, etc.).
|
||||
- Slope limit map (Texture2D): limits maximum slope per region.
|
||||
|
||||
## Data Model
|
||||
### TerrainControlLayers (Resource)
|
||||
- biome_map: Texture2D
|
||||
- height_profile: Curve
|
||||
- slope_limit_map: Texture2D
|
||||
- mask_layers: Array[Texture2D]
|
||||
- mask_names: Array[String]
|
||||
- resolution: Vector2i (shared texture resolution for all layers)
|
||||
|
||||
### TerrainGenerator (Node)
|
||||
- control_layers: TerrainControlLayers
|
||||
- station_paths: Array[NodePath]
|
||||
- rail_paths: Array[NodePath]
|
||||
- rail_influence_radius: float
|
||||
- station_influence_radius: float
|
||||
- station_flatten_height: float
|
||||
- rail_flatten_strength: float
|
||||
- biome_preview_mode: enum (None, Biome, Slope, MaskIndex)
|
||||
|
||||
## Workflow
|
||||
1. Artist places bel vedere station nodes in the scene.
|
||||
2. Rail spline is generated as Path3D with Curve3D (known points near stations).
|
||||
3. Artist assigns/creates a TerrainControlLayers resource.
|
||||
4. Artist paints biome map and mask layers in-editor and sets height/slope controls.
|
||||
5. TerrainGenerator regenerates and previews terrain with rail/station constraints.
|
||||
|
||||
## Generation Pipeline
|
||||
1. Base height: sample noise and scale by max_height.
|
||||
2. Apply height profile: evaluate Curve to remap height.
|
||||
3. Apply island/edge mask if enabled.
|
||||
4. Sample biome map and masks to modulate height and material blending.
|
||||
5. Apply slope limits by clamping local height gradients per pixel.
|
||||
6. Apply rail constraints:
|
||||
- Compute distance from vertex to nearest point on rail spline.
|
||||
- Within rail_influence_radius, flatten height toward rail height.
|
||||
- Enforce maximum slope along rail direction.
|
||||
7. Apply station constraints:
|
||||
- Within station_influence_radius, flatten to station height.
|
||||
- Optionally expand a platform/yard mask for props and paths.
|
||||
8. Output: mesh + per-vertex data for materials (biome/mask indices) and props.
|
||||
|
||||
## Rail Spline Handling
|
||||
- Rail spline is Path3D with Curve3D and may be partially defined near stations.
|
||||
- The generator samples the spline at fixed intervals to build a distance field.
|
||||
- If only station-adjacent points exist, interpolate curve points between stations.
|
||||
|
||||
## Editor Tooling
|
||||
- Terrain Control Painter:
|
||||
- Paint biome IDs, slope limits, and masks directly on terrain.
|
||||
- Toggle view modes (biome/slope/mask overlays).
|
||||
- Provide brush size, hardness, and opacity.
|
||||
- Preview toggle:
|
||||
- Regenerate terrain on parameter changes in the editor.
|
||||
|
||||
## Props Integration
|
||||
- Provide maps to props system:
|
||||
- biome_id, slope_limit, rail_distance, station_distance, mask values.
|
||||
- Placement rules can use these to control density and exclusions.
|
||||
|
||||
## Definition of Done
|
||||
- Artists can paint biomes/masks and see results in-editor.
|
||||
- Terrain respects rail and station constraints.
|
||||
- Slope limits and height profiles affect terrain as expected.
|
||||
- Outputs for props placement are available and consistent.
|
||||
|
||||
## Open Questions
|
||||
- How is rail height defined relative to terrain (absolute Y or curve metadata)?
|
||||
- Should station flattening use station Y or a per-station override value?
|
||||
- Expected texture resolution for control layers?
|
||||
BIN
proc-terrain/biomes.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
@@ -1,7 +1,4 @@
|
||||
[gd_resource type="FastNoiseLite" format=3 uid="uid://clpxlmv87brc7"]
|
||||
|
||||
[resource]
|
||||
noise_type = 4
|
||||
seed = 42
|
||||
frequency = 0.008
|
||||
fractal_octaves = 4
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[gd_resource type="ShaderMaterial" format=3 uid="uid://4a05tpi1tu14"]
|
||||
|
||||
[ext_resource type="Shader" uid="uid://d4b4lfd2r51iu" path="res://proc-terrain/triplanar_slope.gdshader" id="1_shader"]
|
||||
[ext_resource type="Texture2D" uid="uid://5pfh43bjvef4" path="res://proc-terrain/rock.png" id="2_50hjp"]
|
||||
[ext_resource type="Texture2D" uid="uid://rc8iqoij2c5w" path="res://proc-terrain/grass.png" id="3_8s6t3"]
|
||||
[ext_resource type="Texture2D" uid="uid://cfffdsmmgils3" path="res://proc-terrain/Dirt_baseColor.tga" id="2_50hjp"]
|
||||
[ext_resource type="Texture2D" uid="uid://dq7id4wwphvm6" path="res://proc-terrain/Grass_baseColor.tga" id="3_8s6t3"]
|
||||
|
||||
[resource]
|
||||
render_priority = 0
|
||||
@@ -12,3 +12,5 @@ shader_parameter/texture_side = ExtResource("2_50hjp")
|
||||
shader_parameter/blend_sharpness = 5.0
|
||||
shader_parameter/slope_threshold = 0.7
|
||||
shader_parameter/uv_scale = Vector3(0.1, 0.1, 0.1)
|
||||
shader_parameter/texture_top_tiling = Vector2(2, 2)
|
||||
shader_parameter/texture_side_tiling = Vector2(1, 1)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,41 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://rc8iqoij2c5w"
|
||||
path.s3tc="res://.godot/imported/grass.png-288495857c635d40287c0bebe39e1299.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://proc-terrain/grass.png"
|
||||
dest_files=["res://.godot/imported/grass.png-288495857c635d40287c0bebe39e1299.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
4
proc-terrain/preview_material.tres
Normal file
@@ -0,0 +1,4 @@
|
||||
[gd_resource type="StandardMaterial3D" format=3 uid="uid://40g5lvx2hw5k"]
|
||||
|
||||
[resource]
|
||||
vertex_color_use_as_albedo = true
|
||||
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,41 +0,0 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://5pfh43bjvef4"
|
||||
path.s3tc="res://.godot/imported/rock.png-342cc50a309b9d723500126a447b3982.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://proc-terrain/rock.png"
|
||||
dest_files=["res://.godot/imported/rock.png-342cc50a309b9d723500126a447b3982.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
BIN
proc-terrain/slop_limit_map.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
proc-terrain/slop_limit_map_1.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
16
proc-terrain/terrain_control_layer.tres
Normal file
@@ -0,0 +1,16 @@
|
||||
[gd_resource type="Resource" script_class="TerrainControlLayers" format=3 uid="uid://diftpul4fiqwa"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://cdx6jsbu8586x" path="res://proc-terrain/biomes.png" id="1_1o45a"]
|
||||
[ext_resource type="Script" uid="uid://dn0dmf6vj6ybp" path="res://proc-terrain/terrain_control_layers.gd" id="1_2pv2t"]
|
||||
[ext_resource type="Texture2D" uid="uid://b8dd3au482ax" path="res://proc-terrain/slop_limit_map.png" id="3_3iw73"]
|
||||
|
||||
[sub_resource type="Curve" id="Curve_2pv2t"]
|
||||
_data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.48395061, 1), 0.0, 0.0, 0, 0, Vector2(0.99814117, 0.004347801), 0.0, 0.0, 0, 0]
|
||||
point_count = 3
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_2pv2t")
|
||||
resolution = Vector2i(1024, 1024)
|
||||
biome_map = ExtResource("1_1o45a")
|
||||
height_profile = SubResource("Curve_2pv2t")
|
||||
slope_limit_map = ExtResource("3_3iw73")
|
||||
110
proc-terrain/terrain_control_layers.gd
Normal file
@@ -0,0 +1,110 @@
|
||||
@tool
|
||||
class_name TerrainControlLayers
|
||||
extends Resource
|
||||
|
||||
## Resource containing artist-controlled terrain layers for procedural generation.
|
||||
## Includes biome maps, height profiles, masks, and slope limits.
|
||||
|
||||
signal layers_changed
|
||||
|
||||
@export var resolution: Vector2i = Vector2i(512, 512):
|
||||
set(value):
|
||||
resolution = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export_group("Biome Control")
|
||||
@export var biome_map: Texture2D:
|
||||
set(value):
|
||||
biome_map = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export var height_profile: Curve:
|
||||
set(value):
|
||||
if height_profile and height_profile.changed.is_connected(_on_curve_changed):
|
||||
height_profile.changed.disconnect(_on_curve_changed)
|
||||
height_profile = value
|
||||
if height_profile:
|
||||
height_profile.changed.connect(_on_curve_changed)
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export_group("Slope Control")
|
||||
@export var slope_limit_map: Texture2D:
|
||||
set(value):
|
||||
slope_limit_map = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export_range(0.0, 90.0) var default_slope_limit: float = 45.0:
|
||||
set(value):
|
||||
default_slope_limit = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export_group("Mask Layers")
|
||||
@export var mask_layers: Array[Texture2D] = []:
|
||||
set(value):
|
||||
mask_layers = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
@export var mask_names: Array[String] = []:
|
||||
set(value):
|
||||
mask_names = value
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
|
||||
func _on_curve_changed() -> void:
|
||||
emit_changed()
|
||||
layers_changed.emit()
|
||||
|
||||
|
||||
func get_biome_value(uv: Vector2) -> Color:
|
||||
if not biome_map:
|
||||
return Color.WHITE
|
||||
var img := biome_map.get_image()
|
||||
if not img:
|
||||
return Color.WHITE
|
||||
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||
return img.get_pixel(px, py)
|
||||
|
||||
|
||||
func get_slope_limit(uv: Vector2) -> float:
|
||||
if not slope_limit_map:
|
||||
return default_slope_limit
|
||||
var img := slope_limit_map.get_image()
|
||||
if not img:
|
||||
return default_slope_limit
|
||||
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||
return img.get_pixel(px, py).r * 90.0
|
||||
|
||||
|
||||
func get_height_profile_value(normalized_height: float) -> float:
|
||||
if not height_profile:
|
||||
return normalized_height
|
||||
return height_profile.sample(clampf(normalized_height, 0.0, 1.0))
|
||||
|
||||
|
||||
func get_mask_value(mask_index: int, uv: Vector2) -> float:
|
||||
if mask_index < 0 or mask_index >= mask_layers.size():
|
||||
return 0.0
|
||||
var tex := mask_layers[mask_index]
|
||||
if not tex:
|
||||
return 0.0
|
||||
var img := tex.get_image()
|
||||
if not img:
|
||||
return 0.0
|
||||
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||
return img.get_pixel(px, py).r
|
||||
|
||||
|
||||
func get_mask_name(mask_index: int) -> String:
|
||||
if mask_index < 0 or mask_index >= mask_names.size():
|
||||
return "mask_%d" % mask_index
|
||||
return mask_names[mask_index]
|
||||
1
proc-terrain/terrain_control_layers.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dn0dmf6vj6ybp
|
||||
@@ -2,6 +2,11 @@
|
||||
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):
|
||||
@@ -11,7 +16,7 @@ extends MeshInstance3D
|
||||
noise_setting.changed.connect(_on_settings_changed)
|
||||
_request_update()
|
||||
|
||||
@export var material_setting: ShaderMaterial:
|
||||
@export var material_setting: Material:
|
||||
set(value):
|
||||
material_setting = value
|
||||
if mesh:
|
||||
@@ -49,6 +54,70 @@ extends MeshInstance3D
|
||||
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):
|
||||
@@ -59,6 +128,9 @@ extends MeshInstance3D
|
||||
_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:
|
||||
@@ -88,6 +160,8 @@ func generate_terrain() -> void:
|
||||
if not noise_setting:
|
||||
return
|
||||
|
||||
_cache_constraints()
|
||||
|
||||
var st := SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
|
||||
@@ -97,20 +171,110 @@ func generate_terrain() -> void:
|
||||
var half_z: float = terrain_size.y / 2.0
|
||||
|
||||
var heights: Array[float] = []
|
||||
heights.resize(subdivision * subdivision)
|
||||
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 height: float = noise_setting.get_noise_2d(world_x, world_z) * max_height
|
||||
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)
|
||||
|
||||
heights[z * subdivision + x] = height
|
||||
# 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
|
||||
@@ -128,30 +292,169 @@ func generate_terrain() -> void:
|
||||
var v01 := Vector3(x0, heights[idx01], z1)
|
||||
var v11 := Vector3(x1, heights[idx11], z1)
|
||||
|
||||
# Triangle 1: v00, v01, v10
|
||||
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_uv(Vector2(x0 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5))
|
||||
st.set_color(c00)
|
||||
st.set_uv(uv00)
|
||||
st.add_vertex(v00)
|
||||
st.set_uv(Vector2(x0 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5))
|
||||
st.add_vertex(v01)
|
||||
st.set_uv(Vector2(x1 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5))
|
||||
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, v01, v11
|
||||
# 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_uv(Vector2(x1 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5))
|
||||
st.set_color(c10)
|
||||
st.set_uv(uv10)
|
||||
st.add_vertex(v10)
|
||||
st.set_uv(Vector2(x0 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5))
|
||||
st.add_vertex(v01)
|
||||
st.set_uv(Vector2(x1 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5))
|
||||
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:
|
||||
|
||||
153
proc-terrain/terrain_props_data.gd
Normal file
@@ -0,0 +1,153 @@
|
||||
@tool
|
||||
class_name TerrainPropsData
|
||||
extends RefCounted
|
||||
|
||||
## Data structure containing terrain information for props placement.
|
||||
## Generated by TerrainGenerator after terrain generation.
|
||||
|
||||
var resolution: Vector2i = Vector2i(128, 128)
|
||||
var terrain_size: Vector2 = Vector2(200, 200)
|
||||
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] = []
|
||||
|
||||
|
||||
func get_index(x: int, z: int) -> int:
|
||||
return z * resolution.x + x
|
||||
|
||||
|
||||
func get_uv(x: int, z: int) -> Vector2:
|
||||
return Vector2(float(x) / float(resolution.x - 1), float(z) / float(resolution.y - 1))
|
||||
|
||||
|
||||
func get_world_position(x: int, z: int) -> Vector3:
|
||||
var half_x := terrain_size.x / 2.0
|
||||
var half_z := terrain_size.y / 2.0
|
||||
var step_x := terrain_size.x / float(resolution.x - 1)
|
||||
var step_z := terrain_size.y / float(resolution.y - 1)
|
||||
var idx := get_index(x, z)
|
||||
return Vector3(
|
||||
x * step_x - half_x,
|
||||
heights[idx] if idx < heights.size() else 0.0,
|
||||
z * step_z - half_z
|
||||
)
|
||||
|
||||
|
||||
func get_height(x: int, z: int) -> float:
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= heights.size():
|
||||
return 0.0
|
||||
return heights[idx]
|
||||
|
||||
|
||||
func get_biome_id(x: int, z: int) -> int:
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= biome_ids.size():
|
||||
return 0
|
||||
return biome_ids[idx]
|
||||
|
||||
|
||||
func get_slope_limit(x: int, z: int) -> float:
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= slope_values.size():
|
||||
return 45.0
|
||||
return slope_values[idx]
|
||||
|
||||
|
||||
func get_rail_distance(x: int, z: int) -> float:
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= rail_distances.size():
|
||||
return INF
|
||||
return rail_distances[idx]
|
||||
|
||||
|
||||
func get_station_distance(x: int, z: int) -> float:
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= station_distances.size():
|
||||
return INF
|
||||
return station_distances[idx]
|
||||
|
||||
|
||||
func get_mask_value(mask_index: int, x: int, z: int) -> float:
|
||||
if mask_index < 0 or mask_index >= mask_values.size():
|
||||
return 0.0
|
||||
var mask_arr: Array = mask_values[mask_index]
|
||||
var idx := get_index(x, z)
|
||||
if idx < 0 or idx >= mask_arr.size():
|
||||
return 0.0
|
||||
return mask_arr[idx]
|
||||
|
||||
|
||||
func get_calculated_slope(x: int, z: int) -> float:
|
||||
if x <= 0 or x >= resolution.x - 1 or z <= 0 or z >= resolution.y - 1:
|
||||
return 0.0
|
||||
|
||||
var step_x := terrain_size.x / float(resolution.x - 1)
|
||||
var step_z := terrain_size.y / float(resolution.y - 1)
|
||||
|
||||
var h := get_height(x, z)
|
||||
var h_xm := get_height(x - 1, z)
|
||||
var h_xp := get_height(x + 1, z)
|
||||
var h_zm := get_height(x, z - 1)
|
||||
var h_zp := get_height(x, z + 1)
|
||||
|
||||
var dx := (h_xp - h_xm) / (2.0 * step_x)
|
||||
var dz := (h_zp - h_zm) / (2.0 * step_z)
|
||||
|
||||
return rad_to_deg(atan(sqrt(dx * dx + dz * dz)))
|
||||
|
||||
|
||||
func can_place_prop(x: int, z: int, rules: Dictionary) -> bool:
|
||||
var slope := get_calculated_slope(x, z)
|
||||
var rail_dist := get_rail_distance(x, z)
|
||||
var station_dist := get_station_distance(x, z)
|
||||
var biome := get_biome_id(x, z)
|
||||
|
||||
if rules.has("max_slope") and slope > rules["max_slope"]:
|
||||
return false
|
||||
if rules.has("min_rail_distance") and rail_dist < rules["min_rail_distance"]:
|
||||
return false
|
||||
if rules.has("min_station_distance") and station_dist < rules["min_station_distance"]:
|
||||
return false
|
||||
if rules.has("allowed_biomes") and not biome in rules["allowed_biomes"]:
|
||||
return false
|
||||
if rules.has("excluded_biomes") and biome in rules["excluded_biomes"]:
|
||||
return false
|
||||
|
||||
if rules.has("required_mask"):
|
||||
var mask_idx: int = rules["required_mask"]
|
||||
var threshold: float = rules.get("mask_threshold", 0.5)
|
||||
if get_mask_value(mask_idx, x, z) < threshold:
|
||||
return false
|
||||
|
||||
if rules.has("excluded_mask"):
|
||||
var mask_idx: int = rules["excluded_mask"]
|
||||
var threshold: float = rules.get("exclude_threshold", 0.5)
|
||||
if get_mask_value(mask_idx, x, z) >= threshold:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
func get_density_multiplier(x: int, z: int, rules: Dictionary) -> float:
|
||||
var multiplier := 1.0
|
||||
|
||||
if rules.has("slope_density_curve"):
|
||||
var slope := get_calculated_slope(x, z)
|
||||
var curve: Curve = rules["slope_density_curve"]
|
||||
multiplier *= curve.sample(clampf(slope / 90.0, 0.0, 1.0))
|
||||
|
||||
if rules.has("rail_distance_curve"):
|
||||
var dist := get_rail_distance(x, z)
|
||||
var curve: Curve = rules["rail_distance_curve"]
|
||||
var max_dist: float = rules.get("rail_distance_max", 100.0)
|
||||
multiplier *= curve.sample(clampf(dist / max_dist, 0.0, 1.0))
|
||||
|
||||
if rules.has("density_mask"):
|
||||
var mask_idx: int = rules["density_mask"]
|
||||
multiplier *= get_mask_value(mask_idx, x, z)
|
||||
|
||||
return multiplier
|
||||
1
proc-terrain/terrain_props_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://csau51iw3gxam
|
||||
@@ -1,5 +1,5 @@
|
||||
shader_type spatial;
|
||||
render_mode cull_disabled;
|
||||
render_mode cull_back;
|
||||
|
||||
group_uniforms textures;
|
||||
uniform sampler2D texture_top : source_color, filter_linear_mipmap, repeat_enable;
|
||||
@@ -11,6 +11,8 @@ uniform float slope_threshold : hint_range(0.0, 1.0) = 0.7;
|
||||
|
||||
group_uniforms uv_scaling;
|
||||
uniform vec3 uv_scale = vec3(0.1, 0.1, 0.1);
|
||||
uniform vec2 texture_top_tiling = vec2(1.0, 1.0);
|
||||
uniform vec2 texture_side_tiling = vec2(1.0, 1.0);
|
||||
|
||||
varying vec3 world_pos;
|
||||
varying vec3 world_normal;
|
||||
@@ -23,9 +25,9 @@ void vertex() {
|
||||
void fragment() {
|
||||
vec3 abs_normal = abs(world_normal);
|
||||
|
||||
vec2 uv_xz = world_pos.xz * uv_scale.x;
|
||||
vec2 uv_xy = world_pos.xy * uv_scale.y;
|
||||
vec2 uv_zy = world_pos.zy * uv_scale.z;
|
||||
vec2 uv_xz = world_pos.xz * uv_scale.x * texture_top_tiling;
|
||||
vec2 uv_xy = world_pos.xy * uv_scale.y * texture_side_tiling;
|
||||
vec2 uv_zy = world_pos.zy * uv_scale.z * texture_side_tiling;
|
||||
|
||||
vec3 weights = pow(abs_normal, vec3(blend_sharpness));
|
||||
weights /= (weights.x + weights.y + weights.z);
|
||||
|
||||