Frontend Development 13 min read

Step‑by‑Step Ocean Simulation with three.js

This tutorial walks readers through building a realistic, animated ocean scene in three.js, covering project initialization, geometry creation, custom shaders, wave calculations, and dynamic boat positioning and rotation using JavaScript and WebGL techniques.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Step‑by‑Step Ocean Simulation with three.js

After a long hiatus, the author presents a detailed three.js tutorial that guides readers to render a dynamic ocean and a boat that reacts to wave motion.

1. Initialize the three.js project

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
let renderer, camera, scene, controls, clock, lineHelper;
// Renderer
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
// Camera
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(0, 10, 20);
// Resize handling
function resize() {
    renderer.setSize(window.innerWidth, window.innerHeight);
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize, false);
// Scene and controls
scene = new THREE.Scene();
controls = new OrbitControls(camera, renderer.domElement);
clock = new THREE.Clock();
function render() {
    requestAnimationFrame(render);
    const elapsedTime = clock.getElapsedTime();
    controls.update();
    renderer.render(scene, camera);
}

1.2 Add basic 3D objects

// Lights
const light = new THREE.DirectionalLight(0xffffff, 0.5);
light.position.set(0, 10, 20);
scene.add(light);
const light2 = new THREE.DirectionalLight(0xffffff, 0.1);
light2.position.set(-5, 5, -5);
scene.add(light2);
const ambient = new THREE.AmbientLight(0xffffff, 0.2);
scene.add(ambient);
// Boat (a simple box)
box = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshLambertMaterial());
scene.add(box);
// Direction line helper
const helperGeometry = new THREE.BufferGeometry();
helperGeometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0,0,0, 0,5,0]), 3));
const lineHelper = new THREE.LineSegments(helperGeometry, new THREE.MeshBasicMaterial({color: 0xff0000, depthTest:false}));
scene.add(lineHelper);

2. Create the ocean surface

First a dense plane (100 × 100 with 500 × 500 vertices) is generated so that vertex positions can be altered later.

let material;
material = new THREE.ShaderMaterial({wireframe:true});
const geometry = new THREE.PlaneGeometry(100, 100, 500, 500);
geometry.rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

Next a custom vertex and fragment shader are added. The vertex shader computes a height value using several sine functions, while the fragment shader samples a water texture and adds color.

const SCALE = 5;
const vertexShader = `
    #define SCALE ${SCALE}.0
    varying vec2 vUv;
    uniform float uTime;
    float calculateSurface(float x, float z) {
        float y = 0.0;
        y += (sin(x * 1.0 / SCALE + uTime) + sin(x * 2.3 / SCALE + uTime * 1.5) + sin(x * 3.3 / SCALE + uTime * 0.4)) / 3.0;
        y += (sin(z * 0.2 / SCALE + uTime * 1.8) + sin(z * 1.8 / SCALE + uTime * 1.8) + sin(z * 2.8 / SCALE + uTime * 0.8)) / 3.0;
        return y;
    }
    void main(){
        vUv = uv;
        vec3 pos = position;
        pos.y += calculateSurface(pos.x, pos.z);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos,1.0);
    }
`;
const fragmentShader = `
    varying vec2 vUv;
    uniform sampler2D uMap;
    uniform float uTime;
    uniform vec3 uColor;
    void main(){
        vec2 uv = vUv * 10.0 + vec2(uTime * -0.05);
        uv.y += 0.01 * (sin(uv.x * 3.5 + uTime * 0.35) + sin(uv.x * 4.8 + uTime * 1.05) + sin(uv.x * 7.3 + uTime * 0.45)) / 3.0;
        uv.x += 0.12 * (sin(uv.y * 4.0 + uTime * 0.5) + sin(uv.y * 6.8 + uTime * 0.75) + sin(uv.y * 11.3 + uTime * 0.2)) / 3.0;
        vec4 tex1 = texture2D(uMap, uv);
        vec4 tex2 = texture2D(uMap, uv + vec2(0.2));
        vec3 blue = uColor;
        gl_FragColor = vec4(blue + vec3(tex1.a * 0.9 - tex2.a * 0.02), 1.0);
    }
`;
const texture = new THREE.TextureLoader().load('./textures/water.png');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const uniforms = {uMap:{value:texture}, uTime:{value:0}, uColor:{value:new THREE.Color('#0051da')}, depthTest:true, depthWrite:true};
material = new THREE.ShaderMaterial({uniforms, vertexShader, fragmentShader, side:THREE.DoubleSide, wireframe:true});
const oceanGeometry = new THREE.PlaneGeometry(100,100,500,500);
oceanGeometry.rotateX(-Math.PI/2);
const oceanMesh = new THREE.Mesh(oceanGeometry, material);
scene.add(oceanMesh);

In the render loop the uniform uTime is updated:

material.uniforms.uTime.value = clock.getElapsedTime();

3. Update boat height according to the wave function

const position = box.position;
const {x, z} = position;
position.y = (sin(x / SCALE + elapsedTime) + sin(2.3*x / SCALE + 1.5*elapsedTime) + sin(3.3*x / SCALE + 0.4*elapsedTime)) / 3.0;
position.y += (sin(0.2*z / SCALE + 1.8*elapsedTime) + sin(1.8*z / SCALE + 1.8*elapsedTime) + sin(2.8*z / SCALE + 0.8*elapsedTime)) / 3.0;

4. Compute boat orientation and acceleration

Derivatives of the wave function give the surface slope (kx, kz). The surface normal n = new THREE.Vector3(-kx, 1, -kz).normalize() and the rotation axis are derived via cross products, then the boat is rotated accordingly.

function dx(x,t){return 1/3*(Math.cos(x/SCALE + t)/SCALE + Math.cos(2.3*x/SCALE + 1.5*t)*2.3/SCALE + Math.cos(3.3*x/SCALE + 0.4*t)*3.3/SCALE);}
function dz(z,t){return 1/3*(Math.cos(0.2*z/SCALE + 1.8*t)*0.2/SCALE + Math.cos(1.8*z/SCALE + 1.8*t)*1.8/SCALE + Math.cos(2.8*z/SCALE + 0.8*t)*2.8/SCALE);}
const kx = dx(x, elapsedTime);
const kz = dz(z, elapsedTime);
const n = new THREE.Vector3(-kx, 1, -kz).normalize();
const axes = new THREE.Vector3().crossVectors(n, new THREE.Vector3(kx,1,kz)).normalize();
function getAngleBetweenVectors(v1,v2){let dot=v1.dot(v2); if(dot>0.99995) return 0; if(dot<-0.99995) return Math.PI; return Math.acos(dot);}
const angle = getAngleBetweenVectors(new THREE.Vector3(0,1,0), n);
box.rotation.set(0,0,0);
box.rotateOnAxis(axes, -angle);
// Acceleration
const speed = new THREE.Vector3();
const dir = new THREE.Vector3().crossVectors(n, axes).normalize().divideScalar(100);
const newSpeed = speed.add(dir);
const endPosition = box.position.clone().addScaledVector(newSpeed, 1);
const y = (sin(x / SCALE + elapsedTime) + sin(2.3*x / SCALE + 1.5*elapsedTime) + sin(3.3*x / SCALE + 0.4*elapsedTime)) / 3.0 +
          (sin(0.2*z / SCALE + 1.8*elapsedTime) + sin(1.8*z / SCALE + 1.8*elapsedTime) + sin(2.8*z / SCALE + 0.8*elapsedTime)) / 3.0;
const truePosition = new THREE.Vector3(endPosition.x, y, endPosition.z);
box.position.copy(truePosition);

The final result is an animated ocean with a boat that rises, tilts, and accelerates naturally as the waves move.

javascriptThree.jsWebGLShader3D graphicsOcean simulation
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.