[CGクロニクル #06] ベジェ曲線と曲面の美(ティーポットの滑らかな肌)

[CGクロニクル #06] ベジェ曲線と曲面の美(ティーポットの滑らかな肌)

はじめに

前回のフォン・シェーディングによって、私たちはピクセル単位で計算された「プラスチックのような艶」を手に入れました。しかし、ここで一つの残酷な事実に直面します。どれほど光の計算を精緻にしても、その光を反射している土台が「平らなポリゴンの集合体」である限り、シルエットの境界には必ずカクつき(ポリゴンのエッジ)が露呈してしまうのです。

点と点を直線で結ぶだけの無骨なデジタル世界に、真の意味での「滑らかさ」を持ち込むにはどうすればよいのか。その答えは、キャンバスの上ではなく、自動車工場の製図室にありました。

直線から曲線へ:ルノーとシトロエンの数学

職人の「勘」を、いかにして数式にするか

1960年代初頭、自動車のボディデザインは完全にアナログな職人芸でした。デザイナーは「雲形定規」や「スプライン(木や金属の細長い板を、重りでたわませて曲線を作る道具)」を使って実物大の図面を描き、クレイモデル(粘土模型)を削り出していました。

問題は、その美しい流線型を「いかにして工場の数値制御(NC)工作機械に伝えるか」でした。

当時は、曲線上にある無数の点の座標を測り、それをパンチカードに打ち込んで機械に入力するという気の遠くなるような作業をしていました。しかし、これではデータ量が膨大になる上、点と点の間を機械が直線で繋ごうとするため、削り出された金属の表面には微細なカクつき(ジャギー)が生じてしまいます。滑らかな車体を作るためには、無数の点ではなく、「曲線を定義する数式」そのものを機械に与える必要があったのです。

カステリョの幾何学と、ベジェの代数学

この難題に対し、フランスの二大自動車メーカーで、二人の数学者が独立して答えに辿り着きました。

  1. ポール・ド・カステリョ(シトロエン): 彼は1959年、「線分を一定の比率で分割し続ける」という非常に幾何学的で直感的なアルゴリズム(ド・カステリョのアルゴリズム)を考案しました。しかし、シトロエン社はこの画期的な発明を「企業秘密」として外部に一切公表しませんでした。

  2. ピエール・ベジェ(ルノー): その数年後の1962年、ルノーのエンジニアだったベジェは、バーンスタイン多項式を用いた代数的なアプローチで、カステリョと全く同じ曲線に到達しました。ルノー社はこれを革新的なツールキット(UNISURF)として広く公開したため、この曲線は「ベジェ曲線」として歴史に名を残すことになります。

彼らの発明の最もエレガントな点は、「曲線そのものを直接いじるのではなく、曲線の外側にある『制御点』を動かすことで、間接的に曲線を操る」という発想の転換でした。これにより、わずか4つの座標データを渡すだけで、工作機械は無限の精度で滑らかな曲線を削り出すことができるようになったのです。

ド・カステリョのアルゴリズムを体感する

シトロエンのド・カステリョが考案した「線分の分割」がどのように曲線を形作るのか。
当時のエンジニアたちが発見した「線形補間の魔法」を、以下のシミュレーターで実際に触って確認してみてください。

De Casteljau's Algorithmt = 0.50

補間が織りなす魔法(バーンスタイン多項式)

ベジェ曲線の根底にあるのは、非常にシンプルな「線形補間(Lerp)」の連続です。 2つの点 P0P_0P1P_1 の間を時間 tt0t10 \le t \le 1)で移動する点は以下の式で表されます。

P(t)=(1t)P0+tP1P(t) = (1-t)P_0 + tP_1

これを3つの制御点(2次ベジェ)、4つの制御点(3次ベジェ)へと拡張していくと、計算は美しい対称性を持つ「バーンスタイン多項式(Bernstein polynomial)」として展開されます。私たちがグラフィックソフトでペンツールを使って描く「あの曲線」や、現在あなたが読んでいるこの画面のフォントの輪郭線は、主に4つの制御点を持つ3次ベジェ曲線で構成されています。

P(t)=(1t)3P0+3(1t)2tP1+3(1t)t2P2+t3P3P(t) = (1-t)^3 P_0 + 3(1-t)^2 t P_1 + 3(1-t) t^2 P_2 + t^3 P_3

パスカルの三角形と「重み」のブレンド

この3次ベジェ曲線の数式をよく見ると、各項の係数が「1,3,3,11, 3, 3, 1」になっていることに気づくでしょう。これは数学における「パスカルの三角形」そのものです。この式は、各制御点 PnP_n が、時間 tt の経過に伴ってどれくらいの「重み(影響力)」を持つかを示しています。

  • t=0t=0 のとき、(1t)3(1-t)^3 のみが 11 となり、曲線は点 P0P_0 からスタートします。
  • tt が進むにつれて、P1P_1P2P_2 の係数が滑らかに増減し、磁石のように曲線を引っ張ります。
  • t=1t=1 のとき、最後の t3t^311 となり、最終的に P3P_3 に到達して運動を終えます。

いかなる tt の値においても、これらの係数(重み)をすべて足し合わせると必ず 11 になります。この性質により、曲線は決して制御点を結んだ多角形(コントロールポリゴン)の外側へ大きく暴れることはありません。常に予測可能で、美しく制御されたカーブを描くのです。

「無限の精度」という革命

この数式の最大の美しさは「解像度に依存しない」という点にあります。

ピクセル(ドットの集まり)で描かれたラスタ画像上の線は、拡大すれば必ずジャギー(ピクセルの階段)が現れます。しかし、ベジェ曲線は「状態を記述した数式」です。レンダリングする際、tt の値を 0.10.1 刻みで計算しようと、0.000010.00001 刻みで計算しようと、曲線自体は決して破綻しません。無限にズームしても、理論上は完璧に滑らかなままです。

データ量としては「たった4つの座標」を保持するだけで、無限の精度を持つ曲線を内包できる。

この圧倒的なデータ効率の良さと数学的な美しさが、のちにPostScriptやAdobe Illustratorといったベクターグラフィックスの基盤となり、デスクトップ・パブリッシング(DTP)という、印刷とデザインの世界を根底から覆す革命を引き起こすことになります。

線から面へ:ベジェパッチとユタ・ティーポット

1次元の「曲線」の概念を、2次元の「曲面」へと拡張したものが「ベジェ曲面(ベジェパッチ)」です。

線を引くための時間パラメータ tt に加えて、もう一つのパラメータを追加し、uuvv0u,v10 \le u, v \le 1)という2つの変数を用います。縦方向のベジェ曲線と、横方向のベジェ曲線を掛け合わせる(テンソル積)ことで、網の目のように張られた空間に滑らかな曲面を定義するのです。 最も一般的な「双3次ベジェパッチ(Bicubic Bézier patch)」は、4×4=164 \times 4 = 16 個の制御点によって1枚の曲面を構成します。

「ティーポット」はポリゴンではなかった

ここで、本連載の第1回で登場した「ユタ・ティーポット」の物語へと繋がります。

1975年、ユタ大学のマーティン・ニューウェルが妻のティーセットを方眼紙にスケッチし、その座標をコンピュータに手入力して生まれたこの有名な形状。実は、あのオリジナルのティーポットはポリゴンの集合体(頂点データ)ではありませんでした。たった32枚の「ベジェパッチ」とその制御点データによって構成されていたのです。

ニューウェルが入力したデータは、わずか306個の制御点座標のみ。しかし、レンダリング時にパラメータ u,vu, v の解像度を上げれば上げるほど、無限に滑らかな曲面として描画することができました。

なぜティーポットが「完璧なテストピース」だったのか

ティーポットがあれほどまでにCGの歴史で愛され、標準モデルとして君臨し続けた理由は、それが単なる手頃な日用品だったからではありません。CGの研究者たちにとって、「これ以上なく完璧な形状(要件)を満たしていたから」です。

  • 多様な曲率: 球のような凸面、首の部分の凹面、そして鞍点(サドルポイント)が含まれている。
  • トポロジーの複雑さ: 取っ手の部分には「穴」が空いている。
  • 自己交差と影: 注ぎ口や取っ手が本体に影を落とし(セルフシャドウ)、隠面消去アルゴリズムの格好のテストになる。
  • 極端なデータ軽量性: これほど複雑な形状でありながら、メモリが極端に少なかった当時のコンピュータでも保持できる「わずか306個の制御点データ」で全体を表現できる。

数式がカタチを作る哲学

現在のリアルタイムCGやWebブラウザ上のThree.jsなどでは、描画パイプラインの制約上、曲面モデルも最終的には細かな三角形ポリゴンに分割(テッセレーション)されてから画面に描かれます。そのため、私たちは「3Dモデル=ポリゴンの塊」という認識を持ちがちです。

しかし、その根底にある「少数の制御点と数式によって、カタチのイデア(完全な姿)を定義する」という先人たちの哲学は、NURBS曲面やサブディビジョン・サーフェスといった技術に姿を変え、現代の3Dモデリングツールの中で脈々と生き続けています。

私たちがディスプレイの中で、まるで手で触れられそうなほど滑らかな工業製品やキャラクターの曲線を堪能できるのは、すべてこの「0と1の世界に、数式で滑らかさを持ち込んだ」エンジニアたちの情熱の上に成り立っているのです。

【一分間の数式美】数式が描く完璧なループ

今回は、ポリゴンや頂点データを一切使わず、GLSLのフラグメントシェーダー内の数式のみで「決して歪まない完璧な曲線」を描き出します。 空間上のピクセル座標が、ベジェ曲線の数式が描く軌跡から「どれだけ離れているか(Distance Field)」を計算し、色を塗る。解像度の概念が存在しない、純粋な数学のループです。

// GLSL Fragment Shader: Pure Mathematical Curve
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;

// 2次ベジェ曲線の距離関数 (SDF) の概念的アプローチ
float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C) {
    vec2 a = B - A;
    vec2 b = A - 2.0*B + C;
    vec2 c = a * 2.0;
    vec2 d = A - pos;

    float kk = 1.0 / dot(b,b);
    float kx = kk * dot(a,b);
    float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
    float kz = kk * dot(d,a);

    float res = 0.0;
    float p = ky - kx*kx;
    float p3 = p*p*p;
    float q = kx*(2.0*kx*kx - 3.0*ky) + kz;
    float h = q*q + 4.0*p3;

    // 解析的解法による最短距離の計算(一部省略・最適化)
    if(h >= 0.0) {
        h = sqrt(h);
        vec2 x = (vec2(h, -h) - q) / 2.0;
        vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
        float t = clamp(uv.x+uv.y-kx, 0.0, 1.0);
        res = length(d + (c + b*t)*t);
    } else {
        float z = sqrt(-p);
        float v = acos( q/(p*z*2.0) ) / 3.0;
        float m = cos(v);
        float n = sin(v)*1.732050808;
        vec3 t = clamp( vec3(m+m, -n-m, n-m)*z-kx, 0.0, 1.0);
        res = min(length(d+(c+b*t.x)*t.x),
                  length(d+(c+b*t.y)*t.y));
    }
    return res;
}

void main() {
    vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution.xy) / min(u_resolution.x, u_resolution.y);

    // 時間で動く3つの制御点
    vec2 p0 = vec2(sin(u_time * 0.5) * 0.8, cos(u_time * 0.3) * 0.8);
    vec2 p1 = vec2(cos(u_time * 0.7) * 0.5, sin(u_time * 0.4) * 0.5);
    vec2 p2 = vec2(sin(u_time * 0.2) * 0.8, -cos(u_time * 0.6) * 0.8);

    // 曲線からの距離を計算
    float d = sdBezier(uv, p0, p1, p2);

    // 距離関数を使ったアンチエイリアス付きの描画 (光るワイヤー)
    float glow = 0.01 / (d + 0.001);
    vec3 color = vec3(0.2, 0.8, 1.0) * glow;

    gl_FragColor = vec4(color, 1.0);
}

画面上を漂う3つの制御点が織りなす青い光の軌跡。どんなに画面を拡大してもピクセルのジャギーは見えず、ただ純粋な計算結果だけがそこにあります。頂点の制約から解き放たれたこの「曲線の美学」は、やがてレイトレーシングという「光の物理法則」へとバトンを渡していくのです。

サンプル

import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';

const ShaderArt06: React.FC = () => {
  const mountRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!mountRef.current) return;

    const width = mountRef.current.clientWidth;
    const height = 400; // お好みに合わせて調整してください

    // レンダラーのセットアップ
    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setSize(width, height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    mountRef.current.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    // 画面全体を覆うための正投影カメラ
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

    // ユニフォーム変数の定義
    const uniforms = {
      u_time: { value: 0.0 },
      u_resolution: { value: new THREE.Vector2(width, height) }
    };

    // ジオメトリとマテリアル (画面いっぱいの板ポリゴン)
    const geometry = new THREE.PlaneGeometry(2, 2);
    const material = new THREE.ShaderMaterial({
      uniforms: uniforms,
      vertexShader: `
        void main() {
          // 変換行列を通さず、そのままクリップ空間へ
          gl_Position = vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        precision highp float;
        uniform vec2 u_resolution;
        uniform float u_time;

        // 2次ベジェ曲線の距離関数 (SDF) の概念的アプローチ
        float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C) {
            vec2 a = B - A;
            vec2 b = A - 2.0*B + C;
            vec2 c = a * 2.0;
            vec2 d = A - pos;

            float kk = 1.0 / dot(b,b);
            float kx = kk * dot(a,b);
            float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
            float kz = kk * dot(d,a);

            float res = 0.0;
            float p = ky - kx*kx;
            float p3 = p*p*p;
            float q = kx*(2.0*kx*kx - 3.0*ky) + kz;
            float h = q*q + 4.0*p3;

            // 解析的解法による最短距離の計算
            if(h >= 0.0) {
                h = sqrt(h);
                vec2 x = (vec2(h, -h) - q) / 2.0;
                vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
                float t = clamp(uv.x+uv.y-kx, 0.0, 1.0);
                res = length(d + (c + b*t)*t);
            } else {
                float z = sqrt(-p);
                float v = acos( q/(p*z*2.0) ) / 3.0;
                float m = cos(v);
                float n = sin(v)*1.732050808;
                vec3 t = clamp( vec3(m+m, -n-m, n-m)*z-kx, 0.0, 1.0);
                res = min(length(d+(c+b*t.x)*t.x),
                          length(d+(c+b*t.y)*t.y));
            }
            return res;
        }

        void main() {
            vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution.xy) / min(u_resolution.x, u_resolution.y);

            // 時間で動く3つの制御点
            vec2 p0 = vec2(sin(u_time * 0.5) * 0.8, cos(u_time * 0.3) * 0.8);
            vec2 p1 = vec2(cos(u_time * 0.7) * 0.5, sin(u_time * 0.4) * 0.5);
            vec2 p2 = vec2(sin(u_time * 0.2) * 0.8, -cos(u_time * 0.6) * 0.8);

            // 曲線からの距離を計算
            float d = sdBezier(uv, p0, p1, p2);

            // 距離関数を使ったアンチエイリアス付きの描画 (光るワイヤー)
            float glow = 0.01 / (d + 0.001);
            vec3 color = vec3(0.2, 0.8, 1.0) * glow;

            gl_FragColor = vec4(color, 1.0);
        }
      `
    });

    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    // リサイズ処理
    const handleResize = () => {
      if (!mountRef.current) return;
      const w = mountRef.current.clientWidth;
      renderer.setSize(w, height);
      uniforms.u_resolution.value.set(w, height);
    };
    window.addEventListener('resize', handleResize);

    // アニメーションループ
    const clock = new THREE.Clock();
    let animationId: number;

    const animate = () => {
      uniforms.u_time.value = clock.getElapsedTime();
      renderer.render(scene, camera);
      animationId = requestAnimationFrame(animate);
    };
    animate();

    // クリーンアップ
    return () => {
      window.removeEventListener('resize', handleResize);
      cancelAnimationFrame(animationId);
      if (mountRef.current && renderer.domElement) {
        mountRef.current.removeChild(renderer.domElement);
      }
      geometry.dispose();
      material.dispose();
      renderer.dispose();
    };
  }, []);

  return (
    <div
      ref={mountRef}
      style={{
        width: '100%',
        height: '400px',
        backgroundColor: '#000',
        borderRadius: '8px',
        overflow: 'hidden',
        border: '1px solid #333'
      }}
    />
  );
};

export default ShaderArt06;

コード解説:数学的最短距離の追求

今回実装したシェーダーの核心部は、単なる描画命令ではなく、「点と曲線の最短距離を求める」という純粋な幾何学の問題を解いています。

1. 距離関数(SDF)という考え方

通常のCGでは「点 A から点 B へ線を引く」という命令を出しますが、このシェーダーでは正反対のアプローチをとっています。

全ピクセルに対して同時に、「自分(現在のピクセル)からベジェ曲線までの最短距離 dd はいくらか?」を問いかけています。これが SDF(Signed Distance Field) と呼ばれる手法です。この距離 dd が 0 に近ければ曲線の上、遠ければ背景であると判断します。

2. 3次方程式への挑戦

2次ベジェ曲線 B(t)B(t) と任意の点 PP との最短距離を求めるには、距離の 2 乗を最小化する tt を見つける必要があります。この関数の微分をとると、tt に関する 3次方程式 が現れます。

f(t)=dot(B(t)P,B(t))=0f(t) = \text{dot}(B(t) - P, B'(t)) = 0

コード内の kx, ky, kzp, q, h といった変数群は、この 3 次方程式を カルダノの公式 などを用いて代数的に解いているプロセスです。

  • h0h \ge 0 の場合: 実数解が 1 つ(曲線に対して垂線が 1 本引ける状態)。
  • h<0h < 0 の場合: 実数解が 3 つ(曲線に対して垂線が 3 本引ける可能性があり、その中から最小値を選択する)。

この「解の公式」を GPU の全ピクセルで並列実行することで、一切の近似を排した「完璧な曲線」が描かれます。

3. 指数的な「光の滲み」

計算された最短距離 dd をどのように色に変えるか。ここでは以下の 1 行が「艶」と「実在感」を生んでいます。

float glow = 0.01 / (d + 0.001);

これは物理学における逆二乗の法則に似た処理です。距離 dd が 0 に近づくほど、値は急激に大きくなります。

  • 0.001 を足しているのは、ゼロ除算(エラー)を防ぐためです。
  • この反比例の関係により、曲線の中心は白く飽和し、外側に向かって滑らかに減衰する「ネオンのような発光」が得られます。