Add freshwater habitats and saltwater tide ribbons

This commit is contained in:
Alexander Sellite 2026-07-31 16:05:44 -04:00
parent 53fdc9ef21
commit 69b7969e25
40 changed files with 1323 additions and 148 deletions

View file

@ -34,6 +34,12 @@ children. Child transforms are local offsets owned by that feature.
## Water bodies
Every fishable region must author its `water_type` explicitly. The starter pond
is `FRESH_WATER`; the starter coast is `SALT_WATER`. Fish species use the same
central type definition through an allowed-water-type bitmask, so pool contents
and authoritative selection are both validated against the region. Do not infer
habitat from node names, pool filenames, coordinates, or water height.
Select `WaterBodies/Pond` to move or resize the pond. Its transform is the
authoritative surface position. The `surface_size`, fishing padding, recovery
padding, and recovery depth properties update the visible water and gameplay
@ -86,3 +92,36 @@ for provisional props when visual and collision scale together. Avoid
non-uniform root scaling because it can deform collision and imported geometry
unpredictably. Make mutable per-instance shapes, meshes, and materials local to
the scene so editing one prop does not change unrelated instances.
## Shoreline tide ribbons
Each map that uses generated tide ribbons owns a `ShorelineRibbonBaker` node and
one `ShorelineRibbonConfig` resource per water body. Each fishable water body
declares the shared `WaterType` value used by fishing, Logbook classification,
and shoreline presentation. A configuration designates
only the static terrain `CollisionShape3D` or `MeshInstance3D` to intersect, the
water height, explicit generation bounds, water-facing reference, optional
smoothing overrides, and the generated `.tres` output path. Props, docks,
players, bobbers, and gameplay areas are not scanned unless a map author
explicitly selects one as the terrain source. The baker reports every configured
body but intentionally generates ribbons only for `SALT_WATER`; freshwater uses
the shared depth-tinted shader without tide marks or an advancing wash.
To rebuild in the editor, select the map's `ShorelineRibbonBaker` node and press
**Rebuild Shoreline Ribbons** in the Inspector. The equivalent validation/CI
command for the starter map is:
```sh
godot --headless --path . --script scripts/bake_shoreline_ribbons.gd
```
Set the baker's `debug_path_stage` to Raw, Simplified, or Smoothed before an
editor rebuild to compare the extraction stages. Keep it Off in the finished
scene; the temporary editor-only line preview is never saved as gameplay data.
Rebuild a saltwater ribbon only when its terrain at the waterline, configured
water height, or generation bounds change. Freshwater terrain changes do not
require a ribbon rebuild because freshwater bodies have no ribbon. Adding or
moving unrelated props, fishing areas, recovery volumes, or other gameplay
nodes does not require a rebuild. Normal gameplay loads the committed generated
saltwater meshes and never runs the extraction step.

View file

@ -10,6 +10,7 @@ enum SurfaceHeightMode {
}
@export var location_tags: Array[StringName] = []
@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER
@export var fish_pool: FishPoolType
@export var selection_priority: int = 0
@export_group("Surface")

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,71 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
uniform vec4 lead_color : source_color = vec4(0.620, 0.875, 0.855, 1.0);
uniform vec4 trail_color : source_color = vec4(0.430, 0.780, 0.770, 1.0);
uniform float ribbon_width : hint_range(0.2, 2.0, 0.01) = 0.85;
uniform float land_inset : hint_range(0.0, 0.4, 0.01) = 0.10;
uniform float tide_speed : hint_range(0.1, 3.0, 0.01) = 1.42;
uniform float tide_distance : hint_range(0.0, 0.6, 0.01) = 0.30;
uniform float lead_width : hint_range(0.01, 0.2, 0.005) = 0.065;
uniform float trail_width : hint_range(0.02, 0.4, 0.005) = 0.18;
uniform float lead_strength : hint_range(0.0, 1.0, 0.01) = 0.48;
uniform float trail_strength : hint_range(0.0, 1.0, 0.01) = 0.25;
uniform float phase_scale : hint_range(0.001, 0.2, 0.001) = 0.035;
uniform float phase_strength : hint_range(0.0, 2.0, 0.01) = 0.55;
varying vec3 world_position;
float hash_21(vec2 point) {
return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453);
}
float value_noise(vec2 point) {
vec2 cell = floor(point);
vec2 local = fract(point);
vec2 blend = local * local * (3.0 - 2.0 * local);
float bottom = mix(hash_21(cell), hash_21(cell + vec2(1.0, 0.0)), blend.x);
float top = mix(
hash_21(cell + vec2(0.0, 1.0)),
hash_21(cell + vec2(1.0, 1.0)),
blend.x
);
return mix(bottom, top, blend.y);
}
float soft_line(float coordinate, float center, float width) {
float normalized_width = max(width / ribbon_width, 0.001);
float derivative_width = min(fwidth(coordinate) * 1.25, normalized_width * 0.65);
return 1.0 - smoothstep(
max(normalized_width * 0.25 - derivative_width, 0.0),
normalized_width + derivative_width,
abs(coordinate - center)
);
}
void vertex() {
world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
float phase = (
value_noise(world_position.xz * phase_scale) * 2.0 - 1.0
) * phase_strength;
float ebb = sin(TIME * tide_speed + phase) * 0.5 + 0.5;
float shoreline = land_inset / ribbon_width;
float lead_position = shoreline + ebb * tide_distance / ribbon_width;
float trail_position = lead_position + max(tide_distance * 0.72, 0.10) / ribbon_width;
float lead = soft_line(UV.y, lead_position, lead_width);
float trail = soft_line(UV.y, trail_position, trail_width);
float edge_fade = smoothstep(0.0, 0.055, UV.y) * (1.0 - smoothstep(0.90, 1.0, UV.y));
float lead_alpha = lead * lead_strength;
float trail_alpha = trail * trail_strength;
float combined_alpha = max(lead_alpha, trail_alpha) * edge_fade;
ALBEDO = mix(trail_color.rgb, lead_color.rgb, lead_alpha);
ALPHA = combined_alpha;
}

View file

@ -0,0 +1 @@
uid://5dauni8hb2lv

View file

@ -0,0 +1,19 @@
[gd_resource type="ShaderMaterial" load_steps=2 format=3]
[ext_resource type="Shader" path="res://world/materials/shoreline_tide.gdshader" id="1_shader"]
[resource]
render_priority = 1
shader = ExtResource("1_shader")
shader_parameter/lead_color = Color(0.619608, 0.87451, 0.854902, 1)
shader_parameter/trail_color = Color(0.431373, 0.780392, 0.768627, 1)
shader_parameter/ribbon_width = 0.85
shader_parameter/land_inset = 0.1
shader_parameter/tide_speed = 1.42
shader_parameter/tide_distance = 0.3
shader_parameter/lead_width = 0.065
shader_parameter/trail_width = 0.18
shader_parameter/lead_strength = 0.48
shader_parameter/trail_strength = 0.25
shader_parameter/phase_scale = 0.035
shader_parameter/phase_strength = 0.55

View file

@ -1,93 +1,241 @@
shader_type spatial;
render_mode cull_back, diffuse_burley, specular_schlick_ggx;
render_mode blend_mix, depth_draw_never, cull_back, unshaded;
uniform vec4 deep_color : source_color = vec4(0.055, 0.31, 0.50, 1.0);
uniform vec4 wave_color : source_color = vec4(0.18, 0.59, 0.74, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.64, 0.88, 0.93, 1.0);
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
uniform float primary_wave_scale : hint_range(0.01, 2.0, 0.01) = 0.52;
uniform float secondary_wave_scale : hint_range(0.01, 2.0, 0.01) = 0.29;
uniform vec2 primary_speed = vec2(0.16, 0.07);
uniform vec2 secondary_speed = vec2(-0.08, 0.12);
uniform float wave_strength : hint_range(0.0, 1.0, 0.01) = 0.42;
uniform vec4 shore_color : source_color = vec4(0.475, 0.788, 0.796, 1.0);
uniform vec4 shallow_color : source_color = vec4(0.227, 0.604, 0.663, 1.0);
uniform vec4 deep_color : source_color = vec4(0.110, 0.345, 0.435, 1.0);
uniform vec4 tide_wash_color : source_color = vec4(0.360, 0.720, 0.735, 1.0);
uniform float highlight_fresnel_strength : hint_range(0.0, 1.0, 0.01) = 0.22;
uniform float fresnel_power : hint_range(0.5, 12.0, 0.1) = 4.0;
uniform float roughness : hint_range(0.0, 1.0, 0.01) = 0.32;
uniform float specular_strength : hint_range(0.0, 1.0, 0.01) = 0.28;
uniform float shallow_depth : hint_range(0.1, 3.0, 0.01) = 0.70;
uniform float deep_depth : hint_range(0.5, 12.0, 0.05) = 3.60;
uniform float shore_depth_width : hint_range(0.02, 1.0, 0.01) = 0.16;
uniform float shore_alpha : hint_range(0.1, 1.0, 0.01) = 0.44;
uniform float shallow_alpha : hint_range(0.1, 1.0, 0.01) = 0.60;
uniform float deep_alpha : hint_range(0.1, 1.0, 0.01) = 0.82;
uniform bool tide_effect_enabled = true;
uniform float tide_speed : hint_range(0.1, 3.0, 0.01) = 1.42;
uniform float tide_distance : hint_range(0.0, 0.6, 0.01) = 0.30;
uniform float tide_wash_width : hint_range(0.05, 1.0, 0.01) = 0.38;
uniform float tide_wash_strength : hint_range(0.0, 0.3, 0.01) = 0.11;
uniform float tide_wash_alpha_shift : hint_range(0.0, 0.15, 0.005) = 0.045;
uniform float phase_noise_scale : hint_range(0.001, 0.5, 0.001) = 0.055;
uniform float phase_noise_strength : hint_range(0.0, 2.0, 0.01) = 0.68;
uniform float contour_corner_variation : hint_range(0.0, 0.15, 0.005) = 0.045;
uniform float contour_smoothing_pixels : hint_range(0.0, 2.5, 0.05) = 1.0;
uniform float contour_smoothing_strength : hint_range(0.0, 1.0, 0.01) = 0.72;
uniform float contour_depth_reject : hint_range(0.05, 2.0, 0.01) = 0.60;
uniform float open_water_drift_scale : hint_range(0.001, 0.3, 0.001) = 0.018;
uniform vec2 open_water_drift_speed = vec2(0.010, -0.007);
uniform float open_water_drift_strength : hint_range(0.0, 0.08, 0.001) = 0.045;
uniform float distant_alpha_start : hint_range(10.0, 2000.0, 1.0) = 260.0;
uniform float distant_alpha_end : hint_range(20.0, 5000.0, 1.0) = 1400.0;
varying vec3 world_position;
varying float surface_view_depth;
float hash_21(vec2 point) {
return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453);
}
float value_noise(vec2 point) {
vec2 cell = floor(point);
vec2 local = fract(point);
vec2 blend = local * local * (3.0 - 2.0 * local);
float bottom = mix(hash_21(cell), hash_21(cell + vec2(1.0, 0.0)), blend.x);
float top = mix(
hash_21(cell + vec2(0.0, 1.0)),
hash_21(cell + vec2(1.0, 1.0)),
blend.x
);
return mix(bottom, top, blend.y);
}
float reconstruct_view_depth(
vec2 screen_uv,
float raw_depth,
mat4 inverse_projection
) {
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
vec3 ndc = vec3(screen_uv, raw_depth) * 2.0 - 1.0;
#else
vec3 ndc = vec3(screen_uv * 2.0 - 1.0, raw_depth);
#endif
vec4 view_position = inverse_projection * vec4(ndc, 1.0);
return -view_position.z / max(abs(view_position.w), 0.00001);
}
float sample_water_depth(
vec2 screen_uv,
float water_surface_depth,
mat4 inverse_projection
) {
float raw_depth = texture(depth_texture, screen_uv).r;
float scene_depth = reconstruct_view_depth(
screen_uv,
raw_depth,
inverse_projection
);
return scene_depth - water_surface_depth;
}
float contour_neighbor_weight(
float center_depth,
float neighbor_depth,
float rejection_distance
) {
if (neighbor_depth <= 0.0) {
return 0.0;
}
float safe_rejection = max(rejection_distance, 0.001);
return 1.0 - smoothstep(
safe_rejection * 0.35,
safe_rejection,
abs(neighbor_depth - center_depth)
);
}
void vertex() {
world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
vec4 world_vertex = MODEL_MATRIX * vec4(VERTEX, 1.0);
vec4 view_vertex = VIEW_MATRIX * world_vertex;
world_position = world_vertex.xyz;
surface_view_depth = -view_vertex.z;
}
void fragment() {
const vec2 primary_direction = vec2(0.835, 0.550);
const vec2 secondary_direction = vec2(-0.485, 0.875);
float raw_depth = texture(depth_texture, SCREEN_UV).r;
float scene_view_depth = reconstruct_view_depth(
SCREEN_UV,
raw_depth,
INV_PROJECTION_MATRIX
);
float water_depth = max(scene_view_depth - surface_view_depth, 0.0);
water_depth = min(water_depth, deep_depth * 4.0);
vec2 primary_coordinates = (
world_position.xz * primary_wave_scale
+ TIME * primary_speed
float contour_depth = water_depth;
float contour_feature_support = 1.0;
if (tide_effect_enabled) {
vec2 contour_texel = (
vec2(1.0) / vec2(textureSize(depth_texture, 0))
* contour_smoothing_pixels
);
vec2 secondary_coordinates = (
world_position.xz * secondary_wave_scale
+ TIME * secondary_speed
float contour_depth_sum = water_depth;
float contour_weight_sum = 1.0;
float left_depth = sample_water_depth(
SCREEN_UV - vec2(contour_texel.x, 0.0),
surface_view_depth,
INV_PROJECTION_MATRIX
);
float primary_phase = dot(primary_coordinates, primary_direction);
float secondary_phase = dot(
secondary_coordinates,
secondary_direction
float right_depth = sample_water_depth(
SCREEN_UV + vec2(contour_texel.x, 0.0),
surface_view_depth,
INV_PROJECTION_MATRIX
);
float primary_wave = sin(primary_phase);
float secondary_wave = sin(secondary_phase);
float combined_wave = (
primary_wave * 0.62
+ secondary_wave * 0.38
float upper_depth = sample_water_depth(
SCREEN_UV - vec2(0.0, contour_texel.y),
surface_view_depth,
INV_PROJECTION_MATRIX
);
float wave_band = smoothstep(
0.34,
0.66,
combined_wave * 0.5 + 0.5
float lower_depth = sample_water_depth(
SCREEN_UV + vec2(0.0, contour_texel.y),
surface_view_depth,
INV_PROJECTION_MATRIX
);
float left_weight = contour_neighbor_weight(
water_depth, left_depth, contour_depth_reject
);
float right_weight = contour_neighbor_weight(
water_depth, right_depth, contour_depth_reject
);
float upper_weight = contour_neighbor_weight(
water_depth, upper_depth, contour_depth_reject
);
float lower_weight = contour_neighbor_weight(
water_depth, lower_depth, contour_depth_reject
);
contour_depth_sum += left_depth * left_weight;
contour_depth_sum += right_depth * right_weight;
contour_depth_sum += upper_depth * upper_weight;
contour_depth_sum += lower_depth * lower_weight;
float neighbor_support = left_weight + right_weight + upper_weight + lower_weight;
contour_weight_sum += neighbor_support;
float smoothed_contour_depth = contour_depth_sum / contour_weight_sum;
contour_depth = mix(
water_depth,
smoothed_contour_depth,
contour_smoothing_strength
);
contour_feature_support = smoothstep(0.75, 1.75, neighbor_support);
}
vec2 wave_slope = (
cos(primary_phase)
* primary_direction
* primary_wave_scale
* 0.62
+ cos(secondary_phase)
* secondary_direction
* secondary_wave_scale
* 0.38
);
vec3 world_normal = normalize(
vec3(
-wave_slope.x * wave_strength * 0.34,
1.0,
-wave_slope.y * wave_strength * 0.34
)
);
NORMAL = normalize(
(VIEW_MATRIX * vec4(world_normal, 0.0)).xyz
);
float shallow_mix = smoothstep(0.0, shallow_depth, water_depth);
float deep_mix = smoothstep(shallow_depth, deep_depth, water_depth);
vec3 water_color = mix(shore_color.rgb, shallow_color.rgb, shallow_mix);
water_color = mix(water_color, deep_color.rgb, deep_mix);
float water_alpha = mix(shore_alpha, shallow_alpha, shallow_mix);
water_alpha = mix(water_alpha, deep_alpha, deep_mix);
float fresnel = pow(
1.0 - clamp(dot(NORMAL, VIEW), 0.0, 1.0),
fresnel_power
if (tide_effect_enabled) {
vec2 phase_coordinates = world_position.xz * phase_noise_scale;
float phase_variation = (
value_noise(phase_coordinates)
+ value_noise(phase_coordinates * 0.47 + vec2(9.7, 3.1)) * 0.5
- 0.75
) * phase_noise_strength;
float corner_variation = (
value_noise(phase_coordinates * 1.73 + vec2(4.2, 13.6)) * 2.0 - 1.0
) * contour_corner_variation;
float ebb = sin(TIME * tide_speed + phase_variation) * 0.5 + 0.5;
float lead_threshold = shore_depth_width + ebb * tide_distance + corner_variation;
float tide_wash = 1.0 - smoothstep(
lead_threshold,
lead_threshold + max(tide_wash_width, 0.001),
contour_depth
);
vec3 water_color = mix(
deep_color.rgb,
wave_color.rgb,
wave_band * wave_strength
);
ALBEDO = mix(
tide_wash *= contour_feature_support;
tide_wash *= 1.0 - smoothstep(shallow_depth, deep_depth, water_depth);
water_color = mix(
water_color,
highlight_color.rgb,
fresnel * highlight_fresnel_strength
tide_wash_color.rgb,
tide_wash * tide_wash_strength
);
ROUGHNESS = roughness;
SPECULAR = specular_strength;
water_alpha = min(
water_alpha
+ tide_wash * tide_wash_alpha_shift,
0.96
);
}
vec2 drift_coordinates = (
world_position.xz * open_water_drift_scale
+ TIME * open_water_drift_speed
);
float broad_drift = value_noise(drift_coordinates) * 2.0 - 1.0;
water_color *= 1.0 + broad_drift * open_water_drift_strength * deep_mix;
float distant_mix = smoothstep(
distant_alpha_start,
max(distant_alpha_end, distant_alpha_start + 1.0),
surface_view_depth
);
water_color = mix(water_color, deep_color.rgb, distant_mix * 0.72);
water_alpha = mix(water_alpha, 0.96, distant_mix);
ALBEDO = water_color;
ALPHA = clamp(water_alpha, 0.1, 0.96);
METALLIC = 0.0;
SPECULAR = 0.0;
ROUGHNESS = 1.0;
}

View file

@ -5,3 +5,30 @@
[resource]
render_priority = 0
shader = ExtResource("1_water_shader")
shader_parameter/shore_color = Color(0.47451, 0.788235, 0.796078, 1)
shader_parameter/shallow_color = Color(0.227451, 0.603922, 0.662745, 1)
shader_parameter/deep_color = Color(0.109804, 0.345098, 0.435294, 1)
shader_parameter/tide_wash_color = Color(0.360784, 0.721569, 0.733333, 1)
shader_parameter/shallow_depth = 0.7
shader_parameter/deep_depth = 3.6
shader_parameter/shore_depth_width = 0.16
shader_parameter/shore_alpha = 0.44
shader_parameter/shallow_alpha = 0.6
shader_parameter/deep_alpha = 0.82
shader_parameter/tide_effect_enabled = true
shader_parameter/tide_speed = 1.42
shader_parameter/tide_distance = 0.3
shader_parameter/tide_wash_width = 0.38
shader_parameter/tide_wash_strength = 0.11
shader_parameter/tide_wash_alpha_shift = 0.045
shader_parameter/phase_noise_scale = 0.055
shader_parameter/phase_noise_strength = 0.68
shader_parameter/contour_corner_variation = 0.045
shader_parameter/contour_smoothing_pixels = 1.0
shader_parameter/contour_smoothing_strength = 0.72
shader_parameter/contour_depth_reject = 0.6
shader_parameter/open_water_drift_scale = 0.018
shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
shader_parameter/open_water_drift_strength = 0.045
shader_parameter/distant_alpha_start = 260.0
shader_parameter/distant_alpha_end = 1400.0

View file

@ -0,0 +1,23 @@
[gd_resource type="ShaderMaterial" load_steps=2 format=3]
[ext_resource type="Shader" path="res://world/materials/stylized_water.gdshader" id="1_water_shader"]
[resource]
render_priority = 0
shader = ExtResource("1_water_shader")
shader_parameter/shore_color = Color(0.47451, 0.788235, 0.796078, 1)
shader_parameter/shallow_color = Color(0.227451, 0.603922, 0.662745, 1)
shader_parameter/deep_color = Color(0.109804, 0.345098, 0.435294, 1)
shader_parameter/tide_wash_color = Color(0.360784, 0.721569, 0.733333, 1)
shader_parameter/shallow_depth = 0.7
shader_parameter/deep_depth = 3.6
shader_parameter/shore_depth_width = 0.16
shader_parameter/shore_alpha = 0.44
shader_parameter/shallow_alpha = 0.6
shader_parameter/deep_alpha = 0.82
shader_parameter/tide_effect_enabled = false
shader_parameter/open_water_drift_scale = 0.018
shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
shader_parameter/open_water_drift_strength = 0.045
shader_parameter/distant_alpha_start = 260.0
shader_parameter/distant_alpha_end = 1400.0

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,138 @@
@tool
class_name ShorelineRibbonBaker
extends Node
@export var water_bodies: Array[ShorelineRibbonConfig] = []
@export_enum("Off", "Raw", "Simplified", "Smoothed") var debug_path_stage := 0
@export_tool_button("Rebuild Shoreline Ribbons")
var rebuild_shorelines: Callable = rebuild_all
func rebuild_all() -> Array[Dictionary]:
var results: Array[Dictionary] = []
for configuration: ShorelineRibbonConfig in water_bodies:
var result := _rebuild(configuration)
if result.is_empty():
return []
results.append(result)
_update_debug_display(results)
return results
func _rebuild(configuration: ShorelineRibbonConfig) -> Dictionary:
if configuration == null:
push_error("Shoreline ribbon configuration is missing.")
return {}
if configuration.water_type != WaterType.Type.SALT_WATER:
return {
"skipped": true,
"water_type": configuration.water_type,
"output_path": configuration.output_resource_path,
}
var source := get_node_or_null(configuration.terrain_source) as Node3D
if source == null:
push_error(
"Shoreline terrain source was not found: %s"
% configuration.terrain_source
)
return {}
if configuration.output_resource_path.is_empty():
push_error("Shoreline output resource path is empty.")
return {}
var faces := _terrain_faces(source)
if faces.is_empty():
push_error("Configured shoreline terrain source exposes no triangles.")
return {}
var result := ShorelineRibbonGenerator.generate(
faces,
configuration.water_height,
configuration.generation_bounds,
configuration.water_reference,
configuration.water_is_inside,
configuration.simplification_tolerance,
configuration.smoothing_iterations,
configuration.resample_spacing
)
var output_directory := configuration.output_resource_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_directory))
var save_error := ResourceSaver.save(
result["mesh"], configuration.output_resource_path
)
if save_error != OK:
push_error(
"Failed to save %s: %s"
% [configuration.output_resource_path, error_string(save_error)]
)
return {}
result["output_path"] = configuration.output_resource_path
result["water_height"] = configuration.water_height
result["water_type"] = configuration.water_type
result["skipped"] = false
return result
func _update_debug_display(results: Array[Dictionary]) -> void:
var previous := get_node_or_null("_ShorelinePathDebug")
if previous != null:
previous.queue_free()
if not Engine.is_editor_hint() or debug_path_stage == 0:
return
var debug_mesh := ImmediateMesh.new()
var debug_material := StandardMaterial3D.new()
debug_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
debug_material.albedo_color = Color(1.0, 0.35, 0.65, 1.0)
var stage_key: String = [
"", "debug_raw_paths", "debug_simplified_paths", "debug_smoothed_paths"
][debug_path_stage]
for result: Dictionary in results:
if result.get("skipped", false):
continue
var height := float(result["water_height"]) + 0.06
for path_data: Dictionary in result[stage_key]:
var points: PackedVector2Array = path_data["points"]
if points.size() < 2:
continue
debug_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP, debug_material)
for point: Vector2 in points:
debug_mesh.surface_add_vertex(Vector3(point.x, height, point.y))
if path_data["closed"]:
debug_mesh.surface_add_vertex(Vector3(points[0].x, height, points[0].y))
debug_mesh.surface_end()
var debug_instance := MeshInstance3D.new()
debug_instance.name = "_ShorelinePathDebug"
debug_instance.mesh = debug_mesh
add_child(debug_instance)
func _terrain_faces(source: Node3D) -> PackedVector3Array:
var result := PackedVector3Array()
if source is CollisionShape3D:
var collision_shape := source as CollisionShape3D
var concave_shape := collision_shape.shape as ConcavePolygonShape3D
if concave_shape == null:
return result
for vertex: Vector3 in concave_shape.get_faces():
result.append(_to_map_space(source, vertex))
return result
if source is MeshInstance3D:
var mesh_instance := source as MeshInstance3D
if mesh_instance.mesh == null:
return result
for surface: int in mesh_instance.mesh.get_surface_count():
var arrays := mesh_instance.mesh.surface_get_arrays(surface)
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if indices.is_empty():
for vertex: Vector3 in vertices:
result.append(_to_map_space(source, vertex))
else:
for index: int in indices:
result.append(_to_map_space(source, vertices[index]))
return result
func _to_map_space(source: Node3D, vertex: Vector3) -> Vector3:
var map_root := get_parent() as Node3D
if map_root == null:
return source.to_global(vertex)
return map_root.to_local(source.to_global(vertex))

View file

@ -0,0 +1 @@
uid://bj704rvwwh77b

View file

@ -0,0 +1,14 @@
class_name ShorelineRibbonConfig
extends Resource
@export_node_path("CollisionShape3D", "MeshInstance3D") var terrain_source: NodePath
@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER
@export var water_height := 0.0
@export var generation_bounds := Rect2()
@export var water_reference := Vector2.ZERO
@export var water_is_inside := true
@export_file("*.tres") var output_resource_path := ""
@export_group("Optional Smoothing Overrides")
@export var simplification_tolerance := -1.0
@export_range(-1, 4, 1) var smoothing_iterations := -1
@export var resample_spacing := -1.0

View file

@ -0,0 +1 @@
uid://c8hk3eojujyax

View file

@ -0,0 +1,443 @@
class_name ShorelineRibbonGenerator
extends RefCounted
const WATER_PLANE_EPSILON := 0.001
const ENDPOINT_MERGE_TOLERANCE := 0.03
const LOOP_CLOSURE_TOLERANCE := 0.06
const MINIMUM_FRAGMENT_LENGTH := 2.0
const SIMPLIFICATION_TOLERANCE := 0.20
const SMOOTHING_ITERATIONS := 2
const RESAMPLE_SPACING := 0.22
const RIBBON_WIDTH := 0.85
const LAND_INSET := 0.10
const SURFACE_OFFSET := 0.018
static func generate(
faces: PackedVector3Array,
water_height: float,
bounds: Rect2,
water_reference: Vector2,
water_is_inside: bool,
simplification_override := -1.0,
smoothing_iterations_override := -1,
resample_spacing_override := -1.0
) -> Dictionary:
var simplification := (
SIMPLIFICATION_TOLERANCE
if simplification_override < 0.0
else simplification_override
)
var smoothing_iterations := (
SMOOTHING_ITERATIONS
if smoothing_iterations_override < 0
else smoothing_iterations_override
)
var resample_spacing := (
RESAMPLE_SPACING
if resample_spacing_override < 0.0
else resample_spacing_override
)
var segments := _extract_segments(faces, water_height, bounds)
var raw_paths := _stitch_segments(segments)
var processed_paths: Array[Dictionary] = []
var simplified_paths: Array[Dictionary] = []
var raw_point_count := 0
var simplified_point_count := 0
var smoothed_point_count := 0
for path_data: Dictionary in raw_paths:
var raw_points: PackedVector2Array = path_data["points"]
var closed: bool = path_data["closed"]
if _path_length(raw_points, closed) < MINIMUM_FRAGMENT_LENGTH:
continue
var simplified := _simplify(raw_points, closed, simplification)
simplified_paths.append({"points": simplified, "closed": closed})
var smoothed := _chaikin(simplified, closed, smoothing_iterations)
var resampled := _resample(smoothed, closed, resample_spacing)
if resampled.size() < (3 if closed else 2):
continue
raw_point_count += raw_points.size()
simplified_point_count += simplified.size()
smoothed_point_count += resampled.size()
processed_paths.append({"points": resampled, "closed": closed})
var mesh := _build_mesh(
processed_paths,
water_height,
water_reference,
water_is_inside
)
return {
"mesh": mesh,
"segment_count": segments.size(),
"loop_count": processed_paths.size(),
"raw_point_count": raw_point_count,
"simplified_point_count": simplified_point_count,
"smoothed_point_count": smoothed_point_count,
"triangle_count": _mesh_triangle_count(mesh),
"debug_raw_paths": raw_paths,
"debug_simplified_paths": simplified_paths,
"debug_smoothed_paths": processed_paths,
}
static func _extract_segments(
faces: PackedVector3Array,
water_height: float,
bounds: Rect2
) -> Array[PackedVector2Array]:
var segments: Array[PackedVector2Array] = []
for index: int in range(0, faces.size(), 3):
var triangle: Array[Vector3] = [
faces[index], faces[index + 1], faces[index + 2]
]
var hits := PackedVector2Array()
for edge: int in 3:
var a := triangle[edge]
var b := triangle[(edge + 1) % 3]
var distance_a := a.y - water_height
var distance_b := b.y - water_height
if (
absf(distance_a) <= WATER_PLANE_EPSILON
and absf(distance_b) <= WATER_PLANE_EPSILON
):
continue
if not (
distance_a * distance_b < 0.0
or absf(distance_a) <= WATER_PLANE_EPSILON
or absf(distance_b) <= WATER_PLANE_EPSILON
):
continue
var denominator := distance_a - distance_b
var amount := (
0.0
if absf(denominator) <= WATER_PLANE_EPSILON
else distance_a / denominator
)
var hit3 := a.lerp(b, clampf(amount, 0.0, 1.0))
var hit := Vector2(hit3.x, hit3.z)
if bounds.has_point(hit) and not _contains_near(hits, hit):
hits.append(hit)
if (
hits.size() == 2
and hits[0].distance_to(hits[1]) > WATER_PLANE_EPSILON
):
segments.append(hits)
return segments
static func _stitch_segments(
segments: Array[PackedVector2Array]
) -> Array[Dictionary]:
var point_by_key: Dictionary = {}
var adjacency: Dictionary = {}
var unused_edges: Dictionary = {}
for segment: PackedVector2Array in segments:
var a_key := _point_key(segment[0])
var b_key := _point_key(segment[1])
if a_key == b_key:
continue
point_by_key[a_key] = segment[0]
point_by_key[b_key] = segment[1]
if not adjacency.has(a_key):
adjacency[a_key] = []
if not adjacency.has(b_key):
adjacency[b_key] = []
var edge_key := _edge_key(a_key, b_key)
if unused_edges.has(edge_key):
continue
(adjacency[a_key] as Array).append(b_key)
(adjacency[b_key] as Array).append(a_key)
unused_edges[edge_key] = true
var starts: Array = []
for key: Vector2i in adjacency:
if (adjacency[key] as Array).size() != 2:
starts.append(key)
for key: Vector2i in adjacency:
if not starts.has(key):
starts.append(key)
var paths: Array[Dictionary] = []
for start: Vector2i in starts:
while _has_unused_neighbor(start, adjacency, unused_edges):
var walked := _walk_path(start, point_by_key, adjacency, unused_edges)
if (walked["points"] as PackedVector2Array).size() >= 2:
paths.append(walked)
return paths
static func _walk_path(
start: Vector2i,
point_by_key: Dictionary,
adjacency: Dictionary,
unused_edges: Dictionary
) -> Dictionary:
var points := PackedVector2Array()
var previous := Vector2i(2147483647, 2147483647)
var current := start
var closed := false
var guard := unused_edges.size() + 2
while guard > 0:
guard -= 1
points.append(point_by_key[current])
var next_key := Vector2i(2147483647, 2147483647)
for candidate: Vector2i in adjacency[current]:
if candidate == previous and (adjacency[current] as Array).size() > 1:
continue
if unused_edges.get(_edge_key(current, candidate), false):
next_key = candidate
break
if next_key.x == 2147483647:
for candidate: Vector2i in adjacency[current]:
if unused_edges.get(_edge_key(current, candidate), false):
next_key = candidate
break
if next_key.x == 2147483647:
break
unused_edges[_edge_key(current, next_key)] = false
previous = current
current = next_key
if current == start:
closed = true
break
return {"points": points, "closed": closed}
static func _simplify(
points: PackedVector2Array,
closed: bool,
tolerance: float
) -> PackedVector2Array:
if points.size() <= (4 if closed else 2):
return points
if not closed:
return _rdp_open(points, tolerance)
var split_a := 0
var split_b := 1
var greatest_distance := 0.0
for a: int in points.size():
for b: int in range(a + 1, points.size()):
var distance := points[a].distance_squared_to(points[b])
if distance > greatest_distance:
greatest_distance = distance
split_a = a
split_b = b
var first_arc := _closed_arc(points, split_a, split_b)
var second_arc := _closed_arc(points, split_b, split_a)
var first_result := _rdp_open(first_arc, tolerance)
var second_result := _rdp_open(second_arc, tolerance)
var result := PackedVector2Array()
for index: int in first_result.size() - 1:
result.append(first_result[index])
for index: int in second_result.size() - 1:
result.append(second_result[index])
return result if result.size() >= 4 else points
static func _rdp_open(
points: PackedVector2Array,
tolerance: float
) -> PackedVector2Array:
if points.size() <= 2:
return points
var greatest_distance := 0.0
var split_index := 0
for index: int in range(1, points.size() - 1):
var distance := _point_segment_distance(
points[index], points[0], points[points.size() - 1]
)
if distance > greatest_distance:
greatest_distance = distance
split_index = index
if greatest_distance <= tolerance:
return PackedVector2Array([points[0], points[points.size() - 1]])
var left := _rdp_open(points.slice(0, split_index + 1), tolerance)
var right := _rdp_open(points.slice(split_index), tolerance)
var result := PackedVector2Array()
for index: int in left.size() - 1:
result.append(left[index])
result.append_array(right)
return result
static func _closed_arc(
points: PackedVector2Array,
start: int,
finish: int
) -> PackedVector2Array:
var result := PackedVector2Array()
var index := start
result.append(points[index])
while index != finish:
index = (index + 1) % points.size()
result.append(points[index])
return result
static func _chaikin(
points: PackedVector2Array,
closed: bool,
iterations: int
) -> PackedVector2Array:
var result := points
for _iteration: int in iterations:
var next := PackedVector2Array()
if not closed:
next.append(result[0])
var edge_count := result.size() if closed else result.size() - 1
for index: int in edge_count:
var a := result[index]
var b := result[(index + 1) % result.size()]
next.append(a.lerp(b, 0.25))
next.append(a.lerp(b, 0.75))
if not closed:
next.append(result[result.size() - 1])
result = next
return result
static func _resample(
points: PackedVector2Array,
closed: bool,
spacing: float
) -> PackedVector2Array:
var total_length := _path_length(points, closed)
if total_length <= spacing:
return points
var count := maxi(roundi(total_length / spacing), 3 if closed else 2)
var actual_spacing := total_length / float(count if closed else count - 1)
var result := PackedVector2Array()
var edge := 0
var edge_start_distance := 0.0
var edge_length := points[0].distance_to(points[1])
for sample: int in count:
var target := actual_spacing * sample
while target > edge_start_distance + edge_length and edge < points.size() - 1:
edge_start_distance += edge_length
edge += 1
if edge >= points.size() - 1:
edge_length = points[edge].distance_to(points[0]) if closed else 0.0
else:
edge_length = points[edge].distance_to(points[edge + 1])
var next_index := (edge + 1) % points.size()
var amount := (
0.0
if edge_length <= WATER_PLANE_EPSILON
else (target - edge_start_distance) / edge_length
)
result.append(points[edge].lerp(points[next_index], clampf(amount, 0.0, 1.0)))
return result
static func _build_mesh(
paths: Array[Dictionary],
water_height: float,
water_reference: Vector2,
water_is_inside: bool
) -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var uvs := PackedVector2Array()
var indices := PackedInt32Array()
for path_data: Dictionary in paths:
var points: PackedVector2Array = path_data["points"]
var closed: bool = path_data["closed"]
var base_index := vertices.size()
var path_distance := 0.0
for index: int in points.size():
if index > 0:
path_distance += points[index - 1].distance_to(points[index])
var previous := points[(index - 1 + points.size()) % points.size()]
var following := points[(index + 1) % points.size()]
if not closed:
previous = points[maxi(index - 1, 0)]
following = points[mini(index + 1, points.size() - 1)]
var tangent := previous.direction_to(following)
var water_normal := Vector2(-tangent.y, tangent.x)
var toward_reference := points[index].direction_to(water_reference)
if (
(water_is_inside and water_normal.dot(toward_reference) < 0.0)
or (not water_is_inside and water_normal.dot(toward_reference) > 0.0)
):
water_normal = -water_normal
var land_point := points[index] - water_normal * LAND_INSET
var water_point := points[index] + water_normal * (RIBBON_WIDTH - LAND_INSET)
vertices.append(Vector3(land_point.x, water_height + SURFACE_OFFSET, land_point.y))
vertices.append(Vector3(water_point.x, water_height + SURFACE_OFFSET, water_point.y))
normals.append(Vector3.UP)
normals.append(Vector3.UP)
uvs.append(Vector2(path_distance, 0.0))
uvs.append(Vector2(path_distance, 1.0))
var edge_count := points.size() if closed else points.size() - 1
for index: int in edge_count:
var next := (index + 1) % points.size()
var a := base_index + index * 2
var b := a + 1
var c := base_index + next * 2
var d := c + 1
indices.append_array(PackedInt32Array([a, c, b, b, c, d]))
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
if not vertices.is_empty():
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
static func _point_key(point: Vector2) -> Vector2i:
return Vector2i(
roundi(point.x / ENDPOINT_MERGE_TOLERANCE),
roundi(point.y / ENDPOINT_MERGE_TOLERANCE)
)
static func _edge_key(a: Vector2i, b: Vector2i) -> String:
if a.x < b.x or (a.x == b.x and a.y <= b.y):
return "%d:%d|%d:%d" % [a.x, a.y, b.x, b.y]
return "%d:%d|%d:%d" % [b.x, b.y, a.x, a.y]
static func _has_unused_neighbor(
key: Vector2i,
adjacency: Dictionary,
unused_edges: Dictionary
) -> bool:
for neighbor: Vector2i in adjacency[key]:
if unused_edges.get(_edge_key(key, neighbor), false):
return true
return false
static func _contains_near(points: PackedVector2Array, point: Vector2) -> bool:
for existing: Vector2 in points:
if existing.distance_to(point) <= WATER_PLANE_EPSILON:
return true
return false
static func _point_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float:
var segment := b - a
if segment.length_squared() <= WATER_PLANE_EPSILON:
return point.distance_to(a)
var amount := clampf((point - a).dot(segment) / segment.length_squared(), 0.0, 1.0)
return point.distance_to(a + segment * amount)
static func _path_length(points: PackedVector2Array, closed: bool) -> float:
var result := 0.0
for index: int in points.size() - 1:
result += points[index].distance_to(points[index + 1])
if closed and points.size() > 2:
result += points[points.size() - 1].distance_to(points[0])
return result
static func _mesh_triangle_count(mesh: ArrayMesh) -> int:
if mesh.get_surface_count() == 0:
return 0
var arrays := mesh.surface_get_arrays(0)
return (arrays[Mesh.ARRAY_INDEX] as PackedInt32Array).size() / 3

View file

@ -0,0 +1 @@
uid://dhp7ctspabll

26
world/water/water_type.gd Normal file
View file

@ -0,0 +1,26 @@
class_name WaterType
extends RefCounted
enum Type {
FRESH_WATER,
SALT_WATER,
OTHER,
}
const FRESH_WATER_MASK := 1 << Type.FRESH_WATER
const SALT_WATER_MASK := 1 << Type.SALT_WATER
const ALL_FISHABLE_MASK := FRESH_WATER_MASK | SALT_WATER_MASK
static func mask_for(type: Type) -> int:
return 1 << int(type) if type != Type.OTHER else 0
static func label(type: Type) -> String:
match type:
Type.FRESH_WATER:
return "Fresh Water"
Type.SALT_WATER:
return "Salt Water"
_:
return "Other"

View file

@ -0,0 +1 @@
uid://nao2xukkyoc8