Tutorial 69: Water and Reflections
Planar reflections overview
The most common real-time water reflection technique is planar reflection: render the scene a second time from a camera that is mirrored across the water plane, store the result in a RenderTarget, and then sample it from the water surface shader. The effect is physically correct for flat water and costs exactly one extra scene render per water plane.
The reflection camera is constructed by reflecting the main camera's position and orientation across the plane y = waterLevel:
// Build the reflection view matrix
float waterY = 0.0f; // world-space water level
// Flip the camera position across y = waterY
Vector3 reflPos = cameraPos_;
reflPos.Y = 2.0f * waterY - reflPos.Y;
// Flip the look-at target as well
Vector3 reflTarget = cameraTarget_;
reflTarget.Y = 2.0f * waterY - reflTarget.Y;
// Invert Up to maintain winding order (negate Y)
Matrix reflView = Matrix::CreateLookAt(reflPos, reflTarget,
Vector3(0.0f, -1.0f, 0.0f));
RenderTarget for reflection
Allocate a RenderTarget at half resolution to save bandwidth; the distortion from the normal map will hide the reduction in sharpness:
// LoadContent
auto& gd = getGraphicsDeviceProperty();
reflectionRT_ = std::make_unique<RenderTarget2D>(
gd,
gd.getBackBufferWidth() / 2,
gd.getBackBufferHeight() / 2,
false,
SurfaceFormat::Color,
DepthFormat::Depth24);
Clip plane in the reflection pass
When rendering the reflection, geometry below the water plane must be clipped away or it will appear in the reflection texture incorrectly. Pass a clip plane as a shader uniform and use gl_ClipDistance[0] in the vertex shader:
// reflect_scene.vert — add clip plane support
uniform vec4 ClipPlane; // (0, 1, 0, -waterY) for above-water clip
out float gl_ClipDistance[1];
void main() {
vec4 worldPos = World * vec4(aPosition, 1.0);
gl_Position = Projection * View * worldPos;
gl_ClipDistance[0] = dot(worldPos, ClipPlane);
// ... pass normals, texcoords
}
Set ClipPlane = Vector4(0, 1, 0, -waterY) for the reflection pass (keep only above-plane geometry). OpenGL must have GL_CLIP_DISTANCE0 enabled; in CNA's EASYGL backend call the underlying GL function once at startup. For the main scene pass set ClipPlane = Vector4(0, 0, 0, 0) to effectively disable clipping.
Reflection render pass
void DrawReflectionPass() {
auto& gd = getGraphicsDeviceProperty();
gd.SetRenderTarget(reflectionRT_.get());
gd.Clear(Color::CornflowerBlue);
// Set clip plane: keep pixels above water
sceneEffect_->Parameters()["ClipPlane"].SetValue(
Vector4(0.0f, 1.0f, 0.0f, -waterY_));
sceneEffect_->Parameters()["View"].SetValue(reflView_);
sceneEffect_->Parameters()["Projection"].SetValue(proj_);
DrawSceneGeometry();
// Disable clip plane for main pass
sceneEffect_->Parameters()["ClipPlane"].SetValue(
Vector4(0.0f, 0.0f, 0.0f, 0.0f));
gd.SetRenderTarget(nullptr);
}
Wave displacement in the vertex shader
Move water vertices up and down using a sum of sine waves. Two waves at different frequencies and angles give a more natural appearance than a single wave:
// water.vert
uniform float Time;
uniform float WaveAmplitude; // e.g. 0.15
uniform float WaveSpeed; // e.g. 0.8
void main() {
vec3 pos = aPosition;
// Wave 1: diagonal direction
float w1 = sin(pos.x * 0.4 + pos.z * 0.3 + Time * WaveSpeed)
* WaveAmplitude;
// Wave 2: opposite diagonal, higher frequency
float w2 = sin(pos.x * 0.7 - pos.z * 0.5 + Time * WaveSpeed * 1.3)
* WaveAmplitude * 0.5;
pos.y += w1 + w2;
vWorldPos = (World * vec4(pos, 1.0)).xyz;
gl_Position = Projection * View * World * vec4(pos, 1.0);
vTexCoord = aTexCoord;
}
Water normal map animation
A scrolling normal map provides per-pixel wave detail without per-vertex cost. Use two normal map samples at different scales and scroll speeds, then combine them:
// water.frag
uniform sampler2D ReflectionTexture;
uniform sampler2D NormalMap;
uniform float Time;
uniform vec3 CameraPos;
in vec3 vWorldPos;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
// Scroll two normal map samples in different directions
vec2 uv1 = vTexCoord + vec2( 0.02, 0.01) * Time;
vec2 uv2 = vTexCoord + vec2(-0.01, 0.03) * Time;
vec3 n1 = texture(NormalMap, uv1).xyz * 2.0 - 1.0;
vec3 n2 = texture(NormalMap, uv2 * 0.7).xyz * 2.0 - 1.0;
vec3 normal = normalize(n1 + n2);
// Perturb reflection UV using normal XZ for distortion
vec2 distort = normal.xz * 0.04;
// Project world position to clip space for reflection UV
// (simplified — use gl_FragCoord in screenspace for accuracy)
vec2 screenUV = gl_FragCoord.xy /
vec2(textureSize(ReflectionTexture, 0));
vec2 reflUV = screenUV + distort;
vec4 reflColor = texture(ReflectionTexture, reflUV);
// Deep water colour
vec3 waterColor = vec3(0.05, 0.15, 0.25);
// Fresnel term
vec3 V = normalize(CameraPos - vWorldPos);
float fresnel = pow(1.0 - max(0.0, dot(V, normal)), 4.0);
fresnel = mix(0.05, 1.0, fresnel); // clamp base reflectivity
vec3 result = mix(waterColor, reflColor.rgb, fresnel);
fragColor = vec4(result, 0.85); // slight transparency
}
Fresnel term
The Fresnel effect describes how reflectivity of a surface increases as the viewing angle grazes the surface. At normal incidence (looking straight down at water) you see mostly the water colour and refraction. At grazing angles (looking across the water surface) you see almost pure reflection. The Schlick approximation is cheap and accurate enough:
// Schlick Fresnel approximation
// R0 = base reflectivity at 0 degrees (for water ~0.02)
float R0 = 0.02;
float cosTheta = max(0.0, dot(viewDir, surfaceNormal));
float fresnel = R0 + (1.0 - R0) * pow(1.0 - cosTheta, 5.0);
In the water shader, use the Fresnel value to blend between the underwater/deep-water colour (or refraction texture) and the reflection texture.
Full per-frame sequence
void Draw(const GameTime& gt) override {
auto& gd = getGraphicsDeviceProperty();
float t = static_cast<float>(gt.TotalGameTime.TotalSeconds());
// 1. Build reflection camera
BuildReflectionView();
// 2. Render scene from reflection camera (with clip plane)
DrawReflectionPass();
// 3. Render main scene to back buffer
gd.SetRenderTarget(nullptr);
gd.Clear(Color::CornflowerBlue);
sceneEffect_->Parameters()["View"].SetValue(view_);
DrawSceneGeometry();
// 4. Draw water surface on top
waterEffect_->Parameters()["Time"].SetValue(t);
waterEffect_->Parameters()["ReflectionTexture"].SetValue(*reflectionRT_);
waterEffect_->Parameters()["View"].SetValue(view_);
waterEffect_->Parameters()["Projection"].SetValue(proj_);
waterEffect_->Parameters()["World"].SetValue(
Matrix::CreateTranslation(0.0f, waterY_, 0.0f));
waterEffect_->Parameters()["CameraPos"].SetValue(cameraPos_);
gd.BlendState = BlendState::AlphaBlend;
for (auto& pass : waterEffect_->CurrentTechnique().Passes) {
pass.Apply();
gd.SetVertexBuffer(*waterVB_);
gd.SetIndexBuffer(*waterIB_);
gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
0, 0, waterVerts_, 0, waterPrims_);
}
gd.BlendState = BlendState::Opaque;
gd.Present();
}