世界を数式で描く 05 / 05 もくもく会トップ
SERIES 世界を数式で描く ― 05

数百万の点をブラウザで動かすthree.js で粒子化する仕組み

04で作った点群は、171万点ありました。これをブラウザで、毎秒60回描き直しながら動かします。なぜそれができるのかを、処理の流れと、触って違いが分かる実験で見ていきます。

この記事で持ち帰る3行
  1. 重い計算は前もって済ませ、毎フレームは GPU の単純な並列処理だけにする
  2. 点の形・大きさ・重なりは、シェーダ(GPU で動く小さなプログラム)で決まる
  3. 粒子化とは、点の位置を時間の関数にすること

処理の流れ:撮影時・読み込み時・毎フレーム

「全画素を点にする」は、2Dの画像を3D空間の標本に読み替えることです。「three.js で粒子化する」は、その標本を GPU に渡し、全部の点に同じ処理を並列に当てて描き、さらに時間で動かすことです。仕事は3つの時期に分かれます。

V05-1

画素から粒子までの7段

何を見るか青=撮影時(Python・1回)、灰=読み込み時(JavaScript・1回)、紫=毎フレーム(GPU・並列)。 触ってみよう段を押すと、その段を実際に担っている C' のコードが右に出ます。

bake_room.py(Blender の Python)

sc.cycles.filter_width = 0.01          # 画素の中心だけを標本化 → Position が縁で混ざらない
sc.render.resolution_x = sc.render.resolution_y = RES
…
bpy.ops.render.render(write_still=True)
…
rgb = np.stack([ch(f"ViewLayer.Combined.{c}") for c in "RGB"], -1)
コードは C' の bake_room.py と room.html からの抜粋です。「…」は省略した箇所で、それ以外は原文のままです。

点はどれだけの量になるか

画素をすべて点にすると、量はすぐに膨らみます。フルHDの画像を36方向から撮ると、約7,465万点です。位置と色をそれぞれ32ビットの小数3つで持つと、1点24バイト、合計約1.8GBになり、ブラウザでは扱えません。そこで、マスで間引くことと、数値を丸めて小さく持つ(量子化)ことが必須になります。

V05-4

メモリ計算機

何を見るか撮影の条件と、1点の持ち方から、データの大きさを計算します。下段は C' の実測です。 触ってみよう色を8ビット整数にすると何割減るか。視点を半分にすると何が減り、何が残るか(04の穴を思い出す)。
位置
色
C' の実測点の数1点大きさ
撮った画素(264視点 × 128×128)4,325,376——
2.5cm のマスで間引いた点群(room.bin)1,707,389(39%)15B(位置 float32・色 8ビット)25.6MB
記事用:6cm のマス・位置を16ビットに(04で表示)292,4919B2.6MB

three.js に何を渡すか

three.js には、点の位置と色を型付き配列(Float32Array など、数値だけが詰まった配列)のまま渡します。BufferGeometry にこの配列を属性として持たせ、THREE.Points として描きます。JSON のような文字の形ではなくバイナリで渡すのは、そのまま GPU のメモリへ転送できるからです。C' の room.bin も、ファイルの先頭に点の数を書き、その後ろに位置(32ビット小数)と色(8ビット整数)を並べただけの形です。記事用に軽くした版は、これに座標の範囲を足し、位置を16ビット整数に丸めています。

GPU はなぜ速いか

数百万点を毎フレーム描けるのは、全部の点に同じプログラム(頂点シェーダ)を並列に当て、1回の描画命令で済ませているからです。逆に、JavaScript のループで毎フレーム全点の位置を書き換えると、計算そのものに加えて、CPU から GPU へのデータ転送が毎回発生します。

V05-5

CPU で動かすか、GPU で動かすか

何を見るか同じ揺らぎを、JavaScript のループで位置を書き換える方法と、頂点シェーダに時間だけを渡す方法で動かし、1秒あたりの描画回数(FPS)を比べます。 触ってみよう点の数を増やしてから切り替える。どの点数で差が開くかは端末によって違います。
画面に入ると始まります
FPS はモニタの更新回数(多くは60)で頭打ちになります。差は、CPU 側が間に合わなくなったときに現れます。

点の大きさ・形・重なり

GPU は点を、画面上の小さな正方形として描きます。大きさは頂点シェーダの gl_PointSize が決めます。カメラからの距離で割れば、遠くの点ほど小さくなり、遠近感が出ます(three.js では sizeAttenuation)。丸い粒にしたいときは、フラグメントシェーダで正方形の中の位置(gl_PointCoord)を見て、円の外側を捨てます(discard)。

半透明にすると、もう1つ問題が出ます。点ごとに前後を並べ替えて描くわけではないので、重なりの順序が崩れます。加算合成(色を足し合わせる描き方)は順序に関係なく同じ結果になりますが、重なった所が明るくなります。

V05-2

点の描き方プレイグラウンド

何を見るか同じ点の集まりを、形・遠近・合成の切り替えで描き分けます。FPS も表示します。 触ってみよう① 遠近を切ると、手前も奥も同じ大きさになり奥行きが消える。② 加算合成にすると、点が密な中心が白く飛ぶ。③ 点を200万まで増やして FPS を見る。
形 遠近 合成
画面に入ると始まります
スマートフォンなど画面の狭い端末では、点の上限を50万に下げています。

粒子化:時間を入れる

静止した点を動かすには、位置を時間の関数にします。たとえば、散らばった位置から元の位置へ、時間とともに補間します。ノイズで揺らすこともできます。時間は uniform(全点に共通の値)として毎フレーム1つだけ渡し、位置の計算は頂点シェーダが全点で並列に行います。

速度や力で動かす(ぶつかる、流れに乗る)には、前のフレームの状態から次の状態を計算し、結果を書き戻す必要があります。頂点シェーダは結果を書き戻せないので、状態をテクスチャ(画像)に持たせて往復で更新する方法(GPGPU)か、WebGPU のコンピュートシェーダを使います。これは02の数値積分と同じ問題です。

V05-3実例

粒子化モーフ:散らばった点が部屋になる

何を見るか04の C' の点群(約29万点)を、球状に散らばった位置から部屋の形へ集めます。位置の計算はすべて頂点シェーダで行っています。 触ってみよう時間のスライダーを左右に動かす。ノイズを強くすると、集まりきった後も点が揺れ続ける。
JavaScript が毎フレーム渡すのは、時間とノイズの強さの2つの数だけです。
シェーダで見る
attribute vec3 scatter;         // 散らばった位置(読み込み時に1回だけ作る)
uniform float uT, uNoise, uTime;
float e = uT * uT * (3.0 - 2.0 * uT);              // ゆっくり始まり、ゆっくり止まる
vec3 p = mix(scatter, position, e);                  // 位置 = 時間の関数
p += uNoise * sin(position.yzx * 3.1 + uTime * 1.3); // 揺らぎ
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);

症状から原因を当てる

点群は、どこかの約束を取り違えると、決まった形で壊れます。下の6枚は、04の逆射影ラボでわざと壊した状態です。原因を選んでください。

V05-6

症状クイズ

何を見るかどれも、同じ部屋(立方体と球を置いた箱)を撮って点に戻したものです。正しく戻すと、壁は平らで、部屋は床が下にあります。 触ってみよう選ぶとすぐに正誤と理由が出ます。迷ったら04のラボで同じ壊し方を試せます。

点群の次の表現

点群は、面も材質も持たない「見えた光の記録」です。近づくと隙間が見え、照明を変えることもできません。この弱点を埋める表現がいくつかあります。

表現何を持つか点群との違い
メッシュ点をつないだ三角形の面近づいても隙間がない。材質を付ければ照明を変えられる
NeRF空間の各点の色と密度を返す連続関数(ニューラルネット)点の代わりに関数で場面を持つ。描くたびに関数を何度も評価する
3D Gaussian Splatting大きさ・向き・不透明度・視点で変わる色を持つ、ぼけた楕円体の集まり点に広がりと視点依存の色を足し、画像との誤差を小さくするよう最適化する

3D Gaussian Splatting は Kerbl ほか(ACM Transactions on Graphics、2023年)。各要素は位置・不透明度・異方的な共分散(楕円体の形)・球面調和関数の係数(視点で変わる色)を持ちます(arXiv:2308.04079)。

用語集

GPU
同じ計算を大量のデータに並列で当てるのが得意な演算装置
シェーダ
GPU で動く小さなプログラム。頂点シェーダは点の位置と大きさ、フラグメントシェーダは画素の色を決める
型付き配列
Float32Array など、同じ型の数値だけを隙間なく詰めた配列。そのまま GPU に送れる
uniform
全部の点に共通してシェーダへ渡す値。時間やカメラ位置など
sizeAttenuation
カメラからの距離に応じて点を小さくする設定
加算合成
重なった色を足し合わせる描き方。順序に依存しないが、重なりが明るくなる
量子化
数値を粗い段階に丸めて、少ないビットで持つこと
FPS
1秒あたりに画面を描き直す回数
GPGPU
GPU を描画以外の計算に使うこと。状態をテクスチャに持たせて更新する方法がある

この記事の問い