Real-time dreamy Cloudscapes with Volumetric Raymarching の解説
Real-time dreamy Cloudscapes with Volumetric Raymarching - Maxime Heckel's Blog の解説記事の要約です。
Volumetric Raymarching
雲を立体として描画するために、Volumetric raymarchingを使用する。
通常のraymarchingはSDFを用いて、与えられた点から物体の表面までの距離を返すが、Volumetric raymarchingでは、物体内部をサンプリングする。SDFをdistanceではなくdensityとして捉えて計算する。
float sdSphere(vec3 p, float radius) {
return length(p) - radius;
}
// 物体内部をpositiveな値として考えるので、-distance
float scene(vec3 p) {
float distance = sdSphere(p, 1.0);
return -distance;
}
ノイズを加えて雲っぽく

Fractal Brownian Motion = 非整数ブラウン運動
これの物理的な詳細は分かりませんが、perlinとかよりもオーガニックなノイズになるらしい。
float fbm(vec3 p) {
vec3 q = p + uTime * 0.5 * vec3(1.0, -0.2, -1.0);
float g = noise(q);
float f = 0.0;
float scale = 0.5;
float factor = 2.02;
for (int i = 0; i < 6; i++) {
f += scale * noise(q);
q *= factor;
factor += 0.21;
scale *= 0.5;
}
return f;
}
光の散乱
よくあるnormalと光の方向ベクトルの内積で計算する手法は負荷が高すぎる(法線の計算処理が重いので)。
代わりに**directional derivatives(方向微分)**を用いる。(ある方向における微分ということで、要は偏微分みたいなイメージと認識しています)。 と の2点での変化率(微分)を求める。
// scene(p)がその点における密度
// sunDirectionにちょっと進んだ点における密度
float diffuse = clamp((scene(p) - scene(p + 0.3 * sunDirection)) / 0.3, 0.0, 1.0);
密度が高くなる方向への光が進む場合、より拡散する(拡散しながら減衰する)。低密度な方向へ進むイコール雲が薄い部分なので、光の散乱は少なくなる。
// 環境光vec3にdiffuseの光を足して最終的な光を計算
vec3 lin = vec3(0.60,0.60,0.75) * 1.1 + 0.8 * vec3(1.0,0.6,0.3) * diffuse;


最適化
blue noise dithering
volumetric raymarchingのステップ数の最適化。単純にステップ数を減らすとステップの境界が見えるので、blue noise ditheringでごまかす。ランダムノイズを加えるより、blue noiseの方が見た目が自然だそうです。

temporal blue noise dithering
単にブルーノイズをかけるだけではノイズそのものが見えてしまうため、フレームごとにランダムに動かしてやると、より自然にノイズをかけられる。
float offset = fract(blueNoise + float(uFrame%32) / sqrt(0.5));
Bicubic filtering
低い解像度でレンダリングして、最終的にポストプロセスでアップスケーリングする。
バイリニアは周囲4点から補間。バイキュービックは16点から補間。解説記事ではバイキュービックを用いている。
画素の補間(Nearest neighbor, Bilinear, Bicubic)|画像処理ソリューション


バイキュービック補間をポストプロセスとして処理するため、一度オフスクリーンにレンダリングしてからメインに描画しているようです。
// オフスクリーン用の仮想シーン
const magicScene = new THREE.Scene();
{createPortal(
<mesh ref={mesh} scale={[viewport.width, viewport.height, 1]}>
<planeBufferGeometry args={[1, 1]} />
<shaderMaterial
key={uuidv4()}
fragmentShader={fragmentShader}
vertexShader={vertexShader}
uniforms={uniforms}
wireframe={false}
/>
</mesh>,
magicScene
)}
- 雲の描画
- 結果を
useFBO()のレンダリングターゲットに保存 - レンダリング結果を取得して
- メインのスクリーンへ描画
const renderTargetA = useFBO(
window.innerWidth / resolution,
window.innerHeight / resolution
);
useFrame((state) => {
// オフスクリーンへ
gl.setRenderTarget(renderTargetA);
gl.render(magicScene, camera);
// 描画結果の取得
upscalerMaterialRef.current.uniforms.uTexture.value = renderTargetA.texture;
screenMesh.current.material = upscalerMaterialRef.current;
// メインへ描画(レンダリングターゲットをnullに戻す)
gl.setRenderTarget(null);
});
bicubicUpscaleMaterialで拡大する処理をシェーダーで書いているみたいです。
物理的に正確なレンダリングへ
Beer's Law ランベルト・ベールの法則
光の吸収の計算。半透明な物質を通過すると、距離に応じて光は指数関数的に減衰する。 は吸収係数。
この処理を物理的に正しく計算するには、新たに、サンプルしている点から光源に向かってraycastingして、吸収 を求める。つまり、レイマーチングをネストさせて実行しているようです(カメラのレイマーチングの中で光源へのレイマーチング…)。
サンプル赤点1つにつき、さらに光源に向かってレイキャストさせて光の吸収を計算する。サンプル黄色点の各ステップで密度を取得して、ベールの法則を適用した吸収を累積していく。

実際の吸収係数は水滴の粒の大きさ、大気などによって計算するらしいが、コード内では定数として定義している模様。左が吸収係数を上げたもの、右が下げたもの。右から光を当てている。


異方性散乱
光の散乱は均等に散乱せずに偏って散乱する。**Henyey-Greenstein phase function(位相関数)**を用いている。位相関数は、光の入射角 に関してどの方向に散乱するかの確率を表す。

// muはdot(rayDirection, sunDirection) = 入射角θ
float HenyeyGreenstein(float g, float mu) {
float gg = g * g;
return (1.0 / (4.0 * PI)) * ((1.0 - gg) / pow(1.0 + gg - 2.0 * g * mu, 1.5));
}