GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

パーリンノイズ地形9選 — ライブラリなし、計算だけで

パーリンノイズとは、隣り合う値がなめらかにつながる乱数のことで、山・雲・海・星空といった自然の形を数式だけで描き出す元になる素材です。この9種をノイズ用ライブラリを一切使わずCanvas 2Dに直接実装しました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

なぜこの順番か

並びは見栄えの順ではありません。ノイズが地形へ積み上がっていく順です。まずノイズを1本の高さとして読む断面が生まれ(01・04)、同じ場を2次元に広げると地図になり(02・08)、空に掛けると流れる現象になります(03・05・09)。最後に鋭い変形と掘られた空間で地形そのものに起伏がつきます(06・07)。近所の記事では、シード1つで模様を引き直すCSS背景パターンと、ピクセルを直接計算するキャンバス特殊効果がこの記事の隣人です。

項目別コード

01地形断面 — fbmの稜線シルエット

アウトドア・登山LPのヒーロー背景に使う、オクターブ4重のfbmで描いた稜線のシルエット1本です。

地形断面 — fbmの稜線シルエット
新しいタブで開く

01 · 地形断面 — fbmの稜線シルエット — 計測 累積面積 31.644% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fbmlineTorequestAnimationFrame

稜線はなぜ重ねて作るのか

var OCT = 4;   // オクターブ4重

// fbm — 周波数2倍・振幅半分で4重に積む。オクターブごとに自分の位相を別々に流す
// (i+1) 倍で押すため高周波のオクターブほど速く流れ、稜線が部位ごとに削り直される
function fbm1(x, sec, flow) {
  var sum = 0, amp = 0.5, norm = 0, f = 1;
  for (var i = 0; i < OCT; i++) {
    var phase = sec * flow * (i + 1);
    sum += vnoise1(x * f + phase) * amp;
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return sum / norm;
}

遠景と近景の2枚

ノイズ1枚だけでは太い丘1つにしかならず稜線になりませんが、周波数を2倍に上げ振幅を半分に下げた層を4回積むと、大きな山脈の上に細かい起伏が乗ります。各オクターブの位相は時間の (i+1) 倍の速さで流れるので、稜線は部位ごとに少しずつ彫り直されます。遠景・近景の2つのシルエットは、同じ fbm1 にスケールと流れだけ変えた重ねです(実測: 累積面積31.64パーセント、23/23フレームで動き)。

02等高線マップ — 標高帯付きキャンバス

旅行・不動産・気象ダッシュボードの地図カードに合う、標高帯5段と細い等高線を重ねた地図です。

等高線マップ — 標高帯付きキャンバス
新しいタブで開く

02 · 等高線マップ — 標高帯付きキャンバス — 計測 累積面積 42.324% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataputImageDatafbm

等高線はどうやってピクセルから出るのか

var MW = 160, MH = 100;                 // 高さマップのバッファ解像度 — 画面が大きくなってもこの値は変わらない
var buf = sctx.createImageData(MW, MH); // 1回だけ作って毎フレーム同じ配列を上書きする
var px = buf.data;
var heights = new Float32Array(MW * MH); // 閾値線の検査に使い回すため高さを1フレーム分残しておく

var SCALE = 0.052;          // 格子間隔 — 小さいほど山と谷が太くなる(実測チューニング)
var FLOW = 0.5;             // 時間軸が流れる速さ — 山脈が膨らむ速さ
var OCT = 5;                // fbmのオクターブ数 — 多いほど等高線が細かくなる
var LINE_LEVELS = 11;       // 等高線の閾値区分(色帯5段とは別 — 地図に見せるには細かくする)

marching squares を使わない境界検出

2次元fbmの高さマップを160×100ピクセルのバッファにピクセルごと計算し、標高帯5段の色を塗ります。同じ高さマップを11段の閾値で割り直し、隣接8マスと等級が違う境界ピクセルだけ濃く上書きすると、marching squares なしでも等高線が現れます。時間はノイズの第3軸として流れるので山脈がゆっくり膨らみ、等高線も一緒に引き直されます。バッファは16,000ピクセルに固定し、画面の大きさは拡大補間に任せるので、ウィンドウを広げても計算量は変わりません(実測: 累積面積42.32パーセント)。

03雲フィールド — 流れるfbm雲の層

天気アプリ・旅行サイトの背景装飾に使う、ゆっくり流れていく雲の層です。

雲フィールド — 流れるfbm雲の層
新しいタブで開く

03 · 雲フィールド — 流れるfbm雲の層 — 計測 累積面積 35.34% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataputImageDatarequestAnimationFrame

値ノイズとパーリンノイズは何が違うのか

function lp(a, b, t) { return a + (b - a) * t; }
function gdot(h, x, y, z) { var i = (h & 255) * 3; return GRAD[i] * x + GRAD[i + 1] * y + GRAD[i + 2] * z; }

function noise3(x, y, z) {
  var xf0 = Math.floor(x), yf0 = Math.floor(y), zf0 = Math.floor(z);
  var xf = x - xf0, yf = y - yf0, zf = z - zf0;
  var X = xf0 & 255, Y = yf0 & 255, Z = zf0 & 255;
  var u = fade(xf), v = fade(yf), w = fade(zf);
  var A = PERM[X] + Y, AA = PERM[A] + Z, AB = PERM[A + 1] + Z;
  var B = PERM[X + 1] + Y, BA = PERM[B] + Z, BB = PERM[B + 1] + Z;
  return lp(
    lp(lp(gdot(PERM[AA], xf, yf, zf),             gdot(PERM[BA], xf - 1, yf, zf), u),
       lp(gdot(PERM[AB], xf, yf - 1, zf),         gdot(PERM[BB], xf - 1, yf - 1, zf), u), v),
    lp(lp(gdot(PERM[AA + 1], xf, yf, zf - 1),     gdot(PERM[BA + 1], xf - 1, yf, zf - 1), u),
       lp(gdot(PERM[AB + 1], xf, yf - 1, zf - 1), gdot(PERM[BB + 1], xf - 1, yf - 1, zf - 1), u), v),
    w);
}

格子点に置くのは乱数ではなく方向

値ノイズは格子の頂点ごとに乱数を1つ置いてその間を補間しますが、パーリンノイズは格子点ごとに方向ベクトル(グラディエント)を置き、サンプル点までの距離ベクトルとの内積を取ります。格子の中心で値がちょうど0になる性質のおかげで模様に流れが生まれ、雲のようにゆらぐ表現には値ノイズより向いています。雲量のダイヤルを回すと空は快晴から曇天へ切り替わり、雲の縁が濡れたように滲みます(実測: 累積面積35.34パーセント)。

04海のうねり — ノイズ波高断面

海洋・サーフィン・リゾートLPの背景に使う、規則なく上下する海の断面です。

海のうねり — ノイズ波高断面
新しいタブで開く

04 · 海のうねり — ノイズ波高断面 — 計測 累積面積 29.956% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fbmquadraticCurveTorequestAnimationFrame

波はなぜサイン波ではなくノイズなのか

// 層1つの波高 y 座標 — fbm値(0~1におおよそ寄る)のコントラストを広げて base の上下に揺らす
function swellY(x, sec, cfg) {
  var n = fbm1(x * cfg.scale + cfg.offset, sec, cfg.flow, cfg.oct);
  n = n * 2 - 0.5;                    // 値ノイズは中央に寄る — コントラストを一度広げる
  return cfg.base - n * cfg.amp;
}

// 点をノイズのサンプリング窓をパンしながら取り出し、quadraticCurveTo(中点をアンカーに)で
// つないで柔らかい波の断面を描き、キャンバスの底まで塗る。手前には薄い飛沫のハイライトを重ねる。
function drawSwell(sec, cfg, topColor, bottomColor, foam) {
  var step = 6;
  var pts = [];
  for (var x = 0; x <= W; x += step) pts.push([x, swellY(x, sec, cfg)]);

水平線・中景・近景の3枚

サイン波で波を描くと峰と谷が同じ間隔で戻ってくるのでプールの水面になります。横位置ごとのfbm値を波高として読むと、次の峰がどこでどれだけ立ち上がるか予測がつかなくなり、海らしくなります。点と点の中点をアンカーに quadraticCurveTo でつなぐと、ノイズの階段が滑らかな水面へほどけます。水平線・中景・近景の3層にスケールと流れの速さを変えて、遠いほどゆっくり穏やかに、近いほど速く荒れます(実測: 累積面積29.96パーセント、近景の最大面積12.54パーセント)。

05オーロラカーテン — 縦方向ノイズの光幕

夜景・イベント・冬キャンペーンのヒーローに使う、幕が揺れながら交わるオーロラです。

オーロラカーテン — 縦方向ノイズの光幕
新しいタブで開く

05 · オーロラカーテン — 縦方向ノイズの光幕 — 計測 累積面積 42.083% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataglobalCompositeOperationfbm

重なるほど明るくなる lighter 合成

function paintBand(b, z, cLow, cHigh) {
  var P = BP[b];
  var sig = P.sig, inv2 = 1 / (2 * sig * sig);
  var cut = 3.5 * sig;
  cpx.fill(0);
  var sway = new Float32Array(NH);      // 中心線の左右の寄り — 行ごとに1回サンプリングする
  for (var yy = 0; yy < NH; yy++) {
    var sv = fbm(P.ph * 0.13, yy * 0.02, z * P.spd, 3);
    sway[yy] = (sv - 0.5) * 2 * P.amp;
  }

光の足し算

function paint(sec) {
  var z = sec * FLOW;                 // 第3軸 = 時間
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  paintBack(z);
  ctx.globalCompositeOperation = 'source-over';
  ctx.drawImage(bg, 0, 0, NW, NH, 0, 0, W, H);
  // ③ 重ね — lighter は src+dst を足す。幕が重なる場所が自然に明るくなる
  ctx.globalCompositeOperation = 'lighter';
  for (var b = 0; b < NB; b++) {
    paintBand(b, z, b % 2 ? CB1 : CA1, b % 2 ? CB2 : CA2);
    ctx.drawImage(curt, 0, 0, NW, NH, 0, 0, W, H);
  }
  ctx.globalCompositeOperation = 'source-over';
}

6枚の幕はそれぞれ縦プロファイル(上が濃く下へ薄まる)にガウス断面を掛け、中心線をfbmで左右に揺すりながら縦のたわみを時間軸へ流します。重なる場所が自然に明るくなるのは光の足し算だからで、オーロラの物理と同じ方向です。フレームごとの最大面積は4.05パーセントと狭いものの、累積面積は42.08パーセント — 幕全体が常に編み直されているということです。

06砂丘の稜線 — リッジドノイズの砂丘

旅行・砂漠ツアー・ミニマルブランドの背景に使う、奥へ重なって引っ込む砂丘です。

砂丘の稜線 — リッジドノイズの砂丘
新しいタブで開く

06 · 砂丘の稜線 — リッジドノイズの砂丘 — 計測 累積面積 30.374% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fbmlineToglobalAlpha

絶対値を反転させると稜線が立つ

// リッジドfbm — vnoise1(0..1) を -1..1 に広げ、絶対値を反転(1-|n|) して稜線を尖らせる。
// オクターブごとに自分の位相を流し、稜線が時間とともに部位ごとに削り直される(01と同じパターン)。
function ridgedFbm1(x, sec, flow) {
  var sum = 0, amp = 0.55, norm = 0, f = 1;
  for (var i = 0; i < OCT; i++) {
    var phase = sec * flow * (i + 1);
    var n = vnoise1(x * f + phase);
    var s = n * 2 - 1;              // -1..1
    var r = 1 - Math.abs(s);        // リッジド — 境界(n=0.5)で1、谷で0
    sum += r * amp;
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return sum / norm; // 0..1
}

手前と奥で分ける globalAlpha

fbmをそのまま使うと峰が緩やかに丸まり丘になりますが、値を-1から1に広げて絶対値を反転させる (1-|n|) リッジド変形は、ノイズが符号を変える境界で1になるので、オクターブを積むほどその境界に沿って鋭い稜線が立ちます。前後2層を違う globalAlpha で塗り、奥の砂丘を陽炎のようにぼかして引っ込ませるのも遠近の計算です(実測: 累積面積30.37パーセント、強度52.6)。

07洞窟断面 — ノイズで掘られた空洞

探検・ゲーム・ダークファンタジーLPに使う、鍾乳石と石筍が下がる洞窟の断面です。

洞窟断面 — ノイズで掘られた空洞
新しいタブで開く

07 · 洞窟断面 — ノイズで掘られた空洞 — 計測 累積面積 31.048% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fbmquadraticCurveToclip

天井と床の間だけ残す clip

function paint(sec) {
  ctx.clearRect(0, 0, W, H);
  // 1) 岩の土台 — 画面全体を暗い岩のトーンで敷く
  var rock = ctx.createLinearGradient(0, 0, 0, H);
  rock.addColorStop(0, C_ROCK_TOP);
  rock.addColorStop(1, C_ROCK_BOTTOM);
  ctx.fillStyle = rock;
  ctx.fillRect(0, 0, W, H);

  // 2) 空洞 — 天井・床の2本のfbm曲線の間だけ clip で残す
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(0, ceilY(0, sec));
  for (var x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
  for (var x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
  ctx.closePath();
  ctx.clip();

鍾乳石が育つ場所もノイズが決める

天井の曲線はfbm、床の曲線も別オフセットのfbmなので、2本の間はノイズで掘られた空洞になります。clip が掛かった内側にだけ空洞の色と下から昇る採光・鍾乳石を描き、岩がまた覆います。鍾乳石と石筍はxマスごとにノイズをもう1つ読み、閾値を超える場所にだけその値で長さを決めて垂らすので、大きい物の横に小さい物が群れる本物の洞窟の模様になります。シードを変えると別の洞窟が掘られます(実測: 強度平均120.2 — 09の星空の179.8に次いで濃く変化)。

08河川侵食 — 最急降下の流線

地理・環境・データストーリーテリングの挿絵に使う、谷に沿って蛇行する川筋です。

河川侵食 — 最急降下の流線
新しいタブで開く

08 · 河川侵食 — 最急降下の流線 — 計測 累積面積 51.864% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fbmlineTorequestAnimationFrame

川筋はノイズではなく計算が描く

function trace(ix) {
  var path = [ix];
  var x = ix % GW, y = (ix / GW) | 0;
  for (var s = 0; s < MAX_STEP; s++) {
    var best = -1, bestH = heights[y * GW + x];
    for (var dy = -1; dy <= 1; dy++) {
      var ny = y + dy; if (ny < 0 || ny >= GH) continue;
      for (var dx = -1; dx <= 1; dx++) {
        if (dx === 0 && dy === 0) continue;
        var nx = x + dx; if (nx < 0 || nx >= GW) continue;
        var h = heights[ny * GW + nx];
        if (h < bestH) { bestH = h; best = ny * GW + nx; }
      }
    }
    if (best < 0) break;                 // 盆地に到達 — 川が湖で閉じる
    x = best % GW; y = (best / GW) | 0;
    path.push(best);
    if (y === GH - 1 || y === 0) break;  // 地図の外(海)へ出た
  }
  return path;
}

46×28の格子を下る水

46×28の格子ごとにfbmの高さを取って地形を塗り、水源7か所から始めて隣接8マスのうちもっとも低いマスへ1マスずつ下る最急降下の追跡を繰り返します。水路が谷に沿って蛇行するのはノイズの形ではなく、どこがより低いかという計算の結果です。水路は毎フレームキャッシュなしに最初から追跡し直され、ノイズの時間軸が流れて山が生まれ直すと水路も一緒に張り直ります(実測: 累積面積51.86パーセントでこの記事で最も広く動く)。

09星空 — ノイズ密度の星野

夜テーマ・ポートフォリオ・イベントカウントダウンの背景に使う、星の密度がノイズの模様に従う星空です。

星空 — ノイズ密度の星野
新しいタブで開く

09 · 星空 — ノイズ密度の星野 — 計測 累積面積 34.494% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataputImageDatafbm

星が生まれる場所も瞬きもノイズ

var D_SCALE = 0.09, D_FLOW = 0.32, D_THRESH = 0.60;    // 密度ノイズ — 星が生まれる閾値
var N_SCALE = 0.018, N_FLOW = 0.5;                     // 星雲ノイズ — 低周波(太い模様)、帯の境界がゆらぐ
var T_SCALE = 0.35, T_FLOW = 1.7;                      // 瞬きノイズ — 星ごとに違う位相で揺れる

均一乱数だと塩を撒いた紙になる

      // 密度 — 閾値を超えるマスにだけ星を乗せる(星の場所そのものがfbmの模様に従う)
      var d = fbm(x * D_SCALE + 500.7, dy + 233.9, zd, 3);
      if (d > D_THRESH) {
        // 瞬き — 星ごとに自分の座標をオフセットに使う別位相のノイズで明るさを揺らす
        var tw = fbm(x * T_SCALE + 811.2, y * T_SCALE + 47.6, sec * T_FLOW, 2);
        var edge = (d - D_THRESH) / (1 - D_THRESH);          // 閾値をどれだけ超えたか(0..1)
        var glow = Math.min(1, edge * 1.6 + tw * 0.6);
        r = STAR_DIM[0] + (STAR[0] - STAR_DIM[0]) * glow;
        g = STAR_DIM[1] + (STAR[1] - STAR_DIM[1]) * glow;
        b = STAR_DIM[2] + (STAR[2] - STAR_DIM[2]) * glow;
        stars++;
      }

均一乱数で星を撒くと宇宙ではなく塩を振った紙になります。密度ノイズが閾値を超えるマスにだけ星を置くと、星が群れと空白を作り天の川の模様が生まれ、各星の明るさは自分の座標をオフセットに使う別のノイズで揺れて瞬きます。背景の星雲も同じ方法の低周波fbmを22段の帯に分けて塗るので、画面全体が計算でゆらめきます(実測: 強度平均179.8 — 9種の中でもっとも濃く変化、フレームごとの面積は2.06パーセントと狭いが累積34.49パーセント)。

パーリンノイズ アルゴリズムはどうやって地形になるのか?

1つの関数、3つの読み方

記事全体を通して原理は1つです。ノイズは格子上の乱数を隣と滑らかにつなぐ関数にすぎません。その値を高さとして読むと稜線・砂丘・海になり、閾値で切ると等高線・雲・星の密度になり、もっとも低い隣をたどると川筋になります。

順序表と内積、それだけ

パーリンノイズ アルゴリズムの実体は、シードを混ぜた順序表1つと格子点ごとの内積計算数行で、外部ライブラリは要りません。同じシードを入れると同じ表が再生され、同じ地形が戻ります。すべての盤に付いたシード入力がこの再生の証明です。フレームごとに変わるのはノイズの第3軸(時間)だけで、計算は決定的なまま画面は生きています。

どこで壊れるか — 落とし穴

稜線が左右同じ形で繰り返された最初の盤

最初の盤の01稜線は、それらしい山を描いたあと、画面の外に出た位置で最初とまったく同じ山を描き直していました。順序表を256マスにして座標を & 255 で切り取ると、ノイズのサンプリング位置が256格子を1周すると値が最初に戻る周期性がそのまま現れたのです。オクターブごとにスケールを変えたので周期はずれて見た目は誤魔化せましたが、遠景と近景のオフセットが偶然にも周期の倍数の近くに来ると、2つのシルエットが同じ間隔で回るのが目につきました。根本の修正はオクターブ・層ごとに独立したオフセット(13.7、241.3のような無理数に近い値)を与えることでした。

等高線が標高帯の境界に貼り付く

等高線マップでも、塗り分けの閾値(5段)と線の閾値(11段)がずれないと等高線が標高帯の境界にそのまま張り付き、地図ではなく輪郭の図案になったことがあります。閾値の密度を色より細かく取り、8近傍検査で境界だけ拾う今の構造は、この修正の結果です。9枚の完成したソースとReact版は記事の下のパスワード付きzipに入っていて、開くパスワードはp2v2mgjzです。

3つのバリエーションとアクセシビリティ

名前 変える値 一言の感想
オクターブ数 02 等高線 5オクターブ → 8オクターブ 粗かった地形が細かく裂ける
シード 09 星空 シード 7 → 91 同じ規則で、まったく別の星座配置
色 $terrain-mint → $terrain-peach 冷たい地形のトーンから暖かい砂漠のトーンへ

reduced-motion でも地形は残る

アクセシビリティでは9枚が同じ道をたどります。prefers-reduced-motion: reduce を設定した利用者には rAF ループを開始せず、シードが決めた静的な1枚だけを描きます。01は稜線の完成したシルエット、02は等高線の引かれた地図、08は水路の張り終わった川筋で、この静的フレームでもシード入力は生きており、数値を変えると即座に別の地形1枚に描き直されます。ノイズ アニメーション が止まっても地形という結果が残るのが、この構造の要点です。

FAQ

パーリンノイズの実装をライブラリなしで直接書いても性能は足りますか?

足ります。ピクセルごとに計算する02・03・09もバッファを160×100(16,000ピクセル)に固定し、画面の大きさは拡大補間に任せるので、計算量はウィンドウの大きさと無関係です。9枚すべてで23コマのフレームが動いたことが実測で確認できており、rAF が渡す時刻でフレームを進めるため、遅い端末では同じ計算がゆっくり再生されるだけで積み上がりません。

ノイズ アニメーション 作り方を他の波形にも応用できますか?

できます。この記事の構造は、1次元の断面(01・04)→ 2次元の地図(02・08)→ 時間を第3軸にした流れ(03・05・09)という積み方なので、煙・霧・水流・炎のような流れる現象にも同じ3段階で取りかかれます。まず1本の曲線で外形を決め、次に面に広げ、最後に時間軸を足す、という順序です。

ソースコードをそのままコピーしても良いですか?

良いです。記事の下のパスワード付きzipに9枚のバニラ版とReact版がまるごと入っていて、貼り付ければすぐ動きます。色は各盤のscss冒頭にあるパステルトークン変数で入れ替えられ、シード入力に同じ数値を入れると同じ地形を毎回引き直します。

さらに読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。