1616 words
8 minutes
The Reapture - Rivers

River Generation#

I wanted to have rivers in my game, but I didn’t want to model them all (which comes with issues and tedium pertaining to the UV’s and vertex colours).

My solution was to instead make a mesh generator that handles all the UV’s, vertex colours, and all around mesh generation.

Mesh Generation#

The starting point was the Spline Container.
This component lets you create splines within the editor, and contains the Spline object.
Using Spline.Evaluate we can get the point’s: position, tangent, and up vector.

spline

Spline-BeforeSpline-After
BeforeAfter

I will eventually create the logic to carve out the terrain, but that will be a biiig project as I would like to make it somehow work outside of the terrain.
i.e. when you make changes to the spline water it won’t keep the old carvings, and when you edit the terrain it doesn’t get in the way of the spline water.
Lots to think about… potentially even a custom Terrain script 😵‍💫😭.

Quad Generation#

quadgenquadgen-example
The order of verticesExample of the generated mesh

The order of the vertices don’t matter, as long as they’re clockwise (and make a triangle ofc).

SplineWater.cs
private void AddQuad(float x, float y)
{
// Addition of vertices matter!
// Make sure it's clockwise!
// Triangle 1
AddTriVertex(x + 1f, y); // C
AddTriVertex(x, y + 1f); // B
AddTriVertex(x, y); // A
// Triangle 2
AddTriVertex(x + 1f, y); // C
AddTriVertex(x + 1f, y + 1f); // D
AddTriVertex(x, y + 1f); // B
// That concludes our quad
}

Tri Generation#

Handles all of the data creation.
I will be splitting the UV and Color so they can be updated separately from the mesh.

SplineWater.cs
private void AddTriVertex(float x, float y)
{
35 collapsed lines
_spline.Evaluate((float)y / _numberOfVerticesAlongSpline, out float3 position, out float3 tangent, out float3 upVector);
position = transform.InverseTransformPoint(position); // We need to get it in local space
Quaternion rotation = Quaternion.LookRotation(tangent, upVector); // Get the rotation of the point
Vector3 offset = rotation * Vector3.left * (x * (width / _numberOfVerticesPerWidth)); // Use the rotation to set the width position
Vector3 pos = (Vector3)position + offset; // Final position
float scaling = 1f; // TODO scaling based on steepness, this will make waterfalls move faster and stretch
// If the vertex exists, then assign the triangle point to the vertex's index
if (vertices.Contains(pos))
{
triangles.Add(vertices.IndexOf(pos));
}
// Add the data to all the channels
else
{
triangles.Add(vertices.Count);
vertices.Add(pos);
// Scale UV
Vector4 UV = new Vector4(x, y);
UV.x *= (width / _numberOfVerticesPerWidth); // World space width
UV.y /= (_numberOfVerticesAlongSpline); // Normalise to 0-1
float length = _spline.Spline.GetLength() * scaling; // World space length * scaling
UV.y *= length;
// Reverse the UV...
if (reverseUV)
{
UV.x = -UV.x;
UV.y = length - UV.y;
}
uv.Add(UV);
}
}

Auto Generation#

One issue I had was trying to have the mesh auto generate when you change the spline or the SplineWater parameters.

First I tried using OnValidate, but I got frustrated wondering why my list’s weren’t saving; as it turns out OnValidate is not meant to be used to create stuff 💀.

“Don’t use it to do other tasks such as creating objects or calling other non-thread-safe Unity API”
- Unity Documentation

This was solved by making a CustomEditor class specific for SplineWater.

WARNING

I also serialised the list, and I think that was more necessary than doing it through the editor, but the editor is just a better way of doing it

SplineWaterInspector.cs
public override void OnInspectorGUI()
{
SplineWater splineWater = target as SplineWater;
EditorGUI.BeginChangeCheck();
base.OnInspectorGUI();
if (EditorGUI.EndChangeCheck())
{
EditorGUI.BeginChangeCheck();
splineWater.GenerateLODs();
splineWater.Spline.changed -= OnSplineChanged;
splineWater.Spline.changed += OnSplineChanged;
if (EditorGUI.EndChangeCheck())
{
EditorUtility.SetDirty(splineWater);
EditorSceneManager.MarkSceneDirty(splineWater.gameObject.scene);
}
}
}

This checks to see if any edits were made, and then generate the new meshes if there was.
We need to set it dirty to make sure it can be saved. I’m not sure if it’s really necessary here, but I haven’t tried without it.

There is also another section that checks if the water has been generated, and if not it will generate when the editor opens.
Getting it to generate real-time is a must for me, but right now the actual generation takes quite a while (for long rivers).
I want to eventually optimise it by splitting them into submeshes, but I’m unsure of how I’m going to tackle that atm.
Splitting will also help with optimisation as I can have the LOD’s be per spline segment, rather than the whole river (as long rivers will pretty much be at max detail at all times).

NOTE

Due to this project potentially being released, I don’t really want to give out all the code. This is more of a reference than a tutorial.
I might potentially publish my shaders and scripts at a later date, but it will be a while after release.

Vertex Colour and UV.zw#

I have space for the vertex colour and the UV.zw values for more data.
I intend to use the extra UV parameters for speed and scale.

I don’t know what else the vertex colour could be used for, but I’m thinking it could be a literal colour so I can have a dynamic colour along the length of the river.

Closed Spline#

This was actually really simple. I just added a modulo before adding to the triangle data lists.
I put this part off as I thought it would be quite difficult, but when it came around to tackling this problem my first thought was “I wonder if a modulo would work”, and I’m glad I tried that first as it worked straight out of the gate!

SplineWater.cs
float scaling = 1f; // TODO scaling based on steepness, this will make waterfalls move faster and stretch
// Merge tri if closed
if (_spline.Spline.Closed)
{
y = y % _numberOfVerticesAlongSpline;
}
// If the vertex exists, then assign the triangle point to the vertex's index
if (vertices.Contains(pos))
{
triangles.Add(vertices.IndexOf(pos));
}

There is one issue though, the smoothing of the UV’s (between the start and end) only works on large quad sizes, lower sizes will make the abrupt difference very apparent.
In the future I will need to grab data from the material to adjust it so that the Gerstner Waves loop properly.
Right now though, I will use rocks to hide the changes.

World Depth#

I followed this post https://ameye.dev/notes/stylized-water-shader/ to learn some water shading methods, and I’m happy I did some research to find this resource as they have the BEST depth logic I’ve ever seen for Unity.

DepthFade-Example
Look how nice it looks!

There is an issue with non-y-flat surfaces, but I’ll go through that in the next section.

DepthFade-BadNormals
Any steep angle above the camera view has this awful artefact that is way too harsh and would be very distracting, so I needed to fix it.
(Any surface above the camera will have this issue, but it’s only visible when it’s angled)

World Depth Normal Fix#

Their Depth Fade node uses a more sophisticated method than just the scene depth; however, the logic was flawed as it only supported flat surfaces on the y plane.
In order to get it working at steep angles I used this Quaternion shader functions that I found on Github. Thanks Mattatz!

Simply by rotating the… position?

Tbh I kinda js placed this rotation fix after each position node to find which one it needed to be in, I don’t really understand this shader 💀.

Inside the Depth Fade node, add the nodes in the Normal Fix area.

DepthFade-Subgraph

In order to get the quaternion functions to work in ShaderGraph, we need to make proper functions for them.
You can place the following functions underneath the original from_to_rotation function.

Quaternion.hlsl
void from_to_rotation_float(float3 v1, float3 v2, out float4 o)
{
o = from_to_rotation(v1, v2);
}
void from_to_rotation_half(float3 v1, float3 v2, out half4 o)
{
o = from_to_rotation(v1, v2);
}

And the following underneath the rotate_vector function (the function with float4 r, not float3 r !!!).

Quaternion.hlsl
void rotate_vector_float(float3 v, float4 r, out float3 o)
{
o = rotate_vector(v, r);
}
void rotate_vector_half(float3 v, float4 r, out half3 o)
{
o = rotate_vector(v, r);
}

In order to use the functions within ShaderGraph, you need the Custom Function node.
First add a Custom Function node, then match the following values:

from-to-rotation-noderotate-vector-node

After all this, you can see the labour of our work below!

DepthFade-BadNormalsDepthFade-FixedNormals
Before fixAfter fix

Refraction Fix#

NOTE

You may have to zoom in to see the difference on smaller devices

fpsgunwarpingfpsgunwarpingfix
fpsgunwarping-zoomfpsgunwarpingfix-zoom
Without the fix you can see warping on the arm, even though it’s out of the water.Now anything out of the water will not refract, and I haven’t found any issues yet so this screen-space solution might work properly!

I needed a function to get the world position based on the depth texture for sampling the HAZE fog volume asset I bought.
This was because I wanted to get the fog colour underneath the water, not on the water (so you can see through the water and get proper fog).
And coincidentally I needed it here too! So I already had the nodes ready.

Thanks to bgolus on the Unity forums I was able to convert depth to world position.
My subgraph looks like this:

WARNING

Make sure to enable Custom Binding on the UV property in order for Branch on Input Connection to work.

DepthWorldPosition

Below is how I used it in the water shader.
The green input is just the altered screen space UV from the noise / refraction.
The split node is basically just the depth difference from the camera.

DepthFixImplementation

Potential Future Features#

I want to add the ability for branches, however that is a much more difficult problem that I don’t think I’m willing to tackle for this project.


Music I’ve Been Listening To#

The Reapture - Rivers
Author
CYXNIGHT
Published at
15/05/2026
License
CC BY-NC-SA 4.0