feat(world): triplanar seabed shader with procedural normal maps and detail shading

Rock is normal-mapped triplanar in world space at two scales, sand carries an
oriented ripple normal map on the xz plane, and a grain map drives detail
occlusion, albedo mottling and roughness. The three maps are seamless,
seeded, mipmapped textures built synchronously by SeabedDetailTextures, so
no asset is needed and builds are deterministic. The mesh has no tangents:
perturbed normals are computed in world space and written to NORMAL.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-06 23:05:33 +02:00
1 parent f22a5e69aa
commit 17f58912c1
12 files changed
+208 -16

No files matched your search

@@ -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]