INTERACTIVE STUDY / 02 — CPU + WEBGL

Terrain Lab.

数式から、風景をつくる。5つのアルゴリズムが生み出す地形を、触って比べる。

5つの生成式とソースコードを読む ↓

IMPLEMENTATION / EQUATIONS & SOURCE

入力から出力まで、コードで追う。

各節は「何を計算するか → 数式 → 実際のコード」の順に読めます。コードは動作する実装からの抜粋で、単独実行用ではありません。省略した周辺処理は参照元で確認できます。

01 / シードから高さへ:共通の合成処理

シード文字列を32bit整数に変換し、乱数列・置換表・特徴点を決定します。Diamond-Square以外は、グリッド座標をノイズの入力座標へ変換し、周波数を2倍、振幅を永続度p倍にしながらK層を合成します。fBmは独立したノイズの種類ではなく、この多重合成の方法です。

計算式
F(x,z) = [Σₖ₌₀ᴷ⁻¹ pᵏ N(qₖ)] / [Σₖ₌₀ᴷ⁻¹ pᵏ]
qₖ = (s · 2ᵏ · x/n + oₓ, s · 2ᵏ · z/n + o𝓏)

nは分割数、sはスケール、Kはオクターブ数、pは永続度。オフセットはPerlin/Simplex/Ridgedで(13.7, 9.3)、Worleyで(0, 0)。スケールを増やすと、表示領域内の起伏の数が増えます。

public/demos/terrain-lab/terrain.js : 165このサイトのビルド元
        for (let o = 0; o < p.octaves; o++) {
          let v =
            p.algorithm === "simplex"
              ? noise.simplex((x / n) * freq + 13.7, (z / n) * freq + 9.3)
              : p.algorithm === "worley"
                ? noise.worley((x / n) * freq, (z / n) * freq)
                : noise.perlin((x / n) * freq + 13.7, (z / n) * freq + 9.3);
          if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
          value += v * amplitude;
          total += amplitude;
          amplitude *= p.persistence;
          freq *= 2;
        }
該当箇所をGitHubで読む ↗

02 / Perlin:格子の勾配を補間する

4つの格子頂点で、seedから決まる勾配ベクトルgと、頂点からサンプル位置への変位dの内積を計算します。x方向、次にz方向へ補間して連続的な値を得ます。補間係数には5次式を使い、格子境界で変化が滑らかにつながるようにしています。

計算式
fade(t) = 6t⁵ − 15t⁴ + 10t³
lerp(a,b,t) = a + t(b−a)
N(u,v) = lerp(lerp(g₀₀·d₀₀,g₁₀·d₁₀,fade(u)),
              lerp(g₀₁·d₀₁,g₁₁·d₁₁,fade(u)),fade(v))

u,vはセル内の小数座標。勾配は8方向の候補から置換表で選びます。この実装では対角方向の勾配を単位長に正規化していません。

public/demos/terrain-lab/terrain.js : 40このサイトのビルド元
    perlin(x, y) {
      const ix = Math.floor(x),
        iy = Math.floor(y),
        X = ix & 255,
        Y = iy & 255;
      x -= ix;
      y -= iy;
      return lerp(
        lerp(
          grad(perm[X + perm[Y]], x, y),
          grad(perm[X + 1 + perm[Y]], x - 1, y),
          fade(x),
        ),
        lerp(
          grad(perm[X + perm[Y + 1]], x, y - 1),
          grad(perm[X + 1 + perm[Y + 1]], x - 1, y - 1),
          fade(x),
        ),
        fade(y),
      );
    },
該当箇所をGitHubで読む ↗

03 / Simplex:三角格子の3頂点を使う

入力座標をskewして三角格子のセルを選び、unskewで元の座標系に戻します。セル内の位置で三角形の向きを判定し、3頂点の寄与を加算。各頂点の影響は距離に応じて減衰させます。

計算式
F₂ = (√3−1)/2, G₂ = (3−√3)/6
aⱼ = max(0, 0.5 − dⱼₓ² − dⱼ𝓏²)
N(x,z) = 70 · Σⱼ₌₀² aⱼ⁴ (gⱼ · dⱼ)

dⱼは各頂点からの変位、gⱼは勾配。70はこの実装の振幅調整係数です。実装中のローカル変数zは減衰係数で、地形のz座標とは別です。

public/demos/terrain-lab/terrain.js : 72このサイトのビルド元
      let n = 0;
      for (const [dx, dy, xx, yy] of [
        [0, 0, x0, y0],
        [i1, j1, x0 - i1 + g, y0 - j1 + g],
        [1, 1, x0 - 1 + 2 * g, y0 - 1 + 2 * g],
      ]) {
        let z = 0.5 - xx * xx - yy * yy;
        if (z > 0) {
          z *= z;
          n +=
            z * z * grad(perm[((i + dx) & 255) + perm[(j + dy) & 255]], xx, yy);
        }
      }
      return n * 70;
    },
該当箇所をGitHubで読む ↗

04 / Ridged:ノイズのゼロ付近を尾根に変える

Perlinノイズを絶対値にして反転・二乗し、共通のfBm合成へ渡します。元の値が0に近い場所ほど高さが大きくなるため、丘の頂点とは異なる位置に稜線が現れます。このデモは層間の重みフィードバックを持たないRidged fBmです。

計算式
R(x,z) = (1 − |Nₚₑᵣₗᵢₙ(x,z)|)²
public/demos/terrain-lab/terrain.js : 172このサイトのビルド元
          if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
          value += v * amplitude;
          total += amplitude;
          amplitude *= p.persistence;
          freq *= 2;
        }
該当箇所をGitHubで読む ↗

05 / Diamond-Square:平均と乱数で再帰分割

四隅を乱数で初期化し、四隅の平均から中央点を生成(diamond)。次に上下左右の既知の値から辺の中点を生成(square)します。分割幅を半分、乱数の振幅をp倍にしながら繰り返します。境界は存在する近傍だけを平均し、反対側へラップしません。

計算式
Hnew = (Σᵢ₌₁ᵐ Hᵢ)/m + (U−0.5)Aₗ
A₀ = 0.8, Aₗ₊₁ = pAₗ, U ∈ [0,1)

mは利用可能な近傍数、lは分割段階。配列は(n+1)²点でn=2ᵏ。64/128/256分割に対応し、スケールとオクターブのUIは使用しません。

public/demos/terrain-lab/terrain.js : 129このサイトのビルド元
    for (let y = 0; y <= n; y += h)
      for (let x = (y / h) % 2 === 0 ? h : 0; x <= n; x += step) {
        let v = 0,
          c = 0;
        for (const [dx, dy] of [
          [-h, 0],
          [h, 0],
          [0, -h],
          [0, h],
        ])
          if (x + dx >= 0 && x + dx <= n && y + dy >= 0 && y + dy <= n) {
            v += at(x + dx, y + dy);
            c++;
          }
        set(x, y, v / c + (r() - 0.5) * amp);
      }
    amp *= rough;
  }
該当箇所をGitHubで読む ↗

06 / Worley:特徴点までの最短距離

整数セルごとに1個の特徴点をseedから生成します。サンプルを含むセルと周囲8セルの計9個を調べ、最も近い点までのユークリッド距離を使います。距離が小さい場所が凹地となり、セル境界付近に稜線が現れます。任意の特徴点配置に対する厳密な全探索ではなく、3×3近傍に限定したデモ実装です。

計算式
D(x,z) = minⱼ∈₃×₃ √((x−qⱼₓ)² + (z−qⱼ𝓏)²)
N(x,z) = min(D(x,z), 1.4) / 1.4

qⱼは特徴点。1.4でクリップしてスケール調整後、オクターブ合成に渡します。3×3の範囲外により近い点がある場合は捉えられません。

public/demos/terrain-lab/terrain.js : 87このサイトのビルド元
    worley(x, y) {
      let d = 9;
      const ix = Math.floor(x),
        iy = Math.floor(y);
      for (let a = -1; a <= 1; a++)
        for (let b = -1; b <= 1; b++) {
          const cx = ix + a,
            cy = iy + b,
            h = seedHash(`${seed}:${cx}:${cy}`),
            r = rng(h),
            dx = cx + r() - x,
            dy = cy + r() - y;
          d = Math.min(d, Math.hypot(dx, dy));
        }
      return Math.min(d, 1.4) / 1.4;
    },
該当箇所をGitHubで読む ↗

07 / 正規化・島・3Dメッシュ・PNGの対応

地形全体の最小・最大値で高さを0〜1へ正規化してから、中心からの距離に応じて高さを減らします。3D表示は平面の頂点に高さ倍率Aを掛けて設定し、法線を再計算します。ハイトマップはこの倍率を含まない8bitグレースケールで、メッシュそのもののエクスポートではありません。

計算式
h₀ = (F−Fmin)/(Fmax−Fmin)  [分母0 / zero denominator → 1]
d = √((2x/n−1)² + (2z/n−1)²)
h = max(0, h₀ − 0.5d^(2.4))  [島ON / island on]
y₃ᴅ = Ah, ywater = A · sea, gray = round(255h)

島OFFではh=h₀。地形ごとの正規化により、異なる手法の絶対振幅の大小は比較できません。128分割なら129²=16,641頂点、2×128²=32,768三角形です。

public/demos/terrain-lab/terrain.js : 181このサイトのビルド元
  let lo = Infinity,
    hi = -Infinity;
  for (const h of a) {
    lo = Math.min(lo, h);
    hi = Math.max(hi, h);
  }
  for (let z = 0; z <= n; z++)
    for (let x = 0; x <= n; x++) {
      const i = z * (n + 1) + x;
      let h = (a[i] - lo) / (hi - lo || 1);
      if (p.island) {
        const d = Math.sqrt(((x / n) * 2 - 1) ** 2 + ((z / n) * 2 - 1) ** 2);
        h = Math.max(0, h - Math.pow(d, 0.0 + 2.4) * 0.5);
      }
      a[i] = h;
    }
該当箇所をGitHubで読む ↗

08 / CPU生成とGPU描画を分ける

Workerが高さ配列を計算し、Float32Arrayのバッファを転送してメインスレッドへ渡します。メインスレッドでThree.jsの頂点・色・法線を更新し、WebGLで描画します。生成時間はハイトフィールド計算のみで、Worker間通信・メッシュ構築・GPU描画を含みません。

public/demos/terrain-lab/worker.js : 1このサイトのビルド元
import { generateTerrain } from "./terrain.js";
onmessage = ({ data: { id, params } }) => {
  try {
    const result = generateTerrain(params);
    postMessage({ id, ...result }, [result.heights.buffer]);
  } catch (e) {
    postMessage({ id, error: e.message });
  }
};
該当箇所をGitHubで読む ↗

METHOD / HOW TO EXPLORE

シードを固定し、違いを見る。

Perlin・SimplexはfBmで多重合成。RidgedはPerlinを絶対値・反転・二乗して合成します。Diamond-Squareは再帰分割、Worleyは特徴点への最短距離(F1)から高さを計算します。各地形の高さは0〜1に正規化されるため、形状の比較を目的としています。

同じシードと設定から同じ地形を再現できます。高さ・海面・島のフォールオフを調整し、3D画像や8bitハイトマップをPNGで保存できます。Diamond-Squareではスケール・オクターブ数を使用しません。

生成時間はWorker内でのハイトフィールド計算のみ。メッシュ構築・描画時間は含みません。FPSは描画性能の参考値で、いずれも端末・ブラウザー・設定に依存します。既存C++作品やゲームの実測値ではありません。

デモのソースコード ↗