Drag to orbit · Scroll to zoom · Press Esc to exit

Sky & Water

HOW TO MAKE A REALISTIC
WATER SHADER IN THREE.JS

By Shane Brumback · · Free Three.js tutorial

Build a realistic animated ocean in the browser with Three.js — reflections, a dynamic sky, and a moving sun, using the built-in Water and Sky objects.

Three.js realistic water shader ocean effect
Live Three.js Water addon ocean with Sky and a roughness-0 cube.

A Three.js water shader is the built-in Water addon (three/addons/objects/Water.js) — you give it a large plane and a tiling normal map, point a sun direction at it, and advance its time uniform each frame. Pair it with the Sky addon for a matching horizon and a PMREMGenerator environment map for reflections. On modern Three.js (r170+) this renders a realistic, reflective ocean in under 50 lines, with no custom GLSL.

LIVE DEMO

The reflective ocean, dynamic sky, and floating mirror cube rendering behind this page are the live demo. Launch the full-screen demo to drag-orbit the camera and watch the sun highlights track across the waves.

Drag to orbit · Scroll to zoom · Press Esc to exit

Introduction

Realistic water used to be one of the hardest effects in real-time 3D. In Three.js it's almost turnkey: the library ships a ready-made Water object and a matching Sky object in its addons. Give the water a big plane and a tiling normal map, point a sun at it, and animate a time uniform — you get rippling, reflective ocean water in under 50 lines.

This free tutorial builds exactly that: an infinite reflective ocean, a physically-based sky with a sun you can position, and a chrome cube bobbing on the surface to show off the reflections. Copy the code below and it runs.

WANT MORE WATER?

Build real Gerstner waves in a custom Three.js shader, then add buoyancy so crates, barrels and boats bob and sway on the swell.

Three.js Gerstner waves with floating objects that bob and tilt on the water

Frequently Asked Questions

How do you make realistic water in Three.js?

Three.js ships a ready-made Water object in its addons (three/addons/objects/Water.js). You give it a large plane geometry and a tiling water-normals texture, set a sun direction and water color, then advance its time uniform every frame. Combined with the Sky object you get a realistic reflective ocean with very little code.

What is the Three.js Water object?

Water is a built-in helper mesh in the Three.js addons that renders an animated, reflective water surface. It uses a normal map for ripples and reads a sun direction so highlights track your light. You animate it by incrementing water.material.uniforms['time'].value with the frame delta.

How do you add reflections to the water?

The Water object reflects the scene automatically via a reflection render target. Pair it with the Sky object and a PMREMGenerator environment map so floating objects pick up realistic sky reflections. Setting a floating cube's material roughness to 0 shows the mirror-like reflection clearly.

Is this Three.js water tutorial free?

Yes. This is a completely free tutorial. The full source code is shown on the page, you can copy it, and there is a live ocean demo running in the background. No sign-in or membership is required.

Which version of Three.js has Water.js and what is the import path?

Water and Sky ship in the Three.js examples addons, not the core build. On modern Three.js (r170+) import them from three/addons/objects/Water.js and three/addons/objects/Sky.js via an import map that maps three/addons/ to the examples/jsm/ folder. On older setups the equivalent path is examples/jsm/objects/Water.js.

Where do I get waternormals.jpg and why does file:// fail?

Grab waternormals.jpg from the Three.js repo at examples/textures/water/waternormals.jpg and place it next to your HTML. Opening the page as a file:// URL fails because browsers block texture loads and module imports over the file protocol for security. Serve the folder over HTTP instead, for example python -m http.server, then open http://localhost:8000.

Should I use the Water addon or write a custom GLSL ocean?

Use the built-in Water addon when you want a realistic reflective ocean fast with almost no shader code. Write a custom GLSL ocean only when you need control the addon does not expose, such as Gerstner wave displacement, buoyancy, or a stylized look. The addon gives planar reflections and a normal-map ripple out of the box; custom GLSL trades more setup for full control.

How do I make the Three.js water perform well on mobile?

Lower the reflection render target to 512x512 (textureWidth and textureHeight), cap renderer.setPixelRatio at about 1.5, shrink the water plane, and pause the animation loop when the canvas is offscreen. The planar reflection pass is the main cost, so a smaller reflection target is the biggest single win on phones.

Can I change the water color, choppiness, and sun elevation at runtime?

Yes. Set water.material.uniforms['waterColor'].value and distortionScale for color and choppiness, and recompute the sun direction from new elevation and azimuth angles, then copy it into both sky.material.uniforms['sunPosition'] and water.material.uniforms['sunDirection']. The live demo on this page wires exactly these to sliders.

How To Add The Code

The Water and Sky objects live in the Three.js addons, so this uses ES modules with an import map. You need Three.js, its jsm/objects/Water.js and jsm/objects/Sky.js, and a water-normals texture.

  1. 1Create the HTML file and add an importmap that maps three and three/addons/ to the Three.js build and jsm folders.
  2. 2Import the pieces — THREE, OrbitControls, Water, and Sky.
  3. 3Set up scene, camera & renderer with Reinhard tone mapping for a natural sky exposure.
  4. 4Create the Water from a huge plane and a tiling waternormals.jpg texture (set it to RepeatWrapping), rotate it flat, and add it to the scene.
  5. 5Add the Sky and set its turbidity, rayleigh, and mie uniforms for a believable atmosphere.
  6. 6Position the sun — convert elevation/azimuth to a direction, feed it to both the sky and the water's sunDirection, and bake an environment map with PMREMGenerator.
  7. 7Add a mirror cube (roughness 0) so you can see the reflections, then animate it and advance the water's time uniform each frame.
  8. 8Serve the folder locally (textures need HTTP, not file://) and open it — you'll see a live reflective ocean.

Tip: grab waternormals.jpg from the Three.js examples (examples/textures/water/) and run a local server, e.g. python -m http.server.

Code Breakdown

Here is the whole thing in plain English. Each piece below maps to a chunk of the full code underneath — read these first and the code will feel obvious. Beginners: focus on the bold summaries. Developers: the notes after each explain the why.

1. The import map — how the browser finds Three.js

<script type="importmap">
  { "imports": { "three": "./build/three.module.js", "three/addons/": "./jsm/" } }
</script>

In plain English: this tells the browser "when my code says import ... from 'three', go grab these files." No bundler, no build step. Developer note: Water and Sky live in the examples/jsm addons, not the core build — that's why we map three/addons/ separately.

2. Scene, camera, renderer — the stage

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.toneMapping = THREE.ReinhardToneMapping;
const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 1, 20000);
camera.position.set(30, 30, 100);

In plain English: the renderer is the paintbrush, the scene is the canvas, the camera is your eye. Developer note: ReinhardToneMapping compresses the very bright sky into a range your monitor can show — without it the sun blows out to pure white. The far plane is huge (20000) because the ocean is a 10,000-unit plane.

3. How do you add the Three.js Water and Sky objects?

const water = new Water(new THREE.PlaneGeometry(10000, 10000), {
  textureWidth: 512, textureHeight: 512,
  waterNormals: new THREE.TextureLoader().load('waternormals.jpg', t => {
    t.wrapS = t.wrapT = THREE.RepeatWrapping;   // tile the ripples forever
  }),
  sunDirection: new THREE.Vector3(),
  waterColor: 0x001e0f, distortionScale: 3.7
});
water.rotation.x = -Math.PI / 2;                 // lay it flat

In plain English: Three.js ships a ready-made ocean. You hand it a giant flat plane and a ripple texture; it does the reflection and wave math for you. The texture is set to repeat so the ripples tile instead of stretching. Developer note: distortionScale controls how strongly the normal map bends reflections; waterColor is the deep-water tint that shows where reflection is weak.

4. How do you position the sun so highlights match the sky?

const phi   = THREE.MathUtils.degToRad(90 - 2);   // elevation
const theta = THREE.MathUtils.degToRad(180);      // azimuth
sun.setFromSphericalCoords(1, phi, theta);
sky.material.uniforms['sunPosition'].value.copy(sun);
water.material.uniforms['sunDirection'].value.copy(sun).normalize();

In plain English: you pick where the sun sits with two angles (how high, which way). That single direction is handed to both the sky and the water, so the glare on the waves always lines up with the sun in the sky. Developer note: setFromSphericalCoords converts elevation/azimuth into an XYZ vector — change the two degree values to move the sun from high noon to a low sunset.

5. Why does the mirror cube need PMREMGenerator?

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(sky).texture;

In plain English: we take a snapshot of the sky and use it as the "world reflection" for shiny objects. That's why the mirror cube reflects the actual sunset instead of looking flat. Developer note: PMREMGenerator pre-filters the sky into the mip-mapped format PBR materials need for roughness-correct reflections — do it once, not every frame.

6. How do you animate the water shader?

function animate() {
  requestAnimationFrame(animate);
  water.material.uniforms['time'].value += 1 / 60;   // advance the waves
  controls.update();
  renderer.render(scene, camera);
}

In plain English: every frame we nudge the water's time value forward — that one number is what rolls the ripples across the surface. Stop incrementing it and the ocean freezes. Developer note: adding a fixed 1/60 ties wave speed to frame count; for frame-rate independence multiply a real delta (clock.getDelta()) instead.

Which water approach should you use?

There are three common ways to render water in Three.js. Here is how the built-in Water addon used in this tutorial compares with a hand-written GLSL ocean and the official Three.js Ocean example.

Approach Setup effort Reflections Wave type Mobile cost
Water addon (this tutorial) Low — a few lines Planar reflection, built in Normal-map ripples (flat plane) Moderate — one reflection pass
Custom GLSL ocean High — write the shaders Only what you code (cube map or SSR) Real Gerstner wave displacement Tunable — depends on your shader
Three.js Ocean example Low — copy the example Planar reflection (same Water class) Normal-map ripples Moderate — same as the addon

This tutorial builds on the official Three.js water demo. See the reference example here: threejs.org — webgl_shaders_ocean.

The Full Code

Save as index.html next to your Three.js build/ and jsm/ folders and a waternormals.jpg, then serve the folder.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Three.js Water Shader</title>
  <style> body { margin: 0; overflow: hidden; } </style>
  <script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
  <script type="importmap">
    { "imports": { "three": "./build/three.module.js", "three/addons/": "./jsm/" } }
  </script>
</head>
<body>
<!-- Slider panel to tweak the sky, sun and water live -->
<div id="ui" style="position:fixed;top:12px;left:12px;z-index:10;background:rgba(0,0,0,0.6);color:#fff;
     font:13px system-ui,sans-serif;padding:12px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.15);width:210px">
  <label>Sun elevation: <span id="elVal">2</span>&deg;<br>
    <input id="elevation" type="range" min="0" max="90" step="0.5" value="2" style="width:100%"></label><br>
  <label>Sun direction: <span id="azVal">180</span>&deg;<br>
    <input id="azimuth" type="range" min="-180" max="180" step="1" value="180" style="width:100%"></label><br>
  <label>Water choppiness: <span id="dsVal">3.7</span><br>
    <input id="distortion" type="range" min="0" max="8" step="0.1" value="3.7" style="width:100%"></label><br>
  <label>Haze (turbidity): <span id="tbVal">10</span><br>
    <input id="turbidity" type="range" min="0" max="20" step="0.5" value="10" style="width:100%"></label>
</div>
<script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  import { Water } from 'three/addons/objects/Water.js';
  import { Sky } from 'three/addons/objects/Sky.js';

  const clock = new THREE.Clock();

  // Renderer, scene, camera
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.toneMapping = THREE.ReinhardToneMapping;
  document.body.appendChild(renderer.domElement);

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 1, 20000);
  camera.position.set(30, 30, 100);

  const sun = new THREE.Vector3();

  // Reflective water surface
  const water = new Water(
    new THREE.PlaneGeometry(10000, 10000),
    {
      textureWidth: 512,
      textureHeight: 512,
      waterNormals: new THREE.TextureLoader().load('waternormals.jpg', t => {
        t.wrapS = t.wrapT = THREE.RepeatWrapping;
      }),
      sunDirection: new THREE.Vector3(),
      sunColor: 0xffffff,
      waterColor: 0x001e0f,
      distortionScale: 3.7
    }
  );
  water.rotation.x = -Math.PI / 2;
  scene.add(water);

  // Sky + sun
  const sky = new Sky();
  sky.scale.setScalar(10000);
  scene.add(sky);
  sky.material.uniforms['turbidity'].value = 10;
  sky.material.uniforms['rayleigh'].value = 2;
  sky.material.uniforms['mieCoefficient'].value = 0.005;
  sky.material.uniforms['mieDirectionalG'].value = 0.8;

  const pmrem = new THREE.PMREMGenerator(renderer);
  const params = { elevation: 2, azimuth: 180 };
  function updateSun() {
    const phi = THREE.MathUtils.degToRad(90 - params.elevation);
    const theta = THREE.MathUtils.degToRad(params.azimuth);
    sun.setFromSphericalCoords(1, phi, theta);
    sky.material.uniforms['sunPosition'].value.copy(sun);
    water.material.uniforms['sunDirection'].value.copy(sun).normalize();
    scene.environment = pmrem.fromScene(sky).texture;
  }
  updateSun();

  // A mirror cube to show the reflections
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(30, 30, 30),
    new THREE.MeshStandardMaterial({ roughness: 0 })
  );
  scene.add(mesh);

  // Orbit camera
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.maxPolarAngle = Math.PI * 0.495;
  controls.target.set(0, 10, 0);
  controls.minDistance = 40;
  controls.maxDistance = 200;
  controls.enableZoom = false; // let the mouse wheel scroll the page instead of zooming
  controls.enablePan = false;
  controls.update();

  // Wire the sliders to the sky, sun and water
  const $ = id => document.getElementById(id);
  $('elevation').addEventListener('input', e => {
    params.elevation = parseFloat(e.target.value); $('elVal').textContent = e.target.value; updateSun();
  });
  $('azimuth').addEventListener('input', e => {
    params.azimuth = parseFloat(e.target.value); $('azVal').textContent = e.target.value; updateSun();
  });
  $('distortion').addEventListener('input', e => {
    water.material.uniforms['distortionScale'].value = parseFloat(e.target.value); $('dsVal').textContent = e.target.value;
  });
  $('turbidity').addEventListener('input', e => {
    sky.material.uniforms['turbidity'].value = parseFloat(e.target.value); $('tbVal').textContent = e.target.value; updateSun();
  });

  addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  function animate() {
    requestAnimationFrame(animate);
    const delta = clock.getDelta();
    const t = performance.now() * 0.001;
    mesh.position.y = Math.sin(t) * 20 + 5;
    mesh.rotation.x += delta * 0.5;
    mesh.rotation.z += delta * 0.51;
    water.material.uniforms['time'].value += delta;
    renderer.render(scene, camera);
  }
  animate();
</script>
</body>
</html>

What To Try Next

  • Animate params.elevation over time and call updateSun() for a sunrise-to-sunset cycle.
  • Change waterColor to a tropical turquoise or a stormy grey.
  • Raise distortionScale for choppier seas, lower it for a calm lake.
  • Float a GLTF boat or buoy on the surface and bob it with the same sine wave.
  • Add fog matching the horizon color for a hazy, atmospheric ocean.