Water shader

물 셰이더

A custom shader that ripples surface height with sine waves and adds fresnel and specular to fake a rolling water surface.

Also known as: Procedural wavesFresnel waterVertex-displaced water
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x030916);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1.1, 3.6);
camera.lookAt(0, -0.5, -4);

const vertexShader = [
  'uniform float uTime;',
  'varying vec3 vNormal;',
  'varying vec3 vViewDir;',
  'varying float vHeight;',
  'void main() {',
  '  vec3 p = position;',
  '  float h = sin(p.x * 1.6 + uTime * 1.3) * 0.18 + sin(p.y * 2.3 - uTime * 1.7) * 0.12 + sin((p.x + p.y) * 3.1 + uTime * 0.8) * 0.06;',
  '  p.z += h;',
  '  vHeight = h;',
  '  vec3 tangentX = normalize(vec3(1.0, 0.0, 1.6 * cos(p.x * 1.6 + uTime * 1.3) * 0.18));',
  '  vec3 tangentY = normalize(vec3(0.0, 1.0, 2.3 * cos(p.y * 2.3 - uTime * 1.7) * 0.12));',
  '  vNormal = normalize(normalMatrix * normalize(cross(tangentX, tangentY)));',
  '  vec4 mv = modelViewMatrix * vec4(p, 1.0);',
  '  vViewDir = normalize(-mv.xyz);',
  '  gl_Position = projectionMatrix * mv;',
  '}',
].join('\n');

const fragmentShader = [
  'varying vec3 vNormal;',
  'varying vec3 vViewDir;',
  'varying float vHeight;',
  'void main() {',
  '  vec3 n = normalize(vNormal);',
  '  vec3 v = normalize(vViewDir);',
  '  vec3 lightDir = normalize(vec3(0.4, 0.7, 0.5));',
  '  float fresnel = pow(1.0 - max(dot(n, v), 0.0), 3.0);',
  '  vec3 deep = vec3(0.02, 0.12, 0.28);',
  '  vec3 shallow = vec3(0.1, 0.45, 0.55);',
  '  vec3 base = mix(deep, shallow, clamp(vHeight * 1.5 + 0.5, 0.0, 1.0));',
  '  vec3 h = normalize(lightDir + v);',
  '  float spec = pow(max(dot(n, h), 0.0), 60.0);',
  '  vec3 color = mix(base, vec3(0.7, 0.85, 0.95), fresnel * 0.6) + spec * 0.9;',
  '  gl_FragColor = vec4(color, 1.0);',
  '}',
].join('\n');

const geo = new THREE.PlaneGeometry(12, 12, 130, 130);
const mat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader, fragmentShader });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  mat.uniforms.uTime.value = t * 0.001;
  renderer.render(scene, camera);
});

Convincing water needs two things layered together: geometry that actually rises and falls (the waves), and shading whose highlights shift with viewing angle (the reflection). This demo’s vertex shader blends three sin() waves at different frequencies and phases into a height (z), then recomputes each vertex’s normal by taking the cross product of two tangent vectors derived from that height function’s x/y derivatives — reuse the flat plane’s original normal, as in vertex-displacement, and the bumps read as flat no matter how much they move.

In the fragment shader, fresnel = pow(1 - dot(normal, viewDir), n) stays small when looking straight down (reads as seeing into the water) and climbs toward 1 at a grazing angle (reads as reflected sky) — that angle-dependent shift is what makes water look like water. Add a specular highlight from raising the angle between the half-vector (halfway between light and view direction) and the normal to a high power, and you get sunlight glinting off the ripples.

In production, the Water/Water2 addons provide a far more capable version with real normal-map textures and reflection/refraction render targets. This demo substitutes procedural sine waves because the site can’t load external images — the tradeoff is that the wave pattern visibly repeats.

When to use

Faking water quickly without external textures, or learning the principle before reaching for the fuller Water addon.