ジオメトリをつくる — 球・トーラスのプロシージャル生成
第13章では、立方体の頂点をひとつずつ配列に手書きしました。面の向きを考えながら座標を書き並べる 作業は、立方体ならなんとかなります。しかしこの章で描く球は頂点が 561 個あります。手書きは 現実的ではありません — そして、する必要もありません。球の頂点の位置は数式で計算できるからです。頂点データを、手で書く代わりにプログラムで作る。これをプロシージャル生成 (procedural generation)と呼びます。
three.js で new THREE.SphereGeometry(1, 32, 16)と書いたとき、内部ではまさにこの章でやる計算が走って、頂点の配列ができあがっています。この章は、その 1 行の中身を自作する章です。作るのは平面・球・トーラスの 3 つ。そして 3 つとも、「(u, v) のグリッドを曲面に貼り付ける」というたった 1 つの型で作れることを見ていきます。
この章で学ぶこと:
- 頂点の手書きから数式生成へ。生成結果はいつも同じ形式(位置・法線・UV + インデックス)なので、第6章以来 2 度目の共通ヘルパー
src/lib/geometry.tsに切り出す - (u, v) ∈ [0,1]² のグリッドを曲面に貼り付ける
buildGridの型 — グリッド分割、三角形の巻き(外から見て CCW)、Uint16 インデックスの上限 - 球は第10章の極座標の 3D 版。緯線の輪は第9章の円運動 vec2(cos, sin) で作る
- 極で重なる頂点と、u = 0 / u = 1 の「継ぎ目」の重複頂点がなぜ必要か
- 新しい属性、法線と UV。この章では法線を色として可視化する(本格利用は法線が第17章、UV が第16章)
toWireframeIndices+gl.LINESのワイヤーフレーム表示。VBO を共有し、IBO だけ違う VAO を 2 つ作る
1. 手書きの限界と、2 度目の共通化
第13章の立方体で、頂点データの形式はすでに決まっています。各頂点はいくつかの 属性の組を持ち(第13章では位置と色、この章からは位置・法線・UV)、三角形はインデックスの並びで 表す。実はこの形式は、この先どんな形を作っても変わりません。 球でもトーラスでも、第19章で外部からモデルを読み込むようになっても、GPU に渡すものは「頂点の属性の束 + 三角形のインデックス」のままです。
形が毎回同じなら、型を 1 つ決めて使い回せます。この章では、生成したジオメトリを次のかたまりで受け渡すことにします。
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 つに閉じ込められます。
/** 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章のライティングです)。
buildGridは cols × rows 個のマスを作るので、格子点(= 頂点)は (cols + 1) × (rows + 1) 個です。 隣のマスどうしは辺の頂点を共有するので、これはインデックス描画(第13章)がいちばん得意とする データです。頂点は「v の行を下から上へ、各行の中は u を左から右へ」の順で並べ、各マスを 対角線で 2 枚の三角形に割ります。
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 を向きます。
/**
* 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 つ分あるので、割り当てはこうなります。
- v → 極角—
polar = v × π。v = 0 が南極、v = 1 が北極。輪の半径はsin(polar)、高さは-cos(polar)(南極で -1、赤道で 0、北極で +1) - u → 方位角—
phi = u × 2π。半径sin(polar)の輪の上を cos / sin で 1 周する。第9章で円運動をvec2(cos(t), sin(t))と書いたのとまったく同じ計算です
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— XY 平面上の大円の上にあるので、第9章の円運動そのままに
C = majorRadius × (cos φ, sin φ, 0) - 断面の平面— C で管を輪切りにした断面は、「大円の外向き」方向と「+Z(穴の軸)」方向が張る平面です。外向きの 単位ベクトルは
out = (cos φ, sin φ, 0) - 断面上の点 P— その平面内で半径
tubeRadiusの円運動をもう 1 回:P = C + tubeRadius × (cos ψ · out + sin ψ · z)
法線も、球と同じ発想でただで手に入ります。管の表面の外向きは、断面の中心 C から P へ向かう方向です。P − C を tubeRadius で割ればcos ψ · out + sin ψ · z— out と z は直交する単位ベクトルなので、この組み合わせの長さは cos²ψ + sin²ψ = 1。 正規化すら不要な単位法線です。成分を展開したものが、コードの (nx, ny, nz) です。
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を大きくすれば太いドーナツ、小さくすれば細い輪。そしてtubeRadiusがmajorRadiusを超えると、管が原点を通り越して自分自身にめり込みます(「壊してみる」で試します)。
6. 新しい属性を色で見る — 法線と UV
生成した 3 つの配列(位置・法線・UV)は、第13章で学んだインターリーブで 1 本の VBO にまとめます。並びは 1 頂点 = [位置 3, 法線 3, UV 2] の 8 float、stride は 32 バイトです。 毎回同じ詰め替えループを書くのも共通化の対象なので、これも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章の常套句と同じ発想です。
// 法線 (-1〜+1) を 0〜1 の色へ写す。+x を向いた面は赤く、+y は緑、+z は青くなる。
// 補間で長さが 1 からずれるので normalize してから使う(理由は第17章で詳しく)
vec3 color = normalize(v_normal) * 0.5 + 0.5;デモのソリッド表示がこの色です。右(+x)を向いた面ほど赤く、上(+y)ほど緑、手前(+z)ほど 青くなります。球を眺めると、色が位置ではなく向きに貼り付いていることが わかります — 回転しても「右を向いている側はつねに赤っぽい」ままです。これは頂点シェーダーで 法線をモデル行列で回しているからです。
// 法線もモデル行列で回す。左上 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 で重複を取り除きます。
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 を切り替えるだけで済みます。
// 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 つ倒すだけになります。
// 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 に置くコードも全文をここに掲載します — 隠すためではなく、繰り返し書かないための共通化だからです。
// ジオメトリ生成ヘルパー(第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;
}// 第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);#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);
}#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 が大円で、この章の引数と順序が逆なので移植時は注意 |
BufferGeometryにsetAttribute('position' / 'normal' / 'uv')+setIndex(...) | Geometryインターフェース(positions / normals / uvs / indices)。three.js の組み込みジオメトリが内部で作っているのも同じ 4 点セット |
InterleavedBuffer + InterleavedBufferAttribute | interleave() と、stride / offset を指定する頂点属性の配線(第13章) |
マテリアルの wireframe: true / WireframeGeometry | toWireframeIndices()で辺の IBO を作り、gl.LINESで描く(VBO は共有し、IBO だけ違う VAO を切り替える) |
side: THREE.FrontSide(既定)/ THREE.DoubleSide | gl.enable(gl.CULL_FACE)+ 外から見て CCW の巻き / カリング無効(第13章) |
手元で動かして、壊してみる
この章のコードはsrc/lessons/14-procedural-geometry/とsrc/lib/geometry.tsにあります。生成コードは、引数や式を変えた結果が即座に形になって返ってくるので、壊し甲斐が あります。
main.tsのcreateSphere(1.0)をcreateSphere(1.0, 32, 3)にする — 緯線の輪が 3 段しかない、そろばんの玉のような球になります。ワイヤーフレームで 見ると「グリッドを貼り付けただけ」の正体がよくわかります。createSphere(1.0, 8, 4)など経度方向も減らして、どこまで球に見えるか探ってみてくださいcreateTorus(0.75, 0.3)をcreateTorus(0.3, 0.75)にする — tubeRadius が majorRadius を超え、管が原点を突き抜けて自分自身にめり込みます。 外から見るとりんごのような形です。式は何も壊れておらず、「意味をなすパラメータの範囲」が あるだけ、という例ですsrc/lib/geometry.tsのbuildGridにあるindices.set([a, b, d, a, d, c], index)をindices.set([a, d, b, a, c, d], index)にする — 全三角形の巻きが逆(外から見て時計回り)になり、カリングですべて消えて何も描かれなくなります。main.tsのgl.enable(gl.CULL_FACE)をコメントアウトすると再び見えますが、表示されているのは全部「裏面」ですcreateSphereのnz = -sinPolar * Math.sin(phi)のマイナスを外す — 球だけ巻きが逆になり、外側の面がカリングされて、球の内側だけが見える不思議な絵になります(手前半分が消え、奥半分の内壁が見えています)geometry.fragのコメントアウトされた市松模様の 2 行を有効にする(法線色の行と差し替え)— UV の確認です。球の極でマスがつぶれて集まる様子、平面にきれいに 8 × 8 が並ぶ様子が見えますgeometry.vertのv_normal = mat3(u_model) * a_normal;をv_normal = a_normal;にする — 色が「世界のどちらを向いているか」ではなく表面に貼り付いたものになり、模様のように 物体と一緒に回ります。法線をワールド空間へ持っていくことの意味が見える実験ですcreatePlane(2.0, 2.0, 8, 8)の分割数を1, 1にする — ソリッド表示では違いがまったくわかりませんが、ワイヤーフレームにすると三角形 2 枚だけになっています。「分割数は形ではなく計算の細かさを買う」の確認です
まとめ
- 頂点データは手書きから数式生成へ。生成結果は位置・法線・UV + インデックスの共通形式で、第6章以来 2 度目の共通ヘルパー
src/lib/geometry.tsに切り出した(新しい API はゼロ、入っているのは既習のループと三角関数だけ) - 平面・球・トーラスはすべて「(u, v) ∈ [0,1]² のグリッドを曲面に貼り付ける」1 つの型。
buildGridがマスを三角形 2 枚(外から見て CCW)に割り、形ごとの違いは surface 関数だけ - 球は極座標(第10章)の 3D 版。v が極角、u が方位角で、緯線の輪は第9章の円運動。極と u = 0/1 の継ぎ目には「位置が同じで UV が違う」重複頂点が要る
- トーラスは円運動の 2 段重ね。断面の中心 C = majorRadius × (cos φ, sin φ, 0)、法線は C から 表面の点へ向かう向きそのもの
- 法線は normal × 0.5 + 0.5 で色として確認(本格利用と法線行列は第17章)、UV は市松模様で 確認(テクスチャは第16章)
- ワイヤーフレームは辺の IBO +
gl.LINES。VBO を共有して IBO だけ違う VAO を 2 つ作れば、「IBO は VAO に記録される」(第13章)おかげで VAO の切り替えだけで描き分けられる
形は自由に作れるようになりましたが、デモのカメラはずっと固定で、動いているのはモデル行列の ほうでした。次章はカメラを動かします。ドラッグで回り込み、ホイールで寄れる 軌道カメラを作るのですが、そこで使うのは球面座標 — つまり、この章で球の頂点を置いたのと 同じ数学で、今度はカメラの位置を決めます。同じ 2 つの角度に、今度はカメラの側から 会いに行きましょう(角度の割り当ては少し変わります。この章の極角は南極から測りましたが、 カメラでは水平面から測る仰角のほうが操作の感覚に合うので、第15章はそちらを使います)。