노멀 맵

Normal map

표면 법선(빛을 반사하는 방향)을 픽셀마다 다르게 기록한 텍스처로, 지오메트리를 늘리지 않고 굴곡진 디테일을 흉내 냅니다.

다른 이름: Tangent-space normal mapNormal mapping
···
html
<canvas id="nm-canvas"></canvas><span class="nm-cap">procedural normal map · moving light</span>
css
#nm-canvas{display:block;height:88%;width:auto;max-width:94%;border-radius:6px;image-rendering:pixelated}
.nm-cap{position:absolute;left:0;right:0;bottom:4%;text-align:center;font-size:clamp(9px,2.4vmin,12px);color:var(--muted)}
js
const canvas = document.getElementById('nm-canvas');
const ctx = canvas.getContext('2d');
const W = 128, H = 86;
canvas.width = W; canvas.height = H;
const bumps = [];
for (let i = 0; i < 9; i++) bumps.push({ x: Math.random() * W, y: Math.random() * H, r: 8 + Math.random() * 10 });
function heightAt(x, y) {
  let h = 0;
  for (const b of bumps) {
    const d = Math.hypot(x - b.x, y - b.y) / b.r;
    if (d < 1) h += Math.pow(Math.cos(d * Math.PI * 0.5), 2);
  }
  return h;
}
const nx = new Float32Array(W * H), ny = new Float32Array(W * H), nz = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const hl = heightAt(x - 1, y), hr = heightAt(x + 1, y), hu = heightAt(x, y - 1), hd = heightAt(x, y + 1);
    const vx = -(hr - hl) * 6, vy = -(hd - hu) * 6, vz = 1;
    const len = Math.hypot(vx, vy, vz);
    const i = y * W + x;
    nx[i] = vx / len; ny[i] = vy / len; nz[i] = vz / len;
  }
}
const img = ctx.createImageData(W, H);
function render(t) {
  const ang = t * 0.0007;
  const lx = Math.cos(ang), ly = Math.sin(ang) * 0.6, lz = 0.7;
  const llen = Math.hypot(lx, ly, lz);
  const Lx = lx / llen, Ly = ly / llen, Lz = lz / llen;
  for (let i = 0; i < W * H; i++) {
    const d = Math.max(0, nx[i] * Lx + ny[i] * Ly + nz[i] * Lz);
    const base = 40 + d * 255 * 0.75;
    const p = i * 4;
    img.data[p] = base * 0.65 + 20;
    img.data[p + 1] = base * 0.55 + 30;
    img.data[p + 2] = base * 0.95 + 50;
    img.data[p + 3] = 255;
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

면은 원래 하나의 평평한 법선(그 면이 향하는 방향)만 가지지만, 조명 계산은 정점이 아니라 픽셀 단위로 일어납니다. 노멀 맵은 이 픽셀 단위 법선을 RGB 채널에 인코딩해 둔 텍스처입니다 — R은 X, G는 Y, B는 Z 방향의 기울기를 나타내고, 그래서 전형적인 노멀 맵은 은은한 보라·파랑 색으로 보입니다.

렌더러는 표면을 그릴 때 이 텍스처에서 픽셀마다 법선을 읽어 와 조명 계산에 씁니다. 실제 형태는 여전히 평평하지만, 빛이 마치 굴곡을 타고 흐르는 것처럼 보입니다 — 실루엣(윤곽선)은 절대 바뀌지 않는다는 게 범프/디스플레이스먼트와 공유하는 특징이자 한계입니다.

Blender·Maya·C4D에서는 하이폴리 스컬프트를 로우폴리 위에 "베이크"해서 노멀 맵을 뽑는 게 표준 워크플로입니다(texture-baking 항목 참고). 이 데모는 그 과정 없이 절차적으로 볼록한 범프 몇 개의 높이 필드를 만들고, 거기서 직접 법선을 유도해 움직이는 빛으로 비춰 봅니다.

언제 쓰나

실시간 게임·웹 3D에서 폴리곤을 늘리지 않고 표면 디테일(주름, 리벳, 천 질감)을 더할 때.