diff --git a/docs/briefs/captures/terrain_showcase_boulders.png b/docs/briefs/captures/terrain_showcase_boulders.png index 1b15a61..3b62ea9 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 f97ca8c..232aeca 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 ea47899..9c46cbc 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 a99580c..5986c7e 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_patch_reef.png b/docs/briefs/captures/zone1_vigilant_patch_reef.png new file mode 100644 index 0000000..850aca5 Binary files /dev/null and b/docs/briefs/captures/zone1_vigilant_patch_reef.png differ diff --git a/docs/briefs/captures/zone1_vigilant_patch_reef.png.import b/docs/briefs/captures/zone1_vigilant_patch_reef.png.import new file mode 100644 index 0000000..de1c60f --- /dev/null +++ b/docs/briefs/captures/zone1_vigilant_patch_reef.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfhdetospcaup" +path="res://.godot/imported/zone1_vigilant_patch_reef.png-c13e4b56c466e2ab61173a145fd844d2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://docs/briefs/captures/zone1_vigilant_patch_reef.png" +dest_files=["res://.godot/imported/zone1_vigilant_patch_reef.png-c13e4b56c466e2ab61173a145fd844d2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md index d20889b..017a2df 100644 --- a/docs/briefs/terrain-site-zone1-gameplay.md +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -265,7 +265,11 @@ de contacts existant les sépare du fond. présence sonar. Le shader roche ajoute gazon algal sur les faces éclairées, croûtes corallines par plaques et éponges encroûtantes à l'ombre, le tout s'éteignant entre 40 et 75 m. L'optique de la colonne d'eau est partagée par - `water_light.gdshaderinc`. + `water_light.gdshaderinc`. Têtes de corail en volume (même jour) : trois + dômes bosselés procéduraux (massif, cerveau à crêtes, lobé) à base plate, + semés sur la roche douce (< 30°) de 12 à 40 m en patch reefs par bruit, une + `MultiMesh` par tuile et par variante, shader opaque `seabed_coral.gdshader` + (couleur de sommet × teinte d'instance, marbrure, même colonne d'eau). - Brouillard, lumière directionnelle et particules de la scène de test actuelle sont conservés et réglés par site. - Passe lumière du 2026-10-06 (`dive_site.tscn`) : l'ambiance vient d'un ciel diff --git a/docs/briefs/terrain-varied-landforms.md b/docs/briefs/terrain-varied-landforms.md index 21fab30..7949ed0 100644 --- a/docs/briefs/terrain-varied-landforms.md +++ b/docs/briefs/terrain-varied-landforms.md @@ -182,7 +182,8 @@ 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é). Les + (gradins du tombant), `zone1_vigilant_plateau.png` (plateau ondulé) et + `zone1_vigilant_patch_reef.png` (têtes de corail autour du caboteur). 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. diff --git a/game/world/sites/seabed_coral.gdshader b/game/world/sites/seabed_coral.gdshader new file mode 100644 index 0000000..db0354e --- /dev/null +++ b/game/world/sites/seabed_coral.gdshader @@ -0,0 +1,39 @@ +shader_type spatial; +render_mode depth_draw_opaque; + +// Massive coral heads: opaque, vertex-coloured (mesh shade times the instance tint), +// lit under the same water column as the seabed. INSTANCE_CUSTOM.x is a per-head +// mottling phase; the submarine's projectors are not attenuated. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032); +uniform float reference_depth_m = 24.0; +uniform float minimum_daylight = 0.05; +uniform float mottle_strength = 0.18; + +varying float daylight; +varying vec3 chroma; +varying float mottle; + +void vertex() { + vec3 world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec3 transmittance = water_transmittance(-world.y, water_absorption_per_m, reference_depth_m); + daylight = water_daylight(transmittance, minimum_daylight); + chroma = water_chroma(transmittance, water_absorption_per_m, reference_depth_m); + mottle = sin(VERTEX.x * 9.0 + INSTANCE_CUSTOM.x * TAU) * sin(VERTEX.z * 7.0 + VERTEX.y * 5.0); +} + +void fragment() { + ALBEDO = COLOR.rgb * (1.0 + mottle * mottle_strength) * chroma; + AO = daylight; + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = 0.8; + METALLIC = 0.0; +} + +void light() { + float sun = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight); + float lambert = clamp(dot(NORMAL, LIGHT), 0.0, 1.0); + DIFFUSE_LIGHT += lambert * ATTENUATION * LIGHT_COLOR * ALBEDO * sun / PI; +} diff --git a/game/world/sites/seabed_flora_actor.gd b/game/world/sites/seabed_flora_actor.gd index 38b2a0b..7c4cf91 100644 --- a/game/world/sites/seabed_flora_actor.gd +++ b/game/world/sites/seabed_flora_actor.gd @@ -2,8 +2,9 @@ class_name SeabedFloraActor extends Node3D ## Scatters the seabed flora of a site from its height field, deterministically from a -## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock. One -## MultiMeshInstance3D per terrain tile and per kind, with a visibility range, no +## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock, and +## massive coral heads on the gentle rock of patch reefs. One MultiMeshInstance3D per +## terrain tile and per kind (per variant for corals), with a visibility range, no ## collision and no sonar presence. Placement is pure and runs on the worker thread ## pool; the multimeshes are built afterwards on the main thread. @@ -11,8 +12,9 @@ const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") const SeabedFloraAssets = preload("res://game/world/sites/seabed_flora_assets.gd") const FLORA_SHADER = preload("res://game/world/sites/seabed_flora.gdshader") +const CORAL_SHADER = preload("res://game/world/sites/seabed_coral.gdshader") -enum Kind { SEAGRASS, SEA_FAN } +enum Kind { SEAGRASS, SEA_FAN, CORAL_HEAD } const TILE_CELLS := 64 # Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium @@ -30,6 +32,19 @@ const FAN_DEPTH_MAX_M := 60.0 const FAN_KEEP_PROBABILITY := 0.45 const FAN_VISIBILITY_M := 170.0 const FAN_HEADING := Vector2(0.8, 0.6) +# Coral heads: patch reefs are the gentle (< 30 deg) rock of the sunlit band, 12 to +# 40 m, clustered by noise; heads are 0.6 to 2.2 m across. +const CORAL_SPACING_M := 3.0 +const CORAL_DEPTH_MIN_M := 12.0 +const CORAL_DEPTH_FADE_START_M := 30.0 +const CORAL_DEPTH_MAX_M := 40.0 +const CORAL_SLOPE_COSINE := 0.866 # cos(30 deg) +const CORAL_REEF_THRESHOLD := 0.05 +const CORAL_KEEP_PROBABILITY := 0.55 +const CORAL_VISIBILITY_M := 150.0 +const CORAL_COLORS: Array[Color] = [ + Color(0.72, 0.58, 0.3), Color(0.5, 0.52, 0.28), Color(0.68, 0.48, 0.36), Color(0.62, 0.6, 0.42), +] const FAN_COLORS: Array[Color] = [ Color(0.55, 0.35, 0.62), Color(0.85, 0.42, 0.2), Color(0.8, 0.7, 0.3), Color(0.6, 0.3, 0.45), ] @@ -41,6 +56,7 @@ class TilePlacements extends RefCounted: var transforms: Array[Transform3D] = [] var colors := PackedColorArray() var custom := PackedColorArray() + var variants := PackedInt32Array() var _field: TerrainHeightField @@ -48,10 +64,12 @@ var _seed := 0 var _assets: SeabedFloraAssets var _grass_material: ShaderMaterial var _fan_material: ShaderMaterial +var _coral_material: ShaderMaterial +var _reef_noise: FastNoiseLite var _meadow_noise: FastNoiseLite var _fan_noise: FastNoiseLite -var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0} -var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: []} +var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0} +var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []} func configure(field: TerrainHeightField, seed: int) -> String: @@ -64,25 +82,28 @@ func configure(field: TerrainHeightField, seed: int) -> String: _assets = SeabedFloraAssets.build(seed) _grass_material = _material(_assets.blade_texture) _fan_material = _material(_assets.fan_texture) + _coral_material = ShaderMaterial.new() + _coral_material.shader = CORAL_SHADER _meadow_noise = _noise(seed + 11, 0.012) _fan_noise = _noise(seed + 23, 0.03) + _reef_noise = _noise(seed + 37, 0.02) var tiles := _tiles() var grass: Array[TilePlacements] = [] var fans: Array[TilePlacements] = [] + var corals: Array[TilePlacements] = [] for tile in tiles: - var grass_tile := TilePlacements.new() - grass_tile.cells = tile - grass.append(grass_tile) - var fan_tile := TilePlacements.new() - fan_tile.cells = tile - fans.append(fan_tile) + for list: Array[TilePlacements] in [grass, fans, corals]: + var placements := TilePlacements.new() + placements.cells = tile + list.append(placements) var group := WorkerThreadPool.add_group_task( - _place_tile.bind(grass, fans), tiles.size(), -1, true, "seabed flora" + _place_tile.bind(grass, fans, corals), tiles.size(), -1, true, "seabed flora" ) WorkerThreadPool.wait_for_group_task_completion(group) for index in tiles.size(): _add_multimesh(Kind.SEAGRASS, grass[index]) _add_multimesh(Kind.SEA_FAN, fans[index]) + _add_coral_multimeshes(corals[index]) return "" @@ -102,8 +123,8 @@ func _clear() -> void: for child in get_children(): remove_child(child) child.free() - _counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0} - _samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: []} + _counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0} + _samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []} func _tiles() -> Array[Rect2i]: @@ -122,7 +143,7 @@ func _tiles() -> Array[Rect2i]: return tiles -func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements]) -> void: +func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements], corals: Array[TilePlacements]) -> void: var tile := grass[tile_index].cells var cell := _field.cell_size_m() var origin := _field.origin_m() @@ -134,6 +155,7 @@ func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[Tile generator.seed = _seed * 7919 + tile_index _place_grass(grass[tile_index], generator, x_min, x_max, z_min, z_max) _place_fans(fans[tile_index], generator, x_min, x_max, z_min, z_max) + _place_corals(corals[tile_index], generator, x_min, x_max, z_min, z_max) ## Cheap rejections first, straight from the field arrays (cell reads, one noise read), @@ -233,7 +255,72 @@ func _place_fans(out: TilePlacements, generator: RandomNumberGenerator, x_min: f z += FAN_SPACING_M -func _add_multimesh(kind: Kind, placements: TilePlacements) -> void: +func _place_corals(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void: + var origin := _field.origin_m() + var cell := _field.cell_size_m() + var columns := _field.columns() + var rows := _field.rows() + var elevations := _field.elevations_m() + var materials := _field.materials() + var rock := SonarMaterial.Value.ROCK + var slope_limit := (1.0 / (CORAL_SLOPE_COSINE * CORAL_SLOPE_COSINE) - 1.0) * (2.0 * cell) * (2.0 * cell) + var z := z_min + while z < z_max: + var x := x_min + while x < x_max: + var px := x + generator.randf() * CORAL_SPACING_M + var pz := z + generator.randf() * CORAL_SPACING_M + x += CORAL_SPACING_M + var column := int(roundf((px - origin.x) / cell)) + var row := int(roundf((pz - origin.y) / cell)) + if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1: + continue + var index := row * columns + column + if materials[index] != rock: + continue + var depth := -elevations[index] + if depth < CORAL_DEPTH_MIN_M or depth > CORAL_DEPTH_MAX_M: + continue + var rise_x := elevations[index + 1] - elevations[index - 1] + var rise_z := elevations[index + columns] - elevations[index - columns] + if rise_x * rise_x + rise_z * rise_z > slope_limit: + continue + var reef := _reef_noise.get_noise_2d(px, pz) + if reef < CORAL_REEF_THRESHOLD: + continue + var density := (1.0 - smoothstep(CORAL_DEPTH_FADE_START_M, CORAL_DEPTH_MAX_M, depth)) * CORAL_KEEP_PROBABILITY + if generator.randf() > density * clampf((reef - CORAL_REEF_THRESHOLD) * 5.0, 0.4, 1.0): + continue + var yaw := generator.randf() * TAU + var radius := generator.randf_range(0.35, 1.3) + var squash := generator.randf_range(0.7, 1.15) + var phase := generator.randf() + var variant := generator.randi_range(0, _assets.coral_meshes.size() - 1) + var color: Color = CORAL_COLORS[generator.randi_range(0, CORAL_COLORS.size() - 1)] + var elevation := _field.elevation_at(px, pz) + var basis := Basis(Vector3.UP, yaw).scaled(Vector3(radius, radius * squash, radius)) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.08, pz))) + out.colors.append(color) + out.custom.append(Color(phase, 0.0, 0.0, 0.0)) + out.variants.append(variant) + z += CORAL_SPACING_M + + +## Coral heads of one tile, one MultiMesh per mesh variant. +func _add_coral_multimeshes(placements: TilePlacements) -> void: + for variant in _assets.coral_meshes.size(): + var subset := TilePlacements.new() + subset.cells = placements.cells + for index in placements.transforms.size(): + if placements.variants[index] != variant: + continue + subset.transforms.append(placements.transforms[index]) + subset.colors.append(placements.colors[index]) + subset.custom.append(placements.custom[index]) + _add_multimesh(Kind.CORAL_HEAD, subset, variant) + + +func _add_multimesh(kind: Kind, placements: TilePlacements, variant: int = 0) -> void: var count := placements.transforms.size() if count == 0: return @@ -241,7 +328,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void: multimesh.transform_format = MultiMesh.TRANSFORM_3D multimesh.use_colors = true multimesh.use_custom_data = true - multimesh.mesh = _assets.tuft_mesh if kind == Kind.SEAGRASS else _assets.fan_mesh + match kind: + Kind.SEAGRASS: + multimesh.mesh = _assets.tuft_mesh + Kind.SEA_FAN: + multimesh.mesh = _assets.fan_mesh + Kind.CORAL_HEAD: + multimesh.mesh = _assets.coral_meshes[variant] multimesh.instance_count = count for index in count: multimesh.set_instance_transform(index, placements.transforms[index]) @@ -249,13 +342,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void: multimesh.set_instance_custom_data(index, placements.custom[index]) var instance := MultiMeshInstance3D.new() instance.name = "%s_%d_%d" % [ - "Seagrass" if kind == Kind.SEAGRASS else "SeaFans", + ["Seagrass", "SeaFans", "Corals%d" % variant][kind], placements.cells.position.x / TILE_CELLS, placements.cells.position.y / TILE_CELLS, ] instance.multimesh = multimesh - instance.material_override = _grass_material if kind == Kind.SEAGRASS else _fan_material - instance.visibility_range_end = GRASS_VISIBILITY_M if kind == Kind.SEAGRASS else FAN_VISIBILITY_M + instance.material_override = [_grass_material, _fan_material, _coral_material][kind] + instance.visibility_range_end = [GRASS_VISIBILITY_M, FAN_VISIBILITY_M, CORAL_VISIBILITY_M][kind] instance.visibility_range_end_margin = 20.0 instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF instance.cast_shadow = ( diff --git a/game/world/sites/seabed_flora_assets.gd b/game/world/sites/seabed_flora_assets.gd index 8f01a52..ab697c0 100644 --- a/game/world/sites/seabed_flora_assets.gd +++ b/game/world/sites/seabed_flora_assets.gd @@ -4,17 +4,23 @@ extends RefCounted ## Procedural meshes and alpha-cut textures for the seabed flora, built synchronously ## from a seed so no asset is needed and every build is identical: a seagrass tuft ## (three crossed unit quads carrying a strap-blade texture) and a sea fan (one unit -## quad carrying a branching net texture). Meshes are unit sized, y from 0 to 1. +## quad carrying a branching net texture), and three massive coral heads (bumpy +## domes with a flat base: boulder, brain-ridged and lobed). Meshes are unit sized, +## y from 0 to 1. const BLADE_TEXTURE_WIDTH := 128 const BLADE_TEXTURE_HEIGHT := 256 const BLADE_COUNT := 5 const FAN_TEXTURE_SIZE := 256 +const CORAL_VARIANT_COUNT := 3 +const CORAL_RINGS := 14 +const CORAL_SEGMENTS := 20 var tuft_mesh: ArrayMesh var fan_mesh: ArrayMesh var blade_texture: ImageTexture var fan_texture: ImageTexture +var coral_meshes: Array[ArrayMesh] = [] static func build(seed: int) -> SeabedFloraAssets: @@ -23,9 +29,77 @@ static func build(seed: int) -> SeabedFloraAssets: result.fan_texture = _texture_from(_fan_image(seed + 1)) result.tuft_mesh = _crossed_quads(3) result.fan_mesh = _crossed_quads(1) + for variant in CORAL_VARIANT_COUNT: + result.coral_meshes.append(_coral_head(seed + 10 + variant, variant)) return result +## Dome of unit radius and unit height on a flat base, displaced by seeded noise: +## variant 0 a boulder coral (broad bumps), 1 a brain coral (fine ridges), 2 a lobed +## head (deep lobes). Vertex colour carries the shade (crevices darker). +static func _coral_head(seed: int, variant: int) -> ArrayMesh: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.fractal_octaves = 3 + var frequency := [0.9, 2.6, 0.6][variant] as float + var amplitude := [0.12, 0.07, 0.3][variant] as float + noise.frequency = frequency + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED if variant == 1 else FastNoiseLite.FRACTAL_FBM + var vertices := PackedVector3Array() + var colors := PackedColorArray() + var indices := PackedInt32Array() + # Rings from the top (ring 0, a single pole) down to the base rim; the base is a + # flat fan closed on a centre vertex. + var ring_vertex_count := CORAL_SEGMENTS + for ring in range(CORAL_RINGS + 1): + var polar := (PI * 0.5) * float(ring) / float(CORAL_RINGS) # 0 at the pole, pi/2 at the rim + for segment in CORAL_SEGMENTS: + var azimuth := TAU * float(segment) / float(CORAL_SEGMENTS) + var direction := Vector3(sin(polar) * cos(azimuth), cos(polar), sin(polar) * sin(azimuth)) + var bump := noise.get_noise_3d(direction.x * 3.0, direction.y * 3.0, direction.z * 3.0) + # Keep the rim on the ground: the displacement fades towards the base. + var radius := 1.0 + bump * amplitude * (0.35 + 0.65 * cos(polar)) + var position := direction * radius + position.y = maxf(position.y, 0.0) + vertices.append(position) + var shade := clampf(0.78 + bump * 0.9, 0.5, 1.1) + colors.append(Color(shade, shade, shade)) + for ring in CORAL_RINGS: + for segment in CORAL_SEGMENTS: + var next_segment := (segment + 1) % CORAL_SEGMENTS + var a := ring * ring_vertex_count + segment + var b := ring * ring_vertex_count + next_segment + var c := (ring + 1) * ring_vertex_count + segment + var d := (ring + 1) * ring_vertex_count + next_segment + if ring > 0: + indices.append_array([a, b, c]) + indices.append_array([b, d, c]) + var centre := vertices.size() + vertices.append(Vector3.ZERO) + colors.append(Color(0.6, 0.6, 0.6)) + var rim := CORAL_RINGS * ring_vertex_count + for segment in CORAL_SEGMENTS: + indices.append_array([centre, rim + segment, rim + (segment + 1) % CORAL_SEGMENTS]) + var normals := PackedVector3Array() + normals.resize(vertices.size()) + for index in range(0, indices.size(), 3): + var face_normal := (vertices[indices[index + 2]] - vertices[indices[index]]).cross(vertices[indices[index + 1]] - vertices[indices[index]]) + for corner in 3: + normals[indices[index + corner]] += face_normal + for index in normals.size(): + normals[index] = normals[index].normalized() if normals[index].length() > 0.0 else Vector3.UP + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + + ## Vertical unit quads crossed around the y axis, each 1 wide and 1 tall, UV mapped ## so the texture's bottom row sits on the ground. static func _crossed_quads(count: int) -> ArrayMesh: diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index 8f3ce07..c9502ce 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -20,7 +20,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 := 49 +const TEST_COUNT := 51 var _failures: Array[String] = [] @@ -279,6 +279,24 @@ func _test_zone1_site() -> void: for sample in result.samples: has_sand = has_sand or sample.material == SonarMaterial.Value.SAND _expect(result.accepted and has_sand, "a reference scan from the zone 1 spawn reads the sand plateau") + + # The calibration site lies below 40 m, so coral heads are checked on the patch reef here. + var flora := site.flora() + var coral_count := flora.instance_count(SeabedFloraActor.Kind.CORAL_HEAD) + _expect(coral_count > 0, "the Banc du Vigilant grows coral heads on its patch reef (%d)" % coral_count) + var field_for_corals := definition.height_field() + var corals_on_gentle_rock := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.CORAL_HEAD): + var at := transform.origin + var column := int(roundf((at.x - field_for_corals.origin_m().x) / field_for_corals.cell_size_m())) + var row := int(roundf((at.z - field_for_corals.origin_m().y) / field_for_corals.cell_size_m())) + corals_on_gentle_rock = ( + corals_on_gentle_rock + and field_for_corals.material_at(at.x, at.z) == SonarMaterial.Value.ROCK + and field_for_corals.normal_at_cell(column, row).y >= SeabedFloraActor.CORAL_SLOPE_COSINE - 0.05 + and -at.y >= SeabedFloraActor.CORAL_DEPTH_MIN_M and -at.y <= SeabedFloraActor.CORAL_DEPTH_MAX_M + ) + _expect(corals_on_gentle_rock, "coral heads sit on gentle rock in the lit band") site.queue_free()