Water from two shaders: a pool, a stream and a waterfall

There is no water simulation on the island and no reflections. Two ShaderMaterials cover all of it, one lying flat and one falling, sharing a single time and night uniform.

Water is the easiest thing to overbuild. The island has two water materials: no reflection, no refraction, no fluid solver.

Flat water: one material, three places

The pool and the stream share a material. Their geometry could not be more different — a circle versus a riverbed traced from Bézier curves — but the fragment shader only cares about position and time:

float w = sin(vPosition.x * 9. + vPosition.y * 12. + time * 1.6)
        * sin(vPosition.y * 7. - time);
float ring = pow(max(0., sin(length(vPosition.xy) * 19. - time * 1.6)), 22.);
vec3 c = mix(vec3(.10, .43, .43), vec3(.29, .72, .67), w * .25 + .5);
c += ring * .1;

Two multiplied sines give the irregular ripples; a high power gives the occasional glint. Because it reads position rather than UV, the same material works on any shape — the riverbed needs no UVs of its own.

Falling water: vertex displacement plus stripes

The waterfall is a 0.94 × 4.9 plane subdivided 10 × 32. Its vertex shader makes the sheet ripple:

p.z += sin(uv.y * 12. + time * 2. + uv.x * 5.) * .055;

The fragment side is a column of high-frequency stripes over a slow pulse, faded at both edges with smoothstep:

float stripe = pow(.5 + .5 * sin(vUv.x * 70. + sin(vUv.y * 8. - time * 3.)), 7.);
float edge = smoothstep(0., .1, vUv.x) * (1. - smoothstep(.83, 1., vUv.x));
gl_FragColor = vec4(col, edge * fade * .85);

The material sets transparent and depthWrite: false, otherwise the falling water hides the fog behind it.

One uniform object for two materials

time and night live in a single object:

const waterUniforms = { time: { value: 0 }, night: { value: 0 } };

The animation loop writes it once and both materials update. What that buys is not fewer lines — it guarantees both bodies of water darken in the same instant when you switch to night, instead of drifting apart.

Two includes you must not forget

A custom fragment shader skips tone mapping and colour space conversion by default, which leaves the water one stop brighter than everything around it, glowing like plastic:

#include <tonemapping_fragment>
#include <colorspace_fragment>

Those are three’s own shader chunks, written after you compute your colour. Forget them and you will start suspecting the ambient light is misconfigured.

Closing

Water is convincing because of motion and highlights, not physics. Two sines, one power and a shared uniform are enough to make a static island look alive.

← Back to all posts

Comments

…