Calculating waves in C# (to get water height) [Resolved]

I have a vertex displacement shader (in shader graph) that is working adequately to displace a surface to create ocean waves (its a basic Gerstner-like wave function).

I’m now trying to create an identical version of the math within the shader in a script so that I can get the height of the water at any position.

What I can’t make sense of is that it looks to me like the script should generate the same results as the shader but the two quickly diverge.

The Displacement method generates 5 new displacements (Vector3s) based on 5 different sets of parameters grabbed from the material (direction, amplitude and time), adds them together, and adds them to the original position.

If I run the Displacement method and the shader once they both produce the identical result (I’m using a debug node in shader graph to test this). But quickly the script generates Vector3 displacements that the majority of time are positive on the Y axis. And so, according to the script the water height is pretty much constantly rising. Whereas the shader definitely does not behave this way.

Evidently my math isn’t good enough to understanding what is causing this, nor is my understanding of shaders and coding good enough to figure out why the two are producing different results.

I appreciate this is a lot to ask. If anyone has any tips or pointers, that would be very much appreciated.

For reference, here is the script:

private void FixedUpdate()
{
testVector = Displacement(testVector);
}

private Vector3 Displacement(Vector3 position)
{
displacement1 = Wave(position, forwardBack1, amplitude1, timeScales1 * Time.time);
displacement2 = Wave(position, forwardBack2, amplitude2, timeScales2 * Time.time);
displacement3 = Wave(position, leftRight1, amplitude3, timeScales3 * Time.time);
displacement4 = Wave(position, leftRight2, amplitude4, timeScales4 * Time.time);
displacement5 = Wave(position, diagonal, amplitude5, timeScale5 * Time.time);

newDisplacement = ((displacement1 + displacement2) + (displacement3 + displacement4)) + displacement5;
newPosition = newDisplacement + position;

return newPosition;
}

private Vector3 Wave(Vector3 position, Vector3 direction, float amplitude, float time)
{
float theta = Theta(position, direction, time);

// Calculate X
float x = Mathf.Sin(theta) * WaveInput(direction, direction.x, amplitude);
float negateX = -1 * x;

// Calculate Y
float y = Mathf.Cos(theta) * amplitude;

// Calculate Z
float z = Mathf.Sin(theta) * WaveInput(direction, direction.z, amplitude);
float negateZ = -1 * z;

return new Vector3(negateX, y, negateZ);
}

private float WaveInput(Vector3 direction, float axis, float amplitude)
{
double d = amplitude / Math.Tanh(direction.magnitude * depth);
float dToFloat = Convert.ToSingle(d);
float input = (axis / direction.magnitude) * dToFloat;
return input;
}

private float Theta(Vector3 position, Vector3 direction, float time)
{
float x = (direction.x * position.x) + (direction.z * position.z);
float theta = (x - (Frequency(direction) * time)) - phase;
return theta;
}

private float Frequency(Vector3 v)
{
float vectorLength = v.magnitude;
float x = Convert.ToSingle((gravity * vectorLength) * Math.Tanh(vectorLength * depth));
float frequency = Mathf.Sqrt(x);
return frequency;
}

And the shader
As input, the shader uses:

  • the absolute world position of the vertices,
  • 5 different “directions” (Vector3s),
  • 5 different “amplitudes” (floats)
  • 5 different “time” values multiplied by Time

It adds together 5 “Waves” generated by the Wave subshader and adds that to the position. Then converts the new absolute world position back to object position.

The Wave (and WaveInput) Subshader

Theta


Frequency

You could do the math on CPU and use it on both.
You could use a Compute Shader to do the math on the GPU and get the data back to the CPU (faster)

Best to avoid doing the same calculations on CPU and GPU. Things are a little different on one another.

I disagree with this.

First: you absolutely cannot do the calculation on the CPU and use it on the GPU for waves. The fact is that the GPU is going to be calculating wave height for every vertex in the mesh. It would not be efficient to do that much calculation on the CPU.

The CPU will only need to do the calculation for specific points of interest you care about (for example the position of individual boats on the water). This is manageable for the CPU.

As for doing the calculation on the GPU and transferring it back to the CPU, that will most likely be much, much slower than simply repeating the calculation as needed for specific points of interest. It will also require you to write a compute shader. Furthermore there won’t be a good way to request wave heights for specific positions, so you will need to get the entire wave height array or whatever, which is way more data than you need.

Therefore I advocate for duplicating the calculation as needed. That being said I’m not sure exactly where your calculations are diverging. I’d check on things like making sure you’re using radians for trig functions etc.

3 Likes

Thanks for the input!
Ideally, I’d like to stick with the existing approach.

I had the same thought re trig functions and I think I’ve accounted for it. (My understanding is Mathf.cos and Mathf.sin and the Cos and Sin shader nodes all take radians as their input float. And (i believe) what I’m passing into them is identical in the shader and the script).

I feel like I am overlooking something dumb and I can’t see the forest for the trees here.
Maybe something stupid like misunderstanding the output of the Position node.

Yes. I did something similar for log polar grid. Though CPU does need only a portion of the math, and as you said it rarely needs to do more than a couple of points at a time. Whereas the shader is chewing through the entire screen the whole time. My case was slightly specific, but that’s a good advice in general.

1 Like

This is just a guess but are you sure you have to update the position in a feedback loop feeding in the wave position from previous step? From what I understand the vertex shader always gets the base position from original mesh as input instead of modified position from previous frame.

So try

private void FixedUpdate()
{
    waveTop = Displacement(input); // where input comes from mouse, keyboard or whatever your logic but don't assign result back to input
}
// instead of
private void FixedUpdate()
{
     testVector = Displacement(testVector); // next iteration will get modified testVector instead of x/z position at base level
}

If the code is really meant to update position in a feedback loop, I would suggest looking for a different wave formula. In theory integral of sin(x) is still somekind of sinusoidal with some offsets and should remain in fixed range. But in practice due to floating point precision limits and also potential minor variations in timestep, I wouldn’t be surprised if it drifts over time. Especially when evaluating on two different kind of floating point units (GPU and CPU).

2 Likes

Yes! Thank you karliss_codwild!

I had tried:

        private void FixedUpdate()
        {
            testVector += Displacement(startingPos);
        }

And this didn’t seem to work.
But following your input I got the floating object to ask this script for the wave height based on the object’s starting position and this works exactly as I had hoped. Thank you!

1 Like

You are right about that, but that’s easily solvable by introducing time as a shader parameter. CPU should handle time and drive the shader, I think that’s the easiest and most reliable setup.

2 Likes