Terrain des sites de plongée : champ de hauteur authoré, Banc du Vigilant, formes variées et rendu sous-marin #4

Open
olix21 wants to merge 47 commits from olix21/worktree-terrain-site-zone1 AGit into main
pull from: olix21/worktree-terrain-site-zone1
12 changed files with 208 additions and 16 deletions
Showing only changes of commit 17f58912c1 - Show all commits

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+9 -4
View File
@@ -251,10 +251,15 @@ de contacts existant les sépare du fond.
- Brouillard, lumière directionnelle et particules de la scène de test - Brouillard, lumière directionnelle et particules de la scène de test
actuelle sont conservés et réglés par site. actuelle sont conservés et réglés par site.
**Écart d'implémentation (graybox)** : le shader livré n'est pas triplanaire. **État du 2026-10-06 (demande d'Olivier)** : le shader mélange sable et roche
Il mélange sable et roche par la pente à partir de 20° (sable sous 20°, roche par la pente à partir de 20° (sable sous 20°, roche au-dessus de 40°, fondu
au-dessus de 40°, fondu entre ; le matériau sonar bascule à 20°) et rend les entre ; le matériau sonar bascule à 20°). La roche est en normal map
rides par modulation d'albédo, sans normal map. À revoir en tranche 6. **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 ## 8. Architecture proposée
+4 -3
View File
@@ -182,9 +182,10 @@ Plan de masse (nord en haut) :
`terrain_showcase_canyon.png` (gradins et fond ridé), `_crevasse.png`, `terrain_showcase_canyon.png` (gradins et fond ridé), `_crevasse.png`,
`_boulders.png`, `_spurs.png`, `_escarpment.png` (face de la falaise vue du `_boulders.png`, `_spurs.png`, `_escarpment.png` (face de la falaise vue du
pied) ; pour le Banc du Vigilant recuit, `zone1_vigilant_tombant.png` pied) ; pour le Banc du Vigilant recuit, `zone1_vigilant_tombant.png`
(gradins du tombant) et `zone1_vigilant_plateau.png` (plateau ondulé). Le (gradins du tombant) et `zone1_vigilant_plateau.png` (plateau ondulé). Les
rendu (brouillard, rides en albédo, absence d'ombrage de détail) n'est pas captures sont faites avec le shader à normal maps et ombrage de détail
l'objet de ce brief : à ce stade, le relief se lit surtout au sonar. (brief du Banc du Vigilant, §7) ; brouillard et lumière restent ceux de la
scène de site.
## 8. Décisions laissées à Olivier ## 8. Décisions laissées à Olivier
@@ -2,14 +2,17 @@ class_name BathymetricTerrainActor
extends StaticBody3D extends StaticBody3D
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") 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 TERRAIN_SHADER = preload("res://game/world/sites/seabed_terrain.gdshader")
const TILE_CELLS := 64 const TILE_CELLS := 64
const VISIBILITY_RANGE_END_M := 420.0 const VISIBILITY_RANGE_END_M := 420.0
const VISIBILITY_RANGE_MARGIN_M := 60.0 const VISIBILITY_RANGE_MARGIN_M := 60.0
const DETAIL_TEXTURE_SEED := 4102
var _field: TerrainHeightField var _field: TerrainHeightField
var _material: ShaderMaterial var _material: ShaderMaterial
var _detail_textures: SeabedDetailTextures
var _tiles_root: Node3D var _tiles_root: Node3D
var _collision: CollisionShape3D var _collision: CollisionShape3D
@@ -19,8 +22,13 @@ func configure(field: TerrainHeightField) -> String:
return "terrain field is missing" return "terrain field is missing"
_clear() _clear()
_field = field _field = field
if _detail_textures == null:
_detail_textures = SeabedDetailTextures.build(DETAIL_TEXTURE_SEED)
_material = ShaderMaterial.new() _material = ShaderMaterial.new()
_material.shader = TERRAIN_SHADER _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_tiles()
_build_collision() _build_collision()
return "" return ""
@@ -30,6 +38,10 @@ func height_field() -> TerrainHeightField:
return _field return _field
func terrain_material() -> ShaderMaterial:
return _material
func tile_count() -> int: func tile_count() -> int:
return 0 if _tiles_root == null else _tiles_root.get_child_count() return 0 if _tiles_root == null else _tiles_root.get_child_count()
@@ -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)
+84 -8
View File
@@ -1,6 +1,13 @@
shader_type spatial; shader_type spatial;
render_mode depth_draw_opaque; 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 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 rock_color : source_color = vec3(0.21, 0.20, 0.18);
uniform vec3 deep_tint : source_color = vec3(0.04, 0.09, 0.15); 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 rock_slope_cosine = 0.7660; // cos(40 deg)
uniform float depth_tint_start_m = 20.0; uniform float depth_tint_start_m = 20.0;
uniform float depth_tint_end_m = 90.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_position;
varying vec3 world_normal; varying vec3 world_normal;
@@ -19,17 +37,75 @@ void vertex() {
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); 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() { 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); 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; // Sand: ripple normal map rotated to the swell direction, on the xz plane.
float ripple = sin(phase) * ripple_contrast * (1.0 - rockness); vec2 ripple_axis = normalize(ripple_direction);
albedo *= 1.0 + ripple; 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 depth_m = -world_position.y;
float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m); float tint = smoothstep(depth_tint_start_m, depth_tint_end_m, depth_m);
albedo = mix(albedo, deep_tint, tint * 0.6); albedo = mix(albedo, deep_tint, tint * 0.6);
ALBEDO = albedo; 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; METALLIC = 0.0;
} }
@@ -9,6 +9,7 @@ const BathymetricTerrainActor = preload(
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
const SitePlacement = preload("res://game/domain/sites/site_placement.gd") const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
const DiveSite = preload("res://game/world/sites/dive_site.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 SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd")
const ReferenceBathymetryAcquisition = preload( const ReferenceBathymetryAcquisition = preload(
"res://game/world/sonar/reference_bathymetry_acquisition.gd" "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 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 := 36 const TEST_COUNT := 40
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -74,6 +75,30 @@ func _test_actor_matches_field() -> void:
await physics_frame await physics_frame
_expect(actor.tile_count() == 2, "80 x 64 cells split into 2 x 1 tiles of at most 64 cells") _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 first_tile := actor.get_node("Tiles").get_child(0) as MeshInstance3D
var arrays: Array = first_tile.mesh.surface_get_arrays(0) var arrays: Array = first_tile.mesh.surface_get_arrays(0)
var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var tile_vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]