Tutorial 69: Water and Reflections

CNA Tutorials  ·  3D Rendering

What you’ll learn

  • Rendering a planar reflection into a RenderTarget2D with a clipping plane.
  • Displacing the water surface in the vertex shader.
  • Animating a normal map and applying a Fresnel term.
  • The full per-frame pass ordering.

Before you startTutorial 23: Render Targets for Off-Screen Rendering (the reflection pass renders off-screen) and Tutorial 52: Writing Custom Shaders (ShaderEffect) (both water shaders are custom). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the thirteen 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls. Requires a renderer with render-target support.

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.getPresentationParametersProperty().getBackBufferWidthProperty()  / 2,
    gd.getPresentationParametersProperty().getBackBufferHeightProperty() / 2,
    false,
    SurfaceFormat::Color,
    DepthFormat::Depth24);

SurfaceFormat::Color is the portable reflection format for the shader-capable configurations in this tutorial. Skia and IGL have renderer-qualified promoted formats, but they do not change the common EasyGL/Vulkan path; an LDR RGBA8 reflection is also sufficient for this distortion pass.

Building the scene and water shaders

Both shaders in this example are ShaderEffects. Alpha.1's XNA/FNA Effect Framework bytecode loader is a separate renderer-qualified path. Here ShaderEffect takes renderer-native source and has no Parameters collection, techniques or passes; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), after Apply(). See Tutorial 52.

The constructor takes three arguments — the device and the two shader sources. The strings are the GLSL text itself, never a file path:

#include "Microsoft/Xna/Framework/Graphics/ShaderEffect.hpp"
#include "System/IO/File.hpp"

sceneEffect_ = std::make_unique<ShaderEffect>(
    gd,
    System::IO::File::ReadAllText("Content/effects/reflect_scene.vert.glsl"),
    System::IO::File::ReadAllText("Content/effects/reflect_scene.frag.glsl"));
waterEffect_ = std::make_unique<ShaderEffect>(
    gd,
    System::IO::File::ReadAllText("Content/effects/water.vert.glsl"),
    System::IO::File::ReadAllText("Content/effects/water.frag.glsl"));

// Neither constructor throws on a compile failure -- this is the only signal.
if (!sceneEffect_->IsEffectValid() || !waterEffect_->IsEffectValid()) {
    // The GLSL did not compile. Do not draw with it.
}

// The scrolling wave normal map is an ordinary texture asset.
normalMap_ = getContentProperty().Load<Texture2D>("textures/water_normal");

// Sampler unit assignments for the water shader, set once. Apply() binds the
// program first; SetUniformXxx() writes into whatever program is bound.
waterEffect_->Apply();
waterEffect_->SetUniformInt("ReflectionTexture", 0);
waterEffect_->SetUniformInt("NormalMap",         1);

SetUniformMat4 takes raw column-major floats rather than a Matrix, so a small helper keeps the matrix call sites readable:

// ShaderEffect's matrix setter takes raw column-major floats.
static void SetMat4(ShaderEffect& fx, const char* name, const Matrix& m) {
    float cm[16];
    m.ToColumnMajor(cm);
    fx.SetUniformMat4(name, cm);
}

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 the plane with SetUniformVec4("ClipPlane", 0.0f, 1.0f, 0.0f, -waterY) for the reflection pass (keep only above-plane geometry). OpenGL must have GL_CLIP_DISTANCE0 enabled; on the GL family of renderers, 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);

    // Apply() binds the program; SetUniformXxx() writes into whatever program
    // is currently bound, so this order is mandatory.
    sceneEffect_->Apply();

    // Set clip plane: keep pixels above water
    sceneEffect_->SetUniformVec4("ClipPlane", 0.0f, 1.0f, 0.0f, -waterY_);
    SetMat4(*sceneEffect_, "View",       reflView_);
    SetMat4(*sceneEffect_, "Projection", proj_);

    DrawSceneGeometry();

    // Disable clip plane for the main pass
    sceneEffect_->SetUniformVec4("ClipPlane", 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.getTotalGameTimeProperty().getTotalSecondsProperty());

    // 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_->Apply();
    SetMat4(*sceneEffect_, "View", view_);
    DrawSceneGeometry();

    // 4. Draw water surface on top
    waterEffect_->Apply();

    // Units 0 and 1 are the ones the samplers were pointed at in LoadContent().
    // SetTexture takes a reference, and RenderTarget2D derives from Texture2D.
    waterEffect_->SetTexture(0, *reflectionRT_);
    waterEffect_->SetTexture(1, *normalMap_);

    waterEffect_->SetUniformFloat("Time", t);
    SetMat4(*waterEffect_, "View",       view_);
    SetMat4(*waterEffect_, "Projection", proj_);
    SetMat4(*waterEffect_, "World",
            Matrix::CreateTranslation(0.0f, waterY_, 0.0f));
    waterEffect_->SetUniformVec3("CameraPos",
                                  cameraPos_.X, cameraPos_.Y, cameraPos_.Z);

    // A ShaderEffect has no techniques or passes to iterate -- Apply() above
    // bound the one compiled program, so this is a single ordinary draw.
    gd.setBlendStateProperty(BlendState::AlphaBlend);
    gd.SetVertexBuffer(waterVB_.get());
    gd.SetIndexBuffer(waterIB_.get());
    gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
                             0, 0, waterVerts_, 0, waterPrims_);
    gd.setBlendStateProperty(BlendState::Opaque);

    gd.Present();
}