[Next.js #45] Procedural Rings — 1Dノイズと極座標で描く惑星の環と影の同期

はじめに

前回の [Next.js #43] では、極地の夜空に揺らめく「オーロラ」を実装し、大気と地表の表現を極めました。

星本体の表現は圧倒的なレベルに達しましたが、今回は視点を少し宇宙空間へと引き戻します。
Next.js と Three.js (GLSL) を連携させ、土星のような「惑星の環(Rings)」をプロシージャルに実装。

この実装の背景となる数学的な理論やノイズのアルゴリズムについては、姉妹シリーズである [Noise 入門 #39] にて詳しく解説しています。

本記事では、それを Next.js (Three.js) 環境に組み込み、特に「惑星本体とリングの影を同期させる」という実践的なシェーダーのテクニックに焦点を当てます。



スクリーンショット:

動画(YouTube):

動画(PC):

1. 1Dノイズと極座標によるリングマテリアル

リングの描画には、重い3Dモデルは必要ありません。シンプルな PlaneGeometry と、極座標系を用いた Shader を組み合わせることで、1Dノイズから無限の解像度を持つ「塵と氷の軌道」を錬成します。

Next.js のコンポーネント内でマテリアルを定義します。リングは裏側からも見える必要があり、かつ背後の星を透かして見せるため、transparent: true と side: THREE.DoubleSide の設定が必須です。

// Next.js コンポーネント内の初期化例
const ringGeometry = new THREE.PlaneGeometry(12, 12);
const ringMaterial = new THREE.ShaderMaterial({
  vertexShader: ringVertexShader,
  fragmentShader: ringFragmentShader,
  uniforms: uniforms,
  transparent: true,
  side: THREE.DoubleSide, // 両面描画
  depthWrite: false,      // アルファの破綻を防ぐ
});

const ringMesh = new THREE.Mesh(ringGeometry, ringMaterial);
// 土星のように少し傾ける
ringMesh.rotation.x = 1.8;
ringMesh.rotation.z = 0.2;
scene.add(ringMesh);

Fragment Shader における極座標変換

フラグメントシェーダー側では、UV座標を中心(0.0)からの「距離(radius)」に変換し、それを 1D FBM(フラクタル・ブラウン運動) に渡すことで同心円状の縞模様を生成します。

// --- ringFragmentShader 内 ---
vec2 p = vUv * 2.0 - 1.0;
float radius = length(p); // 中心からの距離

// ドーナツ型のベースマスク
float ringMask = smoothstep(uRingInnerRadius, uRingInnerRadius + 0.01, radius)
               - smoothstep(uRingOuterRadius - 0.01, uRingOuterRadius, radius);

// 距離を200倍して1Dノイズに渡し、細かな塵の密度を生成
float density = fbm1D(radius * 200.0);
density = smoothstep(0.3, 0.7, density); // 隙間を作る

2. 惑星の影と同期させる「Fake Shadow」の実装

リングを描画しただけでは、惑星の周りに光る板が乗っているだけに見えてしまいます。実在感を出すためには「影(Shadow)」が必要です。

ここでは重いレイマーチングを使わず、数学的な外積(Cross Product)を利用した非常に軽量で正確な Fake Shadow を実装します。

vec3 lightDir = normalize(uLightDir);
float shadowMask = 1.0;

// リングのピクセルが光の反対側(惑星の裏側)にある場合のみ計算
if(dot(vWorldPosition, lightDir) < 0.0) {
    // 🌍 外積を利用して、光のレイと原点(惑星中心)との最短距離を求める
    float distToLightRay = length(cross(vWorldPosition, lightDir));

    // 惑星の半径(例: 2.0)より内側を通る光は遮られているため、影を落とす
    shadowMask = smoothstep(1.9, 2.15, distToLightRay);

    // 完全に真っ暗にならないよう環境光を残す
    shadowMask = mix(0.15, 1.0, shadowMask);
}

finalColor *= shadowMask;

length(cross(vWorldPosition, lightDir)) という一文が魔法の鍵です。これにより、リングがどのように傾いていようと、常に惑星の球体が落とす円柱状の影の中に正確に収まるようになります。

3. ターミネーター(昼夜の境界)ズレ問題の解決

影の実装において、非常によく起こるバグがあります。それは「惑星の昼夜の境界線(ターミネーター)と、リングに落ちる影の方向がズレてしまう」という問題です。

これは、地形をプロシージャルに隆起(Displacement)させる Terrain Shader 側で、法線(Normal)がローカル空間のまま計算されていたことが原因です。惑星を自転(rotation.y += speed)させた際、見た目は回っているのに、光の計算基準が固定されたままになってしまいます。

解決策:法線のワールド空間への変換

Terrain の Vertex Shader で、再計算した新しい法線(newNormal)に対して normalMatrix を掛けることで、法線を常に正しいワールド空間に向けることができます。

// --- Terrain Vertex Shader ---

// (中略:ノイズによる地形の隆起と、偏微分による法線 newNormal の再計算)

// ❌ 誤り:ローカル空間のままフラグメントへ渡している
// vNormal = newNormal;

// ⭕️ 正解:normalMatrix を掛けてワールド空間の法線に変換する
vNormal = normalize(normalMatrix * newNormal);

この修正により、太陽の位置(uLightDir)に対して、惑星の明るい面とリングに落ちる影が、どんなに自転させても完全に同期するようになります。

4. UI とインフォメーションの設置

Next.js と組み合わせる強みは、美しい UI や DOM とのシームレスな統合です。 今回、画面の隅に世界観を補足するシンプルなオーバーレイを設置しました。

<div id="info">
  <h1>Procedural Rings</h1>
  <p>Orbits of ice and dust sculpted with 1D FBM. Witness the celestial harmony of synchronized planetary shadows.</p>
</div>

(1D FBMで彫り出された氷と塵の軌道。惑星の影が同期する、天体の調和を目撃してください。)

パラメータ制御には lil-gui や Leva などのライブラリを用いることで、React のステートと同期させながらリアルタイムに太陽の角度(Azimuth / Elevation)を弄り、影の落ち方を楽しむことができます。

まとめ

1Dノイズという最もシンプルな次元のノイズも、極座標と Shader の数学を通すことで、宇宙規模の巨大な構造物へと変化します。
そして「外積による影の計算」と「法線のワールド空間変換」を組み合わせることで、複数のオブジェクトが同じ光の法則を共有する、実在感のある空間が完成しました。

今回の実装で、ノイズによる地形、海、雲、オーロラ、嵐、そして今回のリング。一つの「星」を構成するすべての要素が揃いました。

次回はいよいよ、これまでの実装をコンポーネントとして抽象化し、WEB上に複数のプロシージャル惑星が軌道を描く「ミニ太陽系」を展開します。
コンポーネント指向が生きる、フロントエンドと Shader の融合の集大成です。お楽しみに!