물 셰이더

Water shader

사인파로 표면 높이를 흔들고 프레넬·스페큘러를 더해 물결치는 수면을 흉내 내는 커스텀 셰이더.

다른 이름: 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);
});

물 표면은 두 가지가 합쳐져야 그럴듯합니다: 정점이 실제로 오르내리는 파동(geometry)과, 보는 각도에 따라 반짝임이 달라지는 반사(shading). 이 데모의 정점 셰이더는 sin() 함수 세 개를 다른 주파수·위상으로 섞어 높이(z)를 만들고, 그 높이 함수를 x·y 방향으로 미분한 접선 벡터 두 개를 외적(cross)해서 매 정점의 법선을 다시 계산합니다 — vertex-displacement 항목처럼 원래 평면의 법선을 그대로 쓰면 굴곡이 있어도 표면이 평평하게 보입니다.

프래그먼트 셰이더의 fresnel = pow(1 - dot(normal, viewDir), n)은 표면을 정면으로 볼 때는 작고(물속이 비쳐 보이는 느낌) 스치듯 볼 때는 1에 가까워져(하늘이 반사되는 느낌) 물 특유의 각도 의존적인 색을 만듭니다. 여기에 half-vector(빛과 시선의 중간 방향)와 법선의 각도를 아주 높은 지수로 제곱해 만드는 스펙큘러 하이라이트를 더하면 햇빛이 수면에 부서지는 반짝임이 생깁니다.

실제 프로덕션에서는 THREE.Water/Water2 애드온이 진짜 노멀맵 텍스처와 반사·굴절 렌더 타깃까지 갖춘 훨씬 정교한 버전을 제공합니다. 이 데모는 외부 이미지를 쓸 수 없는 이 사이트 제약 때문에 절차적 사인파로 대신했습니다 — 파형이 규칙적으로 반복되는 티가 난다는 게 절충점입니다.

언제 쓰나

외부 텍스처 없이 빠르게 물 느낌을 내야 할 때, 또는 더 정교한 Water 애드온 전에 원리를 익힐 때.