Against the Tide
Description
Against the Tide is a technical demo for a strategy simulation game, developed in Unity. The concept is a colony sim where your objective is to restore a coral reef. It features a grid system where each cell has environmental properties that affect coral growth, such as temperature and acidity. The player places buildings on the grid that change those properties, and therefore the coral growth, and is rewarded for successful reef restoration by unlocking more building types.
Technical highlights
Wave functions and floating game objects
The water shader in Against the Tide uses a simplified version of the Gerstner wave function to displace the vertices of the water surface plane. It combines together a number of the same wave functions to create a more dynamic and natural look than a single wave. I looked at a Fast Fourier transform, but for the stylised appearance I wanted, this simpler method was totally sufficient.
The game features buildings that can float on the ocean surface. To create the appearance of the buildings floating, I replicated the same code used to produce the waves in the shader in a C# script. In this way, I could "sample" the wave displacement at a given world space coordinate and calculate the height of the water at that point.
Below is the WaveManager script, which references the properties of the water shader and replicates the wave function.
Show the WaveManager class
public class WaveManager : Singleton<WaveManager>
{
public GameObject waterSurfacePrefab;
private Material _oceanMaterial;
[SerializeField] private float phase;
[SerializeField] private float gravity;
[SerializeField] private float depth;
[SerializeField] private Vector4 timeScales;
[SerializeField] private float timeScale5;
[SerializeField] private Vector3 forwardBack1;
[SerializeField] private Vector3 forwardBack2;
[SerializeField] private Vector3 leftRight1;
[SerializeField] private Vector3 leftRight2;
[SerializeField] private Vector3 diagonal;
[SerializeField] private float amplitude1;
[SerializeField] private float amplitude2;
[SerializeField] private float amplitude3;
[SerializeField] private float amplitude4;
[SerializeField] private float amplitude5;
private static readonly int Phase = Shader.PropertyToID("_Phase");
private static readonly int Gravity = Shader.PropertyToID("_Gravity");
private static readonly int Depth = Shader.PropertyToID("_Depth_1");
private static readonly int TimeScales = Shader.PropertyToID("_TimeScales");
private static readonly int Time5 = Shader.PropertyToID("_Time_5");
private static readonly int ForwardBack1 = Shader.PropertyToID("_Forward_Back_1");
private static readonly int ForwardBack2 = Shader.PropertyToID("_Forward_Back_2");
private static readonly int LeftRight1 = Shader.PropertyToID("_Left_Right_1");
private static readonly int LeftRight2 = Shader.PropertyToID("_Left_Right_2");
private static readonly int Diagonal1 = Shader.PropertyToID("_Diagonal_1");
private static readonly int AmplitudeFb1 = Shader.PropertyToID("_Amplitude_FB1");
private static readonly int AmplitudeFb2 = Shader.PropertyToID("_Amplitude_FB2");
private static readonly int AmplitudeLr1 = Shader.PropertyToID("_Amplitude_LR1");
private static readonly int AmplitudeLr2 = Shader.PropertyToID("_Amplitude_LR2");
private static readonly int AmplitudeD1 = Shader.PropertyToID("_Amplitude_D1");
[SerializeField] private Vector3 displacement1;
[SerializeField] private Vector3 displacement2;
[SerializeField] private Vector3 displacement3;
[SerializeField] private Vector3 displacement4;
[SerializeField] private Vector3 displacement5;
[SerializeField] private Vector3 newDisplacement;
[SerializeField] private Vector3 newPosition;
private void Start()
{
SetVariables();
}
[Button]
private void SetVariables()
{
if (waterSurfacePrefab.TryGetComponent(out Renderer r))
{
_oceanMaterial = r.sharedMaterial;
}
else
{
Debug.Log("Failed to get water surface material");
return;
}
phase = _oceanMaterial.GetFloat(Phase);
gravity = _oceanMaterial.GetFloat(Gravity);
depth = _oceanMaterial.GetFloat(Depth);
timeScales = _oceanMaterial.GetVector(TimeScales);
timeScale5 = _oceanMaterial.GetFloat(Time5);
forwardBack1 = _oceanMaterial.GetVector(ForwardBack1);
forwardBack2 = _oceanMaterial.GetVector(ForwardBack2);
leftRight1 = _oceanMaterial.GetVector(LeftRight1);
leftRight2 = _oceanMaterial.GetVector(LeftRight2);
diagonal = _oceanMaterial.GetVector(Diagonal1);
amplitude1 = _oceanMaterial.GetFloat(AmplitudeFb1);
amplitude2 = _oceanMaterial.GetFloat(AmplitudeFb2);
amplitude3 = _oceanMaterial.GetFloat(AmplitudeLr1);
amplitude4 = _oceanMaterial.GetFloat(AmplitudeLr2);
amplitude5 = _oceanMaterial.GetFloat(AmplitudeD1);
}
public float GetWaterHeight(Vector3 position)
{
Vector3 waveTop = Displacement(position);
float y = waveTop.y;
return y;
}
private Vector3 Displacement(Vector3 position)
{
displacement1 = Wave(position, forwardBack1, amplitude1, timeScales.x * Time.time);
displacement2 = Wave(position, forwardBack2, amplitude2, timeScales.y * Time.time);
displacement3 = Wave(position, leftRight1, amplitude3, timeScales.z * Time.time);
displacement4 = Wave(position, leftRight2, amplitude4, timeScales.w * 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 below is the FloatingObject class, which makes use of the WaveManager's GetWaterHeight method. It uses an array of "floating points" (transforms) assigned in the inspector to determine where the object interacts with the water. I could capture the water height at each of these points, but I was happy just using the main transform position of the game object at start to establish where to sample the water height. This method isn't designed for moving floating objects, but it could easily be adapted to do so.
For each floating point below the water surface, an upwards force is applied proportional to its depth below the surface.
Show the FloatingObject class
[RequireComponent(typeof(Rigidbody))]
public class FloatingObject : MonoBehaviour
{
public Transform[] floatingPoints;
public float waterSurfaceOffset;
public float underWaterDrag = 3;
public float underWaterAngularDrag = 1;
public float airDrag = 0;
public float airAngularDrag = 0.05f;
public float floatingPower = 15;
private Rigidbody _rigidbody;
private float _waveHeight;
private bool _underWater;
private int _pointsUnderWater;
[ShowInInspector] private Vector3 _startingPosition;
[ShowInInspector] private Vector3 _currentPosition;
private WaveManager _waveManager;
public virtual void Start()
{
_startingPosition = transform.position;
_rigidbody = GetComponent<Rigidbody>();
_waveManager = WaveManager.Instance;
}
private void FixedUpdate()
{
_pointsUnderWater = 0;
_waveHeight = _waveManager.GetWaterHeight(_startingPosition) + waterSurfaceOffset;
for (int i = 0; i < floatingPoints.Length; i++)
{
float difference = floatingPoints[i].position.y - _waveHeight;
// Apply upwards force if the point is underwater
if (difference <= 0)
{
_pointsUnderWater++;
float forceMagnitude = floatingPower * Mathf.Abs(difference);
_rigidbody.AddForceAtPosition(Vector3.up * forceMagnitude, floatingPoints[i].position, ForceMode.Force);
}
}
// Update the drag state if there is a change to the underwater state
bool isNowUnderWater = _pointsUnderWater > 0;
if (isNowUnderWater != _underWater)
{
_underWater = isNowUnderWater;
UpdateDragState(_underWater);
}
}
private void UpdateDragState(bool isUnderwater)
{
if (isUnderwater)
{
_rigidbody.drag = underWaterDrag;
_rigidbody.angularDrag = underWaterAngularDrag;
}
else
{
_rigidbody.drag = airDrag;
_rigidbody.angularDrag = airAngularDrag;
}
}
}