Recursive AI

Render planar water reflections in C#

onegame is a free, open-source game engine for .NET from Recursive AI. MIT or Apache-2.0, no editor required.

Planar reflections mirror a scene on one horizontal water plane. This example uses Vulkan on Linux or Windows.

Download the zip first (pond.gltf is in it).

Terminal
dotnet add package RecursiveAI.OneGame.Render3D
dotnet add package RecursiveAI.OneGpu.Backends

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

pond.gltf is the original sample in the zip: a horizontal sheet named “Water” and a red box above it.

C#
using System.Numerics;
using OneGame.Gltf;
using RecursiveAI.OneGpu;
using OneGame.Render3D;
 
using var device = GpuBackends.CreateDevice();
using var renderer = new Renderer3D(device);
using var model = renderer.Upload(GltfLoader.LoadFile("pond.gltf"));
var scene = new Scene3D
{
    Camera = Camera3D.LookAt(new(0, 4, 8), new(0, 0, -1), Vector3.UnitY),
    Samples = 1 // One sample per pixel: no MSAA.
};
scene.Add(model);
var water = model.FindMaterial("Water")!;
water.PlanarReflection = true;
scene.Reflection.Height = 0;
using var target = new Offscreen3D(renderer, 640, 480);
Console.WriteLine($"Captured {target.Capture(scene).Length} RGBA bytes");
Console.WriteLine($"Reflection pass: {renderer.LastStats.PlanarReflection}");

Renders the scene offscreen and prints:

Text
Captured 1228800 RGBA bytes
Reflection pass: True

Unzip the example, then dotnet run.

Full program

C#
using System.Numerics;
using System.Text;
using OneGame.Gltf;
using RecursiveAI.OneGpu;
using OneGame.Render3D;
 
using var device = GpuBackends.CreateDevice();
using var renderer = new Renderer3D(device);
using var model = renderer.Upload(GltfLoader.LoadFile("pond.gltf"));
var scene = new Scene3D
{
    Camera = Camera3D.LookAt(new(0, 4, 8), new(0, 0, -1), Vector3.UnitY),
    Samples = 1 // One sample per pixel: no MSAA.
};
scene.Add(model);
var water = model.FindMaterial("Water")!;
water.PlanarReflection = true;
scene.Reflection.Height = 0;
using var target = new Offscreen3D(renderer, 640, 480);
scene.Environment.Intensity = 0;
scene.Environment.Background = BackgroundMode.Color;
scene.Environment.BackgroundColor = Vector3.Zero;
scene.Post.Bloom = false;
scene.Post.ToneMapper = ToneMapper.None;
scene.Lights.Add(new Light3D
{
    Kind = LightKind.Directional,
    Direction = Vector3.Normalize(new(-0.3f, -1, -0.5f)),
    Color = Vector3.One, Intensity = 3
});
var rgba = target.Capture(scene);
Console.WriteLine($"Captured {rgba.Length} RGBA bytes");
using (var file = File.Create("reflection.ppm"))
{
    file.Write(Encoding.ASCII.GetBytes("P6\n640 480\n255\n"));
    for (int i = 0; i < rgba.Length; i += 4) file.Write(rgba, i, 3);
}
Console.WriteLine($"Adapter: {device.Adapter.Name}");
Console.WriteLine($"Reflection pass: {renderer.LastStats.PlanarReflection}");
Console.WriteLine("Wrote reflection.ppm (640 x 480)");

The full program adds directional lighting and writes reflection.ppm, a portable RGB image, at 640 × 480 pixels. Our NVIDIA RTX 4070 Vulkan run printed:

Text
Captured 1228800 RGBA bytes
Adapter: NVIDIA GeForce RTX 4070
Reflection pass: True
Wrote reflection.ppm (640 x 480)

The rendered capture below is that PPM converted to PNG without changing its pixels. The lower red shape is the box’s reflection on the sheet.

Red box and its reflection on a horizontal sheet

If the camera is at or below the plane, or quality is Low, the renderer uses the environment instead and PlanarReflection is false.

The sample’s material definitions are below; pond.gltf also embeds its mesh buffers, so it needs no textures or external files. The sheet is at y = 0, spans x/z from −10 to 10, and faces upward. The box is centered at (0, 1.5, −2), with sides of length 2. You may redistribute this original sample.

JSON
{
  "materials": [
    {
      "name": "Water",
      "alphaMode": "BLEND",
      "pbrMetallicRoughness": {
        "baseColorFactor": [
          0.9,
          0.9,
          0.9,
          1
        ],
        "metallicFactor": 1,
        "roughnessFactor": 0.02
      }
    },
    {
      "name": "Red box",
      "pbrMetallicRoughness": {
        "baseColorFactor": [
          0.8,
          0.1,
          0.1,
          1
        ],
        "metallicFactor": 0,
        "roughnessFactor": 0.8
      }
    }
  ],
  "nodes": [
    {
      "mesh": 0
    },
    {
      "mesh": 1
    }
  ]
}

Variations

For a weaker reflection at half-size resolution, insert this before the capture in the full program:

C#
// water and scene: the material and scene created above.
water.ReflectionStrength = 0.5f;
scene.Quality = QualityLevel.Medium;

This still prints Reflection pass: True. Medium’s reflection image is half the frame’s width and height.

The short answer

Set Material3D.PlanarReflection on the water material, set Scene3D.Reflection.Height to the water’s world-space y coordinate, and keep the camera above that plane. Use PBR shading and a quality level other than Low. OneGame then inserts the mirrored scene pass before the normal forward pass.

The download is a small rendered example rather than a complete water simulation. It has no waves, foam or refraction. Its smooth, highly metallic sheet makes the reflected geometry easy to see.

How do I get a water reflection effect?

Load the scene and find its material by glTF name, as the snippet at the top does. FindMaterial("Water") returns the first material with that name or null. The sample supplies that name; with another file, check the result rather than assuming the material exists. The glTF importer does not read extras as a reflection switch, so enable it in C# after loading.

The pinned renderer documentation describes a single additional scene draw into a reflection image. The camera is mirrored about the horizontal plane; opted-in PBR surfaces project their positions into that view and sample it for specular light. The scene’s Reflection.Enabled switch can disable the feature for the whole scene.

The plane height must agree with the actual geometry. Moving the sheet alone does not move Scene3D.Reflection.Height. There is one plane per frame, not an independent mirror camera for every material, and its normal is always +Y. This API is not an arbitrary wall-mirror system.

Why are planar reflections not showing?

First check the material name and style. Toon, flat and unlit materials ignore this reflection path. The sample uses a PBR material with roughness 0.02 and strength 1. ReflectionStrength ranges from 0 to 1; zero disables its contribution. Roughness reduces the planar contribution rather than blurring its image.

Then check the camera and quality level. The camera must be above the plane, the opted-in material must be visible, and the scene reflection switch must be enabled. Low quality disables the reflection image. Medium, High and Ultra render it at 0.5×, 0.75× and 1× the frame dimensions respectively. High is the default; the full program leaves that level unchanged.

Finally inspect renderer.LastStats.PlanarReflection after a frame. It tells you whether the pass ran, not whether every expected object appears in it. An object may be outside the mirrored frustum or excluded from that pass. The full program prints this flag next to its saved capture so you can distinguish those situations.

Does the water effect show reflection or refraction?

This feature supplies reflection: an image of the scene viewed across the mirror plane. It does not implement transmission through water or a refracted underwater view. The sample deliberately uses alpha 1 even though its material is in the blended pass, so the capture demonstrates the reflected box without transparent-water behavior.

The reflection image includes the backdrop, opaque and blended surfaces, instanced batches, directional and spot shadows, fog, and shading from the mirrored camera. It excludes the opted-in surfaces themselves, everything below the plane, outlines, billboards and debug geometry. Clustered point and spot lights do not light the reflection; directional lights do. Blended surfaces retain the main view’s sorting order, which is approximate for the mirrored view.

A normal map can distort the reflection lookup, and UV scrolling can move texture coordinates, but neither creates geometric waves. Fresnel shading still affects the reflected light, and the image fades near its edges.

All onegame recipes