線形補間とベジェ曲線

CG

パラメータ形式 は、パラメータを動かせば曲線上の点が次々と得られる、扱いやすい表し方でした。
しかし、そもそもパラメータを動かして曲線を得るには、2次曲線 のように、パラメータを含む数式を知っている必要があります。車体や翼の断面のような形を作りたいとき、その形になる関数を知る手がかりがありません。

ベジェ曲線の具体例
CG

そこで、関数を直接考えるのをやめ、いくつかの点の置き方で曲線の形を操れるようにしたのがベジェ曲線です。形を決めるために置くこれらの点を制御点といいます。フランスの自動車メーカーで車体の設計に用いた、ピエール・ベジェの名が付いています。

線形補間(1次ベジェ曲線?)

ベジェ曲線を理解する上で重要となるのは、ベジェ曲線上の点の座標 は、制御点の座標値をある比率で混合することで表されるということです。パラメータ を0から1の間で動かすと、その混合比が連続的に変化して、 の軌跡が曲線になります。

曲線に入る前に、まずは直線を描くことで、この考え方を探ってみましょう。

2つの制御点 を用意し、その間を直線で結びます。
この線分上の点 は、パラメータ を使って、次のように表すことができます。

を動かすことで、点 が線分上で制御点 のどちらに近づいていくが変化します。

Action

tを動かして、点C(t)が線分をどう内分するかに注目しよう

Three.jsによる実装概要
// 2 つの制御点。この 2 点を結ぶ線分の上を C(t) が動く
const P0 = new Vector3(-2.2, -1.25, 0)
const P1 = new Vector3(2.2, 1.25, 0)

// C(t) = (1 − t)P0 + tP1。座標値を 1 − t : t の割合で混ぜ合わせる
const interpolate = (t: number, target: Vector3) =>
  target
    .copy(P0)
    .multiplyScalar(1 - t)
    .addScaledVector(P1, t)

// 両端が t で動く線分。頂点を作り直さず、座標だけ書き換える
const createSegment = (color: string) => {
  const geometry = new BufferGeometry()
  const positions = new Float32BufferAttribute(new Float32Array(6), 3)
  geometry.setAttribute("position", positions)
  const material = new LineBasicMaterial({ color })
  const line = new LineSegments(geometry, material)
  // 端点が動くので、あらかじめ計算した範囲に頼らず常に描く
  line.frustumCulled = false

  return {
    object: line,
    set: (from: Vector3, to: Vector3) => {
      positions.setXYZ(0, from.x, from.y, from.z)
      positions.setXYZ(1, to.x, to.y, to.z)
      positions.needsUpdate = true
    }
  }
}

// 区間の広がりを示す両矢印。区間は線分に添えるものなので、軸も矢じりも
// 半透明にして線分より沈ませる。矢じりの円錐は既定で +y を向いているので、
// 線分の向きへ回してから先端を端点に合わせる
const createMeasureArrow = (color: string) => {
  const group = new Group()

  const shaftGeometry = new BufferGeometry()
  const shaftPositions = new Float32BufferAttribute(new Float32Array(6), 3)
  shaftGeometry.setAttribute("position", shaftPositions)
  const shaftMaterial = new LineBasicMaterial({ color, transparent: true, opacity: ARROW_OPACITY })
  const shaft = new LineSegments(shaftGeometry, shaftMaterial)
  shaft.frustumCulled = false
  group.add(shaft)

  const headGeometry = new ConeGeometry(ARROW_HEAD_RADIUS, ARROW_HEAD_LENGTH, 12)
  const headMaterial = new MeshBasicMaterial({ color, transparent: true, opacity: ARROW_OPACITY })
  const heads = [new Mesh(headGeometry, headMaterial), new Mesh(headGeometry, headMaterial)]
  group.add(heads[0], heads[1])

  const direction = new Vector3()

  return {
    object: group,
    set: (from: Vector3, to: Vector3) => {
      shaftPositions.setXYZ(0, from.x, from.y, from.z)
      shaftPositions.setXYZ(1, to.x, to.y, to.z)
      shaftPositions.needsUpdate = true
      direction.subVectors(to, from).normalize()
      const angle = Math.atan2(direction.y, direction.x)
      heads[0].rotation.z = angle + Math.PI / 2
      heads[0].position.copy(from).addScaledVector(direction, ARROW_HEAD_LENGTH / 2)
      heads[1].rotation.z = angle - Math.PI / 2
      heads[1].position.copy(to).addScaledVector(direction, -ARROW_HEAD_LENGTH / 2)
    }
  }
}

// 線分は C(t) を境に 2 つに分かれる。P0 側には P0 にかかる重み 1 − t、P1 側には P1 にかかる重み t を添える
const t = 0.3
const current = interpolate(t, new Vector3())
const near = createSegment(NEAR_COLOR)
const far = createSegment(FAR_COLOR)
near.set(P0, current)
far.set(current, P1)
scene.add(near.object, far.object)

// 線分に対して下向きの単位ベクトル。矢印を線分から逃がす向きに使う
const below = new Vector3(P1.y - P0.y, P0.x - P1.x, 0).normalize()

// 2 つの制御点にかかる重みを示す 2 本の矢印。線分と平行に、少し下へずらして並べる
const nearArrow = createMeasureArrow(NEAR_COLOR)
const farArrow = createMeasureArrow(FAR_COLOR)
nearArrow.set(
  P0.clone().addScaledVector(below, ARROW_OFFSET),
  current.clone().addScaledVector(below, ARROW_OFFSET)
)
farArrow.set(
  current.clone().addScaledVector(below, ARROW_OFFSET),
  P1.clone().addScaledVector(below, ARROW_OFFSET)
)
scene.add(nearArrow.object, farArrow.object)

// 自分で置いた 2 つの制御点と、t から計算で決まる点
const controlGeometry = new SphereGeometry(CONTROL_RADIUS, 16, 12)
const controlMaterial = new MeshBasicMaterial({ color: CONTROL_COLOR })
const start = new Mesh(controlGeometry, controlMaterial)
const end = new Mesh(controlGeometry, controlMaterial)
start.position.set(P0.x, P0.y, LAYER_POINT)
end.position.set(P1.x, P1.y, LAYER_POINT)
const markerGeometry = new SphereGeometry(MARKER_RADIUS, 16, 12)
const markerMaterial = new MeshBasicMaterial({ color: MARKER_COLOR })
const marker = new Mesh(markerGeometry, markerMaterial)
marker.position.set(current.x, current.y, LAYER_POINT)
scene.add(start, end, marker)

の間を点で埋めていくことは、 の間の足りない点を計算して滑らかに補うことにあたります。この補う操作を補間、直線の場合は特に線形補間(linear interpolation)と呼びます。

線形補間を行う関数は、lerpやmixという名前で呼ばれることも多くあります。lerpはlinear interpolationの略、mixは、まさにある比率(直線では )で座標値を混合することを表していますね。

2次ベジェ曲線

曲線を表現するためには、さらにもう1つの制御点 を追加する必要があります。

2つの線分が で繋がっているとし、 の間、 の間をそれぞれ線形補間した点を求め、それらを線分で結んでパラメータを動かすことで、 の間を繋ぐ曲線が現れます。

Action

tを動かすと、各線分の内分点が動き、C(t)が動いた跡が曲線を描くことを観察しよう

Three.jsによる実装概要
// 3 つの制御点。2 本の線分が開いた形になる位置に固定で置く
const P0 = new Vector3(-2.6, -1.1, 0)
const P1 = new Vector3(0, 1.7, 0)
const P2 = new Vector3(2.6, -1.1, 0)

// 線形補間。2 点の座標値を 1 − t : t の割合で混ぜる
const lerp = (from: Vector3, to: Vector3, t: number, target: Vector3) =>
  target
    .copy(from)
    .multiplyScalar(1 - t)
    .addScaledVector(to, t)

// 2 次ベジェ曲線上の点 C(t)。
// P0P1 上の点 Q0(t) と P1P2 上の点 Q1(t) を、同じ t でもう一度線形補間して求める
const quadraticPoint = (t: number, target: Vector3) =>
  lerp(lerp(P0, P1, t, qa), lerp(P1, P2, t, qb), t, target)

// 頂点が t で動く折れ線。頂点を作り直さず、座標だけ書き換える
const createPolyline = (count: number, z: number) => {
  const geometry = new BufferGeometry()
  const positions = new Float32BufferAttribute(new Float32Array(count * 3), 3)
  geometry.setAttribute("position", positions)

  return {
    geometry,
    set: (index: number, point: Vector3) => positions.setXYZ(index, point.x, point.y, z),
    commit: () => {
      positions.needsUpdate = true
    }
  }
}

// 制御点を順に結んだ 2 本の線分。制御点は動かないので 1 度組めばよい
const edgeGeometry = new BufferGeometry().setFromPoints(
  [P0, P1, P2].map((point) => new Vector3(point.x, point.y, LAYER_EDGE))
)
scene.add(new Line(edgeGeometry, new LineBasicMaterial({ color: EDGE_COLOR })))

// 2 本の線分を同じ数に分け、同じ番号の分点どうしを結ぶ。この束の縁に曲線が浮かび上がる
const familyPoints: Vector3[] = []
for (let i = 0; i <= DIVISION_COUNT; i++) {
  const ratio = i / DIVISION_COUNT
  familyPoints.push(lerp(P0, P1, ratio, new Vector3()), lerp(P1, P2, ratio, new Vector3()))
}
const familyMaterial = new LineBasicMaterial({
  color: CHORD_COLOR,
  transparent: true,
  opacity: FAMILY_OPACITY
})
scene.add(new LineSegments(new BufferGeometry().setFromPoints(familyPoints), familyMaterial))

// 束のうち、今の t にあたる 1 本
const t = 0.35
const first = lerp(P0, P1, t, new Vector3())
const second = lerp(P1, P2, t, new Vector3())
const chord = createSegment(CHORD_COLOR, LAYER_CHORD)
chord.set(first, second)
scene.add(chord.object)

// C が 0 から今の t までに通った跡。折れ線で近似する
const trace = createPolyline(TRACE_SEGMENTS + 1, LAYER_TRACE)
for (let i = 0; i <= TRACE_SEGMENTS; i++) {
  trace.set(i, quadraticPoint((t * i) / TRACE_SEGMENTS, sample))
}
trace.commit()
scene.add(new Line(trace.geometry, new LineBasicMaterial({ color: TRACE_COLOR })))

// 形を決めている 3 つの制御点
const controlGeometry = new SphereGeometry(CONTROL_RADIUS, 16, 12)
const controlMaterial = new MeshBasicMaterial({ color: CONTROL_COLOR })
for (const point of [P0, P1, P2]) {
  const mesh = new Mesh(controlGeometry, controlMaterial)
  mesh.position.set(point.x, point.y, LAYER_POINT)
  scene.add(mesh)
}

// 今の t が 2 本の線分を分けている 2 点と、そのあいだをさらに同じ割合で分けた点 C(t)
const knotGeometry = new SphereGeometry(KNOT_RADIUS, 16, 12)
const knotMaterial = new MeshBasicMaterial({ color: CHORD_COLOR })
const knot0 = new Mesh(knotGeometry, knotMaterial)
const knot1 = new Mesh(knotGeometry, knotMaterial)
knot0.position.set(first.x, first.y, LAYER_POINT)
knot1.position.set(second.x, second.y, LAYER_POINT)
const markerGeometry = new SphereGeometry(MARKER_RADIUS, 16, 12)
const markerMaterial = new MeshBasicMaterial({ color: MARKER_COLOR })
const marker = new Mesh(markerGeometry, markerMaterial)
const current = lerp(first, second, t, new Vector3())
marker.position.set(current.x, current.y, LAYER_POINT)
scene.add(knot0, knot1, marker)

このベジェ曲線の制御点は、 の3つです。制御点を結ぶ線分上に並ぶ点は、この3点から計算で求まります。

の間を内分した点を 、 の間も同様の比率で内分した点を と書くことにしましょう。制御点を結ぶ2本の線分は直線なので、どちらも線形補間の式で表せます。

そして、 と を結ぶ線分を、もう一度同じ で線形補間した点が です。

と に線形補間の式を代入して展開すると、3つの制御点だけで書かれた式になります。

こうして、 は の2次多項式として表されるため、この曲線は2次ベジェ曲線と呼ばれます。

制御多角形

制御点を順に線分で結んだ折れ線を制御多角形といいます。
ベジェ曲線は、この折れ線の角の内部で丸まったような曲線になります。ベジェ曲線は制御多角形の角には届かず、必ずその内側に収まる性質があります。

Action

好きな制御点をドラッグで動かし、制御多角形と曲線の形を変化させてみよう

Three.jsによる実装概要
// 制御点が 3 つの側と 4 つの側。どちらもすべての点をドラッグで動かせる
const QUADRATIC_POINTS: [number, number][] = [
  [-1.6, -1],
  [0, 1.4],
  [1.6, -1]
]
const CUBIC_POINTS: [number, number][] = [
  [-1.7, -1],
  [-0.6, 1.4],
  [0.7, 1.4],
  [1.7, -1]
]

// ド・カステリョのアルゴリズムで使う作業用の点。何度も呼ばれるので、その都度は作らない
const work: Vector3[] = []

// 制御点が何個でも使えるベジェ曲線上の点。
// 隣り合う点どうしを t で線形補間する操作を、点が 1 つになるまで繰り返す
const bezierPoint = (controls: Vector3[], t: number, target: Vector3) => {
  while (work.length < controls.length) work.push(new Vector3())
  controls.forEach((control, i) => work[i].copy(control))

  for (let last = controls.length - 1; last > 0; last--) {
    for (let i = 0; i < last; i++) work[i].lerp(work[i + 1], t)
  }

  return target.copy(work[0])
}

// 頂点が動く折れ線。頂点を作り直さず、座標だけ書き換える
const createPolyline = (count: number, z: number) => {
  const geometry = new BufferGeometry()
  const positions = new Float32BufferAttribute(new Float32Array(count * 3), 3)
  geometry.setAttribute("position", positions)

  return {
    geometry,
    set: (index: number, point: Vector3) => positions.setXYZ(index, point.x, point.y, z),
    commit: () => {
      positions.needsUpdate = true
      geometry.computeBoundingSphere()
    }
  }
}

// 制御多角形の内側を塗る面。頂点を 1 番目の制御点から扇状に三角形へ分け、
// 折れ線と同じ頂点をそのまま使う
const createPolygonFill = (count: number) => {
  const geometry = new BufferGeometry()
  const positions = new Float32BufferAttribute(new Float32Array(count * 3), 3)
  geometry.setAttribute("position", positions)
  const index: number[] = []
  for (let i = 1; i < count - 1; i++) index.push(0, i, i + 1)
  geometry.setIndex(index)

  const material = new MeshBasicMaterial({
    color: POLYGON_COLOR,
    transparent: true,
    opacity: POLYGON_FILL_OPACITY,
    // 制御点を動かすと表裏が入れ替わりうるので、どちらから見ても塗る
    side: DoubleSide,
    // 手前に重なる曲線・破線が面の形に欠けないよう、深度は書かない
    depthWrite: false
  })

  return {
    object: new Mesh(geometry, material),
    set: (i: number, point: Vector3) => positions.setXYZ(i, point.x, point.y, LAYER_FILL),
    commit: () => {
      positions.needsUpdate = true
    }
  }
}

// 制御多角形(破線と塗り)とベジェ曲線(実線)を重ねた 1 枚のパネル
const createPanel = (source: [number, number][], offsetX: number) => {
  const group = new Group()
  group.position.x = offsetX

  const controls = source.map(([x, y]) => new Vector3(x, y, 0))
  const sample = new Vector3()

  // 制御多角形の内側。破線より奥に敷き、面としての広がりを示す
  const fill = createPolygonFill(controls.length)
  group.add(fill.object)

  // 制御点を順に結んだ折れ線。曲線と描き分けるため破線にする
  const polygon = createPolyline(controls.length, LAYER_POLYGON)
  const polygonMaterial = new LineDashedMaterial({
    color: POLYGON_COLOR,
    dashSize: DASH_SIZE,
    gapSize: GAP_SIZE
  })
  const polygonLine = new Line(polygon.geometry, polygonMaterial)
  group.add(polygonLine)

  // 制御点から求めた曲線
  const curve = createPolyline(CURVE_SEGMENTS + 1, LAYER_CURVE)
  group.add(new Line(curve.geometry, new LineBasicMaterial({ color: CURVE_COLOR })))

  // 制御点。どれもドラッグで動かせるので同じ見た目にし、掴んでいる 1 つだけ色を変える
  const controlGeometry = new SphereGeometry(CONTROL_RADIUS, 16, 12)
  const controlMaterial = new MeshBasicMaterial({ color: CONTROL_COLOR })
  const activeMaterial = new MeshBasicMaterial({ color: ACTIVE_COLOR })
  const meshes = controls.map(() => {
    const mesh = new Mesh(controlGeometry, controlMaterial)
    group.add(mesh)
    return mesh
  })

  // 制御点の今の位置から、制御多角形と曲線を引き直す
  const refresh = () => {
    controls.forEach((control, i) => {
      polygon.set(i, control)
      fill.set(i, control)
      meshes[i].position.set(control.x, control.y, LAYER_POINT)
    })
    polygon.commit()
    fill.commit()
    // 破線の刻みは頂点ごとの「線に沿った距離」で決まるため、頂点を動かすたびに測り直す
    polygonLine.computeLineDistances()

    for (let i = 0; i <= CURVE_SEGMENTS; i++) {
      curve.set(i, bezierPoint(controls, i / CURVE_SEGMENTS, sample))
    }
    curve.commit()
  }

  refresh()

  return {
    object: group,
    offsetX,
    controls,
    setActive: (index: number, active: boolean) => {
      meshes[index].material = active ? activeMaterial : controlMaterial
    },
    // ワールド座標で受け取った位置をパネルの座標へ直し、動かせる範囲に収めて置き直す
    move: (index: number, worldX: number, worldY: number) => {
      const x = Math.min(Math.max(worldX - offsetX, DRAG_MIN_X), DRAG_MAX_X)
      const y = Math.min(Math.max(worldY, DRAG_MIN_Y), DRAG_MAX_Y)
      controls[index].set(x, y, 0)
      refresh()
    }
  }
}

// 制御点の数だけを変えた 2 枚を左右に並べ、同じ操作での違いを見比べられるようにする
const panels = [
  createPanel(QUADRATIC_POINTS, -PANEL_OFFSET),
  createPanel(CUBIC_POINTS, PANEL_OFFSET)
]
panels.forEach((panel) => scene.add(panel.object))

// ポインタの位置は、図がすべて載っている z = 0 の平面との交点として求める
const canvas = renderer.domElement
const raycaster = new Raycaster()
const pointer = new Vector2()
const dragPlane = new Plane(new Vector3(0, 0, 1), 0)
const hit = new Vector3()

const toScenePoint = (event: PointerEvent) => {
  const bounds = canvas.getBoundingClientRect()
  pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1
  pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1
  raycaster.setFromCamera(pointer, camera)

  return raycaster.ray.intersectPlane(dragPlane, hit)
}

// ポインタに最も近い制御点。掴める距離に無ければ null
const pick = (world: Vector3) => {
  let target: { panel: Panel; index: number } | null = null
  let nearest = PICK_RADIUS

  for (const panel of panels) {
    for (let index = 0; index < panel.controls.length; index++) {
      const control = panel.controls[index]
      const distance = Math.hypot(world.x - (control.x + panel.offsetX), world.y - control.y)
      if (distance < nearest) {
        nearest = distance
        target = { panel, index }
      }
    }
  }

  return target
}

let dragging: { panel: Panel; index: number } | null = null

canvas.addEventListener("pointerdown", (event) => {
  const world = toScenePoint(event)
  if (!world) return
  dragging = pick(world)
  if (!dragging) return

  dragging.panel.setActive(dragging.index, true)
  // canvas の外まで指が出ても動かし続けられるようにする(pointerup で自動的に解ける)
  canvas.setPointerCapture(event.pointerId)
  invalidate()
})

canvas.addEventListener("pointermove", (event) => {
  if (!dragging) return
  const world = toScenePoint(event)
  if (!world) return

  dragging.panel.move(dragging.index, world.x, world.y)
  // 描画は要求されたときだけ走る。Tweakpane や OrbitControls を経由しない操作なので、
  // ここで次のフレームを頼む
  invalidate()
})

canvas.addEventListener("pointerup", () => {
  if (!dragging) return
  dragging.panel.setActive(dragging.index, false)
  dragging = null
  invalidate()
})

高次ベジェ曲線
CG

直線を表すには2つの制御点、2次ベジェ曲線では3つの制御点が必要でした。
この規則性は高次になっても続き、 次のベジェ曲線を表すには、 個の制御点が必要になります。

個の制御点を とすると、 次ベジェ曲線は次の式で表されます。

n次ベジェ曲線の式

は、制御点間の線分を内分する比率を表す関数であり、これまでに登場した直線や2次ベジェ曲線を表す式の、 の前についていた係数部分です。

直線の場合

たとえば、(直線)の場合の式は次のようなものでした。

そのため、 の部分は次のようになります。

は、各制御点にどれだけの重みをかけるかを決める関数と捉えることもできます。
には の重みがかかり、 には の重みがかかっているのです。

が0から1まで動く間に、その重みの配分が少しずつ移り変わり、それにつれて点が始点から終点へ動いていきます。

2次ベジェ曲線の場合

同様に、 の場合(2次ベジェ曲線)の式を思い出すと、

の部分は次のようになります。

一般に、ベジェ曲線上の点は、 個の制御点に重み をかけて足し合わせたものになります。 の は、この係数は何個目の制御点 につくかを表す数字です。

バーンスタイン基底関数
CG

各制御点にかかる重み の式には、規則性があることが知られています。
この規則性を表した関数をバーンスタイン基底関数といい、次のような式になります。

バーンスタイン基底関数

は 個から 個を選ぶ組み合わせが何通りあるかを表す数です。これに のべき乗と のべき乗をかけ合わせた形になっていて、 が小さい制御点ほど 側の指数が大きくなります。

次のベジェ曲線は、 個の制御点が1点になるまで線形補間を 回繰り返して求まります。1回ごとに と のどちらかがかかるため、 を 回選ぶと が残ります。 を選ぶのが何回目であっても、 回選んだ組み合わせはどれも同じ に行き着きます。その組み合わせの数が にあたります。

CGで最もよく使われるのは、制御点を4つ置く3次のベジェ曲線です。これは の場合であり、4つの基底関数は次のようになります。

これらの係数を使うと、3次ベジェ曲線は次の数式で表されることがわかります。

バーンスタイン基底関数によって、こうして高次のベジェ曲線の式も求めることができるのです。

制御点と重みつき平均

3次ベジェ曲線の式では、 という4つの重みが各制御点にかけられます。
この重みを表すバーンスタイン基底関数のグラフをそれぞれ描いてみると、次のようになります。

Action

tの値を変えて、4本のグラフの高さが入れ替わる様子を観察しよう
右側の縦帯から、4本のグラフの高さの合計は常に1となることも読み取ろう

Three.jsによる実装概要
// 3 次のバーンスタイン基底関数の数と、二項係数 C(3, i)
const BASIS_COUNT = 4
const BINOMIALS = [1, 3, 3, 1]

// 3 次のバーンスタイン基底関数 B(i, 3)(t) = C(3, i) t^i (1 − t)^(3 − i)
const bernstein = (i: number, t: number) =>
  BINOMIALS[i] * t ** i * (1 - t) ** (BASIS_COUNT - 1 - i)

// グラフの座標(横軸 t・縦軸 重み)を、シーンの座標に移す
const toScene = (t: number, weight: number, z: number, target: Vector3) =>
  target.set(t * PLOT_WIDTH, weight * PLOT_HEIGHT, z)

// グラフ全体を canvas の中央に寄せる
const graph = new Group()
graph.position.copy(GRAPH_OFFSET)
scene.add(graph)

// 軸と目盛り。横軸は t の 0〜1、縦軸は重みの 0〜1 を測る
const axisPoints: Vector3[] = [
  new Vector3(0, 0, LAYER_AXIS),
  new Vector3(PLOT_WIDTH + AXIS_OVERSHOOT, 0, LAYER_AXIS),
  new Vector3(0, 0, LAYER_AXIS),
  new Vector3(0, PLOT_HEIGHT + AXIS_OVERSHOOT, LAYER_AXIS)
]
for (const value of [0.5, 1]) {
  axisPoints.push(
    new Vector3(value * PLOT_WIDTH, 0, LAYER_AXIS),
    new Vector3(value * PLOT_WIDTH, -TICK_LENGTH, LAYER_AXIS),
    new Vector3(0, value * PLOT_HEIGHT, LAYER_AXIS),
    new Vector3(-TICK_LENGTH, value * PLOT_HEIGHT, LAYER_AXIS)
  )
}
const axisGeometry = new BufferGeometry().setFromPoints(axisPoints)
const axisMaterial = new LineBasicMaterial({ color: AXIS_COLOR })
graph.add(new LineSegments(axisGeometry, axisMaterial))

// 重みが 1 になる高さの補助線。積み上げ棒の先がここで止まり続けることを読み取らせる
const guideGeometry = new BufferGeometry().setFromPoints([
  new Vector3(0, PLOT_HEIGHT, LAYER_GUIDE),
  new Vector3(BAR_X + BAR_WIDTH / 2, PLOT_HEIGHT, LAYER_GUIDE)
])
const guideMaterial = new LineBasicMaterial({
  color: AXIS_COLOR,
  transparent: true,
  opacity: GUIDE_OPACITY
})
graph.add(new LineSegments(guideGeometry, guideMaterial))

// 4 本の基底関数。色は曲線・重みの点・積み上げ棒で共有する
const basisMaterials = BASIS_COLORS.map((color) => new LineBasicMaterial({ color }))
const fillMaterials = BASIS_COLORS.map((color) => new MeshBasicMaterial({ color }))
basisMaterials.forEach((material, i) => {
  const points: Vector3[] = []
  for (let step = 0; step <= CURVE_SEGMENTS; step++) {
    const t = step / CURVE_SEGMENTS
    points.push(toScene(t, bernstein(i, t), LAYER_CURVE, new Vector3()))
  }
  graph.add(new Line(new BufferGeometry().setFromPoints(points), material))
})

// 今の t を示す縦線。長さは変わらないので、横へ動かすだけでよい
const t = 0.35
const readoutGeometry = new BufferGeometry().setFromPoints([
  new Vector3(0, 0, LAYER_READOUT),
  new Vector3(0, PLOT_HEIGHT, LAYER_READOUT)
])
const readoutMaterial = new LineBasicMaterial({ color: READOUT_COLOR })
const readoutLine = new LineSegments(readoutGeometry, readoutMaterial)
readoutLine.position.x = t * PLOT_WIDTH
graph.add(readoutLine)

// 今の t で各基底関数が取る重み。点の高さと、積み上げ棒の 1 段ぶんになる
const dotGeometry = new SphereGeometry(DOT_RADIUS, 16, 12)
const barGeometry = new PlaneGeometry(1, 1)
const scratch = new Vector3()
let bottom = 0

fillMaterials.forEach((material, i) => {
  const weight = bernstein(i, t)

  const dot = new Mesh(dotGeometry, material)
  dot.position.copy(toScene(t, weight, LAYER_POINT, scratch))
  graph.add(dot)

  // 積み上げ棒。4 段の高さを足すとつねに重み 1 のぶんになる
  const height = weight * PLOT_HEIGHT
  const bar = new Mesh(barGeometry, material)
  bar.scale.set(BAR_WIDTH, Math.max(height, MIN_BAR_HEIGHT), 1)
  bar.position.set(BAR_X, bottom + height / 2, LAYER_BAR)
  graph.add(bar)
  bottom += height
})

このグラフから、重みはどの でも正の値をとり、すべての重みを合計すると1になることがわかります。

そして、この性質は3次に限らずどの次数でも成り立ちます。つまり、次の式が成り立つのです。

ベジェ曲線の式は重み を制御点にかけ合わせたものですが、この重み の和が1であることから、曲線上の点は制御点の重み付き平均になっているといえます。

通常の平均は、すべての値を同じ扱いで足し合わせ、個数で割って求めます。

一方、重み付き平均は、値ごとの重要度を加味した平均です。値ごとに重み をかけて足し合わせ、重みの合計で割って求めます。

特に、重みの合計が1の場合は、分母が1になるため、割る操作は要りません。
線分の上の点を表した も、和が1となる重み と を使っているため、 と を重みつき平均した式といえます。

この性質は、ベジェ曲線の振る舞いにおいて非常に重要になります。具体的にベジェ曲線のどんな振る舞いに繋がるのかは、ベジェ曲線・曲面の数学的性質 で見ていきます。