Water simulation in GLSL
Over the weekend, I put together a very basic water simulation with GLSL (the shader language in OpenGL). I'm pretty happy with the results so far. It's amazing how much you can get out of such a small amount of code.
Step one in generating the water you see above is building a mesh for it. I started with a heightmap, set the "sea level" elevation, and copied everything below that. So the vertices of the mesh actually store depth for the water at every point. I don't use this information right now, but it could probably be used to make smaller or slower waves near the coast for more realism.
The mesh gets drawn with a vertex shader, which essentially applies a sum of sine waves to the surface. The height at each point is a function of XY position and time. The number of waves and the amplitude, wavelength, speed, and direction of each wave are configurable parameters.
const float pi = 3.14159;
uniform float waterHeight;
uniform float time;
uniform int numWaves;
uniform float amplitude[8];
uniform float wavelength[8];
uniform float speed[8];
uniform vec2 direction[8];
float wave(int i, float x, float y) {
float frequency = 2*pi/wavelength[i];
float phase = speed[i] * frequency;
float theta = dot(direction[i], vec2(x, y));
return amplitude[i] * sin(theta * frequency + time * phase);
}
float waveHeight(float x, float y) {
float height = 0.0;
for (int i = 0; i < numWaves; ++i)
height += wave(i, x, y);
return height;
}
The beautiful thing about modelling wave height as a fixed function like this is that you can generate normal vectors with some simple calculus. Below, I have two functions which take the partial derivatives with respect to X and Y, and another which sums them to produce a normal.
float dWavedx(int i, float x, float y) {
float frequency = 2*pi/wavelength[i];
float phase = speed[i] * frequency;
float theta = dot(direction[i], vec2(x, y));
float A = amplitude[i] * direction[i].x * frequency;
return A * cos(theta * frequency + time * phase);
}
float dWavedy(int i, float x, float y) {
float frequency = 2*pi/wavelength[i];
float phase = speed[i] * frequency;
float theta = dot(direction[i], vec2(x, y));
float A = amplitude[i] * direction[i].y * frequency;
return A * cos(theta * frequency + time * phase);
}
vec3 waveNormal(float x, float y) {
float dx = 0.0;
float dy = 0.0;
for (int i = 0; i < numWaves; ++i) {
dx += dWavedx(i, x, y);
dy += dWavedy(i, x, y);
}
vec3 n = vec3(-dx, -dy, 1.0);
return normalize(n);
}
The normal vector is left in world space so it can be used for cubemap texture coordinates in the fragment shader. I just reused my skybox textures as a cubemap. That's how I got the reflection you see. I set the alpha value for the water to 0.5 so it's fairly transparent.
Here's another screenshot:
Exercises left for the reader (and me, next weekend):
- Change transparency based on the angle between the normal vector and the eye vector
- Create a dynamic bump map for high frequency waves, using the same techniques
- Reflection of the environment, not just the skybox
- Refraction so the terrain under the water looks "wavy"
RSS feed