第3部 3D ラスタライズパイプライン — 第14章

ジオメトリをつくる — 球・トーラスのプロシージャル生成

第13章では、立方体の頂点をひとつずつ配列に手書きしました。面の向きを考えながら座標を書き並べる 作業は、立方体ならなんとかなります。しかしこの章で描く球は頂点が 561 個あります。手書きは 現実的ではありません — そして、する必要もありません。球の頂点の位置は数式で計算できるからです。頂点データを、手で書く代わりにプログラムで作る。これをプロシージャル生成 (procedural generation)と呼びます。

three.js で new THREE.SphereGeometry(1, 32, 16)と書いたとき、内部ではまさにこの章でやる計算が走って、頂点の配列ができあがっています。この章は、その 1 行の中身を自作する章です。作るのは平面・球・トーラスの 3 つ。そして 3 つとも、「(u, v) のグリッドを曲面に貼り付ける」というたった 1 つの型で作れることを見ていきます。

生成したジオメトリがゆっくり回転します。上の段で形状(平面 / 球 / トーラス)、下の段で表示(ソリッド / ワイヤーフレーム)を切り替えられます。ソリッドの色は法線をそのまま色にしたもの(normal × 0.5 + 0.5)です。ソリッド表示のとき、平面が回って裏を向いた瞬間に消えるのは、第13章の裏面カリングが 働いている証拠です(カリングはポリゴンにしか効かないので、ワイヤーフレーム表示では平面も 消えません。7 節)。

この章で学ぶこと:

1. 手書きの限界と、2 度目の共通化

第13章の立方体で、頂点データの形式はすでに決まっています。各頂点はいくつかの 属性の組を持ち(第13章では位置と色、この章からは位置・法線・UV)、三角形はインデックスの並びで 表す。実はこの形式は、この先どんな形を作っても変わりません。 球でもトーラスでも、第19章で外部からモデルを読み込むようになっても、GPU に渡すものは「頂点の属性の束 + 三角形のインデックス」のままです。

形が毎回同じなら、型を 1 つ決めて使い回せます。この章では、生成したジオメトリを次のかたまりで受け渡すことにします。

src/lib/geometry.ts(抜粋)
export interface Geometry {
  /** 頂点位置 (x, y, z) × 頂点数 */
  positions: Float32Array;
  /** 単位法線 (nx, ny, nz) × 頂点数 */
  normals: Float32Array;
  /** テクスチャ座標 (u, v) × 頂点数 */
  uvs: Float32Array;
  /** 三角形リスト(3 要素で 1 枚) */
  indices: Uint16Array;
}

置き場所はsrc/lib/geometry.ts— レッスン内のローカル関数ではなく、共通モジュールです。第6章で共通化のルールを決めたのを 覚えているでしょうか。「読者がまだ中身を知らないコードは、隠さない」。 このファイルがルールに反しないのは、中に新しい WebGL API が 1 つもないからです。入っているのは、 第13章で手書きしたのと同じ形式のデータを、for ループと sin / cos(第9章)で組み立てるコード だけです。そして第6章の「この先の共通化の予定」で、第13〜14章にジオメトリ生成まわりのヘルパーを 予告していました。この章がその回収です。

以降の章では「球を出したい」と思ったらcreateSphere(1.0)と 1 行書けば済むようになります。ただしその 1 行を気持ちよく使うために、この章では中身を 最後まで見ておきます。

2. 共通の型 — (u, v) グリッドを曲面に貼り付ける

平面・球・トーラスは見た目こそ違いますが、どれも切れ目なく格子を描ける面です。 地球儀には緯線と経線が描けます。ドーナツの表面にも、大円方向と断面方向の格子が描けます。 ということは、逆向きに考えられます — まず平らな格子を作り、それを曲面の形に貼り付ければよいのです。

そこでsrc/lib/geometry.tsの中心には、グリッドを 1 回だけ実装したbuildGrid(cols, rows, surface)という関数を置いてあります。(u, v) ∈ [0,1]² のグリッドの各格子点について、「その (u, v) が曲面上のどこへ行くか」を答えるコールバックsurfaceを呼び出す作りです。形ごとの違いは、このコールバック 1 つに閉じ込められます。

src/lib/geometry.ts(抜粋)
/** buildGrid のコールバック。パラメータ (u, v) ∈ [0,1]² から曲面上の 1 頂点を返す */
type SurfaceFn = (
  u: number,
  v: number,
) => { position: [number, number, number]; normal: [number, number, number] };

コールバックが返すのは、位置と法線の 2 つです。ここで初登場する属性なので 定義しておきます。法線 (normal)は、その頂点で面に垂直な、外向きの単位ベクトルです。位置が「どこにあるか」を持つのに対し、 法線は「どちらを向いているか」を持ちます。第13章の立方体は位置と色だけでしたが、この章からは 法線と UV が頂点の属性に加わります(法線が主役になるのは第17章のライティングです)。

パラメータ空間 (u, v)abcdu: 0 → 1v1 マス = 三角形 a→b→d と a→d→csurface(u, v)貼り付けた後も 1 マス = 三角形 2 枚のまま
buildGrid の考え方。平らなグリッドを作り、各格子点を surface(u, v) が曲面上へ運びます。マスを三角形 2 枚に割る仕事はグリッド側で 1 回だけ書けばよく、形ごとの違いは surface だけになります。

buildGridは cols × rows 個のマスを作るので、格子点(= 頂点)は (cols + 1) × (rows + 1) 個です。 隣のマスどうしは辺の頂点を共有するので、これはインデックス描画(第13章)がいちばん得意とする データです。頂点は「v の行を下から上へ、各行の中は u を左から右へ」の順で並べ、各マスを 対角線で 2 枚の三角形に割ります。

src/lib/geometry.ts(抜粋)
const indices = new Uint16Array(cols * rows * 6);
let index = 0;
for (let iy = 0; iy < rows; iy++) {
  for (let ix = 0; ix < cols; ix++) {
    const a = iy * (cols + 1) + ix; // 左下
    const b = a + 1; // 右下
    const c = a + (cols + 1); // 左上
    const d = c + 1; // 右上
    indices.set([a, b, d, a, d, c], index);
    index += 6;
  }
}

三角形の頂点の並び a→b→d と a→d→c は、どちらも図のマスを反時計回り (CCW)にたどっています。第13章で決めた「外から見て CCW = 表」という約束をここでも守るためです。 surface 関数が「u が右・v が上・法線が手前」という右手系の向きで曲面を貼る限り、パラメータ空間で CCW だった三角形は、貼り付けた後も外から見て CCW のままです。デモがカリング有効のまま 正しく見えていること自体が、生成した全三角形の巻きが正しいことの証明になっています。

3. 平面 — いちばん簡単な surface

型ができたので、いちばん簡単な形から埋めていきます。平面は (u, v) を XY 座標に引き伸ばすだけです。中心を原点に置きたいので 0.5 を引き、法線はどの頂点でも +Z を向きます。

src/lib/geometry.ts(抜粋)
/**
 * XY 平面上の矩形(法線 +Z、原点中心)。three.js の PlaneGeometry と同じ向き。
 * 床として使うときはモデル行列で X 軸まわりに -90° 回す
 */
export function createPlane(width: number, height: number, segmentsX = 1, segmentsY = 1): Geometry {
  return buildGrid(segmentsX, segmentsY, (u, v) => ({
    position: [(u - 0.5) * width, (v - 0.5) * height, 0],
    normal: [0, 0, 1],
  }));
}

ここで素朴な疑問が出ます。平面はどれだけ分割しても形は変わらないのに、なぜsegmentsX/segmentsYという分割数を受け取るのでしょうか。答えは「頂点シェーダーの計算は頂点ごとにしか 走らないから」です。第17章で頂点単位のライティング(Gouraud シェーディング)を試すとき、また 後の章で頂点を波打たせるような変形をするとき、頂点が 4 つしかない平面では 4 か所しか計算できず、結果は荒くなります。分割数は「形の細かさ」ではなく「計算の細かさ」を買うパラメータです。デモをワイヤーフレーム表示にすると、8 × 8 に分割された平面が 確認できます。

もう 1 つ、デモの平面でぜひ観察してほしいのが消える瞬間です。平面には 三角形が表を向いた側(+Z 側)しかないので、回転して裏を向くと、全三角形がカリングされて 何も描かれなくなります。三角形に裏表がある(第13章)ことが、これほど露骨に見える形はありません。

4. 球 — 極座標を 3D に持ち上げる

第10章で、平面上の点を「距離 r と角度 θ」で指す極座標を学びました。球面はその 3D 版で 指せます。半径は一定なので、残るは角度 2 つ — 極角 (polar angle)「南極から北極へ、どこまで登ったか」と、方位角 (azimuth)「その高さの輪の上を、 どこまで回ったか」です。地球でいえば緯度と経度にあたります。(u, v) のグリッドがちょうど角度 2 つ分あるので、割り当てはこうなります。

横から見た断面+y南極 v = 0北極 v = 1Ppolar = v·π輪の半径 sin(polar)高さ -cos(polar)上から見た緯線の輪+xφ = u·2π輪の上を cos / sin で 1 周する(第9章の円運動)
球の頂点の作り方。v(極角)が「どの緯線の輪か」を決め、u(方位角)がその輪の上の位置を 決めます。第10章の (r, θ) に角度が 1 つ増えただけです。
src/lib/geometry.ts(抜粋)
export function createSphere(radius: number, segments = 32, rings = 16): Geometry {
  return buildGrid(segments, rings, (u, v) => {
    const polar = v * Math.PI; // 0(南極) 〜 π(北極)
    const phi = u * Math.PI * 2;
    const sinPolar = Math.sin(polar);
    // z を -sin にすると外から見て CCW になる(buildGrid の分割との組み合わせで決まる)
    const nx = sinPolar * Math.cos(phi);
    const ny = -Math.cos(polar);
    const nz = -sinPolar * Math.sin(phi);
    return {
      position: [nx * radius, ny * radius, nz * radius],
      normal: [nx, ny, nz],
    };
  });
}

そしてこの関数のいちばん美しいところは法線です。原点中心の球では、表面の点の 「外向き」は、原点からその点へ向かう方向そのものです。つまり、半径 1 で計算した位置 (nx, ny, nz) がそのまま単位法線になり、位置はそれを radius 倍するだけ。法線のための追加の計算はゼロです。

5. トーラス — 円の周りに円を回す

トーラス(ドーナツ形)は一見複雑ですが、構造を言葉にすると単純です。「大きな円の上を、小さな円が 1 周する」。半径majorRadiusの大円(ドーナツの中心を通る輪)の各点に、半径tubeRadiusの断面(管の輪切り)が乗っています。角度もやはり 2 つで、u が大円を、v が断面を 1 周します。

順番に組み立てます。u から決まる大円の角度を φ、v から決まる断面の角度を ψ とすると:

法線も、球と同じ発想でただで手に入ります。管の表面の外向きは、断面の中心 C から P へ向かう方向です。P − C を tubeRadius で割ればcos ψ · out + sin ψ · z— out と z は直交する単位ベクトルなので、この組み合わせの長さは cos²ψ + sin²ψ = 1。 正規化すら不要な単位法線です。成分を展開したものが、コードの (nx, ny, nz) です。

上から: 大円と断面の中心原点C(断面の中心)majorRadiusφ = u·2π薄い 2 本の円は管の内側と外側の輪郭C を通る断面(out と +z の平面)外向き out+zCtubeRadiusP法線ψ = v·2π法線 = (P − C) / tubeRadius。長さは cos²ψ + sin²ψ = 1
トーラスの構造。左の φ が断面の中心 C を決め、右の ψ が断面内での位置 P を決めます。円運動を 2 回重ねているだけで、法線は「断面の中心から P へ向かう向き」がそのまま使えます。
src/lib/geometry.ts(抜粋)
export function createTorus(
  majorRadius: number,
  tubeRadius: number,
  majorSegments = 48,
  tubeSegments = 24,
): Geometry {
  return buildGrid(majorSegments, tubeSegments, (u, v) => {
    const phi = u * Math.PI * 2; // 大円の角度
    const psi = v * Math.PI * 2; // 管の断面の角度
    // 管の断面の中心 = (cos φ, sin φ, 0) × majorRadius。
    // そこから断面の外向き(cos ψ 成分)と Z 方向(sin ψ 成分)に tubeRadius だけ離れる
    const nx = Math.cos(psi) * Math.cos(phi);
    const ny = Math.cos(psi) * Math.sin(phi);
    const nz = Math.sin(psi);
    return {
      position: [
        (majorRadius + tubeRadius * Math.cos(psi)) * Math.cos(phi),
        (majorRadius + tubeRadius * Math.cos(psi)) * Math.sin(phi),
        tubeRadius * Math.sin(psi),
      ],
      normal: [nx, ny, nz],
    };
  });
}

パラメータの意味がわかると、形の変え方もわかります。tubeRadiusを大きくすれば太いドーナツ、小さくすれば細い輪。そしてtubeRadiusmajorRadiusを超えると、管が原点を通り越して自分自身にめり込みます(「壊してみる」で試します)。

6. 新しい属性を色で見る — 法線と UV

生成した 3 つの配列(位置・法線・UV)は、第13章で学んだインターリーブで 1 本の VBO にまとめます。並びは 1 頂点 = [位置 3, 法線 3, UV 2] の 8 float、stride は 32 バイトです。 毎回同じ詰め替えループを書くのも共通化の対象なので、これもgeometry.tsに入れてあります。

src/lib/geometry.ts(抜粋)
export function interleave(geometry: Geometry): Float32Array {
  const vertexCount = geometry.positions.length / 3;
  const data = new Float32Array(vertexCount * 8);
  for (let i = 0; i < vertexCount; i++) {
    data.set(geometry.positions.subarray(i * 3, i * 3 + 3), i * 8);
    data.set(geometry.normals.subarray(i * 3, i * 3 + 3), i * 8 + 3);
    data.set(geometry.uvs.subarray(i * 2, i * 2 + 2), i * 8 + 6);
  }
  return data;
}

さて、法線と UV は生成しましたが、本格的な出番はまだ先です — 法線はライティング(第17章)、UV はテクスチャ(第16章)。とはいえ、正しく生成できたかはいま確かめたい。 こういうとき使えるのが、第7章や第10章で何度もやった「まず色にして眺める」です。法線の各成分は -1〜+1 なので、0〜1 へ写してから RGB に入れます。θ を 0〜1 に写した第10章の常套句と同じ発想です。

src/lessons/14-procedural-geometry/geometry.frag(抜粋)
// 法線 (-1〜+1) を 0〜1 の色へ写す。+x を向いた面は赤く、+y は緑、+z は青くなる。
// 補間で長さが 1 からずれるので normalize してから使う(理由は第17章で詳しく)
vec3 color = normalize(v_normal) * 0.5 + 0.5;

デモのソリッド表示がこの色です。右(+x)を向いた面ほど赤く、上(+y)ほど緑、手前(+z)ほど 青くなります。球を眺めると、色が位置ではなく向きに貼り付いていることが わかります — 回転しても「右を向いている側はつねに赤っぽい」ままです。これは頂点シェーダーで 法線をモデル行列で回しているからです。

src/lessons/14-procedural-geometry/geometry.vert(抜粋)
// 法線もモデル行列で回す。左上 3×3 だけ取り出すのは平行移動を効かせないため。
// この書き方が通用するのは回転だけのとき(拡大縮小が入ると崩れる)。
// 正しい一般解 =「法線行列」は、ライティングを扱う第17章で
v_normal = mat3(u_model) * a_normal;

UV のほうは、フラグメントシェーダーに市松模様で可視化するコードをコメントで入れてあります。 有効にすると、UV が球やトーラスの表面をどう覆っているかが見えます(第16章でテクスチャは、この UV に沿って貼られます)。

7. ワイヤーフレーム — VAO を 2 つ作る

分割数や三角形の割り方を目で確かめるには、面を塗りつぶすより辺だけを線で描くワイヤーフレーム表示が向いています。WebGL には three.js のwireframe: trueのようなスイッチはありませんが、部品はすでに全部持っています。drawElementsのモードをgl.TRIANGLESからgl.LINES(インデックス 2 個で線分 1 本)に変え、インデックスを「三角形のリスト」 から「辺のリスト」に差し替えるだけです。

辺のリストは三角形のリストから機械的に作れます。各三角形は辺を 3 本持ちますが、隣り合う三角形は 辺を共有しているので、そのまま並べると同じ辺が 2 回ずつ描かれてしまいます。toWireframeIndicesは Set で重複を取り除きます。

src/lib/geometry.ts(抜粋)
const edges = new Set<number>();
for (let i = 0; i < indices.length; i += 3) {
  const tri = [indices[i], indices[i + 1], indices[i + 2]];
  for (let e = 0; e < 3; e++) {
    const p = tri[e];
    const q = tri[(e + 1) % 3];
    // 辺 (p, q) と (q, p) を同一視するため、小さい方を上位に入れた 1 つの数にする
    edges.add(Math.min(p, q) * 65536 + Math.max(p, q));
  }
}

ここで第13章の知識が実戦投入されます。ワイヤーフレーム用に変わるのはインデックスだけで、頂点データは三角形描画と完全に同じです。つまり VBO は 1 本を共有し、IBO だけ差し替えたい。そして第13章で学んだとおり、ELEMENT_ARRAY_BUFFER のバインドは VAO に記録される(ARRAY_BUFFER は記録されない、という例の非対称です)。ならば「同じ VBO の配線 + 三角形の IBO」を記録した VAO と、「同じ VBO の配線 + 辺の IBO」を記録した VAO の 2 つを作っておけば、 描画時は VAO を切り替えるだけで済みます。

src/lessons/14-procedural-geometry/main.ts(抜粋)
// VBO は 1 本だけ。ソリッドとワイヤーフレームで共有する。
// ARRAY_BUFFER のバインドは VAO に記録されない(第13章)ので、
// ここで 1 回バインドすれば、この関数の最後までバインドされたままになる
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);

// VAO ① ソリッド用: 頂点の配線 + 三角形の IBO を記録する
const solidVao = gl.createVertexArray();
gl.bindVertexArray(solidVao);
configureAttributes(gl);
const triangleIbo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, triangleIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

// VAO ② ワイヤーフレーム用: 同じ VBO を同じ配線でつなぎ、IBO だけ辺のリストにする。
// ELEMENT_ARRAY_BUFFER のバインドは VAO に記録される(第13章)ので、
// あとは VAO を切り替えるだけで IBO も一緒に差し替わる
const wireVao = gl.createVertexArray();
gl.bindVertexArray(wireVao);
configureAttributes(gl);
const wireIbo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, wireIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wireIndices, gl.STATIC_DRAW);

描画ループ側は、状態機械のスイッチを 1 つ倒すだけになります。

src/lessons/14-procedural-geometry/main.ts(抜粋)
// VAO を切り替えるだけで、頂点の配線ごと IBO が差し替わる
if (wireframe) {
  gl.uniform1f(wireframeLocation, 1.0);
  gl.bindVertexArray(currentMesh.wireVao);
  gl.drawElements(gl.LINES, currentMesh.lineIndexCount, gl.UNSIGNED_SHORT, 0);
} else {
  gl.uniform1f(wireframeLocation, 0.0);
  gl.bindVertexArray(currentMesh.solidVao);
  gl.drawElements(gl.TRIANGLES, currentMesh.triangleIndexCount, gl.UNSIGNED_SHORT, 0);
}

「IBO が VAO に記録される」という仕様は、第13章では覚えるしかない非対称に見えたかもしれません。 しかし「同じ頂点を違うインデックスで描き分けたい」という今回のような場面では、VAO を切り替えるだけでインデックスごと切り替わってくれる、都合のよい設計として効いてきます。

コード全文

この章の主役である共通ヘルパーsrc/lib/geometry.tsと、それを使うレッスン側の 3 ファイルです。第6章と同じく、src/lib に置くコードも全文をここに掲載します — 隠すためではなく、繰り返し書かないための共通化だからです。

src/lib/geometry.ts(この章で導入した共通ヘルパー)
// ジオメトリ生成ヘルパー(第14章で導入)
// 平面・球・トーラスの頂点(位置・法線・UV)と三角形インデックスを生成する。
// どの形も「2 次元のグリッドをパラメータ空間として曲面に貼り付ける」だけで作れるので、
// グリッドの三角形分割は buildGrid() に一本化してある。
//
// 頂点データの前提(第13章の立方体と同じ):
// - インデックス描画(gl.TRIANGLES + drawElements)で使う
// - 三角形は「外から見て反時計回り(CCW)」= gl.cullFace(gl.BACK) でそのまま使える
// - UV の v は下が 0(gl_FragCoord と同じ向き。画像テクスチャを貼るときの上下は第16章)

export interface Geometry {
  /** 頂点位置 (x, y, z) × 頂点数 */
  positions: Float32Array;
  /** 単位法線 (nx, ny, nz) × 頂点数 */
  normals: Float32Array;
  /** テクスチャ座標 (u, v) × 頂点数 */
  uvs: Float32Array;
  /** 三角形リスト(3 要素で 1 枚) */
  indices: Uint16Array;
}

/** buildGrid のコールバック。パラメータ (u, v) ∈ [0,1]² から曲面上の 1 頂点を返す */
type SurfaceFn = (
  u: number,
  v: number,
) => { position: [number, number, number]; normal: [number, number, number] };

/**
 * (cols+1) × (rows+1) 個の頂点を持つグリッドを生成し、各マスを 2 枚の三角形に割る。
 * 頂点は「行 = v(下から上)、列 = u(左から右)」の順に並ぶ。
 *
 * 各マスの分割(外から見て CCW):
 *   c --- d      三角形 1: a → b → d
 *   |   / |      三角形 2: a → d → c
 *   | /   |
 *   a --- b
 */
function buildGrid(cols: number, rows: number, surface: SurfaceFn): Geometry {
  if (cols < 1 || rows < 1) {
    // 0 を渡すと u = ix / cols が 0 除算になり、全頂点が NaN になる(エラーは出ない)
    throw new Error(`分割数は 1 以上にしてください(cols=${cols}, rows=${rows})`);
  }
  const vertexCount = (cols + 1) * (rows + 1);
  if (vertexCount > 65535) {
    // インデックスを Uint16Array で持つため。番号 65535 は WebGL2 では
    // 「プリミティブ再開」の予約値なので頂点番号として使えず、上限は 0〜65534 = 65535 頂点
    throw new Error(`頂点数 ${vertexCount} は Uint16 インデックスの上限(65535)を超えています`);
  }

  const positions = new Float32Array(vertexCount * 3);
  const normals = new Float32Array(vertexCount * 3);
  const uvs = new Float32Array(vertexCount * 2);

  let vertex = 0;
  for (let iy = 0; iy <= rows; iy++) {
    const v = iy / rows;
    for (let ix = 0; ix <= cols; ix++) {
      const u = ix / cols;
      const { position, normal } = surface(u, v);
      positions.set(position, vertex * 3);
      normals.set(normal, vertex * 3);
      uvs.set([u, v], vertex * 2);
      vertex++;
    }
  }

  const indices = new Uint16Array(cols * rows * 6);
  let index = 0;
  for (let iy = 0; iy < rows; iy++) {
    for (let ix = 0; ix < cols; ix++) {
      const a = iy * (cols + 1) + ix; // 左下
      const b = a + 1; // 右下
      const c = a + (cols + 1); // 左上
      const d = c + 1; // 右上
      indices.set([a, b, d, a, d, c], index);
      index += 6;
    }
  }

  return { positions, normals, uvs, indices };
}

/**
 * XY 平面上の矩形(法線 +Z、原点中心)。three.js の PlaneGeometry と同じ向き。
 * 床として使うときはモデル行列で X 軸まわりに -90° 回す
 */
export function createPlane(width: number, height: number, segmentsX = 1, segmentsY = 1): Geometry {
  return buildGrid(segmentsX, segmentsY, (u, v) => ({
    position: [(u - 0.5) * width, (v - 0.5) * height, 0],
    normal: [0, 0, 1],
  }));
}

/**
 * 原点中心の球。v は極角(v=0 が南極、v=1 が北極)、u は経度 1 周。
 * 単位球上の位置がそのまま法線になる
 */
export function createSphere(radius: number, segments = 32, rings = 16): Geometry {
  return buildGrid(segments, rings, (u, v) => {
    const polar = v * Math.PI; // 0(南極) 〜 π(北極)
    const phi = u * Math.PI * 2;
    const sinPolar = Math.sin(polar);
    // z を -sin にすると外から見て CCW になる(buildGrid の分割との組み合わせで決まる)
    const nx = sinPolar * Math.cos(phi);
    const ny = -Math.cos(polar);
    const nz = -sinPolar * Math.sin(phi);
    return {
      position: [nx * radius, ny * radius, nz * radius],
      normal: [nx, ny, nz],
    };
  });
}

/**
 * XY 平面に置いたトーラス(穴が Z 軸を向く)。three.js の TorusGeometry と同じ向き。
 * u が大円(中心の輪)を 1 周、v が管の断面を 1 周する
 */
export function createTorus(
  majorRadius: number,
  tubeRadius: number,
  majorSegments = 48,
  tubeSegments = 24,
): Geometry {
  return buildGrid(majorSegments, tubeSegments, (u, v) => {
    const phi = u * Math.PI * 2; // 大円の角度
    const psi = v * Math.PI * 2; // 管の断面の角度
    // 管の断面の中心 = (cos φ, sin φ, 0) × majorRadius。
    // そこから断面の外向き(cos ψ 成分)と Z 方向(sin ψ 成分)に tubeRadius だけ離れる
    const nx = Math.cos(psi) * Math.cos(phi);
    const ny = Math.cos(psi) * Math.sin(phi);
    const nz = Math.sin(psi);
    return {
      position: [
        (majorRadius + tubeRadius * Math.cos(psi)) * Math.cos(phi),
        (majorRadius + tubeRadius * Math.cos(psi)) * Math.sin(phi),
        tubeRadius * Math.sin(psi),
      ],
      normal: [nx, ny, nz],
    };
  });
}

/**
 * 位置・法線・UV を 1 本の Float32Array にインターリーブする。
 * 1 頂点 = [px, py, pz, nx, ny, nz, u, v] の 8 float(stride 32 バイト)
 */
export function interleave(geometry: Geometry): Float32Array {
  const vertexCount = geometry.positions.length / 3;
  const data = new Float32Array(vertexCount * 8);
  for (let i = 0; i < vertexCount; i++) {
    data.set(geometry.positions.subarray(i * 3, i * 3 + 3), i * 8);
    data.set(geometry.normals.subarray(i * 3, i * 3 + 3), i * 8 + 3);
    data.set(geometry.uvs.subarray(i * 2, i * 2 + 2), i * 8 + 6);
  }
  return data;
}

/**
 * 三角形インデックスから重複のない辺を取り出し、gl.LINES 用のインデックスにする。
 * ワイヤーフレーム表示用(頂点バッファは三角形描画と共有できる)。
 * 頂点番号が 65536 未満である前提(辺のキーに 65536 を掛けたエンコードを使うため)
 */
export function toWireframeIndices(indices: Uint16Array): Uint16Array {
  const edges = new Set<number>();
  for (let i = 0; i < indices.length; i += 3) {
    const tri = [indices[i], indices[i + 1], indices[i + 2]];
    for (let e = 0; e < 3; e++) {
      const p = tri[e];
      const q = tri[(e + 1) % 3];
      // 辺 (p, q) と (q, p) を同一視するため、小さい方を上位に入れた 1 つの数にする
      edges.add(Math.min(p, q) * 65536 + Math.max(p, q));
    }
  }
  const lines = new Uint16Array(edges.size * 2);
  let i = 0;
  for (const edge of edges) {
    lines[i++] = Math.floor(edge / 65536);
    lines[i++] = edge % 65536;
  }
  return lines;
}
src/lessons/14-procedural-geometry/main.ts
// 第14章: ジオメトリをつくる — 球・トーラスのプロシージャル生成
// 第13章では立方体の頂点を配列に手書きした。この章では頂点を数式から生成する。
// 生成そのものは共通ヘルパー src/lib/geometry.ts(この章で導入)が担当し、
// このファイルは「生成したデータを GPU に載せて描く」部分に集中する。
//
// 見どころ:
// - どの形も同じ形式(位置・法線・UV + 三角形インデックス)で返ってくるので、
//   GPU 側の段取り(createMesh)は 1 つ書けば平面・球・トーラスに使い回せる
// - ソリッドとワイヤーフレームで VBO を共有し、IBO だけ違う VAO を 2 つ作る
//   (「ELEMENT_ARRAY_BUFFER のバインドは VAO に記録される」(第13章)の実践的な応用)

import { mat4 } from 'gl-matrix';
import {
  createPlane,
  createSphere,
  createTorus,
  type Geometry,
  interleave,
  toWireframeIndices,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './geometry.frag?raw';
import vertexSource from './geometry.vert?raw';

// ---------------------------------------------------------------------------
// メッシュ: ジオメトリを GPU に載せ、描き方(三角形 / 線分)ごとの VAO を用意する
// ---------------------------------------------------------------------------

interface Mesh {
  /** ソリッド用 VAO(頂点の配線 + 三角形の IBO) */
  solidVao: WebGLVertexArrayObject;
  /** ワイヤーフレーム用 VAO(同じ配線 + 辺の IBO) */
  wireVao: WebGLVertexArrayObject;
  /** drawElements に渡す三角形インデックスの個数 */
  triangleIndexCount: number;
  /** drawElements に渡す線分インデックスの個数 */
  lineIndexCount: number;
}

// インターリーブ後の 1 頂点 = [位置 3, 法線 3, UV 2] の 8 float。
// stride / offset はバイト単位なので、第13章と同じく BYTES_PER_ELEMENT から組み立てる
const STRIDE = 8 * Float32Array.BYTES_PER_ELEMENT; // 32 バイト
const NORMAL_OFFSET = 3 * Float32Array.BYTES_PER_ELEMENT; // 位置 3 つ分の後ろ
const UV_OFFSET = 6 * Float32Array.BYTES_PER_ELEMENT; // 位置 + 法線の後ろ

// 頂点属性の配線(第13章と同じ stride / offset 方式)。
// VAO と、切り出し元の VBO(ARRAY_BUFFER)をバインドした状態で呼ぶこと
function configureAttributes(gl: WebGL2RenderingContext): void {
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, STRIDE, 0);
  gl.enableVertexAttribArray(1); // a_normal
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, STRIDE, NORMAL_OFFSET);
  gl.enableVertexAttribArray(2); // a_uv
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, STRIDE, UV_OFFSET);
}

// ---------------------------------------------------------------------------
// カメラ定数(第3部で共通の 45°・0.1・100)
// ---------------------------------------------------------------------------

const FOVY = (45 * Math.PI) / 180; // 垂直方向の視野角
const NEAR = 0.1; // 近クリップ面
const FAR = 100; // 遠クリップ面

function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const wireIndices = toWireframeIndices(geometry.indices);

  // VBO は 1 本だけ。ソリッドとワイヤーフレームで共有する。
  // ARRAY_BUFFER のバインドは VAO に記録されない(第13章)ので、
  // ここで 1 回バインドすれば、この関数の最後までバインドされたままになる
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);

  // VAO ① ソリッド用: 頂点の配線 + 三角形の IBO を記録する
  const solidVao = gl.createVertexArray();
  gl.bindVertexArray(solidVao);
  configureAttributes(gl);
  const triangleIbo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, triangleIbo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  // VAO ② ワイヤーフレーム用: 同じ VBO を同じ配線でつなぎ、IBO だけ辺のリストにする。
  // ELEMENT_ARRAY_BUFFER のバインドは VAO に記録される(第13章)ので、
  // あとは VAO を切り替えるだけで IBO も一緒に差し替わる
  const wireVao = gl.createVertexArray();
  gl.bindVertexArray(wireVao);
  configureAttributes(gl);
  const wireIbo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, wireIbo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wireIndices, gl.STATIC_DRAW);

  // VAO のバインドを外しておく(以降のバッファ操作を VAO に記録しないため)
  gl.bindVertexArray(null);

  return {
    solidVao,
    wireVao,
    triangleIndexCount: geometry.indices.length,
    lineIndexCount: wireIndices.length,
  };
}

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  shapeControls: HTMLParagraphElement,
  modeControls: HTMLParagraphElement,
): void {
  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  // --- メッシュ生成: 数式 → 頂点データ → GPU ------------------------------
  // 引数(サイズと分割数)を変えて眺めるのがこの章の実験の中心
  const shapes = [
    { label: '平面', mesh: createMesh(gl, createPlane(2.0, 2.0, 8, 8)) },
    { label: '球', mesh: createMesh(gl, createSphere(1.0)) },
    { label: 'トーラス', mesh: createMesh(gl, createTorus(0.75, 0.3)) },
  ] as const;
  const initialShape = 1; // 最初は球
  let currentMesh = shapes[initialShape].mesh;
  let wireframe = false;

  // --- 切替ボタン(形状と表示の 2 系統) ------------------------------------

  for (const [index, shape] of shapes.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = shape.label;
    button.setAttribute('aria-pressed', index === initialShape ? 'true' : 'false');
    button.addEventListener('click', () => {
      currentMesh = shape.mesh;
      for (const b of shapeControls.querySelectorAll('button')) {
        b.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    shapeControls.append(button);
  }

  const modes = [
    { label: 'ソリッド(法線色)', wire: false },
    { label: 'ワイヤーフレーム', wire: true },
  ] as const;
  for (const [index, mode] of modes.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = mode.label;
    button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
    button.addEventListener('click', () => {
      wireframe = mode.wire;
      for (const b of modeControls.querySelectorAll('button')) {
        b.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    modeControls.append(button);
  }

  // --- 描画の準備 -----------------------------------------------------------

  // uniform ロケーションは初期化時に 1 回だけ取得する(第4章)。
  // プログラムも 1 つしか使わないので useProgram もここで 1 回だけ(第6章と同じ)
  gl.useProgram(program);
  const modelLocation = gl.getUniformLocation(program, 'u_model');
  const viewLocation = gl.getUniformLocation(program, 'u_view');
  const projectionLocation = gl.getUniformLocation(program, 'u_projection');
  const wireframeLocation = gl.getUniformLocation(program, 'u_wireframe');

  // 3D の定番設定(第13章): 深度テスト + 裏面カリング。
  // カリングを有効にしたまま正しく描ければ、生成した三角形の巻き(CCW)が正しい証明になる。
  // なお CULL_FACE はポリゴンにしか効かないので、gl.LINES のワイヤーフレームは
  // 有効のままでも裏側の線まで全部描かれる(本文 7 節)
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // カメラは固定。ビュー行列は 1 回だけ作り、射影行列はアスペクト比が
  // リサイズで変わるので毎フレーム作り直す(第12章)
  const view = mat4.create();
  mat4.lookAt(view, [0, 1.2, 3.2], [0, 0, 0], [0, 1, 0]);
  const projection = mat4.create();
  const model = mat4.create();

  // --- リサイズ: ループの先頭で「変わっていれば作り直す」(第4章と同じ) ----

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    // display: none などで表示サイズが 0 のときも、バッファは最低 1px 確保しておく
    // (0 だとアスペクト比が 0 / 0 = NaN になり、射影行列が丸ごと壊れる)
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- 描画ループ -----------------------------------------------------------

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();

    const time = timestamp / 1000;

    // モデル行列: ゆっくり回して、全方向からの見た目と巻きの正しさを確かめる
    mat4.identity(model);
    mat4.rotateY(model, model, time * 0.4);
    mat4.rotateX(model, model, time * 0.25);

    // 射影行列: アスペクト比は毎フレーム描画バッファのサイズ(viewport と同じ値)から計算する
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    // 深度バッファも一緒にクリアする(第13章)
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.uniformMatrix4fv(modelLocation, false, model);
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

    // VAO を切り替えるだけで、頂点の配線ごと IBO が差し替わる
    if (wireframe) {
      gl.uniform1f(wireframeLocation, 1.0);
      gl.bindVertexArray(currentMesh.wireVao);
      gl.drawElements(gl.LINES, currentMesh.lineIndexCount, gl.UNSIGNED_SHORT, 0);
    } else {
      gl.uniform1f(wireframeLocation, 0.0);
      gl.bindVertexArray(currentMesh.solidVao);
      gl.drawElements(gl.TRIANGLES, currentMesh.triangleIndexCount, gl.UNSIGNED_SHORT, 0);
    }

    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナー解除もしていない。
  // 止める必要が出るケースは第6章の stop()、リソース解放の一般論は第35章
  requestAnimationFrame(frame);
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const shapeControls = document.querySelector<HTMLParagraphElement>('#shape-buttons');
const modeControls = document.querySelector<HTMLParagraphElement>('#mode-buttons');
if (!canvas || !shapeControls || !modeControls) {
  throw new Error('デモに必要な要素が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, shapeControls, modeControls);
src/lessons/14-procedural-geometry/geometry.vert
#version 300 es

// 頂点属性。1 本のインターリーブ VBO から stride / offset で切り出される(第13章)。
// 配線番号は layout(location = N) で固定する(第3章と同じ流儀)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

// モデル・ビュー・射影行列(第12章)。CPU 側(gl-matrix)で組んで毎フレーム渡す
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_normal;
out vec2 v_uv;

void main() {
  // 法線もモデル行列で回す。左上 3×3 だけ取り出すのは平行移動を効かせないため。
  // この書き方が通用するのは回転だけのとき(拡大縮小が入ると崩れる)。
  // 正しい一般解 =「法線行列」は、ライティングを扱う第17章で
  v_normal = mat3(u_model) * a_normal;
  v_uv = a_uv;
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/14-procedural-geometry/geometry.frag
#version 300 es
precision highp float;

in vec3 v_normal;
in vec2 v_uv;

// 0.0 = ソリッド(法線を色として可視化) / 1.0 = ワイヤーフレーム(単色)
uniform float u_wireframe;

out vec4 fragColor;

void main() {
  // 法線 (-1〜+1) を 0〜1 の色へ写す。+x を向いた面は赤く、+y は緑、+z は青くなる。
  // 補間で長さが 1 からずれるので normalize してから使う(理由は第17章で詳しく)
  vec3 color = normalize(v_normal) * 0.5 + 0.5;

  // UV を確かめたいときは、上の 1 行をこの 2 行に差し替える(市松模様。テクスチャは第16章)。
  // fract が 4 周し、1 周が step で明暗 2 マスに分かれるので 8 × 8 マスになる:
  // vec2 tile = step(0.5, fract(v_uv * 4.0));
  // vec3 color = vec3(abs(tile.x - tile.y));

  // ワイヤーフレーム表示のときは単色に切り替える
  fragColor = vec4(mix(color, vec3(0.55, 0.80, 1.00), u_wireframe), 1.0);
}

three.js との対応

この章で作ったものは、three.js のジオメトリまわりの機能とほぼ 1 対 1 で対応します。

three.jsこの章
new THREE.PlaneGeometry(width, height, widthSegments, heightSegments)createPlane(width, height, segmentsX, segmentsY)。向きも同じ(XY 平面・法線 +Z)
new THREE.SphereGeometry(radius, widthSegments, heightSegments)createSphere(radius, segments, rings)。widthSegments が経度方向 = segments、heightSegments が緯度方向 = rings
new THREE.TorusGeometry(radius, tube, radialSegments, tubularSegments)createTorus(majorRadius, tubeRadius, majorSegments, tubeSegments)。対応は radius = majorRadius、tube = tubeRadius。ただし分割数は radialSegments が管の断面、tubularSegments が大円で、この章の引数と順序が逆なので移植時は注意
BufferGeometrysetAttribute('position' / 'normal' / 'uv')+setIndex(...)Geometryインターフェース(positions / normals / uvs / indices)。three.js の組み込みジオメトリが内部で作っているのも同じ 4 点セット
InterleavedBuffer + InterleavedBufferAttributeinterleave() と、stride / offset を指定する頂点属性の配線(第13章)
マテリアルの wireframe: true / WireframeGeometrytoWireframeIndices()で辺の IBO を作り、gl.LINESで描く(VBO は共有し、IBO だけ違う VAO を切り替える)
side: THREE.FrontSide(既定)/ THREE.DoubleSidegl.enable(gl.CULL_FACE)+ 外から見て CCW の巻き / カリング無効(第13章)

手元で動かして、壊してみる

この章のコードはsrc/lessons/14-procedural-geometry/src/lib/geometry.tsにあります。生成コードは、引数や式を変えた結果が即座に形になって返ってくるので、壊し甲斐が あります。

まとめ

形は自由に作れるようになりましたが、デモのカメラはずっと固定で、動いているのはモデル行列の ほうでした。次章はカメラを動かします。ドラッグで回り込み、ホイールで寄れる 軌道カメラを作るのですが、そこで使うのは球面座標 — つまり、この章で球の頂点を置いたのと 同じ数学で、今度はカメラの位置を決めます。同じ 2 つの角度に、今度はカメラの側から 会いに行きましょう(角度の割り当ては少し変わります。この章の極角は南極から測りましたが、 カメラでは水平面から測る仰角のほうが操作の感覚に合うので、第15章はそちらを使います)。