[CGクロニクル #15] 次世代のキャンバス:WebGPUとリアルタイムレイトレーシングが切り開く未来
[CGクロニクル #15] 次世代のキャンバス:WebGPUとリアルタイムレイトレーシングが切り開く未来
私たちが画面越しに見つめてきた「光」は、長い間、美しい嘘の積み重ねでした。
ユタ・ティーポットの滑らかな曲面も、グーロー・シェーディングがもたらした陰影も、すべては計算資源の制約という檻の中でいかに「現実らしく錯覚させるか」というハックの歴史でした。しかし、時代は移り変わります。WebGLによってブラウザに解放された描画の力は、今、WebGPUとリアルタイムレイトレーシングという次なる特異点へと到達しようとしています。
全15回にわたって紐解いてきた「0と1で描かれた光の記憶」。その最終回となる今回は、グラフィックスAPIの世代交代と、計算シェーダー(Compute Shader)が切り開く「描画の再定義」について語りましょう。
前回の記事:
[CGクロニクル #14] ブラウザの中の宇宙:WebGLとThree.jsがもたらした「描画」の民主化 // PROTOCOL.LAIN
Webブラウザがいかにして3Dキャンバスへと変貌を遂げたのか。WebGLとThree.jsが切り開いた、特別な環境を必要としない3D表現の民主化の歴史と、個人のための描画環境について解説します。
lain-lab.comWebGLの限界と、オーバーヘッドからの脱却
前回の記事で、WebGLとThree.jsがもたらした「3D表現の民主化」について触れました。特別なソフトウェアをインストールすることなく、誰もがURLを開くだけでインタラクティブな3D空間にアクセスできる。それは間違いなく、Webの歴史における一つの革命でした。
しかし、華やかなフロントエンドの進化の裏側で、グラフィックス・エンジニアたちは常に「見えない壁」と戦い続けていました。 その壁の正体は、WebGLの根底にある「1990年代の思想」そのものです。
1990年代の亡霊:グローバル・ステートマシン
WebGLは、デスクトップ向けのグラフィックスAPIである「OpenGL(具体的にはOpenGL ES 2.0/3.0)」をブラウザ向けに移植したものです。OpenGLが設計されたのは1990年代初頭。当時はCPUがシングルコアであることが当たり前で、GPU(当時はグラフィックス・アクセラレータと呼ばれていました)のアーキテクチャも現在とは全く異なるものでした。
この時代に生まれたOpenGLは、「巨大なグローバル・ステートマシン」として設計されました。 これは、グラフィックス・パイプライン全体が一つの状態(ステート)を共有し、それを順番に切り替えながら描画を行っていく仕組みです。
例えば、赤い箱と青い球を描画したい場合、WebGLでは以下のような手続きを「直列に」行う必要があります。
- 箱の頂点データをバインドする(
gl.bindBuffer) - 箱用のシェーダープログラムを有効にする(
gl.useProgram) - 赤色という状態(Uniform変数)をCPUからGPUへ転送する
- 描画命令を出す(Draw Call)
- 球の頂点データをバインドする
- 球用のシェーダープログラムを有効にする
- 青色という状態を転送する
- 描画命令を出す(Draw Call)
一見すると単純ですが、現代のハードウェアにおいて、この「状態の切り替え」と「直列な命令」は致命的な弱点となります。
マルチコア時代との決定的な不一致
現代のCPUは多数のコアを持ち、並列処理を得意としています。しかし、WebGLのグローバル・ステートマシンは「現在の状態」を一つしか持てないため、複数のCPUコアから同時に描画命令を構築することができません。別のスレッドが勝手に状態(例えば使用するテクスチャ)を変更してしまうと、描画が完全に破綻してしまうからです。
結果として、どれだけCPUのコア数が増えても、WebGLの描画処理は「たった1つのメインスレッド」で順番に処理するしかない、という大きなボトルネックを抱えることになりました。
ドローコールの渋滞と、飢えるGPU
そして最大の壁が「ドローコール(Draw Call)のオーバーヘッド」です。
CPUがGPUに対して「これを描画しろ」と命令を出す(Draw Callを発行する)たびに、ブラウザの内部では多大なコストが発生しています。APIの呼び出し、状態のバリデーション(矛盾がないかのチェック)、ブラウザのセキュリティ層からOSのグラフィックスドライバへの翻訳……。
現代のGPUは、数千から数万の計算コアを持ち、膨大な頂点やピクセルを一瞬で処理する「怪物」です。しかし、CPU側でドローコールを発行するための準備(状態変更と検証)に時間がかかりすぎるため、「CPUが命令を出すのを、GPUが暇を持て余して待っている」という現象が起きます。 これが「CPUバウンド」と呼ばれる状態です。
どんなに優秀なシェーダーを書き、どんなに強力なGPUを積んでいても、画面上に独立したオブジェクト(草、石、破片など)が数千個を超えたあたりで、フレームレートは急激に低下し始めます。GPUが限界を迎えたからではなく、CPUが「描画の指示出し」に追いつけなくなるからです。
私たちはこの制約を回避するために、複数のオブジェクトを無理やり一つのメッシュに結合(ジオメトリ・マージ)したり、インスタンシングという手法を使って同じ形状を使い回したりと、様々なハックを駆使してきました。しかし、それは根本的な解決ではなく、古いアーキテクチャの上での延命措置に過ぎなかったのです。
この「直列処理の限界」と「重すぎる命令コスト」というWebGLの枷を完全に破壊し、現代のハードウェアの真の力をブラウザに解き放つために。 次なる規格である WebGPU は、アーキテクチャの根本的な刷新から設計されることになります。
WebGPU:アーキテクチャの刷新と「計算」の解放
グローバル・ステートマシンという1990年代の亡霊と、ドローコールの渋滞によるCPUの限界。 その高く分厚い壁を打ち破るために生まれたのが WebGPU です。
誤解してはならないのは、WebGPUは決して「WebGL 3.0」ではないということです。これは、Vulkan、Metal、Direct3D 12といった、現代のハードウェアアーキテクチャに寄り添うモダンな低レイヤーAPIの思想を、ブラウザというサンドボックス向けに完全に再構築した、全く新しい次元のキャンバスです。
WebGLが「描画の度に状態を変更する」のに対し、WebGPUは「パイプライン・ステート・オブジェクト(PSO)」という概念を導入しました。あらかじめシェーダーやブレンド設定などの「状態」を一つのオブジェクトとして焼き付けておくことで、描画時のオーバーヘッドを劇的に削減します。さらに、描画コマンドの構築をマルチスレッド(複数のCPUコア)で分散して行えるようになったことで、WebGL時代には数千個で限界を迎えていたオブジェクトを、数万、数十万というスケールで同時に描画することが可能になりました。
しかし、WebGPUがもたらした最大のブレイクスルーは、描画パイプラインの最適化ではありません。 それは Compute Shader(計算シェーダー) のネイティブサポートにあります。
GPGPUというアクロバットからの卒業
これまでWebGLの環境下で、私たちはGPUの圧倒的な並列処理能力を「純粋な計算」に転用しようと試みてきました。いわゆる「GPGPU(General-Purpose computing on Graphics Processing Units)」と呼ばれる手法です。
しかし、WebGLには絵を画面に出力する機能しかありません。そこでエンジニアたちは、目に見えない仮想のテクスチャ(FBO)を作成し、計算したい初期データをピクセルの色(RGB値)として書き込みました。そして、画面いっぱいに平らな四角形(フルスクリーン・クワッド)を描画し、フラグメントシェーダーの中で色を読み取って計算を行い、その結果を再び別のテクスチャの色として出力する……という、極めてアクロバティックなハックを行ってきたのです。
データ型は無理やり浮動小数点テクスチャに押し込み、計算結果を次のフレームに渡すために2枚のテクスチャを交互に入れ替える(Ping-Pongハック)。それは、グラフィックスAPIの本来の用途を逸脱した、涙ぐましい努力の結晶でした。
純粋な「計算の海」へのダイブ
WebGPUのCompute Shaderは、こうしたハックを過去のものにします。 もはや、画面に「絵」を出す必要すらありません。頂点を定義する必要も、ピクセルの色にデータを隠蔽する必要もありません。
Compute Shaderの世界では、「スレッド(Invocation)」と「ワークグループ(Workgroup)」という概念を用いて、数百万の並列計算を純粋な「データの処理」としてGPUに直接投げ込むことができます。
各スレッドには3次元の空間的なインデックス(x, y, z)が割り当てられますが、GPUが扱うメモリバッファはあくまで1次元の直線的な配列です。そのため、無数のスレッドが自分が読み書きすべきメモリの正確な番地(アドレス)を特定するために、シェーダーの冒頭には次のような「次元を畳み込む」数式がしばしば記述されます。
このシンプルな数式によって、広大な3次元グリッドに配置されたスレッドたちは、一次元配列という連続したメモリ空間の正しい位置へと一斉にアクセスし、衝突することなく計算を進めていくのです。
数万のパーティクルが重力や風の影響を受けて舞う物理演算、複雑な偏微分方程式を解く流体シミュレーション、さらには巨大な行列演算を必要とする機械学習の推論(WebNNなどの基盤技術)までもが、ブラウザ上でリアルタイムに実行可能になります。
「画素(ピクセル)」を塗るためだけに使われていたシリコンのチップは、その呪縛から解き放たれ、ついに真の意味で汎用的な「計算の海」へと姿を変えたのです。
現実の光を模倣する:リアルタイムレイトレーシング
「計算の海」であるCompute Shaderがもたらした恩恵は、単なる処理の最適化にとどまりません。計算能力の解放は、かつてはエンジニアたちの夢物語でしかなかった究極の技術を、ついにリアルタイムの領域へと引きずり込みました。 それが リアルタイムレイトレーシング です。
第7回で紹介したTurner Whittedの再帰的レイトレーシングは、ピクセルごとに光の経路を逆算していく画期的な手法でした。しかし、それは1フレームの画像を生成するのに数十分、あるいは数時間という膨大な時間を要する「オフラインレンダリング」の特権でした。リアルタイム処理が必須のゲームやインタラクティブWebの世界では、到底手の届かないオーパーツだったのです。
しかし現在、私たちは全く別の次元に立っています。専用のハードウェア・アクセラレータ(RTコアによるBVH探索の高速化)と、Compute Shaderが駆動する強力なノイズ除去(デノイズ)アルゴリズム、そして膨大な並列計算能力によって、CGにおける「究極の真理」とも言える数式を、毎秒60回(60fps)という速度で解き続けようとしているのです。
それが、1986年にJames Kajiyaが提唱した レンダリング方程式(The Rendering Equation) です。
この美しい積分方程式は、「ある点 から特定の方向 へ放たれる光の総量 」を定義しています。 それは、その点自身が発する光()に、半球状()のあらゆる方向から飛び込んでくる光()が、材質の特性(、すなわち第13回で触れたPBRのBRDF)と入射角()に応じてどのように反射するかをすべて足し合わせたものです。
無限の方向から来る光の積分()をリアルタイムに計算することは不可能です。そのため、現代のリアルタイムレイトレーシングでは、モンテカルロ法を用いてランダムな数本のレイ(光線)だけを飛ばし、確率的に結果を推定します。当然、レイの数が少なければ画面はザラザラのノイズだらけになりますが、ここで再びCompute Shaderの力が輝きます。空間的・時間的に情報を再利用する高度なデノイザーが、そのノイズを一瞬にして「滑らかな現実の光」へと再構築するのです。
「美しい嘘」への終止符
リアルタイムレイトレーシングの到来は、私たちが長年積み上げてきた「美しい嘘」の歴史への終止符を意味します。
第12回で四苦八苦して捏造したシャドウマップは、解像度不足によるジャギーや、光の漏れ(ピーターパン現象)という宿命を抱えていました。画面外のオブジェクトの反射を描画できないスクリーンスペース・リフレクション(SSR)や、薄暗い角の影を擬似的に表現するSSAOといったラスタライズ法のハックたちは、どれも「物理法則」ではなく「人間の錯覚」を利用したものでした。
しかし、光の物理法則を直接シミュレートするレイトレーシングの前では、これらの複雑なハックはすべて不要になります。
光源から放たれた光が壁に当たって乱反射し(グローバルイルミネーション)、赤い壁の近くに置かれた白い球体がほんのりと赤く染まる(カラーブリーディング)。水面は周囲の景色を正確に歪ませて映し出し、複雑な形状のオブジェクトはピクセル単位で正確なソフトシャドウを落とす。
「影を描画するアルゴリズム」や「反射を描画するアルゴリズム」が別々に存在するのではなく、ただ「光の粒子が空間を飛び交うルール」を数式で定義するだけで、すべての物理現象がごく自然に、かつ同時に浮かび上がってくるのです。
もはや、私たちが画面の中に行っているのは「描画」ではありません。 それは、0と1の宇宙の中に光の法則を記述し、その結果を観察する「現実のシミュレーション」と呼ぶべき領域へと到達したのです。
おわりに:0と1が紡ぐ、終わらない光の記憶
ユタ大学の片隅でスキャンされた朝食のドーナツ(トーラス)と、マーティン・ニューウェルが妻とのティータイムに見つめたティーポット。 そこから始まったコンピュータ・グラフィックスの歴史は、私たち人間の「まだ見ぬ世界を見たい」という純粋な渇望と、それを数式で記述しようとする狂気じみた執念、そしてハードウェアの劇的な進化が織りなす壮大なクロニクルでした。
かつて、限られたメモリと貧弱な計算能力——古き良き8ビットPCのような厳しい制約——の中で、先人たちは1ピクセルでも多く、1フレームでも速く描画するために文字通り知恵を絞り尽くしました。固定パイプラインの制限の中で生み出されたDDAやブレゼンハムのハック、あるいは自然界のゆらぎを数式に落とし込むための果てしない探求。それらは、リアルタイムレイトレーシングが当たり前になりつつある現代から見れば、過去の遺物として片付けられてしまうかもしれません。
しかし、その過程で生まれたアルゴリズムたちは、決して無駄になったわけではありません。 数式が予期せぬ完璧な模様を描き出した瞬間の震えるような感動や、ピクセルの一つ一つに宿る数学的な美しさは、APIがどれほどモダンになろうとも失われることはありません。それらは、私たちが眠る間を惜しんでエディタに向かい、コードを書き続ける動機そのものとして、今も根底に脈々と流れ続けています。
WebGLが切り開いたブラウザの中の宇宙は、WebGPUという新たなエンジンを得て、さらに広大で深い「計算の海」へと姿を変えようとしています。カンバスは進化し、描画のルールは根底から覆りました。
しかし、数式を通じて世界を理解し、画面の向こう側に0と1だけで全く新しい宇宙を創り出そうとするエンジニアの情熱だけは、CGの黎明期から何一つ変わっていません。
次世代のキャンバスには、一体どんな景色が描かれるのでしょうか。
その答えは、他でもない、今この瞬間も黒い画面に向かい、終わらない光の記憶を紡ぐために新しいアルゴリズムを書き連ねている私たちの手の中にあります。
一分間の数式美:未来のカンバス(High-Luminance Abstract Space)
連載の最後を飾るのは、物理的な制約から解き放たれ、光そのものが情報として飽和していくような抽象空間の表現です。レイマーチングと光の累積(Accumulation)を用いた、未来の描画を予感させる高輝度な演算風景。
これまでの歴史で先人たちが夢見た「光のシミュレーション」が、わずか数十行の数式でリアルタイムに描画される。それこそが、次世代のカンバスが持つ圧倒的なポテンシャルです。
// GLSL - Canvas of the Future
// 光が飽和し、情報が溶け合う次世代の演算空間をイメージしたフラグメントシェーダー
precision highp float;
uniform vec2 resolution;
uniform float time;
// 空間を歪めるローテート関数
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
// 距離関数:無限に続く抽象的な発光構造体
float map(vec3 p) {
p.xz *= rot(time * 0.2);
p.xy *= rot(time * 0.1);
// 空間の折り畳みと多重構造
vec3 q = fract(p) - 0.5;
float d1 = length(q) - 0.15;
// 計算シェーダーの「グリッド」を連想させる骨組み
float d2 = length(q.xy) - 0.02;
d2 = min(d2, length(q.yz) - 0.02);
d2 = min(d2, length(q.zx) - 0.02);
return min(d1, d2);
}
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
vec3 ro = vec3(time * 0.5, time * 0.3, time * 0.8); // 視点の移動
vec3 rd = normalize(vec3(uv, 1.0));
float t = 0.0;
float glow = 0.0;
// レイマーチングによる空間探索と光の蓄積(GIのメタファー)
for(int i = 0; i < 80; i++) {
vec3 p = ro + rd * t;
float d = map(p);
// 表面に近づくほど光(情報)を蓄積する
glow += 0.015 / (0.01 + abs(d));
if(d < 0.001 || t > 10.0) break;
t += d * 0.7; // アーティファクトを防ぎつつ前進
}
// 光の飽和と色収差的な表現
vec3 col = vec3(0.1, 0.4, 0.8) * glow;
col = mix(col, vec3(0.9, 0.2, 0.5), sin(t * 2.0 + time) * 0.5 + 0.5);
// 輝度のトーンマッピング(ACES的なアプローチ)
col = col / (1.0 + col);
col = pow(col, vec3(1.0 / 2.2)); // ガンマ補正
gl_FragColor = vec4(col, 1.0);
}