補間(Interpolation)

2 つの値の中間を求める計算。トゥイーンの本体。 数値・座標・カラー・配列・角度それぞれの補間方法。

線形補間(lerp)

最も基本。t が 0〜1 の範囲で、ab の間を直線的に進む。

function lerp(a, b, t) {
  return a + (b - a) * t
}

lerp(0, 100, 0)    // 0
lerp(0, 100, 0.5)  // 50
lerp(0, 100, 1)    // 100
lerp(0, 100, 0.3)  // 30

逆 lerp(inverseLerp)

値からその位置(0〜1)を求める:

function invLerp(a, b, v) {
  return (v - a) / (b - a)
}

invLerp(0, 100, 30)  // 0.3

remap(再マッピング)

ある範囲を別の範囲に変換。「マウス X 座標を回転角度に」「スクロール量を opacity に」などで多用。

function remap(v, inMin, inMax, outMin, outMax) {
  return outMin + ((v - inMin) / (inMax - inMin)) * (outMax - outMin)
}

remap(50, 0, 100, 0, 1)       // 0.5
remap(800, 0, 1000, -10, 10)  // 6
remap(scrollY, 0, 500, 0, 1)  // よくある使い方

clamp

範囲を制限する。lerp / remap と組み合わせると範囲外で暴れない

function clamp(v, min = 0, max = 1) {
  return Math.min(Math.max(v, min), max)
}

const t = clamp((now - start) / duration)  // 0〜1 に確実に

smoothstep / smootherstep

境界が滑らかな t(イージングの一種)。GLSL 由来だが JS でも便利。

function smoothstep(edge0, edge1, x) {
  const t = clamp((x - edge0) / (edge1 - edge0))
  return t * t * (3 - 2 * t)   // 3t² - 2t³
}

function smootherstep(edge0, edge1, x) {
  const t = clamp((x - edge0) / (edge1 - edge0))
  return t * t * t * (t * (t * 6 - 15) + 10)  // 6t⁵ - 15t⁴ + 10t³
}

ベクトル / オブジェクトの補間

複数のフィールドを各成分ごとに lerp する。

function lerpObject(a, b, t) {
  const out = {}
  for (const key in a) {
    if (typeof a[key] === "number") {
      out[key] = a[key] + (b[key] - a[key]) * t
    } else {
      out[key] = a[key]
    }
  }
  return out
}

const start = { x: 0, y: 0, opacity: 0 }
const end   = { x: 200, y: 100, opacity: 1 }
lerpObject(start, end, 0.5)
// { x: 100, y: 50, opacity: 0.5 }

3D ベクトル

function lerpVec3(a, b, t) {
  return [
    a[0] + (b[0] - a[0]) * t,
    a[1] + (b[1] - a[1]) * t,
    a[2] + (b[2] - a[2]) * t,
  ]
}

// Three.js でも標準で使える
vec.lerp(target, t)
vec.lerpVectors(start, end, t)

角度の補間(最短経路)

単純 lerp だと「359° → 1°」が「-358°」を経由してしまう。±180° 以内を選ぶ。

function lerpAngle(a, b, t) {
  let diff = ((b - a + 540) % 360) - 180  // -180〜180
  return a + diff * t
}

lerpAngle(350, 10, 0.5)  // 360 (= 0)
// 単純 lerp だと 180 になってしまう

ラジアン版

const TAU = Math.PI * 2

function lerpRad(a, b, t) {
  let diff = ((b - a + Math.PI * 3) % TAU) - Math.PI
  return a + diff * t
}

カラー補間

RGB 補間(最も単純)

function lerpRgb(a, b, t) {
  return {
    r: Math.round(a.r + (b.r - a.r) * t),
    g: Math.round(a.g + (b.g - a.g) * t),
    b: Math.round(a.b + (b.b - a.b) * t),
  }
}

lerpRgb({r: 255, g: 0, b: 0}, {r: 0, g: 0, b: 255}, 0.5)
// { r: 128, g: 0, b: 128 }   暗紫色(中間が灰色っぽくなる問題)

RGB 直接補間は中間が暗くなるのが弱点。原因はガンマ補正されていない sRGB 空間で線形補間しているから。

線形 RGB 補間(より正しい)

// sRGB → linear
function srgbToLinear(c) {
  c /= 255
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
function linearToSrgb(c) {
  const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1/2.4) - 0.055
  return Math.round(Math.min(Math.max(v, 0), 1) * 255)
}

function lerpRgbLinear(a, b, t) {
  const lerpCh = (ca, cb) =>
    linearToSrgb(srgbToLinear(ca) + (srgbToLinear(cb) - srgbToLinear(ca)) * t)
  return { r: lerpCh(a.r, b.r), g: lerpCh(a.g, b.g), b: lerpCh(a.b, b.b) }
}

HSL 補間(色相を回す)

function lerpHsl(a, b, t) {
  return {
    h: lerpAngle(a.h, b.h, t),  // 色相は環状なので最短経路
    s: a.s + (b.s - a.s) * t,
    l: a.l + (b.l - a.l) * t,
  }
}

OKLCH / OKLAB(最も知覚的に均一)

2024 年以降の Web で標準化された知覚均一な色空間。CSS の color-mix(in oklch, ...) もこれ。 中間色がきれい。

// CSS Color Module Level 4 を使えるなら
element.style.color = `color-mix(in oklch, red ${(1-t)*100}%, blue)`

カラー文字列のパース

function parseHex(hex) {
  hex = hex.replace("#", "")
  if (hex.length === 3) hex = hex.split("").map(c => c + c).join("")
  return {
    r: parseInt(hex.slice(0, 2), 16),
    g: parseInt(hex.slice(2, 4), 16),
    b: parseInt(hex.slice(4, 6), 16),
  }
}

function rgbString({ r, g, b }) {
  return `rgb(${r}, ${g}, ${b})`
}

const start = parseHex("#ff0000")
const end   = parseHex("#0000ff")
const mid   = lerpRgb(start, end, 0.5)
element.style.color = rgbString(mid)

配列の補間

function lerpArray(a, b, t) {
  return a.map((v, i) => v + (b[i] - v) * t)
}

lerpArray([0, 0, 0], [10, 20, 30], 0.5)
// [5, 10, 15]

クォータニオン(3D 回転)の補間

3D 回転はクォータニオン (Quaternion) で表現するのが標準。線形補間ではなく slerp(球面線形補間)を使う。 Three.js の Quaternion.slerp がそれ。

// Three.js で
quat.slerpQuaternions(qStart, qEnd, t)

// 短く
quat.slerp(qTarget, t)

damp(フレームレート独立な lerp)

毎フレーム同じ t で lerp するとFPS で速度が変わる。 指数減衰の式にするとフレームレート独立になる。

// dt: 経過時間(秒), smoothing: 0.001〜0.1(0 で即追従、大きいほどゆっくり)
function damp(current, target, smoothing, dt) {
  return target + (current - target) * Math.exp(-dt / smoothing)
}

// 使い方
let x = 0
function loop(now) {
  const dt = (now - prev) / 1000
  x = damp(x, mouseX, 0.1, dt)  // mouseX に向かって減衰追従
  ...
}

damp の挙動

damp は終わりがない(asymptotic)。100% は到達しない。マウス追従などの連続的な追従に向く。 トゥイーンとは別ジャンル。

ステップ補間

パラパラ漫画的な、補間しない離散値。

function step(values, t) {
  const i = Math.min(Math.floor(t * values.length), values.length - 1)
  return values[i]
}

step(["a", "b", "c", "d"], 0.5)  // "c"

カトマルロムスプライン(4 点を通る滑らか曲線)

複数のキーフレームを滑らかにつなぐのに使う。3D パスやカメラワークで便利。

function catmullRom(p0, p1, p2, p3, t) {
  const t2 = t * t
  const t3 = t2 * t
  return 0.5 * (
    (2 * p1) +
    (-p0 + p2) * t +
    (2*p0 - 5*p1 + 4*p2 - p3) * t2 +
    (-p0 + 3*p1 - 3*p2 + p3) * t3
  )
}

// 配列の中で適切な 4 点を選ぶ
function spline(points, t) {
  const seg = (points.length - 1) * t
  const i = Math.floor(seg)
  const localT = seg - i
  const p0 = points[Math.max(i - 1, 0)]
  const p1 = points[i]
  const p2 = points[Math.min(i + 1, points.length - 1)]
  const p3 = points[Math.min(i + 2, points.length - 1)]
  return catmullRom(p0, p1, p2, p3, localT)
}

Three.js には CatmullRomCurve3 として完成版がある。

ベジェ曲線

2 次ベジェ(3 点)

function quadBezier(p0, p1, p2, t) {
  const u = 1 - t
  return u*u*p0 + 2*u*t*p1 + t*t*p2
}

3 次ベジェ(4 点、CSS の cubic-bezier と同じ)

function cubicBezier(p0, p1, p2, p3, t) {
  const u = 1 - t
  return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3
}

CSS の cubic-bezier(.25, .1, .25, 1) はこれを 1 次元のイージング関数として使ったもの。

SVG パスに沿わせる

const path = document.querySelector("path")
const total = path.getTotalLength()

function loop(now) {
  const t = ((now / 1000) % 5) / 5  // 5 秒で 1 周
  const pt = path.getPointAtLength(t * total)
  element.style.transform = `translate(${pt.x}px, ${pt.y}px)`
  requestAnimationFrame(loop)
}

Bezier ベースのイージング

CSS の cubic-bezier(x1, y1, x2, y2) を JS で再現する場合、(0,0) と (1,1) を端点にした 2 次元のベジェから x が t になる位置を逆算 → その位置の y を返す。

function cubicBezierEase(x1, y1, x2, y2) {
  // ニュートン法で x → t を解いて y を返す
  return function(x) {
    let t = x
    for (let i = 0; i < 8; i++) {
      const ct = cubicBezier(0, x1, x2, 1, t) - x
      const dt = 3*(1-t)*(1-t)*x1 + 6*(1-t)*t*(x2-x1) + 3*t*t*(1-x2)
      if (Math.abs(dt) < 1e-6) break
      t -= ct / dt
    }
    return cubicBezier(0, y1, y2, 1, t)
  }
}

const easeInOut = cubicBezierEase(0.42, 0, 0.58, 1)
easeInOut(0.5)

整数の補間(離散値)

スコアの増加など、整数で表示したいとき:

function lerpInt(a, b, t) {
  return Math.round(a + (b - a) * t)
}

文字列の補間

単純な文字列はできないが、「タイプライター効果」のような文字数を増やすことはできる:

function lerpString(text, t) {
  const len = Math.round(text.length * t)
  return text.slice(0, len)
}

使い分けまとめ

状況関数
数値の中間lerp
マウス追従damp
角度lerpAngle
RGB の高品質補間lerpRgbLinear / OKLCH
3D 回転Quaternion.slerp
多点を滑らかcatmullRom
イージング曲線cubicBezierEase
脳に刻む 1 行

lerp(a, b, t) = a + (b - a) * t。これを起点に、次元 / 色空間 / 球面 / 曲線へ 展開していくだけ。