第4部 プロシージャル & ジェネラティブ — 第26章

fbm とドメインワーピング

第25章で、格子から gradientNoiseが 1 枚できました。眺めると、なめらかで、たしかにランダムです。ただ、雲にも地形にも岩肌にも 見えません。ノイズ 1 枚にはひとつの粗さしかないからです。本物の雲には、空を 横切る大きな塊と、その縁のほつれと、さらに細かいざらつきが同時にあります。

この章でやるのは、その「同時にある」を作ることです。方法は粗さの違うノイズを重ねて足すだけ — 第9章で sin を 3 本足したのと、まったく同じ発想です。この足し算にfbm (fractional Brownian motion)という名前が付いています。後半では、足し算の中身を少し変えるだけで炎や大理石になることを見て、 最後に座標のほうをノイズで歪ませるドメインワーピングへ進みます。第10章で 「ドメイン変形」と呼んだ手口の、いちばん有機的な使い方です。

この章のデモ 4 本。ボタンで切り替えられます。「オクターブ 1〜6」は足す枚数を左から順に 増やした並置(ポインタ左右で lacunarity、上下で gain)、「fbm の変奏 4 種」は同じ座標を 4 通りの積み方で描いた 2×2、「ワープ 0/1/2 段」は座標を歪ませる段数の並置(ポインタ左右で ずらす量)、「雲」は 1 段ワープした fbm を smoothstepで抜いた一枚絵です。

この章で学ぶこと:

1. オクターブを積む — fbm

第9章のデモ 2「波の重ね合わせ」で、こう書きました。振幅は 0.40 / 0.20 / 0.10、周波数は 2 / 5 / 11 です。

src/lessons/09-trigonometry/02-superpose.frag(再掲・抜粋)
  // 周波数を上げるほど振幅を小さくしている(細かい波ほど控えめに足す)
  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 です。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
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 つの変数だけです。

1 枚ぶんのノイズをオクターブ (octave)と呼びます。音楽で 1 オクターブ上が周波数 2 倍であることからの借用で、lacunarityが 2.0 のときは文字どおり 1 枚ごとに 1 オクターブ上がります。

周波数振幅×10.500+×20.250+×40.125+×80.063=合計fbm同じ 1 本の直線上を、5 格子ぶん歩いたときの値(縦の倍率は行ごとに共通・合計だけ別)
4 オクターブの内訳と合計。上ほど大きく粗く、下ほど小さく細かい。合計(強調色)は、大きな うねりの上に細かい揺れが乗った形になります。この図の折れ線は、この章と同じgradientNoiseを JavaScript に移植して実際に計算した値です。

デモ 1 本目「オクターブ 1〜6」は、これを画面で見るためのものです。6 本の帯は、まったく同じ座標に対してオクターブ数だけを変えています。左端は 1 枚だけのなめらかな起伏、右へ行くほど細部が乗っていき、それでも大きな形は変わりません。あとから足されるものは小さいので、全体の骨格は最初の 1〜2 枚で決まっているのです。帯の下端の点の数が、その帯のオクターブ数です。

src/lessons/26-fbm-and-warping/01-octaves.frag
#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 の冒頭に既定値をまとめてあります。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
const int FBM_OCTAVES = 5; // 足すノイズの枚数
const float FBM_LACUNARITY = 2.0; // 1 枚進むごとの周波数の倍率
const float FBM_GAIN = 0.5; // 1 枚進むごとの振幅の倍率(persistence とも呼ぶ)

問題は「なぜ gain が 0.5 なのか」です。「細かいものほど小さく」は分かりますが、どれだけ小さくすればよいのかは、それだけでは決まりません。答えの手がかりは傾きにあります。

オクターブ i の寄与は amplitude_i × noise(frequency_i × p)です。これを座標で微分すると、noiseの微分の前にamplitude_i × frequency_iが掛かります。つまりその 1 枚が作れる「坂の急さ」は振幅と周波数の積で決まる。 そしてループの初項がamplitude = 0.5/frequency = 1.0である限り、lacunaritygainに何を入れても、

amplitude_i × frequency_i = 0.5 × (lacunarity × gain)ⁱ

となるので、lacunarity × gain = 1のとき、この積はすべてのオクターブで 等しくなります。「どの粗さの層も、同じくらいの急さで凸凹する」— これがlacunarity = 2.0gain = 0.5の組み合わせの意味です。実際に測るとこうなります(各オクターブの寄与について、x 方向の中心差分で|傾き|の最大値を 20 万点から求めた値)。

lacunarity × gain1 枚目2 枚目3 枚目4 枚目5 枚目
2.0 × 0.50 = 1.000.9791.0081.0030.9960.989
3.0 × 0.333… = 1.001.0021.0060.9861.0161.015
2.0 × 0.35 = 0.700.9980.6820.4880.3480.243
2.0 × 0.70 = 1.400.9871.3951.9522.7703.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 万点)
10.5±0.3536−0.3524 〜 +0.3489
20.75±0.5303−0.4247 〜 +0.4173
30.875±0.6187−0.4508 〜 +0.4615
40.9375±0.6629−0.4669 〜 +0.4702
50.96875±0.6850−0.4779 〜 +0.4590
60.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 行で足ります。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
float fbm01(vec2 p) {
  return clamp(fbm(p) + 0.5, 0.0, 1.0);
}

4. 同じ足し算の、中身だけを変える

ここからが面白いところです。ループの骨格はそのままに、足すものを 1 行変えるだけで、質感がまるごと変わります。デモ 2 本目は、まったく同じ座標・同じオクターブ数で 4 つを並べたものです。

turbulenceは、足す前にabsで折ります。ノイズが 0 を横切る場所が「折り目」になり、そこだけ値が急に落ちる — なめらかな 起伏の中に細い谷の網目が走ります。煙や炎の筋として使われる形です。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
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 乗して、尾根を細く尖らせます。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
    float r = 1.0 - abs(gradientNoise(p * frequency) * NOISE_TO_UNIT);
    sum += amplitude * r * r;

大理石は、fbm を「絵」としてではなく位相のずれとして使います。第9章のsinがここで戻ってきます — 縞を作り、その位相を fbm で乱すだけです。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
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で閾値を切ります。

src/lessons/26-fbm-and-warping/04-scene.frag(抜粋)
  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 の仕事です。

src/lessons/26-fbm-and-warping/02-variants.frag
#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 本目は座標をずらしてから読みます

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
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 を読みます。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
float warp1(vec2 p, float strength, vec2 flow) {
  vec2 q = fbm2(p + flow);
  return fbm(p + strength * q);
}

座標の格子が「押されて」どうなるかを絵にすると、次のようになります。まっすぐな格子が、場所ごとに 違う向き・違う量で押しやられ、うねった網になります。この歪んだ網の上でノイズを読むから、模様が 渦を巻くのです。

もとの座標 pp + s · fbm2(p)押しやる量も向きも、fbm が場所ごとに決める
ドメインワーピングで座標の格子がどう押されるか。見やすさのため図ではs = 0.5にしてあります(デモ 3 はポインタ未操作の中央でs = 3.0、デモ 4 はCLOUD_WARP = 1.0なので、実際の歪みはこれより大きくなります)。歪んだ網の各交点で fbm を読み直したものが、ワープ後の値です。

2 段は、この操作をもう一度重ねます。「歪ませるためのベクトル」を作る座標そのものを、さらに別の fbm で歪ませる、という入れ子です。

src/lessons/26-fbm-and-warping/fbm.glsl(抜粋)
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が返す幅は、もう正確ではありません。

src/lessons/26-fbm-and-warping/03-warp.frag
#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 文字も変えていません(先頭の説明コメントだけ、この章向けに書き直してあります)。 この章の主役は「ノイズをどう積むか」であって、ノイズそのものではないからです。

src/lessons/26-fbm-and-warping/fbm.glsl
// この章の主役: ノイズの「積み方」を集めたチャンク。
// 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.glslgradientNoiseを呼ぶためです。

src/lessons/26-fbm-and-warping/main.ts(抜粋)
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章で見たとおりです)。

src/lessons/26-fbm-and-warping/03-warp.frag(抜粋)
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)

6. コスト — 1 ピクセルでハッシュを何回呼んでいるか

ここまで気軽に fbm を重ねてきましたが、この手法の計算量は掛け算で積み上がります。 数えてみましょう。gradientNoiseは 1 回の呼び出しで格子点 4 個の勾配を引くので、pcg3dを 4 回呼びます。fbm はgradientNoiseをオクターブ数だけ呼びます。ワープはその fbm を何本も呼びます。

1 ピクセルで計算するものfbm の本数gradientNoisepcg3d
fbm(1 オクターブ)114
fbm(5 オクターブ・既定)1520
ワープ 1 段(5 オクターブ)31560
ワープ 2 段(5 オクターブ)525100

このページの canvas の大きさを数えてみます。本文のmainmax-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 つあります。

なお、ここで数えたのは呼び出し回数であって GPU 時間ではありません。実際に何ミリ秒かかっているかを測るにはタイマークエリが要ります。CPU バウンドと GPU バウンドの見分け方も含めて、計測の話は第35章でまとめて扱います。

最後に、動かし方について。この章のノイズは 2 次元なので、時間を 3 つ目の軸として渡すことはできません。2D のまま動かす正攻法は 2 つで、デモ 4 本目は両方を使っています。

src/lessons/26-fbm-and-warping/04-scene.frag(抜粋)
  // ① 座標そのものを横へ流す
  vec2 q = p * NOISE_SCALE + vec2(u_time * DRIFT_SPEED, 0.0);

  // ② 歪ませる場だけを別の速さで縦へ流す
  vec2 flow = vec2(0.0, u_time * FLOW_SPEED);

ドメインをスクロールすると、模様は形を保ったまま流れます(風で雲が移動するのに相当)。②ワープの「歪ませる場」だけを動かすと、模様の位置は動かないまま、内側で形が変わり続けます(雲が湧いて崩れるのに相当)。この 2 つを違う速さで掛け合わせると、単純なスクロールよりずっと生きた動きになります。デモ 3 でも同じ仕掛けを使っていて、ワープなしの左端だけが止まって見えるのはそのためです。

src/lessons/26-fbm-and-warping/04-scene.frag
#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 つ足しただけです。

src/lessons/26-fbm-and-warping/main.ts
// 第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この章
ShaderMaterialfragmentShaderに書くノイズ関数GLSL は同じ言語なので、fbm.glslの関数はそのまま貼って使える(座標の作り方だけ、UV かgl_FragCoordかに合わせる)
THREE.ShaderChunk+#include <...>main.tsresolveIncludesによる文字列置換(第23章で作ったもの。この章で 2 チャンクに増えた)
ノイズを焼いた画像テクスチャを貼る画像を持たず、毎フレーム座標から計算する。解像度に依存せず拡大しても破綻しない代わりに、 コストが 6 節の表のとおり掛かる
Vector2を使った CPU 側の座標変形座標変形はすべてフラグメントシェーダー内で完結する(第10章のドメイン変形と同じ手順で、 変形の中身がノイズになっただけ)

手元で動かして、壊してみる

この章のコードはsrc/lessons/26-fbm-and-warping/にあります。fbm.glslの冒頭 3 つの定数を触ると、デモ 2・3・4 に一度に効くので、まずそこから(デモ 1 だけは、この 3 つをポインタから引数で渡しているので効きません。デモ 1 で試すぶんはポインタで動かしてください)。

まとめ

形を作る道具はこれで一通り揃いました。次に必要になるのは、作ったスカラー場をどう色にするかです。この章で「2 色のmixだけ」に留めた部分がそっくり残っています。次章第27章「色とパレット — sRGB・トーンマッピング・余弦パレット」では、リニア空間と sRGB の正確な行き来とガンマ、トーンマッピングの比較(Reinhard / ACES / AgX)と露出、HDR 画像の扱い、Inigo Quilez の余弦パレット、そしてバンディングを消すディザリングを扱います。第7章から引きずってきた 「0〜1 の RGB を素朴に足していてよいのか」という宿題も、そこで清算します。