ITエンジニア勉強ブログ

自分が学んだことを共有していきます。

花畑のプロシージャル生成

花畑をプロシージャル生成するJavaScriptプログラムを作成しました。


See the Pen
Flower Garden
by Imai (@imai1)
on CodePen.


アルゴリズムの解説

花の配置

自然に見えるよう描画時に位置を崩していますが、データ構造的には単に碁盤状のマス目に花を配置しているだけです。ただし、各マス毎に一様ランダムに花または空白をサンプリングするとホワイトノイズ画像のような配置になってしまうため、同種の花がカタマリを作るよう工夫を加えました。

はじめに各マスにランダムに花を配置して、マルコフ連鎖モンテカルロ法のように状態遷移を繰り返し、良い具合にカタマリが作られた最後の状態を描画します。

状態遷移にはシェリングの分居モデルを利用しました。異人種のエージェントが引越しを繰り返すことでクラスタを作って居住するというエージェントシミュレーションモデルです。詳しい解説は以下の記事をご覧いただけたら嬉しいです。

imai1.hatenablog.com


コード内ではSegregationModelというクラスがこの処理を担当します。このクラスも上記のリンク先で紹介したコードそのままですので、解説は上記の記事をご覧ください。

描画処理

まずは、花の画像用のImageオブジェクトを用意します。

function loadImages(srcs) {
  const promises = [];
  for (const src of srcs) {
    promises.push(new Promise((resolve, reject) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.src = src;
    }));
  }
  return promises;
}

const promiseLoadingImages = loadImages([
  "data URLを列挙。長いので省略。"
]);

Image.srcの設定はPromiseで非同期処理を行います。

配置モデルをシミュレーションして、画像の読み込みも終わったら、drawModelToCanvascanvasに描画します。

// モデルを初期化する。
const model = new SegregationModel(20, 20);
model.initializeMap(new ProbabilityDistribution([0.15, 0.15, 0.15]));

// 描画前に収束させる。
const threshold = 0.6;
for (let i = 0; i < 100; ++i) {
  model.update(threshold);
}

// Imageの読み込みが完了したらcanvasに描画する。
Promise.all(promiseLoadingImages).then(imgs => {
  drawModelToCanvas(model, canvasId, cellWidth, cellHeight, noiseSize, imgs);
});

drawModelToCanvasの実装は以下の通りです。

function drawModelToCanvas(model, canvasId, cellWidth, cellHeight, noizeSize, imgs) {
  const canvas = document.getElementById(canvasId);
  const context = canvas.getContext("2d");

  context.clearRect(0, 0, canvas.width, canvas.height);

  for (let y = 0; y < model.height; ++y) {
    for (let x = 0; x < model.width; ++x) {
      const person = model.map[y][x];
      if (person != undefined) {
        const img = imgs[person];
        const width = cellWidth;
        const height = img.height * cellWidth / img.width;
        // 画像の左端が x * cellWidth + ノイズ
        const dx = x * cellWidth + noise(noiseSize);
        // 画像の下端が (y+1) * cellHeight + ノイズ
        const dy = y * cellHeight - (height - cellHeight) + noise(noiseSize);
        context.drawImage(img, dx, dy, width, height);
      }
    }
  }
}

大まかには、SegregationModel.mapを一マスの大きさがcellWidth×cellHeightのグリッドに描画しているだけです。

画像は横幅が必ずcellWidthになるようにアスペクト比を維持して拡大縮小します。

        const width = cellWidth;
        const height = img.height * cellWidth / img.width;

縦幅がcellHeightと異なる場合は、(ノイズ0において)画像の下端がマスの下端に一致する位置に描画します。これによって、cellHeightを画像より小さめに設定すると、データ構造状で上下に連続する花の画像が重ねて表示され、奥行き感が生まれます。

最後に、実際の描画位置にnoise関数でランダムな変位を加えて完成です。

        // 画像の左端が x * cellWidth + ノイズ
        const dx = x * cellWidth + noise(noiseSize);
        // 画像の下端が (y+1) * cellHeight + ノイズ
        const dy = y * cellHeight - (height - cellHeight) + noise(noiseSize);
        context.drawImage(img, dx, dy, width, height);

素材について

いらすとやさんからお借りしました。コード内に個別の画像のリンクがあります。

デフォルメ具合や色合いの揃った素材を集めないとあまり綺麗にならないので頑張って厳選しました。