INTERACTIVE STUDY / 02 — CPU + WEBGL
Terrain Lab.
数式から、風景をつくる。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)。スケールを増やすと、表示領域内の起伏の数が増えます。
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;
}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方向の候補から置換表で選びます。この実装では対角方向の勾配を単位長に正規化していません。
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),
);
},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座標とは別です。
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;
},04 / Ridged:ノイズのゼロ付近を尾根に変える
Perlinノイズを絶対値にして反転・二乗し、共通のfBm合成へ渡します。元の値が0に近い場所ほど高さが大きくなるため、丘の頂点とは異なる位置に稜線が現れます。このデモは層間の重みフィードバックを持たないRidged fBmです。
R(x,z) = (1 − |Nₚₑᵣₗᵢₙ(x,z)|)² if (p.algorithm === "ridged") v = (1 - Math.abs(v)) ** 2;
value += v * amplitude;
total += amplitude;
amplitude *= p.persistence;
freq *= 2;
}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は使用しません。
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;
}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.4qⱼは特徴点。1.4でクリップしてスケール調整後、オクターブ合成に渡します。3×3の範囲外により近い点がある場合は捉えられません。
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;
},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三角形です。
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;
}08 / CPU生成とGPU描画を分ける
Workerが高さ配列を計算し、Float32Arrayのバッファを転送してメインスレッドへ渡します。メインスレッドでThree.jsの頂点・色・法線を更新し、WebGLで描画します。生成時間はハイトフィールド計算のみで、Worker間通信・メッシュ構築・GPU描画を含みません。
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 });
}
};METHOD / HOW TO EXPLORE
シードを固定し、違いを見る。
Perlin・SimplexはfBmで多重合成。RidgedはPerlinを絶対値・反転・二乗して合成します。Diamond-Squareは再帰分割、Worleyは特徴点への最短距離(F1)から高さを計算します。各地形の高さは0〜1に正規化されるため、形状の比較を目的としています。
同じシードと設定から同じ地形を再現できます。高さ・海面・島のフォールオフを調整し、3D画像や8bitハイトマップをPNGで保存できます。Diamond-Squareではスケール・オクターブ数を使用しません。
生成時間はWorker内でのハイトフィールド計算のみ。メッシュ構築・描画時間は含みません。FPSは描画性能の参考値で、いずれも端末・ブラウザー・設定に依存します。既存C++作品やゲームの実測値ではありません。
デモのソースコード ↗