Continue(s)

Twitter:@dn0t_ GitHub:@ogrew

p5.jsのnoise関数を自炊する。

f:id:taiga006:20201219000819j:plain

この記事はProcessing Advent Calendar 2020の19日目の記事です。

■ はじめに

12月になってこの度、五年付き合っている彼女と同棲を始めまして、平日の晩御飯は主に僕が担当しているんですが、如何せんもうすぐ27歳なのに人生でここまでほとんど料理をしてこなかったので日々悪戦苦闘しています。今一番欲しいのは家庭科の教科書です。

そんな生活の中でふと思うのが「どこまでやって自分の料理と言えるか?」と言う問題です。例えばカレーライス。市販のレトルトカレーを湯煎してご飯にかけても立派なカレーライスだし、スパイスからこだわって絶妙なバランスで配合して作ったカレーもまた立派なカレーライスです。

結局は心の持ちようなのですが、ことp5.jsを使って作品を作る身となったとき、そこにはすでに様々な便利な関数が用意されています。市販のレトルトカレーがすでに他の人が作ったソースコードのアレンジであるなら、(多少他からのインスパイアはあるにせよ)一からアイデアを考えて作った作品と言うのがハウス食品のバーモンドカレーといったところでしょうか。

今日はせっかくの企画なので、スパイスからこだわってみたいと思います。その中でも特にみんなが愛してやまないnoise関数を取り上げたいと思います。

だいぶ回りくどい前説でしたが、
Processing Advent Calender2020の19日目の企画名はズバリ!
「p5.jsのnoise関数を自作したいんじゃ!!」 です。

少し長くなりますが、のんびりやっていきましょう。
また、質問やおかしいところの指摘などはTwitterでメンションしてください。
最後に参考にさせていただいたサイト様へのリンクもまとめています。

■ noise関数とは?

そもそもnoise関数とは何でしょうか?
ここでは初心者の方向けに、よく勘違いされるrandom関数と比較して説明します。

random関数は字のごとくランダムな数値を返す関数です。引数の数によってその挙動が変わります。

  var r1 = random();
  var r2 = random(10,20);
  var r3 = random([1, 1, 2, 3, 5, 8, 13, 21]);
  
  print(r1);  // 0.5186563025200495
  print(r2);  // 13.759339618758705
  print(r3);  // 5

プロットするとこんな感じです。

  for (var i = 0; i < width; i++) {
    var r = random(-150,150);
    line(i, height/2, i, height/2+r);
  }

f:id:taiga006:20201216230200p:plain:w400

踏んだら痛そうです。
ではそれと比較してnoise関数とはなんなのでしょうか?ここではまず、公式のリファレンスから文章を引用します。

Returns the Perlin noise value at specified coordinates. Perlin noise is a random sequence generator producing a more naturally ordered, harmonic succession of numbers compared to the standard random() function.

要約すると「この関数は指定した座標でのPerlin noiseの値を返します。Perlin noiseとは標準のrandom関数に比べてより自然な並びで、より調和のとれた一連の数値を生成させるランダムシーケンスジェネレータです。」といった内容になります。「より自然な並びで、より調和のとれた」というのを確認してみたいと思います。

  var pre = 0;
  for (var i = 0; i < width; i+=10) {
    var l = noise(i)*300 - 150;
    line(i-10, height/2+pre, i, height/2+l);
    pre = l;
  }

f:id:taiga006:20201216232309p:plain:w400

わかりやすさのために線グラフのようにしました。random関数の結果を薄く表示していますが、違いが判るでしょうか?こっちも踏んだら痛そう。これが「より自然な並びで、より調和のとれた」noise関数、Perlin noiseの力です。

■ Perlin Noiseとは?

noise関数がどんなものか分かっていれば普段の創作活動はそれで十分です。ですが、今回はスパイスからこだわってオリジナルのルウを作りたいのです。ということで上にも出てきたPerlin Noiseというのを調べる必要が出てきます。ここでも、Wikipediaからその説明部分を引用してきます。

パーリンノイズ(英: Perlin noise)とは、コンピュータグラフィックスのリアリティを増すために使われるテクスチャ作成技法。(中略)パーリンノイズは (x,y,z) または (x,y,z,time) の関数として実装され、事前に計算された勾配に従って内挿を行い、時間的/空間的に擬似乱数的に変化する値を生成する。

なるほど、わかるようでわかりません。大丈夫です、ここでは「事前に計算された勾配」や「擬似乱数的に変化する」あたりが実装していくうえでポイントになりそうなことだけ押さえておけば十分です。

ちなみに余談ですがPerlin noiseを考えたKen Perlinさんはディズニー映画「トロン」の制作にあたってこのアルゴリズムを開発(正確にはそのプロト型はもっと以前に開発)し、それをまとめた論文でアカデミー科学技術賞を受賞しています。そのオリジナルとなるソースコードここで実際に見ることができます。

トロン:オリジナル(字幕版)

トロン:オリジナル(字幕版)

  • 発売日: 2013/11/26
  • メディア: Prime Video

ということで、ここからは実際に実装方法を考えていきます。

■ 今回のゴール

実装にあたって今回のゴールを定めておきます。Perlin noiseはその拡張性から4Dなどでも実装できますが、今回はわかりやすさのため2Dで考えていきます。

目標はこのようなノイズテクスチャを生成することです。

function setup() {
  createCanvas(400, 400);
  background(0);
  var n = 0;
  var noiseScale = 0.03;
  for (let w = 0; w < width; w++) {
    for (let h = 0; h < height; h++) {
      n = noise(w*noiseScale, h*noiseScale);
      stroke(n * 255);
      point(w, h);
    }
  }
}

f:id:taiga006:20201217003239p:plain:w400

■ 言葉での説明

今回は2Dで考えるので関数に渡されるX,Yは平面の座標を意味します。ここからの考え方はピクセルシェーダー(フラグメントシェーダー)の考え方のそれと同じです。

まず最初にするのは、平面を任意の大きさの正方形で分割することです。それから、入力された座標 (X,Y) がどの正方形ブロックに内包されているかに注目します。 f:id:taiga006:20201218004830j:plain:w500

そして、ポイントとなるのが「正方形をなす4つの頂点それぞれが疑似乱数的勾配ベクトルを持つ」というところです。

f:id:taiga006:20201218005528j:plain:w500

おそらく聞きなれない「勾配ベクトル」というキーワードでさじを投げてしまう人がいるかもしれませんが、難しく考える必要はなく、つまりこれは各頂点が固有のベクトルを持っていることにすぎません。

正確性を欠いた説明をすればこのベクトルは「それぞれの整数座標においてどの方向にどれほど暗かったり明るかったりを制御できるか」を意味します。

そうなると、次に考えるべきなのは、座標 (X,Y) が周囲の4つの整数座標からどれくらいの距離にあるのかということです。

f:id:taiga006:20201218005659j:plain:w500

距離ベクトルは(今見ている点の座標)ー(4点の座標)で計算できます。

察しの良い人であれば、これを求めることで「先ほどの勾配ベクトルによって4点からそれぞれどれくらい影響を受けるのか」を計算することができることに気が付くでしょう。

最初の勾配ベクトルと後に出てきた距離ベクトルの間の内積から4点それぞれからの影響度合いを求めます。

内積は(これも雑な説明ですが)2つのベクトルがどれほど似ているかを考えるのに便利です。

こうして今見ている (X,Y) 座標に対して周囲の4つの整数座標がどれほどの影響度合いを持つのかを求めることができました。

求めた4つの影響度合いから加重平均を求めて最終的な値(=グレースケール)を決めます。

f:id:taiga006:20201218224156j:plain:w500

ここで先回りの説明となってしまうのですが、単純に線形補間(足して2で割る)をするとあまりきれいなノイズにはなりません。Perlin Noiseは「より自然な並びで、より調和のとれた」結果でなければなりませんので、そのためにEase-in, Ease-out(緩やかに持ち上がって、ゆるやかに落ち着くよう)な関数を用意します。

初期のPerlin Noiseの実装で使われていたのは -2t^3 + 3t^2 という関数でしたが現在広く知られている改良版では 6t^5 - 15t^4 + 10t^3 が使われています。

f:id:taiga006:20201218010339p:plain:w500

グラフで見るとそれぞれこのような関数です。0,1付近でのEase-In,Ease-Outな感じがわかりますね。

この非線形な補間に使う関数はfade関数として実装しますが、それを使うか使わないかの違いも後ほど比較してみましょう。

言葉での説明は以上となります。ここからは実際にp5.jsを使って実装をしていきます。

■ 実装①

function perlin(x, y) {
  // (x,y)座標がどの正方形ブロックに入っているか
  var xi = floor(x);
  var yi = floor(y);
  // 左下の座標からの距離
  var xf = (x - xi)*1.0;
  var yf = (y - yi)*1.0;
  
  // 周囲の4つの整数座標ごとの影響値
  // (勾配ベクトルと距離ベクトルの内積)
  // gXX ... 各点の勾配ベクトル
  // sXX ... 各点から今見ているピクセル座標を指すベクトル
  // vXX ... gXXとsXXの内積(ドット積)
  var g00 = grad(xi, yi);
  var s00 = createVector(xf, yf);
  var v00 = p5.Vector.dot(g00, s00);
  var g10 = grad(xi + 1.0, yi);
  var s10 = createVector(xf - 1.0, yf);
  var v10 = p5.Vector.dot(g10, s10);
  var g01 = grad(xi, yi + 1.0);
  var s01 = createVector(xf, yf - 1.0);
  var v01 = p5.Vector.dot(g01, s01);
  var g11 = grad(xi + 1.0, yi + 1.0);
  var s11 = createVector(xf - 1.0, yf - 1.0);
  var v11 = p5.Vector.dot(g11, s11);
  
  // より自然な変化を見せるための補正
  var ux = fade(xf)*1.0;
  var uy = fade(yf)*1.0;
  
  // 上辺側の2点(00, 10)と下辺側の2点(01, 11)それぞれでの影響値の平均
  var v0010 = lerpF(v00, v10, ux);
  var v0111 = lerpF(v01, v11, ux);
  // 最終的な周囲の4点からの影響値の平均
  var vAve = lerpF(v0010, v0111, uy);
  
  return vAve;
}

noise関数はp5.jsにすでに用意された関数名なので今回は perlin という名前の関数を作ります。

上の実装はまさに前節で説明したロジックそのままとなっています。コメントを多めに残しているのでわかりやすいと思います。ただし格子点がそれぞれ持つ固有な(疑似乱数的)勾配ベクトルを求める grad と先ほど説明した補間に使う fade 関数はまだ用意していません。例外として lerpF 関数についてはp5.jsには lerp 関数がもともとあるんですがベクトル同士の線形補間にしか使えないので今回はいわゆる実数で使うlerpを別で用意しました。

function lerpF(a, b, t) {
  return a + t * (b - a);
}

これだけです。

それでは gradfade 関数を見ていきます。

■ 実装②

function fade(t) {
  if(easeFlg) {
    // 6t^5 - 15t^4 + 10t^3
    return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);    
  } else {
    // 3t^2 - 2t^3
    return t * t * (3.0 - 2.0 * t);
  }
}

今回は改良前後で使われていたfade関数の違いを後で確認するためにフラグで分岐できるようにしておきました。

それから grad 関数についてです。これは上でも説明したように整数座標それぞれでの疑似乱数的勾配ベクトルが求めたいです。そのためにはまずその勾配ベクトルとなるものを用意する必要があります。

var randomTable = [];
var permX = [];
var permY = [];
var tableSize = 255;

function setupRandomTable() {
  for(var i = 0; i < tableSize; i++) {
    var rx = random();
    var ry = random();
    var v1 = createVector(rx, ry);
    var v2 = createVector(1.0, 1.0);
    // -1 ~ +1
    v1 = v1.mult(2.0);
    v1 = v1.sub(v2);
    randomTable[i] = v1;
  }
  for(var j = 0; j < tableSize; j++) {
    permX[j] = j;
    permY[j] = j;
  }
  permX = shuffle(permX);
  permY = shuffle(permY);
}

function grad(a, b) {
  var pX = permX[int(a % tableSize)];
  var pY = permY[int((b + pX) % tableSize)];
  return createVector(randomTable[pY].x, randomTable[pY].y);
}

実は最初このgrad関数を考えるところでつまづき、結果として次のような画像しか生成できませんでした。

f:id:taiga006:20201218230515p:plain:w400

randomを使っているのになぜ?と思っていたのですが、これは上で言うとpermX,Yを考えることで解決しました。

波模様が出ていると言うことは繰り返しが発生しているわけで、それを防ぐためにはrandomな配列に対してそれを参照するインデックスも不規則な順序な必要があります。

この実装はこちらのaa_debdebさんのC#でのパーリンノイズの実装記事を参考にさせていただきました。

同じ目的のgrad関数には他にも様々な実装方法があります。シェーダー系でよく見るのは次のような関数を使ったものです。

        float random (fixed2 pos) { 
            return frac(sin(dot(pos, fixed2(12.9898,78.233))) * 43758.5453);
        }

他にも「perlin noise permutation」とかで検索するといろいろ出てきます。

■ 実装③

ここまでで、おおかたの実装は終わっています。では最初のnoise関数を今回作ったオリジナルのperlin関数に差し替えてみましょう。

function setup() {
  setupRandomTable();
  createCanvas(size, size);
  background(0);
  var n = 0;
  var noiseScale = 0.03;
  for (let w = 0; w < width; w++) {
    for (let h = 0; h < height; h++) {
      // n = noise(w*noiseScale, h*noiseScale);
      n = perlin(w*noiseScale, h*noiseScale);
      stroke(n * 255);
      point(w, h);
    }
  }
}

f:id:taiga006:20201217105236p:plain:w400

おっと、まだら模様のようなものが現れましたが残念ながら目標としているノイズテクスチャとはかけ離れていますね。これは用意したperlinの返り値に問題がありました。vAve は-1 ~ +1 の間の数値を取るのでこれを補正します。

  // return vAve;
  return (vAve+1.0) / 2.0;

結果はこうなります。 f:id:taiga006:20201217105636p:plain:w400

できました!! ちなみにですが、先走って説明したfade関数について、それを導入しなかった場合の結果も気になりますよね? と言うことで以下の行をコメントアウトして再実行します。

  //var ux = fade(xf)*1.0;
  //var uy = fade(yf)*1.0;
  var v0010 = lerpF(v00, v10, xf);
  var v0111 = lerpF(v01, v11, xf);
  var vAve = lerpF(v0010, v0111, yf);

f:id:taiga006:20201217105841p:plain:w400 一応それらしい結果にはなりましたが、よく見ると分割した正方形ブロックの境目部分が目立つようになってしまいました。これがfade関数の力です。

それから改良前後で使われていたfade関数の比較も見てみましょう。

3t2 - 2t3(改良前) 6t5 - 15t4 + 10t3(改良後)
f:id:taiga006:20201217114703p:plain:w300 f:id:taiga006:20201217114651p:plain:w300

うーーーん、どうでしょう。若干ですが、改良後の方が白黒がまばらな気がします。
しかし、ここまで作ってきた実装方法だと大きな違いはわかりません。

■ できたはできたけど...

これにてパーリンノイズの実装は完成なのですが、どうにも納得がいきません。
目標としていたノイズテクスチャと自作のテクスチャを並べてみます。

noise関数 perlin関数(自作)
f:id:taiga006:20201217003239p:plain:w300 f:id:taiga006:20201217105636p:plain:w300

noise関数の複雑な入り乱れに対して、自作の関数は全体的に「ぼやっとしている」感じがします。

これを解決するのがfBMです。

■ fBM(非整数ブラウン運動)とは何ぞや?

周波数を一定の割合で増加させる(lacunarity)と同時に振幅を減らしながら(gain)ノイズを(octaveの数だけ)繰り返し重ねることで、より粒度の細かいディテールを持ったノイズを作り出すことができます。このテクニックは「非整数ブラウン運動(fBM)」または単に「フラクタルノイズ」と呼ばれていて、最も単純な形は下記のコードのようになります。

みんなの教科書"The Book of Shaders"からの引用です。より詳しい説明はそちらに任せます。(非常にわかりやすいです。) thebookofshaders.com

つまり、パーリンノイズをfBMに食わせることによってより複雑な見た目のノイズを生成することができるわけです。

種明かしをしてしまうとp5.jsのnoise関数も内部ではfBMをさせています。そして、その複雑さを司るパラメータを制御する関数もあります。それが noiseDetail() 関数です。

noiseDetail() 関数は引数を2つとります。1つ目はoctave数(どれだけ周波数をあげるループをさせるか)、2つ目はfalloff(周波数をあげた際の信号への影響度合い)です。p5.jsではデフォルトでそれぞれ4と0.5になっています。つまり、元の信号に対して周波数を1, 2, 4, 8と高くさせ、その分寄与率は100, 50, 25, 12.5%みたいに下げていき、最終的にそれらを合算させているのです。(つまり理論的にはoctave数を増やせば疑似フラクタルな波形が描画できるはず…?)

では、それらを加味してfBMをperlin noiseに組み込んでいきます。

function fbm(x, y, octaves, falloff) {
  var sum = 0.0;
  var freq = 1.0;
  var amp = 1.0;
  var max = 0.0;
  for(let o=0; o<octaves; o++) {
    sum += perlin(x*freq, y*freq) * amp;
    max += amp;
    amp *= falloff;
    freq *= 2.0;
  }
  return sum/max;
}

function setup() {
  setupRandomTable();
  createCanvas(size, size);
  ...
      // n = perlin(w*noiseScale, h*noiseScale);
      n = fbm(w*noiseScale, h*noiseScale, 4, 0.5);
  ...
}

f:id:taiga006:20201217113508p:plain:w400

だいぶディティールの細かい結果を得ることができました。

元のnoise関数と比べると濃淡の差が大きく見えますがこれはおそらくgrad関数の実装の問題だと思われます。p5.js(やおそらくProcessingも)のgrad関数の実装はビット演算などを駆使して、今回の実装とはだいぶ違います。(気になる人はぜひ自分で読んでみてください。)

Turbulenceについて

fBMを少し改良するだけでまた違った印象のノイズを生み出すことができます。それがTurbulence(乱流)と呼ばれるものです。これはfBMで直接ノイズ関数を使用する代わりに、符号付きノイズ関数の絶対値を使用します。

function turbulence(x, y, octaves, persistence)
{
  var sum = 0.0;
  var freq = 1.0;
  var amp = 1.0;
  var max = 0.0;
  for(let o=0; o<octaves; o++) {
    // 絶対値を累算
    sum += abs(perlin(x*freq, y*freq)) * amp;
    max += amp;
    amp *= persistence;
    freq *= 2.0;
  }
  
  // いい感じにするための補正
  return sum/max*2.0;
}

f:id:taiga006:20201218210955p:plain:w400

いい感じにきもいのができました。これは雲模様や地形のプロシージャル生成などに応用されたりします。(確かな資料は見つけられませんでしたが、このノイズもPerlinさんが考えたらしいです。)

他にも少し改変させるだけでこんな模様を出せたりもします。
(どうやるかは考えてみてください。)

f:id:taiga006:20201218213949p:plain:w400

TouchDesignerのnoise TOP

p5.jsの話から逸れますが個人的に感動した話をします。僕が普段愛用しているTouchDesignerと言うノードベースのヴィジュアルプログラミング環境にはノイズを表現するオペレータがあります。それがNoise TOPと呼ばれるものです。

こちらでは先ほど出てきたoctaves数は Harmonics と、falloffは Roughness と言い換えられています。それから周波数をあげていく際の係数(上の実装では固定で2.0にしている部分)である Harmonic Spread といったパラメータもあります。

f:id:taiga006:20201218002809p:plain:w500

今回のように、あるツールでの理解度が上がる(ルウが作れるようになる)と他のツールの解像度も自然と上がっていく感じは非常に気持ちが良いです。

※TouchDesigner自体については2年前のKayacのAdvent Calenderで僕が長々と説明している記事があるのでそちらをご参照ください。

最終的なソースコード

var randomTable = [];
var permX = [];
var permY = [];
var tableSize = 255;
var size = 500;
var easeFlg = true; // swtich fade(ease) function

function setupRandomTable() {
  for(var i = 0; i < tableSize; i++) {
    var rx = random();
    var ry = random();
    var v1 = createVector(rx, ry);
    var v2 = createVector(1.0, 1.0);
    // -1 ~ +1
    v1 = v1.mult(2.0);
    v1 = v1.sub(v2);
    randomTable[i] = v1;
  }
  for(var j = 0; j < tableSize; j++) {
    permX[j] = j;
    permY[j] = j;
  }
  permX = shuffle(permX);
  permY = shuffle(permY);
}
function fade(t) {
  if(easeFlg) {
    // 6t^5 - 15t^4 + 10t^3
    return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);    
  } else {
    // 3t^2 - 2t^3
    return t * t * (3.0 - 2.0 * t);
  }
}

function grad(a, b) {
  var pX = permX[int(a % tableSize)];
  var pY = permY[int((b + pX) % tableSize)];
  return createVector(randomTable[pY].x, randomTable[pY].y);
}

function lerpF(a, b, t) {
  return a + t * (b - a);
}

function perlin(x, y) {
  var xi = floor(x);
  var yi = floor(y);
  var xf = (x - xi)*1.0;
  var yf = (y - yi)*1.0;

  var g00 = grad(xi, yi);
  var s00 = createVector(xf, yf);
  var v00 = p5.Vector.dot(g00, s00);
  var g10 = grad(xi + 1.0, yi);
  var s10 = createVector(xf - 1.0, yf);
  var v10 = p5.Vector.dot(g10, s10);
  var g01 = grad(xi, yi + 1.0);
  var s01 = createVector(xf, yf - 1.0);
  var v01 = p5.Vector.dot(g01, s01);
  var g11 = grad(xi + 1.0, yi + 1.0);
  var s11 = createVector(xf - 1.0, yf - 1.0);
  var v11 = p5.Vector.dot(g11, s11);

  var ux = fade(xf)*1.0;
  var uy = fade(yf)*1.0;

  var v0010 = lerpF(v00, v10, ux);
  var v0111 = lerpF(v01, v11, ux);

  var vAve = lerpF(v0010, v0111, uy);

  return (vAve + 1.0) / 2.0; // この辺、適宜補正してください
}

function fbm(x, y, octaves, persistence) {
  var sum = 0.0;
  var freq = 1.0;
  var amp = 1.0;
  var max = 0.0;

  for(let o=0; o<octaves; o++) {
    sum += perlin(x*freq, y*freq) * amp;
    max += amp;
    amp *= persistence;
    freq *= 2.0;
  }
  return sum/max;
}

function turbulence(x, y, octaves, persistence)
{    
  var sum = 0.0;
  var freq = 1.0;
  var amp = 1.0;
  var max = 0.0;
  for(let o=0; o<octaves; o++) {
    sum += abs(perlin(x*freq, y*freq)) * amp;
    max += amp;
    amp *= persistence;
    freq *= 2.0;
  }
  return sum/max;
}

function setup() {
  setupRandomTable();
  createCanvas(size, size);
  background(0);
  var n = 0;
  var noiseScale = 0.03;

  for (let w = 0; w < width; w++) {
    for (let h = 0; h < height; h++) {

      n = fbm(w*noiseScale, h*noiseScale, 4, 0.5);
      // n = turbulence(w*noiseScale, h*noiseScale, 4, 0.5);
      // n = perlin(w*noiseScale, h*noiseScale);

      stroke(n * 255);
      point(w, h);
    }
  }
}

function draw() {
}

さいごに

以上で「p5.jsのnoise関数を自作したいんじゃ!!」 の記事を終わります。

1週間ほどかけて調べながらまとめたので勘違いやミスなどあるかもしれませんが、書き終えた感想としては非常に楽しかったです。

なるほど、こんな気持ちでおじさんたちはよくわからんコリアンダーやらガラムマサラやらターメリックやらを購入してきて日がな一日カレーを煮込んでいるわけですね。少し気持ちがわかりました。

普段使っている機械や植物、食べ物や乗り物など表面的な理解さえあれば問題なく使いこなすことはできるかもしれませんが、一歩踏み込んで自分でやってみたり作ってみたりことでより豊かな生活が広がる、そういうことをnoise関数は僕に気が付かせてくれました。

それではまた明日のProcessing Advent Calender 2020をお楽しみに。さようなら。

参考リンク

★現在のp5.jsのnoise関数の実装
- processing/p5.js/noise.js at main