This is the third tutorial in a series about creating the appearance of flowing materials. While the previous two parts deal with animating surface textures, this one is about creating waves by animating vertex positions.

This tutorial is made with Unity 2017.4.4f1.

Sine Waves

Animating textures can create the illusion of a moving surface, but the mesh surface itself remains motionless. This is fine for small ripples, but cannot represent larger waves. On large bodies of water—like an ocean of big lake—the wind can create big waves that can persist for a long time. To represent these wind waves, we'll make new shader that displaces mesh vertices vertically, using a sine wave function.

Adjusting Vertices Create a new surface shader named Waves. We'll leave the fragment surface function unchanged. Instead, add another function vert to adjust the vertex data. This function has a single vertex parameter, both for input and output. We'll use Unity's default vertex data structure, appdata_full . Shader "Custom/Waves" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void vert(inout appdata_full vertexData) {} void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" } To indicate that the surface shader should use the vertex function, add vertex:vert to the surface pragma directive. #pragma surface surf Standard fullforwardshadows vertex:vert Create a new Waves material that uses this shader. I've given it the same albedo and smoothness as our other two materials. Waves material. Because we're going to displaces vertices, we cannot make do with a quad this time. Instead, create a default plane via GameObject / 3D Object / Plane and have it use the Waves material. This gives us a grid of 10×10 quads to work with. Waves plane, with wireframe.

Adjusting Y Ignoring the Z dimension for now, the position of each vertex can be defined as `P = [[x],[y]]`, where `P` is its final position, `x` is the original X coordinate, and `y` is the original Y coordinate, both in object space. To create a wave, we have to adjust the Y component of `P`. The simplest way to make a wave is to use a sine wave based on `x`, so `y = sin x`. The final point is then `P = [[x],[sin x]]`. void vert(inout appdata_full vertexData) { float3 p = vertexData.vertex.xyz; p.y = sin(p.x); vertexData.vertex.xyz = p; } Sine wave. The result is a sine wave along the X dimension, which is constant along the Z dimension. The quads of the plane are of unit size, so the entire plane covers a 10×10 area centered on its local origin. So we end up seeing `10/(2pi)~~1.59` periods of a sine wave.

Amplitude The default amplitude of a sine wave is 1, but we don't need to limit ourselves to that. Let's add a property to our shader so we can use `P_y = a sin x` instead, where `a` is the amplitude. Properties { … _Amplitude ("Amplitude", Float) = 1 } SubShader { … half _Glossiness; half _Metallic; fixed4 _Color; float _Amplitude; void vert(inout appdata_full vertexData) { float3 p = vertexData.vertex.xyz; p.y = _Amplitude * sin(p.x); vertexData.vertex.xyz = p; } … }

Amplitude set to 2.

Wavelength In the case of `sin x`, the length of a full sine wave is `2pi~~6.28`. This is the wavelength and let's make it configurable too. To easily control the wavelength, we first have to multiply `x` by `2pi` then divide by the desired wavelength. So we end up with `sin((2pix)/lambda)`, where `lambda` (lambda) is the wavelength. `2pi` divided by `lambda` is known as the wave number `k=(2pi)/lambda`. We could use this as the shader property, so we don't need to perform a division in the shader. That's a useful optimization, but in this tutorial we'll stick with the more user-friendly wavelength. `lambda` (linear from 0 to 10) and `k`. Inside the shader, we will explicitly use the wave number, so we end up with `P_y=asin(kx)`. Shader "Custom/Waves" { Properties { … _Wavelength ("Wavelength", Float) = 10 } SubShader { … float _Amplitude , _Wavelength ; void vert(inout appdata_full vertexData) { float3 p = vertexData.vertex.xyz; float k = 2 * UNITY_PI / _Wavelength; p.y = _Amplitude * sin( k * p.x); vertexData.vertex.xyz = p; } … }

Wavelength set to 10, amplitude to 1.

Speed The wave needs to move, so we have to define a speed. It is most convenient to use the phase speed `c`, which defines how fast the entire wave moves in units per second. This is done by using the time offset `kct`. To make the wave move in the positive direction, we have to subtract this from `kx`, so we end up with `P_y=sin(kx-kct)=sin(k(x-ct))`. Properties { _Speed ("Speed", Float) = 1 } SubShader { … float _Amplitude, _Wavelength , _Speed ; void vert(inout appdata_full vertexData) { float3 p = vertexData.vertex.xyz; float k = 2 * UNITY_PI / _Wavelength; p.y = _Amplitude * sin(k * ( p.x - _Speed * _Time.y) ); vertexData.vertex.xyz = p; } … }

Speed set to 5.

Normal Vectors Our surface is curved and moving, but the lighting is still that of a motionless flat plane. That's because we haven't changed the vertex normals yet. Instead of directly calculating the normal vector, let's first look at the surface tangent vector in the X dimension, `T`. For a flat surface `T=[[x^'],[0]]=[[1],[0]]`, which corresponds to the original plane's tangent. But for our wave we have to use `T=P^'=[[x^'],[asin(k(x-ct))^']]`. The derivative of the sine is the cosine, so `sin^'x=cosx`. But the argument of the sine is a function itself in our case. We can say that we have `P_y=asinf`, where `f=k(x-ct)`. We have to use the chain rule, `(P_y)^'=f^'acosf`. And `f^'=k`, so we end up with `T=[[1],[kacosf]]`. This makes sense, because changing the wavelength also changes the slope of the wave. To get the final tangent vector in the shader, we have to normalize `T`. float k = 2 * UNITY_PI / _Wavelength; float f = k * (p.x - _Speed * _Time.y); p.y = _Amplitude * sin( f ); float3 tangent = normalize(float3(1, k * _Amplitude * cos(f), 0)); The normal vector is the cross product of both tangent vectors. As our wave is constant in the Z dimension, the binormal is always the unit vector and can be ignored, so we end up with `N=[[-kacosf],[1]]`. We can just grab the normalized tangent components after normalizing them. float3 tangent = normalize(float3(1, k * _Amplitude * cos(f), 0)); float3 normal = float3(-tangent.y, tangent.x, 0); vertexData.vertex.xyz = p; vertexData.normal = normal; Correct normal vectors.

Mesh Resolution While our wave looks fine when using a wavelength of 10, it won't work so well for small wavelengths. For example, a wavelength of 2 produces a standing sawtooth wave. Wavelength 2, speed 1. A wavelength of 1 produces no wave at all, instead the whole plane goes up and down uniformly. Other small wavelengths produce ugly waves that can even move backwards. This problem is causes by the limited resolution of our plane mesh. Because vertices are spaces one unit apart, it cannot deal with wavelengths of 2 or smaller. In general, you have to keep the wavelength greater than twice the edge length of the triangles in your mesh. You don't want to cut it too close, because waves made up of two or three quads don't look good. Either use larger wavelengths, or increase the resolution of your mesh. The simplest approach is to just use another mesh. Here is an alternative plane mesh that consists of 100×100 quads, instead of just 10×10. Each quad is still 1×1 unit, so you'll have to zoom out and multiply the wave properties by 10 to get the same result as before. Big plane, wave settings ×10, zoomed out.