[CGクロニクル #01] 朝食のドーナツとユタ・ティーポット:カタチの誕生

[CGクロニクル #01] 朝食のドーナツとユタ・ティーポット:カタチの誕生

はじめに

全15回でお送りする連載「CGの深淵を遡る:数式で綴る黎明期から現代まで」。 第1回となる本稿では、コンピュータグラフィックスにおける「カタチの誕生」に焦点を当てる。

私たちが現在ブラウザ上で当たり前のように操作している数百万ポリゴンの3Dモデルも、そのルーツを辿れば、メモリと計算資源が極限まで制限されていた時代の「純粋な数式」に行き着く。

0と1の空間に「立体」を定義する

1960年代から70年代にかけて、コンピュータのメモリは非常に高価であり、数万の頂点座標を保持するポリゴンメッシュなど夢のまた夢であった。そこで初期のグラフィックス研究者たちは、少ないパラメータで立体を表現するために、解析幾何学の力を借りた。

球体(Sphere)、円柱(Cylinder)、そしてトーラス(Torus)。 これらは「幾何学的プリミティブ」と呼ばれ、数式のみで完全にその表面を定義できる。

中でも、朝食のドーナツでお馴染みの「トーラス」は、初期のCGにおいて特別な地位を占めていた。

1. 「絶望的なメモリ制約」と磁気コアメモリ

1960年代から70年代のコンピュータは、現在のようなシリコンベースのRAMではなく、小さな磁石の輪に電線を通した「磁気コアメモリ」が主流でした。1キロバイト(メガでもギガでもなく、キロです)のメモリが、物理的に巨大な箱であり、数千ドルもする時代です。

現代のポリゴンメッシュのように「1つの頂点に x,y,zx, y, z の浮動小数点数を持たせ、それを何万個も配列に格納する」などというアプローチは、物理的にも予算的にも100%不可能でした。データを「点群」として保持すること自体が許されない世界だったのです。

2. 究極のデータ圧縮としての「解析幾何学」

頂点データが持てないなら、どうやって立体を描くのか。その答えが「数式(関数)」でした。

例えば、半径 rr の球体を描きたい場合、表面の何千もの頂点座標をメモリに保存するのではなく、以下の数式1つをプログラムに記述します。

x2+y2+z2=r2x^2 + y^2 + z^2 = r^2

この「陰関数表現」を用いれば、必要なパラメータは中心座標と半径のわずか数バイトで済みます。画面上のピクセル(あるいは光線)がこの数式を満たすかどうかを「その都度計算機に解かせる」ことで、メモリの消費を極限まで削り、代わりにCPUの計算力でカタチを浮かび上がらせたのです。これはまさに、現代の手続き型(プロシージャル)生成の原点と言えます。

3. トーラスが背負った「計算機へのストレステスト」

球体や円柱は数式がシンプルですが、描画結果も単純で、アルゴリズムのテストには物足りません。そこで白羽の矢が立ったのがトーラスです。

トーラスは「穴が開いている(種数が1)」というトポロジー的な特徴により、以下のテストを1つのオブジェクトで同時に行えました。

  • オクルージョン(隠面消去): 手前の面が奥の面を隠す処理の確認。
  • セルフシャドウ: 自分自身の影が、自分の内側の穴に落ちるかどうかの確認。
  • 交差判定の負荷: レイトレーシングにおいて、レイ(光線)と球体の交差判定は「2次方程式」を解くだけで済みますが、トーラスとの交差判定は「4次方程式」を解く必要があります。これは当時のFPU(浮動小数点演算処理装置)にとって、最高にハードなベンチマークテストでした。

朝食のドーナツは、当時の計算機科学者たちにとって「自分たちのレンダラーが正しく、かつ高速に動くか」を試すための、最も美しくて残酷な試験管だったのです。

なぜトーラスだったのか?

球体や立方体に比べ、トーラスの描画は計算コストが高い。それでも好んで描画テストに用いられた理由は、そのトポロジー(位相幾何学)的な複雑さにある。

トーラスは、外側に向かって膨らむ「凸面」と、内側の穴に向かって凹む「凹面」の両方を持ち合わせている。さらに「穴が開いている(種数が1である)」ため、光を当てた際の陰影の変化が非常に豊かであり、自己遮蔽(自分自身の影が自分に落ちる現象)のテストに最適だったのだ。

トーラスの表面上の任意の点は、大円の半径をRR、小円の半径をrr、そして2つの角度パラメータθ\thetaϕ\phiを用いて、以下の数式で美しく定義される。

x(θ,ϕ)=(R+rcosθ)cosϕx(\theta, \phi) = (R + r \cos \theta) \cos \phi

y(θ,ϕ)=(R+rcosθ)sinϕy(\theta, \phi) = (R + r \cos \theta) \sin \phi

z(θ,ϕ)=rsinθz(\theta, \phi) = r \sin \theta

このたった3行の三角関数が、3次元空間に滑らかなドーナツ型を顕現させる。データとしての頂点群を持たず、計算によってその都度「そこにあるべき点」を導き出すこのアプローチは、手続き型生成(Procedural Generation)の最も原始的な形と言えるだろう。

凸と凹の同居:法線ベクトルのストレステスト

球体はどこまで行っても「外側に膨らむ(凸)」図形であり、立方体は「平坦」です。これらの図形に光を当てる計算は、比較的単純に実装できます。

しかしトーラスは違います。外周部分は光を弾き返すような「凸面」を持っていますが、ドーナツの穴の内側に向かうにつれて、馬の鞍のような「凹面(負の曲率)」へと滑らかに変化します。光が当たったとき、ハイライトがどのように伸び、どう減衰していくのか。表面の向き(法線ベクトル)の計算アルゴリズムが正確でなければ、トーラスの陰影は途端に破綻し、不自然な黒い染みやエッジを生み出してしまいます。トーラスを美しく描画できることこそが、シェーディングアルゴリズムの正確性を証明する試金石だったのです。

究極の難問「自己遮蔽(セルフシャドウ)」

もう一つの大きな理由が「影」です。レイトレーシング(光線追跡法)を想像してみてください。

球体の場合、光源からの光が遮られるのは「他の物体の後ろに隠れた時」だけです。しかし、穴の開いたトーラスは違います。「自分自身の影が、自分自身の内側の壁に落ちる」のです。

あるピクセルに影を落とすかどうかを判定するためには、光線が「ドーナツの管を貫通して、向こう側の管に当たっていないか」を計算しなければなりません。球体との交差判定が2次方程式で済むのに対し、トーラスとの交差判定は「4次方程式」を解く必要があります。これは当時のFPU(浮動小数点演算装置)を悲鳴を上げさせるほどの重い処理でしたが、だからこそ、レンダラーの性能限界を見極めるための最高のベンチマークとして愛されたのです。

軌跡としての数式

そして、この複雑な立体が、驚くほど美しい対称性を持った数式で記述されることも、数学者やエンジニアを魅了しました。

x(θ,ϕ)=(R+rcosθ)cosϕx(\theta, \phi) = (R + r \cos \theta) \cos \phi

y(θ,ϕ)=(R+rcosθ)sinϕy(\theta, \phi) = (R + r \cos \theta) \sin \phi

z(θ,ϕ)=rsinθz(\theta, \phi) = r \sin \theta

この式を紐解くと、CGにおける「手続き」の美学が見えてきます。

  1. まず、rcosθr \cos \thetarsinθr \sin \theta で、原点を中心とした小さな円(断面)を作ります。
  2. その小円を、大円の半径 RR の位置まで外側に押し出します(R+rcosθR + r \cos \theta)。
  3. 最後に、それを cosϕ\cos \phisinϕ\sin \phi によってぐるりと360度回転(スイープ)させます。

「円を、さらに円軌道で振り回す」。 たったこれだけの論理的な手続きが、頂点データ(ポリゴン)を一切消費することなく、無限の解像度を持つドーナツ型を計算機のメモリ上に顕現させるのです。

トーラスの構造を体感する

数式がどのようにカタチと影を形作るのか、大円の半径(RR)と小円の半径(rr)のパラメータを操作し、光が自己遮蔽(セルフシャドウ)を引き起こす様子を観測できるウィジェットを用意しました。

Three.jsを操るあなたなら、この「円を円でスイープする」という数学的アプローチが、現代の TorusGeometry の裏側でどのように動いているか、直感的に感じ取れるはずです。

ユタ・ティーポットの衝撃(1975年)

幾何学的なプリミティブによる研究が進む中、より「現実の物体」に近い、複雑な曲面を持つモデルが求められ始めた。

1975年、ユタ大学のマーティン・ニューエル(Martin Newell)は、妻とのお茶の時間をヒントに、手元にあったメリタ社製のティーポットを方眼紙にスケッチし、その座標をコンピュータに入力した。これが後に3Dグラフィックスの「Hello World」となるユタ・ティーポット(Utah Teapot)の誕生である。

ティーポットが選ばれた理由も、トーラスと同様に「レンダリングのテストに最適だったから」である。

  • 丸みを帯びた胴体(滑らかな曲面反射のテスト)
  • 取っ手と注ぎ口の穴(トーラス以上の複雑なトポロジー)
  • 自分自身に落ちる複雑な影(シャドウアルゴリズムの検証)

ニューエルはこれを、ベジェ曲線(Bézier curve)のパッチを用いてデータ化した。数式で制御可能な曲線を用いることで、解像度に依存しない滑らかな表面を定義したのだ。

「現実の複雑さ」への渇望

球体やトーラスが「数式から生まれたイデアの図形」であるならば、ユタ・ティーポットは「現実の不完全さを、いかに数学で模倣するか」という、CGの次なる挑戦の象徴でした。

1970年代中盤のユタ大学は、CG研究の世界的中心地(メッカ)でした。研究者たちは、幾何学的なプリミティブ(基本図形)のレンダリングには成功していましたが、「人間が日常生活で目にするような、有機的で複雑な曲面をどう表現するか」という壁に直面していました。

マーティン・ニューエルが妻のサンドラとティータイムを過ごしていた際、「身の回りのものをモデリングしてみては?」という彼女の提案から、この歴史的アイコンは生まれました。

ティーポットが備えていた「完璧な困難さ」

手元にあったメリタ社製のティーポット(Melitta Teapot)は、当時のレンダリングアルゴリズムを検証するための、奇跡的なほど完璧な難易度を備えていました。

  1. 自己遮蔽の極致: 取っ手の影が胴体に落ち、注ぎ口の影が蓋に落ちる。光の角度を変えれば、その影は複雑に歪みながら表面を這う。
  2. 曲率の多様性: 胴体の「緩やかな丸み」、蓋のつまみの「鋭いカーブ」、注ぎ口の「細く長い筒」。様々な曲率が同居しており、ハイライト(鏡面反射)の計算が破綻しないかをテストするのに最適でした。
  3. トポロジーの罠: 注ぎ口には「穴」があり、取っ手と胴体の間にも「空間(穴)」がある。トーラス(種数1)よりもさらに複雑な位相幾何学的構造を持っていました。

つまり、ティーポットを美しく、破綻なくレンダリングできれば、そのアルゴリズムは「現実のあらゆる物体に応用できる」という証明になったのです。

点と線から「曲面」へ:ベジェパッチの魔法

ニューエルは、方眼紙にティーポットの断面をスケッチし、その座標を手作業でコンピュータに入力しました。しかし、彼はその座標を単なる「ポリゴンの頂点」としては扱いませんでした。彼が用いたのは「ベジェ曲線(Bézier curve)」、より正確にはそれを3次元に拡張した「ベジェパッチ(Bézier Patch)」です。

ベジェ曲線は、自動車のデザイン(CAD)のためにルノー社のピエール・ベジェらが考案した数学的手法です。数個の「制御点(Control Points)」を指定するだけで、その点に引っ張られるようにして滑らかな曲線を定義できます。

ティーポット全体は、わずか32枚のベジェパッチ(後日、底面を追加してより完全になった)で構成されていました。このアプローチの何が画期的だったのでしょうか?

それは、「解像度(ポリゴン数)に依存しない」ということです。

遠くから見るときは少ない分割数(少ない計算量)で粗く描き、カメラが限界まで近づいたときは、数式を元に無限に細かく分割して「絶対に角張らない、完璧に滑らかな曲面」を描画できる。ハードウェアの進化に合わせて、どれだけでも高精細に描画できる「未来を見据えたデータ構造」だったからこそ、ユタ・ティーポットは数十年にわたり、数え切れないほどの論文でベンチマークとして使われ続けたのです。

ベジェ曲線の「引力」を体感する

「少ない制御点で、滑らかなカーブを作る」。 言葉にすると単純ですが、これは現代のIllustratorのペンツールから、3DモデリングのSubdivision Surfaceまで、あらゆるデザインツールの根幹を成す数学的発明です。

ユタ・ティーポットを形作ったこの「ベジェ曲線」が、制御点によってどのようにカーブを形作るのか。その計算の美しさを直感的に理解できるウィジェットを用意しました。

結び:制約が生んだ「数学のパレット」

初期のCGは、現実世界を模倣しようとするエンジニアたちのロマンと、計算機の非情なハードウェア的制約との終わりのない戦いであった。彼らは、メガバイトどころかキロバイト単位のメモリという極限の「枯渇」の中で、頂点データを増やすのではなく、sincos、そして多項式という「数学のパレット」を使って、ディスプレイの中に光と影の魔法を描き出した。

彼らが記述した数式は、単なるプログラムではない。それは0と1の宇宙に「物理法則と実存」を定義するための、神への祈りのようなものだった。

しかし、空間に完璧なカタチを定義できても、それを最終的に映し出すのは「ピクセル」という無骨な四角形の集まりである。連続的な数式を、どうやって不連続な格子状のキャンバスに定着させるのか。

次回の第2回では、ピクセルというデジタル特有の制約に立ち向かい、「完璧な直線を引く」ために生み出された最初期のハック、DDA(Digital Differential Analyzer)とBresenhamのアルゴリズムについて紐解いていく。

【一分間の数式美】The Pure Torus

最後に、現代のGLSL(フラグメントシェーダー)を用いたRaymarchingにおいて、トーラスがどれほどシンプルかつエレガントに記述されるかを示して本稿を閉じる。

ここに頂点(ポリゴン)データは一切存在しない。あるのは、空間上の任意の点から「トーラスの表面までの最短距離」を測る、たった1つの数式(符号付き距離関数:SDF)だけである。

// トーラスの符号付き距離関数 (Signed Distance Function)
// p: 空間上の現在位置 (x, y, z)
// t: xに大円の半径、yに小円の半径を指定するベクトル (vec2)

float sdTorus( vec3 p, vec2 t )
{
    // 1. p.xz平面(上から見た図)での原点からの距離から、大円の半径(t.x)を引く
    // 2. その結果と、高さ(p.y)を使って、断面となる小円の中心からの距離を測る
    vec2 q = vec2(length(p.xz) - t.x, p.y);

    // 3. 最後に小円の半径(t.y)を引くことで、表面までの距離を返す
    return length(q) - t.y;
}

たったこれだけのコードである。 1行目で空間をスライスして円の断面を作り出し、2行目でその断面からの距離を測る。この計算を、画面の全ピクセルに対して同時に、毎秒60回の速度で走らせることで、暗闇の中から数学的に完全な曲面を持つトーラスが浮かび上がる。

1970年代の研究者たちが、磁気コアメモリと格闘しながら夢見た「数式が直接光を放つ世界」。それは半世紀の時を経て、今や我々の手元にあるブラウザとGPUの中で、息をするように当たり前に実行されているのだ。

サンプル

import React, { useRef, useMemo } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import * as THREE from 'three'

// --- 1. シェーダーを描画する「中身」のコンポーネント ---
const RaymarchingMesh = () => {
  const meshRef = useRef<THREE.Mesh>(null!);

  // ユニフォーム変数を初期化
  const uniforms = useMemo(() => ({
    uTime: { value: 0 },
    uResolution: { value: new THREE.Vector2() }
  }), []);

  // Canvasの内側なので、useFrameが使えるようになります
  useFrame((state) => {
    const { clock, size } = state;
    meshRef.current.material.uniforms.uTime.value = clock.getElapsedTime();
    meshRef.current.material.uniforms.uResolution.value.set(size.width, size.height);
  });

  return (
    <mesh ref={meshRef}>
      <planeGeometry args={[2, 2]} />
      <shaderMaterial
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={uniforms}
      />
    </mesh>
  );
};

// --- 2. Canvasを設置するメインコンポーネント ---
export default function Scene() {
  return (
    <div style={{ width: '100%', height: '400px', background: '#000' }}>
      <Canvas>
        <RaymarchingMesh />
      </Canvas>
    </div>
  );
}

// --- シェーダーコード(変更なし) ---
const vertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position, 1.0);
  }
`;

const fragmentShader = `
  uniform float uTime;
  uniform vec2 uResolution;
  varying vec2 vUv;

  float sdTorus( vec3 p, vec2 t ) {
    vec2 q = vec2(length(p.xz) - t.x, p.y);
    return length(q) - t.y;
  }

  void main() {
    vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution.xy) / min(uResolution.x, uResolution.y);
    vec3 ro = vec3(0, 0, 3);
    vec3 rd = normalize(vec3(uv, -1.5));

    float t = 0.0;
    for(int i = 0; i < 64; i++) {
      vec3 p = ro + rd * t;
      float s = sin(uTime * 0.5);
      float c = cos(uTime * 0.5);
      p.xz *= mat2(c, s, -s, c);
      p.yz *= mat2(c, s, -s, c);

      float d = sdTorus(p, vec2(0.8, 0.3));
      if(d < 0.001 || t > 10.0) break;
      t += d;
    }

    vec3 col = (t < 10.0) ? vec3(0.1, 0.6, 1.0) * (1.5 / t) : vec3(0.05);
    gl_FragColor = vec4(col, 1.0);
  }
`;