乱数とノイズ
第9章で sin を重ねて模様を作ったとき、こう書きました — 「sin の重ね合わせは必ず周期的で、山の高さの出方にも偏りのないランダムさがないからです。雲や地形や水面のような『自然なでたらめさ』が欲しくなったとき、その答えが第25章のノイズです」。その第25章です。
やることは 3 段構えです。まずハッシュ— 座標を入れると毎回同じ値が返るのに、隣どうしは無関係に見える関数を作ります。次にその値を格子に置いて補間するvalue noise、最後に格子に置くものを値から勾配ベクトルに変えたgradient noise (Perlin noise)です。three.js にもSimplexNoiseが付属していますが、あれは CPU (JavaScript) 側で動くクラスで、シェーダーの中では使えません。GPU の上で「でたらめ」を作るには、GPU の実行モデルに合った別の道具が要ります。
fract(sin(...))、右が整数ハッシュ(ポインタ左右で格子の細かさ、上下で座標を遠くへ)。「補間関数の比較」は横 4 列が最近傍 / 線形 / 3 次 / 5 次、上段がノイズ・下段がその勾配。「Perlin の骨格」はポインタ右で格子と勾配ベクトルが現れます。「value と gradient」は上段が value・下段が gradient で、左からそのまま / 勾配 / 等高線。この章で学ぶこと:
- 状態を持つ疑似乱数生成器が GPU に載らない理由と、その代わりのハッシュ
fract(sin(dot(p, k)) * 43758.5453)の実際の問題 — 「質が悪い」のではなく「同じシェーダーが機械によって別の絵になる」uintとビット演算による整数ハッシュ(pcg3d)。桁あふれが混ぜる力になることprecision highp int;を書かないと別物のハッシュになる話(第21章のサンプラと同じ構図)- value noise と、補間関数(最近傍 / 線形 / 3 次 / 5 次)の質の違い
- gradient noise (Perlin) — 格子点に置くのは値ではなく勾配。格子点の上では必ず 0 になる
- 2 つのノイズの値域と、0〜1 に写す常套句
1. GPU に「乱数」は無い
CPU で乱数が欲しければMath.random()を呼びます。中身は疑似乱数生成器 (pseudo-random number generator)で、内部に状態を持ち、呼ばれるたびにその状態をかき混ぜて次の値を返します。three.js のMathUtils.seededRandomもこの形です(Mulberry32 という生成器で、モジュール内の_seedを書き換えながら進みます)。
// CPU の疑似乱数生成器(three.js の MathUtils.seededRandom と同じ Mulberry32)。
// 状態 seed を持ち、1 回呼ぶたびに書き換える。「次の値」は「前の値」なしには出せない
let seed = 12345;
function random(): number {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}この形はフラグメントシェーダーに持ち込めません。第1章で確認したとおり、GPU のプログラミングモデルでは各要素は互いの結果に依存できないからです — 「実行順序が保証されず、他の要素の計算結果を読む手段も基本的にない」。seedを 1 つ置いて全ピクセルで順番に更新する、という書き方そのものが成立しません。
そこで発想を変えます。「呼ぶたびに次の値を返す関数」ではなく、「座標を入れると値が返る関数」にするのです。これがハッシュ (hash)です。満たしてほしい性質は 2 つだけです。
- 決定的 — 同じ座標を入れたら、いつでも・どこでも同じ値が返る
- 近い入力が、無関係な出力になる— 隣のセル (x+1, y) の値が、(x, y) の値から予測できない
前章でこれが足りなくなった場面を思い出しておきます。第24章 6 節のround版のタイリングでは、セルの番地idが副産物として手に入り、デモ 3 の右下はその番地からsin(id.x * 1.7 + id.y * 2.3 + u_time * 0.5)を作って大きさを変えていました。規則的に変化するだけなので、隣り合うセルの大きさは互いに予測できてしまいます。このsinを、これから作るハッシュに差し替えれば、セルごとに無関係な値が手に入ります。
2. 定番の fract(sin(...)) の何が問題か
シェーダーのハッシュとして、たぶんいちばん見かける式がこれです。デモ 1 本目の左半分がこの式で、右半分が次節の整数ハッシュです。
/**
* 定番の「1 行ハッシュ」。GLSL ES 3.00 §4.5.1 は三角関数の精度を「未定義」と
* 定めているので、この式の結果は実装によって変わりうる。本文 2 節
*/
float fractSinHash(vec2 cell) {
return fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
}まず、よくある批判から否定しておきます。この式は「値が偏っている」わけでも「隣どうしが相関している」わけでもありません。セル座標 -300〜299 の 36 万セルを倍精度で計算し、20 ビンに分けた χ² は 28.8(自由度 19 の 95% 点は 30.1)で、一様性は棄却されません。セル座標 -200〜199 の 16 万セルで測った隣のセル (+1, 0) との相関係数も 0.0036 で、次節の整数ハッシュ(同じ標本で 0.0006)と桁が同じです。絵として見るぶんには、この式はちゃんとランダムに見えます。
ひとつ断っておくと、上の χ² は倍精度で測った値です。GPU 上で実際に走るのは単精度なので、同じ 36 万セルを単精度で測り直すと χ² は150.5まで跳ね上がります。ただしこれは分布が偏ったからではなく、下の (3) で見る刻みの粗さのせいです。この 36 万セルが取る相異なる値は 9,809 個しかなく、その 9,809 個の集合だけで χ² を取ると 6.1 に落ちます。自分で測って数字が合わないときは、まず単精度か倍精度かを疑ってください。
問題は別のところにあります。3 つに分けて挙げます。
(1) sin の精度が、仕様上「未定義」
GLSL ES 3.00 仕様 §4.5.1 は、単精度演算に要求される精度を表で定めたあと、こう書いています — 「Built-in functions not listed above and not defined as equations of the above have undefined precision. These include, for example, the trigonometric functions and determinant.」。三角関数は名指しで「精度は未定義」です。sinの結果が実装ごとに違ってよいのなら、それを 43758.5453 倍して小数部だけを取り出した値も、当然実装ごとに違います。
どのくらい違うのか。sinの結果が 1 ULP(単精度の最下位ビット 1 つぶん、約 6×10⁻⁸)だけずれた実装を用意して、100 × 100 セルの値を比べてみると、そのうち 21〜38 セルで結果が 0.1 以上変わりました(最大 0.998)。1 ULP のずれが、そのセルの「乱数」を丸ごと別の値にします。43758.5453 倍という増幅が、下位ビットの違いを上位まで持ち上げてしまうためです。
(2) dot の計算の仕方まで、仕様が 2 通り許している
同じ §4.5.1 の精度の表には、こういう行があります — 「a * b + c: Correctly rounded single operation or sequence of two correctly rounded operations.」。a * b + cは1 回の演算としてまとめて丸めてもよいし、2 回に分けて丸めてもよい、という意味です。前者は FMA (fused multiply-add) と呼ばれ、多くの GPU が持っている命令です。dot(cell, vec2(k1, k2))はまさにa * b + cの形なので、コンパイラが FMA に潰すかどうかは実装の自由です。dotには積が 2 つあるので、仕様が許すのは「2 回に分けて丸める」か「どちらか一方の積を足し算と融合して 1 回で丸める」かの 3 通りで、どの形でも少なくとも一方の積は丸められます。
2 回丸めた版を基準にして、単精度で再現して比べたのが次の表です(セル座標のオフセットを変えて 100 × 100 = 1 万セルずつ、値が 0.1 以上変わったセル数)。
dot の実装 | オフセット 0 | 4,096 | 262,144 |
|---|---|---|---|
x * K1 のほうを融合 | 627 | 535 | 475 |
y * K2 のほうを融合 | 1,691 | 2,031 | 2,036 |
どちらの積を FMA に潰すかで、影響を受けるセルは約 5 % から約 20 % まで動きます(475〜2,036 セル / 1 万。変わったセルでの差は最大 0.98)。sinの実装が完全に一致していても、掛け算と足し算のまとめ方が違うだけで、最悪 2 割のセルが別の値になるということです。
(3) 43758.5453 倍した時点で、小数部の桁が残っていない
sin(...)は -1〜+1 なので、43758.5453 倍した値の絶対値は最大で約 43758 です。単精度の仮数は 24 ビットしかないので、この大きさの数の刻み幅(ULP)は 2⁻⁸ = 0.0039 になります。fractは整数部を引くだけで刻みを細かくはしないので、いちばん粗いセルでは、得られる「乱数」が 0〜1 を 256 段階に割った値にしかなりません。刻みはセルごとに変わります —|sin(...)|が 1 に近いセルほど粗く、0 に近いセルほど細かくなるからです。
実測すると、49 万セル(セル座標 -350〜349)のうち46 % が刻み 2⁻⁸、97 % が 2⁻¹² 以上の粗さでした。「256 段階」は最悪値で、大半のセルもその数倍から数十倍の細かさしかない、ということです。100 万セル(セル座標 0〜999)を走査したときに現れた相異なる値は16,312 通りでした。
比較のために書いておくと、同じ 100 万セルで次節の整数ハッシュが返すfloatは 980,382 通りです。足りない約 2 万件の99.3 % はuintをfloatに直すときの丸めで、uintのまま数えると 999,867 通り — つまり本物のハッシュ衝突は 133 件です。これは 32 ビットの誕生日問題の期待値 (10⁶)² / (2 × 2³²) ≒ 116 件とほぼ一致するので、むしろハッシュがよく混ざっている証拠になります。
デモ 1 本目でこれを触れます。ポインタの上下で、セル座標に 2 の冪 (0 → 2²⁴) のオフセットが加わり、座標が原点から遠くへ運ばれます。ハーネスはポインタを動かすまでu_mouseに canvas の中央を渡すので(第6章)、読み込んだ直後はもう真ん中 = オフセット 2¹² = 4,096 の状態です。オフセット 0 の素の絵を見るにはポインタを下げてください。上げていくとsinに渡る引数が大きくなり、単精度で表せる引数の刻みがセルの間隔より粗くなって、別々のセルが同じ値になり始めます。左半分だけがブロック状に潰れていくのが見えるはずです。実測では、120 × 120 = 14,400 セルが取る相異なる値が次のように減りました。
| 座標のオフセット | 左: fract(sin) の相異なる値 | 右: 整数ハッシュの相異なる値 |
|---|---|---|
| 0 | 1,947 / 14,400 | 14,400 / 14,400 |
| 2¹⁶ = 65,536 | 1,440 / 14,400 | 14,394 / 14,400 |
| 2²⁰ = 1,048,576 | 609 / 14,400 | 14,397 / 14,400 |
| 2²³ = 8,388,608 | 147 / 14,400 | 14,394 / 14,400 |
| 2²⁴ = 16,777,216 | 76 / 14,400 | 3,721 / 14,400 |
最後の行では右側も崩れます。ただしこれはハッシュのせいではありません。2²⁴ を超えると単精度の float が整数を 1 刻みで表せなくなるので、セル座標そのものが 2 つずつ同じ値に潰れるのです。ハッシュを整数にしても、それを載せている座標の精度までは救えません。
#version 300 es
// 第25章 デモ1: 白色ノイズを 2 通りのハッシュで作り、左右に並べて比べる。
// 左 = 定番の fract(sin(dot(p, k)) * 43758.5453)
// 右 = 整数ハッシュ(noise.glsl の hash21 → pcg3d)
// 両方に「同じセル座標」を渡しているので、違いはハッシュの中身だけ。
//
// ポインタ左右 = 格子の細かさ。
// ポインタ上下 = セル座標を 2 の冪だけ遠くへ運ぶ(0 → 2^24)。
// 未操作のとき u_mouse は canvas の中央なので、読み込み直後は e = 12(オフセット 4096)。
// オフセット 0 の素の絵を見るにはポインタを下げる。
// 上へ動かしていくと、まず左だけが崩れる(sin に渡す引数の精度が足りなくなる)。
// 一番上まで上げると右も崩れるが、そちらはハッシュではなく
// 「セル座標を載せている float そのもの」の精度の限界。
precision highp float;
precision highp int; // ← これが無いと右のハッシュが別物になる。本文 3 節
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// 格子の細かさ(画面の短辺あたりのセル数)。ポインタ左右でこの間を動く
const float CELLS_MIN = 8.0;
const float CELLS_MAX = 160.0;
// ポインタを一番上まで動かしたときのオフセット(2 の何乗まで運ぶか)
const float MAX_EXP = 24.0;
// ----------------------------------------------------------------------
/**
* 定番の「1 行ハッシュ」。GLSL ES 3.00 §4.5.1 は三角関数の精度を「未定義」と
* 定めているので、この式の結果は実装によって変わりうる。本文 2 節
*/
float fractSinHash(vec2 cell) {
return fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// 左右どちらの帯にいるか。0 = 左(fract(sin))、1 = 右(pcg3d)
int side = uv.x < 0.5 ? 0 : 1;
// 帯ごとのローカル座標。両方の帯が「同じセル座標」を見るようにする。
// x に aspect * 0.5 を掛けているのはセルを正方形に保つため
float aspect = u_resolution.x / u_resolution.y;
vec2 local = vec2(fract(uv.x * 2.0) * aspect * 0.5, uv.y);
float mx = u_mouse.x / u_resolution.x;
float my = u_mouse.y / u_resolution.y;
vec2 base = floor(local * mix(CELLS_MIN, CELLS_MAX, mx));
// 座標を遠くへ運ぶ。2 の冪に丸めているのは、崩れ始める桁を読み取りやすくするため
float e = min(floor(my * (MAX_EXP + 1.0)), MAX_EXP);
vec2 cell = base + (e < 1.0 ? 0.0 : exp2(e));
// 左は float のまま sin に渡し、右は ivec2 にしてから整数ハッシュへ渡す
float h = side == 0 ? fractSinHash(cell) : hash21(ivec2(cell));
vec3 color = vec3(0.06, 0.07, 0.09) + h * vec3(0.86, 0.88, 0.92);
// 中央の仕切り線(第4部の既定形。描画バッファのピクセル単位で引く)
float border = abs(gl_FragCoord.x - u_resolution.x * 0.5);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, border));
fragColor = vec4(color, 1.0);
}3. 整数ハッシュ — uint とビット演算
sinをやめて、整数のビットをかき混ぜる方式に変えます。第4章の uniform の型の表で「uint/ uvec2など(WebGL2 で追加)」の行に「ビット演算を使う場面(第25章の hash など)」と書いておいた、その場面です。WebGL2 (GLSL ES 3.00) でuintとビット演算が使えるようになったことが、この手のハッシュを実用にしました。
この章が使うのはpcg3dです。出典は Mark Jarzynski, Marc Olano, "Hash Functions for GPU Rendering",Journal of Computer Graphics TechniquesVol. 9, No. 3 (2020), 20–38(jcgt.org/published/0009/03/02)。論文 p.32 に実装が載っており、結論(p.33)では 3 入力 3 出力 (N→N) の用途でpcg3d/ pcg4d が推奨されています。
uvec3 pcg3d(uvec3 v) {
v = v * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return v;
}やっていることは 3 種類の操作の繰り返しです。掛けて足す(1 行目)、成分どうしを掛け合わせて足し込む(x に y*z、y に z*x、z に x*y)、自分自身を右シフトして XOR する(v ^= v >> 16u)。
このうち混ぜる力の大半を担っているのが、実は桁あふれです。GLSL ES 3.00 §4.1.3 は整数演算の桁あふれをこう定めています — 「For all precisions, operations resulting in overflow or underflow will not cause any exception, nor will they saturate, rather they will "wrap" to yield the low-order n bits of the result where n is the size in bits of the integer.」。例外も飽和もせず、下位 n ビットだけが残る。uintどうしの掛け算は 64 ビットぶんの積になりますが、そのうち上位 32 ビットが捨てられて、下位 32 ビットだけが返るのです。この「捨てられ方」が、上位の情報を下位に押し込む役割を果たします。
3 出力のうち 1 つだけ使えば、整数の格子座標から 0〜1 の乱数が 1 つ得られます。
float hash21(ivec2 cell) {
uint h = pcg3d(uvec3(ivec3(cell, 0))).x;
return float(h) / 4294967296.0; // 2^32 で割って 0〜1 へ
}ivec2をわざわざ経由しているのには理由があります。GLSL ES 3.00 §5.4.1 に「The constructor uint(int) preserves the bit pattern in the argument, which will change its value if it is negative.」とあり、int→ uint の変換はビットパターンをそのまま移すと保証されています。だから座標が -3 でも、2 の補数のビット列が決まった 1 つのuintになり、ハッシュに渡せます。この関数の分布は、セル座標 -300〜299 の 36 万セル・20 ビンの χ² が 24.4(自由度 19 の 95% 点 30.1)。隣接セルとの相関係数は、セル座標 -200〜199 の 16 万セルで (+1, 0) が 0.0006、(0, +1) が -0.0010 でした(2 節のfract(sin)と同じ標本です。この桁の相関はどれも標本によって符号が入れ替わる程度の量なので、比べるときは必ず同じ標本で測ってください)。
precision highp float;
precision highp int; // ← これが無いと右のハッシュが別物になる。本文 3 節// これは書いてはいけない。p が負のとき、float → uint の変換は未定義(§5.4.1)
uvec2 bad = uvec2(floor(p));
// ivec2 を経由する。int → uint はビットパターンをそのまま移すので、
// 負の座標でも「決まった 1 つの uint」に落ちる(§5.4.1)
ivec2 cell = ivec2(floor(p));
uvec2 good = uvec2(cell);4. value noise — 格子点に乱数を置いて補間する
ハッシュだけでは白色ノイズ(隣どうしがまったく無関係な砂嵐)にしかなりません。雲や地形が欲しいなら、「近い場所は近い値」という滑らかさが要ります。いちばん素直な作り方がvalue noiseです。手順は 2 つだけです。
- 整数の格子点に、ハッシュで乱数を置く(格子の間隔を 1.0 とする)
- その間を補間する— 4 隅の値から、セル内の位置に応じて重み付き平均を取る
mixで混ぜます。ここで t をそのまま使わず、補間関数 u に通すのが次節の主題です。float valueNoise(vec2 p) {
vec2 base = floor(p);
ivec2 cell = ivec2(base);
vec2 t = p - base;
vec2 u = vec2(fade(t.x), fade(t.y));
float a = hash21(cell + ivec2(0, 0));
float b = hash21(cell + ivec2(1, 0));
float c = hash21(cell + ivec2(0, 1));
float d = hash21(cell + ivec2(1, 1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}4 隅の値はすべて 0〜1 で、mixはその間の値しか返しません。だからvalueNoise の戻り値は構造上 0〜1 に収まります(300 万点の実測で 0.000106 〜 0.999851)。そしてもう 1 つ、この作り方には性質がついてきます。セル内で値が最大になる場所は、必ず格子点(4 隅のどれか)です。これも実測ではなく構造から言えます。3 回のmixを展開すると 4 隅にかかる重みは(1-u.x)(1-u.y)/u.x(1-u.y)/(1-u.x)u.y/u.x·u.yで、uが 0〜1 に収まる補間関数ならこの 4 つはすべて非負で、足すとちょうど 1です。つまり結果は 4 隅の値の凸結合なので、n ≤ max(a, b, c, d)が補間関数によらず成り立ちます。値域を「構造上 0〜1」と言えるのとまったく同じ理屈です。極値が格子に 整列するので、拡大していくと格子の並びが模様として見えてきます。6 節の gradient noise は、この点をひっくり返します。
5. 補間関数の質 — 折れ目はどこから来るか
セル内の位置 t(0〜1)をそのまま mix の係数にすると線形補間です。動きますが、絵にすると格子に沿った折れ目が見えます。なぜかは、tを通す関数u(t)の両端での傾きを見るとわかります。
ノイズの値は、格子線をまたぐ手前と向こうで別の 4 隅の組から作られます。値そのものは 格子線上で一致しますが、傾き(微分)は一致するとは限りません。線形補間ではu'(t) = 1で一定なので、格子線をはさんで傾きがいきなり変わります。36 万本の格子線を 1 本あたり 20 点、合わせて 720 万サンプルで測ると、格子線での ∂n/∂x の跳びは平均 0.470、最大は1.986(跳びは格子線に沿って 1 次式なので、両端 72 万通りの厳密評価)でした。目には「折れ目」として映ります。
直し方は簡単で、両端で傾きが 0 になる関数を通してからmixに渡します。傾きが両側とも 0 なら、格子線をまたいでも跳びようがありません。候補は 2 つあります。
- 3 次の 3t² − 2t³—
smoothstepの中身そのものです(第7章)。u'(0) = u'(1) = 0 - 5 次の 6t⁵ − 15t⁴ + 10t³— Perlin の
fade。u'だけでなくu''も両端で 0
/** Perlin の 5 次補間。t = 0 と t = 1 の両端で 1 階・2 階の傾きがともに 0 になる */
float fade(float t) {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}デモ 2 本目がこの 4 種の比較です。横 4 列が左から 最近傍 / 線形 / 3 次 / 5 次、上段がノイズそのもの、下段が同じノイズの勾配の大きさです。上段だけ見ていると 3 列目と 4 列目の区別はまずつきません。差は下段に出ます。
| 補間 | 上段(ノイズ) | 下段(勾配)に出るもの |
|---|---|---|
| 最近傍 | 正方形のブロック | ほぼ全面が真っ黒(97 % の画素で勾配がほぼ 0)。段差の位置に細い線が 1 ピクセル幅で走るだけ |
| 線形 | 格子に沿った折れ目が見える | セルごとに明るさの違うパッチが並び、格子線で明るさが不連続に切り替わる |
| 3 次(smoothstep) | 滑らか | 連続。格子線に沿って細い暗線が走る |
| 5 次(fade) | 滑らか(3 次と見分けにくい) | 連続。暗線がより太い帯になる |
/** 補間関数 4 種。mode = 0 最近傍 / 1 線形 / 2 三次 / 3 五次 */
float interpolate(float t, int mode) {
if (mode == 0) return step(0.5, t); // 補間しない(近いほうの格子点の値をそのまま)
if (mode == 1) return t; // 線形。mix にそのまま渡すのと同じ
if (mode == 2) return t * t * (3.0 - 2.0 * t); // 3 次。smoothstep の中身
return fade(t); // 5 次。noise.glsl の fade
}下段の暗線について、正直に書いておきます。3 次でも 5 次でも、格子線の上では x 方向の勾配がちょうど 0 になります— u'(0) = u'(1) = 0 にした当然の帰結です。違うのは「0 の近くにいる範囲の広さ」で、3 次はu'が原点付近で6t(1 次)なのに対し、5 次は30t²(2 次)なので、より広い範囲で 0 に張り付きます。格子線からの距離ごとに |∂n/∂x| の平均を測ると次のようになりました(60 × 60 = 3,600 セル × セルあたり 10 点 = 距離 1 つにつき 36,000 サンプル)。
| 格子線からの距離 | 0 | 0.02 | 0.05 | 0.1 | 0.3 | 0.5 |
|---|---|---|---|---|---|---|
| 3 次 | 0.000 | 0.033 | 0.080 | 0.152 | 0.355 | 0.422 |
| 5 次 | 0.000 | 0.003 | 0.020 | 0.070 | 0.383 | 0.542 |
つまり3 次と 5 次の差は、絵をそのまま眺めるぶんにはほとんど出ません。差が出るのは、ノイズを微分して使うとき — ノイズから法線を作って陰影をつける、勾配の向きに何かを流す、といった用途です。そのとき 3 次だと、2 階微分の跳び(図の右パネルの ±6)が「格子に沿った不自然な縞」として現れます。fadeが 5 次なのはそのためです。
#version 300 es
// 第25章 デモ2: value noise の補間関数を 4 種類並べて比べる。
// 横 4 列 = 最近傍 / 線形 / 3 次(smoothstep と同じ式) / 5 次(Perlin の fade)
// 上の段 = ノイズそのもの
// 下の段 = 同じノイズの「勾配の大きさ」(微分表示)
// 上の段だけ見ていると 3 列目と 4 列目の区別はつかない。差は下の段に出る。
// ポインタ左右でノイズの周波数が変わる。
precision highp float;
precision highp int;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// ノイズの周波数(セル 1 枚の高さあたりの格子数)。ポインタ左右でこの間を動く
const float FREQ_MIN = 2.0;
const float FREQ_MAX = 7.0;
// 微分表示の明るさ。勾配の大きさをこの値で割ってから色にする
const float GRAD_SCALE = 1.2;
// ----------------------------------------------------------------------
/** 補間関数 4 種。mode = 0 最近傍 / 1 線形 / 2 三次 / 3 五次 */
float interpolate(float t, int mode) {
if (mode == 0) return step(0.5, t); // 補間しない(近いほうの格子点の値をそのまま)
if (mode == 1) return t; // 線形。mix にそのまま渡すのと同じ
if (mode == 2) return t * t * (3.0 - 2.0 * t); // 3 次。smoothstep の中身
return fade(t); // 5 次。noise.glsl の fade
}
/**
* noise.glsl の valueNoise の、補間関数だけを差し替えられる版。
* この比較がデモの主題なので、共有チャンクは変えずにここへローカル版を置いている
*/
float valueNoiseWith(vec2 p, int mode) {
vec2 base = floor(p);
ivec2 cell = ivec2(base);
vec2 t = p - base;
vec2 u = vec2(interpolate(t.x, mode), interpolate(t.y, mode));
float a = hash21(cell + ivec2(0, 0));
float b = hash21(cell + ivec2(1, 0));
float c = hash21(cell + ivec2(0, 1));
float d = hash21(cell + ivec2(1, 1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void main() {
// 画面を 4 列 × 2 行に割る
vec2 cellSize = vec2(u_resolution.x / 4.0, u_resolution.y / 2.0);
vec2 index = min(floor(gl_FragCoord.xy / cellSize), vec2(3.0, 1.0));
int mode = int(index.x);
bool topRow = index.y > 0.5;
float freq = mix(FREQ_MIN, FREQ_MAX, u_mouse.x / u_resolution.x);
// どのセルも「同じノイズの同じ場所」を見るように、セル内の位置から座標を作る。
// cellSize.y で割っているので、画面上のピクセルは正方形のまま
vec2 inCell = gl_FragCoord.xy - index * cellSize;
vec2 q = inCell / cellSize.y * freq;
float n = valueNoiseWith(q, mode);
// 勾配の大きさ。dFdx / dFdy は画面のピクセル単位なので、ノイズ座標あたりに直す
// (セルの境界をまたぐ 2×2 の組では別の列の値が混ざるが、そこは仕切り線で隠れる)
float grad = length(vec2(dFdx(n), dFdy(n))) * cellSize.y / freq;
vec3 color;
if (topRow) {
color = vec3(0.06, 0.07, 0.09) + n * vec3(0.84, 0.86, 0.90);
} else {
float g = clamp(grad / GRAD_SCALE, 0.0, 1.0);
color = vec3(0.05, 0.06, 0.08) + g * vec3(0.95, 0.72, 0.34);
}
// セルの仕切り線(第4部の既定形。描画バッファのピクセル単位で引く)
vec2 edge = min(inCell, cellSize - inCell);
float border = min(edge.x, edge.y);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, border));
fragColor = vec4(color, 1.0);
}6. gradient noise (Perlin) — 格子点に置くのは値ではなく勾配
value noise の弱点は 4 節で見たとおり、極値が必ず格子点に来ることです。明るい点と暗い点が格子の交点にきれいに整列するので、拡大すると格子が透けます。Ken Perlin のgradient noiseは、この一点を狙って設計を変えます。
格子点に置くのは「値」ではなく「長さ 1 の向き(勾配ベクトル)」です。そして、あるセル内の点pに対して 4 隅それぞれについて次を計算します。
- その格子点から p への変位ベクトルを取る
- その格子点の勾配ベクトルとの内積を取る(= 4 つのスカラー値が得られる)
- それを value noise と同じ 3 回の
mixで補間する
float gradientNoise(vec2 p) {
vec2 base = floor(p);
ivec2 cell = ivec2(base);
vec2 t = p - base;
vec2 u = vec2(fade(t.x), fade(t.y));
float a = dot(randomGradient(cell + ivec2(0, 0)), t - vec2(0.0, 0.0));
float b = dot(randomGradient(cell + ivec2(1, 0)), t - vec2(1.0, 0.0));
float c = dot(randomGradient(cell + ivec2(0, 1)), t - vec2(0.0, 1.0));
float d = dot(randomGradient(cell + ivec2(1, 1)), t - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}格子点の上で必ず 0 になることは、-50〜+50 の 101 × 101 = 10,201 点で確認しました(|値|の最大が 0)。デモ 3 本目の白い線が「値がちょうど 0 の等高線」で、この線が必ずすべての格子点を通っているのが見えます。格子・格子点・各格子点の勾配ベクトルの重ね書きはポインタの左右で濃さが変わります。読み込んだ直後はu_mouseが canvas の中央なので(第6章)すでに半分の濃さで重なっています。左いっぱいまで 動かすと素のノイズだけに、右いっぱいまで動かすと骨格が全部見える状態になるので、往復させて見比べてください。
value noise が「格子点に極値」だったのに対して、gradient noise は「格子点に 0」です。極値はセルの内側のどこかに出るので、格子との整列が崩れます。
単位ベクトルの作り方に落とし穴がある
「長さ 1 のランダムなベクトル」を作る方法は 2 つ思いつきます。角度を乱数で選んで cos / sin を取るか、正方形からランダムな点を取って normalize するかです。noise.glslは前者を採っています。
/** 格子点に置く長さ 1 の勾配ベクトル。角度を乱数で選ぶので、向きの分布が一様になる */
vec2 randomGradient(ivec2 cell) {
float angle = hash21(cell) * TAU;
return vec2(cos(angle), sin(angle));
}// 正方形から取って normalize する版。短く書けるが、向きが斜め 45 度に偏る
vec2 badGradient(ivec2 cell) {
vec2 v = vec2(hash21(cell), hash21(cell + ivec2(7919, 0))) * 2.0 - 1.0;
return normalize(v);
}後者が駄目な理由は、正方形が円ではないからです。正方形の中の点を単位円へ射影すると、対角線の方向に多くの点が集まります(正方形の中心から角までの距離は辺の中点までの距離の √2 倍あり、そのぶん角の方向に「材料」が多い)。実際にhash21を使って 196 万本のベクトルを作り、向きを 36 方位のビンに分けて数えたのがこちらです。
| 作り方 | 最も少ない方位 / 期待値 | 最も多い方位 / 期待値 |
|---|---|---|
角度から作る(randomGradient) | 0.990 | 1.007 |
正方形から取って normalize | 0.790 | 1.448 |
正方形版では、いちばん濃い方位(斜め 45 度)がいちばん薄い方位の 1.8 倍以上になります。勾配の向きが斜めに偏るということは、ノイズの模様全体が斜めに引き伸ばされるということです。cosとsinを 1 回ずつ余分に呼ぶコストで、この偏りは消えます。
#version 300 es
// 第25章 デモ3: gradient noise (Perlin) の骨格を見る。
// 青 = 負、橙 = 正、白い細線 = 値がちょうど 0 の等高線。
// ポインタ左右 = 格子・格子点・各格子点に置かれた勾配ベクトルの重ね書きの濃さ。
// 未操作のとき u_mouse は canvas の中央なので、読み込み直後は overlay = 0.5(半分の濃さ)。
// 見どころは「白い 0 の線が、必ずすべての格子点を通る」こと。
precision highp float;
precision highp int;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// 画面の短辺(標準座標の -1〜+1)に何セル入れるか
const float CELLS = 2.5;
// 勾配ベクトルの描画の長さ(格子 1 マスを 1.0 とした比)と根元の太さ
const float ARROW_LENGTH = 0.42;
const float ARROW_WIDTH = 0.030;
// 格子点に打つ点の半径
const float DOT_RADIUS = 0.055;
// ----------------------------------------------------------------------
/** 格子点 a から向き g へ伸びる、先細りの矢。戻り値は 0〜1 のマスク */
float spike(vec2 p, vec2 a, vec2 g, float w) {
vec2 pa = p - a;
vec2 ba = g * ARROW_LENGTH;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); // 第8章の線分の射影
float d = length(pa - ba * h) - mix(ARROW_WIDTH, 0.0, h);
return 1.0 - smoothstep(-w, w, d);
}
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
vec2 q = p * CELLS; // ノイズ座標。格子の間隔がちょうど 1.0 になる
// ノイズ座標での「1 ピクセルぶんの幅」。アンチエイリアスの基準に使い回す
float w = CELLS * 2.0 / min(u_resolution.x, u_resolution.y);
float n = gradientNoise(q);
vec3 color = vec3(0.06, 0.07, 0.09);
color = mix(color, vec3(0.16, 0.34, 0.62), clamp(-n * 1.7, 0.0, 1.0)); // 負 = 青
color = mix(color, vec3(0.92, 0.58, 0.26), clamp(n * 1.7, 0.0, 1.0)); // 正 = 橙
// 値が 0 の等高線。fwidth は n に対して取る(第8章の塗りの常套句と同じ考え方)
float nw = fwidth(n);
float zero = 1.0 - smoothstep(0.0, nw * 1.6, abs(n));
// ポインタ左右で重ね書きの強さを変える。0 = 素のノイズ、1 = 骨格を全部見せる
float overlay = u_mouse.x / u_resolution.x;
// 格子線: 整数座標にいちばん近い距離
vec2 f = abs(q - floor(q + 0.5));
float grid = 1.0 - smoothstep(0.0, w * 1.5, min(f.x, f.y));
// いま自分がいるセルの 4 隅に、勾配ベクトルと点を描く。
// 矢の長さが格子間隔より短いので、隣のセルの矢はここまで届かない
float arrows = 0.0;
float dots = 0.0;
vec2 base = floor(q);
for (int j = 0; j <= 1; j++) {
for (int i = 0; i <= 1; i++) {
ivec2 cell = ivec2(base) + ivec2(i, j);
vec2 a = vec2(cell);
arrows = max(arrows, spike(q, a, randomGradient(cell), w));
dots = max(dots, 1.0 - smoothstep(-w, w, length(q - a) - DOT_RADIUS));
}
}
color = mix(color, vec3(0.52, 0.58, 0.68), grid * 0.6 * overlay);
color = mix(color, vec3(0.96, 0.98, 1.00), zero * (0.30 + 0.60 * overlay));
color = mix(color, vec3(0.90, 0.94, 1.00), arrows * overlay);
color = mix(color, vec3(0.26, 0.92, 0.72), dots * overlay);
fragColor = vec4(color, 1.0);
}7. 値域と、0〜1 に写す常套句
2 つのノイズは値域が違います。混ぜて使うときも、色に入れるときも、次章で足し合わせるときも、ここを間違えると全部ずれるので、はっきりさせておきます。
valueNoise | gradientNoise | |
|---|---|---|
| 値域 | 0 〜 1(構造上) | 約 -0.705 〜 +0.702(理論上限 √2/2 = 0.70711) |
| 実測(乱択) | 0.000106 〜 0.999851(300 万点) | -0.7051 〜 +0.7021(500 万点) |
| 平均のあたり | 0.5 付近 | 0 付近(符号付き) |
| 格子点の上での値 | その格子点のハッシュ値(= セル内の極値) | 必ず 0 |
| 格子点の上での勾配 | 0(3,600 格子点の実測で |∇n| = 0.000) | その格子点の randomGradient(cell) そのもの。長さはちょうど 1(3,600 格子点の実測で |∇n| = 1.000) |
最後の行は名前のとおりです。格子点の上ではt = (0, 0)で、fade(0) = 0かつfade'(0) = 0なので、4 隅のうち生き残るのは自分自身の項だけになり、∂n/∂tx = g₀₀.x/∂n/∂ty = g₀₀.yがそのまま出てきます。つまり格子点での勾配は、そこに置いた勾配ベクトルそのものです。31 × 31 = 961 の格子点で数値微分するとrandomGradient(cell)との差は 10⁻⁹ 台で、長さは 1.000000 でした。「格子点に置いた勾配」という名前は比喩ではなく、そのとおりの意味だということです。
// gradient noise を 0〜1 へ。掛ける 0.70711 は 0.5 / 理論上限(= 0.5 / 0.70711)で、
// 数としてはたまたま理論上限と同じ値になる。これで上限がちょうど 0.5 に写る
float n01 = gradientNoise(p) * 0.70711 + 0.5; // 実測 0.0014 〜 0.9965
// 端まで使い切る必要がないなら、こちらのほうが覚えやすい
float safe01 = gradientNoise(p) * 0.5 + 0.5; // 実測 0.1475 〜 0.8510上の行は理論上限で正規化する版です。0.5 / (√2/2) = 0.70711を掛けると、上限 √2/2 がちょうど 0.5 に写るので、結果が 0〜1 を目一杯使います(実測で 0.0014 〜 0.9965)。下の行は* 0.5 + 0.5という、符号付きの値を 0〜1 に持ち上げる一般的な書き方です。こちらは 0.1475 〜 0.8510 の範囲にしか広がらない代わりに、係数を覚える必要がありません。どちらでもよいのですが、どちらを使ったかを忘れないこと— コントラストが 1.4 倍違います。
デモ 4 本目が両者の並置です。上段が value、下段が gradient、左からそのまま / 勾配 / 等高線で、下段は上の常套句で 0〜1 に揃えてあります。見どころは右の 2 列です。
- 中央(勾配)— この列が表示しているのは
|∇n|です。value 側には格子がはっきり出ます。理由はu'(0) = u'(1) = 0で、縦の格子線の上では∂n/∂x がちょうど 0、横の格子線の上では∂n/∂y がちょうど 0になるからです(実測しても数値微分の誤差である 10⁻¹⁰ 台)。片方が落ちるぶん|∇n|は格子線上で平均 0.328 と、全体平均 0.506 より低くなり、両方が落ちる格子点ではちょうど 0 になります。gradient 側にはこの落ち込みがありません — 格子線上の平均 0.837 に対して全体平均 0.820 で、ほとんど差がないからです(いずれも 3,600 セルでの実測)。 - 右(等高線 + 格子)— value 側は等高線が格子点を取り囲む閉曲線になります(格子点が極値だから)。gradient 側は、中間の高さの等高線が格子点を通り抜けます(格子点が 0 だから)。
セル内の最大値が格子点にあったセルの数を数えると、value は 625 / 625、gradient は 1 / 625 でした。gradient noise が「格子が見えにくい」と言われる理由は、この一点に尽きます。
#version 300 es
// 第25章 デモ4: value noise と gradient noise を同じ条件で並べる。
// 上の段 = value noise(0〜1 をそのまま)
// 下の段 = gradient noise(n * 0.70711 + 0.5 で 0〜1 に写したもの)
// 左の列 = そのまま表示 / 中の列 = 勾配の大きさ / 右の列 = 等高線 + 格子
// 中と右の列に、格子との関係の違いがはっきり出る。
// ポインタ左右でノイズの周波数が変わる。
precision highp float;
precision highp int;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
#include "noise.glsl"
// ---- 書き換えて試すための定数 ----------------------------------------
// ノイズの周波数(セル 1 枚の高さあたりの格子数)。ポインタ左右でこの間を動く
const float FREQ_MIN = 2.0;
const float FREQ_MAX = 6.0;
// 微分表示の明るさ。勾配の大きさをこの値で割ってから色にする
const float GRAD_SCALE = 1.2;
// 等高線の本数(0〜1 を何段に割るか)
const float CONTOURS = 10.0;
// gradient noise を 0〜1 に写す係数。1/√2 = 0.70711(理論上限 √2/2 の逆数の半分)
const float GRADIENT_TO_01 = 0.70711;
// ----------------------------------------------------------------------
void main() {
// 画面を 3 列 × 2 行に割る
vec2 cellSize = vec2(u_resolution.x / 3.0, u_resolution.y / 2.0);
vec2 index = min(floor(gl_FragCoord.xy / cellSize), vec2(2.0, 1.0));
int col = int(index.x);
bool topRow = index.y > 0.5;
float freq = mix(FREQ_MIN, FREQ_MAX, u_mouse.x / u_resolution.x);
// どのセルも「同じノイズの同じ場所」を見るように、セル内の位置から座標を作る
vec2 inCell = gl_FragCoord.xy - index * cellSize;
vec2 q = inCell / cellSize.y * freq;
// 上下とも 0〜1 に揃えてから比べる。gradient 側は本文 7 節の常套句
float n = topRow ? valueNoise(q) : gradientNoise(q) * GRADIENT_TO_01 + 0.5;
// ノイズ座標あたりの勾配の大きさ(dFdx / dFdy は画面のピクセル単位なので直す)
float grad = length(vec2(dFdx(n), dFdy(n))) * cellSize.y / freq;
vec3 color = vec3(0.06, 0.07, 0.09) + n * vec3(0.84, 0.86, 0.90);
if (col == 1) {
float g = clamp(grad / GRAD_SCALE, 0.0, 1.0);
color = vec3(0.05, 0.06, 0.08) + g * vec3(0.95, 0.72, 0.34);
} else if (col == 2) {
// 等高線: 0〜1 を CONTOURS 段に割り、段の境目に線を引く(第8章の fract(d * N))
float bands = n * CONTOURS;
float e = fwidth(bands);
float edge = min(fract(bands), 1.0 - fract(bands));
float line = 1.0 - smoothstep(0.0, e * 1.2, edge);
color = mix(color * 0.55, vec3(0.35, 0.85, 0.95), line);
// 格子を薄く重ねて、等高線と格子点の関係を見えるようにする
vec2 f = abs(q - floor(q + 0.5));
float gw = fwidth(q.x) * 1.5;
float grid = 1.0 - smoothstep(0.0, gw, min(f.x, f.y));
color = mix(color, vec3(0.95, 0.45, 0.35), grid * 0.7);
}
// セルの仕切り線(第4部の既定形。描画バッファのピクセル単位で引く)
vec2 edgePx = min(inCell, cellSize - inCell);
float border = min(edgePx.x, edgePx.y);
color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, border));
fragColor = vec4(color, 1.0);
}8. 1 オクターブでできること、できないこと
ここまでで手に入ったのは、ひとつの決まった大きさのもやもやです。周波数を上げれば細かく、下げれば粗くなりますが、どちらにしても「特徴の大きさがひと通りしかない絵」でしかありません。雲にも岩肌にも水面にも見えないのは、そこに原因があります。
自然の見た目は、たいてい複数のスケールが同時に存在しています。大きな塊があり、その表面に中くらいの起伏があり、さらに細かいざらつきが乗っている。第9章の sin の重ね合わせで「低い周波数を大きく、高い周波数を小さく重ねる」という定石を先に体験しましたが、あのときの部品が sin だったので周期性が残りました。部品をこの章のノイズに差し替えると、周期性のない、しかし複数スケールを持つ絵になります。
その組み立て方がfbm (fractional Brownian motion)です。この章では意図的に一切足していません —この章のノイズは全部「1 オクターブ」です。次章の主役をここで先に見せてしまうと、値域と格子の性質という土台が霞むためです。
コード全文
シェーダー 4 本は各節に掲載したとおりです。4 本すべてが読み込む共有チャンクnoise.glslと、それを配る CPU 側のmain.tsを載せます。#includeを文字列置換で解決するのは第23章と同じ仕組みで、この章ではnoise.glsl1 本を 4 本のシェーダーへ配るために使っています。
// 置換文字列を関数で渡すのは、String.replace が `$&` などを特別扱いするため(第23章)
function resolveIncludes(source: string): string {
return source.replace('#include "noise.glsl"', () => noiseChunkSource.trim());
}// 第25章の中核。4 本のデモがすべてこのチャンクを読み込む
// (`#include "noise.glsl"` を main.ts が文字列置換する。仕組みは第23章と同じ)。
//
// このチャンクを使うシェーダーは、先頭に必ず次の 2 行を書くこと。
// precision highp float;
// precision highp int;
// フラグメントシェーダーの整数の既定精度は mediump で(GLSL ES 3.00 §4.5.4)、
// mediump の uint は 16 ビットしか保証がない(§4.5.1)。32 ビットで折り返すことを
// 前提にした下のハッシュは、そこで別物になる。詳しくは第25章の本文。
const float TAU = 6.283185307179586;
/**
* pcg3d — 3 つの uint を、互いに無関係に見える 3 つの uint へかき混ぜる整数ハッシュ。
* 出典: Mark Jarzynski, Marc Olano, "Hash Functions for GPU Rendering",
* Journal of Computer Graphics Techniques, Vol. 9, No. 3 (2020), p.32.
* https://jcgt.org/published/0009/03/02/
* 掛け算と足し算は 2^32 で折り返す(GLSL ES 3.00 §4.1.3)。
* この桁あふれがビットをかき混ぜる動力そのもので、避けるべき事故ではない。
*/
uvec3 pcg3d(uvec3 v) {
v = v * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return v;
}
/**
* 整数の格子座標 → 0〜1 の乱数 1 つ。
* ivec2 → uvec2 の変換はビットパターンをそのまま移すので(§5.4.1)、負の座標も安全に扱える。
* float から直接 uint へ変換してはいけない — 負の float から uint への変換は未定義(§5.4.1)。
*/
float hash21(ivec2 cell) {
uint h = pcg3d(uvec3(ivec3(cell, 0))).x;
return float(h) / 4294967296.0; // 2^32 で割って 0〜1 へ
}
/** 格子点に置く長さ 1 の勾配ベクトル。角度を乱数で選ぶので、向きの分布が一様になる */
vec2 randomGradient(ivec2 cell) {
float angle = hash21(cell) * TAU;
return vec2(cos(angle), sin(angle));
}
/** Perlin の 5 次補間。t = 0 と t = 1 の両端で 1 階・2 階の傾きがともに 0 になる */
float fade(float t) {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
/** value noise: 格子点に置いた乱数そのものを補間する。戻り値は 0〜1 */
float valueNoise(vec2 p) {
vec2 base = floor(p);
ivec2 cell = ivec2(base);
vec2 t = p - base;
vec2 u = vec2(fade(t.x), fade(t.y));
float a = hash21(cell + ivec2(0, 0));
float b = hash21(cell + ivec2(1, 0));
float c = hash21(cell + ivec2(0, 1));
float d = hash21(cell + ivec2(1, 1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
/**
* gradient noise (Perlin): 格子点に置いた勾配ベクトルと、格子点からの変位との内積を
* 補間する。格子点の上では必ず 0 になる。とりうる値の理論上限は ±√2/2 = 0.70711 で、
* 500 万点を乱択して出た最大・最小は -0.7051 / +0.7021。後者は乱択の結果なので、
* 測り直すと ±0.005 ほど振れる(上限に近づくだけで、超えることはない)。
*/
float gradientNoise(vec2 p) {
vec2 base = floor(p);
ivec2 cell = ivec2(base);
vec2 t = p - base;
vec2 u = vec2(fade(t.x), fade(t.y));
float a = dot(randomGradient(cell + ivec2(0, 0)), t - vec2(0.0, 0.0));
float b = dot(randomGradient(cell + ivec2(1, 0)), t - vec2(1.0, 0.0));
float c = dot(randomGradient(cell + ivec2(0, 1)), t - vec2(0.0, 1.0));
float d = dot(randomGradient(cell + ivec2(1, 1)), t - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}// 第25章: 乱数とノイズ
// 第2部と同じ構成: 1 つの canvas に対して、切替ボタンでフラグメントシェーダーを
// 差し替える。この章で 1 つだけ増えているのが、4 本すべてが読み込む noise.glsl を
// 配るための `#include` の解決(仕組みは第23章と同じ)。
import { startFullscreenShader } from '../../lib/fullscreen-shader';
import hashSource from './01-hash.frag?raw';
import valueNoiseSource from './02-value-noise.frag?raw';
import gradientNoiseSource from './03-gradient-noise.frag?raw';
import compareSource from './04-compare.frag?raw';
import noiseChunkSource from './noise.glsl?raw';
// 置換文字列を関数で渡すのは、String.replace が `$&` などを特別扱いするため(第23章)
function resolveIncludes(source: string): string {
return source.replace('#include "noise.glsl"', () => noiseChunkSource.trim());
}
const demos = [
{ id: 'hash', label: 'ハッシュ 2 種', source: resolveIncludes(hashSource) },
{ id: 'value', label: '補間関数の比較', source: resolveIncludes(valueNoiseSource) },
{ id: 'gradient', label: 'Perlin の骨格', source: resolveIncludes(gradientNoiseSource) },
{ id: 'compare', label: 'value と gradient', source: resolveIncludes(compareSource) },
] 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 が用意しているノイズはすべて CPU (JavaScript) 側です。シェーダーの中で使いたければ、この章のように自分で書くことになります。
| three.js | この章 |
|---|---|
MathUtils.seededRandom(s) | Mulberry32 という状態を持つ疑似乱数生成器。GPU には載らない形(1 節)。ただし内部でやっているMath.imulとシフト・XOR の組み合わせはpcg3dと同じ道具立てで、最後に/ 4294967296するところもhash21と同じ |
SimplexNoise(three/addons/math/SimplexNoise.js) | CPU 側の JS クラス。コンストラクタでMath.random()から 256 要素の置換表を作るので、リロードのたびに模様が変わります(カスタム乱数を渡せば固定できます)。noise(x, y)の中身は simplex noise で、この章の正方格子とは格子の形自体が違う別系統。GLSL では使えません |
ShaderMaterial の中でノイズが欲しいとき | この章のnoise.glslをそのまま貼れます。three.js は生成するシェーダーの先頭にprecision <p> int;を注入していて(WebGLProgram.jsのgeneratePrecision)、<p>の既定は'highp'です。この 1 語が落ちるのはWebGLCapabilitiesのgetMaxPrecisionがgetShaderPrecisionFormat(…, HIGH_FLOAT)で 0 を返したときですが、GLSL ES 3.00 ではフラグメントシェーダーの highp サポートが必須なので(第5章 7 節)、WebGL2 でこの判定が落ちることはありません。実際にmediumpになるのは、利用者がnew THREE.WebGLRenderer({ precision: 'mediump' })と自分で指定したときです(three.js r185 で確認) |
RawShaderMaterial で生の GLSL を書くとき | WebGLProgram.jsはisRawShaderMaterialのときだけgeneratePrecisionを呼びません。#versionと#define SHADER_TYPE/SHADER_NAMEしか前置されないので、precision行は全部自分で書く必要があります。この章の.fragと同じ 2 行(highp float/highp int)を先頭に置いてください |
THREE.DataTextureにノイズを焼いて貼る | CPU でノイズを作ってテクスチャに載せる古典的な手法(第16章のtexImage2Dに相当)。解像度で頭打ちになる代わりに毎フレームの計算は不要。この章は逆に「毎フレーム計算するが解像度の上限が無い」側 |
uniform1ui / uniform2ui 相当 | three.js のuniformsにはシェーダー側の型宣言から自動で選ばれる。生の WebGL2 では第4章の表のとおり自分で選ぶ(シードを送るならここ) |
手元で動かして、壊してみる
この章のコードはsrc/lessons/25-random-and-noise/にあります。ノイズは「1 箇所いじると絵が丸ごと変わる」ので、変えた場所と結果の対応をつけながら進めてください。
- まず精度の落とし穴を体験する— どれか 1 本の
.fragからprecision highp int;の行を消す。多くのデスクトップ GPU ではmediumpでも 32 ビットで計算されるため何も変わらない可能性が高いのですが、それこそがこの落とし穴の怖さです(手元で再現しないバグ)。スマートフォンでも開けるなら、そちらで見比べてください 01-hash.fragでポインタを上へ動かす — 左半分がブロック状に潰れていく境目の高さを探す。次にMAX_EXPを 24.0 から 20.0 に下げて、いちばん上まで動かしても右半分が崩れないことを確かめる01-hash.fragの43758.5453を43.7585453に変える — 増幅が 1000 分の 1 になります。これは両刃です。9 万セルで測ると、相異なる値は 4,916 → 83,233 通りに増えて 3 節 (3) の粗さは消える一方、隣接セルの相関は (+1, 0) で 0.0024 → 0.0286、(+1, +1) で -0.0032 → -0.0914 と 10〜30 倍に上がります。43758.5453 という大きな数はこのトレードオフの片側に振り切った値だったわけです02-value-noise.fragのinterpolateのmode == 2の行をt * t(両端の傾きが 0 と 2)に差し替える — 片側だけ滑らかな補間がどう見えるか。上段では気づけず、下段の勾配表示ではっきりします02-value-noise.fragのGRAD_SCALEを 0.3 くらいまで下げる — 下段が飽和して、勾配が「ちょうど 0」の場所だけが黒く残ります。3 列目と 4 列目で黒い帯の太さが違うことが見えるはずです03-gradient-noise.fragのrandomGradientの呼び出しを、本文のbadGradient(正方形から取ってnormalize)に差し替える — 勾配ベクトルの矢が斜め方向に集まり、模様も斜めに伸びます。表の 1.448 という数字の正体です03-gradient-noise.fragのgradientNoise(q)をvalueNoise(q) - 0.5に差し替える — 白い「0 の等高線」が格子点を通らなくなり、格子点のまわりを回るようになります。6 節の主張の裏返しの確認です04-compare.fragのGRADIENT_TO_01を 0.5 にする — 下段のコントラストだけが落ちます。7 節の「どちらを使ったか忘れないこと」の実地確認です04-compare.fragのCONTOURSを 30.0 に上げる — 等高線が密になり、上段では格子点が「同心円の中心」に、下段では「線が素通りする点」になっていることがよりはっきりします
まとめ
- GPU に状態を持つ乱数生成器は置けない(第1章「各要素は互いの結果に依存できない」)。代わりに座標 → 値のハッシュを使う。決定的であることは制約ではなく利点
fract(sin(dot(p, k)) * 43758.5453)は分布も隣接相関も悪くない。問題は再現性— sin の精度が仕様上未定義(§4.5.1)、a * b + cの丸め方が 2 通り許されている(FMA)、そして 43758 倍で小数部が 8 ビット程度しか残らない- 整数ハッシュ
pcg3d(Jarzynski & Olano 2020)はuintの桁あふれ(§4.1.3 の「下位 n ビットに wrap」)を混ぜる力として使う precision highp int;は必須。precision highp float;はint/uintに効かない(§4.5.4)。書き忘れると 16 ビット実装でv >> 16uが未定義になり(§5.9)、ハッシュ値が 1.53 × 10⁻⁵ 以下に潰れて絵が消える。第21章 6 節のサンプラと同じ構図。uvec2(floor(p))は負の座標で未定義(§5.4.1)なのでivec2を経由する- value noise は格子点の乱数を補間する。補間関数は両端で傾きが 0 でなければ折れ目が出る(線形の跳びは 720 万サンプルの実測で平均 0.470)。5 次の
fadeは 2 階微分まで 0 なので、ノイズを微分して使うときに差が出る - gradient noise は格子点に勾配ベクトルを置き、変位との内積を補間する。格子点の上では必ず 0。極値がセル内に散るので格子が見えにくい。勾配ベクトルは角度から作る(正方形 +
normalizeは斜め 45 度が 1.448 倍に偏る) - 値域は
valueNoiseが 0〜1、gradientNoiseが約 -0.705 〜 +0.702(理論上限 √2/2)。0〜1 に写すなら* 0.70711 + 0.5か* 0.5 + 0.5
次章第26章「fbm とドメインワーピング — 雲・大理石・有機的な模様」では、この章の 1 オクターブのノイズを部品として、周波数を倍々にしながら振幅を減らして重ねるfbmを組みます。そのうえで、第10章で名前を付けた「ドメイン変形」を一歩進め、座標そのものをノイズで歪めるドメインワーピングへ進みます。この章で押さえた値域(0〜1 と ±0.7)が、そこでの足し算の設計に そのまま効いてきます。