Terrain des sites de plongée : champ de hauteur authoré, Banc du Vigilant, formes variées et rendu sous-marin #4
No files matched your search
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 438 KiB |
|
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))]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||