feat(world): depth-coloured water column fog and de-tiled sand ripples

A FogVolume covering the site carries a fog shader whose scattering albedo
follows the same spectral transmittance as the seabed and whose density
grows below the reference depth, so haze darkens and turns indigo with
depth around every object; the global volumetric density moves into the
volume and the far depth fog only covers what lies beyond 128 m. Sand
ripples are bent by a slow domain warp, blended with a larger ripple
scale and faded out between 40 and 60 m, which hides the 3.2 m tiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-06 23:44:04 +02:00
1 parent 61ef4a892d
commit d7e50f8879
11 files changed
+82 -15

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 250 KiB

@@ -265,6 +265,14 @@ de contacts existant les sépare du fond.
atténués. Plancher à 5 % pour que le pied du tombant reste lisible. Palette atténués. Plancher à 5 % pour que le pied du tombant reste lisible. Palette
GDP §18.2 obtenue de fait : vert-bleu à 24 m, bleu froid à 45 m, indigo au GDP §18.2 obtenue de fait : vert-bleu à 24 m, bleu froid à 45 m, indigo au
pied du tombant ; ciel, brouillard et soleil (0,9, ombres 45 %) refroidis. pied du tombant ; ciel, brouillard et soleil (0,9, ombres 45 %) refroidis.
- Brouillard par profondeur : un `FogVolume` (`WaterColumn`, 1,8 km × 260 m)
porte un shader `fog` (`water_column_fog.gdshader`) dont l'albédo suit la même
transmittance spectrale que le fond et dont la densité croît lentement sous
24 m ; la densité volumétrique globale passe à 0, la portée volumétrique à
128 m, le brouillard de distance (couleur fixe, 0,0045) ne couvre que
l'au-delà. Tuilage du sable cassé par une déformation de domaine lente
(37 m), une seconde échelle de rides (11,3 m) et une extinction des rides
entre 40 et 60 m de fond.
**État du 2026-10-06 (demande d'Olivier)** : le shader mélange sable et roche **État du 2026-10-06 (demande d'Olivier)** : le shader mélange sable et roche
par la pente à partir de 20° (sable sous 20°, roche au-dessus de 40°, fondu par la pente à partir de 20° (sable sous 20°, roche au-dessus de 40°, fondu
+14 -4
View File
@@ -1,7 +1,11 @@
[gd_scene load_steps=6 format=3] [gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] [ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"]
[ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] [ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"]
[ext_resource type="Shader" path="res://game/world/sites/water_column_fog.gdshader" id="3_water_column"]
[sub_resource type="ShaderMaterial" id="WaterColumnMaterial"]
shader = ExtResource("3_water_column")
[sub_resource type="ProceduralSkyMaterial" id="SkyUnderwater"] [sub_resource type="ProceduralSkyMaterial" id="SkyUnderwater"]
sky_top_color = Color(0.22, 0.52, 0.66, 1) sky_top_color = Color(0.22, 0.52, 0.66, 1)
@@ -51,15 +55,16 @@ fog_enabled = true
fog_light_color = Color(0.05, 0.18, 0.29, 1) fog_light_color = Color(0.05, 0.18, 0.29, 1)
fog_light_energy = 0.38 fog_light_energy = 0.38
fog_sun_scatter = 0.15 fog_sun_scatter = 0.15
fog_density = 0.006 fog_density = 0.0045
fog_aerial_perspective = 0.5 fog_aerial_perspective = 0.5
fog_height = -4.0 fog_height = -4.0
fog_height_density = 0.025 fog_height_density = 0.025
volumetric_fog_enabled = true volumetric_fog_enabled = true
volumetric_fog_density = 0.01 volumetric_fog_density = 0.0
volumetric_fog_albedo = Color(0.5, 0.78, 0.92, 1) volumetric_fog_albedo = Color(0.5, 0.78, 0.92, 1)
volumetric_fog_anisotropy = 0.45 volumetric_fog_anisotropy = 0.45
volumetric_fog_length = 72.0 volumetric_fog_length = 128.0
volumetric_fog_detail_spread = 1.6
volumetric_fog_ambient_inject = 0.5 volumetric_fog_ambient_inject = 0.5
adjustment_enabled = true adjustment_enabled = true
adjustment_contrast = 1.06 adjustment_contrast = 1.06
@@ -82,6 +87,11 @@ shadow_blur = 2.0
directional_shadow_max_distance = 160.0 directional_shadow_max_distance = 160.0
directional_shadow_fade_start = 0.75 directional_shadow_fade_start = 0.75
[node name="WaterColumn" type="FogVolume" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -110, 0)
size = Vector3(1800, 260, 1800)
material = SubResource("WaterColumnMaterial")
[node name="Terrain" type="StaticBody3D" parent="."] [node name="Terrain" type="StaticBody3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("2_terrain") script = ExtResource("2_terrain")
+28 -10
View File
@@ -29,6 +29,14 @@ uniform sampler2D sand_normal_map : hint_normal, repeat_enable, filter_linear_mi
uniform sampler2D rock_normal_map : hint_normal, repeat_enable, filter_linear_mipmap; uniform sampler2D rock_normal_map : hint_normal, repeat_enable, filter_linear_mipmap;
uniform sampler2D rock_grain_map : hint_default_white, repeat_enable, filter_linear_mipmap; uniform sampler2D rock_grain_map : hint_default_white, repeat_enable, filter_linear_mipmap;
uniform float sand_tile_m = 3.2; uniform float sand_tile_m = 3.2;
// Second ripple scale (megaripples) and the slow domain warp that hide the tiling.
uniform float sand_macro_tile_m = 11.3;
uniform float sand_macro_strength = 0.45;
uniform float sand_warp_tile_m = 37.0;
uniform float sand_warp_m = 1.4;
// Swell-built ripples fade out with depth as the wave energy at the bed dies.
uniform float ripple_fade_start_m = 40.0;
uniform float ripple_fade_end_m = 60.0;
uniform float rock_tile_m = 7.0; uniform float rock_tile_m = 7.0;
uniform float rock_grain_tile_m = 1.7; uniform float rock_grain_tile_m = 1.7;
uniform float sand_normal_strength = 1.0; uniform float sand_normal_strength = 1.0;
@@ -81,17 +89,27 @@ void fragment() {
float up = clamp(n.y, 0.0, 1.0); float up = clamp(n.y, 0.0, 1.0);
float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up);
// Sand: ripple normal map rotated to the swell direction, on the xz plane. // Sand: ripple normal map rotated to the swell direction, on the xz plane. The
// plane is bent by a slow warp (two decorrelated reads of the seamless grain map)
// and a second, larger ripple scale is blended in, so no period repeats across
// the field. Ripples fade with depth.
vec2 ripple_axis = normalize(ripple_direction); vec2 ripple_axis = normalize(ripple_direction);
vec2 sand_uv = vec2( vec2 warp = vec2(
dot(world_position.xz, ripple_axis), texture(rock_grain_map, world_position.xz / sand_warp_tile_m).r,
dot(world_position.xz, vec2(-ripple_axis.y, ripple_axis.x)) texture(rock_grain_map, world_position.xz / sand_warp_tile_m + vec2(0.37, 0.61)).r
) / sand_tile_m; ) - 0.5;
vec3 sand_tangent = unpack_normal(texture(sand_normal_map, sand_uv)); vec2 bent_xz = world_position.xz + warp * sand_warp_m;
vec3 sand_perturbation = ( vec2 ripple_frame = vec2(
vec3(ripple_axis.x, 0.0, ripple_axis.y) * sand_tangent.x dot(bent_xz, ripple_axis),
+ vec3(-ripple_axis.y, 0.0, ripple_axis.x) * sand_tangent.y dot(bent_xz, vec2(-ripple_axis.y, ripple_axis.x))
) * sand_normal_strength; );
vec3 sand_tangent = unpack_normal(texture(sand_normal_map, ripple_frame / sand_tile_m));
vec3 macro_tangent = unpack_normal(texture(sand_normal_map, ripple_frame / sand_macro_tile_m + vec2(0.5, 0.25)));
float depth_for_ripples = -world_position.y;
float ripple_strength = sand_normal_strength * (1.0 - smoothstep(ripple_fade_start_m, ripple_fade_end_m, depth_for_ripples));
vec2 tangent_xy = sand_tangent.xy * ripple_strength + macro_tangent.xy * sand_macro_strength * ripple_strength;
vec3 sand_perturbation = vec3(ripple_axis.x, 0.0, ripple_axis.y) * tangent_xy.x
+ vec3(-ripple_axis.y, 0.0, ripple_axis.x) * tangent_xy.y;
vec3 sand_normal = normalize(n + sand_perturbation); vec3 sand_normal = normalize(n + sand_perturbation);
// Rock: coarse triplanar normal map plus fine grain, both in world space. // Rock: coarse triplanar normal map plus fine grain, both in world space.
@@ -0,0 +1,25 @@
shader_type fog;
// Water column for the volumetric fog: the scattering albedo follows the same
// per-channel Beer-Lambert transmittance as the seabed shader, so the haze turns
// from green-blue near the plateau to indigo at the drop-off foot, and the density
// grows slowly with depth (more suspended sediment, less light). It covers every
// object in the volume, not only the terrain.
uniform vec3 shallow_albedo : source_color = vec3(0.5, 0.78, 0.92);
uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032);
uniform float reference_depth_m = 24.0;
uniform float minimum_albedo = 0.03;
uniform float base_density = 0.012;
uniform float density_per_m = 0.00012;
uniform float emission_strength = 0.0;
void fog() {
// Above the reference depth the haze keeps its shallow colour: brightening it
// further would wash the upper water column towards white.
float excess_depth_m = max(-WORLD_POSITION.y - reference_depth_m, 0.0);
vec3 transmittance = exp(-water_absorption_per_m * excess_depth_m);
ALBEDO = clamp(shallow_albedo * transmittance, vec3(minimum_albedo), vec3(1.0));
DENSITY = base_density + density_per_m * excess_depth_m;
EMISSION = vec3(emission_strength);
}
@@ -19,7 +19,7 @@ const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn")
const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres" const ZONE1_SITE_PATH := "res://content/sites/zone1_vigilant.tres"
const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres" const SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres"
const GROOVE_PROBE_OFFSET_M := 25.0 const GROOVE_PROBE_OFFSET_M := 25.0
const TEST_COUNT := 40 const TEST_COUNT := 41
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -179,6 +179,12 @@ func _test_dive_site_scene() -> void:
await physics_frame await physics_frame
await physics_frame await physics_frame
_expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles")
var water_column := site.get_node_or_null("WaterColumn") as FogVolume
_expect(
water_column != null and water_column.material is ShaderMaterial
and (water_column.material as ShaderMaterial).shader != null,
"site carries a water-column fog volume with its depth shader"
)
_expect(site.placeholder_count() == 2, "calibration target and beacon are placed") _expect(site.placeholder_count() == 2, "calibration target and beacon are placed")
var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D
_expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body") _expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body")