feat: add rain puddles and stabilize shoreline water

This commit is contained in:
Alexander Sellite 2026-09-02 01:27:20 -04:00
parent eeef99793d
commit 3a61056ce5
49 changed files with 1033 additions and 1852 deletions

View file

@ -1,7 +0,0 @@
[plugin]
name="straywild Shoreline Auto-Baker"
description="Rebuilds configured static shoreline ribbons after authored terrain reimports."
author="woofmeow"
version="1.0.0"
script="shoreline_auto_baker.gd"

View file

@ -1,66 +0,0 @@
@tool
extends EditorPlugin
const TERRAIN_RESOURCE := (
"res://art/exported/environment/terrain/starter_island.glb"
)
const REGION_SCENE := "res://world/regions/starter_island_region.tscn"
var _bake_pending := false
func _enter_tree() -> void:
var filesystem := get_editor_interface().get_resource_filesystem()
if not filesystem.resources_reimported.is_connected(_on_resources_reimported):
filesystem.resources_reimported.connect(_on_resources_reimported)
func _exit_tree() -> void:
var filesystem := get_editor_interface().get_resource_filesystem()
if filesystem.resources_reimported.is_connected(_on_resources_reimported):
filesystem.resources_reimported.disconnect(_on_resources_reimported)
func _on_resources_reimported(resources: PackedStringArray) -> void:
if not resources.has(TERRAIN_RESOURCE) or _bake_pending:
return
_bake_pending = true
call_deferred("_bake_configured_shorelines")
func _bake_configured_shorelines() -> void:
_bake_pending = false
var packed_region := ResourceLoader.load(
REGION_SCENE,
"PackedScene",
ResourceLoader.CACHE_MODE_REPLACE,
) as PackedScene
if packed_region == null:
push_error("Shoreline auto-bake could not load %s." % REGION_SCENE)
return
var region := packed_region.instantiate() as Node3D
add_child(region)
var baker := region.get_node_or_null(
"ShorelineRibbonBaker"
) as ShorelineRibbonBaker
if baker == null:
push_error("Shoreline auto-bake found no configured baker.")
region.queue_free()
return
var results := baker.rebuild_all()
for result: Dictionary in results:
if result.get("skipped", false):
continue
var output_path: String = result.get("output_path", "")
if not output_path.is_empty():
ResourceLoader.load(
output_path,
"ArrayMesh",
ResourceLoader.CACHE_MODE_REPLACE,
)
print(
"Shoreline auto-bake: %s, %d loops, %d triangles"
% [output_path, result.loop_count, result.triangle_count]
)
region.queue_free()
get_editor_interface().get_resource_filesystem().scan()

View file

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

View file

@ -309,9 +309,9 @@ revision-specific inventory.
## Generated resources
Godot `.import` sidecars and deterministic shoreline `.tres` meshes are derived
repository resources, not original artwork. `.godot/imported/`, editor caches,
captures, exports, and temporary test data are not tracked.
Godot `.import` sidecars and other deterministic generated resources are
derived repository resources, not original artwork. `.godot/imported/`, editor
caches, captures, exports, and temporary test data are not tracked.
## Release gate

View file

@ -233,12 +233,10 @@ materials local to the scene.
### Shoreline presentation
The visible shoreline treatment is currently produced by the water materials.
The older smooth-ribbon baker and generated starter-island ribbon resource are
retained as development history/tooling, but the ribbon mesh is disabled and
is not part of the current rendered shoreline. Do not treat a ribbon rebuild
as a normal terrain-authoring requirement or re-enable the mesh as incidental
cleanup.
The visible shoreline treatment is produced entirely by the water materials.
It does not require supplemental shoreline meshes or generation tooling. Keep
shoreline foam and edge-effect work in the water materials rather than adding
terrain-authoring requirements.
### Facial-feature textures

View file

@ -235,7 +235,7 @@ profile also:
- keeps the title water and full-window menu patterns static;
- replaces the animated cooler water and decorative bubbles with flat water;
- disables decorative title-screen fish and bubbles;
- retains title and dusk music while omitting rain and shoreline ambience;
- retains title and dusk music while omitting rain ambience;
- reduces rain to 48 particles simulated at 8 FPS; and
- replaces procedural sky clouds and 81 moving local cloud patches with one
flat cloud ceiling.

View file

@ -119,6 +119,9 @@ const NetworkWorldWeatherServiceType = preload(
"res://network/network_world_weather_service.gd"
)
const RainAmbienceType = preload("res://world/rain_ambience.gd")
const RainPuddlePresentationType = preload(
"res://world/rain_puddle_presentation.gd"
)
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const NetworkJobServiceType = preload("res://network/network_job_service.gd")
const GatherableCatalogType = preload(
@ -309,8 +312,8 @@ var _home_entry_transition_active: bool = false
var _home_entry_walk_tween: Tween
var _observed_cancel_input_frame: int = -1
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
var _rain_puddles: RainPuddlePresentationType
func _ready() -> void:
@ -337,11 +340,10 @@ func _ready() -> void:
_rain_ambience.name = "RainAmbience"
add_child(_rain_ambience)
_rain_ambience.configure(_world_weather)
_rain_puddles = RainPuddlePresentationType.new()
_rain_puddles.name = "RainPuddlePresentation"
add_child(_rain_puddles)
_configure_audio_performance_profile(_performance_profile.is_light())
_shoreline_ambience.configure(
_player,
_test_world.get_saltwater_shoreline_mesh(),
)
if not _settings_manager.settings_changed.is_connected(
_apply_runtime_settings
):
@ -376,10 +378,8 @@ func _configure_audio_performance_profile(light_profile: bool) -> void:
_new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH)
_dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH)
if light_profile:
_shoreline_ambience.set_audio_enabled(false)
_rain_ambience.set_audio_enabled(false)
return
_shoreline_ambience.set_audio_enabled(true)
_rain_ambience.set_audio_enabled(true)
@ -439,7 +439,6 @@ func _disable_dedicated_presentation_tree() -> void:
NodePath("TitleMusic"),
NodePath("NewGameMusic"),
NodePath("DuskMusic"),
NodePath("ShorelineAmbience"),
]:
var audio_node: Node = get_node_or_null(audio_path)
if audio_node == null:
@ -674,6 +673,12 @@ func _initialize_application(dedicated: bool) -> void:
Callable(_player, "get_active_gameplay_camera"),
_performance_profile.is_light(),
)
_rain_puddles.configure(
_test_world,
_world_weather,
_player,
_performance_profile.is_light(),
)
if not _world_weather.weather_changed.is_connected(
_on_world_weather_changed
):
@ -1926,8 +1931,8 @@ func _set_gameplay_active(active: bool) -> void:
_end_shop_npc_conversation(true)
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
_shoreline_ambience.set_active(active)
_rain_ambience.set_active(active)
_rain_puddles.set_active(active)
if active and not was_gameplay_started:
var current_hour: float = _world_time.get_time_hours()
_dusk_music_played_for_natural_day = (
@ -2641,10 +2646,7 @@ func _apply_world(
_test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
)
_shoreline_ambience.configure(
_player,
_test_world.get_saltwater_shoreline_mesh(),
)
_rain_puddles.refresh_world()
return true
@ -3037,7 +3039,7 @@ func _on_local_home_space_changed(
var inside_rv: bool = String(space_id).begins_with("rv:")
_world_time_visuals.set_local_home_interior_active(inside_rv)
_rain_ambience.set_suppressed(inside_rv)
_shoreline_ambience.set_suppressed(inside_rv)
_rain_puddles.set_suppressed(inside_rv)
_surface_drawings_root.visible = not inside_rv
_world_gatherables_root.visible = not inside_rv
_refresh_active_hotbar_item()

View file

@ -49,7 +49,6 @@
[ext_resource type="Script" uid="uid://llj46fx0oco7" path="res://network/network_world_weather_service.gd" id="51_network_world_weather"]
[ext_resource type="Script" uid="uid://daqtje84gqgu0" path="res://jobs/player_job_service.gd" id="52_player_jobs"]
[ext_resource type="Script" uid="uid://drlyytr2o3uw8" path="res://network/network_job_service.gd" id="53_network_jobs"]
[ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"]
[ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"]
[ext_resource type="Script" path="res://settings/keyboard_mouse_mapping_manager.gd" id="keyboard_mouse_mapping"]
[ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"]
@ -353,14 +352,6 @@ bus = &"Music"
unique_name_in_owner = true
bus = &"Music"
[node name="ShorelineAmbience" type="Node" parent="." unique_id=1948037177]
unique_name_in_owner = true
script = ExtResource("54_shoreline_ambience")
[node name="WavesAudio" type="AudioStreamPlayer" parent="ShorelineAmbience" unique_id=193530658]
unique_name_in_owner = true
bus = &"Environment"
[node name="WorldPixelationPostprocess" parent="." unique_id=344378000 instance=ExtResource("16_world_pixelation")]
unique_name_in_owner = true

View file

@ -24,10 +24,6 @@ TextureSamplingPolicy="*res://main/texture_sampling_policy.gd"
window/stretch/aspect="expand"
[editor_plugins]
enabled=PackedStringArray("res://addons/straywild_shoreline_baker/plugin.cfg")
[gui]
timers/tooltip_delay_sec=0.0

View file

@ -1,42 +0,0 @@
extends SceneTree
const REGION_SCENE := preload("res://world/regions/starter_island_region.tscn")
func _initialize() -> void:
call_deferred("_bake")
func _bake() -> void:
var started := Time.get_ticks_usec()
var region := REGION_SCENE.instantiate()
root.add_child(region)
var baker := region.get_node_or_null("ShorelineRibbonBaker") as ShorelineRibbonBaker
if baker == null:
push_error("The map does not contain a configured ShorelineRibbonBaker.")
quit(1)
return
var results := baker.rebuild_all()
if results.size() != baker.water_bodies.size():
quit(1)
return
for result: Dictionary in results:
if result.get("skipped", false):
print(
"Shoreline %s: type=%s skipped intentionally"
% [result["output_path"], WaterType.label(result["water_type"])]
)
continue
print(
"Shoreline %s: type=%s segments=%d loops=%d raw=%d simplified=%d smoothed=%d triangles=%d"
% [
result["output_path"],
WaterType.label(result["water_type"]),
result["segment_count"],
result["loop_count"],
result["raw_point_count"],
result["simplified_point_count"],
result["smoothed_point_count"],
result["triangle_count"],
]
)
print("Shoreline bake completed in %.2f ms" % ((Time.get_ticks_usec() - started) / 1000.0))
quit()

View file

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

View file

@ -233,7 +233,7 @@ selected data root. Device-local configuration remains under
PortMaster launches use the light performance profile by default. Put the word
\`normal\` in \`straywild/conf/performance_profile\` to opt a capable device
into the normal rendering profile. PortMaster packages retain title and world
music while omitting rain and shoreline ambience in both profiles. See
music while omitting rain ambience in both profiles. See
\`straywild/licenses/\` for bundled credits and license information.
## Controls

View file

@ -45,7 +45,6 @@ readonly -a QUICK_TESTS=(
"tests/player_gathering_animation_validation.gd"
"tests/player_experience_validation.gd"
"tests/profile_title_validation.gd"
"tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd"
"tests/surface_drawing_performance_validation.gd"
"tests/surface_drawing_texture_renderer_validation.gd"

View file

@ -505,7 +505,7 @@ func _run() -> void:
"trailblazer",
))
var remote_transform: Transform3D = player.global_transform
remote_transform.origin += Vector3(0.8, 0.0, 0.0)
remote_transform.origin += Vector3(20.0, 0.0, 0.0)
var remote_avatar := spawn_service.spawn_remote_player(
NAMEPLATE_TEST_PEER_ID,
remote_transform,
@ -534,6 +534,29 @@ func _run() -> void:
assert(remote_plate.visible)
assert((remote_nameplate.get("name") as Label).text == "remote stray")
assert((remote_nameplate.get("title") as Label).text == "trailblazer")
chat_ui.call(
"_show_speech_bubble",
NAMEPLATE_TEST_PEER_ID,
"distant hello",
"",
)
await process_frame
chat_ui.call("_update_speech")
var remote_speech_entries: Dictionary = chat_ui.get("_speech")
var remote_speech: Dictionary = remote_speech_entries.get(
NAMEPLATE_TEST_PEER_ID,
{},
)
var remote_speech_bubble := remote_speech.get("bubble") as PanelContainer
assert(remote_speech_bubble != null and remote_speech_bubble.visible)
assert(is_equal_approx(
remote_speech_bubble.scale.x,
ChatUI.speech_scale_for_distance(
remote_avatar.global_position.distance_to(player.global_position)
),
))
assert(remote_speech_bubble.scale.x < 1.0)
chat_ui.call("_on_peer_removed", NAMEPLATE_TEST_PEER_ID)
spawn_service.remove_peer(NAMEPLATE_TEST_PEER_ID)
registry.remove_peer(NAMEPLATE_TEST_PEER_ID)
chat_ui.call("_update_nameplates")

View file

@ -524,21 +524,16 @@ func _validate_starter_water_bodies() -> void:
assert(ocean.water_type == WaterType.Type.SALT_WATER)
assert(pond.fish_pool == PondPool)
assert(ocean.fish_pool == OceanPool)
assert(region.get_node_or_null("ShorelineRibbons/Pond") == null)
var shoreline_geometry := (
region.get_node_or_null("ShorelineRibbons/Ocean") as MeshInstance3D
)
assert(shoreline_geometry != null)
assert(not shoreline_geometry.visible)
assert(shoreline_geometry.material_override == null)
var pond_material := (
region.get_node("WaterBodies/Pond/VisualWater") as MeshInstance3D
).material_override as ShaderMaterial
var ocean_material := (
region.get_node("WaterBodies/Ocean/VisualWater") as MeshInstance3D
).material_override as ShaderMaterial
assert(not bool(pond_material.get_shader_parameter("tide_effect_enabled")))
assert(bool(ocean_material.get_shader_parameter("tide_effect_enabled")))
assert(pond_material != null)
assert(ocean_material != null)
assert(float(ocean_material.get_shader_parameter("shoreline_foam_depth")) > 0.0)
assert(float(ocean_material.get_shader_parameter("shoreline_foam_strength")) > 0.0)
region.free()

View file

@ -131,9 +131,6 @@ func _validate_normal_profile(main: Node) -> void:
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null)
assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null)
assert(
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null
)
assert(
(main.get_node("RainAmbience") as AudioStreamPlayer).stream != null
)

View file

@ -40,6 +40,15 @@ func _validate_voice_distance_attenuation() -> void:
ChatUI.animalese_volume_offset_db_for_distance(24.0),
-80.0,
))
assert(is_equal_approx(ChatUI.speech_scale_for_distance(0.0), 1.0))
assert(is_equal_approx(ChatUI.speech_scale_for_distance(4.0), 1.0))
var middle_distance_scale := ChatUI.speech_scale_for_distance(14.0)
assert(middle_distance_scale < 1.0)
assert(middle_distance_scale > ChatUI.SPEECH_MINIMUM_SCALE)
assert(is_equal_approx(
ChatUI.speech_scale_for_distance(24.0),
ChatUI.SPEECH_MINIMUM_SCALE,
))
func _run_host() -> void:

View file

@ -0,0 +1,84 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
assert(bool(main.call(
"_apply_world",
WorldLayout.GENERATED,
PlayerSaveManager.DEFAULT_WORLD_SEED,
true,
)))
var weather := main.get_node(
"%WorldWeatherService"
) as WorldWeatherService
assert(weather != null)
weather.apply_authoritative_snapshot(
WorldWeatherService.Weather.RAINY,
60.0,
)
main.call("_set_gameplay_active", true)
await physics_frame
await physics_frame
var puddles := main.get_node(
"RainPuddlePresentation"
) as RainPuddlePresentation
assert(puddles != null)
assert(not puddles.is_light_performance_profile())
assert(puddles.is_processing())
puddles.set_suppressed(true)
assert(not puddles.is_processing())
puddles.set_suppressed(false)
assert(puddles.is_processing())
puddles.set_active(false)
assert(not puddles.is_processing())
puddles.set_active(true)
assert(puddles.is_processing())
for _tick: int in 18:
puddles.call("_process", 0.25)
assert(puddles.get_active_puddle_count() > 0)
var visual_root := puddles.get_node("PuddleVisuals") as Node3D
assert(visual_root != null)
for puddle_visual: Node in visual_root.get_children():
assert(puddle_visual is MeshInstance3D)
assert(puddle_visual.get_child_count() == 0)
weather.apply_authoritative_snapshot(
WorldWeatherService.Weather.SUNNY,
60.0,
)
puddles.call("_process", 30.0)
assert(puddles.get_active_puddle_count() == 0)
_stop_audio_players(main)
main.queue_free()
for _frame: int in 8:
await process_frame
print("Rain puddle runtime validation: PASS")
quit()
func _stop_audio_players(root_node: Node) -> void:
if root_node is AudioStreamPlayer:
(root_node as AudioStreamPlayer).stop()
elif root_node is AudioStreamPlayer2D:
(root_node as AudioStreamPlayer2D).stop()
elif root_node is AudioStreamPlayer3D:
(root_node as AudioStreamPlayer3D).stop()
for child: Node in root_node.get_children():
_stop_audio_players(child)

View file

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

View file

@ -1,50 +0,0 @@
extends SceneTree
const ShorelineAmbienceType := preload("res://world/shoreline_ambience.gd")
const WavesStream := preload("res://audio/ambience/waves.wav")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var waves := WavesStream as AudioStreamWAV
assert(waves != null)
var segments: Array[PackedVector2Array] = [
PackedVector2Array([Vector2.ZERO, Vector2(10.0, 0.0)]),
]
assert(is_equal_approx(
ShorelineAmbienceType.distance_to_segments(Vector2(5.0, 3.0), segments),
3.0,
))
assert(is_equal_approx(
ShorelineAmbienceType.distance_to_segments(Vector2(-4.0, 0.0), segments),
4.0,
))
var controller := ShorelineAmbienceType.new() as ShorelineAmbience
var runtime_audio := AudioStreamPlayer.new()
runtime_audio.name = "WavesAudio"
runtime_audio.unique_name_in_owner = true
runtime_audio.stream = WavesStream
runtime_audio.bus = &"SFX"
controller.add_child(runtime_audio)
runtime_audio.owner = controller
root.add_child(controller)
await process_frame
assert(runtime_audio.bus == &"Environment")
assert(runtime_audio.stream == null)
controller.set_audio_enabled(true)
assert(controller.near_distance < controller.far_distance)
assert(controller.near_volume_db > controller.far_volume_db)
var runtime_waves := runtime_audio.stream as AudioStreamWAV
assert(runtime_waves.loop_mode == AudioStreamWAV.LOOP_FORWARD)
assert(runtime_waves.loop_begin == 0)
assert(runtime_waves.loop_end == 1044956)
controller.free()
await process_frame
print("Shoreline ambience validation: PASS")
quit()

View file

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

View file

@ -1,97 +0,0 @@
extends SceneTree
const ShorelineMesh: ArrayMesh = preload(
"res://world/generated/shorelines/starter_ocean_shoreline.tres"
)
const GEOMETRY_EPSILON := 0.0001
const CROSSING_NEIGHBORHOOD := 32
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
assert(ShorelineMesh.get_surface_count() == 1)
var arrays := ShorelineMesh.surface_get_arrays(0)
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
assert(not vertices.is_empty())
assert(vertices.size() % 2 == 0)
assert(indices.size() == vertices.size() * 3)
var point_count: int = int(float(vertices.size()) / 2.0)
var land_edge := PackedVector2Array()
var water_edge := PackedVector2Array()
for index: int in point_count:
var land := vertices[index * 2]
var water := vertices[index * 2 + 1]
land_edge.append(Vector2(land.x, land.z))
water_edge.append(Vector2(water.x, water.z))
var ribbon_width := land.distance_to(water)
assert(ribbon_width >= 0.34)
assert(ribbon_width <= 1.5)
var crossing_count := (
_count_local_crossings(water_edge)
+ _count_local_crossings(land_edge)
)
if crossing_count > 0:
push_error(
"Shoreline ribbon contains %d local edge crossings."
% crossing_count
)
quit(1)
return
print("Shoreline ribbon validation: PASS")
quit()
func _count_local_crossings(points: PackedVector2Array) -> int:
var crossing_count := 0
for first: int in points.size():
var first_next := (first + 1) % points.size()
var checked_neighbors := mini(
CROSSING_NEIGHBORHOOD,
points.size() - 2,
)
for offset: int in range(2, checked_neighbors + 1):
var second := (first + offset) % points.size()
var second_next := (second + 1) % points.size()
if second_next == first:
continue
if _segments_cross(
points[first],
points[first_next],
points[second],
points[second_next],
):
crossing_count += 1
return crossing_count
func _segments_cross(
a: Vector2,
b: Vector2,
c: Vector2,
d: Vector2,
) -> bool:
if (
maxf(a.x, b.x) < minf(c.x, d.x) - GEOMETRY_EPSILON
or maxf(c.x, d.x) < minf(a.x, b.x) - GEOMETRY_EPSILON
or maxf(a.y, b.y) < minf(c.y, d.y) - GEOMETRY_EPSILON
or maxf(c.y, d.y) < minf(a.y, b.y) - GEOMETRY_EPSILON
):
return false
var first_direction := b - a
var second_direction := d - c
var denominator := first_direction.cross(second_direction)
if absf(denominator) <= GEOMETRY_EPSILON:
return false
var offset := c - a
var first_amount := offset.cross(second_direction) / denominator
var second_amount := offset.cross(first_direction) / denominator
return (
first_amount > GEOMETRY_EPSILON
and first_amount < 1.0 - GEOMETRY_EPSILON
and second_amount > GEOMETRY_EPSILON
and second_amount < 1.0 - GEOMETRY_EPSILON
)

View file

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

View file

@ -19,6 +19,9 @@ const FishAvailabilityType = preload("res://fish/fish_availability.gd")
const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd"
)
const RainPuddlePresentationType = preload(
"res://world/rain_puddle_presentation.gd"
)
func _initialize() -> void:
@ -34,6 +37,7 @@ func _run() -> void:
_validate_fishing_weather_seams()
_validate_fishing_spot_context()
_validate_weather_presentation()
_validate_rain_puddle_surface_rules()
print("World weather validation: PASS")
quit()
@ -285,6 +289,53 @@ func _validate_fishing_spot_context() -> void:
weather.free()
func _validate_rain_puddle_surface_rules() -> void:
var dry_flat := PackedVector3Array([
Vector3(-1.0, 0.0, -1.0),
Vector3(2.0, 0.0, -1.0),
Vector3(-1.0, 0.0, 2.0),
])
assert(RainPuddlePresentationType.is_puddle_surface_eligible(
dry_flat,
-0.25,
))
var water_edge := PackedVector3Array([
Vector3(-1.0, 0.0, -1.0),
Vector3(2.0, 0.0, -1.0),
Vector3(-1.0, -0.2, 2.0),
])
assert(not RainPuddlePresentationType.is_puddle_surface_eligible(
water_edge,
-0.25,
))
var steep_ground := PackedVector3Array([
Vector3(-1.0, 0.0, -1.0),
Vector3(2.0, 0.0, -1.0),
Vector3(-1.0, 2.0, -0.75),
])
assert(not RainPuddlePresentationType.is_puddle_surface_eligible(
steep_ground,
-0.25,
))
var center := Vector3(4.0, 1.0, -3.0)
var radii := Vector2(1.0, 0.5)
assert(RainPuddlePresentationType.is_point_inside_puddle(
Vector3(4.75, 1.0, -3.0),
center,
radii,
))
assert(not RainPuddlePresentationType.is_point_inside_puddle(
Vector3(5.1, 1.0, -3.0),
center,
radii,
))
assert(not RainPuddlePresentationType.is_point_inside_puddle(
Vector3(4.0, 2.1, -3.0),
center,
radii,
))
func _validate_weather_presentation() -> void:
var world_root := Node3D.new()
root.add_child(world_root)

View file

@ -39,6 +39,7 @@ const NAMEPLATE_SPEECH_GAP: float = 5.0
const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0
const ANIMALESE_SILENT_DISTANCE: float = 24.0
const ANIMALESE_SILENT_VOLUME_DB: float = -80.0
const SPEECH_MINIMUM_SCALE: float = 0.60
const ROLEPLAY_FONT_SLANT: float = -0.18
const MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000
const MOBILE_COMPACT_WIDTH: float = 620.0
@ -1357,6 +1358,19 @@ static func animalese_volume_offset_db_for_distance(distance: float) -> float:
return maxf(linear_to_db(amplitude), ANIMALESE_SILENT_VOLUME_DB)
static func speech_scale_for_distance(distance: float) -> float:
if not is_finite(distance) or distance >= ANIMALESE_SILENT_DISTANCE:
return SPEECH_MINIMUM_SCALE
if distance <= ANIMALESE_FULL_VOLUME_DISTANCE:
return 1.0
var distance_weight := inverse_lerp(
ANIMALESE_FULL_VOLUME_DISTANCE,
ANIMALESE_SILENT_DISTANCE,
distance,
)
return lerpf(1.0, SPEECH_MINIMUM_SCALE, distance_weight)
func _on_history(messages: Array) -> void:
for peer_id: int in _speech.keys():
var state: Dictionary = _speech[peer_id]
@ -1841,6 +1855,10 @@ func _update_speech() -> void:
bubble.hide()
continue
var world_position := avatar.get_nameplate_anchor_position()
var bubble_scale := speech_scale_for_distance(
avatar.global_position.distance_to(_player.global_position)
)
bubble.scale = Vector2.ONE * bubble_scale
if camera.is_position_behind(world_position):
bubble.hide()
continue
@ -1860,18 +1878,20 @@ func _update_speech() -> void:
):
bubble.hide()
continue
var visual_size := bubble.size * bubble_scale
var visual_pointer_height := SPEECH_POINTER_HEIGHT * bubble_scale
var desired := screen_position - Vector2(
bubble.size.x * 0.5,
bubble.size.y + SPEECH_POINTER_HEIGHT,
visual_size.x * 0.5,
visual_size.y + visual_pointer_height,
)
bubble.position = Vector2(
clampf(desired.x, 8.0, viewport_size.x - bubble.size.x - 8.0),
clampf(desired.x, 8.0, viewport_size.x - visual_size.x - 8.0),
clampf(
desired.y,
8.0,
viewport_size.y
- bubble.size.y
- SPEECH_POINTER_HEIGHT
- visual_size.y
- visual_pointer_height
- 8.0,
),
)
@ -1879,7 +1899,7 @@ func _update_speech() -> void:
if pointer != null:
pointer.position = Vector2(
clampf(
screen_position.x - bubble.position.x,
(screen_position.x - bubble.position.x) / bubble_scale,
SPEECH_POINTER_HALF_WIDTH + 12.0,
bubble.size.x - SPEECH_POINTER_HALF_WIDTH - 12.0,
),

File diff suppressed because one or more lines are too long

View file

@ -126,7 +126,6 @@ const FOLIAGE_WIND_SOURCE_MATERIALS_META: StringName = (
@onready var _diggable_beach: DiggableArea3D = %DiggableBeach
@onready var _ocean: WaterBodyAuthoring = %OceanWater
@onready var _fresh_water_root: Node3D = %FreshWaterBodies
@onready var _shoreline_reference: MeshInstance3D = %ShorelineReference
var _current_seed := PlayerSaveManager.DEFAULT_WORLD_SEED
var _light_performance_profile := false
@ -146,7 +145,6 @@ func _ready() -> void:
_validate_prop_catalog()
_validate_biome_catalog()
_configure_static_water()
_build_shoreline_reference()
_set_foliage_wind_enabled(not _light_performance_profile)
@ -199,10 +197,6 @@ func get_rv_upgrade_shop() -> RVUpgradeInteractionType:
return _rv_upgrade_shop
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference
func get_prop_catalog() -> TerrainPropCatalog:
return prop_catalog
@ -218,10 +212,13 @@ func get_biome_at(coordinate: Vector2i) -> StringName:
func set_light_performance_profile(enabled: bool) -> void:
_light_performance_profile = enabled
_set_foliage_wind_enabled(not enabled)
_apply_water_materials()
var surface_motion := _get_ocean_surface_motion()
if surface_motion != null:
surface_motion.set_motion_enabled(not enabled)
# Reset the old shader material before _apply_water_materials() swaps it.
surface_motion.set_motion_enabled(false)
_apply_water_materials()
if surface_motion != null and not enabled:
surface_motion.set_motion_enabled(true)
func set_foliage_wind_strength_multiplier(multiplier: float) -> void:
@ -1921,42 +1918,6 @@ func _add_prop_collision(
body.add_child(collision)
func _build_shoreline_reference() -> void:
var half := get_playable_half_extents()
var width := 0.05
var vertices := PackedVector3Array()
var indices := PackedInt32Array()
var corners := [
Vector3(-half.x, WATER_HEIGHT, -half.y),
Vector3(half.x, WATER_HEIGHT, -half.y),
Vector3(half.x, WATER_HEIGHT, half.y),
Vector3(-half.x, WATER_HEIGHT, half.y),
]
for index: int in 4:
var start: Vector3 = corners[index]
var finish: Vector3 = corners[(index + 1) % 4]
var direction := (finish - start).normalized()
var perpendicular := Vector3(-direction.z, 0.0, direction.x) * width
var base := vertices.size()
vertices.append_array(PackedVector3Array([
start - perpendicular,
start + perpendicular,
finish + perpendicular,
finish - perpendicular,
]))
indices.append_array(PackedInt32Array([
base, base + 1, base + 2,
base, base + 2, base + 3,
]))
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_shoreline_reference.mesh = mesh
func _append_surface_triangles(
result: Array[PackedVector3Array],
mesh_instance: MeshInstance3D,

View file

@ -112,8 +112,3 @@ unique_name_in_owner = true
[node name="Decorations" type="Node3D" parent="."]
unique_name_in_owner = true
[node name="ShorelineReference" type="MeshInstance3D" parent="."]
unique_name_in_owner = true
visible = false
cast_shadow = 0

View file

@ -1,71 +0,0 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
uniform vec4 lead_color : source_color = vec4(0.780, 0.960, 0.930, 1.0);
uniform vec4 trail_color : source_color = vec4(0.500, 0.840, 0.820, 1.0);
uniform float ribbon_width : hint_range(0.2, 2.0, 0.01) = 1.35;
uniform float land_inset : hint_range(0.0, 0.4, 0.01) = 0.12;
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.42;
uniform float lead_width : hint_range(0.01, 0.2, 0.005) = 0.14;
uniform float trail_width : hint_range(0.02, 0.4, 0.005) = 0.32;
uniform float lead_strength : hint_range(0.0, 1.0, 0.01) = 0.88;
uniform float trail_strength : hint_range(0.0, 1.0, 0.01) = 0.52;
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

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

View file

@ -1,19 +0,0 @@
[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.78, 0.96, 0.93, 1)
shader_parameter/trail_color = Color(0.5, 0.84, 0.82, 1)
shader_parameter/ribbon_width = 1.35
shader_parameter/land_inset = 0.12
shader_parameter/tide_speed = 1.42
shader_parameter/tide_distance = 0.42
shader_parameter/lead_width = 0.14
shader_parameter/trail_width = 0.32
shader_parameter/lead_strength = 0.88
shader_parameter/trail_strength = 0.52
shader_parameter/phase_scale = 0.035
shader_parameter/phase_strength = 0.55

View file

@ -1,35 +1,27 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_back, unshaded, fog_disabled;
// Use one depth sample directly behind this water pixel. Do not turn this into
// a neighbourhood filter: samples from adjacent cliff faces are what caused
// the former wedges and folding at corners.
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
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 vec4 shoreline_foam_color : source_color = vec4(0.580, 0.850, 0.860, 1.0);
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 shoreline_horizontal_reach : hint_range(0.05, 1.5, 0.01) = 0.32;
// WaterSurfaceMotion supplies the inverse of its visual bob here. Shore
// coloration and foam therefore use the physical waterline rather than a
// surface that moves a few centimetres through the terrain every frame.
uniform float stable_surface_height_offset = 0.0;
uniform float contact_fade_depth : hint_range(0.0, 0.1, 0.001) = 0.018;
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);
@ -41,14 +33,17 @@ uniform float surface_mark_strength : hint_range(0.0, 0.3, 0.005) = 0.12;
uniform float surface_mottle_scale : hint_range(0.01, 1.0, 0.01) = 0.11;
uniform vec2 surface_mottle_speed = vec2(-0.018, 0.013);
uniform float surface_mottle_strength : hint_range(0.0, 0.2, 0.005) = 0.07;
uniform float shoreline_foam_speed : hint_range(0.1, 3.0, 0.01) = 1.42;
uniform float shoreline_foam_phase_noise_scale : hint_range(0.001, 0.5, 0.001) = 0.055;
uniform float shoreline_foam_phase_noise_strength : hint_range(0.0, 2.0, 0.01) = 0.68;
uniform vec2 shoreline_mark_density = vec2(4.60, 3.20);
uniform vec2 shoreline_mark_drift = vec2(0.085, -0.060);
uniform float shoreline_mark_depth : hint_range(0.1, 2.0, 0.01) = 0.95;
uniform float shoreline_mark_strength : hint_range(0.0, 1.0, 0.01) = 0.82;
uniform float shoreline_base_fill : hint_range(0.0, 1.0, 0.01) = 0.32;
uniform float shoreline_opacity_boost : hint_range(0.0, 0.5, 0.01) = 0.26;
uniform float shoreline_foam_depth : hint_range(0.1, 2.0, 0.01) = 0.55;
uniform float shoreline_foam_strength : hint_range(0.0, 1.0, 0.01) = 0.95;
uniform float shoreline_base_fill : hint_range(0.0, 1.0, 0.01) = 0.58;
uniform float shoreline_opacity_boost : hint_range(0.0, 0.5, 0.01) = 0.38;
uniform float surface_highlight_night_floor : hint_range(0.0, 1.0, 0.01) = 0.72;
uniform float shoreline_minimum_reach : hint_range(0.1, 1.0, 0.01) = 0.48;
uniform float shoreline_minimum_reach : hint_range(0.1, 1.0, 0.01) = 0.22;
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;
@ -171,40 +166,6 @@ vec3 reconstruct_world_position(
}
float sample_water_depth(
vec2 screen_uv,
float water_surface_height,
mat4 inverse_projection,
mat4 inverse_view
) {
float raw_depth = texture(depth_texture, screen_uv).r;
vec3 scene_world_position = reconstruct_world_position(
screen_uv,
raw_depth,
inverse_projection,
inverse_view
);
return water_surface_height - scene_world_position.y;
}
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() {
vec4 world_vertex = MODEL_MATRIX * vec4(VERTEX, 1.0);
vec4 view_vertex = VIEW_MATRIX * world_vertex;
@ -221,151 +182,32 @@ void vertex() {
void fragment() {
float raw_depth = texture(depth_texture, SCREEN_UV).r;
vec3 scene_world_position = reconstruct_world_position(
SCREEN_UV,
raw_depth,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float water_depth = max(world_position.y - scene_world_position.y, 0.0);
water_depth = min(water_depth, deep_depth * 4.0);
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
);
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),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float right_depth = sample_water_depth(
SCREEN_UV + vec2(contour_texel.x, 0.0),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float upper_depth = sample_water_depth(
SCREEN_UV - vec2(0.0, contour_texel.y),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float lower_depth = sample_water_depth(
SCREEN_UV + vec2(0.0, contour_texel.y),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float upper_left_depth = sample_water_depth(
SCREEN_UV - contour_texel,
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float upper_right_depth = sample_water_depth(
SCREEN_UV + vec2(contour_texel.x, -contour_texel.y),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float lower_left_depth = sample_water_depth(
SCREEN_UV + vec2(-contour_texel.x, contour_texel.y),
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float lower_right_depth = sample_water_depth(
SCREEN_UV + contour_texel,
world_position.y,
INV_PROJECTION_MATRIX,
INV_VIEW_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
);
float diagonal_scale = 0.70710678;
float upper_left_weight = contour_neighbor_weight(
water_depth, upper_left_depth, contour_depth_reject
) * diagonal_scale;
float upper_right_weight = contour_neighbor_weight(
water_depth, upper_right_depth, contour_depth_reject
) * diagonal_scale;
float lower_left_weight = contour_neighbor_weight(
water_depth, lower_left_depth, contour_depth_reject
) * diagonal_scale;
float lower_right_weight = contour_neighbor_weight(
water_depth, lower_right_depth, contour_depth_reject
) * diagonal_scale;
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;
contour_depth_sum += upper_left_depth * upper_left_weight;
contour_depth_sum += upper_right_depth * upper_right_weight;
contour_depth_sum += lower_left_depth * lower_left_weight;
contour_depth_sum += lower_right_depth * lower_right_weight;
float neighbor_support = (
left_weight
+ right_weight
+ upper_weight
+ lower_weight
+ upper_left_weight
+ upper_right_weight
+ lower_left_weight
+ lower_right_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);
// The ocean is a single flat surface. Its visible surf must not be inferred
// from neighbouring depth-buffer pixels: that is camera dependent at cliffs
// and corners, and previously created flickering triangular shoreline masks.
// Use only the terrain sample directly behind this pixel for depth coloration
// and foam. Clear depth is sky/open water, not an imaginary zero-depth shore.
bool has_scene_depth = raw_depth < 0.99999;
float water_depth = deep_depth * 4.0;
if (has_scene_depth) {
vec3 scene_world_position = reconstruct_world_position(
SCREEN_UV,
raw_depth,
INV_PROJECTION_MATRIX,
INV_VIEW_MATRIX
);
float stable_surface_height = (
world_position.y + stable_surface_height_offset
);
water_depth = max(stable_surface_height - scene_world_position.y, 0.0);
}
vec2 scene_world_dx = dFdx(scene_world_position.xz);
vec2 scene_world_dy = dFdy(scene_world_position.xz);
float horizontal_dx = max(length(scene_world_dx), 0.0001);
float horizontal_dy = max(length(scene_world_dy), 0.0001);
float depth_gradient = length(vec2(
dFdx(water_depth) / horizontal_dx,
dFdy(water_depth) / horizontal_dy
));
float estimated_shore_distance = min(
water_depth / max(depth_gradient, 0.001),
shoreline_horizontal_reach * 4.0
);
float proximity_center = shoreline_horizontal_reach * 0.86;
float proximity_filter_width = max(
fwidth(estimated_shore_distance) * 1.5,
shoreline_horizontal_reach * 0.12
);
float shoreline_proximity = 1.0 - smoothstep(
max(proximity_center - proximity_filter_width, 0.0),
proximity_center + proximity_filter_width,
estimated_shore_distance
);
float contact_filter_width = max(fwidth(water_depth) * 1.5, 0.004);
water_depth = min(water_depth, deep_depth * 4.0);
// A very thin alpha threshold is visibly unstable at oblique angles, even
// with a correct centre depth sample. Blend the final contact over a broad,
// fixed physical range rather than toggling it across a single screen pixel.
float contact_stability = smoothstep(
max(contact_fade_depth - contact_filter_width, 0.0),
contact_fade_depth + contact_filter_width,
0.0,
max(contact_fade_depth, 0.10),
water_depth
);
@ -376,36 +218,67 @@ void fragment() {
float water_alpha = mix(shore_alpha, shallow_alpha, shallow_mix);
water_alpha = mix(water_alpha, deep_alpha, deep_mix);
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
);
tide_wash *= contour_feature_support;
tide_wash *= shoreline_proximity;
tide_wash *= 1.0 - smoothstep(shallow_depth, deep_depth, water_depth);
water_color = mix(
water_color,
tide_wash_color.rgb,
tide_wash * tide_wash_strength
);
water_alpha = min(
water_alpha
+ tide_wash * tide_wash_alpha_shift,
0.96
);
// Foam is driven by the exact same stable, one-pixel terrain depth as the
// base shoreline. Its breakup pattern lives in world coordinates, so camera
// motion cannot bend, fold, or slide it across unrelated terrain at corners.
float shoreline_foam = 0.0;
if (has_scene_depth) {
vec2 phase_coordinates = (
world_position.xz * shoreline_foam_phase_noise_scale
);
float shoreline_phase = (
value_noise(phase_coordinates)
+ value_noise(
phase_coordinates * 0.47 + vec2(9.7, 3.1)
) * 0.5
- 0.75
) * shoreline_foam_phase_noise_strength;
float shoreline_cycle = (
sin(TIME * shoreline_foam_speed + shoreline_phase) * 0.5 + 0.5
);
float animated_shoreline_depth = shoreline_foam_depth * mix(
shoreline_minimum_reach,
1.0,
shoreline_cycle
);
float shoreline_mask = 1.0 - smoothstep(
max(animated_shoreline_depth * 0.08, 0.01),
max(animated_shoreline_depth, 0.02),
water_depth
);
shoreline_mask *= 1.0 - smoothstep(
shallow_depth,
deep_depth,
water_depth
);
float shoreline_marks = max(
blocky_mark_layer(
world_position.xz * shoreline_mark_density
+ TIME * shoreline_mark_drift,
17.0,
0.04
),
blocky_mark_layer(
world_position.xz * shoreline_mark_density * 1.37
- TIME * shoreline_mark_drift.yx * 0.73,
41.0,
0.08
)
);
shoreline_foam = shoreline_mask * mix(
shoreline_base_fill,
1.0,
shoreline_marks
);
water_color = mix(
water_color,
shoreline_foam_color.rgb,
shoreline_foam * shoreline_foam_strength
);
water_alpha = min(
water_alpha + shoreline_foam * shoreline_opacity_boost,
0.98
);
}
vec2 flowing_grid = (
@ -464,57 +337,6 @@ void fragment() {
surface_mark * surface_mark_strength * mark_visibility
);
vec2 shoreline_phase_coordinates = world_position.xz * phase_noise_scale;
float shoreline_phase = (
value_noise(shoreline_phase_coordinates)
+ value_noise(
shoreline_phase_coordinates * 0.47 + vec2(9.7, 3.1)
) * 0.5
- 0.75
) * phase_noise_strength;
float shoreline_cycle = (
sin(TIME * tide_speed + shoreline_phase) * 0.5 + 0.5
);
float animated_shoreline_depth = shoreline_mark_depth * mix(
shoreline_minimum_reach,
1.0,
shoreline_cycle
);
float shoreline_mask = 1.0 - smoothstep(
max(animated_shoreline_depth * 0.08, 0.01),
max(animated_shoreline_depth, 0.02),
contour_depth
);
float shoreline_depth_visibility = 1.0 - smoothstep(
shallow_depth,
deep_depth,
water_depth
);
shoreline_mask *= shoreline_depth_visibility;
float shoreline_marks = max(
blocky_mark_layer(
world_position.xz * shoreline_mark_density
+ TIME * shoreline_mark_drift,
17.0,
0.04
),
blocky_mark_layer(
world_position.xz * shoreline_mark_density * 1.37
- TIME * shoreline_mark_drift.yx * 0.73,
41.0,
0.08
)
);
float shoreline_foam = shoreline_mask * mix(
shoreline_base_fill,
1.0,
shoreline_marks
);
shoreline_foam *= shoreline_proximity;
water_alpha = min(
water_alpha + shoreline_foam * shoreline_opacity_boost,
0.98
);
// Keep the water tint visually continuous across authored terrain contact.
// Fully fading here exposes the raised sand edge as a broken tan seam; the
// fishing resolver, rather than this presentation shader, owns castability.
@ -551,7 +373,7 @@ void fragment() {
);
float visible_highlight = max(
surface_highlight,
shoreline_foam * shoreline_mark_strength
shoreline_foam * shoreline_foam_strength
);
water_color = mix(
water_color,

View file

@ -8,26 +8,12 @@ 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/shoreline_foam_color = Color(0.580392, 0.85098, 0.858824, 1)
shader_parameter/shallow_depth = 0.7
shader_parameter/deep_depth = 3.6
shader_parameter/shore_depth_width = 0.1
shader_parameter/shore_alpha = 0.68
shader_parameter/shallow_alpha = 0.78
shader_parameter/deep_alpha = 0.9
shader_parameter/tide_effect_enabled = true
shader_parameter/tide_speed = 1.42
shader_parameter/tide_distance = 0.11
shader_parameter/tide_wash_width = 0.16
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.025
shader_parameter/contour_smoothing_pixels = 1.35
shader_parameter/contour_smoothing_strength = 0.88
shader_parameter/contour_depth_reject = 0.6
shader_parameter/shoreline_horizontal_reach = 0.32
shader_parameter/contact_fade_depth = 0.018
shader_parameter/open_water_drift_scale = 0.018
shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
@ -39,10 +25,13 @@ shader_parameter/surface_mark_strength = 0.12
shader_parameter/surface_mottle_scale = 0.11
shader_parameter/surface_mottle_speed = Vector2(-0.018, 0.013)
shader_parameter/surface_mottle_strength = 0.07
shader_parameter/shoreline_foam_speed = 1.42
shader_parameter/shoreline_foam_phase_noise_scale = 0.055
shader_parameter/shoreline_foam_phase_noise_strength = 0.68
shader_parameter/shoreline_mark_density = Vector2(4.6, 3.2)
shader_parameter/shoreline_mark_drift = Vector2(0.085, -0.06)
shader_parameter/shoreline_mark_depth = 0.55
shader_parameter/shoreline_mark_strength = 0.95
shader_parameter/shoreline_foam_depth = 0.55
shader_parameter/shoreline_foam_strength = 0.95
shader_parameter/shoreline_base_fill = 0.58
shader_parameter/shoreline_opacity_boost = 0.38
shader_parameter/surface_highlight_night_floor = 0.72

View file

@ -8,26 +8,12 @@ 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/shoreline_foam_color = Color(0.580392, 0.85098, 0.858824, 1)
shader_parameter/shallow_depth = 0.7
shader_parameter/deep_depth = 3.6
shader_parameter/shore_depth_width = 0.1
shader_parameter/shore_alpha = 0.68
shader_parameter/shallow_alpha = 0.78
shader_parameter/deep_alpha = 0.9
shader_parameter/tide_effect_enabled = false
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.35
shader_parameter/contour_smoothing_strength = 0.88
shader_parameter/contour_depth_reject = 0.6
shader_parameter/shoreline_horizontal_reach = 0.32
shader_parameter/contact_fade_depth = 0.018
shader_parameter/open_water_drift_scale = 0.018
shader_parameter/open_water_drift_speed = Vector2(0.01, -0.007)
@ -39,10 +25,13 @@ shader_parameter/surface_mark_strength = 0.12
shader_parameter/surface_mottle_scale = 0.11
shader_parameter/surface_mottle_speed = Vector2(-0.018, 0.013)
shader_parameter/surface_mottle_strength = 0.07
shader_parameter/shoreline_foam_speed = 1.42
shader_parameter/shoreline_foam_phase_noise_scale = 0.055
shader_parameter/shoreline_foam_phase_noise_strength = 0.68
shader_parameter/shoreline_mark_density = Vector2(4.6, 3.2)
shader_parameter/shoreline_mark_drift = Vector2(0.085, -0.06)
shader_parameter/shoreline_mark_depth = 0.55
shader_parameter/shoreline_mark_strength = 0.95
shader_parameter/shoreline_foam_depth = 0.55
shader_parameter/shoreline_foam_strength = 0.95
shader_parameter/shoreline_base_fill = 0.58
shader_parameter/shoreline_opacity_boost = 0.38
shader_parameter/surface_highlight_night_floor = 0.72

View file

@ -0,0 +1,645 @@
class_name RainPuddlePresentation
extends Node3D
## Client-only rain puddles. These are deliberately visual-only: they have no
## collision, water-region, fishing, persistence, or networking components.
const PUDDLE_SURFACE_MATERIALS: Array[StringName] = [
&"grass_lite",
&"grass",
&"sand",
]
const TERRAIN_COLLISION_MASK: int = 1
const MAX_PUDDLES: int = 14
const SURFACE_CELL_SIZE: float = 8.0
const SURFACE_SEARCH_CELL_RADIUS: int = 3
const SURFACE_CLEARANCE_ABOVE_WATER: float = 0.08
const MINIMUM_SURFACE_UP_DOT: float = 0.985
const MINIMUM_PLAYER_DISTANCE: float = 2.5
const MAXIMUM_PLAYER_DISTANCE: float = 17.0
const RELEASE_DISTANCE: float = 23.0
# Puddle radius is intentionally generous: the presentation should read from
# the normal gameplay camera rather than as tiny ground specks.
const MINIMUM_PUDDLE_RADIUS: float = 0.78
const MAXIMUM_PUDDLE_RADIUS: float = 2.76
const MINIMUM_PUDDLE_SPACING: float = 4.35
const MAXIMUM_PLAYER_HEIGHT_OFFSET: float = 1.0
const SPAWN_INTERVAL_SECONDS: float = 0.18
const FADE_IN_ALPHA_PER_SECOND: float = 0.85
const FADE_OUT_ALPHA_PER_SECOND: float = 0.055
const SPLASH_COOLDOWN_SECONDS: float = 0.34
const SPLASH_COLOR := Color(0.42, 0.76, 0.80, 0.92)
const PUDDLE_COLOR := Color(0.055, 0.18, 0.225, 0.42)
class PuddleState:
var visual: MeshInstance3D
var material: StandardMaterial3D
var center: Vector3 = Vector3.ZERO
var radii: Vector2 = Vector2.ONE
var rotation_y: float = 0.0
var alpha: float = 0.0
var active: bool = false
var player_was_inside: bool = false
var splash_cooldown: float = 0.0
var _test_world: TestWorld
var _weather_service: WorldWeatherService
var _local_player: Player
var _light_performance_profile: bool = false
var _is_active: bool = false
var _suppressed: bool = false
var _surface_cache_ready: bool = false
var _surface_triangles: Array[PackedVector3Array] = []
var _surface_triangle_indices_by_cell: Dictionary = {}
var _puddles: Array[PuddleState] = []
var _random := RandomNumberGenerator.new()
var _spawn_accumulator: float = 0.0
var _player_was_grounded: bool = false
var _visual_root: Node3D
var _splash_root: Node3D
var _puddle_mesh: CylinderMesh
func _ready() -> void:
set_process(false)
func _exit_tree() -> void:
if (
_weather_service != null
and _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.disconnect(_on_weather_changed)
func configure(
test_world: TestWorld,
weather_service: WorldWeatherService,
local_player: Player,
light_performance_profile: bool,
) -> void:
if (
_weather_service != null
and _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.disconnect(_on_weather_changed)
_test_world = test_world
_weather_service = weather_service
_local_player = local_player
_light_performance_profile = light_performance_profile
_random.seed = _seed_for_world()
if (
_weather_service != null
and not _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.connect(_on_weather_changed)
if not _light_performance_profile:
_prepare_visual_pool()
refresh_world()
_refresh_processing()
func set_active(active: bool) -> void:
if _is_active == active:
return
_is_active = active
if not _is_active:
_clear_puddles()
_refresh_processing()
func set_suppressed(suppressed: bool) -> void:
if _suppressed == suppressed:
return
_suppressed = suppressed
if _suppressed:
_clear_puddles()
_refresh_processing()
func refresh_world() -> void:
_surface_cache_ready = false
_surface_triangles.clear()
_surface_triangle_indices_by_cell.clear()
_spawn_accumulator = 0.0
_player_was_grounded = false
_random.seed = _seed_for_world()
_clear_puddles()
_refresh_processing()
func is_light_performance_profile() -> bool:
return _light_performance_profile
func get_active_puddle_count() -> int:
var count: int = 0
for puddle: PuddleState in _puddles:
if puddle.active and puddle.alpha > 0.0:
count += 1
return count
func _process(delta: float) -> void:
if not _can_present_puddles():
return
var player_position: Vector3 = _local_player.global_position
var is_raining: bool = _weather_service.is_raining()
if is_raining:
_ensure_surface_cache()
_release_distant_puddles(player_position)
_fade_puddles_in(delta)
_spawn_accumulator += delta
while _spawn_accumulator >= SPAWN_INTERVAL_SECONDS:
_spawn_accumulator -= SPAWN_INTERVAL_SECONDS
if not _spawn_puddle_near(player_position):
break
else:
_fade_puddles_out(delta)
_update_player_splashes(player_position, delta)
_refresh_processing()
func _on_weather_changed(
_weather: WorldWeatherService.Weather,
_seconds_remaining: float,
) -> void:
if _weather_service != null and _weather_service.is_raining():
_spawn_accumulator = SPAWN_INTERVAL_SECONDS
_refresh_processing()
func _can_present_puddles() -> bool:
return (
not _light_performance_profile
and _is_active
and not _suppressed
and _test_world != null
and _test_world.is_world_ready()
and _weather_service != null
and _local_player != null
and _local_player.is_local_control_enabled()
)
func _refresh_processing() -> void:
var should_process: bool = (
_can_present_puddles()
and (
_weather_service.is_raining()
or _has_active_puddles()
)
)
set_process(should_process)
func _has_active_puddles() -> bool:
for puddle: PuddleState in _puddles:
if puddle.active:
return true
return false
func _prepare_visual_pool() -> void:
if _visual_root != null:
return
_visual_root = Node3D.new()
_visual_root.name = "PuddleVisuals"
add_child(_visual_root)
_splash_root = Node3D.new()
_splash_root.name = "PuddleSplashes"
add_child(_splash_root)
_puddle_mesh = CylinderMesh.new()
_puddle_mesh.top_radius = 1.0
_puddle_mesh.bottom_radius = 1.0
_puddle_mesh.height = 0.012
_puddle_mesh.radial_segments = 12
_puddle_mesh.rings = 1
for _index: int in MAX_PUDDLES:
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.roughness = 0.18
material.metallic = 0.0
var visual := MeshInstance3D.new()
visual.name = "RainPuddle"
visual.mesh = _puddle_mesh
visual.material_override = material
visual.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
visual.visible = false
_visual_root.add_child(visual)
var puddle := PuddleState.new()
puddle.visual = visual
puddle.material = material
_puddles.append(puddle)
func _ensure_surface_cache() -> void:
if _surface_cache_ready or _test_world == null:
return
_surface_cache_ready = true
var saltwater_height: float = _test_world.get_saltwater_surface_height()
if is_nan(saltwater_height):
return
var candidate_triangles: Array[PackedVector3Array] = (
_test_world.get_spawn_surface_triangles(
PUDDLE_SURFACE_MATERIALS,
saltwater_height + SURFACE_CLEARANCE_ABOVE_WATER,
MINIMUM_SURFACE_UP_DOT,
)
)
for triangle: PackedVector3Array in candidate_triangles:
if not is_puddle_surface_eligible(triangle, saltwater_height):
continue
var triangle_index: int = _surface_triangles.size()
_surface_triangles.append(triangle)
var center: Vector3 = (
triangle[0] + triangle[1] + triangle[2]
) / 3.0
var cell: Vector2i = _surface_cell_for(center)
var triangle_indices: Array = (
_surface_triangle_indices_by_cell.get(cell, []) as Array
)
triangle_indices.append(triangle_index)
_surface_triangle_indices_by_cell[cell] = triangle_indices
func _spawn_puddle_near(player_position: Vector3) -> bool:
if _active_puddle_count() >= MAX_PUDDLES:
return false
var puddle: PuddleState = _first_inactive_puddle()
if puddle == null:
return false
for _attempt: int in 24:
var triangle_index: int = _random_nearby_triangle_index(
player_position
)
if triangle_index < 0 or triangle_index >= _surface_triangles.size():
return false
var triangle: PackedVector3Array = _surface_triangles[triangle_index]
var position: Vector3 = _random_point_in_triangle(triangle)
var horizontal_distance: float = Vector2(
position.x,
position.z,
).distance_to(Vector2(player_position.x, player_position.z))
if (
horizontal_distance < MINIMUM_PLAYER_DISTANCE
or horizontal_distance > MAXIMUM_PLAYER_DISTANCE
or _is_candidate_blocked(position)
or _is_near_existing_puddle(position)
):
continue
var maximum_radius: float = minf(
MAXIMUM_PUDDLE_RADIUS,
_minimum_triangle_edge_distance(position, triangle) * 0.65,
)
if maximum_radius < MINIMUM_PUDDLE_RADIUS:
continue
var radius_x: float = _random.randf_range(
MINIMUM_PUDDLE_RADIUS,
maximum_radius,
)
var radius_z: float = radius_x * _random.randf_range(0.58, 0.86)
_activate_puddle(
puddle,
position,
Vector2(radius_x, radius_z),
_random.randf_range(0.0, TAU),
)
return true
return false
func _active_puddle_count() -> int:
var count: int = 0
for puddle: PuddleState in _puddles:
if puddle.active:
count += 1
return count
func _first_inactive_puddle() -> PuddleState:
for puddle: PuddleState in _puddles:
if not puddle.active:
return puddle
return null
func _random_nearby_triangle_index(player_position: Vector3) -> int:
var base_cell: Vector2i = _surface_cell_for(player_position)
for _attempt: int in 16:
var cell := base_cell + Vector2i(
_random.randi_range(
-SURFACE_SEARCH_CELL_RADIUS,
SURFACE_SEARCH_CELL_RADIUS,
),
_random.randi_range(
-SURFACE_SEARCH_CELL_RADIUS,
SURFACE_SEARCH_CELL_RADIUS,
),
)
var triangle_indices: Array = (
_surface_triangle_indices_by_cell.get(cell, []) as Array
)
if not triangle_indices.is_empty():
return int(triangle_indices[_random.randi_range(
0,
triangle_indices.size() - 1,
)])
return -1
func _activate_puddle(
puddle: PuddleState,
center: Vector3,
radii: Vector2,
rotation_y: float,
) -> void:
puddle.center = center
puddle.radii = radii
puddle.rotation_y = rotation_y
puddle.alpha = 0.0
puddle.active = true
puddle.player_was_inside = false
puddle.splash_cooldown = 0.0
puddle.visual.global_position = center + Vector3.UP * 0.012
puddle.visual.rotation = Vector3(0.0, rotation_y, 0.0)
puddle.visual.scale = Vector3(radii.x, 1.0, radii.y)
_apply_puddle_alpha(puddle)
puddle.visual.visible = true
func _fade_puddles_in(delta: float) -> void:
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
puddle.alpha = minf(
1.0,
puddle.alpha + FADE_IN_ALPHA_PER_SECOND * delta,
)
_apply_puddle_alpha(puddle)
func _fade_puddles_out(delta: float) -> void:
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
puddle.alpha = maxf(
0.0,
puddle.alpha - FADE_OUT_ALPHA_PER_SECOND * delta,
)
if puddle.alpha <= 0.0:
_deactivate_puddle(puddle)
continue
_apply_puddle_alpha(puddle)
func _release_distant_puddles(player_position: Vector3) -> void:
for puddle: PuddleState in _puddles:
if (
puddle.active
and Vector2(puddle.center.x, puddle.center.z).distance_to(
Vector2(player_position.x, player_position.z)
) > RELEASE_DISTANCE
):
_deactivate_puddle(puddle)
func _deactivate_puddle(puddle: PuddleState) -> void:
puddle.active = false
puddle.alpha = 0.0
puddle.player_was_inside = false
puddle.splash_cooldown = 0.0
if puddle.visual != null:
puddle.visual.visible = false
func _clear_puddles() -> void:
for puddle: PuddleState in _puddles:
_deactivate_puddle(puddle)
if _splash_root == null:
return
for splash: Node in _splash_root.get_children():
splash.queue_free()
func _apply_puddle_alpha(puddle: PuddleState) -> void:
if puddle.material == null:
return
var color := PUDDLE_COLOR
color.a *= puddle.alpha
puddle.material.albedo_color = color
func _is_candidate_blocked(position: Vector3) -> bool:
if not is_inside_tree():
return false
var world: World3D = get_world_3d()
if world == null:
return false
var query := PhysicsRayQueryParameters3D.create(
position + Vector3.UP * 0.05,
position + Vector3.UP * 1.75,
TERRAIN_COLLISION_MASK,
)
query.collide_with_areas = false
query.collide_with_bodies = true
return not world.direct_space_state.intersect_ray(query).is_empty()
func _is_near_existing_puddle(position: Vector3) -> bool:
var horizontal_position := Vector2(position.x, position.z)
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
var minimum_distance: float = (
MINIMUM_PUDDLE_SPACING + maxf(puddle.radii.x, puddle.radii.y)
)
if horizontal_position.distance_to(
Vector2(puddle.center.x, puddle.center.z)
) < minimum_distance:
return true
return false
func _update_player_splashes(
player_position: Vector3,
delta: float,
) -> void:
var grounded: bool = _local_player.is_on_floor()
for puddle: PuddleState in _puddles:
if not puddle.active or puddle.alpha <= 0.08:
continue
puddle.splash_cooldown = maxf(
0.0,
puddle.splash_cooldown - delta,
)
var is_inside: bool = is_point_inside_puddle(
player_position,
puddle.center,
puddle.radii,
puddle.rotation_y,
)
var should_splash: bool = (
is_inside
and puddle.splash_cooldown <= 0.0
and (
not puddle.player_was_inside
or (not _player_was_grounded and grounded)
)
)
if should_splash:
_emit_splash(player_position, puddle.center.y)
puddle.splash_cooldown = SPLASH_COOLDOWN_SECONDS
puddle.player_was_inside = is_inside
_player_was_grounded = grounded
func _emit_splash(player_position: Vector3, surface_height: float) -> void:
if _splash_root == null or not _splash_root.visible:
return
var particles := CPUParticles3D.new()
particles.name = "RainPuddleSplash"
particles.global_position = Vector3(
player_position.x,
surface_height + 0.04,
player_position.z,
)
particles.amount = 7
particles.lifetime = 0.38
particles.one_shot = true
particles.local_coords = true
particles.explosiveness = 1.0
particles.direction = Vector3.UP
particles.spread = 48.0
particles.gravity = Vector3(0.0, -3.2, 0.0)
particles.initial_velocity_min = 0.65
particles.initial_velocity_max = 1.15
particles.scale_amount_min = 0.7
particles.scale_amount_max = 1.0
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = SPLASH_COLOR
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.roughness = 1.0
var droplet_mesh := BoxMesh.new()
droplet_mesh.size = Vector3(0.028, 0.04, 0.028)
droplet_mesh.material = material
particles.mesh = droplet_mesh
_splash_root.add_child(particles)
particles.emitting = true
var cleanup := create_tween()
cleanup.tween_interval(0.5)
cleanup.tween_callback(particles.queue_free)
func _seed_for_world() -> int:
if _test_world == null:
return 1
return max(_test_world.get_generation_seed(), 1) ^ 0x5261696e
func _surface_cell_for(position: Vector3) -> Vector2i:
return Vector2i(
floori(position.x / SURFACE_CELL_SIZE),
floori(position.z / SURFACE_CELL_SIZE),
)
func _random_point_in_triangle(triangle: PackedVector3Array) -> Vector3:
var first: float = _random.randf()
var second: float = _random.randf()
if first + second > 1.0:
first = 1.0 - first
second = 1.0 - second
return triangle[0] + (triangle[1] - triangle[0]) * first + (
triangle[2] - triangle[0]
) * second
func _minimum_triangle_edge_distance(
position: Vector3,
triangle: PackedVector3Array,
) -> float:
var horizontal_position := Vector2(position.x, position.z)
return minf(
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[0].x, triangle[0].z),
Vector2(triangle[1].x, triangle[1].z),
),
minf(
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[1].x, triangle[1].z),
Vector2(triangle[2].x, triangle[2].z),
),
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[2].x, triangle[2].z),
Vector2(triangle[0].x, triangle[0].z),
),
),
)
static func is_puddle_surface_eligible(
triangle: PackedVector3Array,
saltwater_height: float,
) -> bool:
if triangle.size() != 3 or is_nan(saltwater_height):
return false
for vertex: Vector3 in triangle:
if vertex.y <= saltwater_height + SURFACE_CLEARANCE_ABOVE_WATER:
return false
var cross: Vector3 = (triangle[1] - triangle[0]).cross(
triangle[2] - triangle[0]
)
return (
cross.length_squared() > 0.0000001
and absf(cross.normalized().dot(Vector3.UP)) >= MINIMUM_SURFACE_UP_DOT
)
static func is_point_inside_puddle(
world_position: Vector3,
center: Vector3,
radii: Vector2,
rotation_y: float = 0.0,
) -> bool:
if radii.x <= 0.0 or radii.y <= 0.0:
return false
if absf(world_position.y - center.y) > MAXIMUM_PLAYER_HEIGHT_OFFSET:
return false
var offset := Vector2(
world_position.x - center.x,
world_position.z - center.z,
).rotated(-rotation_y)
offset = Vector2(offset.x / radii.x, offset.y / radii.y)
return offset.length_squared() <= 1.0
static func _horizontal_distance_to_segment(
point: Vector2,
start: Vector2,
end: Vector2,
) -> float:
var segment: Vector2 = end - start
var segment_length_squared: float = segment.length_squared()
if segment_length_squared <= 0.0000001:
return point.distance_to(start)
var ratio: float = clampf(
(point - start).dot(segment) / segment_length_squared,
0.0,
1.0,
)
return point.distance_to(start + segment * ratio)

View file

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

View file

@ -107,8 +107,10 @@ func get_rv_upgrade_shop() -> RVUpgradeInteractionType:
return get_node_or_null(rv_upgrade_shop_path) as RVUpgradeInteractionType
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D
func _get_ocean_surface_motion() -> WaterSurfaceMotion:
return get_node_or_null(
^"WaterBodies/Ocean/VisualWater"
) as WaterSurfaceMotion
func set_light_performance_profile(enabled: bool) -> void:
@ -116,9 +118,7 @@ func set_light_performance_profile(enabled: bool) -> void:
var pond := get_node_or_null(
^"WaterBodies/Pond/VisualWater"
) as MeshInstance3D
var ocean := get_node_or_null(
^"WaterBodies/Ocean/VisualWater"
) as MeshInstance3D
var ocean := _get_ocean_surface_motion()
if pond != null:
pond.material_override = (
_create_light_water_material(
@ -129,6 +129,9 @@ func set_light_performance_profile(enabled: bool) -> void:
else NORMAL_FRESH_WATER_MATERIAL
)
if ocean != null:
# Reset the old shader material before replacing it with the lite material.
# Otherwise its last bob offset could leak into the next normal-profile use.
ocean.set_motion_enabled(false)
ocean.material_override = (
_create_light_water_material(
LIGHT_SALT_WATER_COLOR,
@ -137,20 +140,15 @@ func set_light_performance_profile(enabled: bool) -> void:
if enabled
else NORMAL_SALT_WATER_MATERIAL
)
var surface_motion := ocean as WaterSurfaceMotion
if surface_motion != null:
surface_motion.set_motion_enabled(not enabled)
if not enabled:
ocean.set_motion_enabled(true)
func set_foliage_wind_strength_multiplier(multiplier: float) -> void:
_foliage_wind_strength_multiplier = maxf(multiplier, 0.0)
_set_foliage_wind_enabled(_foliage_wind_enabled)
func set_water_motion_strength_multiplier(multiplier: float) -> void:
var ocean := get_node_or_null(
^"WaterBodies/Ocean/VisualWater"
) as WaterSurfaceMotion
var ocean := _get_ocean_surface_motion()
if ocean != null:
ocean.set_motion_strength_multiplier(multiplier)

View file

@ -11,11 +11,8 @@
[ext_resource type="PackedScene" path="res://world/interactables/player_storage_box.tscn" id="8_storage"]
[ext_resource type="Script" uid="uid://dtcgnxl1kmdu1" path="res://world/water_body_authoring.gd" id="10_water_body"]
[ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="13_water"]
[ext_resource type="ArrayMesh" path="res://world/generated/shorelines/starter_ocean_shoreline.tres" id="17_ocean_shoreline"]
[ext_resource type="Material" path="res://world/materials/stylized_water_fresh.tres" id="19_fresh_water"]
[ext_resource type="Script" uid="uid://drdjm443o35tc" path="res://world/water_surface_motion.gd" id="20_surface_motion"]
[ext_resource type="Script" path="res://world/water/shoreline_ribbon_baker.gd" id="21_shoreline_baker"]
[ext_resource type="Script" path="res://world/water/shoreline_ribbon_config.gd" id="22_shoreline_config"]
[ext_resource type="Script" path="res://world/digging/diggable_area_3d.gd" id="23_diggable_area"]
[ext_resource type="Script" path="res://world/gathering/gatherable_anchor_set_3d.gd" id="24_gatherable_anchors"]
[ext_resource type="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="25_decor_shop"]
@ -60,29 +57,6 @@ size = Vector3(48, 5, 18)
resource_local_to_scene = true
size = Vector3(48, 5, 18)
[sub_resource type="Resource" id="ShorelineRibbonConfigPond"]
script = ExtResource("22_shoreline_config")
terrain_source = NodePath("../Terrain/Visual/starter_island")
water_height = 2.51
generation_bounds = Rect2(-17.45, -4.08375, 16.1, 12.3675)
water_reference = Vector2(-9.4, 2.1)
water_is_inside = true
output_resource_path = "res://world/generated/shorelines/starter_pond_shoreline.tres"
[sub_resource type="Resource" id="ShorelineRibbonConfigOcean"]
script = ExtResource("22_shoreline_config")
terrain_source = NodePath("../Terrain/Visual/starter_island")
water_type = 1
water_height = -0.45
generation_bounds = Rect2(-42, -50, 108, 100)
water_reference = Vector2(0, 0)
water_is_inside = false
output_resource_path = "res://world/generated/shorelines/starter_ocean_shoreline.tres"
simplification_tolerance = 0.55
smoothing_iterations = 3
resample_spacing = 0.16
corner_rounding_distance = 2.4
[node name="StarterIslandRegion" type="Node3D" unique_id=1830415700]
script = ExtResource("1_region")
region_id = &"starter_island"
@ -142,17 +116,6 @@ position = Vector3(-19.4381, 4.4016, 0.5559)
[node name="TreeWestNorth" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"]
position = Vector3(-17.4368, 4.3886, 1.3864)
[node name="ShorelineRibbonBaker" type="Node" parent="."]
script = ExtResource("21_shoreline_baker")
water_bodies = Array[ExtResource("22_shoreline_config")]([SubResource("ShorelineRibbonConfigPond"), SubResource("ShorelineRibbonConfigOcean")])
[node name="ShorelineRibbons" type="Node3D" parent="."]
[node name="Ocean" type="MeshInstance3D" parent="ShorelineRibbons"]
visible = false
cast_shadow = 0
mesh = ExtResource("17_ocean_shoreline")
[node name="WaterBodies" type="Node3D" parent="." unique_id=1417012172]
[node name="Pond" type="Node3D" parent="WaterBodies" unique_id=995477450]

View file

@ -30,10 +30,6 @@ func get_player_storage() -> PlayerStorageInteraction:
return null
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return null
func set_light_performance_profile(_enabled: bool) -> void:
pass

View file

@ -1,200 +0,0 @@
class_name ShorelineAmbience
extends Node
const WAVES_STREAM_PATH: String = "res://audio/ambience/waves.wav"
@export_range(0.0, 20.0, 0.1) var near_distance: float = 2.0
@export_range(1.0, 100.0, 0.5) var far_distance: float = 24.0
@export_range(-40.0, 12.0, 0.5) var near_volume_db: float = 1.0
@export_range(-80.0, 0.0, 0.5) var far_volume_db: float = -18.0
@export_range(0.05, 1.0, 0.05) var distance_update_interval: float = 0.2
@export_range(0.1, 20.0, 0.1) var volume_smoothing_speed: float = 4.0
@onready var _waves_audio: AudioStreamPlayer = %WavesAudio
var _listener: Node3D
var _shoreline_segments: Array[PackedVector2Array] = []
var _distance_update_remaining: float = 0.0
var _target_volume_db: float = -80.0
var _is_active := false
var _audio_enabled: bool = false
var _suppressed: bool = false
func _ready() -> void:
_waves_audio.bus = &"Environment"
_waves_audio.stream = null
_waves_audio.volume_db = -80.0
set_process(false)
func set_audio_enabled(enabled: bool) -> void:
if _audio_enabled == enabled:
return
_audio_enabled = enabled
if not _audio_enabled:
_waves_audio.stop()
_waves_audio.stream = null
set_process(false)
return
if not ResourceLoader.exists(WAVES_STREAM_PATH, "AudioStream"):
return
_waves_audio.stream = load(WAVES_STREAM_PATH) as AudioStream
_configure_audio_loop(_waves_audio.stream)
if _is_active:
set_process(true)
_update_target_volume()
_waves_audio.volume_db = _target_volume_db
if _waves_audio.stream != null:
_waves_audio.play()
func configure(listener: Node3D, shoreline_mesh: MeshInstance3D) -> void:
_listener = listener
_shoreline_segments = _extract_shoreline_segments(shoreline_mesh)
_distance_update_remaining = 0.0
if _shoreline_segments.is_empty():
push_warning("Saltwater shoreline ambience has no coastline geometry.")
func set_active(active: bool) -> void:
_is_active = active
set_process(active and _audio_enabled and not _suppressed)
if active and not _suppressed:
if not _audio_enabled:
return
_distance_update_remaining = 0.0
_update_target_volume()
_waves_audio.volume_db = _target_volume_db
if not _waves_audio.playing and _waves_audio.stream != null:
_waves_audio.play()
else:
_waves_audio.stop()
_waves_audio.volume_db = -80.0
func set_suppressed(suppressed: bool) -> void:
if _suppressed == suppressed:
return
_suppressed = suppressed
set_process(_is_active and _audio_enabled and not suppressed)
if suppressed:
_waves_audio.stop()
_waves_audio.volume_db = -80.0
elif _is_active and _audio_enabled:
_distance_update_remaining = 0.0
_update_target_volume()
_waves_audio.volume_db = _target_volume_db
if _waves_audio.stream != null:
_waves_audio.play()
func _process(delta: float) -> void:
if not _is_active:
return
_distance_update_remaining -= delta
if _distance_update_remaining <= 0.0:
_distance_update_remaining = distance_update_interval
_update_target_volume()
_waves_audio.volume_db = lerpf(
_waves_audio.volume_db,
_target_volume_db,
1.0 - exp(-volume_smoothing_speed * delta),
)
func _update_target_volume() -> void:
if _listener == null or _shoreline_segments.is_empty():
_target_volume_db = -80.0
return
var listener_position := Vector2(
_listener.global_position.x,
_listener.global_position.z,
)
var distance := distance_to_segments(listener_position, _shoreline_segments)
var blend := clampf(
inverse_lerp(near_distance, maxf(near_distance + 0.01, far_distance), distance),
0.0,
1.0,
)
_target_volume_db = lerpf(near_volume_db, far_volume_db, smoothstep(0.0, 1.0, blend))
func _extract_shoreline_segments(
shoreline_mesh: MeshInstance3D,
) -> Array[PackedVector2Array]:
var segments: Array[PackedVector2Array] = []
var seen_segments: Dictionary = {}
if shoreline_mesh == null or shoreline_mesh.mesh == null:
return segments
for surface_index in shoreline_mesh.mesh.get_surface_count():
var arrays := shoreline_mesh.mesh.surface_get_arrays(surface_index)
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.is_empty():
continue
for index_offset in range(0, indices.size() - 2, 3):
var triangle := PackedVector2Array()
for corner in 3:
var vertex := shoreline_mesh.to_global(
vertices[indices[index_offset + corner]]
)
triangle.append(Vector2(vertex.x, vertex.z))
_append_unique_segment(segments, seen_segments, triangle[0], triangle[1])
_append_unique_segment(segments, seen_segments, triangle[1], triangle[2])
_append_unique_segment(segments, seen_segments, triangle[2], triangle[0])
return segments
func _append_unique_segment(
segments: Array[PackedVector2Array],
seen_segments: Dictionary,
start: Vector2,
end: Vector2,
) -> void:
if start.distance_squared_to(end) <= 0.000001:
return
# The baked ribbon repeats shared triangle edges. Quantized canonical keys
# keep only distinct segments without an O(n²) startup pass.
var first := start
var second := end
if first.x > second.x or (is_equal_approx(first.x, second.x) and first.y > second.y):
first = end
second = start
var key := "%d,%d:%d,%d" % [
roundi(first.x * 10000.0),
roundi(first.y * 10000.0),
roundi(second.x * 10000.0),
roundi(second.y * 10000.0),
]
if seen_segments.has(key):
return
seen_segments[key] = true
segments.append(PackedVector2Array([start, end]))
static func distance_to_segments(
point: Vector2,
segments: Array[PackedVector2Array],
) -> float:
var closest_squared := INF
for segment in segments:
if segment.size() < 2:
continue
var closest := Geometry2D.get_closest_point_to_segment(
point,
segment[0],
segment[1],
)
closest_squared = minf(closest_squared, point.distance_squared_to(closest))
return sqrt(closest_squared) if closest_squared < INF else INF
func _configure_audio_loop(stream: AudioStream) -> void:
var wav := stream as AudioStreamWAV
if wav == null:
return
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
if wav.loop_end <= wav.loop_begin:
wav.loop_begin = 0
wav.loop_end = int(round(wav.get_length() * wav.mix_rate))

View file

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

View file

@ -188,10 +188,6 @@ func get_fishable_water_regions() -> Array[FishableWaterRegion]:
)
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _active_region.get_saltwater_shoreline_mesh()
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,

View file

@ -1,139 +0,0 @@
@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,
configuration.corner_rounding_distance,
)
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

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

View file

@ -1,15 +0,0 @@
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
@export var corner_rounding_distance := -1.0

View file

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

View file

@ -1,636 +0,0 @@
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.55
const SMOOTHING_ITERATIONS := 3
const RESAMPLE_SPACING := 0.16
const CORNER_ROUNDING_DISTANCE := 2.4
const CORNER_ROUNDING_START_DEGREES := 24.0
const CORNER_ROUNDING_FULL_DEGREES := 78.0
const MAXIMUM_JOIN_SCALE := 1.0
const RIBBON_REACH_GROWTH_PER_METER := 0.52
const RIBBON_WIDTH := 1.35
const LAND_INSET := 0.12
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,
corner_rounding_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 corner_rounding := (
CORNER_ROUNDING_DISTANCE
if corner_rounding_override < 0.0
else corner_rounding_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 rounded := _round_sharp_corners(
simplified,
closed,
corner_rounding,
)
var smoothed := _chaikin(rounded, 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 _round_sharp_corners(
points: PackedVector2Array,
closed: bool,
rounding_distance: float,
) -> PackedVector2Array:
if rounding_distance <= WATER_PLANE_EPSILON or points.size() < 3:
return points
var result := PackedVector2Array()
for index: int in points.size():
if not closed and (index == 0 or index == points.size() - 1):
result.append(points[index])
continue
var previous := points[(index - 1 + points.size()) % points.size()]
var point := points[index]
var following := points[(index + 1) % points.size()]
var incoming_vector := point - previous
var outgoing_vector := following - point
var incoming_length := incoming_vector.length()
var outgoing_length := outgoing_vector.length()
if (
incoming_length <= WATER_PLANE_EPSILON
or outgoing_length <= WATER_PLANE_EPSILON
):
result.append(point)
continue
var incoming := incoming_vector / incoming_length
var outgoing := outgoing_vector / outgoing_length
var turn_degrees := rad_to_deg(
acos(clampf(incoming.dot(outgoing), -1.0, 1.0))
)
var corner_weight := smoothstep(
CORNER_ROUNDING_START_DEGREES,
CORNER_ROUNDING_FULL_DEGREES,
turn_degrees,
)
var cut_distance := minf(
rounding_distance * corner_weight,
minf(incoming_length, outgoing_length) * 0.44,
)
if cut_distance <= WATER_PLANE_EPSILON:
result.append(point)
continue
var entry := point - incoming * cut_distance
var exit := point + outgoing * cut_distance
var curve_steps := maxi(3, ceili(cut_distance / 0.22))
for step: int in curve_steps + 1:
var amount := float(step) / float(curve_steps)
var first := entry.lerp(point, amount)
var second := point.lerp(exit, amount)
var rounded_point := first.lerp(second, amount)
if (
result.is_empty()
or result[result.size() - 1].distance_to(rounded_point)
> WATER_PLANE_EPSILON
):
result.append(rounded_point)
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 closed_water_side := (
_closed_path_water_side(points, water_is_inside)
if closed
else 0.0
)
var base_index := vertices.size()
var water_normals := PackedVector2Array()
var join_scales := PackedFloat32Array()
var water_reaches := PackedFloat32Array()
for index: int in points.size():
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 point := points[index]
var incoming := previous.direction_to(point)
var outgoing := point.direction_to(following)
if incoming.is_zero_approx():
incoming = outgoing
if outgoing.is_zero_approx():
outgoing = incoming
var incoming_normal := Vector2(-incoming.y, incoming.x)
var outgoing_normal := Vector2(-outgoing.y, outgoing.x)
var water_normal := incoming_normal + outgoing_normal
if water_normal.is_zero_approx():
water_normal = outgoing_normal
water_normal = water_normal.normalized()
if closed:
water_normal *= closed_water_side
else:
var toward_reference := point.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 join_scale := minf(
1.0 / maxf(absf(water_normal.dot(outgoing_normal)), 0.55),
MAXIMUM_JOIN_SCALE,
)
var water_reach := _safe_water_reach(
incoming,
outgoing,
water_normal,
previous.distance_to(point),
point.distance_to(following),
(RIBBON_WIDTH - LAND_INSET) * join_scale,
)
water_normals.append(water_normal)
join_scales.append(join_scale)
water_reaches.append(water_reach)
water_reaches = _smooth_water_reaches(
points,
water_reaches,
closed,
)
var path_distance := 0.0
for index: int in points.size():
if index > 0:
path_distance += points[index - 1].distance_to(points[index])
var point := points[index]
var water_normal := water_normals[index]
var join_scale := join_scales[index]
var land_point := (
point - water_normal * LAND_INSET * join_scale
)
var water_point := (
point
+ water_normal * water_reaches[index]
)
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 _safe_water_reach(
incoming: Vector2,
outgoing: Vector2,
water_normal: Vector2,
incoming_length: float,
outgoing_length: float,
desired_reach: float,
) -> float:
var turn_cross := incoming.cross(outgoing)
var water_side := incoming.cross(water_normal)
if turn_cross * water_side <= 0.0:
return desired_reach
var turn_angle := acos(clampf(incoming.dot(outgoing), -1.0, 1.0))
if turn_angle <= WATER_PLANE_EPSILON:
return desired_reach
var radius := minf(incoming_length, outgoing_length) / maxf(
2.0 * sin(turn_angle * 0.5),
WATER_PLANE_EPSILON,
)
return minf(desired_reach, maxf(radius * 0.58, 0.24))
static func _smooth_water_reaches(
points: PackedVector2Array,
reaches: PackedFloat32Array,
closed: bool,
) -> PackedFloat32Array:
var result := reaches.duplicate()
if result.size() < 2:
return result
for _pass: int in 3:
var forward_start := 0 if closed else 1
for index: int in range(forward_start, result.size()):
var previous := (index - 1 + result.size()) % result.size()
var allowed := (
result[previous]
+ points[previous].distance_to(points[index])
* RIBBON_REACH_GROWTH_PER_METER
)
result[index] = minf(result[index], allowed)
var backward_start := result.size() - 1 if closed else result.size() - 2
for index: int in range(backward_start, -1, -1):
var following := (index + 1) % result.size()
var allowed := (
result[following]
+ points[index].distance_to(points[following])
* RIBBON_REACH_GROWTH_PER_METER
)
result[index] = minf(result[index], allowed)
return result
static func _closed_path_water_side(
points: PackedVector2Array,
water_is_inside: bool,
) -> float:
var signed_area_twice := 0.0
for index: int in points.size():
signed_area_twice += points[index].cross(
points[(index + 1) % points.size()]
)
var interior_side := 1.0 if signed_area_twice >= 0.0 else -1.0
return interior_side if water_is_inside else -interior_side
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 floori(
float((arrays[Mesh.ARRAY_INDEX] as PackedInt32Array).size()) / 3.0
)

View file

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

View file

@ -5,6 +5,9 @@ const DEFAULT_AMPLITUDE: float = 0.055
const DEFAULT_CYCLE_SECONDS: float = 7.0
const DEFAULT_SECONDARY_SWELL: float = 0.18
const DEFAULT_PHASE_OFFSET: float = 0.0
const STABLE_SURFACE_HEIGHT_OFFSET_PARAMETER: StringName = (
&"stable_surface_height_offset"
)
@export_range(0.0, 0.2, 0.005, "suffix:m") var amplitude: float = (
DEFAULT_AMPLITUDE
@ -23,29 +26,32 @@ var _motion_strength_multiplier: float = 1.0
func _ready() -> void:
_base_height = position.y
_apply_height_offset(0.0)
set_process(amplitude > 0.0)
func _process(_delta: float) -> void:
position.y = _base_height + calculate_height_offset(
_current_time_seconds(),
get_effective_amplitude(),
cycle_seconds,
secondary_swell,
phase_offset,
_apply_height_offset(
calculate_height_offset(
_current_time_seconds(),
get_effective_amplitude(),
cycle_seconds,
secondary_swell,
phase_offset,
)
)
func set_motion_enabled(enabled: bool) -> void:
set_process(enabled and amplitude > 0.0)
if not enabled:
position.y = _base_height
_apply_height_offset(0.0)
func set_motion_strength_multiplier(multiplier: float) -> void:
_motion_strength_multiplier = maxf(multiplier, 0.0)
if _motion_strength_multiplier <= 0.0:
position.y = _base_height
_apply_height_offset(0.0)
func get_motion_strength_multiplier() -> float:
@ -56,6 +62,25 @@ func get_effective_amplitude() -> float:
return amplitude * _motion_strength_multiplier
func _apply_height_offset(height_offset: float) -> void:
position.y = _base_height + height_offset
# Headless worlds have no visible shoreline to correct. Skip the visual
# material update there and keep dedicated/headless worlds presentation-free.
if DisplayServer.get_name() == "headless":
return
# The surface can still bob visually, but all shoreline shader thresholds
# should remain at the authored waterline. Only the active ocean uses this
# moving material, so publishing the offset on that material is sufficient.
var shader_material := material_override as ShaderMaterial
if shader_material == null:
shader_material = get_active_material(0) as ShaderMaterial
if shader_material != null:
shader_material.set_shader_parameter(
STABLE_SURFACE_HEIGHT_OFFSET_PARAMETER,
-height_offset
)
static func get_default_height_offset() -> float:
return calculate_height_offset(
_current_time_seconds(),