空を見上げると、雲がゆっくりと流れていく。その動きを3D空間に再現したい。そう思ったのは、midori50でテクスチャを使った雲を作った後でした。テクスチャは美しいけれど、球面の継ぎ目が気になって仕方なかった。もっと自然な雲を作りたい。そこで、3D座標を直接使ったノイズ関数で雲を生成する方法を試してみることにしました。
対象読者
- Three.js(3Dグラフィックスライブラリ)で手続き型の雲を作りたいが、球面の継ぎ目が気になる人
- シェーダー(GPU上で実行されるプログラム)でノイズ関数を実装したいが、2Dと3Dの違いが分からない人
- 複数層の雲を重ねて、奥行きのある表現を作りたい人
- 光の散乱計算を実装して、リアルな雲のライティングを実現したい人
記事に書いてあること
- 3D座標を使ったノイズ関数(3次元のノイズ生成関数、FBM:フラクタルブラウニアンモーション)で球面の継ぎ目を消す方法
- 3層構造の球体雲を一定間隔で配置し、層ごとに異なるアニメーション速度を設定する実装
- 光の散乱計算(フォワード散乱:光が進行方向に散乱、バックワード散乱:光が後方に散乱)でリアルな雲のライティングを実現する方法
- 光源方向と層間隔をリアルタイムで調整できるUI(ユーザーインターフェース)の実装
- 全方位に雲を表示する際のアルファ値(透明度)とフェード処理の調整
作ったもの
3層構造の球体雲が空間全体に広がる、全方位雲の生成システムです。3D座標を直接使ったノイズ関数で雲の密度を計算するため、球面の継ぎ目が目立ちません。各層は異なる速度と方向でアニメーションし、光の散乱計算でリアルなライティングを実現しています。
CTA: 3層構造の雲を全画面で確認する
最初は1層の雲を作りました。けれど、それだけでは奥行きが感じられませんでした。3層に増やして、それぞれ異なる速度で動かすことで、空間全体に広がる雲の表現ができました。
3D座標でノイズを計算する理由
midori50では、テクスチャを使った雲を作りました。テクスチャは美しいけれど、球面の継ぎ目(UV座標の境界)が目立ってしまう問題がありました。そこで、3D座標を直接使ったノイズ関数で雲の密度を計算する方法に切り替えました。
球面の継ぎ目が目立たない理由は、3D座標を使うことで、球面のどの位置でも同じ計算方法でノイズを生成できるからです。UV座標(2次元のテクスチャ座標)を使うと、球面の上下で座標がつながっている部分で不自然な継ぎ目が発生します。3D座標なら、空間内の任意の点で一貫したノイズ値を計算できます。
// midori51/app.js (12-76行目)
function createProceduralClouds(displayWholeSphere = false, viewFade = 0.5) {
const vertexShader = `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
precision highp float;
uniform float time;
uniform float brightness;
uniform bool displayWholeSphere;
uniform float viewFade;
uniform float timeOffset;
uniform float layerIndex;
uniform vec3 uCameraPosition;
uniform vec3 uLightDirection;
uniform vec3 uLayerAnimationSpeed; // 層ごとのアニメーション速度
uniform vec3 uLayerAnimationDirection; // 層ごとのアニメーション方向
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vWorldPosition;
// 2Dノイズ関数
float noise2D(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = fract(sin(dot(i, vec2(12.9898, 78.233))) * 43758.5453);
float b = fract(sin(dot(i + vec2(1.0, 0.0), vec2(12.9898, 78.233))) * 43758.5453);
float c = fract(sin(dot(i + vec2(0.0, 1.0), vec2(12.9898, 78.233))) * 43758.5453);
float d = fract(sin(dot(i + vec2(1.0, 1.0), vec2(12.9898, 78.233))) * 43758.5453);
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// 3Dノイズ関数(球面の継ぎ目を目立たなくするため)
float noise3D(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float n = i.x + i.y * 57.0 + 113.0 * i.z;
float a = fract(sin(n) * 43758.5453);
float b = fract(sin(n + 1.0) * 43758.5453);
float c = fract(sin(n + 57.0) * 43758.5453);
float d = fract(sin(n + 58.0) * 43758.5453);
float e = fract(sin(n + 113.0) * 43758.5453);
float f1 = fract(sin(n + 114.0) * 43758.5453);
float g = fract(sin(n + 170.0) * 43758.5453);
float h = fract(sin(n + 171.0) * 43758.5453);
return mix(
mix(mix(a, b, f.x), mix(c, d, f.x), f.y),
mix(mix(e, f1, f.x), mix(g, h, f.x), f.y),
f.z
);
}
`;
}
vertexShaderでは、vWorldPositionに3D空間座標を保存しています。これが、球面の継ぎ目を消す鍵です。fragmentShaderでは、このvWorldPositionを使ってノイズを計算します。
noise3D関数は、3D空間の任意の点で一貫したノイズ値を生成します。8つの角(立方体の頂点)のノイズ値を補間することで、滑らかなノイズを実現しています。f = f * f * (3.0 - 2.0 * f)の部分は、smoothstep関数を使った補間で、より滑らかな結果を得るための処理です。
CTA: 2Dノイズと3Dノイズの違いを確認する
2Dノイズ(UV座標使用)では、球面の上下で継ぎ目が目立ちます。3Dノイズ(3D座標使用)では、継ぎ目が消えて自然な雲の表現ができます。
FBM(Fractal Brownian Motion、フラクタルブラウニアンモーション)で雲の密度を計算
FBMは、複数のオクターブ(周波数)のノイズを重ね合わせる手法です。低周波数のノイズで雲の大まかな形状を決め、高周波数のノイズで細かいディテールを追加します。
// midori51/app.js (78-114行目) - fragmentShader内
// 3D空間座標からノイズを計算するFBM(継ぎ目なし)
float fbm3D(vec3 p) {
float total = 0.0;
float persistence = 0.5;
float amplitude = 0.5;
float frequency = 1.0;
float maxValue = 0.0;
const int octaves = 10;
for(int i = 0; i < octaves; i++) {
total += noise3D(p * frequency) * amplitude;
maxValue += amplitude;
frequency *= 2.0;
amplitude *= persistence;
}
return total / maxValue;
}
// 2D FBM(ワーピング用)
float fbm2D(vec2 p) {
float total = 0.0;
float persistence = 0.5;
float amplitude = 0.5;
float frequency = 1.0;
float maxValue = 0.0;
const int octaves = 6;
for(int i = 0; i < octaves; i++) {
total += noise2D(p * frequency) * amplitude;
maxValue += amplitude;
frequency *= 2.0;
amplitude *= persistence;
}
return total / maxValue;
}
3次元のFBM関数は10オクターブ、2次元のFBM関数は6オクターブです。オクターブ数が多いほど細かいディテールが増えますが、計算コストも増えます。10オクターブは、雲の細かいディテールとパフォーマンスのバランスを取った結果です。
persistence = 0.5は、各オクターブの振幅を半分にする設定です。これにより、低周波数のノイズが主要な形状を決め、高周波数のノイズが細かいディテールを追加します。maxValueで正規化することで、結果を0-1の範囲に収めています。
マルチレイヤーFBMで自然な雲の密度を生成
雲の密度は、複数のFBMを組み合わせて計算します。メインの雲の形状、詳細な雲の形状、非常に細かいディテールの3つを重ね合わせることで、より自然な雲の表現を実現しています。
// midori51/app.js (116-151行目) - fragmentShader内
// 雲の密度を計算するマルチレイヤーFBM(3D座標使用、層ごとに異なるアニメーション)
float cloudDensity(vec3 worldPos, float time) {
// スケール調整
vec3 scaledPos = worldPos * 0.01;
// 層ごとのアニメーション方向と速度を適用
vec3 layerOffset = uLayerAnimationDirection * uLayerAnimationSpeed * time;
// メインの雲の形状(層ごとに異なる速度と方向)
vec3 mainOffset = vec3(time * 0.05, time * 0.03, time * 0.04) + layerOffset * 0.3;
float mainFbm = fbm3D(scaledPos * 0.8 + mainOffset);
// 詳細な雲の形状(細かいディテール、層ごとに異なる動き)
vec3 detailOffset = vec3(time * 0.08, time * 0.05, time * 0.06) + layerOffset * 0.5;
float detailFbm = fbm3D(scaledPos * 2.5 + detailOffset + mainFbm * 0.5);
// 非常に細かいディテール(層ごとに異なる速度)
vec3 fineOffset = vec3(time * 0.12, time * 0.07, time * 0.09) + layerOffset * 0.7;
float fineFbm = fbm3D(scaledPos * 5.0 + fineOffset + detailFbm * 0.3);
// 複数のレイヤーを組み合わせてより自然な雲の密度を生成
float density = mainFbm * 0.6 + detailFbm * 0.3 + fineFbm * 0.1;
// 雲の形状をワープしてより自然な流れを作る(層ごとに異なるワープ速度)
vec2 warpCoord = worldPos.xy * 0.005 + worldPos.z * 0.005;
float warpSpeed = uLayerAnimationSpeed.x * 0.02;
vec2 warp = vec2(
fbm2D(warpCoord + vec2(time * warpSpeed, 0.0)) * 2.0,
fbm2D(warpCoord + vec2(0.0, time * warpSpeed)) * 2.0
);
float warped = fbm3D(scaledPos * 0.6 + vec3(warp, 0.0) + mainOffset);
density = mix(density, warped, 0.3);
// 0-1の範囲に正規化
return clamp(density, 0.0, 1.0);
}
scaledPos = worldPos * 0.01でスケールを調整しています。これにより、ノイズの細かさを制御できます。0.01という値は、試行錯誤の結果です。最初は0.1にしていましたが、雲が粗すぎました。0.01にすると、細かすぎて計算が重くなりました。0.01は、見た目とパフォーマンスのバランスを取った値です。
3つのFBMの重みは、mainFbm * 0.6 + detailFbm * 0.3 + fineFbm * 0.1です。メインの形状が60%、詳細が30%、細かいディテールが10%です。この比率も試行錯誤の結果です。最初は均等にしていましたが、細かいディテールが強すぎて、雲がざらついて見えました。メインの形状を強くすることで、自然な雲の表現になりました。
ワーピングは、雲の形状を歪ませて、より自然な流れを作る処理です。fbm2Dでワープベクトルを計算し、それをfbm3Dの入力に加えることで、雲が流れるような動きを実現しています。mix(density, warped, 0.3)で、元の密度とワープした密度を30%混ぜています。この30%も試行錯誤の結果です。50%にすると、ワープが強すぎて不自然になりました。10%にすると、ワープの効果がほとんど見えませんでした。30%が、自然な流れを保ちながら、ワープの効果も感じられる値です。
CTA: マルチレイヤーFBMの各レイヤーを確認する
メイン、詳細、細かいディテールの3つのレイヤーを個別に表示できます。それぞれの役割が視覚的に理解できます。
光の散乱計算でリアルなライティングを実現
雲は、光を散乱させます。密度が高い部分ほど、光が強く散乱されます。フォワード散乱(光が進行方向に散乱する現象)とバックワード散乱(光が後方に散乱する現象)を計算することで、リアルな雲のライティングを実現しています。
// midori51/app.js (153-182行目) - fragmentShader内
// 光の散乱計算(密度に応じた光の透過と散乱)
float calculateScattering(float density, vec3 viewDir, vec3 lightDir, vec3 worldPos) {
// カメラから雲への方向
vec3 toCamera = normalize(uCameraPosition - worldPos);
// 光の方向ベクトル
vec3 toLight = normalize(lightDir);
// 視点方向と光方向の角度
float viewLightDot = dot(toCamera, toLight);
// フォワード散乱(光が進行方向に散乱)- より強く
float forwardScatter = pow(max(0.0, viewLightDot), 1.5);
// バックワード散乱(光が後方に散乱)
float backScatter = pow(max(0.0, -viewLightDot), 1.2);
// 密度に応じた散乱強度(より強く)
float scatterIntensity = pow(density, 1.8) * 2.5;
// フォワードとバックワードのバランス
float scattering = mix(
backScatter * 0.4,
forwardScatter * 1.2,
smoothstep(-0.3, 0.7, viewLightDot)
) * scatterIntensity;
// 密度が高いほど散乱が強くなる
return scattering * (0.5 + density * 0.5);
}
視点方向と光方向の内積(ベクトルの内積計算)は、1に近いほど視点方向と光方向が一致しています(フォワード散乱)。-1に近いほど、視点方向と光方向が逆です(バックワード散乱)。
フォワード散乱の計算では、指数1.5を使っています。この指数は、散乱の強さを調整するパラメータです。最初は1.0にしていましたが、散乱が弱すぎて、雲が暗く見えました。1.5にすると、散乱が強くなり、雲が明るく見えるようになりました。
密度に応じた散乱強度の計算では、指数1.8と係数2.5を使っています。指数1.8は、密度が高いほど散乱が強くなることを表現するパラメータです。2.5は、散乱の全体の強さを調整する係数です。この値も試行錯誤の結果です。最初は1.0にしていましたが、散乱が弱すぎました。3.0にすると、散乱が強すぎて、雲が白く飛んでしまいました。2.5が、自然な散乱を実現する値です。
CTA: 光の散乱を可視化する
フォワード散乱とバックワード散乱を個別に表示できます。光源方向を変えると、散乱の様子が変わります。
3層構造の雲を生成
3層の球体雲を一定間隔で配置し、それぞれ異なるアニメーション速度と方向を設定しています。内側の層は水平方向にゆっくり、中間の層は垂直方向に中速、外側の層は斜め方向に速く動きます。
// midori51/app.js (318-382行目)
// 3層構造の雲を生成(一定間隔で配置)
// layerSpacingとbaseRadiusはグローバル変数を使用
// 3層の雲を生成(それぞれ異なるアニメーション)
// 各層のアニメーション設定
const layerAnimations = [
{ speed: new THREE.Vector3(0.8, 0.5, 0.6), direction: new THREE.Vector3(1.0, 0.3, 0.5).normalize() }, // 内側の層:水平方向にゆっくり
{ speed: new THREE.Vector3(1.2, 0.8, 1.0), direction: new THREE.Vector3(0.5, 1.0, 0.3).normalize() }, // 中間の層:垂直方向に中速
{ speed: new THREE.Vector3(1.5, 1.2, 1.3), direction: new THREE.Vector3(0.7, 0.5, 1.0).normalize() } // 外側の層:斜め方向に速く
];
for (let layer = 0; layer < 3; layer++) {
const radius = baseRadius + layer * layerSpacing;
const geometry = new THREE.SphereGeometry(radius, 128, 128);
// 各層ごとに少し異なる時間オフセットを設定
const layerTimeOffset = layer * 10.0;
// 層ごとのアニメーション設定を取得
const animConfig = layerAnimations[layer];
const layerAnimationSpeed = animConfig.speed;
const layerAnimationDirection = animConfig.direction;
const layerUniforms = {
time: { type: 'f', value: 0.0 },
brightness: { type: 'f', value: 0.7 },
displayWholeSphere: { type: 'bool', value: displayWholeSphere },
viewFade: { type: 'f', value: viewFade },
timeOffset: { type: 'f', value: layerTimeOffset }, // 層ごとの時間オフセット
layerIndex: { type: 'f', value: layer }, // 層のインデックス
uCameraPosition: { type: 'v3', value: new THREE.Vector3(0, 5, 10) }, // カメラ位置(光の散乱計算用)
uLightDirection: { type: 'v3', value: lightDirection.clone() }, // 光源方向
uLayerAnimationSpeed: { type: 'v3', value: layerAnimationSpeed }, // 層ごとのアニメーション速度
uLayerAnimationDirection: { type: 'v3', value: layerAnimationDirection } // 層ごとのアニメーション方向
};
const material = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
uniforms: layerUniforms,
transparent: true,
side: THREE.BackSide,
depthWrite: false // 層の重なりを適切に処理
});
const cloudSphere = new THREE.Mesh(geometry, material);
cloudSphere.position.set(0, 0, 0);
scene.add(cloudSphere);
// 層の情報を保存(元の半径も保存)
cloudLayers.push({
uniforms: layerUniforms,
mesh: cloudSphere,
baseRadius: baseRadius,
layerIndex: layer
});
// 各層のアニメーションコールバック
addAnimationCallback(() => {
layerUniforms.time.value += 0.008;
layerUniforms.uCameraPosition.value.copy(camera.position); // カメラ位置を更新
layerUniforms.uLightDirection.value.copy(lightDirection); // 光源方向を更新
});
}
内側の層の半径は500、層間の間隔は80です。これらの値は、試行錯誤の結果です。最初は半径300、間隔50にしていましたが、層が近すぎて、雲が重なって見えました。半径500、間隔80にすると、層が適度に離れて、奥行きのある表現になりました。
各層のアニメーション速度と方向は、配列で定義しています。内側の層はX軸0.8、Y軸0.5、Z軸0.6の速度で、X軸1.0、Y軸0.3、Z軸0.5の方向に動きます。中間の層はX軸1.2、Y軸0.8、Z軸1.0の速度で、X軸0.5、Y軸1.0、Z軸0.3の方向に動きます。外側の層はX軸1.5、Y軸1.2、Z軸1.3の速度で、X軸0.7、Y軸0.5、Z軸1.0の方向に動きます。これらの値も試行錯誤の結果です。最初は全て同じ速度にしていましたが、層が同調して動いて、単調な印象になりました。異なる速度と方向にすることで、各層が独立して動き、動的な表現になりました。
球体の内側から見る設定により、カメラが球体の内側にいて、雲を見上げるような視点を実現しています。層の重なりを適切に処理する設定により、内側の層が外側の層に隠れても、アルファブレンディング(透明度の合成処理)が正しく動作します。
CTA: 各層のアニメーションを個別に確認する
各層を個別に表示できます。内側、中間、外側の層が異なる速度と方向で動く様子が確認できます。
光源方向と層間隔をリアルタイムで調整
光源方向と層間隔をリアルタイムで調整できるUIを実装しました。これにより、様々なライティング条件や層の配置を試すことができます。
// midori51/app.js (394-506行目)
// 光源UIコントロールを設定
function setupLightControls() {
const lightControlPanel = document.createElement('div');
lightControlPanel.id = 'lightControlPanel';
lightControlPanel.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 15px;
border-radius: 8px;
font-family: Arial, sans-serif;
font-size: 12px;
z-index: 1000;
min-width: 200px;
`;
lightControlPanel.innerHTML = `
<div style="margin-bottom: 10px; font-weight: bold; border-bottom: 1px solid #666; padding-bottom: 5px;">
光源方向
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; margin-bottom: 5px;">X: <span id="lightXValue">0.5</span></label>
<input type="range" id="lightX" min="-2" max="2" step="0.1" value="0.5"
style="width: 100%;">
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; margin-bottom: 5px;">Y: <span id="lightYValue">1.0</span></label>
<input type="range" id="lightY" min="-2" max="2" step="0.1" value="1.0"
style="width: 100%;">
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; margin-bottom: 5px;">Z: <span id="lightZValue">0.3</span></label>
<input type="range" id="lightZ" min="-2" max="2" step="0.1" value="0.3"
style="width: 100%;">
</div>
<button id="resetLight" style="width: 100%; padding: 5px; margin-top: 5px;
background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">
リセット
</button>
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #666;">
<div style="margin-bottom: 10px; font-weight: bold; border-bottom: 1px solid #666; padding-bottom: 5px;">
層間隔
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; margin-bottom: 5px;">間隔: <span id="layerSpacingValue">80</span></label>
<input type="range" id="layerSpacing" min="20" max="200" step="5" value="80"
style="width: 100%;">
</div>
<button id="resetSpacing" style="width: 100%; padding: 5px; margin-top: 5px;
background-color: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer;">
間隔リセット
</button>
</div>
`;
document.body.appendChild(lightControlPanel);
// スライダーのイベントリスナー
const lightX = document.getElementById('lightX');
const lightY = document.getElementById('lightY');
const lightZ = document.getElementById('lightZ');
const lightXValue = document.getElementById('lightXValue');
const lightYValue = document.getElementById('lightYValue');
const lightZValue = document.getElementById('lightZValue');
lightX.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
lightXValue.textContent = value.toFixed(1);
updateLightDirection(value, parseFloat(lightY.value), parseFloat(lightZ.value));
});
lightY.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
lightYValue.textContent = value.toFixed(1);
updateLightDirection(parseFloat(lightX.value), value, parseFloat(lightZ.value));
});
lightZ.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
lightZValue.textContent = value.toFixed(1);
updateLightDirection(parseFloat(lightX.value), parseFloat(lightY.value), value);
});
// リセットボタン
document.getElementById('resetLight').addEventListener('click', () => {
lightX.value = 0.5;
lightY.value = 1.0;
lightZ.value = 0.3;
lightXValue.textContent = '0.5';
lightYValue.textContent = '1.0';
lightZValue.textContent = '0.3';
updateLightDirection(0.5, 1.0, 0.3);
});
// 層間隔スライダー
const layerSpacingSlider = document.getElementById('layerSpacing');
const layerSpacingValue = document.getElementById('layerSpacingValue');
layerSpacingSlider.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
layerSpacingValue.textContent = value;
updateLayerSpacing(value);
});
// 間隔リセットボタン
document.getElementById('resetSpacing').addEventListener('click', () => {
layerSpacingSlider.value = 80;
layerSpacingValue.textContent = '80';
updateLayerSpacing(80);
});
}
光源方向は、X、Y、Zの3つのスライダーで調整できます。各スライダーは-2から2の範囲で、0.1刻みで調整できます。全ての層の設定を更新する処理で、リアルタイムに反映されます。
層間隔は、20から200の範囲で、5刻みで調整できます。各層のメッシュのスケールを更新する処理で、リアルタイムに反映されます。内側の層は基本半径のまま(スケール1.0)、外側の層は間隔に応じてスケールを調整します。
CTA: 光源方向と層間隔を調整する
スライダーで光源方向と層間隔をリアルタイムで調整できます。様々なライティング条件や層の配置を試すことができます。
実際に試した例
1. 光源方向を変えてみた
光源方向を(0.5, 1.0, 0.3)から(-0.5, 1.0, -0.3)に変えると、雲の明るい部分と暗い部分が逆転しました。散乱計算が正しく動作していることが確認できました。
2. 層間隔を狭めてみた
層間隔を80から40に狭めると、層が近づいて、雲が重なって見えました。逆に200に広げると、層が離れすぎて、雲が薄く見えました。80が、奥行きのある表現を実現する最適な値でした。
3. 全方位表示に切り替えた
displayWholeSphere = trueにすると、上半球だけでなく、全方位に雲が表示されました。アルファ値の最小値を0.1に設定することで、どの角度から見ても雲が見えるようになりました。
使ってみて
1. 光源方向を変えて、雲のライティングを確認する
スライダーでX、Y、Zを調整すると、雲の明るい部分と暗い部分が変わります。散乱計算の効果が視覚的に理解できます。
2. 層間隔を変えて、奥行きの表現を調整する
層間隔を狭めると、雲が重なって見えます。広げると、雲が薄く見えます。80が、奥行きのある表現を実現する最適な値です。
3. カメラを動かして、全方位の雲を確認する
OrbitControlsでカメラを動かすと、どの角度から見ても雲が見えます。3D座標を使ったノイズ関数の効果が確認できます。
まとめ
- 3D座標を使ったノイズ関数(3次元のノイズ生成関数、FBM:フラクタルブラウニアンモーション)で、球面の継ぎ目を消した
- マルチレイヤーFBMで、メイン・詳細・細かいディテールの3つのレイヤーを重ね合わせて、自然な雲の密度を生成した
- 光の散乱計算(フォワード散乱:光が進行方向に散乱、バックワード散乱:光が後方に散乱)で、リアルな雲のライティングを実現した
- 3層構造の球体雲を一定間隔で配置し、層ごとに異なるアニメーション速度と方向を設定した
- 光源方向と層間隔をリアルタイムで調整できるUI(ユーザーインターフェース)を実装した
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。3D座標を使ったノイズ関数で、球面の継ぎ目を消すことができました。同じようなことに興味がある方の参考になれば嬉しいです。