feat(world): volumetric coral heads on patch reefs
Quality Gate / Quality Gate (pull_request) Failing after 3m18s
Quality Gate / Quality Gate (pull_request) Failing after 3m18s
Three procedural coral head meshes (boulder, brain-ridged, lobed domes on a flat base, vertex-shaded crevices) are scattered on gentle rock between 12 and 40 m, clustered by noise, one MultiMesh per tile and variant, with an opaque shader under the shared water column. The coral assertions run on the Banc du Vigilant, whose patch reef lies in the lit band. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f9918224a
commit
48e054557c
12 files changed
+293
-24
No files matched your search
@@ -0,0 +1,39 @@
|
||||
shader_type spatial;
|
||||
render_mode depth_draw_opaque;
|
||||
|
||||
// Massive coral heads: opaque, vertex-coloured (mesh shade times the instance tint),
|
||||
// lit under the same water column as the seabed. INSTANCE_CUSTOM.x is a per-head
|
||||
// mottling phase; the submarine's projectors are not attenuated.
|
||||
|
||||
#include "res://game/world/sites/water_light.gdshaderinc"
|
||||
|
||||
uniform vec3 water_absorption_per_m = vec3(0.11, 0.04, 0.032);
|
||||
uniform float reference_depth_m = 24.0;
|
||||
uniform float minimum_daylight = 0.05;
|
||||
uniform float mottle_strength = 0.18;
|
||||
|
||||
varying float daylight;
|
||||
varying vec3 chroma;
|
||||
varying float mottle;
|
||||
|
||||
void vertex() {
|
||||
vec3 world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
vec3 transmittance = water_transmittance(-world.y, water_absorption_per_m, reference_depth_m);
|
||||
daylight = water_daylight(transmittance, minimum_daylight);
|
||||
chroma = water_chroma(transmittance, water_absorption_per_m, reference_depth_m);
|
||||
mottle = sin(VERTEX.x * 9.0 + INSTANCE_CUSTOM.x * TAU) * sin(VERTEX.z * 7.0 + VERTEX.y * 5.0);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
ALBEDO = COLOR.rgb * (1.0 + mottle * mottle_strength) * chroma;
|
||||
AO = daylight;
|
||||
AO_LIGHT_AFFECT = 0.35;
|
||||
ROUGHNESS = 0.8;
|
||||
METALLIC = 0.0;
|
||||
}
|
||||
|
||||
void light() {
|
||||
float sun = water_sun_factor(LIGHT_IS_DIRECTIONAL, daylight);
|
||||
float lambert = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
|
||||
DIFFUSE_LIGHT += lambert * ATTENUATION * LIGHT_COLOR * ALBEDO * sun / PI;
|
||||
}
|
||||
@@ -2,8 +2,9 @@ class_name SeabedFloraActor
|
||||
extends Node3D
|
||||
|
||||
## Scatters the seabed flora of a site from its height field, deterministically from a
|
||||
## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock. One
|
||||
## MultiMeshInstance3D per terrain tile and per kind, with a visibility range, no
|
||||
## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock, and
|
||||
## massive coral heads on the gentle rock of patch reefs. One MultiMeshInstance3D per
|
||||
## terrain tile and per kind (per variant for corals), with a visibility range, no
|
||||
## collision and no sonar presence. Placement is pure and runs on the worker thread
|
||||
## pool; the multimeshes are built afterwards on the main thread.
|
||||
|
||||
@@ -11,8 +12,9 @@ const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field
|
||||
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
||||
const SeabedFloraAssets = preload("res://game/world/sites/seabed_flora_assets.gd")
|
||||
const FLORA_SHADER = preload("res://game/world/sites/seabed_flora.gdshader")
|
||||
const CORAL_SHADER = preload("res://game/world/sites/seabed_coral.gdshader")
|
||||
|
||||
enum Kind { SEAGRASS, SEA_FAN }
|
||||
enum Kind { SEAGRASS, SEA_FAN, CORAL_HEAD }
|
||||
|
||||
const TILE_CELLS := 64
|
||||
# Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium
|
||||
@@ -30,6 +32,19 @@ const FAN_DEPTH_MAX_M := 60.0
|
||||
const FAN_KEEP_PROBABILITY := 0.45
|
||||
const FAN_VISIBILITY_M := 170.0
|
||||
const FAN_HEADING := Vector2(0.8, 0.6)
|
||||
# Coral heads: patch reefs are the gentle (< 30 deg) rock of the sunlit band, 12 to
|
||||
# 40 m, clustered by noise; heads are 0.6 to 2.2 m across.
|
||||
const CORAL_SPACING_M := 3.0
|
||||
const CORAL_DEPTH_MIN_M := 12.0
|
||||
const CORAL_DEPTH_FADE_START_M := 30.0
|
||||
const CORAL_DEPTH_MAX_M := 40.0
|
||||
const CORAL_SLOPE_COSINE := 0.866 # cos(30 deg)
|
||||
const CORAL_REEF_THRESHOLD := 0.05
|
||||
const CORAL_KEEP_PROBABILITY := 0.55
|
||||
const CORAL_VISIBILITY_M := 150.0
|
||||
const CORAL_COLORS: Array[Color] = [
|
||||
Color(0.72, 0.58, 0.3), Color(0.5, 0.52, 0.28), Color(0.68, 0.48, 0.36), Color(0.62, 0.6, 0.42),
|
||||
]
|
||||
const FAN_COLORS: Array[Color] = [
|
||||
Color(0.55, 0.35, 0.62), Color(0.85, 0.42, 0.2), Color(0.8, 0.7, 0.3), Color(0.6, 0.3, 0.45),
|
||||
]
|
||||
@@ -41,6 +56,7 @@ class TilePlacements extends RefCounted:
|
||||
var transforms: Array[Transform3D] = []
|
||||
var colors := PackedColorArray()
|
||||
var custom := PackedColorArray()
|
||||
var variants := PackedInt32Array()
|
||||
|
||||
|
||||
var _field: TerrainHeightField
|
||||
@@ -48,10 +64,12 @@ var _seed := 0
|
||||
var _assets: SeabedFloraAssets
|
||||
var _grass_material: ShaderMaterial
|
||||
var _fan_material: ShaderMaterial
|
||||
var _coral_material: ShaderMaterial
|
||||
var _reef_noise: FastNoiseLite
|
||||
var _meadow_noise: FastNoiseLite
|
||||
var _fan_noise: FastNoiseLite
|
||||
var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0}
|
||||
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: []}
|
||||
var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0}
|
||||
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
|
||||
|
||||
|
||||
func configure(field: TerrainHeightField, seed: int) -> String:
|
||||
@@ -64,25 +82,28 @@ func configure(field: TerrainHeightField, seed: int) -> String:
|
||||
_assets = SeabedFloraAssets.build(seed)
|
||||
_grass_material = _material(_assets.blade_texture)
|
||||
_fan_material = _material(_assets.fan_texture)
|
||||
_coral_material = ShaderMaterial.new()
|
||||
_coral_material.shader = CORAL_SHADER
|
||||
_meadow_noise = _noise(seed + 11, 0.012)
|
||||
_fan_noise = _noise(seed + 23, 0.03)
|
||||
_reef_noise = _noise(seed + 37, 0.02)
|
||||
var tiles := _tiles()
|
||||
var grass: Array[TilePlacements] = []
|
||||
var fans: Array[TilePlacements] = []
|
||||
var corals: Array[TilePlacements] = []
|
||||
for tile in tiles:
|
||||
var grass_tile := TilePlacements.new()
|
||||
grass_tile.cells = tile
|
||||
grass.append(grass_tile)
|
||||
var fan_tile := TilePlacements.new()
|
||||
fan_tile.cells = tile
|
||||
fans.append(fan_tile)
|
||||
for list: Array[TilePlacements] in [grass, fans, corals]:
|
||||
var placements := TilePlacements.new()
|
||||
placements.cells = tile
|
||||
list.append(placements)
|
||||
var group := WorkerThreadPool.add_group_task(
|
||||
_place_tile.bind(grass, fans), tiles.size(), -1, true, "seabed flora"
|
||||
_place_tile.bind(grass, fans, corals), tiles.size(), -1, true, "seabed flora"
|
||||
)
|
||||
WorkerThreadPool.wait_for_group_task_completion(group)
|
||||
for index in tiles.size():
|
||||
_add_multimesh(Kind.SEAGRASS, grass[index])
|
||||
_add_multimesh(Kind.SEA_FAN, fans[index])
|
||||
_add_coral_multimeshes(corals[index])
|
||||
return ""
|
||||
|
||||
|
||||
@@ -102,8 +123,8 @@ func _clear() -> void:
|
||||
for child in get_children():
|
||||
remove_child(child)
|
||||
child.free()
|
||||
_counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0}
|
||||
_samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: []}
|
||||
_counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0}
|
||||
_samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
|
||||
|
||||
|
||||
func _tiles() -> Array[Rect2i]:
|
||||
@@ -122,7 +143,7 @@ func _tiles() -> Array[Rect2i]:
|
||||
return tiles
|
||||
|
||||
|
||||
func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements]) -> void:
|
||||
func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements], corals: Array[TilePlacements]) -> void:
|
||||
var tile := grass[tile_index].cells
|
||||
var cell := _field.cell_size_m()
|
||||
var origin := _field.origin_m()
|
||||
@@ -134,6 +155,7 @@ func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[Tile
|
||||
generator.seed = _seed * 7919 + tile_index
|
||||
_place_grass(grass[tile_index], generator, x_min, x_max, z_min, z_max)
|
||||
_place_fans(fans[tile_index], generator, x_min, x_max, z_min, z_max)
|
||||
_place_corals(corals[tile_index], generator, x_min, x_max, z_min, z_max)
|
||||
|
||||
|
||||
## Cheap rejections first, straight from the field arrays (cell reads, one noise read),
|
||||
@@ -233,7 +255,72 @@ func _place_fans(out: TilePlacements, generator: RandomNumberGenerator, x_min: f
|
||||
z += FAN_SPACING_M
|
||||
|
||||
|
||||
func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
|
||||
func _place_corals(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void:
|
||||
var origin := _field.origin_m()
|
||||
var cell := _field.cell_size_m()
|
||||
var columns := _field.columns()
|
||||
var rows := _field.rows()
|
||||
var elevations := _field.elevations_m()
|
||||
var materials := _field.materials()
|
||||
var rock := SonarMaterial.Value.ROCK
|
||||
var slope_limit := (1.0 / (CORAL_SLOPE_COSINE * CORAL_SLOPE_COSINE) - 1.0) * (2.0 * cell) * (2.0 * cell)
|
||||
var z := z_min
|
||||
while z < z_max:
|
||||
var x := x_min
|
||||
while x < x_max:
|
||||
var px := x + generator.randf() * CORAL_SPACING_M
|
||||
var pz := z + generator.randf() * CORAL_SPACING_M
|
||||
x += CORAL_SPACING_M
|
||||
var column := int(roundf((px - origin.x) / cell))
|
||||
var row := int(roundf((pz - origin.y) / cell))
|
||||
if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1:
|
||||
continue
|
||||
var index := row * columns + column
|
||||
if materials[index] != rock:
|
||||
continue
|
||||
var depth := -elevations[index]
|
||||
if depth < CORAL_DEPTH_MIN_M or depth > CORAL_DEPTH_MAX_M:
|
||||
continue
|
||||
var rise_x := elevations[index + 1] - elevations[index - 1]
|
||||
var rise_z := elevations[index + columns] - elevations[index - columns]
|
||||
if rise_x * rise_x + rise_z * rise_z > slope_limit:
|
||||
continue
|
||||
var reef := _reef_noise.get_noise_2d(px, pz)
|
||||
if reef < CORAL_REEF_THRESHOLD:
|
||||
continue
|
||||
var density := (1.0 - smoothstep(CORAL_DEPTH_FADE_START_M, CORAL_DEPTH_MAX_M, depth)) * CORAL_KEEP_PROBABILITY
|
||||
if generator.randf() > density * clampf((reef - CORAL_REEF_THRESHOLD) * 5.0, 0.4, 1.0):
|
||||
continue
|
||||
var yaw := generator.randf() * TAU
|
||||
var radius := generator.randf_range(0.35, 1.3)
|
||||
var squash := generator.randf_range(0.7, 1.15)
|
||||
var phase := generator.randf()
|
||||
var variant := generator.randi_range(0, _assets.coral_meshes.size() - 1)
|
||||
var color: Color = CORAL_COLORS[generator.randi_range(0, CORAL_COLORS.size() - 1)]
|
||||
var elevation := _field.elevation_at(px, pz)
|
||||
var basis := Basis(Vector3.UP, yaw).scaled(Vector3(radius, radius * squash, radius))
|
||||
out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.08, pz)))
|
||||
out.colors.append(color)
|
||||
out.custom.append(Color(phase, 0.0, 0.0, 0.0))
|
||||
out.variants.append(variant)
|
||||
z += CORAL_SPACING_M
|
||||
|
||||
|
||||
## Coral heads of one tile, one MultiMesh per mesh variant.
|
||||
func _add_coral_multimeshes(placements: TilePlacements) -> void:
|
||||
for variant in _assets.coral_meshes.size():
|
||||
var subset := TilePlacements.new()
|
||||
subset.cells = placements.cells
|
||||
for index in placements.transforms.size():
|
||||
if placements.variants[index] != variant:
|
||||
continue
|
||||
subset.transforms.append(placements.transforms[index])
|
||||
subset.colors.append(placements.colors[index])
|
||||
subset.custom.append(placements.custom[index])
|
||||
_add_multimesh(Kind.CORAL_HEAD, subset, variant)
|
||||
|
||||
|
||||
func _add_multimesh(kind: Kind, placements: TilePlacements, variant: int = 0) -> void:
|
||||
var count := placements.transforms.size()
|
||||
if count == 0:
|
||||
return
|
||||
@@ -241,7 +328,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
|
||||
multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||
multimesh.use_colors = true
|
||||
multimesh.use_custom_data = true
|
||||
multimesh.mesh = _assets.tuft_mesh if kind == Kind.SEAGRASS else _assets.fan_mesh
|
||||
match kind:
|
||||
Kind.SEAGRASS:
|
||||
multimesh.mesh = _assets.tuft_mesh
|
||||
Kind.SEA_FAN:
|
||||
multimesh.mesh = _assets.fan_mesh
|
||||
Kind.CORAL_HEAD:
|
||||
multimesh.mesh = _assets.coral_meshes[variant]
|
||||
multimesh.instance_count = count
|
||||
for index in count:
|
||||
multimesh.set_instance_transform(index, placements.transforms[index])
|
||||
@@ -249,13 +342,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
|
||||
multimesh.set_instance_custom_data(index, placements.custom[index])
|
||||
var instance := MultiMeshInstance3D.new()
|
||||
instance.name = "%s_%d_%d" % [
|
||||
"Seagrass" if kind == Kind.SEAGRASS else "SeaFans",
|
||||
["Seagrass", "SeaFans", "Corals%d" % variant][kind],
|
||||
placements.cells.position.x / TILE_CELLS,
|
||||
placements.cells.position.y / TILE_CELLS,
|
||||
]
|
||||
instance.multimesh = multimesh
|
||||
instance.material_override = _grass_material if kind == Kind.SEAGRASS else _fan_material
|
||||
instance.visibility_range_end = GRASS_VISIBILITY_M if kind == Kind.SEAGRASS else FAN_VISIBILITY_M
|
||||
instance.material_override = [_grass_material, _fan_material, _coral_material][kind]
|
||||
instance.visibility_range_end = [GRASS_VISIBILITY_M, FAN_VISIBILITY_M, CORAL_VISIBILITY_M][kind]
|
||||
instance.visibility_range_end_margin = 20.0
|
||||
instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
|
||||
instance.cast_shadow = (
|
||||
|
||||
@@ -4,17 +4,23 @@ extends RefCounted
|
||||
## Procedural meshes and alpha-cut textures for the seabed flora, built synchronously
|
||||
## from a seed so no asset is needed and every build is identical: a seagrass tuft
|
||||
## (three crossed unit quads carrying a strap-blade texture) and a sea fan (one unit
|
||||
## quad carrying a branching net texture). Meshes are unit sized, y from 0 to 1.
|
||||
## quad carrying a branching net texture), and three massive coral heads (bumpy
|
||||
## domes with a flat base: boulder, brain-ridged and lobed). Meshes are unit sized,
|
||||
## y from 0 to 1.
|
||||
|
||||
const BLADE_TEXTURE_WIDTH := 128
|
||||
const BLADE_TEXTURE_HEIGHT := 256
|
||||
const BLADE_COUNT := 5
|
||||
const FAN_TEXTURE_SIZE := 256
|
||||
const CORAL_VARIANT_COUNT := 3
|
||||
const CORAL_RINGS := 14
|
||||
const CORAL_SEGMENTS := 20
|
||||
|
||||
var tuft_mesh: ArrayMesh
|
||||
var fan_mesh: ArrayMesh
|
||||
var blade_texture: ImageTexture
|
||||
var fan_texture: ImageTexture
|
||||
var coral_meshes: Array[ArrayMesh] = []
|
||||
|
||||
|
||||
static func build(seed: int) -> SeabedFloraAssets:
|
||||
@@ -23,9 +29,77 @@ static func build(seed: int) -> SeabedFloraAssets:
|
||||
result.fan_texture = _texture_from(_fan_image(seed + 1))
|
||||
result.tuft_mesh = _crossed_quads(3)
|
||||
result.fan_mesh = _crossed_quads(1)
|
||||
for variant in CORAL_VARIANT_COUNT:
|
||||
result.coral_meshes.append(_coral_head(seed + 10 + variant, variant))
|
||||
return result
|
||||
|
||||
|
||||
## Dome of unit radius and unit height on a flat base, displaced by seeded noise:
|
||||
## variant 0 a boulder coral (broad bumps), 1 a brain coral (fine ridges), 2 a lobed
|
||||
## head (deep lobes). Vertex colour carries the shade (crevices darker).
|
||||
static func _coral_head(seed: int, variant: int) -> ArrayMesh:
|
||||
var noise := FastNoiseLite.new()
|
||||
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
noise.seed = seed
|
||||
noise.fractal_octaves = 3
|
||||
var frequency := [0.9, 2.6, 0.6][variant] as float
|
||||
var amplitude := [0.12, 0.07, 0.3][variant] as float
|
||||
noise.frequency = frequency
|
||||
noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED if variant == 1 else FastNoiseLite.FRACTAL_FBM
|
||||
var vertices := PackedVector3Array()
|
||||
var colors := PackedColorArray()
|
||||
var indices := PackedInt32Array()
|
||||
# Rings from the top (ring 0, a single pole) down to the base rim; the base is a
|
||||
# flat fan closed on a centre vertex.
|
||||
var ring_vertex_count := CORAL_SEGMENTS
|
||||
for ring in range(CORAL_RINGS + 1):
|
||||
var polar := (PI * 0.5) * float(ring) / float(CORAL_RINGS) # 0 at the pole, pi/2 at the rim
|
||||
for segment in CORAL_SEGMENTS:
|
||||
var azimuth := TAU * float(segment) / float(CORAL_SEGMENTS)
|
||||
var direction := Vector3(sin(polar) * cos(azimuth), cos(polar), sin(polar) * sin(azimuth))
|
||||
var bump := noise.get_noise_3d(direction.x * 3.0, direction.y * 3.0, direction.z * 3.0)
|
||||
# Keep the rim on the ground: the displacement fades towards the base.
|
||||
var radius := 1.0 + bump * amplitude * (0.35 + 0.65 * cos(polar))
|
||||
var position := direction * radius
|
||||
position.y = maxf(position.y, 0.0)
|
||||
vertices.append(position)
|
||||
var shade := clampf(0.78 + bump * 0.9, 0.5, 1.1)
|
||||
colors.append(Color(shade, shade, shade))
|
||||
for ring in CORAL_RINGS:
|
||||
for segment in CORAL_SEGMENTS:
|
||||
var next_segment := (segment + 1) % CORAL_SEGMENTS
|
||||
var a := ring * ring_vertex_count + segment
|
||||
var b := ring * ring_vertex_count + next_segment
|
||||
var c := (ring + 1) * ring_vertex_count + segment
|
||||
var d := (ring + 1) * ring_vertex_count + next_segment
|
||||
if ring > 0:
|
||||
indices.append_array([a, b, c])
|
||||
indices.append_array([b, d, c])
|
||||
var centre := vertices.size()
|
||||
vertices.append(Vector3.ZERO)
|
||||
colors.append(Color(0.6, 0.6, 0.6))
|
||||
var rim := CORAL_RINGS * ring_vertex_count
|
||||
for segment in CORAL_SEGMENTS:
|
||||
indices.append_array([centre, rim + segment, rim + (segment + 1) % CORAL_SEGMENTS])
|
||||
var normals := PackedVector3Array()
|
||||
normals.resize(vertices.size())
|
||||
for index in range(0, indices.size(), 3):
|
||||
var face_normal := (vertices[indices[index + 2]] - vertices[indices[index]]).cross(vertices[indices[index + 1]] - vertices[indices[index]])
|
||||
for corner in 3:
|
||||
normals[indices[index + corner]] += face_normal
|
||||
for index in normals.size():
|
||||
normals[index] = normals[index].normalized() if normals[index].length() > 0.0 else Vector3.UP
|
||||
var arrays: Array = []
|
||||
arrays.resize(Mesh.ARRAY_MAX)
|
||||
arrays[Mesh.ARRAY_VERTEX] = vertices
|
||||
arrays[Mesh.ARRAY_NORMAL] = normals
|
||||
arrays[Mesh.ARRAY_COLOR] = colors
|
||||
arrays[Mesh.ARRAY_INDEX] = indices
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
return mesh
|
||||
|
||||
|
||||
## Vertical unit quads crossed around the y axis, each 1 wide and 1 tall, UV mapped
|
||||
## so the texture's bottom row sits on the ground.
|
||||
static func _crossed_quads(count: int) -> ArrayMesh:
|
||||
|
||||
Reference in new issue
Block a user