Perlinノイズで自然な地形を生成する — ランダムなのに美しい理由
このトピックを深掘りする
クリエイティブコーディング
ブラウザでの数学的シミュレーション・自然現象の可視化・生成アートの実装技法
全12本中 4 本目。
次に読む記事
波動方程式をブラウザで可視化する — 干渉・回折・共鳴の世界
1D・2D波動方程式を差分法で実装し、干渉・回折・共鳴パターンをCanvasで可視化する。WaveInterference、WaveEquation、ChladniPlateの描画手法と物理の対応を解説。
N体問題をブラウザで — 万有引力シミュレーションの実装
NBody、SolarSystem、GravityWellコンポーネントで使った万有引力計算、ベクトル演算、積分法、軌道描画の実装詳細。
Reaction-Diffusionのパラメータ空間を探索する — Gray-Scottモデルの4つの顔
Gray-Scott Reaction-Diffusionモデルのfeed/kill率を変えると、斑点・縞模様・珊瑚・細胞分裂と全く異なるパターンが現れる。200×200グリッドでの実装と探索記録。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
Math.random()ではダメな理由
Math.random() で地形の高さを決めると、隣接するセルの値が無関係になり、ギザギザの意味不明な地形になる。Perlinノイズは隣接値が滑らかに連続する乱数を生成する。
勾配ノイズの原理
2Dの場合、整数格子点にランダムな勾配ベクトルを割り当て、格子内の各点と格子点との距離ベクトルのドット積を補間する:
// 勾配ベクトル(8方向に限定)
const GRAD = [
[1, 1], [-1, 1], [1, -1], [-1, -1],
[1, 0], [-1, 0], [0, 1], [0, -1],
]
// ハッシュ関数(格子点→勾配インデックス)
const PERM = new Uint8Array(512)
function initPerm() {
const p = Array.from({ length: 256 }, (_, i) => i)
// Fisher-Yatesシャッフル
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[p[i], p[j]] = [p[j], p[i]]
}
for (let i = 0; i < 512; i++) PERM[i] = p[i & 255]
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10)
}
function lerp(a: number, b: number, t: number): number {
return a + t * (b - a)
}fade 関数は5次の滑らかなS字カーブ。Ken Perlinの改良版(2002年)で、1次導関数も2次導関数も境界で0になる。
2D Perlinノイズ
function perlin2d(x: number, y: number): number {
const xi = Math.floor(x) & 255
const yi = Math.floor(y) & 255
const xf = x - Math.floor(x)
const yf = y - Math.floor(y)
const u = fade(xf)
const v = fade(yf)
const aa = PERM[PERM[xi] + yi]
const ab = PERM[PERM[xi] + yi + 1]
const ba = PERM[PERM[xi + 1] + yi]
const bb = PERM[PERM[xi + 1] + yi + 1]
function dot(hash: number, dx: number, dy: number): number {
const g = GRAD[hash & 7]
return g[0] * dx + g[1] * dy
}
const x1 = lerp(dot(aa, xf, yf), dot(ba, xf - 1, yf), u)
const x2 = lerp(dot(ab, xf, yf - 1), dot(bb, xf - 1, yf - 1), u)
return lerp(x1, x2, v)
}返り値は -1〜1。これをそのまま高さマップに使う。
オクターブ合成
単一のPerlinノイズは大きなうねりしか表現できない。複数の周波数を重ね合わせることで、自然な地形のディテールが生まれる:
function fbm(
x: number,
y: number,
octaves: number,
lacunarity = 2.0,
persistence = 0.5,
): number {
let value = 0
let amplitude = 1
let frequency = 1
let maxValue = 0
for (let i = 0; i < octaves; i++) {
value += perlin2d(x * frequency, y * frequency) * amplitude
maxValue += amplitude
amplitude *= persistence
frequency *= lacunarity
}
return value / maxValue
}- lacunarity: 周波数の増加率(2.0で各オクターブが2倍の細かさ)
- persistence: 振幅の減衰率(0.5で各オクターブが半分の影響力)
4-6オクターブで十分にリアルな地形になる。
3D地形メッシュの描画
PerlinLandscapeではCanvas APIの2Dコンテキストのみで擬似3Dメッシュを描画する。アイソメトリック投影を使う:
function renderTerrain(
ctx: CanvasRenderingContext2D,
heightMap: number[][],
cols: number,
rows: number,
) {
const cellSize = 8
const heightScale = 80
ctx.strokeStyle = 'rgba(100, 200, 255, 0.6)'
ctx.lineWidth = 0.5
// 後ろから前に描画(Painter's Algorithm)
for (let y = 0; y < rows - 1; y++) {
for (let x = 0; x < cols - 1; x++) {
const h = heightMap[y][x]
// アイソメトリック変換
const sx = (x - cols / 2) * cellSize + ctx.canvas.width / 2
const sy = (y - rows / 2) * cellSize * 0.5 - h * heightScale + ctx.canvas.height / 2
const h2 = heightMap[y][x + 1]
const sx2 = (x + 1 - cols / 2) * cellSize + ctx.canvas.width / 2
const sy2 = (y - rows / 2) * cellSize * 0.5 - h2 * heightScale + ctx.canvas.height / 2
ctx.beginPath()
ctx.moveTo(sx, sy)
ctx.lineTo(sx2, sy2)
ctx.stroke()
}
}
}y方向のスケールを半分にすることで奥行き感を出す。WebGLを使わずともCanvas 2Dで十分に「地形らしく」見せられる。
PerlinNoiseコンポーネントとの違い
PerlinNoiseは2Dノイズをグレースケールの濃淡で表示するだけのデモ。PerlinLandscapeは同じノイズ関数を使いつつ、3D投影と高さマッピングを追加している。同じ数学関数をどう「見せるか」でまったく違う体験になる。
まとめ:Perlinノイズで使った技術と道具
Perlinノイズの応用範囲はゲーム開発全般に及ぶ。p5.jsの noise() 関数はPerlinノイズそのもので、プロトタイピングに最適だ。道具棚の書籍では、プロシージャル生成の基礎からレベルデザインへの応用まで体系的に学べる。