diff --git a/docs/briefs/captures/terrain_showcase_boulders.png b/docs/briefs/captures/terrain_showcase_boulders.png index 91189a6..5712f6a 100644 Binary files a/docs/briefs/captures/terrain_showcase_boulders.png and b/docs/briefs/captures/terrain_showcase_boulders.png differ diff --git a/docs/briefs/captures/terrain_showcase_canyon.png b/docs/briefs/captures/terrain_showcase_canyon.png index 92f1af7..a77785c 100644 Binary files a/docs/briefs/captures/terrain_showcase_canyon.png and b/docs/briefs/captures/terrain_showcase_canyon.png differ diff --git a/docs/briefs/captures/terrain_showcase_crevasse.png b/docs/briefs/captures/terrain_showcase_crevasse.png index 3eacdeb..2387663 100644 Binary files a/docs/briefs/captures/terrain_showcase_crevasse.png and b/docs/briefs/captures/terrain_showcase_crevasse.png differ diff --git a/docs/briefs/captures/terrain_showcase_spurs.png b/docs/briefs/captures/terrain_showcase_spurs.png index 7731aeb..ebd9bc9 100644 Binary files a/docs/briefs/captures/terrain_showcase_spurs.png and b/docs/briefs/captures/terrain_showcase_spurs.png differ diff --git a/docs/briefs/captures/zone1_vigilant_plateau.png b/docs/briefs/captures/zone1_vigilant_plateau.png index 397e7df..6a48704 100644 Binary files a/docs/briefs/captures/zone1_vigilant_plateau.png and b/docs/briefs/captures/zone1_vigilant_plateau.png differ diff --git a/docs/briefs/captures/zone1_vigilant_tombant.png b/docs/briefs/captures/zone1_vigilant_tombant.png index 8513a9f..847168a 100644 Binary files a/docs/briefs/captures/zone1_vigilant_tombant.png and b/docs/briefs/captures/zone1_vigilant_tombant.png differ diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md index 774f10f..0fa5076 100644 --- a/docs/briefs/terrain-site-zone1-gameplay.md +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -251,10 +251,15 @@ de contacts existant les sépare du fond. - Brouillard, lumière directionnelle et particules de la scène de test actuelle sont conservés et réglés par site. -**Écart d'implémentation (graybox)** : le shader livré n'est pas triplanaire. -Il mélange sable et roche par la pente à partir de 20° (sable sous 20°, roche -au-dessus de 40°, fondu entre ; le matériau sonar bascule à 20°) et rend les -rides par modulation d'albédo, sans normal map. À revoir en tranche 6. +**É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 +entre ; le matériau sonar bascule à 20°). La roche est en normal map +**triplanaire** monde (deux échelles, 7 m et 1,7 m) ; le sable porte une normal +map de rides orientée par la houle sur le plan xz ; une carte de grain sert +d'occlusion de détail et de marbrure d'albédo. Les trois cartes sont +procédurales, sans asset, construites par `SeabedDetailTextures` (bruit seedé, +sans couture, mipmaps) dans la couche monde. Le maillage n'a pas de tangentes : +les normales perturbées sont calculées en espace monde dans le shader. ## 8. Architecture proposée diff --git a/docs/briefs/terrain-varied-landforms.md b/docs/briefs/terrain-varied-landforms.md index 80f5de0..f1a1f5d 100644 --- a/docs/briefs/terrain-varied-landforms.md +++ b/docs/briefs/terrain-varied-landforms.md @@ -182,9 +182,10 @@ Plan de masse (nord en haut) : `terrain_showcase_canyon.png` (gradins et fond ridé), `_crevasse.png`, `_boulders.png`, `_spurs.png`, `_escarpment.png` (face de la falaise vue du pied) ; pour le Banc du Vigilant recuit, `zone1_vigilant_tombant.png` - (gradins du tombant) et `zone1_vigilant_plateau.png` (plateau ondulé). Le - rendu (brouillard, rides en albédo, absence d'ombrage de détail) n'est pas - l'objet de ce brief : à ce stade, le relief se lit surtout au sonar. + (gradins du tombant) et `zone1_vigilant_plateau.png` (plateau ondulé). Les + captures sont faites avec le shader à normal maps et ombrage de détail + (brief du Banc du Vigilant, §7) ; brouillard et lumière restent ceux de la + scène de site. ## 8. Décisions laissées à Olivier diff --git a/game/world/sites/bathymetric_terrain_actor.gd b/game/world/sites/bathymetric_terrain_actor.gd index 3a88bfd..5722aa8 100644 --- a/game/world/sites/bathymetric_terrain_actor.gd +++ b/game/world/sites/bathymetric_terrain_actor.gd @@ -2,14 +2,17 @@ class_name BathymetricTerrainActor extends StaticBody3D const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd") const TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader") const TILE_CELLS := 64 const VISIBILITY_RANGE_END_M := 420.0 const VISIBILITY_RANGE_MARGIN_M := 60.0 +const DETAIL_TEXTURE_SEED := 4102 var _field: TerrainHeightField var _material: ShaderMaterial +var _detail_textures: SeabedDetailTextures var _tiles_root: Node3D var _collision: CollisionShape3D @@ -19,8 +22,13 @@ func configure(field: TerrainHeightField) -> String: return "terrain field is missing" _clear() _field = field + if _detail_textures == null: + _detail_textures = SeabedDetailTextures.build(DETAIL_TEXTURE_SEED) _material = ShaderMaterial.new() _material.shader = TERRAIN_SHADER + _material.set_shader_parameter(&"sand_normal_map", _detail_textures.sand_normal) + _material.set_shader_parameter(&"rock_normal_map", _detail_textures.rock_normal) + _material.set_shader_parameter(&"rock_grain_map", _detail_textures.rock_grain) _build_tiles() _build_collision() return "" @@ -30,6 +38,10 @@ func height_field() -> TerrainHeightField: return _field +func terrain_material() -> ShaderMaterial: + return _material + + func tile_count() -> int: return 0 if _tiles_root == null else _tiles_root.get_child_count() diff --git a/game/world/sites/seabed_detail_textures.gd b/game/world/sites/seabed_detail_textures.gd new file mode 100644 index 0000000..096693c --- /dev/null +++ b/game/world/sites/seabed_detail_textures.gd @@ -0,0 +1,73 @@ +class_name SeabedDetailTextures +extends RefCounted + +## Procedural, seamless detail maps for the seabed shader, built synchronously from +## seeded noise so no texture asset is needed and every build is identical: +## an oriented sand ripple normal map, a rock normal map and the rock grain map +## (height, also read as occlusion and albedo mottling). + +const TEXTURE_SIZE := 256 +const RIPPLES_PER_TILE := 2.0 +const RIPPLE_WARP := 0.22 +const ROCK_BUMP_SCALE := 6.0 +const SAND_BUMP_SCALE := 3.0 + +var sand_normal: ImageTexture +var rock_normal: ImageTexture +var rock_grain: ImageTexture + + +static func build(seed: int) -> SeabedDetailTextures: + var result := SeabedDetailTextures.new() + var rock_height := _rock_height_image(seed) + result.rock_grain = _texture_from(rock_height) + var rock_bumps := rock_height.duplicate() as Image + rock_bumps.bump_map_to_normal_map(ROCK_BUMP_SCALE) + result.rock_normal = _texture_from(rock_bumps) + var sand_height := _sand_height_image(seed + 1) + sand_height.bump_map_to_normal_map(SAND_BUMP_SCALE) + result.sand_normal = _texture_from(sand_height) + return result + + +## Ridged fractal noise: sharp crests and pits, seamless across the tile. +static func _rock_height_image(seed: int) -> Image: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = 0.012 + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED + noise.fractal_octaves = 5 + return noise.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + + +## Ripples across the tile's v axis, warped and grained by noise. Asymmetric profile +## (gentle stoss side, steep lee side) as on a swell-built sand bed. +static func _sand_height_image(seed: int) -> Image: + var warp := FastNoiseLite.new() + warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + warp.seed = seed + warp.frequency = 0.02 + warp.fractal_octaves = 2 + var warp_image := warp.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + var grain := FastNoiseLite.new() + grain.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + grain.seed = seed + 7 + grain.frequency = 0.15 + grain.fractal_octaves = 3 + var grain_image := grain.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true) + var image := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_L8) + for y in TEXTURE_SIZE: + for x in TEXTURE_SIZE: + var phase := (float(y) / float(TEXTURE_SIZE)) * RIPPLES_PER_TILE + phase += (warp_image.get_pixel(x, y).r - 0.5) * RIPPLE_WARP + var fraction := phase - floorf(phase) + var crest := fraction / 0.7 if fraction < 0.7 else (1.0 - fraction) / 0.3 + var height := crest * 0.8 + grain_image.get_pixel(x, y).r * 0.2 + image.set_pixel(x, y, Color(height, height, height)) + return image + + +static func _texture_from(image: Image) -> ImageTexture: + image.generate_mipmaps() + return ImageTexture.create_from_image(image) diff --git a/game/world/sites/seabed_terrain.gdshader b/game/world/sites/seabed_terrain.gdshader index 0142fc5..24de846 100644 --- a/game/world/sites/seabed_terrain.gdshader +++ b/game/world/sites/seabed_terrain.gdshader @@ -1,6 +1,13 @@ shader_type spatial; render_mode depth_draw_opaque; +// Sand / rock split by slope, matching the sonar material rule (sand under 20 deg, +// rock over 40 deg). Rock is normal-mapped triplanar in world space so cliffs and +// ledges keep their detail; sand carries an oriented ripple normal map on the xz plane. +// The mesh has no tangents: perturbed normals are built in world space and written +// to NORMAL in view space. Detail shading comes from the normal maps, a grain map +// used as ambient occlusion in the pits, and albedo mottling. + uniform vec3 sand_color : source_color = vec3(0.50, 0.46, 0.34); uniform vec3 rock_color : source_color = vec3(0.21, 0.20, 0.18); uniform vec3 deep_tint : source_color = vec3(0.04, 0.09, 0.15); @@ -8,8 +15,19 @@ uniform float sand_slope_cosine = 0.9397; // cos(20 deg) uniform float rock_slope_cosine = 0.7660; // cos(40 deg) uniform float depth_tint_start_m = 20.0; uniform float depth_tint_end_m = 90.0; -uniform float ripple_wavelength_m = 1.6; -uniform float ripple_contrast = 0.12; + +uniform sampler2D sand_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 float sand_tile_m = 3.2; +uniform float rock_tile_m = 7.0; +uniform float rock_grain_tile_m = 1.7; +uniform float sand_normal_strength = 1.0; +uniform float rock_normal_strength = 1.1; +uniform float rock_grain_normal_strength = 0.45; +uniform float grain_ao_strength = 0.5; +uniform float grain_albedo_contrast = 0.45; +uniform vec2 ripple_direction = vec2(0.8, 0.6); varying vec3 world_position; varying vec3 world_normal; @@ -19,17 +37,75 @@ void vertex() { world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); } +vec3 unpack_normal(vec4 sample) { + return sample.xyz * 2.0 - 1.0; +} + +// Triplanar normal perturbation (UDN blend): each plane's tangent-space xy is added +// onto the world normal along that plane's axes, weighted by how much the surface +// faces the plane. +vec3 triplanar_normal(sampler2D map, vec3 position, vec3 normal, float strength) { + vec3 weights = pow(abs(normal), vec3(4.0)); + weights /= weights.x + weights.y + weights.z; + vec3 tx = unpack_normal(texture(map, position.zy)); + vec3 ty = unpack_normal(texture(map, position.xz)); + vec3 tz = unpack_normal(texture(map, position.xy)); + vec3 perturbation = ( + vec3(0.0, tx.y, tx.x) * weights.x + + vec3(ty.x, 0.0, ty.y) * weights.y + + vec3(tz.x, tz.y, 0.0) * weights.z + ) * strength; + return normalize(normal + perturbation); +} + +float triplanar_grain(sampler2D map, vec3 position, vec3 normal) { + vec3 weights = pow(abs(normal), vec3(4.0)); + weights /= weights.x + weights.y + weights.z; + return texture(map, position.zy).r * weights.x + + texture(map, position.xz).r * weights.y + + texture(map, position.xy).r * weights.z; +} + void fragment() { - float up = clamp(world_normal.y, 0.0, 1.0); + vec3 n = normalize(world_normal); + float up = clamp(n.y, 0.0, 1.0); float rockness = 1.0 - smoothstep(rock_slope_cosine, sand_slope_cosine, up); - vec3 albedo = mix(sand_color, rock_color, rockness); - float phase = (world_position.x * 0.8 + world_position.z * 0.6) * TAU / ripple_wavelength_m; - float ripple = sin(phase) * ripple_contrast * (1.0 - rockness); - albedo *= 1.0 + ripple; + + // Sand: ripple normal map rotated to the swell direction, on the xz plane. + vec2 ripple_axis = normalize(ripple_direction); + vec2 sand_uv = vec2( + dot(world_position.xz, ripple_axis), + dot(world_position.xz, vec2(-ripple_axis.y, ripple_axis.x)) + ) / sand_tile_m; + vec3 sand_tangent = unpack_normal(texture(sand_normal_map, sand_uv)); + vec3 sand_perturbation = ( + vec3(ripple_axis.x, 0.0, ripple_axis.y) * sand_tangent.x + + vec3(-ripple_axis.y, 0.0, ripple_axis.x) * sand_tangent.y + ) * sand_normal_strength; + vec3 sand_normal = normalize(n + sand_perturbation); + + // Rock: coarse triplanar normal map plus fine grain, both in world space. + vec3 rock_normal = triplanar_normal(rock_normal_map, world_position / rock_tile_m, n, rock_normal_strength); + rock_normal = triplanar_normal(rock_normal_map, world_position / rock_grain_tile_m, rock_normal, rock_grain_normal_strength); + float grain = triplanar_grain(rock_grain_map, world_position / rock_grain_tile_m, n); + + vec3 shading_normal = normalize(mix(sand_normal, rock_normal, rockness)); + NORMAL = normalize((VIEW_MATRIX * vec4(shading_normal, 0.0)).xyz); + + // Albedo: mottled rock, lightly grained sand, both tinted with depth. + vec3 rock_albedo = rock_color * (1.0 + (grain - 0.5) * 2.0 * grain_albedo_contrast); + vec3 sand_albedo = sand_color * (1.0 + (grain - 0.5) * 0.3); + vec3 albedo = mix(sand_albedo, rock_albedo, rockness); float depth_m = -world_position.y; float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m); albedo = mix(albedo, deep_tint, tint * 0.6); ALBEDO = albedo; - ROUGHNESS = mix(0.95, 0.82, rockness); + + // Detail shading: pits of the grain map and ripple troughs read as occlusion. + float rock_ao = mix(1.0, 0.45 + 0.55 * grain, grain_ao_strength); + float sand_ao = mix(1.0, 0.8 + 0.2 * clamp(sand_tangent.z, 0.0, 1.0), grain_ao_strength * 0.6); + AO = mix(sand_ao, rock_ao, rockness); + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = mix(0.95, 0.78 + 0.12 * grain, rockness); METALLIC = 0.0; } diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index a7279d1..266923c 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -9,6 +9,7 @@ const BathymetricTerrainActor = preload( const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") const SitePlacement = preload("res://game/domain/sites/site_placement.gd") const DiveSite = preload("res://game/world/sites/dive_site.gd") +const SeabedDetailTextures = preload("res://game/world/sites/seabed_detail_textures.gd") const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") const ReferenceBathymetryAcquisition = preload( "res://game/world/sonar/reference_bathymetry_acquisition.gd" @@ -18,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 SHOWCASE_SITE_PATH := "res://content/sites/terrain_showcase.tres" const GROOVE_PROBE_OFFSET_M := 25.0 -const TEST_COUNT := 36 +const TEST_COUNT := 40 var _failures: Array[String] = [] @@ -74,6 +75,30 @@ func _test_actor_matches_field() -> void: await physics_frame _expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells") + var material := actor.terrain_material() + _expect( + material != null + and material.get_shader_parameter(&"sand_normal_map") is ImageTexture + and material.get_shader_parameter(&"rock_normal_map") is ImageTexture + and material.get_shader_parameter(&"rock_grain_map") is ImageTexture, + "terrain material carries the three procedural detail maps" + ) + var first_textures := SeabedDetailTextures.build(7) + var second_textures := SeabedDetailTextures.build(7) + _expect( + first_textures.rock_normal.get_image().get_data() == second_textures.rock_normal.get_image().get_data() + and first_textures.sand_normal.get_image().get_data() == second_textures.sand_normal.get_image().get_data(), + "detail maps are deterministic for a seed" + ) + _expect( + first_textures.rock_normal.get_width() == SeabedDetailTextures.TEXTURE_SIZE + and first_textures.rock_normal.get_image().has_mipmaps(), + "detail maps are tile-sized with mipmaps" + ) + var sand_image := first_textures.sand_normal.get_image() + var left := sand_image.get_pixel(0, 100) + var right := sand_image.get_pixel(SeabedDetailTextures.TEXTURE_SIZE - 1, 100) + _expect(absf(left.r - right.r) < 0.25 and absf(left.g - right.g) < 0.25, "sand ripple map wraps across its seam") var first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D var arrays: Array = first_tile.mesh.surface_get_arrays(0) var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]