Kimmy & John 2026

DH2323 — Computer Graphics, KTH 2026

Real-time stylized waterfall rendering in Unity

A real-time stylized waterfall system implemented in Unity. The water surface is rendered via a handwritten HLSL shader built around fractal Brownian motion of Perlin noise, with vertex displacement, finite-difference normals, Blinn-Phong shading, and domain warping. A separate foam shader handles surface froth. Both shaders are applied across three meshes, the river leading into the waterfall, the waterfall face, and the pool below, with per-material parameters that produce visually distinct flows from a single technique.

Authors

John Swärd & Kimmy Kochar Karlsson

13/05/26

Scene composition and the seam problem

This was the point where the foam and water shaders finally started feeling like one system instead of separate effects. The final foam shader combines UV edge masks, thresholded FBM and a radial impact mask, rendered as a transparent layer above the water surface. I also offset the foam slightly along the mesh normals to avoid z-fighting. Once everything came together in the final environment, the foam helped sell the waterfall, river and pool as one connected stylized water system.

finalized waterfall with foam at the lip and impact zone

Surface and Foam shader together on a Waterfall

final environment scene without the water

Final environment scene without the waterfall implemented

final scene with all shaders and environment together

Final scene with all shaders, environment, skybox and tuned parameters together

12/05/26

Surface shader step 8: Tangent space and the vertical waterfall

Hardest step. Up until now the shader assumed the water surface points up, which is fine for a pool but broken for a vertical waterfall. Had to pass tangent, bitangent, and normal from the vertex shader, reconstruct the normal in tangent space, then transform it back to world space using the TBN basis. Conceptually painful for a while because I kept conflating object space and tangent space. Got there in the end! Same shader now works on the horizontal pool and the vertical waterfall face without any weird lighting and other behaviours.

waterfall and pool both rendering with the same shader

Same shader running on the horizontal pool and the vertical waterfall, normals reconstructed in tangent space

12/05/26

Foam shader step 4: Tuning the foam breakup

Once the water shader started getting proper color and lighting, it became much easier to judge what the foam actually looked like in the scene. The breakup was still far too grainy, especially around the pool impact area where the splash looked more like TV static than foam. Most of this step was honestly parameter tuning: adjusting noise scale, cutoff, contrast, softness and the foam widths until the foam became softer and more stylized instead of noisy speckles.

grainy foam on waterfall and pool

Grainy foam breakup before parameter tuning

improved stylized foam with softer breakup

Improved foam breakup after tuning cutoff and contrast

11/05/26

Surface shader step 7: Domain warping

Two lines of code, massive change. Sample FBM, use the result as an offset, sample FBM again at the offset position. The scrolling stopped looking like a flat slide and started looking like currents and eddies. Cranked _WarpStrength from 0.5 to 1.5 and the surface went from a gentle river to actively marbled. fbm(p + fbm(p)) is such a small expression for such a big result.

domain-warped surface showing flow currents

Domain-warped surface, fbm(p + fbm(p)) at _WarpStrength = 1.5 vertically and 0.5 horizontally

10/05/26

Surface shader step 6: Swapping the placeholder for real Perlin

Up until now I'd been using a hash-based placeholder noise. Step 6 was implementing Perlin properly, with a permutation table, the quintic fade function, and gradient interpolation. Followed Adrian Biagioli's walkthrough closely. The visual difference was massive. The hash noise had a grainy, blocky look to it; Perlin gave smooth flowing patterns that actually look like water motion. Also clicked why Perlin's 2002 paper specifically uses 6t⁵−15t⁴+10t³ instead of smoothstep. Continuous second derivatives means the normal reconstruction from step 5 doesn't band.

perlin-based water surface

Real Perlin noise replacing the placeholder, with smoother flowing patterns and no grainy artifacts

09/05/26

Surface shader step 5: Lighting, normals, and the moment it looked wet

Biggest visual jump of the whole project. Reconstructed normals in the fragment shader by sampling FBM at four offsets and taking finite differences. Plugged those into Blinn-Phong, diffuse plus specular plus a small ambient term, and the surface caught light for the first time. Added a deep/shallow color mix driven by the FBM height and it was the first frame where I looked at it and went 'okay that's water'. Five sub-steps inside this one but worth it.

lit water surface with specular highlights and blue color

Per-pixel Blinn-Phong shading with deep and shallow water colors blended by FBM height

08/05/26

Foam shader step 3: Early impact foam experiments

The next step was creating foam where the waterfall hits the pool. I used a radial mask based on distance from a center point in UV space. At this point the result looked extremely grainy and noisy, especially since the water shader was still mostly grayscale. A lot of time here went into adjusting the FBM scale, cutoff and contrast to make the splash read more like foam and less like static.

grainy circular foam impact zone below the waterfall

Early impact foam with noisy radial masking

07/05/26

Surface shader step 4: Vertex displacement

Moved the FBM sample into the vertex shader and used it to push each vertex along its normal. This is where I actually started seeing surface bumps moving around instead of just shaded noise on a flat plane. Unity's default plane only has about 10x10 quads though, so the waves looked like a low-poly mountain. Wrote a small WaterMesh script that builds a subdivided plane and the displacement smoothed out. The conceptual jump was understanding that vertex and fragment shaders can both sample noise independently. Vertex for shape, fragment for surface detail.

displaced water surface with rolling waves

Vertex displacement on a subdivided plane, FBM driving the height of each vertex

05/05/26

Surface shader step 3: Time-based scrolling

Added _Time.y to the UV before sampling and suddenly the noise was moving across the surface. Still just dark clouds drifting over a flat plane, no actual surface deformation yet, but it was the first sign of motion. Two scroll layers at different speeds so it didn't look like a single image sliding.

scrolling noise on a flat plane

Two scrolling noise layers drifting across the plane at different speeds

05/05/26

Foam shader step 2: Testing foam as a layered material

Once the edge masks worked, I started testing the foam together with the water shader. At this stage the water still did not have its final lighting or color, so everything looked pretty rough and grayscale. The important part here was confirming that the foam could work as a transparent layer rendered on top of the water surface.

early grayscale test combining foam and water shaders

Early test combining foam and water shaders

03/05/26

Surface shader step 2: FBM and the octave loop

Wrapped the placeholder noise in an FBM loop with octaves, lacunarity, and gain exposed in the inspector. Cranked octaves up and watched fine detail layer on top of the bigger shapes. Spent way too long confused about why my noise was solid gray before realising I wasn't normalizing by the sum of amplitudes. Every extra octave was making it brighter until it clipped to white. One division fixed it.

fbm noise stack

FBM with four octaves stacked, lacunarity and gain exposed in the inspector

03/05/26

Foam shader step 1: UV edge bands

Started the foam shader with very simple UV masks. The first version just created soft white bands at the top and bottom of the mesh. It did not really look like foam yet, but it proved that the placement logic worked and that the foam could be controlled through UVs, width and softness parameters.

white foam bands on a flat plane

First foam pass using UV-based edge bands

02/05/26

Surface shader step 1: First shader on a flat plane

Started from absolute zero. Made a flat plane, attached an unlit material, wrote a vertex/fragment shader that samples a placeholder noise function and outputs it as grayscale. Just gray static, but seeing that show up on a plane I made was the moment shaders stopped feeling like magic. Spent a while figuring out what TEXCOORD0 actually is and why the vertex shader hands data to the fragment shader through a struct.

grayscale noise on a flat plane

Placeholder noise sampled in the fragment shader, output as grayscale on a flat plane