2415 words
12 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.


Shader#

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


Better Generation#

Steepness#

I wanted to have the river squash and stretch based on the steepness, and the steepness direction.
This took me quite a while to figure out.
I initially wanted to have the steepness be figured without needing to step through the spline, and while it might be possible I decided to precompute the UVs instead.

SplineWater.cs
private void CalculateUVReferenceList()
{
// Clear
scaledUVs.Clear();
steepness.Clear();
// Starting position
float currentUVPosition = 0f;
// Precompute some values
float splineLength = _spline.Spline.GetLength();
float stepSize = splineLength / (float)_numberOfVerticesAlongSpline;
float3 position;
float3 tangent;
float3 upVector;
Vector3 forward;
// Todo, average out scale over a distance
for (int y = 0; y < _numberOfVerticesAlongSpline + 1; y++)
{
int index = y;
scaledUVs.Add(currentUVPosition); // Add the current position
// Tangent is the forward direction, so we don't need to do anything extra
_spline.Evaluate((float)index / _numberOfVerticesAlongSpline, out position, out tangent, out upVector);
forward = Vector3.Normalize(tangent);
if (reverseUV)
{
forward = -forward;
}
float multiplier = Vector3.Dot(forward, Vector3.up);
bool isGoingDownhill = multiplier > 0f;
if (isGoingDownhill)
{
multiplier = Mathf.Pow(multiplier, power);
currentUVPosition += Mathf.Lerp(flatScale, downhillScale, multiplier) * stepSize;
steepness.Add(Mathf.Lerp(0f, -1f, multiplier)); // Downhill is negative
}
else
{
multiplier = Vector3.Dot(-forward, Vector3.up);
multiplier = Mathf.Pow(multiplier, power);
currentUVPosition += Mathf.Lerp(flatScale, uphillScale, multiplier) * stepSize;
steepness.Add(Mathf.Lerp(0f, 1f, multiplier)); // Uphill is positive
}
}
}

I also wanted to have access of the steepness within the shader, so I packed it into the UV2 channel.
This also controls how much foam should be present.
The steepness is shown below where green is downhill, and blue is uphill.

NOTE

When passing through the UVs, use SetUVs()!

River-SideRiver-Side-Debug
Normal shading
It’s hard to see here, but the foam and ripples are stretched.
Debug shading
You can see the downhill sections are in green, and the checkerboard pattern is stretched.
River-ExaggeratedSteepnessRiver-ExaggeratedSteepness-Debug
This is with exaggerated steepnessAnd here is the debug

Gerstner UV#

As the UVs are now stretched, so are the Gerstner waves. So I moved the original UVs into the UV3 channel, so we can have both the scaled and unscaled UVs.

Below are the differences:

NOTE

I used the exaggerated steepness and ramped up the Gerstner waves for demonstration
Both use the same steepness, but the unscaled one does not follow the texture UV

River-Gerstner-ScaledRiver-Gerstner-Unscaled
ScaledUnscaled

Better Shading#

Foam#

The biggest difference is the foam.

First, I needed a texture.

RiverWaterSplashes
This is the image I decided to go with.
Photo by Jorge Vasconez on Unsplash
In order to use this texture I made you NEED to give proper attribution to the original artist!!
Photo by Jorge Vasconez on Unsplash👈

Then I made two texture samples. They both go the same direction down the river, but they move against each other in the perpendicular direction.
I also mirrored one side, and added 0.5 to the y direction on the UV so it isn’t an obvious mirror!

River-Foam-Shader
The shader!
River-Foam-Shader-Gif
A gif of the output for reference.
River-Foam-Gif
I think it’s coming out quite nicely, and I cannot wait to use this in a level.
This gif is using the full foam, normally the foam is determined by the steepness and edges. (I have also added an offset just in case I need the foam to always show).

Colour#

I added in water colour, so now the depth and surface colours can be set. It can also be controller by a gradient on the SplineWater.
The ColourDepth setting controls the cloudiness of the water, which can also be controlled by the depth gradient through the alpha channel.

River-Cloudiness
River-Cloudiness-Settings

Caustics#

River-Caustics

I ADDED CAUSTICS
This mf took me a while to implement, but it turns out that was cs I had an interpolation node the wrong way around and didn’t notice for like an hour 💀.

The complicated part was adding it to the existing framework, but the creation itself is quite simple.

We sample a Voronoi three times, one red, one green, one blue.
Then we spread them by a distance, red in one direction and blue in the other.
I am using a 3D Voronoi thanks to Invertex!

NOTE

You can sample them as many times as you want, but it comes at a pretty bad cost as Voronoi is expensive.
You might do better generating a Voronoi texture beforehand, to save on computation.
As I am using 3D Voronoi I would need to create a 3D Texture.
I might do this in the future as I want this to run on the Steam Deck.

River-Caustics-RGB

In order to have the caustics be projected on the ground, we need to reuse the Depth World Position subgraph.

River-Caustics-WorldDepthPosition
The Y Offset is to move through the 3D Voronoi.
I also decided to pixelate the world position as I wanted it to match the game’s aesthetic.
River-Caustics-Raw
The caustic colour output.
River-Caustics-SceneColour
Multiplied by the scene colour.
River-Caustics-Altogether
Altogether.

The final product will be more subtle, but that’s all customisable.
The best part is that the caustics are occluded by the water colour and foam.

River-Caustics-FullShader
This is the full caustic part of the shader. My apologies for the quality, it’s quite a big section.
River-Caustics-ItsGettingBig
The full shader is getting quite large lmao.

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.

Branching-River


Music I’ve Been Listening To#

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