Terrain des sites de plongée : champ de hauteur authoré, Banc du Vigilant, formes variées et rendu sous-marin #4
No files matched your search
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 131 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||