Two days ago I started on the ocean shader for Pod and another, smaller, single-player game project I will write about later. I’m currently doing most rendering work on the smaller code base, but the results are trivially shared as they both use the same engine. I thought it would be interesting to keep a work log of the development process, as a way to make a shiny and possibly even interesting blog entry. At the time, I had no idea how large this would become.
Note that I haven’t got started on proper sky rendering yet, so the skydome is simply mapped with a 1D gradient texture and there is no actual atmospheric scattering to apply to the ocean surface. This makes the horizon sharper than it should be under most circumstances. Please keep that in mind when looking at the screenshots below.
I use only a single 256×256 normal map for the wave normals throughout the process below and never sample it more than five times per pixel. However, I did replace the contents of it several times.
There is also only one simple directional light source that, for all images below except one, is set to 45° above the horizon.

My first attempt. Blinn-Phong on three summed samples from a normal map generated from regular Perlin noise. Tinted with a hard-coded blue diffuse colour. Hard-coded ambient factor. Horrific.

The same shader as above, but it turns out that Blinn-Phong half-vector interpolation is more sensitive to poor tessellation than regular Phong world position interpolation. Dividing the plane into a grid helped a lot. It still looks horrible, though.

Made a slightly better normal map. Moved to regular Phong. Started using the surface normal (no, not the wave normal from the normal map samples) to reflect into the skydome texture. Slightly less jarring but still awful and not really recognisable as water.

Improved the reflection calculation somewhat. It was still fairly broken at this point, however. Borrowed the wave height map from chapter 18 of GPU Gems 2 and made a normal map from that. Switched to a more subtle normal map filter, smoothing the surface out somewhat. It’s starting to look like a liquid, if perhaps not water.

Lowered the ambient and diffuse factors after some feedback from Pao, but otherwise mostly the same as above. This could maybe pass as being water if you squint and imagine you’re off the coast of a tropical island.

Replaced the ambient lighting with light absorbtion, by adding a slightly broken approximation of Fresnel and using it to blend between two absorbtion factors, with the closer one absorbing less green light. This makes it tend towards green closer to the camera. Modified the skydome colours using reference photos. This is the first one to be recognisable as ocean water.


Realised that water is shiny or transparent and threw out the diffuse part of the lighting. Replaced the borrowed normal map with one I made made in Gimp. Added a layer for tiny ripples, bringing the total number of normal map samples per pixel to four. At this point, I was fairly pleased with the results and headed off to bed.



Saturday. Being away from the computer for a while really helps one to find flaws in what had looked so good after having stared at it for hours on end.
Changed the Fresnel from my approximation of 1.0 - pow(dot(N, V), 2.0) to it’s current state of pow(1.0 - dot(N, V), 2.0) thanks to the paper Per Pixel Fresnel Term. Fixed a bug that was making the entire surface reflect the part of the sky just above the horizon. Started multiplying specular reflection by the Fresnel factor as well. Added another layer, this one for larger waves. This isn’t really visible from this close to the surface, but does help when viewing it from for example hilltops.
Note that the bottom screenshot of the set above uses a slightly different near absorbtion factor that lets out less green light.
At this point, which was yesterday afternoon, I thought it was looking pretty good. I declared the shader complete for now and began writing this blog post. However, as I was describing what was then the final stage, I realised how flawed it was. So instead of publishing, I went back to work.
Instead of interpolating between absorbtion factors, I should be interpolating between emission (from scattering) and partially absorbed reflection, i.e. looking straight down at water, it doesn’t reflect the sky. I also discovered that my R vector had been inverted, as I had forgotten to negate the light vector. Surprisingly, it had looked fairly good even with this mistake. I hadn’t noticed it as my terrain (not shown) still has an appalingly simplistic shader so there was no sane reference point.
However, after these (arguably correct) changes, the water looked like plastic. I then spent most of the night breaking things and then trying to figure out what had broken, why it broke, and how to fix it without going back to the previous, broken behaviour. In the process of doing this, I ended up reading about and figuring out a lot about the properties of water in general and the ocean in particular, as well as making minor digressions into other areas.
Surprisingly, as I learned each new things and applied it to the shader, it invariably got shorter and simpler, but when I went to bed last night it still didn’t look right.

Above is one example of breakage. In this case, among other bugs, the sun vector is… well, where no respectable sun vector should ever go. Note that the tearing towards the bottom of the image isn’t a rendering artifact but rather the result of gnome-screenshot interacting poorly with the screen refresh.
Sunday. Today I did finally manage to figure out what I had broken and how, and to match and arguably surpass the quality of the previous versions. While doing this, I also discovered that about half of the magic numbers used for scrolling were superfluous and took them out. Below is the end result. Enjoy.




In the image below, the contents of the top window is rendered using my shader while the window behind it is the photograph I used to create the skydome gradient. The ocean’s colours fall out of a combination of the reflection and emission factors, but even with such a simple model it matches reality fairly well.
Note that the reason that the waves seem to fade out at different heights within the image partially due to my screenshot being taken from eye level at a beach while the photograph is from a helicopter and partially due to the photograph having been murdered by compression.

Here is the shader in its current state, including the parameters used for the final set of screenhots. I haven’t made any real effort to optimise it yet, so I’m sure there’s room for that.
The variables wyTime and wyCameraPosition are shared uniforms provided by the engine, while sunDirection is provided by the game, using the same mechanism. Their values should be self-explanatory.
uniform sampler2D wavemap;
uniform sampler1D skymap;
uniform float exponent;
uniform vec3 emission;
uniform vec3 reflection;
varying vec2 texCoord;
varying vec3 worldPos;
const float PI = 3.1415926535;
const vec3 Y = vec3(0.0, 1.0, 0.0);
vec3 normal(vec2 coord, vec2 time)
{
return texture2D(wavemap, coord + wyTime / time).xzy - vec3(0.5);
}
void main()
{
vec3 N = vec3(0.0);
// Idea stolen from Source 2007 (thanks!)
vec2 texCoord2 = vec2(texCoord.x + texCoord.y, texCoord.y - texCoord.x);
// Here be magic numbers. Don't try to make too much sense of them; they're
// not calculated from anything but rather the result of much tweaking.
// There are two kinds:
// * The texture coordinate scaling, controlling wave size
// * The time scale, controlling wave speed (also influenced by wave size)
N += normal(texCoord * 5.0, vec2(200.0, 166.0));
N += normal(texCoord * 31.0, vec2(94.0, 70.0));
N += normal(texCoord2 * 67.0, vec2(56.0, 34.0));
N += normal(texCoord * 203.0, vec2(100.0, 20.0));
N += normal(texCoord2 * 123.0, vec2(60.0, 57.0));
N = normalize(N);
vec3 V = normalize(wyCameraPosition - worldPos);
vec3 R = reflect(-sunDirection, N);
// This is far from accurate, but it still looks fairly convincing
float fresnel = pow(1.0 - dot(N, V), 2.0);
// We fake HDR sunlight for now, until the actual sky is operational
vec3 fakeSun = vec3(7.0) * pow(clamp(dot(R, V), 0.0, 1.0), exponent);
vec3 reflected = fakeSun + texture1D(skymap, 1.0 - dot(V, Y)).rgb;
vec3 C = mix(emission, reflected * reflection, fresnel);
gl_FragColor = vec4(C, fresnel);
} |
<?xml version="1.0"?>
<material version="6">
<technique type="forward" quality="1">
<pass>
<program path="shaders/sea.program">
<uniform name="exponent" value="40.0" />
<uniform name="emission" value="0.1 0.15 0.12" />
<uniform name="reflection" value="0.65 0.65 0.65" />
<sampler name="wavemap" texture="textures/wavemap.texture" />
<sampler name="skymap" texture="textures/sky.texture" />
</program>
</pass>
</technique>
</material> |
Here is the skydome gradient.

Here is the wave normalmap.

So there it is; a usable deep ocean shader. Of course, I will need to add many more features to it for the games I’m making, at the very least including depth fogging, geometry reflection and the interaction of waves and land. However, first I need to let the land and the sky catch up visually.