Terrain des sites de plongée : champ de hauteur authoré, Banc du Vigilant, formes variées et rendu sous-marin #4

Open
olix21 wants to merge 47 commits from olix21/worktree-terrain-site-zone1 AGit into main
pull from: olix21/worktree-terrain-site-zone1
16 changed files with 585 additions and 14 deletions
Showing only changes of commit 0f9918224a - Show all commits

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 318 KiB

@@ -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
@@ -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))]
+11
View File
@@ -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
+6 -1
View File
@@ -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
+60
View File
@@ -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;
}
+286
View File
@@ -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
+122
View File
@@ -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)
+34 -12
View File
@@ -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;
}
+27
View File
@@ -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;
}
@@ -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