Tutorial 69: Water and Reflections

CNA Tutorials  ·  3D Rendering

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();
}