数百万の点をブラウザで動かすthree.js で粒子化する仕組み
04で作った点群は、171万点ありました。これをブラウザで、毎秒60回描き直しながら動かします。なぜそれができるのかを、処理の流れと、触って違いが分かる実験で見ていきます。
- 重い計算は前もって済ませ、毎フレームは GPU の単純な並列処理だけにする
- 点の形・大きさ・重なりは、シェーダ(GPU で動く小さなプログラム)で決まる
- 粒子化とは、点の位置を時間の関数にすること
処理の流れ:撮影時・読み込み時・毎フレーム
「全画素を点にする」は、2Dの画像を3D空間の標本に読み替えることです。「three.js で粒子化する」は、その標本を GPU に渡し、全部の点に同じ処理を並列に当てて描き、さらに時間で動かすことです。仕事は3つの時期に分かれます。
画素から粒子までの7段
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)bake_room.py:画素ごとに視線を作り、場面の形(BVH)に当てる
for row in range(RES):
v = (1 - (row + .5) / RES * 2) * tf # 画像の0行目=上
for col in range(RES):
u = ((col + .5) / RES * 2 - 1) * tf
hit = BVH.ray_cast(org, R @ Vector((u, v, -1)))[0]
if hit is not None: P.append(hit[:]); C.append(rgb[row, col])bake_room.py:2.5cm のマス(VOX = 0.025)で束ね、three.js の向きへ
keys = np.floor(P / VOX).astype(np.int32)
…(マスごとに位置と色を足し合わせ、最後に平均)
pos = pos[:, [0, 2, 1]] * np.array([1, 1, -1], np.float32) # Blender(Z上) → three.js(Y上)
f.write(struct.pack("<I", len(pos))); f.write(pos.tobytes()); f.write(col.tobytes())room.html:バイナリをそのまま型付き配列として読む
const n = new DataView(bin).getUint32(0, true);
const pos = new Float32Array(bin, 4, n*3);
const col = new Uint8Array(bin, 4 + n*12, n*3);
…
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
geo.setAttribute('color', new THREE.BufferAttribute(col,3,true));room.html:頂点シェーダ(GLSL・全点で並列に動く)
vec3 p = position; // 1) カメラのすぐ近くの点は押しのけられて舞う(通り抜けるときに粒がほどける) vec3 d = p - camPos; float near = 1.0 - smoothstep(0.0, 2.2, length(d)); p += normalize(d + 1e-4) * near * (0.4 + seed) + flow(p, seed) * near * 0.35; … vec4 mv = modelViewMatrix * vec4(p,1.); gl_Position = projectionMatrix * mv; … gl_PointSize = px * (2.4 + big*9.0) * (3.0 / -mv.z);
room.html:フラグメントシェーダ
vec3 c = pow(vc, vec3(0.8)) * 1.5 * vec3(0.72, 0.98, 1.06); // 露出を上げ、青緑に寄せる(原作の色調) … gl_FragColor = vec4(c, 1.); // 丸めず不透明の四角
room.html:毎フレーム、時間・カメラ位置・崩れの強さの3つだけを渡して描く
renderer.setAnimationLoop(ms=>{
const t = ms/1000, …
…
mat.uniforms.t.value = t; mat.uniforms.camPos.value.copy(cam.position);
mat.uniforms.burst.value = Math.pow(Math.max(0, Math.sin(t*0.45)), 10);
renderer.setClearColor(0x05080a, 1); renderer.render(scene, cam);
});bake_room.py と room.html からの抜粋です。「…」は省略した箇所で、それ以外は原文のままです。点はどれだけの量になるか
画素をすべて点にすると、量はすぐに膨らみます。フルHDの画像を36方向から撮ると、約7,465万点です。位置と色をそれぞれ32ビットの小数3つで持つと、1点24バイト、合計約1.8GBになり、ブラウザでは扱えません。そこで、マスで間引くことと、数値を丸めて小さく持つ(量子化)ことが必須になります。
メモリ計算機
| 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,491 | 9B | 2.6MB |
three.js に何を渡すか
three.js には、点の位置と色を型付き配列(Float32Array など、数値だけが詰まった配列)のまま渡します。BufferGeometry にこの配列を属性として持たせ、THREE.Points として描きます。JSON のような文字の形ではなくバイナリで渡すのは、そのまま GPU のメモリへ転送できるからです。C' の room.bin も、ファイルの先頭に点の数を書き、その後ろに位置(32ビット小数)と色(8ビット整数)を並べただけの形です。記事用に軽くした版は、これに座標の範囲を足し、位置を16ビット整数に丸めています。
GPU はなぜ速いか
数百万点を毎フレーム描けるのは、全部の点に同じプログラム(頂点シェーダ)を並列に当て、1回の描画命令で済ませているからです。逆に、JavaScript のループで毎フレーム全点の位置を書き換えると、計算そのものに加えて、CPU から GPU へのデータ転送が毎回発生します。
CPU で動かすか、GPU で動かすか
点の大きさ・形・重なり
GPU は点を、画面上の小さな正方形として描きます。大きさは頂点シェーダの gl_PointSize が決めます。カメラからの距離で割れば、遠くの点ほど小さくなり、遠近感が出ます(three.js では sizeAttenuation)。丸い粒にしたいときは、フラグメントシェーダで正方形の中の位置(gl_PointCoord)を見て、円の外側を捨てます(discard)。
半透明にすると、もう1つ問題が出ます。点ごとに前後を並べ替えて描くわけではないので、重なりの順序が崩れます。加算合成(色を足し合わせる描き方)は順序に関係なく同じ結果になりますが、重なった所が明るくなります。
点の描き方プレイグラウンド
粒子化:時間を入れる
静止した点を動かすには、位置を時間の関数にします。たとえば、散らばった位置から元の位置へ、時間とともに補間します。ノイズで揺らすこともできます。時間は uniform(全点に共通の値)として毎フレーム1つだけ渡し、位置の計算は頂点シェーダが全点で並列に行います。
速度や力で動かす(ぶつかる、流れに乗る)には、前のフレームの状態から次の状態を計算し、結果を書き戻す必要があります。頂点シェーダは結果を書き戻せないので、状態をテクスチャ(画像)に持たせて往復で更新する方法(GPGPU)か、WebGPU のコンピュートシェーダを使います。これは02の数値積分と同じ問題です。
粒子化モーフ:散らばった点が部屋になる
シェーダで見る
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の逆射影ラボでわざと壊した状態です。原因を選んでください。
症状クイズ
点群の次の表現
点群は、面も材質も持たない「見えた光の記録」です。近づくと隙間が見え、照明を変えることもできません。この弱点を埋める表現がいくつかあります。
| 表現 | 何を持つか | 点群との違い |
|---|---|---|
| メッシュ | 点をつないだ三角形の面 | 近づいても隙間がない。材質を付ければ照明を変えられる |
| 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 を描画以外の計算に使うこと。状態をテクスチャに持たせて更新する方法がある
この記事の問い
- II-11どれだけの量になるか?フルHD × 36方向で約7,465万点、1点24バイトで約1.8GB。間引きと量子化が必須(メモリ計算機)
- II-12three.js に何を渡すのか?BufferGeometry に位置と色の型付き配列を持たせ、THREE.Points として描く
- II-13なぜ数百万点を毎フレーム描けるのか?全点に同じ頂点シェーダを並列に当て、1回の描画命令で済むから
- II-14点の大きさと形はどう決まるか?gl_PointSize が正方形の大きさを決める。gl_PointCoord で円の外を捨てると丸くなる
- II-15半透明にすると何が起きるか?前後の並べ替えがないので順序が崩れる。加算合成は順序に依存しないが明るくなる
- II-16静止した点を動かすには?位置を時間の関数にし、時間を uniform で渡して頂点シェーダで計算する
- II-17速度や力で動かすには?状態を毎フレーム更新する数値積分が要る。状態をテクスチャに持たせるか、コンピュートシェーダを使う
- II-19点群の次に来る表現は何か?メッシュ、NeRF、3D Gaussian Splatting。点群はその出発点