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)