simvx.graphics.renderer.ocean_fft

FFT ocean spectrum + inverse transform (design D11, RM-E7).

Dependency-free (numpy only, no Vulkan / no core-node imports) so BOTH the desktop ocean pass (ocean_pass.py) and the future browser twin (Pyodide, where vulkan is unavailable) drive the identical Tessendorf spectrum. One canonical spectrum + inverse-FFT, producing per-cascade displacement, slope and foam fields that the ocean shader (ocean.vert / ocean.frag, later ocean3d.wgsl) samples.

Model (Tessendorf 2001, the film/game-standard FFT ocean):

  • A Phillips spectrum with directional spreading and an against-wind damp seeds a complex, Hermitian-symmetric height spectrum h0(k) per cascade. Each cascade tiles a square patch of patch_length metres at sizexsize resolution, so short cascades add crisp chop and long cascades add slow swell.

  • Per frame the spectrum is evolved to time t with the deep-water dispersion omega = sqrt(g |k|) and inverse-FFT’d to a real height field, horizontal choppy displacement, and surface slope (for the normal).

  • Foam comes from the Jacobian of the horizontal displacement: where the surface folds onto itself (Jacobian below a threshold) foam is deposited and then decays, so crests keep a lace of surf that dissolves in the troughs.

The wind (direction + strength) is taken from the renderer’s FrameGlobals wind slot, exactly like the built-in Gerstner :class:WaterMaterial; the FFT ocean replaces the analytic Gerstner displacement with a sampled displacement map and feeds the derived normal + foam into the same water shading (design D16).

Design D11 specifies a GPU Stockham radix-2 compute FFT. On desktop Vulkan the per-frame hot path (time-evolve the spectrum + the 2D inverse FFT + assemble displacement / slope / Jacobian-foam) runs entirely on the GPU (ocean_compute.py + ocean_spectrum.comp / ocean_fft.comp / ocean_assemble.comp). This module stays the canonical spectrum builder: it constructs the frozen h0 Phillips spectrum + the dispersion / k-vectors once per :meth:configure (uploaded once to the GPU via :meth:gpu_static_spectrum), and keeps a pure-numpy reference of the exact per-frame maths the shaders run (:func:stockham_ifft2 + :meth:gpu_reference) so the GPU FFT is verifiable headlessly against numpy.fft without an interactive GPU debug loop. The legacy CPU :meth:evaluate is retained as the reference transform the unit tests pin against.

Module Contents

Classes

OceanCascade

One FFT cascade: a square sizexsize patch of patch_length m.

OceanFFT

Multi-cascade Tessendorf ocean spectrum + inverse transform.

Functions

cascade_patch_lengths

The first cascades default patch lengths (metres), longest first.

stockham_ifft2

2D inverse FFT via the Stockham butterfly (rows then columns), 1/N^2.

Data

API

simvx.graphics.renderer.ocean_fft.__all__

[‘GRAVITY’, ‘OceanCascade’, ‘OceanFFT’, ‘cascade_patch_lengths’, ‘stockham_ifft2’]

simvx.graphics.renderer.ocean_fft.GRAVITY

9.81

simvx.graphics.renderer.ocean_fft.cascade_patch_lengths(cascades: int) tuple[float, ...][source]

The first cascades default patch lengths (metres), longest first.

simvx.graphics.renderer.ocean_fft.stockham_ifft2(spec: numpy.ndarray) numpy.ndarray[source]

2D inverse FFT via the Stockham butterfly (rows then columns), 1/N^2.

Numerically matches :func:numpy.fft.ifft2 to float round-off; it is the CPU twin of the GPU ocean_fft.comp row-pass + column-pass chain.

class simvx.graphics.renderer.ocean_fft.OceanCascade[source]

One FFT cascade: a square sizexsize patch of patch_length m.

h0 / h0_mk_conj are the time-invariant spectrum (recomputed only when the wind/config changes); the per-cascade foam field accumulates across evaluate calls so surf lingers on crests and fades in troughs.

size: int

None

patch_length: float

None

weight: float

None

kx: numpy.ndarray

‘field(…)’

kz: numpy.ndarray

‘field(…)’

kmag: numpy.ndarray

‘field(…)’

kx_unit: numpy.ndarray

‘field(…)’

kz_unit: numpy.ndarray

‘field(…)’

omega: numpy.ndarray

‘field(…)’

h0: numpy.ndarray

‘field(…)’

h0_mk_conj: numpy.ndarray

‘field(…)’

gain: float

1.0

foam: numpy.ndarray

‘field(…)’

class simvx.graphics.renderer.ocean_fft.OceanFFT(size: int, cascades: int, *, seed: int = 1337)[source]

Multi-cascade Tessendorf ocean spectrum + inverse transform.

Construct once for a given (size, cascades) quality config (design D13 dials), call :meth:configure whenever the wind/appearance changes, then

Meth:

evaluate once per frame to get the sampled surface fields.

Initialization

configure(*, wind_dir: tuple[float, float], wind_speed: float, amplitude: float, direction_spread: float = 1.0) None[source]

(Re)build the time-invariant spectrum for the current wind/appearance.

Cheap no-op when the inputs are unchanged (so a steady scene rebuilds the spectrum once, not every frame). wind_dir is a 2D XZ direction (need not be normalised); wind_speed sets the Phillips fetch length; amplitude is the target RMS wave height (world units) of the biggest cascade at t=0.

evaluate(time: float, *, choppiness: float = 1.0, foam_threshold: float = 0.6, foam_decay: float = 0.92) tuple[numpy.ndarray, numpy.ndarray][source]

Return (disp, grad) float16 arrays for the current frame.

disp is (cascades, size, size, 4) = [Dx, height, Dz, foam]; grad is (cascades, size, size, 4) = [slope_x, slope_z, 0, 0]. Both are contiguous, ready to upload straight into a texture-2D-array (one layer per cascade). Foam accumulates across calls; a repeated time is a no-op that returns the last result (so a double-eval in a pipelined frame does not double-advance the surf).

property patch_lengths: tuple[float, ...][source]
gpu_static_spectrum() numpy.ndarray[source]

The frozen per-cascade spectrum, packed for the GPU compute FFT.

Returns a contiguous (cascades, N, N, 8) float32 array = two vec4 per texel: [h0.re, h0.im, h0_mk_conj.re, h0_mk_conj.im] then [kx, kz, 0, 0]. The per-cascade gain is baked into h0 / h0_mk_conj (so the shader-side hk already carries the target RMS), exactly as :meth:evaluate multiplies self._time_spectrum * gain. kx_unit / kz_unit / omega are re-derived in the shader from (kx, kz) with the identical formulae (dispersion + safe normalise).

gpu_reference(time: float, *, choppiness: float = 1.0, foam_threshold: float = 0.6, foam_decay: float = 0.92) tuple[numpy.ndarray, numpy.ndarray][source]

CPU twin of the GPU compute chain (spectrum -> Stockham IFFT -> assemble).

Mirrors ocean_spectrum.comp (the 2-real-per-complex packing), the ocean_fft.comp Stockham row/column IFFT and ocean_assemble.comp field assembly, using the SAME derived kx_unit / omega the shader computes from the uploaded (kx, kz). Returns float32 (disp, grad) with the same layout as :meth:evaluate; used by the unit tests to pin the shader maths against numpy headlessly (foam accumulation excluded, it is an identical post-step in both paths).