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