OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / assets / ocean / wave_generator.gd

145 lines · gd ref:
@tool
class_name WaveGenerator extends Node
## Handles the compute pipeline for wave spectra generation/FFT.
## VENDORED from 2Retr0/GodotOceanWaves (assets/water/wave_generator.gd).
## Local changes are marked `# hyper-tps:`; keep them few and obvious so an
## upstream diff stays readable.
##
## Runs on RenderingServer.get_rendering_device() -- the MAIN device. dc-terrain
## uses its own via create_local_rendering_device(), and hyper-games #58 is
## about starving a second VkDevice during bring-up; this adds no third one.

const G := 9.81
const DEPTH := 20.0

var map_size : int
var context : RenderingContext
var pipelines : Dictionary
var descriptors : Dictionary

# Generator state per invocation of `update()`.
var pass_parameters : Array[WaveCascadeParameters]
var pass_num_cascades_remaining : int

func init_gpu(num_cascades : int) -> void:
	# --- DEVICE/SHADER CREATION ---
	if not context: context = RenderingContext.create(RenderingServer.get_rendering_device())
	var spectrum_compute_shader := context.load_shader('res://assets/ocean/shaders/compute/spectrum_compute.glsl')
	var fft_butterfly_shader := context.load_shader('res://assets/ocean/shaders/compute/fft_butterfly.glsl')
	var spectrum_modulate_shader := context.load_shader('res://assets/ocean/shaders/compute/spectrum_modulate.glsl')
	var fft_compute_shader := context.load_shader('res://assets/ocean/shaders/compute/fft_compute.glsl')
	var transpose_shader := context.load_shader('res://assets/ocean/shaders/compute/transpose.glsl')
	var fft_unpack_shader := context.load_shader('res://assets/ocean/shaders/compute/fft_unpack.glsl')

	# --- DESCRIPTOR PREPARATION ---
	var dims := Vector2i(map_size, map_size)
	var num_fft_stages := int(log(map_size) / log(2))

	descriptors[&'spectrum'] = context.create_texture(dims, RenderingDevice.DATA_FORMAT_R32G32B32A32_SFLOAT, RenderingDevice.TEXTURE_USAGE_STORAGE_BIT | RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT, num_cascades)
	descriptors[&'butterfly_factors'] = context.create_storage_buffer(num_fft_stages*map_size * 4 * 4)         # Size: (#FFT stages * map size * sizeof(vec4))
	descriptors[&'fft_buffer'] = context.create_storage_buffer(num_cascades * map_size*map_size * 4*2 * 2 * 4) # Size: (map size^2 * 4 FFTs * 2 temp buffers (for Stockham FFT) * sizeof(vec2))
	# hyper-tps: + CAN_COPY_FROM_BIT so texture_get_data can read these back.
	# Without it readback returns an EMPTY array, which is indistinguishable
	# from 'the FFT produced nothing' and makes the pipeline untestable except
	# by eye. Costs nothing; the maps are still STORAGE+SAMPLING.
	descriptors[&'displacement_map'] = context.create_texture(dims, RenderingDevice.DATA_FORMAT_R16G16B16A16_SFLOAT, RenderingDevice.TEXTURE_USAGE_STORAGE_BIT | RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT | RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT, num_cascades)
	# hyper-tps: + CAN_COPY_FROM_BIT so texture_get_data can read these back.
	# Without it readback returns an EMPTY array, which is indistinguishable
	# from 'the FFT produced nothing' and makes the pipeline untestable except
	# by eye. Costs nothing; the maps are still STORAGE+SAMPLING.
	descriptors[&'normal_map'] = context.create_texture(dims, RenderingDevice.DATA_FORMAT_R16G16B16A16_SFLOAT, RenderingDevice.TEXTURE_USAGE_STORAGE_BIT | RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT | RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT | RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT, num_cascades)

	var spectrum_set := context.create_descriptor_set([descriptors[&'spectrum']], spectrum_compute_shader, 0)
	# hyper-tps: a SECOND set for the same texture, built against the shader
	# that reads it. spectrum_compute declares `writeonly image2DArray spectrum`
	# and spectrum_modulate declares it `readonly`; one set cannot satisfy both
	# in Godot 4.7, which validates writability per pipeline:
	#
	#   Uniforms supplied for set (0): ... Writable: Y
	#   Pipeline shader requires ...      Writable: N
	var spectrum_read_set := context.create_descriptor_set([descriptors[&'spectrum']], spectrum_modulate_shader, 0)
	var fft_butterfly_set := context.create_descriptor_set([descriptors[&'butterfly_factors']], fft_butterfly_shader, 0)
	var fft_compute_set := context.create_descriptor_set([descriptors[&'butterfly_factors'], descriptors[&'fft_buffer']], fft_compute_shader, 0)
	var fft_buffer_set := context.create_descriptor_set([descriptors[&'fft_buffer']], spectrum_modulate_shader, 1)
	var unpack_set := context.create_descriptor_set([descriptors[&'displacement_map'], descriptors[&'normal_map']], fft_unpack_shader, 0)

	# --- COMPUTE PIPELINE CREATION ---
	pipelines[&'spectrum_compute'] = context.create_pipeline([map_size/16, map_size/16, 1], [spectrum_set], spectrum_compute_shader)
	pipelines[&'spectrum_modulate'] = context.create_pipeline([map_size/16, map_size/16, 1], [spectrum_read_set, fft_buffer_set], spectrum_modulate_shader)
	pipelines[&'fft_butterfly'] = context.create_pipeline([map_size/2/64, num_fft_stages, 1], [fft_butterfly_set], fft_butterfly_shader)
	pipelines[&'fft_compute'] = context.create_pipeline([1, map_size, 4], [fft_compute_set], fft_compute_shader)
	pipelines[&'transpose'] = context.create_pipeline([map_size/32, map_size/32, 4], [fft_compute_set], transpose_shader)
	pipelines[&'fft_unpack'] = context.create_pipeline([map_size/16, map_size/16, 1], [unpack_set, fft_buffer_set], fft_unpack_shader)

	# We only need to generate butterfly factors once for each map_size.
	var compute_list := context.compute_list_begin()
	pipelines[&'fft_butterfly'].call(context, compute_list)
	context.compute_list_end()

func _process(delta: float) -> void:
	# Update one cascade each frame for load balancing.
	if pass_num_cascades_remaining == 0: return
	pass_num_cascades_remaining -= 1

	var compute_list := context.compute_list_begin()
	_update(compute_list, pass_num_cascades_remaining, pass_parameters)
	context.compute_list_end()

func _update(compute_list : int, cascade_index : int, parameters : Array[WaveCascadeParameters]) -> void:
	var params := parameters[cascade_index]
	## --- WAVE SPECTRA UPDATE ---
	if params.should_generate_spectrum:
		var alpha := JONSWAP_alpha(params.wind_speed, params.fetch_length*1e3)
		var omega := JONSWAP_peak_angular_frequency(params.wind_speed, params.fetch_length*1e3)
		pipelines[&'spectrum_compute'].call(context, compute_list, RenderingContext.create_push_constant([params.spectrum_seed.x, params.spectrum_seed.y, params.tile_length.x, params.tile_length.y, alpha, omega, params.wind_speed, deg_to_rad(params.wind_direction), DEPTH, params.swell, params.detail, params.spread, cascade_index]))
		params.should_generate_spectrum = false
	pipelines[&'spectrum_modulate'].call(context, compute_list, RenderingContext.create_push_constant([params.tile_length.x, params.tile_length.y, DEPTH, params.time, cascade_index]))

	## --- WAVE SPECTRA INVERSE FOURIER TRANSFORM ---
	var fft_push_constant := RenderingContext.create_push_constant([cascade_index])
	# Note: We need not do a second transpose after computing FFT on rows since rotating the wave by
	#       PI/2 doesn't affect it visually.
	pipelines[&'fft_compute'].call(context, compute_list, fft_push_constant)
	pipelines[&'transpose'].call(context, compute_list, fft_push_constant)
	context.compute_list_add_barrier(compute_list) # FIXME: Why is a barrier only needed here?!
	pipelines[&'fft_compute'].call(context, compute_list, fft_push_constant)

	## --- DISPLACEMENT/NORMAL MAP UPDATE ---
	pipelines[&'fft_unpack'].call(context, compute_list, RenderingContext.create_push_constant([cascade_index, params.whitecap, params.foam_grow_rate, params.foam_decay_rate]))

## Begins updating wave cascades based on the provided parameters. To balance stutter,
## the generator will schedule one cascade update per frame. All cascades from the
## previous invocation that have not been processed yet will be updated.
func update(delta : float, parameters : Array[WaveCascadeParameters]) -> void:
	assert(parameters.size() != 0)
	if not context:
		init_gpu(maxi(2, len(parameters))) # FIXME: This is needed because my RenderContext API sucks...
	elif pass_num_cascades_remaining != 0: # Update cascades from previous invocation that have yet to be processed...
		var compute_list := context.compute_list_begin()
		for i in range(pass_num_cascades_remaining):
			_update(compute_list, i, pass_parameters)
		context.compute_list_end()

	# Update each cascade's parameters that rely on time delta
	for i in len(parameters):
		var params := parameters[i]
		params.time += delta
		# Note: The constants are used to normalize parameters between 0 and 10.
		params.foam_grow_rate = delta * params.foam_amount*7.5
		params.foam_decay_rate = delta * maxf(0.5, 10.0 - params.foam_amount)*1.15

	pass_parameters = parameters
	pass_num_cascades_remaining = len(parameters)

func _notification(what):
	if what == NOTIFICATION_PREDELETE:
		if context: context.free()

# Source: https://wikiwaves.org/Ocean-Wave_Spectra#JONSWAP_Spectrum
static func JONSWAP_alpha(wind_speed:=20.0, fetch_length:=550e3) -> float:
	return 0.076 * pow(wind_speed**2 / (fetch_length*G), 0.22)

# Source: https://wikiwaves.org/Ocean-Wave_Spectra#JONSWAP_Spectrum
static func JONSWAP_peak_angular_frequency(wind_speed:=20.0, fetch_length:=550e3) -> float:
	return 22.0 * pow(G*G / (wind_speed*fetch_length), 1.0/3.0)