feat(world): volumetric coral heads on patch reefs
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:
OlivierandClaude Fable 5.1 committed 2026-10-07 08:22:09 +02:00
1 parent 0f9918224a
commit 48e054557c
12 files changed
+293 -24

No files matched your search

+75 -1
View File
@@ -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: