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)