[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックスを作り直す

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックスを作り直す

前回まで

前回(10日目)ではGLBファイルのブラウザ内圧縮を実装した。

今回はWIRED ROOMのスカイボックスを全面的に作り直す。

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(昼) [JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(夕方) [JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(夜) [JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(WIRED)

問題:Canvas 2Dベースのスカイボックスの限界

これまでのスカイボックスはCanvas 2Dで512x512のテクスチャを描画し、それをSphereGeometryのBackSideに貼り付けていた。星はランダムな点、雲は別の球メッシュにellipseで描いたテクスチャ、月はarc。

動いてはいたが問題があった。

  • 雲が2Dテクスチャの貼り付けなので、球体のUV境界で繋ぎ目が見える
  • 星が静止画(瞬かない)
  • 雲のアニメーションが球体メッシュの回転だけで単調
  • 昼・夕焼け・夜・WIREDの4プリセット全てが同じ貧弱な表現
  • テクスチャ生成のCanvasコードが冗長

解決策はシンプル。全部GLSLフラグメントシェーダーで描く。

ShaderMaterialへの移行

Canvas 2Dのテクスチャ生成コードを全て削除し、頂点シェーダーとフラグメントシェーダーを書く。

頂点シェーダーは球体の各頂点のワールド座標をフラグメントシェーダーに渡すだけ。

varying vec3 vWorldPosition;
void main() {
  vec4 worldPosition = modelMatrix * vec4(position, 1.0);
  vWorldPosition = worldPosition.xyz;
  gl_Position = projectionMatrix * viewMatrix * worldPosition;
}

フラグメントシェーダー側では normalize(vWorldPosition) で球体表面の方向ベクトルを取得する。これが3D空間上のシームレスな座標になる。2DのUV座標ではなく3Dの方向ベクトルを使うことで、球体のUV境界に繋ぎ目が発生しない。

3Dプロシージャル雲

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(昼)

Canvas版の雲は2Dのellipseを並べただけで、引き伸ばされたボケた楕円が回転するだけだった。

シェーダー版では3D Value Noiseを使う。3Dハッシュ関数を定義し、8頂点のtrilinear補間でスムーズなノイズを生成する。

float hash3(vec3 p) {
  return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}

float noise3(vec3 p) {
  vec3 i = floor(p);
  vec3 f = fract(p);
  vec3 u = f * f * (3.0 - 2.0 * f);
  // 8頂点の補間
  float a = hash3(i + vec3(0,0,0));
  float b = hash3(i + vec3(1,0,0));
  // ... 8頂点分
  return mix(mix(mix(a,b,u.x), mix(c,d,u.x), u.y),
             mix(mix(e,f_,u.x), mix(g,h,u.x), u.y), u.z);
}

このノイズを方向ベクトルに対して3オクターブ重ねる(fBm)ことで、もくもくした雲の形状を作る。smoothstepの閾値を狭く絞ることで、ボケた煙ではなくアニメの背景美術のような輪郭のくっきりした雲になる。

vec3 cloudPos = dir * 3.0 + vec3(uTime * 0.02, 0.0, uTime * 0.01);
float n = noise3(cloudPos) * 0.5;
n += noise3(cloudPos * 2.0) * 0.25;
n += noise3(cloudPos * 4.0) * 0.125;
float cloudMask = smoothstep(0.38, 0.45, n);

uTimeでcloudPosをシフトさせることで、雲がゆっくり流れるアニメーションになる。3Dノイズなのでどの方向にスクロールしても完全にシームレス。

星の描画

Canvas版の星はランダムな白い点の静止画だった。シェーダー版では方向ベクトルからUV座標を生成し、グリッドごとにハッシュで星の有無・位置・色を決定する。

vec2 starUV = vec2(atan(dir.x, dir.z) * 50.0, dir.y * 70.0);

各星にsin波の明滅(twinkle)を入れることで、実際にチカチカ瞬く。速度と位相はハッシュ値でランダム化されるので、全ての星が異なるリズムで瞬く。

流れ星

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(流れ星)

夜空プリセットには流れ星を追加した。一定サイクルでランダムな角度・位置に出現し、尾を引きながら流れて消える。

float mTime = uTime * 0.4;
float mCycle = fract(mTime);

fractで周期を作り、floorでサイクルIDを取得。IDからハッシュで出現角度を決定し、ラインの距離関数でストロークを描画する。頭部から尾部にかけてフェードアウトさせることで流れ星らしい見た目になる。

デジタルグリッド(WIREDスタイル)

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(WIRED)

WIREDプリセット用のグリッドオーバーレイもシェーダーに移行した。方向ベクトルから格子状のUVを生成し、fractfwidthでアンチエイリアスされたグリッド線を描画する。

vec2 gridUV = vec2(atan(dir.x, dir.z) * 45.0, dir.y * 60.0);
gridUV.y += uTime * 0.04;
vec2 grid = abs(fract(gridUV - 0.5) - 0.5) / fwidth(gridUV);
float gridPattern = 1.0 - min(min(grid.x, grid.y), 1.0);

時間経過でY方向にスクロールさせ、sin波でパルス状の明滅を加えることで、Serial Experiments Lainのワイヤードっぽい電脳空間の雰囲気を出す。

プリセット制御

4つのスカイボックスプリセットはUniformsの値を変えるだけで切り替わる。

プリセット流れ星グリッド雲の色
Day---
Sunset---パステルオレンジ
Night---
WIRED--

シェーダー内でif (uCloudsIntensity > 0.0)のように分岐するため、不要な計算はスキップされる。

アニメーション

updateSkyboxで毎フレームuTimeを加算するだけで全てのアニメーションが動く。雲の流れ、星の瞬き、流れ星の周期、グリッドのスクロール、全てuTime一本で制御される。

[JavaScript] 3D ROOM制作 11日目 GLSLシェーダーでスカイボックス(流れ星)
export function updateSkybox(S, dt) {
  if (S.skyMaterial && S.skyMaterial.uniforms) {
    S.skyMaterial.uniforms.uTime.value += dt;
  }
  if (S.skyMesh && S.skyRotSpeed) {
    S.skyMesh.rotation.y += S.skyRotSpeed * dt;
  }
}

旧実装で別メッシュとして管理していた雲レイヤー(S.cloudMesh)は完全に不要になった。

結果

Canvas 2Dの静止画テクスチャから、フルシェーダーのリアルタイム描画に置き換わった。

  • 雲のUV繋ぎ目が完全に消えた(3Dノイズなので原理的に発生しない)
  • 星が瞬き、流れ星が流れる(動きのある夜空)
  • 雲が3D空間を流れる(ボリューム感のある昼空・夕焼け)
  • WIREDグリッドがアニメーションする(電脳空間感)
  • コード量はCanvas版とほぼ同等だが、表現力が段違い
  • 別メッシュ管理(cloudMesh)が不要になり、構造がシンプルに

変更ファイルはroom-loader.jsのみ。createSkyboxとupdateSkyboxの2関数を書き換えただけ。


次回:未定。