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.
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.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.