Skip to main content

OceanSimulator

The OceanSimulator component generates ocean wave displacement and normal textures using Fast Fourier Transform (FFT) algorithms. It runs entirely on the CPU using Burst-compiled jobs for high performance.

Overview​

OceanSimulator implements a six-stage simulation pipeline:

  1. Spectrum Initialization - Generate initial wave amplitudes from Phillips spectrum
  2. Temporal Evolution - Advance wave phases based on dispersion relationship
  3. Horizontal FFT - Transform rows from frequency to spatial domain
  4. Vertical FFT - Transform columns from frequency to spatial domain
  5. Displacement Finalization - Apply choppiness and sign correction
  6. Surface Gradients - Compute normals and foam from displacement field

The output is two textures updated each frame: a displacement map and a normal/foam map.

Adding to Scene​

  1. Create a GameObject as a child of your ocean system root
  2. Name it OceanSimulator
  3. Add the OceanSimulator component
  4. Assign a biome profile
using UnityEngine;
using PlatypusIdeas.VROcean.Runtime.Scripts.Ocean;
using PlatypusIdeas.VROcean.Runtime.Scripts.Scene;

// Programmatic setup
GameObject simGo = new GameObject("OceanSimulator");
OceanSimulator simulator = simGo.AddComponent<OceanSimulator>();
simulator.Profile = myBiomeProfile;

Inspector Fields​

FieldTypeDescription
ProfileAquaticBiomeProfileSource of wave configuration
Editor Updates EnabledboolAllow simulation in edit mode (may cause instability)
ResolutionOceanResolutionFFT grid resolution

Resolution Options​

ValueGrid SizeTexture SizePerformanceQuality
_16x1616x1616x16FastestLow detail
_32x3232x3232x32Very fastBasic
_64x6464x6464x64FastGood
_128x128128x128128x128ModerateHigh
_256x256256x256256x256SlowVery high
_512x512512x512512x512Very slowMaximum

For VR on Quest, use 64x64 or 128x128. Higher resolutions provide more wave detail but increase CPU cost significantly.

Public Properties​

Profile​

public AquaticBiomeProfile Profile { get; set; }

The biome profile providing wave configuration. Changing this triggers spectrum regeneration.

// Change profile at runtime
simulator.Profile = newProfile;

Resolution​

public int Resolution { get; }

Returns the current grid resolution as an integer.

int res = simulator.Resolution; // e.g., 128
int texelCount = res * res; // e.g., 16384

DisplacementTexture​

public Texture2D DisplacementTexture { get; }

The GPU texture containing XYZ displacement values. Format: RGBAHalf.

ChannelContent
RX displacement (horizontal, choppiness)
GY displacement (vertical, wave height)
BZ displacement (horizontal, choppiness)
AUnused
// Access for custom shader assignment
Material customMaterial = GetComponent<Renderer>().material;
customMaterial.SetTexture("_WaveDisplacement", simulator.DisplacementTexture);

NormalTexture​

public Texture2D NormalTexture { get; }

The GPU texture containing surface normals and foam data. Format: RGBA32 with mipmaps.

ChannelContent
RNormal X (packed, 0-1 range)
GNormal Z (packed, 0-1 range)
BJacobian / Foam (0 = folded, 1 = flat)
ASmoothness

The texture includes generated mipmaps with filtered normals for distance-based roughness.

DisplacementData​

public NativeArray<half4> DisplacementData { get; }

Direct CPU access to displacement values. This is the raw texture data before GPU upload.

using Unity.Collections;
using Unity.Mathematics;

// Access displacement data for custom physics
NativeArray<half4> data = simulator.DisplacementData;

if (data.IsCreated)
{
int index = y * simulator.Resolution + x;
half4 displacement = data[index];

float heightAtTexel = displacement.y;
}

This array is valid after FinalizeFrame() has been called. Use it for CPU-side water queries in custom systems.

Public Methods​

Simulate​

public void Simulate(Vector3 windVector)

Advances the simulation by one frame. Called automatically by SceneSystem, but can be called manually for custom setups.

// Manual simulation control
Vector3 wind = new Vector3(5f, 0f, 3f); // Wind direction and speed
simulator.Simulate(wind);

The method:

  1. Checks if spectrum needs regeneration (profile or wind changed)
  2. Schedules the job pipeline
  3. Does not block - jobs run asynchronously

FinalizeFrame​

public void FinalizeFrame()

Ensures all simulation jobs are complete and textures are uploaded to GPU. Call before rendering.

// Ensure simulation is complete before rendering
simulator.FinalizeFrame();

// Now safe to use textures
Graphics.Blit(simulator.DisplacementTexture, destination);

SceneSystem calls this automatically in BeginContextRendering. Only call manually if not using SceneSystem.

Job Pipeline​

The simulation uses six Burst-compiled jobs scheduled in sequence:

// Internal pipeline (simplified)
JobHandle handle = default;

// Stage 1: Time evolution
handle = new TemporalEvolutionJob(...).Schedule(texelCount, 64, handle);

// Stage 2: Horizontal FFT
handle = new HorizontalSpectralPassJob(...).Schedule(resolution, 1, handle);

// Stage 3: Vertical FFT
handle = new VerticalSpectralPassJob(...).Schedule(resolution, 1, handle);

// Stage 4: Finalize displacement
handle = new DisplacementFinalizationJob(...).Schedule(texelCount, 64, handle);

// Stage 5: Surface gradients
handle = new SurfaceGradientJob(...).Schedule(texelCount, 64, handle);

// Stage 6: Mipmap generation (multiple jobs)
for (int mip = 1; mip < mipCount; mip++)
{
handle = new MipmapFilterJob(...).Schedule(mipTexelCount, 64, handle);
}

JobHandle.ScheduleBatchedJobs();

Jobs are scheduled non-blocking and completed in FinalizeFrame().

Memory Layout​

Native Arrays​

ArrayTypeSizePurpose
_initialAmplitudesfloat4N*NInitial spectrum
_angularFrequencyTablefloatN*NDispersion values
_heightBufferA/Bfloat2N*NFFT ping-pong
_displacementBufferA/Bfloat4N*NFFT ping-pong
_finalDisplacementfloat3N*NOutput displacement
_normalMipChainfloat4[]VariableMipmap chain

Where N = Resolution.

Memory Usage by Resolution​

ResolutionApproximate Memory
64x64~1.5 MB
128x128~6 MB
256x256~24 MB
512x512~96 MB

Memory is allocated persistently on enable and released on disable.

Performance​

CPU Cost by Resolution​

Measured on Quest 3 (single frame):

ResolutionJob TimeRecommended Use
64x64~0.3 msQuest standalone
128x128~0.8 msQuest with headroom
256x256~2.5 msPC VR
512x512~8 msHigh-end PC only

Optimization Tips​

Use appropriate resolution:

// Adjust resolution based on platform
#if UNITY_ANDROID
resolution = OceanResolution._64x64;
#else
resolution = OceanResolution._128x128;
#endif

Avoid redundant spectrum regeneration: The spectrum regenerates when wind parameters change. Avoid rapid profile switching or wind changes.

Profile version tracking: The simulator tracks profile versions to avoid unnecessary regeneration:

// Profile.Version increments on inspector changes
// Simulator only regenerates when version changes

Spectrum Regeneration​

The wave spectrum is regenerated when:

  • Profile reference changes
  • Profile version changes (inspector modification)
  • Wind vector changes (direction or speed)

Regeneration is synchronous and may cause a brief hitch. For smooth transitions:

// Avoid rapid changes
// Bad: Changing wind every frame
void Update()
{
wind = Mathf.Sin(Time.time) * 10f; // Regenerates every frame!
}

// Good: Change wind infrequently
void ChangeWind(float newSpeed)
{
if (Mathf.Abs(newSpeed - currentSpeed) > 1f)
{
currentSpeed = newSpeed;
// Spectrum regenerates once
}
}

Integration Without SceneSystem​

OceanSimulator can be used independently:

using UnityEngine;
using PlatypusIdeas.VROcean.Runtime.Scripts.Ocean;
using PlatypusIdeas.VROcean.Runtime.Scripts.Scene;

public class CustomOceanController : MonoBehaviour
{
[SerializeField] private OceanSimulator simulator;
[SerializeField] private AquaticBiomeProfile profile;

private Vector3 windVector;

void Start()
{
simulator.Profile = profile;
UpdateWindVector();
}

void Update()
{
// Run simulation
simulator.Simulate(windVector);
}

void LateUpdate()
{
// Finalize before rendering
simulator.FinalizeFrame();

// Apply textures to custom materials
Shader.SetGlobalTexture("_OceanDisplacement", simulator.DisplacementTexture);
Shader.SetGlobalTexture("_OceanNormal", simulator.NormalTexture);
}

void UpdateWindVector()
{
float yaw = profile.WindYaw * Mathf.Deg2Rad;
float speed = profile.WaveConfig.WindSpeed;
windVector = new Vector3(
Mathf.Sin(yaw) * speed,
0f,
Mathf.Cos(yaw) * speed
);
}
}

Accessing Raw Data​

For custom physics or effects, access the displacement data directly:

using Unity.Collections;
using Unity.Mathematics;

public class CustomWaterSampler : MonoBehaviour
{
[SerializeField] private OceanSimulator simulator;

public float SampleHeight(float2 uv)
{
if (!simulator.DisplacementData.IsCreated)
return 0f;

int res = simulator.Resolution;
NativeArray<half4> data = simulator.DisplacementData;

// Bilinear sample
float u = math.frac(uv.x) * res;
float v = math.frac(uv.y) * res;

int x0 = (int)u;
int y0 = (int)v;
int x1 = (x0 + 1) % res;
int y1 = (y0 + 1) % res;

float fx = u - x0;
float fy = v - y0;

float4 c00 = (float4)data[y0 * res + x0];
float4 c10 = (float4)data[y0 * res + x1];
float4 c01 = (float4)data[y1 * res + x0];
float4 c11 = (float4)data[y1 * res + x1];

float4 result = math.lerp(
math.lerp(c00, c10, fx),
math.lerp(c01, c11, fx),
fy
);

return result.y; // Height is in Y channel
}
}

Troubleshooting​

Textures Are Black​

  • Verify Profile is assigned
  • Ensure you are in Play Mode
  • Check that Simulate() and FinalizeFrame() are being called

Simulation Stutters​

  • Spectrum is regenerating too often
  • Check for rapid profile or wind changes
  • Reduce resolution for your platform

Memory Errors​

  • Resolution too high for available memory
  • Reduce resolution or ensure proper cleanup in OnDisable

Jobs Not Completing​

  • FinalizeFrame() not called before texture access
  • Ensure proper frame timing between Simulate and Finalize

Next Steps​