feat(world): seabed flora and encrusting growth on rock
Quality Gate / Quality Gate (pull_request) Failing after 2m58s

SeabedFloraActor scatters seagrass tufts on flat sand in the lit band and
sea fans on rock between 15 and 60 m, deterministically from the site id,
one MultiMesh per tile and kind, with procedural alpha-cut meshes and
textures (SeabedFloraAssets), vertex sway and wrapped lighting, no
collision and no sonar presence. Placement reads the field arrays
directly and runs on the worker thread pool. The seabed shader grows
turf algae on sunlit rock, coralline crusts in patches and encrusting
sponges in the shade, fading out between 40 and 75 m. The water-column
optics move to a shared shader include.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-07 00:44:10 +02:00
1 parent 122f3f5800
commit 0f9918224a
16 files changed
+585 -14

No files matched your search

+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)