本文へスキップ

Real-time dreamy Cloudscapes with Volumetric Raymarching の解説

2025.03
SDFを密度として扱うVolumetric Raymarching。方向微分による光の散乱、ベールの法則、Henyey-Greenstein位相関数まで

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;
}

ノイズを加えて雲っぽく

fbm によるノイズ

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(方向微分)**を用いる。(ある方向における微分ということで、要は偏微分みたいなイメージと認識しています)。pp と p+0.3×sunDirectionp+0.3 \times sunDirection の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;
diffuseを真っ赤にしてみる。光源はx:1.0光源をz:1.0にした

最適化

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)|画像処理ソリューション

出典:alxisdata.localinfo.jp

バイリニア

バイキュービック

バイキュービック補間をポストプロセスとして処理するため、一度オフスクリーンにレンダリングしてからメインに描画しているようです。

// オフスクリーン用の仮想シーン
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
)}
  1. 雲の描画
  2. 結果をuseFBO()のレンダリングターゲットに保存
  3. レンダリング結果を取得して
  4. メインのスクリーンへ描画
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 ランベルト・ベールの法則

光の吸収の計算。半透明な物質を通過すると、距離に応じて光は指数関数的に減衰する。α\alpha は吸収係数。

I=I0×e−αdI = I_0 \times e^{−\alpha d}

この処理を物理的に正しく計算するには、新たに、サンプルしている点から光源に向かってraycastingして、吸収 II を求める。つまり、レイマーチングをネストさせて実行しているようです(カメラのレイマーチングの中で光源へのレイマーチング…)。

サンプル赤点1つにつき、さらに光源に向かってレイキャストさせて光の吸収を計算する。サンプル黄色点の各ステップで密度を取得して、ベールの法則を適用した吸収を累積していく。

ネストしたレイマーチング

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

吸収係数を上げた場合吸収係数を下げた場合

異方性散乱

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

Henyey-Greenstein 位相関数

p(θ)=14π⋅1−g2(1+g2−2gcos⁡θ)3/2p(\theta) = \frac{1}{4\pi} \cdot \frac{1 - g^2}{(1 + g^2 - 2g\cos\theta)^{3/2}}
// 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));
}