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.
  • Which renderers can run a custom-shader water pass, and why the reflection pass needs the opposite cull mode.

Before you start — Tutorial 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 with render targets that executes a custom ShaderEffect, such as OPENGLES3 or VULKAN; the 2D-only SDL_RENDERER throws on 3D calls by default, and STUB draws nothing.

⚠

Requirements: renderer and profile. Both water shaders are custom ShaderEffects, so this tutorial runs only where the renderer executes custom shader source, in that renderer’s own language. It needs no HiDef request: the reflection target is an RGBA8 SurfaceFormat::Color target at half resolution, which the default Reach profile allows as long as its edges stay at or below 2048 pixels. (An HDR reflection, or a reflection target larger than 2048, needs HiDef; see Tutorial 66 for the request and the float-target query.)

Renderer identities Custom-shader water pass
OPENGLES3, WEBGL2Runs; GLSL ES 3.00 (add #version 300 es and precision highp float; to the shader files)
OPENGL33Runs after switching to #version 330 core
VULKAN, SDL_GPUNeeds SPIR-V (SDL_GPU also takes GLSL text in a libshaderc build, Linux and Android)
WEBGPUNeeds WGSL
DIRECTX11Needs HLSL
DIRECTX9HLSL is compiled for SpriteBatch effects; a custom 3D shader is not verified
METALNot available: Metal runs custom effects (MSL) only in SpriteBatch, and a 3D draw with one throws
FNA3D, SOFTWARENot available: CustomEffects is false
HEADLESSAccepts the effects, never executes them: renders nothing
STUB, SDL_RENDERERNot available (no 3D)

Planar reflections overview

The most common real-time water reflection technique is planar reflection: render the scene a second time as seen in a mirror lying on 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 cleanest way to draw the mirrored scene is to put a reflection matrix in front of the ordinary view matrix, so every world position is mirrored across the plane y = waterLevel before the camera sees it. The result lines up with the main camera’s own screen, which is what lets the water shader sample it with the fragment’s screen coordinates:

// Build the reflection view matrix
float waterY = 0.0f;  // world-space water level

// The water plane: dot(normal, p) + d = 0 with normal (0,1,0) and d = -waterY
Plane waterPlane(Vector3::Up, -waterY);

// Row-vector convention: mirror the world position first, then apply the camera.
Matrix reflView = Matrix::CreateReflection(waterPlane) * view_;

Two consequences follow. First, a reflection matrix has a negative determinant, so it flips the winding of every triangle: the reflection pass has to draw with the opposite cull mode, RasterizerState::CullClockwise, or the visible faces are culled (the default CullCounterClockwise is right again for the main pass). Second, mirroring the camera instead (reflected position and target in CreateLookAt) does not flip winding and does not need this, but it produces an image that needs a vertical flip to line up with the main view; negating the camera’s Up vector to compensate rotates the image by 180 degrees, which also mirrors it left to right, so it is not a fix. The mirrored-scene form above avoids both problems.

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: every renderer with render targets accepts it, and an LDR RGBA8 reflection is sufficient for this distortion pass. Float and HDR formats are HiDef-only and probed per renderer (a request the profile or renderer refuses silently becomes Color); see Tutorial 66.

Building the scene and water shaders

⚠

Both shaders in this example are ShaderEffects. This snapshot’s XNA/FNA Effect Framework bytecode loader is a separate renderer-qualified path (FNA3D always, ten more identities behind default-OFF CNA_*_COMPILED_EFFECTS options). Here ShaderEffect takes renderer-native source and has no Parameters collection, techniques or passes; uniforms go through SetUniformXxx() and textures through SetTexture(unit, tex), with Apply() called first. 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. IsEffectValid() is the quick
// check; GetCompileErrorEXT() returns the compiler log (also written to stderr).
if (!sceneEffect_->IsEffectValid() || !waterEffect_->IsEffectValid()) {
    // The shader 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() first is the
// portable order for a ShaderEffect.
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. The classic tool, gl_ClipDistance, is not available here: CNA does not enable GL_CLIP_DISTANCE0 for you and gives callers no raw-GL access to do it themselves, and gl_ClipDistance does not exist in the #version 300 es profile this tutorial uses. Do the clip in the fragment shader with discard instead (an oblique-frustum projection is the other portable option). Pass the plane as a uniform and test the un-mirrored world position, so the vertex shader has to hand that position on:

// reflect_scene.vert — pass the un-mirrored world position to the fragment stage
out vec3 vWorldPos;

void main() {
    vec4 worldPos = World * vec4(aPosition, 1.0);
    vWorldPos     = worldPos.xyz;
    gl_Position   = Projection * View * worldPos;   // View includes the mirror in the reflection pass
    // ... pass normals, texcoords
}

// reflect_scene.frag — clip everything below the water plane
uniform vec4 ClipPlane;  // (0, 1, 0, -waterY): keep what is above the water
in vec3 vWorldPos;

void main() {
    if (dot(vec4(vWorldPos, 1.0), ClipPlane) < 0.0) discard;
    // ... normal shading
}

Set the plane with SetUniformVec4("ClipPlane", 0.0f, 1.0f, 0.0f, -waterY) for the reflection pass (keep only above-plane geometry). For the main scene pass set ClipPlane to (0, 0, 0, 0), which discards nothing.

Reflection render pass

void DrawReflectionPass() {
    auto& gd = getGraphicsDeviceProperty();
    gd.SetRenderTarget(reflectionRT_.get());
    gd.Clear(Color::CornflowerBlue);

    // The mirror flips triangle winding, so cull the other side for this pass.
    gd.setRasterizerStateProperty(RasterizerState::CullClockwise);

    // Apply() first is the portable order for a ShaderEffect.
    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.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);   // back to the default
    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;
uniform vec2      ScreenSize;   // back-buffer size in pixels

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;

    // The reflection was rendered from the main camera's own viewpoint (mirrored
    // scene), so the fragment's screen position is the right lookup. Divide by the
    // BACK BUFFER size, not the reflection target's: that target is half resolution.
    // If the reflection appears upside down on your renderer, flip reflUV.y (render-
    // target texture orientation differs between graphics APIs).
    vec2 screenUV = gl_FragCoord.xy / ScreenSize;
    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 the reflection view: Matrix::CreateReflection(waterPlane) * view_
    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);
    waterEffect_->SetUniformVec2("ScreenSize",
        static_cast<float>(gd.getPresentationParametersProperty().getBackBufferWidthProperty()),
        static_cast<float>(gd.getPresentationParametersProperty().getBackBufferHeightProperty()));
    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);
    // No gd.Present(): Game presents after Draw() returns, so a manual call
    // would present the frame twice.
}