fbm とドメインワーピング
第25章で、格子から gradientNoiseが 1 枚できました。眺めると、なめらかで、たしかにランダムです。ただ、雲にも地形にも岩肌にも 見えません。ノイズ 1 枚にはひとつの粗さしかないからです。本物の雲には、空を 横切る大きな塊と、その縁のほつれと、さらに細かいざらつきが同時にあります。
この章でやるのは、その「同時にある」を作ることです。方法は粗さの違うノイズを重ねて足すだけ — 第9章で sin を 3 本足したのと、まったく同じ発想です。この足し算にfbm (fractional Brownian motion)という名前が付いています。後半では、足し算の中身を少し変えるだけで炎や大理石になることを見て、 最後に座標のほうをノイズで歪ませるドメインワーピングへ進みます。第10章で 「ドメイン変形」と呼んだ手口の、いちばん有機的な使い方です。
smoothstepで抜いた一枚絵です。この章で学ぶこと:
- fbm= オクターブ合成。周波数を
lacunarity倍・振幅をgain倍しながら足すループ - なぜ
gain = 0.5が定番なのか —lacunarity × gain = 1と「傾き」 - 足すと値域はどこまで広がるか。理論上界と実測値の差、0〜1 へ写す常套句
- 同じループの中身を変えるだけの変奏 —
turbulence/ridged/ 大理石 / 雲 - ドメインワーピング—
fbm(p + fbm(p))。段数を上げると渦と繊維が生まれる - 1 ピクセルあたりのハッシュ呼び出し回数という形で見た、この手法のコスト
1. オクターブを積む — fbm
第9章のデモ 2「波の重ね合わせ」で、こう書きました。振幅は 0.40 / 0.20 / 0.10、周波数は 2 / 5 / 11 です。
// 周波数を上げるほど振幅を小さくしている(細かい波ほど控えめに足す)
float wave1 = 0.40 * sin(2.0 * p.x + 1.1 * u_time);
float wave2 = 0.20 * sin(5.0 * p.x - 1.7 * u_time);
float wave3 = 0.10 * sin(11.0 * p.x + 2.9 * u_time);あのとき「周波数を上げるほど、振幅を下げる」のが定石だと書き、4 節の末尾のasideで「『低い周波数を大きく、高い周波数を小さく重ねる』という今日の定石そのものが、第26章で fbm(オクターブ合成)という名前で主役になります」と予告しました。ここで回収します。sin を gradientNoise に差し替えて、倍率をきれいに揃えてループにする。それが fbm です。
float fbm(vec2 p, int octaves, float lacunarity, float gain) {
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
sum += amplitude * gradientNoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return sum;
}ループの中で動いているのは 2 つの変数だけです。
frequency— 座標に掛ける倍率。1 周ごとにlacunarity倍になります。座標を 2 倍すれば、同じ格子を 2 倍細かく通ることになる(第25章)ので、模様が細かくなりますamplitude— 足すときの重み。1 周ごとにgain倍になります。細かい成分ほど、弱く足す
1 枚ぶんのノイズをオクターブ (octave)と呼びます。音楽で 1 オクターブ上が周波数 2 倍であることからの借用で、lacunarityが 2.0 のときは文字どおり 1 枚ごとに 1 オクターブ上がります。
gradientNoiseを JavaScript に移植して実際に計算した値です。デモ 1 本目「オクターブ 1〜6」は、これを画面で見るためのものです。6 本の帯は、まったく同じ座標に対してオクターブ数だけを変えています。左端は 1 枚だけのなめらかな起伏、右へ行くほど細部が乗っていき、それでも大きな形は変わりません。あとから足されるものは小さいので、全体の骨格は最初の 1〜2 枚で決まっているのです。帯の下端の点の数が、その帯のオクターブ数です。
#version 300 es
// 第26章 デモ1: オクターブを 1 枚ずつ足していく。
// 画面を縦に 6 分割し、左から 1・2・3・4・5・6 オクターブの fbm を、
// まったく同じ座標で描く。低い周波数の起伏は 6 帯すべてに共通なので、
// 大きな形は帯をまたいでつながって見え、右へ行くほど細部だけが足されていく。
// 帯の下端の点の数が、その帯のオクターブ数。
//
// ポインタ: 左右 = lacunarity(1.2〜3.2)、上下 = gain(0.2〜0.8)。
// ポインタ未操作(中央)では lacunarity = 2.2 / gain = 0.5 になる。
// fbm.glsl の既定値 2.0 / 0.5 に合わせたいときは、少しだけ左寄りに置く。
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
#include "fbm.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// 標準座標 1.0 が第 1 オクターブの何セル分か
const float NOISE_SCALE = 2.0;
// 帯の数 = 右端の帯のオクターブ数
const int BANDS = 6;
// ----------------------------------------------------------------------
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// 連続パラメータはポインタで動かす(第10章と同じく 0〜1 に直してから mix)
vec2 mouse01 = u_mouse / u_resolution;
float lacunarity = mix(1.2, 3.2, mouse01.x);
float gain = mix(0.2, 0.8, mouse01.y);
// いま何番目の縦帯にいるか(左から 0〜5)。帯ごとに変えるのはオクターブ数だけ
float bandF = gl_FragCoord.x * float(BANDS) / u_resolution.x;
int band = int(bandF);
int octaves = band + 1;
float value = fbm(p * NOISE_SCALE, octaves, lacunarity, gain);
// 既定設定での実測値域は -0.4861〜+0.4831 なので、0.5 を足すだけでほぼ 0〜1 に収まる。
// gain を 0.5 より上げると振幅の和が増えて外へはみ出すので、clamp が効きはじめる
// (画面の白と黒がべたっと潰れる。それが「値域が広がった」ことの見え方)
float t = clamp(value + 0.5, 0.0, 1.0);
vec3 color = mix(vec3(0.06, 0.07, 0.09), vec3(0.86, 0.90, 0.96), t);
// 帯の境目に細い区切り線を引く(px 単位の距離で太さを決める)
float toEdge = min(fract(bandF), 1.0 - fract(bandF)) * u_resolution.x / float(BANDS);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));
// 帯の下端に、オクターブ数ぶんの目印を打つ
float bandWidth = u_resolution.x / float(BANDS);
vec2 inBand = vec2(gl_FragCoord.x - float(band) * bandWidth, gl_FragCoord.y);
float pitch = 11.0;
float startX = bandWidth * 0.5 - pitch * 0.5 * float(octaves - 1);
float marks = 0.0;
for (int i = 0; i < BANDS; i++) {
if (i >= octaves) {
break;
}
vec2 center = vec2(startX + pitch * float(i), 16.0);
marks = max(marks, 1.0 - smoothstep(2.5, 3.5, distance(inBand, center)));
}
color = mix(color, vec3(0.95, 0.62, 0.28), marks);
fragColor = vec4(color, 1.0);
}2. lacunarity と gain — なぜ gain = 0.5 が定番なのか
fbm のつまみは 3 つだけです。fbm.glsl の冒頭に既定値をまとめてあります。
const int FBM_OCTAVES = 5; // 足すノイズの枚数
const float FBM_LACUNARITY = 2.0; // 1 枚進むごとの周波数の倍率
const float FBM_GAIN = 0.5; // 1 枚進むごとの振幅の倍率(persistence とも呼ぶ)octaves— 足す枚数。多いほど細部が増えるが、ある枚数から先は目に見えなくなる(6 節)lacunarity— 周波数の倍率。lacuna(すきま)から来た語で、2.0 が既定。大きくすると 1 枚ごとの粗さの差が開き、模様に「中間の粗さ」がなくなりますgain— 振幅の倍率。persistence(持続度)とも呼ばれます。0.5 が既定
問題は「なぜ gain が 0.5 なのか」です。「細かいものほど小さく」は分かりますが、どれだけ小さくすればよいのかは、それだけでは決まりません。答えの手がかりは傾きにあります。
オクターブ i の寄与は amplitude_i × noise(frequency_i × p)です。これを座標で微分すると、noiseの微分の前にamplitude_i × frequency_iが掛かります。つまりその 1 枚が作れる「坂の急さ」は振幅と周波数の積で決まる。 そしてループの初項がamplitude = 0.5/frequency = 1.0である限り、lacunarityとgainに何を入れても、
amplitude_i × frequency_i = 0.5 × (lacunarity × gain)ⁱ
となるので、lacunarity × gain = 1のとき、この積はすべてのオクターブで 等しくなります。「どの粗さの層も、同じくらいの急さで凸凹する」— これがlacunarity = 2.0とgain = 0.5の組み合わせの意味です。実際に測るとこうなります(各オクターブの寄与について、x 方向の中心差分で|傾き|の最大値を 20 万点から求めた値)。
| lacunarity × gain | 1 枚目 | 2 枚目 | 3 枚目 | 4 枚目 | 5 枚目 |
|---|---|---|---|---|---|
| 2.0 × 0.50 = 1.00 | 0.979 | 1.008 | 1.003 | 0.996 | 0.989 |
| 3.0 × 0.333… = 1.00 | 1.002 | 1.006 | 0.986 | 1.016 | 1.015 |
| 2.0 × 0.35 = 0.70 | 0.998 | 0.682 | 0.488 | 0.348 | 0.243 |
| 2.0 × 0.70 = 1.40 | 0.987 | 1.395 | 1.952 | 2.770 | 3.806 |
積が 1 の 2 行は、lacunarityが 2 でも 3 でも、5 枚のあいだ傾きがほぼ一定です(0.98〜1.02)。積が 0.7 の行は 1 枚ごとに約 0.7 倍ずつ寝ていき、5 枚目には 1 枚目の 4 分の 1 の急さしかありません — 細部が「効かない」 のです。逆に積が 1.4 の行は 1 枚ごとに急になり、5 枚目は 1 枚目の約 3.9 倍。細かい成分が場を支配して、ざらざらした見た目になります。
ここで断っておくと、「積が 1 でなければならない」という決まりはありません。積を 1 より小さくすればなだらかで、大きくすればざらつく — 表現として選べるつまみです。 ただ「特に理由がないときの既定値」としては、どの層も同じくらい効く積 1 の組み合わせが素直で、だから2.0 / 0.5が広く使われている、という理解が実用的です。デモ 1 のポインタで両方を動かして、表の 4 行を目で確かめてみてください。
3. 値域 — 足すと、どこまで広がるのか
足し算である以上、値域は広がります。ここを押さえずに色へ流し込むと、絵が白と黒に潰れます。 まず上界から。各オクターブの振幅を足すと、初項 0.5・公比gainの等比数列の和になります。gain = 0.5のときは
0.5 + 0.25 + 0.125 + … = 1 − 2⁻ᴺ
です。gradientNoiseの値は ±√2/2(≒ 0.7071)を超えないので(第25章)、fbm の値は理論上±0.7071 × (1 − 2⁻ᴺ)の内側にあります。ただしこの上界に達するには、全オクターブが同じ場所で同時に極値を 取る必要があるので、実際にはずっと狭くなります。実測すると次のとおりです。ここで「実測値域」と呼んでいるのは一様乱択でたまたま出た最大・最小で、真の値域を下から見積もった 値です(乱数の種を変えて測り直すと、小数第 2 位が振れます)。
| オクターブ数 N | 振幅の和 1 − 2⁻ᴺ | 理論上界 | 実測値域(300 万点) |
|---|---|---|---|
| 1 | 0.5 | ±0.3536 | −0.3524 〜 +0.3489 |
| 2 | 0.75 | ±0.5303 | −0.4247 〜 +0.4173 |
| 3 | 0.875 | ±0.6187 | −0.4508 〜 +0.4615 |
| 4 | 0.9375 | ±0.6629 | −0.4669 〜 +0.4702 |
| 5 | 0.96875 | ±0.6850 | −0.4779 〜 +0.4590 |
| 6 | 0.984375 | ±0.6961 | −0.4701 〜 +0.4766 |
1 オクターブのときは実測が上界の 99.7% まで届いています(ノイズ 1 枚の極値がそのまま出るので当然です)。ところが 2 枚目からは急に届かなくなり、5 オクターブでは上界の約 70% です。枚数を増やしても、実測の値域は±0.48 あたりで頭打ちになります。既定設定(5 オクターブ)を 2000 万点で測り直すと−0.4861 〜 +0.4831で、fbm(p) + 0.5が 0〜1 の外へ出た点は 1 つもありませんでした。種を変えて 2000 万点を 3 回測り直すと、下端は −0.4772〜−0.4855、上端は +0.4777〜+0.4925 に振れましたが、外へ出た点が 0 なのはどの回も同じです。だから 0〜1 へ写す常套句は、この 1 行で足ります。
float fbm01(vec2 p) {
return clamp(fbm(p) + 0.5, 0.0, 1.0);
}4. 同じ足し算の、中身だけを変える
ここからが面白いところです。ループの骨格はそのままに、足すものを 1 行変えるだけで、質感がまるごと変わります。デモ 2 本目は、まったく同じ座標・同じオクターブ数で 4 つを並べたものです。
turbulenceは、足す前にabsで折ります。ノイズが 0 を横切る場所が「折り目」になり、そこだけ値が急に落ちる — なめらかな 起伏の中に細い谷の網目が走ります。煙や炎の筋として使われる形です。
float turbulence(vec2 p) {
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < FBM_OCTAVES; i++) {
sum += amplitude * abs(gradientNoise(p * frequency) * NOISE_TO_UNIT);
frequency *= FBM_LACUNARITY;
amplitude *= FBM_GAIN;
}
return sum;
}fbmとの差はabs(...)が付いたことだけです(NOISE_TO_UNITは√2 = 1 / (√2/2)で、gradientNoiseの理論上限±√2/2 がちょうど ±1 に写るように選んだ係数です。第25章 7 節で0.5 / (√2/2) = 0.70711を掛けたのと同じ考え方で、基準は実測値ではなく理論上限のほう — 実測の振れ幅 ±0.705 のほうは ±0.997 になります。折る変奏では「1 − |n|が 0 まで届くか」が形を決めるので、先に伸ばしておきます)。実測値域は 300 万点の一様乱択で0.0006 〜 0.7007、中央値 0.230 で、片側にしか出ない分布になります。
ridgedは、折ったうえで上下をひっくり返します。1 − |n|はノイズが 0 の場所で最大になるので、turbulence の谷が、そのまま尾根に変わります。 さらに 2 乗して、尾根を細く尖らせます。
float r = 1.0 - abs(gradientNoise(p * frequency) * NOISE_TO_UNIT);
sum += amplitude * r * r;大理石は、fbm を「絵」としてではなく位相のずれとして使います。第9章のsinがここで戻ってきます — 縞を作り、その位相を fbm で乱すだけです。
float marble(vec2 p, float frequency, float distortion) {
return sin(p.x * frequency + fbm(p) * distortion) * 0.5 + 0.5;
}sinを通しているので値域は構造上 0〜1 で、正規化の心配は要りません。distortionを上げていくと、位相の進み方が場所によって逆転する(位相の x 微分が負になる)点が出てきます。frequency = 4.0のままdistortionを振って 50 万点で測ると、微分が負になる点の割合は 2.0 で 0.14%、4.0 で 7.8%、デモが使っている 6.0 で 17.3%、8.0 で 24.0% でした。縞が折り返して渦を巻くのは、この逆転が起きている場所です。大理石らしいひび割れは 「乱れすぎ」の産物なのです。
雲は、変奏というより後処理です。fbm はどこまで行っても連続なので、そのままでは 霧にしかなりません。輪郭のある塊にするには、第7章のsmoothstepで閾値を切ります。
float threshold = mix(0.32, 0.58, u_mouse.x / u_resolution.x);
float density = smoothstep(threshold, threshold + CLOUD_SOFTNESS, d);閾値をどこに置くかは、値の分布から決めます。既定設定のfbm(p) + 0.5は、200 万点の実測で中央値 0.500、上位 25% が 0.586 以上、上位 5% が 0.707 以上でした。閾値を 0.50 にすればおよそ半分が雲になり、0.70 にすれば 5% だけの晴れた空になります。 「なんとなく」で決めずに、まず分布を測る — これも移植した JS の仕事です。
#version 300 es
// 第26章 デモ2: 同じ足し算の、中身だけを変える。
// 画面を 2×2 に分け、4 区画とも同じ座標・同じオクターブ数で描く。
// 左上 fbm — そのまま足す
// 右上 turbulence — 足す前に abs で折る(細い谷)
// 左下 ridged — 1 - |n| を 2 乗して足す(細い尾根)
// 右下 marble — sin の位相を fbm で乱す(第9章の sin が戻ってくる)
// 違いは fbm.glsl の中の 1〜2 行だけで、座標も格子もまったく同じもの。
//
// ポインタ: 左右でノイズの倍率(0.8〜3.0)。細かくすると 4 つの質感の差が見やすい。
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
#include "fbm.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// turbulence の実測値域(300 万点で 0.0006〜0.7007・中央値 0.230)を 0〜1 側へ寄せる
const float TURBULENCE_SCALE = 1.7;
// 大理石の縞の細かさと、その位相を fbm で乱す強さ
const float MARBLE_FREQUENCY = 4.0;
const float MARBLE_DISTORTION = 6.0;
// ----------------------------------------------------------------------
void main() {
// 区画 1 つぶんの大きさ。全体が 3:2 なので、各区画も 3:2 のまま
vec2 quadrant = u_resolution * 0.5;
vec2 local = mod(gl_FragCoord.xy, quadrant);
// 標準座標の式(第7章)を、画面全体ではなく「区画」に対して適用する
vec2 p = (local * 2.0 - quadrant) / min(quadrant.x, quadrant.y);
// 左下 (0,0) から右上 (1,1)。gl_FragCoord.y は上が大きいので、上段が y = 1
ivec2 cell = ivec2(gl_FragCoord.xy / quadrant);
int variant = (1 - cell.y) * 2 + cell.x; // 0 = 左上, 1 = 右上, 2 = 左下, 3 = 右下
float scale = mix(0.8, 3.0, u_mouse.x / u_resolution.x);
vec2 q = p * scale;
float value;
if (variant == 0) {
value = fbm01(q);
} else if (variant == 1) {
value = turbulence(q) * TURBULENCE_SCALE;
} else if (variant == 2) {
value = ridged(q);
} else {
value = marble(q, MARBLE_FREQUENCY, MARBLE_DISTORTION);
}
value = clamp(value, 0.0, 1.0);
// 4 つを同じ濃淡で並べる。色の設計は第27章の担当なので、ここでは 2 色の mix だけ
vec3 color = mix(vec3(0.06, 0.07, 0.09), vec3(0.86, 0.90, 0.96), value);
// 区画の境目に細い区切り線を引く
vec2 toEdge = min(local, quadrant - local);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, min(toEdge.x, toEdge.y)));
fragColor = vec4(color, 1.0);
}5. ドメインワーピング — 座標をノイズで歪ませる
第10章の万華鏡のところで、こう名前を付けました。「ドメイン変形 (domain transformation)— 形を描く式は変えず、その手前で座標(定義域)のほうを加工する手法です」。そして「さらに第26章では、座標をノイズで歪ませるドメインワーピングに発展し、雲や大理石のような有機的な模様になります」と予告しました。第8章の 3 段パイプラインのまとめでも「距離を歪ませるのか(第26章のドメインワーピング)」と伏線が 引かれています。ここで両方回収します。
やることは 1 行です。fbm(p)と書く代わりに、fbm(p + 何か)と書く。その「何か」を fbm 自身で作ります。
ずらす量は 2 次元のベクトルなので、fbm が 2 本要ります。同じ座標を 2 回読んでも同じ値しか返らないので、2 本目は座標をずらしてから読みます。
vec2 fbm2(vec2 p) {
return vec2(fbm(p), fbm(p + vec2(5.2, 1.3)));
}このオフセット(5.2, 1.3)と、あとで出てくる(1.7, 9.2)/(8.3, 2.8)は、Inigo Quilez の記事Domain Warpingの作例で使われている値をそのまま借りたものです。値そのものに意味はなく、「格子 1 個ぶんより十分大きく、互いに無関係な場所へ飛ぶ」ことだけが条件です。整数にすると格子と揃ってしまう可能性があるので、端数の付いた値が使われます。 準備ができたら、pにこのベクトルを足してから fbm を読みます。
float warp1(vec2 p, float strength, vec2 flow) {
vec2 q = fbm2(p + flow);
return fbm(p + strength * q);
}座標の格子が「押されて」どうなるかを絵にすると、次のようになります。まっすぐな格子が、場所ごとに 違う向き・違う量で押しやられ、うねった網になります。この歪んだ網の上でノイズを読むから、模様が 渦を巻くのです。
s = 0.5にしてあります(デモ 3 はポインタ未操作の中央でs = 3.0、デモ 4 はCLOUD_WARP = 1.0なので、実際の歪みはこれより大きくなります)。歪んだ網の各交点で fbm を読み直したものが、ワープ後の値です。2 段は、この操作をもう一度重ねます。「歪ませるためのベクトル」を作る座標そのものを、さらに別の fbm で歪ませる、という入れ子です。
float warp2(vec2 p, float strength, vec2 flow) {
vec2 q = fbm2(p + flow);
vec2 base = p + strength * q;
vec2 r = vec2(fbm(base + vec2(1.7, 9.2)), fbm(base + vec2(8.3, 2.8)));
return fbm(p + strength * r);
}デモ 3 本目は 0 段 / 1 段 / 2 段の並置です。左端はなめらかな綿のような fbm、1 段になると渦と巻きが現れ、2 段では細い繊維が束になって流れる構造になります。ポインタを左端まで 動かしてstrength = 0にすると、3 帯は完全に同じ絵になります— 足しているベクトルが 0 になるので、当然です。そこから右へ動かしていくと、右の 2 帯だけが崩れていきます。
大事なのは、ワープしても値域はほとんど変わらないことです。実測すると 0 段が −0.4764 〜 +0.4700、1 段が −0.4431 〜 +0.4520、2 段が −0.4629 〜 +0.4425(各 100 万点)。標準偏差もそれぞれ 0.1245 / 0.1189 / 0.1244 でほぼ同じです。ワープが変えるのは同じ値がどこに並ぶかだけで、値そのものの出方は変えていません。座標をいじる 手法だから当たり前とも言えますが、正規化の係数を作り直さなくてよい、という実用上の意味があります。
第8章が予告していたのは、この手口を距離に掛けるほうでした。この章のデモが 歪ませているのは fbm のスカラー場だけですが、掛ける相手は何でもかまいません — 第24章のsdCircle(p, r)をsdCircle(p + s * fbm2(p), r)と書き換えれば、円の輪郭が溶けた雲のような形になります。ただし第24章 4 節の分類で言えば、 これは場所ごとに伸び縮みの向きも量も違う変形なので、距離が壊れる側です。s = 0.15の円について|∇d|を 20 万点の中心差分で測ると、0.586〜1.432 まで散りました。輪郭を塗るだけなら気になりませんが、d - rのオフセットやfillの中のfwidthが返す幅は、もう正確ではありません。
#version 300 es
// 第26章 デモ3: ドメインワーピングの段数を並べる。
// 画面を縦に 3 分割し、左から
// (a) ワープなし fbm(p)
// (b) ワープ 1 段 fbm(p + s * fbm2(p))
// (c) ワープ 2 段 歪ませるための座標そのものを、もう一度歪ませる
// 3 帯とも同じ座標・同じ fbm の設定で、違うのは「座標に何をしてから渡すか」だけ。
//
// ポインタ: 左右でずらす量 s(0.0〜6.0)。s = 0 なら 3 帯は完全に同じ絵になる。
// 「歪ませる場」だけが時間で流れるので、左端は止まったまま、右の 2 帯だけがうねる。
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)
uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
#include "fbm.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// 標準座標 1.0 が第 1 オクターブの何セル分か
const float NOISE_SCALE = 1.6;
// 帯の数(0 段 / 1 段 / 2 段)
const int BANDS = 3;
// 歪ませる場を流す速さ
const float FLOW_SPEED = 0.04;
// ----------------------------------------------------------------------
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
vec2 q = p * NOISE_SCALE;
float strength = mix(0.0, 6.0, u_mouse.x / u_resolution.x);
vec2 flow = vec2(0.0, u_time * FLOW_SPEED);
float bandF = gl_FragCoord.x * float(BANDS) / u_resolution.x;
int band = int(bandF);
float value;
if (band == 0) {
value = fbm(q);
} else if (band == 1) {
value = warp1(q, strength, flow);
} else {
value = warp2(q, strength, flow);
}
// ワープは値域をほとんど変えない(実測: 0 段 ±0.476 / 1 段 ±0.452 / 2 段 ±0.463)。
// 変わるのは「同じ値がどこに並ぶか」だけ
float t = clamp(value + 0.5, 0.0, 1.0);
vec3 color = mix(vec3(0.06, 0.07, 0.09), vec3(0.86, 0.90, 0.96), t);
// 帯の境目に細い区切り線を引く
float toEdge = min(fract(bandF), 1.0 - fract(bandF)) * u_resolution.x / float(BANDS);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));
fragColor = vec4(color, 1.0);
}ここまでで使った関数は、すべてfbm.glslという 1 つのチャンクに入っています。noise.glslのほう(hash21/valueNoise/gradientNoiseほか)は第25章のファイルをそのまま複製したもので、関数の中身は 1 文字も変えていません(先頭の説明コメントだけ、この章向けに書き直してあります)。 この章の主役は「ノイズをどう積むか」であって、ノイズそのものではないからです。
// この章の主役: ノイズの「積み方」を集めたチャンク。
// 4 本のデモが `#include "fbm.glsl"` で差し込む(仕組みは第23章と同じ)。
// 中で gradientNoise を呼ぶので、シェーダー側では noise.glsl を先に include すること。
//
// コメントに書いてある値域は、この GLSL を JS へ移植して Node で実測した値
// (サンプル数は本文 3 節、または各コメントのとおり)。理論上の上界ではなく
// 一様乱択でたまたま出た最大・最小なので、真の値域の下からの見積もりである。
// ---- 書き換えて試すための定数 ----------------------------------------
// この 3 つはデモ 2・3・4 に効く
// (デモ 1 は 3 つともポインタから引数で渡すので、書き換えても効かない)
const int FBM_OCTAVES = 5; // 足すノイズの枚数
const float FBM_LACUNARITY = 2.0; // 1 枚進むごとの周波数の倍率
const float FBM_GAIN = 0.5; // 1 枚進むごとの振幅の倍率(persistence とも呼ぶ)
// ----------------------------------------------------------------------
// abs で折る変奏では、折る前に √2 = 1 / (√2/2) を掛けて振れ幅をそろえる。
// gradientNoise の理論上限 ±√2/2 がちょうど ±1 に写るので、1 - |n| は理論上 0 まで届く
// (実測の ±0.705 は ±0.997 になるので、実際に届くのは 0.003 あたりまで)。
const float NOISE_TO_UNIT = 1.4142135623730951;
/**
* fbm (fractional Brownian motion) = オクターブ合成。
* 周波数を lacunarity 倍しながら、振幅を gain 倍して減らしていくだけの足し算。
* 初項の振幅が 0.5 なので、gain = 0.5 のときの振幅の和は 1 - 2^-octaves。
*/
float fbm(vec2 p, int octaves, float lacunarity, float gain) {
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
sum += amplitude * gradientNoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return sum;
}
/** 既定設定(5 / 2.0 / 0.5)の fbm。実測値域は -0.4861 〜 +0.4831 */
float fbm(vec2 p) {
return fbm(p, FBM_OCTAVES, FBM_LACUNARITY, FBM_GAIN);
}
/** 表示用に 0〜1 へ。実測値域が ±0.49 に収まるので、0.5 を足すだけで足りる(clamp は保険) */
float fbm01(vec2 p) {
return clamp(fbm(p) + 0.5, 0.0, 1.0);
}
/**
* turbulence: fbm との違いは「足す前に abs で折る」1 行だけ。
* ノイズが 0 を横切る場所が折り目 = 細い谷になり、煙や炎の筋に見える。
* 300 万点の一様乱択での実測値域は 0.0006〜0.7007、中央値 0.230
*/
float turbulence(vec2 p) {
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < FBM_OCTAVES; i++) {
sum += amplitude * abs(gradientNoise(p * frequency) * NOISE_TO_UNIT);
frequency *= FBM_LACUNARITY;
amplitude *= FBM_GAIN;
}
return sum;
}
/**
* ridged: 折ったうえで上下を反転し(1 - |n|)、2 乗して山を細くする。
* turbulence の谷が、そのまま尾根としてひっくり返る。実測値域 0.0870〜0.9675
*/
float ridged(vec2 p) {
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < FBM_OCTAVES; i++) {
float r = 1.0 - abs(gradientNoise(p * frequency) * NOISE_TO_UNIT);
sum += amplitude * r * r;
frequency *= FBM_LACUNARITY;
amplitude *= FBM_GAIN;
}
return sum;
}
/**
* 大理石: 縞(sin)の位相を fbm で乱す。第9章の sin がここで戻ってくる。
* sin を 0〜1 へ写しているので、値域は構造上 0〜1。
*/
float marble(vec2 p, float frequency, float distortion) {
return sin(p.x * frequency + fbm(p) * distortion) * 0.5 + 0.5;
}
/**
* 同じ p から独立な 2 つの fbm を取り出して vec2 にする。
* 2 本目の座標をずらすのがコツ(同じ座標を 2 回読んでも同じ値しか返らないため)。
* ずらし量 (5.2, 1.3) は Inigo Quilez "Domain Warping" の作例に倣った値。
* https://iquilezles.org/articles/warp/
*/
vec2 fbm2(vec2 p) {
return vec2(fbm(p), fbm(p + vec2(5.2, 1.3)));
}
/**
* ドメインワーピング 1 段: fbm(p + strength * fbm2(p))。
* flow は「歪ませる場」だけに足すずらしで、時間を入れるとワープだけが流れる。
*/
float warp1(vec2 p, float strength, vec2 flow) {
vec2 q = fbm2(p + flow);
return fbm(p + strength * q);
}
/** ドメインワーピング 2 段: 歪ませるための座標そのものを、もう一度歪ませる */
float warp2(vec2 p, float strength, vec2 flow) {
vec2 q = fbm2(p + flow);
vec2 base = p + strength * q;
vec2 r = vec2(fbm(base + vec2(1.7, 9.2)), fbm(base + vec2(8.3, 2.8)));
return fbm(p + strength * r);
}2 つのチャンクを 4 本のシェーダーへ配る仕組みは、第23章で作った極小のインクルードと同じです。main.tsで文字列置換するだけで、#includeの順序は「noise.glslが先」— fbm.glslがgradientNoiseを呼ぶためです。
function resolveIncludes(source: string): string {
return source
.replace('#include "noise.glsl"', () => noiseChunkSource.trim())
.replace('#include "fbm.glsl"', () => fbmChunkSource.trim());
}チャンクを取り込むシェーダーは、先頭に精度修飾子を 2 行書きます。intの既定精度がmediumpのままだとuintが 32 ビットである保証がなく、ハッシュの結果が実装によって変わります(第25章で見たとおりです)。
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)6. コスト — 1 ピクセルでハッシュを何回呼んでいるか
ここまで気軽に fbm を重ねてきましたが、この手法の計算量は掛け算で積み上がります。 数えてみましょう。gradientNoiseは 1 回の呼び出しで格子点 4 個の勾配を引くので、pcg3dを 4 回呼びます。fbm はgradientNoiseをオクターブ数だけ呼びます。ワープはその fbm を何本も呼びます。
| 1 ピクセルで計算するもの | fbm の本数 | gradientNoise | pcg3d |
|---|---|---|---|
| fbm(1 オクターブ) | 1 | 1 | 4 |
| fbm(5 オクターブ・既定) | 1 | 5 | 20 |
| ワープ 1 段(5 オクターブ) | 3 | 15 | 60 |
| ワープ 2 段(5 オクターブ) | 5 | 25 | 100 |
このページの canvas の大きさを数えてみます。本文のmainはmax-width: 46rem(736 CSS ピクセル)で、左右に1.25remずつのパディングがあり、box-sizing: border-boxなので中身の幅は 696 ピクセル。canvas はwidth: 100%/aspect-ratio: 3 / 2で 1px のボーダーが付くので、実際の描画領域は694 × 462 CSS ピクセルです(高さは 696 × 2/3 = 464 からボーダー 2 本を引いた値。 ブラウザでcanvas.clientWidth / clientHeightを読んで確認しました)。第6章のハーネスはここにdevicePixelRatioを掛けるので、dpr が 2 の環境では描画バッファが 1388 × 924 ≒1.28 メガピクセルになります。 ワープ 2 段を全画面で回すと、1 フレームあたりpcg3dが約 1.28 億回、60fps なら毎秒 約 77 億回です。pcg3dは 1 回あたり、スカラーに数えて整数の乗算 9 回・加算 9 回・右シフト 3 回・XOR 3 回 = 24 命令ぶん。掛け合わせると毎秒 約 1850 億命令の整数演算になります(gradientNoiseの中のcos/sin/dot/mixは別勘定です)。ノイズを積む表現が「重い」と言われるのは、この掛け算のせいです。
削る方向は 3 つあります。
- オクターブを減らす— いちばん効きます。次の
asideのとおり、そもそも画面に出ていない細部を計算していることが多い - ワープの段数を減らす— 1 段減らすと fbm が 2 本減ります(2 段 → 1 段で 4 割減)
- 解像度を下げる— 第6章ハーネスの
maxDprを 1 に落とすと、ピクセル数が 4 分の 1 になります。この章のデモは既定(2)のままにしてあります
なお、ここで数えたのは呼び出し回数であって GPU 時間ではありません。実際に何ミリ秒かかっているかを測るにはタイマークエリが要ります。CPU バウンドと GPU バウンドの見分け方も含めて、計測の話は第35章でまとめて扱います。
最後に、動かし方について。この章のノイズは 2 次元なので、時間を 3 つ目の軸として渡すことはできません。2D のまま動かす正攻法は 2 つで、デモ 4 本目は両方を使っています。
// ① 座標そのものを横へ流す
vec2 q = p * NOISE_SCALE + vec2(u_time * DRIFT_SPEED, 0.0);
// ② 歪ませる場だけを別の速さで縦へ流す
vec2 flow = vec2(0.0, u_time * FLOW_SPEED);①ドメインをスクロールすると、模様は形を保ったまま流れます(風で雲が移動するのに相当)。②ワープの「歪ませる場」だけを動かすと、模様の位置は動かないまま、内側で形が変わり続けます(雲が湧いて崩れるのに相当)。この 2 つを違う速さで掛け合わせると、単純なスクロールよりずっと生きた動きになります。デモ 3 でも同じ仕掛けを使っていて、ワープなしの左端だけが止まって見えるのはそのためです。
#version 300 es
// 第26章 デモ4: 総合の一枚絵 — ワープした fbm を smoothstep で抜いた雲。
// 部品はここまでのデモと同じで、組み合わせ方だけが違う。
// ① ドメインを横へスクロールして、雲全体を流す
// ② ワープの「歪ませる場」を縦へ動かして、雲の内側の形を変え続ける
// ③ 1 段ワープした fbm を smoothstep で 0/1 側へ寄せ、輪郭のある塊にする
// 2D ノイズなので、時間軸は z ではなく「座標をずらす」形で入れている(本文 6 節)。
//
// ポインタ: 左右で雲の量(smoothstep の下側の閾値)。右へ動かすほど晴れる。
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)
uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
#include "fbm.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// 標準座標 1.0 が第 1 オクターブの何セル分か
const float NOISE_SCALE = 1.5;
// 雲全体が横へ流れる速さ(ドメインのスクロール)
const float DRIFT_SPEED = 0.035;
// 歪ませる場が動く速さ(雲が内側で形を変える)
const float FLOW_SPEED = 0.06;
// ワープでずらす量。4.0 まで上げると繊維状にほつれるので、この絵では弱めてある
const float CLOUD_WARP = 1.0;
// 閾値の下側と上側の幅。狭くすると輪郭が硬くなる
const float CLOUD_SOFTNESS = 0.22;
// ここから上を雲の芯として明るくする
const float CLOUD_CORE = 0.52;
// ----------------------------------------------------------------------
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// ① 座標そのものを横へ流す
vec2 q = p * NOISE_SCALE + vec2(u_time * DRIFT_SPEED, 0.0);
// ② 歪ませる場だけを別の速さで縦へ流す
vec2 flow = vec2(0.0, u_time * FLOW_SPEED);
// 0〜1 相当(CLOUD_WARP = 1.0 での実測は 300 万点で 0.021〜0.974・中央値 0.500)
float d = warp1(q, CLOUD_WARP, flow) + 0.5;
// ③ 閾値で抜く。ポインタで雲の量を変える
float threshold = mix(0.32, 0.58, u_mouse.x / u_resolution.x);
float density = smoothstep(threshold, threshold + CLOUD_SOFTNESS, d);
density *= smoothstep(-1.4, -0.4, p.y); // 下ほど雲を薄くして、地平線側を空ける
float core = smoothstep(CLOUD_CORE, CLOUD_CORE + 0.20, d);
// 色は素朴に 2 色ずつの mix だけ。パレット設計・リニア/sRGB は第27章の担当
vec3 sky = mix(vec3(0.26, 0.31, 0.42), vec3(0.07, 0.10, 0.18), p.y * 0.5 + 0.5);
vec3 cloud = mix(vec3(0.52, 0.57, 0.66), vec3(0.93, 0.95, 0.98), core);
vec3 color = mix(sky, cloud, density);
color *= 1.0 - 0.28 * smoothstep(0.7, 1.7, length(p)); // 周辺を少し落とす
fragColor = vec4(color, 1.0);
// 実験: 雲を大理石に差し替える。d 以降を次の 3 行に置き換えると、
// 同じワープを縞の位相に使った大理石になる(色も明るい側へ振ると雰囲気が出る)。
// float m = sin(q.x * 4.0 + warp1(q, CLOUD_WARP, flow) * 8.0) * 0.5 + 0.5;
// vec3 color = mix(vec3(0.10, 0.10, 0.12), vec3(0.92, 0.90, 0.86), m);
// fragColor = vec4(color, 1.0);
}コード全文
シェーダー 4 本とfbm.glslは各節に掲載したとおりです。noise.glslは第25章のファイルの複製なので再掲しません。CPU 側のmain.tsは第2部からの「1 canvas + 切替ボタン」構成に、インクルードの解決を 1 つ足しただけです。
// 第26章: fbm とドメインワーピング
// 第2部と同じ「1 canvas + 切替ボタン」構成(第10章の main.ts をそのまま踏襲)。
// 増えたのは 1 か所だけ — 4 本のフラグメントシェーダーが共通のチャンク
// (noise.glsl / fbm.glsl)を #include で取り込むので、送る前に文字列置換で解決する。
import { startFullscreenShader } from '../../lib/fullscreen-shader';
import octavesSource from './01-octaves.frag?raw';
import variantsSource from './02-variants.frag?raw';
import warpSource from './03-warp.frag?raw';
import sceneSource from './04-scene.frag?raw';
import fbmChunkSource from './fbm.glsl?raw';
import noiseChunkSource from './noise.glsl?raw';
// 第23章と同じ極小のインクルード。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため。
// noise.glsl が先、fbm.glsl が後(fbm.glsl は gradientNoise を呼ぶ)。
function resolveIncludes(source: string): string {
return source
.replace('#include "noise.glsl"', () => noiseChunkSource.trim())
.replace('#include "fbm.glsl"', () => fbmChunkSource.trim());
}
const demos = [
{ id: 'octaves', label: 'オクターブ 1〜6', source: resolveIncludes(octavesSource) },
{ id: 'variants', label: 'fbm の変奏 4 種', source: resolveIncludes(variantsSource) },
{ id: 'warp', label: 'ワープ 0/1/2 段', source: resolveIncludes(warpSource) },
{ id: 'scene', label: '雲', source: resolveIncludes(sceneSource) },
] as const;
const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
if (!canvas || !controls) {
throw new Error('デモに必要な要素が見つかりません');
}
let handle = startFullscreenShader(canvas, demos[0].source);
for (const [index, demo] of demos.entries()) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = demo.label;
button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
button.addEventListener('click', () => {
// いまのループを止めてから、新しいシェーダーで開始し直す(第6章の stop() の出番)。
// 古いプログラムの解放は省略している(後始末は第35章)
handle.stop();
handle = startFullscreenShader(canvas, demo.source);
for (const b of controls.querySelectorAll('button')) {
b.setAttribute('aria-pressed', 'false');
}
button.setAttribute('aria-pressed', 'true');
});
controls.append(button);
}three.js との対応
この章の内容には、three.js に対応する API がほとんどありません。プロシージャルなノイズは シェーダーに自分で書くものだからです。関係するのは「シェーダーの断片をどう配るか」の部分です。
| three.js | この章 |
|---|---|
ShaderMaterialのfragmentShaderに書くノイズ関数 | GLSL は同じ言語なので、fbm.glslの関数はそのまま貼って使える(座標の作り方だけ、UV かgl_FragCoordかに合わせる) |
THREE.ShaderChunk+#include <...> | main.tsのresolveIncludesによる文字列置換(第23章で作ったもの。この章で 2 チャンクに増えた) |
| ノイズを焼いた画像テクスチャを貼る | 画像を持たず、毎フレーム座標から計算する。解像度に依存せず拡大しても破綻しない代わりに、 コストが 6 節の表のとおり掛かる |
Vector2を使った CPU 側の座標変形 | 座標変形はすべてフラグメントシェーダー内で完結する(第10章のドメイン変形と同じ手順で、 変形の中身がノイズになっただけ) |
手元で動かして、壊してみる
この章のコードはsrc/lessons/26-fbm-and-warping/にあります。fbm.glslの冒頭 3 つの定数を触ると、デモ 2・3・4 に一度に効くので、まずそこから(デモ 1 だけは、この 3 つをポインタから引数で渡しているので効きません。デモ 1 で試すぶんはポインタで動かしてください)。
fbm.glslのFBM_GAINを 0.8 にする — 細かい成分が強くなり、5 オクターブでの実測値域が −0.6941 〜 +0.7181 まで広がります。fbm01のclampが働いて、白と黒のべた面が出るのが確認できます。0.25 にすると逆に、実測値域が −0.3754 〜 +0.3658 に縮んで全体が眠くなりますFBM_LACUNARITYを 1.3 にする — 隣り合うオクターブの粗さが近くなり、「大きい起伏と細かい揺れ」の役割分担が 崩れて、のっぺりした一様な模様になります。3.5 にすると逆に、粗さの階層が飛び飛びになって 「中くらいの粗さ」が抜けた見た目になります(値域はどちらもほとんど変わりません。表の 3 行目・4 行目とは違い、変えたのが片方だけなので積が 1 から外れる点にも注目)FBM_OCTAVESを 1 にしてからデモ 3(ワープ)を見る — ノイズ 1 枚でも、ワープすればちゃんと渦を巻きます。 「渦は fbm が作っているのではなく、座標の歪みが作っている」ことの確認ですfbm.glslのridgedの行から* rを 1 つ消して 2 乗をやめる — 尾根が太くぼやけます。逆にr * r * rにすると、より細く鋭い稜線になりますturbulenceとridgedから* NOISE_TO_UNITを消す —absで折る前の振れ幅が ±0.705 になるので、1 - |n|が 0.295 より下に行かなくなり、ridged の谷が浅くなって全体が白っぽくなりますfbm2のオフセットvec2(5.2, 1.3)をvec2(0.0, 0.0)にする — 2 本の fbm が同じ値を返すので、ずらすベクトルが常に 45 度方向になり、ワープが「斜めに引き伸ばす」だけの退屈な変形になります。オフセットの役割が はっきり分かります。ついでにvec2(5.0, 1.0)のような整数にして、格子と揃うことの影響も見てみてください03-warp.fragのflowをvec2(0.0)に固定し、代わりにqにvec2(u_time * 0.05, 0.0)を足す — 「内側で形が変わる」動きが「そのまま流れる」動きに変わります。6 節の ① と ② の違いです04-scene.fragのCLOUD_WARPを 4.0 に上げる — 雲が繊維状にほつれて、綿というより煙になります。この章のデモが 1.0 にしてあるのは、その見え方を避けるためです04-scene.fragの末尾のコメントどおり、雲を大理石に差し替える — 同じワープを「濃度」ではなく「縞の位相」に 使うと、まったく違う素材に見えます
まとめ
- fbmは、周波数を
lacunarity倍・振幅をgain倍しながらノイズを足すループ。第9章の「低周波は大きく・高周波は小さく」がそのまま関数になったもの lacunarity × gain = 1のとき、各オクターブの「傾き」が揃う(実測 0.98〜1.02)。既定の 2.0 / 0.5 はその組み合わせ。決まりではなく、迷ったときの出発点- 振幅の和は
1 − 2⁻ᴺだが、実測値域は理論上界の約 70%(5 オクターブで −0.4861 〜 +0.4831)。正規化の係数は実測から決める - 同じループの中身を変えるだけで、
absで折れば turbulence、1 − |n|を 2 乗すれば ridged、sinの位相に混ぜれば大理石、smoothstepで抜けば雲 - ドメインワーピングは
fbm(p + s · fbm2(p))。段数を上げると渦から繊維へ。値域はほとんど変わらず、 変わるのは「同じ値がどこに並ぶか」だけ pcg3dの呼び出し回数は4 × オクターブ数 × fbm の本数 × ピクセル数で積算する(先頭の 4 はgradientNoise1 回が引く格子点の数)。ワープ 2 段・5 オクターブなら 1 ピクセルあたり 100 回
形を作る道具はこれで一通り揃いました。次に必要になるのは、作ったスカラー場をどう色にするかです。この章で「2 色のmixだけ」に留めた部分がそっくり残っています。次章第27章「色とパレット — sRGB・トーンマッピング・余弦パレット」では、リニア空間と sRGB の正確な行き来とガンマ、トーンマッピングの比較(Reinhard / ACES / AgX)と露出、HDR 画像の扱い、Inigo Quilez の余弦パレット、そしてバンディングを消すディザリングを扱います。第7章から引きずってきた 「0〜1 の RGB を素朴に足していてよいのか」という宿題も、そこで清算します。