Rock is normal-mapped triplanar in world space at two scales, sand carries an oriented ripple normal map on the xz plane, and a grain map drives detail occlusion, albedo mottling and roughness. The three maps are seamless, seeded, mipmapped textures built synchronously by SeabedDetailTextures, so no asset is needed and builds are deterministic. The mesh has no tangents: perturbed normals are computed in world space and written to NORMAL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
74 lines
2.8 KiB
GDScript
74 lines
2.8 KiB
GDScript
class_name SeabedDetailTextures
|
|
extends RefCounted
|
|
|
|
## Procedural, seamless detail maps for the seabed shader, built synchronously from
|
|
## seeded noise so no texture asset is needed and every build is identical:
|
|
## an oriented sand ripple normal map, a rock normal map and the rock grain map
|
|
## (height, also read as occlusion and albedo mottling).
|
|
|
|
const TEXTURE_SIZE := 256
|
|
const RIPPLES_PER_TILE := 2.0
|
|
const RIPPLE_WARP := 0.22
|
|
const ROCK_BUMP_SCALE := 6.0
|
|
const SAND_BUMP_SCALE := 3.0
|
|
|
|
var sand_normal: ImageTexture
|
|
var rock_normal: ImageTexture
|
|
var rock_grain: ImageTexture
|
|
|
|
|
|
static func build(seed: int) -> SeabedDetailTextures:
|
|
var result := SeabedDetailTextures.new()
|
|
var rock_height := _rock_height_image(seed)
|
|
result.rock_grain = _texture_from(rock_height)
|
|
var rock_bumps := rock_height.duplicate() as Image
|
|
rock_bumps.bump_map_to_normal_map(ROCK_BUMP_SCALE)
|
|
result.rock_normal = _texture_from(rock_bumps)
|
|
var sand_height := _sand_height_image(seed + 1)
|
|
sand_height.bump_map_to_normal_map(SAND_BUMP_SCALE)
|
|
result.sand_normal = _texture_from(sand_height)
|
|
return result
|
|
|
|
|
|
## Ridged fractal noise: sharp crests and pits, seamless across the tile.
|
|
static func _rock_height_image(seed: int) -> Image:
|
|
var noise := FastNoiseLite.new()
|
|
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
noise.seed = seed
|
|
noise.frequency = 0.012
|
|
noise.fractal_type = FastNoiseLite.FRACTAL_RIDGED
|
|
noise.fractal_octaves = 5
|
|
return noise.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true)
|
|
|
|
|
|
## Ripples across the tile's v axis, warped and grained by noise. Asymmetric profile
|
|
## (gentle stoss side, steep lee side) as on a swell-built sand bed.
|
|
static func _sand_height_image(seed: int) -> Image:
|
|
var warp := FastNoiseLite.new()
|
|
warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
warp.seed = seed
|
|
warp.frequency = 0.02
|
|
warp.fractal_octaves = 2
|
|
var warp_image := warp.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true)
|
|
var grain := FastNoiseLite.new()
|
|
grain.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
grain.seed = seed + 7
|
|
grain.frequency = 0.15
|
|
grain.fractal_octaves = 3
|
|
var grain_image := grain.get_seamless_image(TEXTURE_SIZE, TEXTURE_SIZE, false, false, 0.1, true)
|
|
var image := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_L8)
|
|
for y in TEXTURE_SIZE:
|
|
for x in TEXTURE_SIZE:
|
|
var phase := (float(y) / float(TEXTURE_SIZE)) * RIPPLES_PER_TILE
|
|
phase += (warp_image.get_pixel(x, y).r - 0.5) * RIPPLE_WARP
|
|
var fraction := phase - floorf(phase)
|
|
var crest := fraction / 0.7 if fraction < 0.7 else (1.0 - fraction) / 0.3
|
|
var height := crest * 0.8 + grain_image.get_pixel(x, y).r * 0.2
|
|
image.set_pixel(x, y, Color(height, height, height))
|
|
return image
|
|
|
|
|
|
static func _texture_from(image: Image) -> ImageTexture:
|
|
image.generate_mipmaps()
|
|
return ImageTexture.create_from_image(image)
|