diff --git a/docs/briefs/captures/terrain_showcase_boulders.png b/docs/briefs/captures/terrain_showcase_boulders.png index b9d3d92..1b15a61 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 d1d1b42..f97ca8c 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 40b6d4c..ea47899 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 89a4a28..a99580c 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_plateau.png b/docs/briefs/captures/zone1_vigilant_plateau.png index 6f2b594..82fc2f2 100644 Binary files a/docs/briefs/captures/zone1_vigilant_plateau.png and b/docs/briefs/captures/zone1_vigilant_plateau.png differ diff --git a/docs/briefs/captures/zone1_vigilant_tombant.png b/docs/briefs/captures/zone1_vigilant_tombant.png index fd1b0e7..8c9d3a6 100644 Binary files a/docs/briefs/captures/zone1_vigilant_tombant.png and b/docs/briefs/captures/zone1_vigilant_tombant.png differ diff --git a/docs/briefs/terrain-site-zone1-gameplay.md b/docs/briefs/terrain-site-zone1-gameplay.md index 5d58e57..d20889b 100644 --- a/docs/briefs/terrain-site-zone1-gameplay.md +++ b/docs/briefs/terrain-site-zone1-gameplay.md @@ -255,6 +255,17 @@ de contacts existant les sépare du fond. assemblés ensuite sur le thread principal. - Props low-poly posés à la main, en nombre borné : blocs d'éboulis au pied du tombant, têtes de corail sur les patch reefs, rien d'animé. +- Flore et encroûtantes (2026-10-07, demande d'Olivier) : `SeabedFloraActor` + sème, depuis le champ de hauteur et une graine dérivée de l'identifiant du + site, des touffes d'herbier sur le sable plat de la bande éclairée (pleines + à 20 m, absentes à 36 m, en prairies par bruit) et des gorgones sur la roche + de 15 à 60 m, cap croisé sur la houle ; une `MultiMesh` par tuile et par + espèce, maillages et textures à découpe alpha procéduraux + (`SeabedFloraAssets`), ondulation en vertex shader, aucune collision ni + 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`. - 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/game/domain/sites/terrain_height_field.gd b/game/domain/sites/terrain_height_field.gd index e20e5cb..4a2c139 100644 --- a/game/domain/sites/terrain_height_field.gd +++ b/game/domain/sites/terrain_height_field.gd @@ -64,6 +64,11 @@ func elevations_m() -> PackedFloat32Array: return _data.elevations_m +## Row-major sonar materials, same layout as elevations_m. +func materials() -> PackedByteArray: + return _data.materials + + func elevation_at_cell(column: int, row: int) -> float: return _data.elevations_m[_data.index_of(_clamp_column(column), _clamp_row(row))] diff --git a/game/world/sites/dive_site.gd b/game/world/sites/dive_site.gd index 2884b63..2343711 100644 --- a/game/world/sites/dive_site.gd +++ b/game/world/sites/dive_site.gd @@ -7,11 +7,13 @@ const SitePlacement = preload("res://game/domain/sites/site_placement.gd") const BathymetricTerrainActor = preload( "res://game/world/sites/bathymetric_terrain_actor.gd" ) +const SeabedFloraActor = preload("res://game/world/sites/seabed_flora_actor.gd") const METAL_REFLECTIVITY := 1.0 const BEACON_COLOR := Color(0.2, 0.78, 0.9) @onready var _terrain: BathymetricTerrainActor = %Terrain +@onready var _flora: SeabedFloraActor = %Flora @onready var _placements_root: Node3D = %Placements var _definition: DiveSiteDefinition @@ -29,6 +31,11 @@ func configure(definition: DiveSiteDefinition) -> String: var terrain_error := _terrain.configure(field) if not terrain_error.is_empty(): return terrain_error + if _flora != null: + # The flora is seeded by the site id, so a site always grows the same meadows. + var flora_error := _flora.configure(field, hash(String(definition.site_id))) + if not flora_error.is_empty(): + return flora_error _definition = definition for child in _placements_root.get_children(): _placements_root.remove_child(child) @@ -47,6 +54,10 @@ func terrain() -> BathymetricTerrainActor: return _terrain +func flora() -> SeabedFloraActor: + return _flora + + func spawn_transform() -> Transform3D: return _definition.spawn_transform() if _definition != null else Transform3D.IDENTITY diff --git a/game/world/sites/dive_site.tscn b/game/world/sites/dive_site.tscn index 736222d..05bfb51 100644 --- a/game/world/sites/dive_site.tscn +++ b/game/world/sites/dive_site.tscn @@ -1,8 +1,9 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=10 format=3] [ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] [ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] [ext_resource type="Shader" path="res://game/world/sites/water_column_fog.gdshader" id="3_water_column"] +[ext_resource type="Script" path="res://game/world/sites/seabed_flora_actor.gd" id="4_flora"] [sub_resource type="ShaderMaterial" id="WaterColumnMaterial"] shader = ExtResource("3_water_column") @@ -96,5 +97,9 @@ material = SubResource("WaterColumnMaterial") unique_name_in_owner = true script = ExtResource("2_terrain") +[node name="Flora" type="Node3D" parent="."] +unique_name_in_owner = true +script = ExtResource("4_flora") + [node name="Placements" type="Node3D" parent="."] unique_name_in_owner = true diff --git a/game/world/sites/seabed_flora.gdshader b/game/world/sites/seabed_flora.gdshader new file mode 100644 index 0000000..10bc6c4 --- /dev/null +++ b/game/world/sites/seabed_flora.gdshader @@ -0,0 +1,60 @@ +shader_type spatial; +render_mode cull_disabled, depth_draw_opaque; + +// Seagrass tufts and sea fans as alpha-cut billboards instanced by MultiMesh. The mesh +// is unit sized (y from 0 to 1); the instance transform gives size and heading, the +// instance COLOR the species tint, INSTANCE_CUSTOM.x the sway phase and +// INSTANCE_CUSTOM.y the sway weight (grass bends, fans barely). Daylight follows the +// same water column as the seabed; the submarine's projectors are not attenuated. + +#include "res://game/world/sites/water_light.gdshaderinc" + +uniform sampler2D albedo_map : source_color, filter_linear_mipmap; +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 sway_amplitude = 0.09; +uniform float sway_speed = 0.8; +uniform float alpha_threshold = 0.5; +uniform float translucency = 0.7; + +varying vec3 world_position; +varying float daylight; +varying vec3 chroma; + +void vertex() { + float phase = INSTANCE_CUSTOM.x * TAU; + float weight = INSTANCE_CUSTOM.y * VERTEX.y * VERTEX.y; + float swing = sin(TIME * sway_speed + phase) + 0.35 * sin(TIME * sway_speed * 2.3 + phase * 1.7); + VERTEX.x += swing * sway_amplitude * weight; + VERTEX.z += cos(TIME * sway_speed * 0.7 + phase) * sway_amplitude * 0.5 * weight; + world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec3 transmittance = water_transmittance(-world_position.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); +} + +void fragment() { + vec4 sample = texture(albedo_map, UV); + ALPHA = sample.a; + ALPHA_SCISSOR_THRESHOLD = alpha_threshold; + ALBEDO = sample.rgb * COLOR.rgb * chroma; + if (!FRONT_FACING) { + NORMAL = -NORMAL; + } + AO = daylight; + AO_LIGHT_AFFECT = 0.35; + ROUGHNESS = 0.85; + METALLIC = 0.0; +} + +void light() { + float sun = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight); + // Thin blades and fan nets let light through and are lit from both faces: the + // lambert term is wrapped around the blade, so a vertical blade under an + // overhead sun is not black. + float facing = dot(NORMAL, LIGHT); + float lambert = clamp(facing, 0.0, 1.0); + float wrapped = clamp(abs(facing) * 0.5 + 0.5, 0.0, 1.0) * translucency; + DIFFUSE_LIGHT += (lambert + wrapped) * ATTENUATION * LIGHT_COLOR * ALBEDO * sun / PI; +} diff --git a/game/world/sites/seabed_flora_actor.gd b/game/world/sites/seabed_flora_actor.gd new file mode 100644 index 0000000..38b2a0b --- /dev/null +++ b/game/world/sites/seabed_flora_actor.gd @@ -0,0 +1,286 @@ +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 +## collision and no sonar presence. Placement is pure and runs on the worker thread +## pool; the multimeshes are built afterwards on the main thread. + +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +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") + +enum Kind { SEAGRASS, SEA_FAN } + +const TILE_CELLS := 64 +# Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium +# and Halophila thin out with the light). +const GRASS_SPACING_M := 2.0 +const GRASS_DEPTH_FULL_M := 20.0 +const GRASS_DEPTH_NONE_M := 36.0 +const GRASS_SLOPE_COSINE := 0.9659 # cos(15 deg) +const GRASS_MEADOW_THRESHOLD := 0.12 +const GRASS_VISIBILITY_M := 110.0 +# Sea fans: on rock from 15 to 60 m, heading across the swell. +const FAN_SPACING_M := 5.0 +const FAN_DEPTH_MIN_M := 15.0 +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) +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), +] + + +## Placements of one tile for one kind, computed off the main thread. +class TilePlacements extends RefCounted: + var cells := Rect2i() + var transforms: Array[Transform3D] = [] + var colors := PackedColorArray() + var custom := PackedColorArray() + + +var _field: TerrainHeightField +var _seed := 0 +var _assets: SeabedFloraAssets +var _grass_material: ShaderMaterial +var _fan_material: ShaderMaterial +var _meadow_noise: FastNoiseLite +var _fan_noise: FastNoiseLite +var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0} +var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: []} + + +func configure(field: TerrainHeightField, seed: int) -> String: + if field == null: + return "flora field is missing" + _clear() + _field = field + _seed = seed + if _assets == null: + _assets = SeabedFloraAssets.build(seed) + _grass_material = _material(_assets.blade_texture) + _fan_material = _material(_assets.fan_texture) + _meadow_noise = _noise(seed + 11, 0.012) + _fan_noise = _noise(seed + 23, 0.03) + var tiles := _tiles() + var grass: Array[TilePlacements] = [] + var fans: 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) + var group := WorkerThreadPool.add_group_task( + _place_tile.bind(grass, fans), 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]) + return "" + + +func instance_count(kind: Kind) -> int: + return int(_counts[kind]) + + +## World transforms of the first few instances of a kind, for inspection and tests. +func sample_transforms(kind: Kind) -> Array[Transform3D]: + var result: Array[Transform3D] = [] + for transform in _samples[kind]: + result.append(transform) + return result + + +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: []} + + +func _tiles() -> Array[Rect2i]: + var tiles: Array[Rect2i] = [] + var last_column := _field.columns() - 1 + var last_row := _field.rows() - 1 + var start_row := 0 + while start_row < last_row: + var end_row := mini(start_row + TILE_CELLS, last_row) + var start_column := 0 + while start_column < last_column: + var end_column := mini(start_column + TILE_CELLS, last_column) + tiles.append(Rect2i(start_column, start_row, end_column - start_column, end_row - start_row)) + start_column = end_column + start_row = end_row + return tiles + + +func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements]) -> void: + var tile := grass[tile_index].cells + var cell := _field.cell_size_m() + var origin := _field.origin_m() + var x_min := origin.x + float(tile.position.x) * cell + var z_min := origin.y + float(tile.position.y) * cell + var x_max := origin.x + float(tile.end.x) * cell + var z_max := origin.y + float(tile.end.y) * cell + var generator := RandomNumberGenerator.new() + 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) + + +## Cheap rejections first, straight from the field arrays (cell reads, one noise read), +## random draws only for accepted candidates: the draw sequence stays deterministic +## because every decision is. +func _place_grass(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 sand := SonarMaterial.Value.SAND + var slope_limit := (1.0 / (GRASS_SLOPE_COSINE * GRASS_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() * GRASS_SPACING_M + var pz := z + generator.randf() * GRASS_SPACING_M + x += GRASS_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 + var depth := -elevations[index] + if depth >= GRASS_DEPTH_NONE_M or materials[index] != sand: + continue + var meadow := _meadow_noise.get_noise_2d(px, pz) + if meadow < GRASS_MEADOW_THRESHOLD: + continue + # Slope by central differences: (dz_dx^2 + dz_dz^2) against tan^2 of the limit. + 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 density := 1.0 - smoothstep(GRASS_DEPTH_FULL_M, GRASS_DEPTH_NONE_M, depth) + if generator.randf() > density * clampf((meadow - GRASS_MEADOW_THRESHOLD) * 4.0, 0.3, 1.0): + continue + var yaw := generator.randf() * TAU + var size := generator.randf_range(0.6, 1.1) + var phase := generator.randf() + var tint := generator.randf_range(0.85, 1.15) + var elevation := _field.elevation_at(px, pz) + var basis := Basis(Vector3.UP, yaw).scaled(Vector3(size, size * 0.9, size)) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.03, pz))) + out.colors.append(Color(0.9 * tint, 1.0 * tint, 0.85 * tint)) + out.custom.append(Color(phase, 1.0, 0.0, 0.0)) + z += GRASS_SPACING_M + + +func _place_fans(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 heading := atan2(-FAN_HEADING.y, FAN_HEADING.x) + var z := z_min + while z < z_max: + var x := x_min + while x < x_max: + var px := x + generator.randf() * FAN_SPACING_M + var pz := z + generator.randf() * FAN_SPACING_M + x += FAN_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 < FAN_DEPTH_MIN_M or depth > FAN_DEPTH_MAX_M: + continue + if generator.randf() > FAN_KEEP_PROBABILITY or _fan_noise.get_noise_2d(px, pz) < -0.1: + continue + var yaw := heading + generator.randf_range(-0.45, 0.45) + var width := generator.randf_range(1.0, 2.4) + var height := generator.randf_range(0.9, 2.0) + var phase := generator.randf() + var color: Color = FAN_COLORS[generator.randi_range(0, FAN_COLORS.size() - 1)] + var elevation := _field.elevation_at(px, pz) + var normal := _field.normal_at_cell(column, row) + # Stand up, leaning a little with the rock face. + var up := (Vector3.UP * 0.6 + normal * 0.4).normalized() + var forward := Vector3(cos(yaw), 0.0, -sin(yaw)) + var right := forward.cross(up).normalized() + forward = up.cross(right).normalized() + var basis := Basis(right * width, up * height, forward) + out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.05, pz))) + out.colors.append(color) + out.custom.append(Color(phase, 0.12, 0.0, 0.0)) + z += FAN_SPACING_M + + +func _add_multimesh(kind: Kind, placements: TilePlacements) -> void: + var count := placements.transforms.size() + if count == 0: + return + var multimesh := MultiMesh.new() + 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 + multimesh.instance_count = count + for index in count: + multimesh.set_instance_transform(index, placements.transforms[index]) + multimesh.set_instance_color(index, placements.colors[index]) + 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", + 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.visibility_range_end_margin = 20.0 + instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF + instance.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if kind == Kind.SEAGRASS + else GeometryInstance3D.SHADOW_CASTING_SETTING_ON + ) + add_child(instance) + _counts[kind] = int(_counts[kind]) + count + var samples: Array = _samples[kind] + for index in mini(count, 4): + if samples.size() < 16: + samples.append(placements.transforms[index]) + + +func _material(texture: ImageTexture) -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = FLORA_SHADER + material.set_shader_parameter(&"albedo_map", texture) + return material + + +static func _noise(seed: int, frequency: float) -> FastNoiseLite: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = frequency + noise.fractal_octaves = 3 + return noise diff --git a/game/world/sites/seabed_flora_assets.gd b/game/world/sites/seabed_flora_assets.gd new file mode 100644 index 0000000..8f01a52 --- /dev/null +++ b/game/world/sites/seabed_flora_assets.gd @@ -0,0 +1,122 @@ +class_name SeabedFloraAssets +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. + +const BLADE_TEXTURE_WIDTH := 128 +const BLADE_TEXTURE_HEIGHT := 256 +const BLADE_COUNT := 5 +const FAN_TEXTURE_SIZE := 256 + +var tuft_mesh: ArrayMesh +var fan_mesh: ArrayMesh +var blade_texture: ImageTexture +var fan_texture: ImageTexture + + +static func build(seed: int) -> SeabedFloraAssets: + var result := SeabedFloraAssets.new() + result.blade_texture = _texture_from(_blade_image(seed)) + result.fan_texture = _texture_from(_fan_image(seed + 1)) + result.tuft_mesh = _crossed_quads(3) + result.fan_mesh = _crossed_quads(1) + return result + + +## 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: + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + var uvs := PackedVector2Array() + var indices := PackedInt32Array() + for quad in count: + var angle := PI * float(quad) / float(count) + var right := Vector3(cos(angle), 0.0, sin(angle)) * 0.5 + var normal := Vector3(-sin(angle), 0.0, cos(angle)) + var base := vertices.size() + vertices.append_array([-right, right, right + Vector3.UP, -right + Vector3.UP]) + for _corner in 4: + normals.append(normal) + uvs.append_array([Vector2(0.0, 1.0), Vector2(1.0, 1.0), Vector2(1.0, 0.0), Vector2(0.0, 0.0)]) + indices.append_array([base, base + 2, base + 1, base, base + 3, base + 2]) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + + +## A few strap-shaped blades rising from the bottom edge, each with its own curve, +## width and length, darker towards the edges and the base. Alpha outside the blades. +static func _blade_image(seed: int) -> Image: + var generator := RandomNumberGenerator.new() + generator.seed = seed + var image := Image.create(BLADE_TEXTURE_WIDTH, BLADE_TEXTURE_HEIGHT, false, Image.FORMAT_RGBA8) + image.fill(Color(0.0, 0.0, 0.0, 0.0)) + for blade in BLADE_COUNT: + var root_x := (float(blade) + 0.5) / float(BLADE_COUNT) * float(BLADE_TEXTURE_WIDTH) + var bend := generator.randf_range(-22.0, 22.0) + var curve := generator.randf_range(0.6, 1.4) + var length := generator.randf_range(0.72, 1.0) + var half_width := generator.randf_range(3.5, 6.0) + var hue_shift := generator.randf_range(-0.05, 0.05) + for y in BLADE_TEXTURE_HEIGHT: + var t := 1.0 - float(y) / float(BLADE_TEXTURE_HEIGHT - 1) # 0 at the base, 1 at the tip + if t > length: + continue + var centre := root_x + bend * pow(t, curve) + var width := half_width * (1.0 - 0.6 * t * t) + for x in range(int(floorf(centre - width)), int(ceilf(centre + width)) + 1): + if x < 0 or x >= BLADE_TEXTURE_WIDTH: + continue + var edge := absf(float(x) - centre) / width + if edge > 1.0: + continue + var shade := (0.7 + 0.3 * (1.0 - edge * edge)) * (0.75 + 0.25 * t) + var color := Color(0.46 + hue_shift, 0.66 + hue_shift * 0.5, 0.3, 1.0) * shade + color.a = 1.0 + image.set_pixel(x, y, color) + return image + + +## A sea fan: branching net inside a half-ellipse rising from a short stem. Ridged +## noise above a threshold gives the branches, denser near the stem. +static func _fan_image(seed: int) -> Image: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.seed = seed + noise.frequency = 0.045 + noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED + noise.fractal_octaves = 3 + var image := Image.create(FAN_TEXTURE_SIZE, FAN_TEXTURE_SIZE, false, Image.FORMAT_RGBA8) + image.fill(Color(0.0, 0.0, 0.0, 0.0)) + var centre_x := float(FAN_TEXTURE_SIZE) * 0.5 + for y in FAN_TEXTURE_SIZE: + var height := 1.0 - float(y) / float(FAN_TEXTURE_SIZE - 1) # 0 at the base + for x in FAN_TEXTURE_SIZE: + var dx := (float(x) - centre_x) / (float(FAN_TEXTURE_SIZE) * 0.48) + var dy := (height - 0.1) / 0.9 + var inside := dx * dx + dy * dy + var stem := absf(dx) < 0.03 and height < 0.25 + if inside > 1.0 and not stem: + continue + var ridge := noise.get_noise_2d(float(x), float(y)) * 0.5 + 0.5 + var density_threshold := 0.5 + 0.22 * inside + if not stem and ridge < density_threshold: + continue + var shade := 0.7 + 0.3 * ridge + image.set_pixel(x, y, Color(shade, shade * 0.92, shade * 0.85, 1.0)) + return image + + +static func _texture_from(image: Image) -> ImageTexture: + image.generate_mipmaps() + return ImageTexture.create_from_image(image) diff --git a/game/world/sites/seabed_terrain.gdshader b/game/world/sites/seabed_terrain.gdshader index 69ddf31..4e8628f 100644 --- a/game/world/sites/seabed_terrain.gdshader +++ b/game/world/sites/seabed_terrain.gdshader @@ -15,8 +15,20 @@ render_mode depth_draw_opaque; // projectors are not attenuated, so they stay useful on the drop-off. Everything is // normalised at reference_depth_m, where the look is the authored palette. +#include "res://game/world/sites/water_light.gdshaderinc" + uniform vec3 sand_color : source_color = vec3(0.56, 0.52, 0.42); uniform vec3 rock_color : source_color = vec3(0.22, 0.21, 0.19); +// Encrusting growth on rock: turf algae on sunlit faces, coralline crusts in patches, +// encrusting sponges in the shade. Patches come from the grain map read at large +// scales; all of it thins out with depth as the light goes. +uniform vec3 turf_color : source_color = vec3(0.3, 0.38, 0.2); +uniform vec3 coralline_color : source_color = vec3(0.6, 0.4, 0.46); +uniform vec3 sponge_color : source_color = vec3(0.82, 0.4, 0.18); +uniform float growth_tile_m = 4.2; +uniform float growth_strength = 1.0; +uniform float growth_fade_start_m = 40.0; +uniform float growth_fade_end_m = 75.0; uniform float sand_slope_cosine = 0.9397; // cos(20 deg) uniform float rock_slope_cosine = 0.7660; // cos(40 deg) // Per-channel absorption of clear tropical water, per metre, scaled for readability. @@ -123,19 +135,29 @@ void fragment() { // Albedo: mottled rock, lightly grained sand. 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); + float depth_m = -world_position.y; + + // Encrusting growth on the rock. + float growth_light = (1.0 - smoothstep(growth_fade_start_m, growth_fade_end_m, depth_m)) * growth_strength; + float sunlit = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + float patch_a = triplanar_grain(rock_grain_map, world_position / growth_tile_m + vec3(0.21, 0.73, 0.37), n); + float patch_b = triplanar_grain(rock_grain_map, world_position / (growth_tile_m * 0.53) + vec3(0.61, 0.11, 0.9), n); + float turf_mask = smoothstep(0.4, 0.58, patch_a) * sunlit * growth_light; + float coralline_mask = smoothstep(0.6, 0.74, patch_b) * growth_light; + float sponge_mask = smoothstep(0.62, 0.74, patch_a * 0.5 + patch_b * 0.5) * (1.0 - 0.6 * sunlit) * growth_light; + float grain_mottle = 1.0 + (grain - 0.5) * 0.6; + rock_albedo = mix(rock_albedo, turf_color * grain_mottle, turf_mask); + rock_albedo = mix(rock_albedo, coralline_color * grain_mottle, coralline_mask); + rock_albedo = mix(rock_albedo, sponge_color * grain_mottle, sponge_mask); + float rock_roughness = 0.78 + 0.12 * grain; + rock_roughness = mix(rock_roughness, 0.6, coralline_mask); + rock_roughness = mix(rock_roughness, 0.9, sponge_mask); vec3 albedo = mix(sand_albedo, rock_albedo, rockness); // Water column: spectral transmittance relative to the reference depth. - float depth_m = -world_position.y; - vec3 transmittance = exp(-water_absorption_per_m * (depth_m - reference_depth_m)); - float brightness = dot(transmittance, vec3(0.2126, 0.7152, 0.0722)); - vec3 chroma = transmittance / max(brightness, 0.001); - daylight = max(brightness, minimum_daylight); - // Normalise so the authored palette holds at the reference depth once the - // reference transmittance (red already filtered by 24 m of water) is applied. - vec3 reference_chroma = exp(-water_absorption_per_m * reference_depth_m); - reference_chroma /= dot(reference_chroma, vec3(0.2126, 0.7152, 0.0722)); - ALBEDO = albedo * chroma * reference_chroma; + vec3 transmittance = water_transmittance(depth_m, water_absorption_per_m, reference_depth_m); + daylight = water_daylight(transmittance, minimum_daylight); + ALBEDO = albedo * water_chroma(transmittance, water_absorption_per_m, reference_depth_m); // Detail shading: pits of the grain map and ripple troughs read as occlusion; // the daylight factor dims the sky ambient with depth. @@ -143,14 +165,14 @@ void fragment() { 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) * daylight; AO_LIGHT_AFFECT = 0.35; - ROUGHNESS = mix(0.95, 0.78 + 0.12 * grain, rockness); + ROUGHNESS = mix(0.95, rock_roughness, rockness); METALLIC = 0.0; } // Lambert diffuse; the sun (directional) is dimmed by the water column, other lights // (submarine projectors) are not. Sand and rock are rough enough to skip specular. void light() { - float sun_factor = LIGHT_IS_DIRECTIONAL ? daylight : 1.0; + float sun_factor = 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_factor / PI; } diff --git a/game/world/sites/water_light.gdshaderinc b/game/world/sites/water_light.gdshaderinc new file mode 100644 index 0000000..c715a75 --- /dev/null +++ b/game/world/sites/water_light.gdshaderinc @@ -0,0 +1,27 @@ +// Shared water-column optics for the seabed and flora shaders: per-channel +// Beer-Lambert transmittance relative to a reference depth, split into a scalar +// daylight factor (dims the sun and the sky ambient) and a chroma (tints albedo). +// Artificial lights are never attenuated: see water_sun_factor. + +const vec3 WATER_LUMINANCE_WEIGHTS = vec3(0.2126, 0.7152, 0.0722); + +vec3 water_transmittance(float depth_m, vec3 absorption_per_m, float reference_depth_m) { + return exp(-absorption_per_m * (depth_m - reference_depth_m)); +} + +float water_daylight(vec3 transmittance, float minimum_daylight) { + return max(dot(transmittance, WATER_LUMINANCE_WEIGHTS), minimum_daylight); +} + +// Chroma of the transmittance, normalised so the authored palette holds at the +// reference depth once the reference transmittance (red already filtered) is applied. +vec3 water_chroma(vec3 transmittance, vec3 absorption_per_m, float reference_depth_m) { + float brightness = max(dot(transmittance, WATER_LUMINANCE_WEIGHTS), 0.001); + vec3 reference = exp(-absorption_per_m * reference_depth_m); + reference /= dot(reference, WATER_LUMINANCE_WEIGHTS); + return transmittance / brightness * reference; +} + +float water_sun_factor(bool light_is_directional, float daylight) { + return light_is_directional ? daylight : 1.0; +} diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index e653a95..8f3ce07 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -10,6 +10,7 @@ const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition 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 SeabedFloraActor = preload("res://game/world/sites/seabed_flora_actor.gd") const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") const ReferenceBathymetryAcquisition = preload( "res://game/world/sonar/reference_bathymetry_acquisition.gd" @@ -19,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 := 44 +const TEST_COUNT := 49 var _failures: Array[String] = [] @@ -188,6 +189,27 @@ func _test_dive_site_scene() -> void: await physics_frame await physics_frame _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") + var flora := site.flora() + var grass_count := flora.instance_count(SeabedFloraActor.Kind.SEAGRASS) + var fan_count := flora.instance_count(SeabedFloraActor.Kind.SEA_FAN) + _expect(grass_count > 0 and fan_count > 0, "calibration site grows seagrass and sea fans (%d, %d)" % [grass_count, fan_count]) + var field_for_flora := definition.height_field() + var grass_on_sand := true + var grass_in_band := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS): + var at := transform.origin + grass_on_sand = grass_on_sand and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.SAND + grass_in_band = grass_in_band and -at.y < SeabedFloraActor.GRASS_DEPTH_NONE_M and absf(at.y - field_for_flora.elevation_at(at.x, at.z)) < 0.1 + _expect(grass_on_sand, "seagrass stands on sand") + _expect(grass_in_band, "seagrass stays in the lit band and on the seabed") + var fans_on_rock := true + for transform in flora.sample_transforms(SeabedFloraActor.Kind.SEA_FAN): + var at := transform.origin + fans_on_rock = fans_on_rock and field_for_flora.material_at(at.x, at.z) == SonarMaterial.Value.ROCK + _expect(fans_on_rock, "sea fans stand on rock") + var first_pass := flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS) + flora.configure(field_for_flora, hash(String(definition.site_id))) + _expect(first_pass == flora.sample_transforms(SeabedFloraActor.Kind.SEAGRASS), "flora placement is deterministic for a seed") var water_column := site.get_node_or_null("WaterColumn") as FogVolume _expect( water_column != null and water_column.material is ShaderMaterial