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.
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; } } }
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.