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>
197 lines
8.2 KiB
GDScript
197 lines
8.2 KiB
GDScript
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), 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:
|
|
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)
|
|
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:
|
|
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)
|