Water wakes in Unity with C#

Part 1. Add a water mesh

We have in previous tutorial sections used planes as water surfaces, but they will not be good enough in this section. The problem is that we can't control the resolution of the plane, so we can't change the distance between each vertice in the plane. So first of all we need a water surface that is much more customizable.

Create a new scene, add a plane to it, and rename the plane GameObject to Water. We will later replace this plane with our own plane. Then remove the mesh collider and replace it with a box collider. We are going to interact with the water with the mouse, so we will need a collider. (You can remove the collider if you don't need to fire a raycast against the water.) When we have created the new water mesh we have to reposition the collider so it covers the new mesh. So create a new script called WaterWakesTutorial, add it to the Water GameObject, and add the following code to it.

//Mesh parameters
Mesh waterMesh;
MeshFilter waterMeshFilter;

//The total size in m
float waterWidth = 3f;
//Width of one square (= distance between vertices)
float gridSpacing = 0.1f;


void Start() {
	//Need reference to the meshfilter so we can add the water
	waterMeshFilter = this.GetComponent<MeshFilter>();

	//Create the water mesh
	//Don't forget to write "using System.Collections.Generic;" at the top
	List<Vector3[]> height_tmp = GenerateWaterMesh.GenerateWater(waterMeshFilter, waterWidth, gridSpacing);

	//Get a reference to the watermesh
	waterMesh = waterMeshFilter.mesh;
	
	//Resize box collider
	//Need a box collider so the mouse can interact with the water
	BoxCollider boxCollider = this.GetComponent<BoxCollider>();
	
	boxCollider.center = new Vector3(waterWidth/2f, 0f, waterWidth/2f);
	boxCollider.size = new Vector3(waterWidth, 0.1f, waterWidth);
	
	//Center the mesh to make it easier to know where it is
	transform.position = new Vector3(-waterWidth/2f, 0f, -waterWidth/2f);
}

Now we need the script that will create the new customizable water mesh. So create a script called GenerateWaterMesh. If you have read the previous sections of this tutorial then you will have an idea of how this script is working. It is basically first figuring out how many vertices we need based on the size of the water. Then it is building the triangles needed and adds them to an array. It also uses a second array with the coordinates of each vertice in Vector3 format. This is the array we need so we can interact with the water surface. Add the following code to GenerateWaterMesh.

public static List<Vector3[]> GenerateWater(MeshFilter waterMeshFilter, float size, float spacing) {
	//Determine the number of vertices per row/column (is always a square)
	int totalVertices = (int)Mathf.Round(size / spacing) + 1;
	
	//Vertices 2d list
	List<Vector3[]> vertices2dArray = new List<Vector3[]>();
	//Triangles
	List<int> tris = new List<int>();
	
	for (int z = 0; z < totalVertices; z++) {
		vertices2dArray.Add(new Vector3[totalVertices]);
		
		for (int x = 0; x < totalVertices; x++) {
			//Fill this array every loop
			Vector3 current_point = new Vector3();
			
			//Get the corrdinates of the vertice
			current_point.x = x * spacing;
			current_point.z = z * spacing;
			current_point.y = 0f; //assume always at 0
			
			vertices2dArray[z][x] = current_point;
			
			//Don't generate a triangle the first coordinate on each row
			if (x <= 0 || z <= 0) {
				continue;
			}
			
			//Build the triangles
			//Each square consists of 2 triangles
			
			//The triangle south-west of the vertice
			tris.Add(x 		+ z * totalVertices);
			tris.Add(x 		+ (z-1) * totalVertices);
			tris.Add((x-1) 	+ (z-1) * totalVertices);
			
			//The triangle west-south of the vertice
			tris.Add(x 		+ z * totalVertices);
			tris.Add((x-1) 	+ (z-1) * totalVertices);
			tris.Add((x-1)	+ z * totalVertices);
		}
	}
	
	//Unfold the 2d array of verticies into a 1d array.
	Vector3[] unfolded_verts = new Vector3[totalVertices*totalVertices];
	for (int i = 0; i < vertices2dArray.Count; i++) {
		//Copies all the elements of the current array to the specified array
		vertices2dArray[i].CopyTo(unfolded_verts, i * totalVertices);
	}
	
	//Generate the mesh
	Mesh waterMesh = new Mesh();
	waterMesh.vertices = unfolded_verts;
	waterMesh.triangles = tris.ToArray();
	//Ensure the bounding volume is correct
	waterMesh.RecalculateBounds();
	//Update the normals to reflect the change
	waterMesh.RecalculateNormals();
	waterMesh.name = "WaterMesh";
	
	
	//Add the generated mesh to the GameObject
	waterMeshFilter.mesh.Clear();
	waterMeshFilter.mesh = waterMesh;
	
	//Return the 2d array
	return vertices2dArray;
}

If you now press the play button, and are in Scene view, you should see a much more detailed plane. Also add a blue color to it.

Part 2. Apply the iWave algorithm

Now we can implement the iWave algorithm. Step 1 in that algorithm is to precompute the kernel values and store them in a lookup table, which is here called storedKernelArray. So add the following to the beginning of WaterWakesTutorial script:

//Water wakes parameters
//Velocity damping term
//Useful to help suppress numerical instabilities that can arise
public float alpha = 0.9f;
//P - kernel size
//6 is the smallest value that gives water-like motion
int P = 8;
//Should be neg or the waves will move in the wrong direction
float g = -9.81f;

//Store the precomputed kernel values here
float[,] storedKernelArray;

...and to the start method:

//Precompute the convolution kernel values
storedKernelArray = new float[P * 2 + 1, P * 2 + 1];

PrecomputeKernelValues();

...and the methods that will precompute the kernel values:

//Precompute the kernel values G(k,l)
void PrecomputeKernelValues() {
	float G_zero = CalculateG_zero();
	
	//P - kernel size
	for (int k = -P; k <= P; k++) {
		for (int l = -P; l <= P; l++) {
			//Need "+ P" because we iterate from -P and not 0, which is how they are stored in the array
			storedKernelArray[k + P, l + P] = CalculateG((float)k,(float)l, G_zero);
		}
	} 
}


//G(k,l)
private float CalculateG(float k, float l, float G_zero) {
	float delta_q = 0.001f;
	float sigma = 1f;
	float r = Mathf.Sqrt(k*k + l*l);
	
	float G = 0f;
	for (int n = 1; n <= 10000; n++) {
		float q_n = ((float)n * delta_q);
		float q_n_square = q_n * q_n; 
		
		G += q_n_square * Mathf.Exp(-sigma * q_n_square) * BesselFunction(q_n * r);
	}
	
	G /= G_zero;
	
	return G;
}


//G_zero
private float CalculateG_zero() {		
	float delta_q = 0.001f;
	float sigma = 1f;
	
	float G_zero = 0f;
	for (int n = 1; n <= 10000; n++) {
		float q_n_square = ((float)n * delta_q) * ((float)n * delta_q);
		
		G_zero += q_n_square * Mathf.Exp(-sigma * q_n_square);	
	}
	
	return G_zero;
}


private float BesselFunction(float x) {
	//From http://people.math.sfu.ca/~cbm/aands/frameindex.htm
	//page 369 - 370
	
	float J_zero_of_X = 0f;
	
	//Test to see if the bessel functions are valid
	//Has to be above -3
	if (x <= -3f) {
		Debug.Log("smaller");
	}
	
	
	//9.4.1
	//-3 <= x <= 3
	if (x <= 3f) {
		//Ignored the small rest term at the end
		J_zero_of_X = 
			1f - 
				2.2499997f * Mathf.Pow (x / 3f, 2f) + 
				1.2656208f * Mathf.Pow (x / 3f, 4f) -
				0.3163866f * Mathf.Pow (x / 3f, 6f) +
				0.0444479f * Mathf.Pow (x / 3f, 8f) - 
				0.0039444f * Mathf.Pow (x / 3f, 10f)+
				0.0002100f * Mathf.Pow (x / 3f, 12f);
	}
	//9.4.3
	//3 <= x <= infinity
	else {
		//Ignored the small rest term at the end
		float f_zero = 
			0.79788456f -
				0.00000077f * Mathf.Pow (3f / x, 1f) -
				0.00552740f * Mathf.Pow (3f / x, 2f) -
				0.00009512f * Mathf.Pow (3f / x, 3f) -
				0.00137237f * Mathf.Pow (3f / x, 4f) -
				0.00072805f * Mathf.Pow (3f / x, 5f) + 
				0.00014476f * Mathf.Pow (3f / x, 6f);
		
		//Ignored the small rest term at the end
		float theta_zero = 
			x -
				0.78539816f - 
				0.04166397f * Mathf.Pow (3f / x, 1f) -
				0.00003954f * Mathf.Pow (3f / x, 2f) -
				0.00262573f * Mathf.Pow (3f / x, 3f) -
				0.00054125f * Mathf.Pow (3f / x, 4f) -
				0.00029333f * Mathf.Pow (3f / x, 5f) + 
				0.00013558f * Mathf.Pow (3f / x, 6f);
		
		//Should be cos and not acos
		J_zero_of_X = Mathf.Pow(x, -1f/3f) * f_zero * Mathf.Cos(theta_zero);
	}
	
	return J_zero_of_X;
}

Now we can implement the iWave algorithm itself by following the pseudo-code in the report. So add the following to the beginning of WaterWakesTutorial script:

//Used in the iWave loop
Vector3[][] height;
Vector3[][] previousHeight;
Vector3[][] verticalDerivative;
public Vector3[][] source;
public Vector3[][] obstruction;
//To update the mesh we need a 1d array
public Vector3[] unfolded_verts;
//To be able to add ambient waves
public Vector3[][] heightDifference;
//Faster to calculate this once
int arrayLength;

float updateTimer = 0f;

...and the following to the start method:

//Init the arrays we need
//These are now filled with heights at 0
height = height_tmp.ToArray();
//Need to clone these
previousHeight 		= CloneList(height);
verticalDerivative 	= CloneList(height);
source				= CloneList(height);
obstruction 		= CloneList(height);
heightDifference 	= CloneList(height);

//Create this once here, so we dont need to create it each update
unfolded_verts = new Vector3[height.Length * height.Length];

arrayLength = height.Length;

We need to clone the arrays with the following methods, so they are not the same, because that's how c# is working. Just add this method to somewhere in the WaterWakesTutorial script:

//Clone an array and the inner array
Vector3[][] CloneList(Vector3[][] arrayToClone) {
	//First clone the outer array
	Vector3[][] newArray = arrayToClone.Clone() as Vector3[][];

	//Then clone the inner arrays
	for (int i = 0; i < newArray.Length; i++) {
		newArray[i] = newArray[i].Clone() as Vector3[];
	}
	
	return newArray;
}

The iWave algorithm is highly dependent on the time parameter in the algorithm. Sometimes when the time parameter is too high, the algorithm will be unstable and produce gigantic waves. We can adjust these gigantic waves with the velocity damping term called alpha, but only if time is constant. So in the update method we have to make sure that time is a constant. If you choose a value other than 0.02f then you might have to change alpha. Add the following to the WaterWakesTutorial script:

void Update() {
	//Move water wakes
	updateTimer += Time.deltaTime;
	
	if (updateTimer > 0.02f) {
		MoveWater(0.02f);
		updateTimer = 0f;
	}
}

//Add water wakes to the water mesh
void MoveWater(float dt) {
	//This will update height[j][i]
	AddWaterWakes(dt);


	//Update the mesh with the new heights
	//Unfold the 2d array of verticies into a 1d array
	for (int i = 0; i < arrayLength; i++) {
		//Copies all the elements of the current array to the specified array
		heightDifference[i].CopyTo(unfolded_verts, i * heightDifference.Length);
	}
	
	//Add the new position of the water to the water mesh
	waterMesh.vertices = unfolded_verts;
	//Ensure the bounding volume is correct
	waterMesh.RecalculateBounds();
	//After modifying the vertices it is often useful to update the normals to reflect the change
	waterMesh.RecalculateNormals();
}

Finally we can add the heart of the iWave algorithm. This method is similar to the pseudo code in the report:

//Add water wakes
void AddWaterWakes(float dt) {
	//If strange gigantic waves happens, adjust alpha
	
	//Add sources and obstructions
	for (int j = 0; j < arrayLength; j++) {
		for (int i = 0; i < arrayLength; i++) {
			//Add height from source
			height[j][i].y += source[j][i].y;
			
			//Clear the source
			source[j][i].y = 0f;
			
			//Add obstruction
			height[j][i].y *= obstruction[j][i].y;
		}
	}
	
	
	//Convolve to update verticalDerivative
	Convolve();
	
	//Same for all
	float twoMinusAlphaTimesDt = 2f - alpha * dt;
	float onePlusAlphaTimesDt = 1f + alpha * dt;
	float gravityTimesDtTimesDt = g * dt * dt;
	
	for (int j = 0; j < arrayLength; j++) {
		for (int i = 0; i < arrayLength; i++) {
			//Faster to do this once
			float currentHeight = height[j][i].y;
			
			//Calculate the new height
			float newHeight = 0f;
			
			newHeight += currentHeight * twoMinusAlphaTimesDt;
			newHeight -= previousHeight[j][i].y; 
			newHeight -= gravityTimesDtTimesDt * verticalDerivative[j][i].y;
			newHeight /= onePlusAlphaTimesDt;
			
			//Save the old height
			previousHeight[j][i].y = currentHeight;
			
			//Add the new height
			height[j][i].y = newHeight;
			
			//If we have ambient waves we can add them here
			//Just replace this with a call to a method where you find the current height of the ambient wave
			//At the current coordinate
			float heightAmbientWave = 0f;
			
			heightDifference[j][i].y = heightAmbientWave + newHeight;
		}
	}
}

If we are going to use obstacles, we have to set the obstacles to default values in the Start method:

//Add obstruction when the wave hits the walls
for (int j = 0; j < arrayLength; j++) {
	for (int i = 0; i < arrayLength; i++) {
		if (j == 0 || j == arrayLength - 1 || i == 0 || i == arrayLength - 1) {
			obstruction[j][i].y = 0f;
		}
		else {
			obstruction[j][i].y = 1f;
		}
	}
}

... and finally the "Convolve height with the kernel and put it into vertical_derivative." I'm going to try to explain what's going on here. What we will do now is to loop through all the vertices in a square with the dimension (-P,P) around the current vertice. This is similar to what Photoshop is doing when you for example sharpen an image, as explained here: Kernel (image processing).

But we also have to take into account what will happen if a section of this square is outside of the water mesh. I've currently ignored the corner values for what happens if we are outside of the water mesh. I don't think they are that important here, but feel free to add them if you want. This is also the "slow" version of this part of the algorithm. The report describes the faster algorithm, but I decided to use the slower version to make it easier to follow the report.

//Convolve to update verticalDerivative
//This might seem unnecessary, but we will save one array if we are doing it before the main loop
void Convolve() {
	for (int j = 0; j < arrayLength; j++) {
		for (int i = 0; i < arrayLength; i++) {
			float vDeriv = 0f;

			//Will include when k, l = 0
			for (int k = -P; k <= P; k++) {
				for (int l = -P; l <= P; l++) {
					//Get the precomputed values
					//Need "+ P" because we iterate from -P and not 0, which is how they are stored in the array
					float kernelValue = storedKernelArray[k + P, l + P];

					//Make sure we are within the water
					if (j+k >= 0 && j+k < arrayLength && i+l >= 0 && i+l < arrayLength) {
						vDeriv += kernelValue * height[j+k][i+l].y;
					}
					//Outside
					else {
						//Right
						if (j+k >= arrayLength && i+l >= 0 && i+l < arrayLength) {
							vDeriv += kernelValue * height[2 * arrayLength - j - k - 1][i+l].y;
						}
						//Top
						else if (i+l >= arrayLength && j+k >= 0 && j+k < arrayLength) {
							vDeriv += kernelValue * height[j+k][2 * arrayLength - i - l - 1].y;
						}
						//Left
						else if (j+k < 0 && i+l >= 0 && i+l < arrayLength) {
							vDeriv += kernelValue * height[-j-k][i+l].y;
						}
						//Bottom
						else if (i+l < 0 && j+k >= 0 && j+k < arrayLength) {
							vDeriv += kernelValue * height[j+k][-i-l].y;
						}
					}
				}
			}

			verticalDerivative[j][i].y = vDeriv;
		}
	}
}

Part 3. Interact with the water surface

If you press play, you will notice that absolutely nothing happens with the water. Let's change that! We will add a method that will interact with the water if you press on the water with your mouse. This is simple. Just call the method CreateWaterWakesWithMouse() each time in the Update loop.

//Interact with the water wakes by clicking with the mouse
void CreateWaterWakesWithMouse() {
	//Fire ray from the current mouse position
	if (Input.GetKey(KeyCode.Mouse0)) {
		RaycastHit hit;
		if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit)) {
			
			//Convert the mouse position from global to local
			Vector3 localPos = transform.InverseTransformPoint(hit.point);

			//Loop through all the vertices of the water mesh
			for (int j = 0; j < arrayLength; j++) {
				for (int i = 0; i < arrayLength; i++) {
					//Find the closest vertice within a certain distance from the mouse
					float sqrDistanceToVertice = (height[j][i] - localPos).sqrMagnitude;
					
					//If the vertice is within a certain range
					float sqrDistance = 0.2f * 0.2f;
					if (sqrDistanceToVertice < sqrDistance) {
						//Get a smaller value the greater the distance is to make it look better
						float distanceCompensator = 1 - (sqrDistanceToVertice / sqrDistance);
						
						//Add the force that now depends on how far the vertice is from the mouse
						source[j][i].y += -0.02f * distanceCompensator;
					}
				}
			}
		}
	}
}

That's it! If you press play and click with your mouse somewhere on the water surface, you will create water wakes.