Ocean Shader

Made by Dion Behre - 500746771

For my R&D project I decided to try to create a realistic ocean with the use of shaders. I had not previously worked with shaders, so everything was quite new for me.

To do list:

  • Find references
  • Create a mesh
  • Basic shader
  • Sine waves
  • Better looking waves
  • Light reflection
  • Normal maps

References

Before I could start with my project, I needed to have some idea on how I wanted my ocean to look. I found a few good videos and screenshots of a real ocean, as well as the ocean of the games Sea Of Thieves and Assassins Creed IV: Black Flag. These would be my references for my project.

Creating the mesh

To create the mesh, I used a lot of information from CatlikeCoding.com where they have a tutorial on procedural grids. I had to modify it a little bit to suit my needs.

Vertices

I decided that I did not want to use a standard plane for my ocean shader, so I created my own. I wanted to be able to change the size of the mesh and the distance between the vertices, so I had to program that in my code.

[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class CreateWaterMesh : MonoBehaviour
{
    public int xSize, zSize;
    private Mesh mesh;
    public Vector3[] vertices;
    [Range(0,1)]
    public float distance;
    private void CreateVertices()
    {
        vertices = new Vector3[(xSize + 1) * (zSize +1)];
        for (int i = 0, z = 0 ; z <= zSize; z++)
        {
            for (int x = 0; x <= xSize; x++, i++)
            {
                vertices[i] = new Vector3(x * distance,0 ,z * distance);
            }
        }

        mesh.vertices = vertices;
    }

Quads

Now that I can place the vertices and change the distance between them, it’s time to draw the actual mesh on screen. A mesh consists of triangles, and two triangles make a quad. I wrote some code to create these quads.

 private void CreateQuads()
    {
        int pointsperQuad = 6;
        int[] triangles = new int[xSize * zSize * pointsperQuad];
        int point1 = 1;
        int point2 = 2;
        int point3 = 3;
        int point4 = 4;
        int point5 = 5;

        for (int ti = 0, vi = 0, z = 0; z < zSize ; z++, vi++)
        {
            for (int x = 0; x < xSize; x++, ti += pointsperQuad, vi++)
            {
                triangles[ti] = vi;
                triangles[ti + point3] = triangles[ti + point2] = vi + 1;
                triangles[ti + point4] = triangles[ti + point1] = vi + xSize + 1;
                triangles[ti + point5] = vi + xSize + 2;
            }
        }

        mesh.triangles = triangles;

    }

This is the result:

Basic shader

Now that the mesh was done it was time to start making the shader. The first thing I did was create a new material. I create an Unlit Shader and deleted almost everything in it. To start of I wanted to create a basic unlit shader that just shows a color on the mesh.

I created a Color property and added 2 structs in a CGINCLUDE. I did this so I could use these structs in multiple subshaders if I wanted to in the future. After this I created a subshader to show a color on my mesh.

Sine Wave

From here on I knew a little bit what to do, but not enough to create what I wanted. I decided to look for information about how to create one. I found a YouTube channel by the name of Freya Holmér. On her channel she has a few tutorials about the basics of shader programming. I watched the first video called Shader Basics, Blending & Textures • Shaders for Game Devs [Part 1] (Freya Holmér, 2021) to get started. I changed the names the structs to make them more logical. After the first video everything made a bit more sense.

From the shader workshop I knew how to make a sine wave with shader code, but it wasn’t good enough for me. I couldn’t control anything about the wave. While looking for videos about making ocean water in unity I found a video that did exactly what I wanted to achieve. He started out with explaining everything about sine waves.  The video was called Ocean waves simulation with Fast Fourier transform (Jump Trajectory, 2020). By listening to his explanation, I was able to write my own sine wave that I could control however I wanted. I could change the height of the wave, the speed at which the wave travelled and the length of the wave. I was also able to make the wave 2-dimensional as well.

After watching the video and trying some things I wrote the following code.

Shader "Unlit/Water"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _WaterColor("Water Color", Color) = (0,0,0,1)
        _WaveLength("Wave Length", Range(0,20)) = 1.0
        _WaveSpeed("Wave Speed", Range(1.0,5.0)) = 1.0
        _WaveHeight("Wave amplitude", Range(1.0,5.0)) = 1.0
        _WaveShift("Wave shift", Range(1.0,5.0)) = 1.0

    }
    
    CGINCLUDE
    #include "UnityCG.cginc"
    #pragma vertex vert;
    #pragma fragment frag;
    
        struct MeshData         //Per-vertex mesh data
        {
            float4 position: POSITION;
            float2 uv : TEXCOORD0;
        };
    
        struct Interpolators    //Data that gets passed from vertex shader to fragment shader
        {
            float4 position: SV_POSITION;
            float2 uv : TEXCOORD0;
            float4 color : COLOR;
        };

        sampler2D _Maintex;
        float4 _WaterColor;
        float _WaveLength;
        float _WaveSpeed;
        float _WaveHeight;
        float _WaveShift;
    ENDCG
    
    
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            Interpolators vert(MeshData i)
            {
                Interpolators o;
        
                float3 worldPos = mul(unity_ObjectToWorld, i.position).xyz;
        
                    i.position.y += _WaveHeight * sin(_WaveLength * (worldPos.x + worldPos.z) + _WaveSpeed * _Time.w) + _WaveShift;
        
                o.color = _WaterColor;
                o.uv = i.uv;
                o.position = UnityObjectToClipPos(i.position);
                return o;
            }

            half4 frag(Interpolators v) : SV_TARGET
            {
                return v.color;
            }
            ENDCG
        }
    }
}
Wave Length: 1, Wave Speed: 1, Wave Amplitude: 1, Wave Shift: 1
Wave Length: 0.2, Wave Speed: 3, Wave Amplitude: 5, Wave Shift: 5

Better looking waves

Now I had a decent looking sine wave, but it didn’t look like ocean water yet. The next step for me was transforming this sine wave into a Gerstner wave. With Gerstner waves, the wave doesn’t just move up and down. The vertex points move in a circle. This looks way more realistic than just having a sine wave. To make this happen I had to completely change how I calculate the vertex movement.

The first thing I did was do some research on Gerstner waves. Through the same video Ocean waves simulation with Fast Fourier transform (Jump Trajectory, 2020) I found another YouTube channel called 3Blue1Brown.  I found a video about a formula that I needed to create Gerstner waves called Euler’s Formula. This formula is used to calculate a circle around a certain point. The video from this channel called E to the power of pi explained in 3.14 minutes | DE5 (3Blue1Brown, 2019) explains this concept in detail.

In my formula I also needed to get the dot product of the object’s Wordlposition’s x and z axis and the x and y position that I wanted the wave to go towards. This way I could change the direction of the waves to wherever I wanted them to go. I partially used some of the information on Catlikecoding about waves (CatLikeCoding, z.d.), but that was all written in a Standard shader, so I had made it work in an unlit shade myself. I also reversed the calculation for the Amplitude of the wave. This way The Gerstner wave will not overlap if the value is under 1.

  Properties
    {
        _WaterColor("Water Color", Color) = (0,0,0,1)
        
        _Wave("Wave (dir,length, steepness)", Vector) = (1,0,0.5,10)
        _WaveSpeed("wave speed",float) = 1
        
    }
Pass
        {
            CGPROGRAM
            Interpolators vert(MeshData i)
            {
                Interpolators o;

                float3 worldPos = mul(unity_ObjectToWorld, i.position).xyz;

                float euler = 2 * UNITY_PI;
                float k = euler / _Wave.z;
                float2 d = normalize(_Wave.xy);
                float f = k * (dot(d, worldPos.xz) + _WaveSpeed * _Time.y);
                float a = _Wave.w / k;
        
                i.position.x += d.x * (a * cos(f));
                i.position.y = a * sin(f);
                i.position.z += d.y * (a * cos(f));
        
                o.color = _WaterColor;
                o.position = UnityObjectToClipPos(i.position);
                o.uv = i.uv;

                return o;
            }
              
            half4 frag(Interpolators v) : SV_TARGET
            {
                return v.color;
            }
            ENDCG
        }
x Direction: 0.23,Direction: 0.5, Wave Length: 5, Wave Amplitude: 0.8, Wave Speed: 1

My biggest struggle

From here I wanted to follow the video again because the water shader in that he created was exactly what I wanted to have. Sadly, I didn’t manage to do it. The next step was to implement a formula called the Fast Fourier transform. I tried to understand this formula, but I sadly failed in doing so on time. There were symbols that I had never seen before. Eventually I started to understand the formula itself but had no idea how to use it in shader code. I watched the video multiple times, tried to find more information and more videos about this formula. After 2 weeks I found something that would help me, but I ran out of time and had to give up and find another way.

Multiple Gerstner waves

To create something that would represent ocean water I decided to use multiple Gerstner waves. To do this, I moved all the code for the waves to a function. Now I only had to call the function for the amount of Gerstner waves I wanted.

 Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _WaterColor("Water Color", Color) = (0,0,0,1)
        
        [Header(Wave1)]
        _WaveA("Wave a (dir,length, steepness)", Vector) = (1,0,0.5,10)
        _WaveSpeedA("wave speed a",float) = 1
        
        [Header(Wave2)]
        _WaveB("Wave b (dir,length, steepness)", Vector) = (0,1,0.25,20)
        _WaveSpeedB("wave speed b",float) = 1

        [Header(Wave3)]
        _WaveC("Wave c (dir,length, steepness)", Vector) = (0,1,0.25,20)       
        _WaveSpeedC("wave speed b",float) = 1[Header(Wave3)]
        
        [Header(Wave4)]
        _WaveD("Wave d (dir,length, steepness)", Vector) = (0,1,0.25,20)       
        _WaveSpeedD("wave speed b",float) = 1
    }
        struct Interpolators    //Data that gets passed from vertex shader to fragment shader
        {
            float4 position: SV_POSITION;
            float2 uv : TEXCOORD0;
            float4 color : COLOR;
        };

        float3 GerstnerWave(
                float4 wave, float waveSpeed ,float3 p)
            {
                float steepness = wave.z;
                float waveLength = wave.w;
                float k = 2 * UNITY_PI / waveLength;
                float2 d = normalize(wave.xy);
                float c = sqrt(9.8 / k);
                float euler = k * (dot(d, p.xz) - c / waveSpeed * _Time.w);
                float a = steepness / k;
            
			return float3(
				d.x * (a * cos(euler)),
				a * sin(euler),
				d.y * (a * cos(euler))
			    );
            }
SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            Interpolators vert(MeshData i)
            {
                Interpolators o;

                float3 worldPos = mul(unity_ObjectToWorld, i.position).xyz;

                float3 p = worldPos;
                p += GerstnerWave(_WaveA, _WaveSpeedA, p);
                p += GerstnerWave(_WaveB, _WaveSpeedB, p);
                p += GerstnerWave(_WaveC, _WaveSpeedC ,p);
                p += GerstnerWave(_WaveD, _WaveSpeedD, p);

                i.position.xyz += p;
        
                o.color = _WaterColor;
                o.position = UnityObjectToClipPos(i.position);
                o.uv = i.uv;

                return o;
            }
              
            half4 frag(Interpolators v) : SV_TARGET
            {
                return v.color;
            }
            ENDCG
        }
Wave 1: x Direction: 1, y Direction: 0, Steepness: 0.31, Wave Length: 44, Wave Speed: 2
Wave 2: x Direction: 1, y Direction: 1, Steepness: 0.53, Wave Length: 19, Wave Speed: 4
Wave 3: x Direction: 1, y Direction: -1, Steepness: 0.3, Wave Length: 38, Wave Speed: 3
Wave 4: x Direction: 0, y Direction: -1, Steepness: 0.7, Wave Length: 28, Wave Speed: 3

Lighting

Now that I had the waves I needed to make the mesh look more like water. I had to start with adding lighting to my unlit shader. I didn't know how to do this so I went to the Youtube channel Freya Holmér again and looked at her second video in her shader tutorial series. Here she explained how to write lighting code. The video is called Healthbars, SDFs & Lighting • Shaders for Game Devs [Part 2] (Freya Holmér, 2021).

Directional Light

The first light I made was a directional light. I had to add a NORMAL to the structs. In the vertex shader I set the normal to UnityObjectToWorldNormal. With this I assigned a value N in the fragment shader. I also assigned a value L. This value held the direction of the directional light in the scene. I calculated the dot product of those two values en returned the vector3 of this. The result was exactly what I wanted.

        struct MeshData         //Per-vertex mesh data
        {
            float4 vertex: POSITION;
            float3 normal: NORMAL;
        };
    
        struct Interpolators    //Data that gets passed from vertex shader to fragment shader
        {
            float4 vertex: SV_POSITION;
            float3 wPos : TEXCOORD2;
            float3 normal : TEXCOORD3;
            float4 color : COLOR;
        };
Interpolators vert(MeshData v)
        {
                ...
                o.normal = UnityObjectToWorldNormal(v.normal);
                o.wPos = mul(unity_ObjectToWorld, v.vertex);
                o.vertex = UnityObjectToClipPos(v.vertex);

                return o;
         }
 float4 frag(Interpolators i) : SV_TARGET
            {
                 // Diffuse lighting
                float3 N = i.normal;
                float3 L = _WorldSpaceLightPos0.xyz;    //Actually a direction
                float3 diffuseLight =  saturate(dot(N,L)) * _WaterColor.xyz;

                return float4(diffuseLight.xxx,1);
            }

Specular Light

I also wanted to have some specular lighting in my shader. I separated the lambertion (dot product from the diffuse light) so I could use it for the specular light as well. I also normalized the normal and created a new variable H that holds the normalized direction of the light. I calculated the dot product of these two and multiplied this by the lambert higher that 0. This way the specular light will not be visible from the other side of the object. I added a _Gloss component and used this to calculate the size of the specular light.

 Properties
    {
        _WaterColor("Water Color", Color) = (0,0,0,1)
        _Gloss("Gloss", Range(0,1)) = 1
        ....
    }
float4 frag(Interpolators i) : SV_TARGET
            {
                 // Diffuse lighting
                float3 N = normalize(i.normal);
                float3 lambert = saturate(dot(N,L));
                float3 diffuseLight =  lambert * _WaterColor.xyz;
                ....

                // Specular lighting
                float3 H = normalize(L);
                
                float3 specularLight = saturate(dot(H,N)) * (lambert > 0); // Blinn-phong
                float specularExponent = exp2( _Gloss * 6 + 1);               
                specularLight = pow(specularLight, specularExponent );
                
                return float4(specularLight.xxx ,1);
            }
                

Now it's time to put both the directional en specular light together. I also multiplied the specular light with the color because that color has to bounce back.

 float3 H = normalize(L);
                
                float3 specularLight = saturate(dot(H,N)) * (lambert > 0); // Blinn-phong
                float specularExponent = exp2( _Gloss * 11) + 2;
                specularLight = pow(specularLight, specularExponent );
                specularLight *= _WaterColor.xyz;
                
                return float4(diffuseLight * _WaterColor + specularLight ,1);
            }

Normal maps

Now that I have waves and lighting in my shader the next step is to add normal maps or bump maps. This is needed so the mesh doesn't just look like a flat plane. A normal map is used to simulate height differences, but it dones't actually add them to a mesh. I used a video called Normal Maps, Tangent Space & IBL • Shaders for Game Devs [Part 3] (Freya Holmér, 2021), as well as a tutorial called Rendering 6, Bumpiness (CatLikeCoding, z.d.). I added some properties for the bump maps. The structs also needed some variables for the normal maps. I made the TEXCOORD's. Since I wanted to use bump maps I also needed to add values for the tangents. I also needed a variable for the bitangent. The bitangent is the cross product of the tangent and the normal.

Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Normal1 ("Texture", 2D) = "bump" {}
        _Normal2 ("Texture", 2D) = "bump" {}
        _WaterColor("Water Color", Color) = (0,0,0,1)
        ....
    }
 struct MeshData         //Per-vertex mesh data
        {
            ....
            float4 tangent: TANGENT;
            float2 uv1 : TEXCOORD0;
            float2 uv2 : TEXCOORD1;
        
        };
    
        struct Interpolators    //Data that gets passed from vertex shader to fragment shader
        {
            ....
            float2 uv1 : TEXCOORD0;
            float2 uv2 : TEXCOORD1;
            ....
            float3 tangent : TEXCOORD4;
            float3 bitangent : TEXCOORD5;
            ....
        };

in the vertex shader I set the bump maps as TRANSFORM_TEX. I also set the tangent and calculate the bitangent. In the fragment shader Unpack the bump maps so they are ready to be used. Because I want to use multiple bump maps I needed to add them together. I did this by using the BlendNormals function. I calculate a matrix with the tangent, bitangent, and the normal. Now that I have the matrix and the blended bump maps, I use these instead of the normal.

 Interpolators vert(MeshData v)
            {
                ....
                o.uv1 = TRANSFORM_TEX(v.uv1, _Normal1);
                o.uv2 = TRANSFORM_TEX(v.uv2, _Normal2);

                o.normal = UnityObjectToWorldNormal(v.normal);
                ....
                o.tangent = UnityObjectToWorldDir(v.tangent.xyz);
                ....
                o.bitangent = cross(o.normal, o.tangent);
                o.bitangent *= v.tangent.w * unity_WorldTransformParams.w;
        
                return o;
            }

float4 frag(Interpolators i) : SV_TARGET
            {
                float3 tangentSpaceNormal1 = UnpackNormal(tex2D(_Normal1, (i.uv1 * _NormalControl1.xy) + _Time.y * _NormalControl1.z));
                float3 tangentSpaceNormal2 = UnpackNormal(tex2D(_Normal2, (i.uv2 * _NormalControl2.xy) + _Time.w * _NormalControl2.z));

                float3 blendedTangents = BlendNormals(tangentSpaceNormal1, tangentSpaceNormal2);
                
                float3x3 mtxTangToWorld = {
                    i.tangent.x, i.bitangent.x, i.normal.x,
                    i.tangent.y, i.bitangent.y, i.normal.y,
                    i.tangent.z, i.bitangent.z, i.normal.z,
                };

                 // Diffuse lighting
                float3 N = mul(mtxTangToWorld , blendedTangents);
                ....
                return float4(diffuseLight  * color + specularLight ,1);
            }

This is the result

Now to move the bump maps and add the waves.

Bibliography

  • Freya Holmér. (2021, 26 Februari) Shader Basics, Blending & Textures • Shaders for Game Devs [Part 1] [Video] https://www.youtube.com/watch?v=kfM-yu0iQBk&t=8608s
  • Freya Holmér. (2021, 26 Februari) Healthbars, SDFs & Lighting • Shaders for Game Devs [Part 2] [Video] https://www.youtube.com/watch?v=mL8U8tIiRRg
  • Freya Holmér. (2021, 26 Februari) Normal Maps, Tangent Space & IBL • Shaders for Game Devs [Part 3] [Video] https://www.youtube.com/watch?v=E4PHFnvMzFc&t=6331s
  • Jump Trajectory. (2020, 6 December) Ocean waves simulation with Fast Fourier transform [Video] https://www.youtube.com/watch?v=kGEqaX4Y4bQ&t=535s
  • 3Blue1Brown. (2019, July 7) E to the power of pi explained in 3.14 minutes | DE5 [Video] https://www.youtube.com/watch?v=v0YEaeIClKY
  • 3Blue1Brown. (2018, January 26) But what is the Fourier Transform? A visual introduction. [Video] https://www.youtube.com/watch?v=spUNpyF58BY&t=823s
  • Catlike Coding. (z.d.) Procedural Grid. Consulted on 3 October 2022 from https://catlikecoding.com/unity/tutorials/procedural-grid/
  • Catlike Coding. (z.d.) Waves. Consulted on 3 October 2022 from https://catlikecoding.com/unity/tutorials/flow/waves/
  • CodeMotion. (2017, April 12) An introduction to Realistic Ocean Rendering through FFT - Fabio Suriano - Codemotion Rome 2017 [Video] https://www.youtube.com/watch?v=P4G0hn5QhMs
  • e-maxx-eng. (2022, June 8) Algorithms for Competitive Programming. https://cp-algorithms.com/algebra/fft.html
  • Catlike Coding. (z.d.) Rendering 6, Bumpiness. Consulted on 3 October 2022 https://catlikecoding.com/unity/tutorials/rendering/part-6/
  • Pensionerov, I.P. (z.d.) FFT-Ocean. https://github.com/gasgiant/FFT-Ocean

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts