Zipanguelike

惑星のつくりかた:実技編

前回の記事の続き。架空の惑星の地図を、プロシージャル生成技術で実際に作ってみます。

目次

  1. まずは成果から
  2. 構成と処理の流れ
  3. コード
  4. 解説
  5. 既知の問題
  6. おわりに
  7. 用語・注釈

まずは成果から

動くプログラムが上にすでに出ていますね。地図の表示・操作をつかさどっているのはおなじみMapLire GL JSです。適当にさわってみてください。

「投影法切替」ボタンを押すと、地図の表示が球とメルカトル図法とに交互に変わります。メルカトル図法で最もズームアウトした状態で見える地図が、今回生成しているデータの全貌です。地形はこのページをリロードするたびに変わります。

構成と処理の流れ

構成

基本的な仕組みはじぱんぐライクと同じ。MapLibreが要求した地図タイルを、サービスワーカーが生成して返します。リモートの地図タイルサーバーは存在せず、すべての処理がクライアント(ブラウザー)内で完結します。

このサービスワーカーを用いたからくりについて詳しくは、『はんけトケ』の記事「クライアントサイド地図タイルサーバー」をお読みください。

メインスレッドの処理

  1. サービスワーカーを起動します。
  2. 前回の記事の要領で地図メッシュをプロシージャル生成し、サービスワーカーに渡します。
  3. MapLibreを起動し、地図タイルのソース(リクエスト先)として、サービスワーカーが受け付ける仮想のタイルURLを設定します。

サービスワーカーの処理

  1. メインスレッドからのメッセージを待ち受け、地図メッシュを受け取ります。
  2. 仮想のタイルURLへのHTTPリクエスト(フェッチ)を検知したら、地図メッシュに基づいてタイルをオフスクリーンキャンバスに描画し、その画像をHTTPレスポンスとして返します。

コード

コードを示します。ファイルのパスは適宜書き換えてください。サービスワーカーはセキュアな環境(HTTPSまたはローカルホスト)でしか動かないことに注意。利用・改変は自由ですが、これらを用いた行為の責任を作者は一切負いません。

HTML: ./index.html

<html>
	<head>
		<!-- MapLibre GL JS(v5.19で動作確認済) -->
		<link href='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css' rel='stylesheet'/>
		<script src='https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js'></script>

		<!-- 勾配ノイズライブラリー(https://github.com/xixixao/noisejs)-->
		<script src='./noisejs/index.js'></script>

		<!-- メインスレッド -->
		<script src='./planet.js'></script>
	</head>
	<body>
		<!-- 地図が入る要素 -->
		<div id="map" style="width:100%; height:100%"></div>
	</body>
</html>

メインスレッド: ./planet.js

// サービスワーカー登録
if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register('./planet-sw.js', {scope: './'}).then(() => {
		// いま登録したサービスワーカーが不活性ならリロード
		if (!navigator.serviceWorker.controller
			|| !navigator.serviceWorker.controller.scriptURL.match(/planet-sw/)
		) {
			window.location.reload();
		}
	});
}


// 初期化
document.addEventListener('DOMContentLoaded', async (event) => {
	const	ZOOMLEVEL = 9;  // 地図メッシュのズームレベル
	const	TILESIZE = 256; // ラスタータイルサイズ(ピクセル)

	// 勾配ノイズによる地図メッシュのプロシージャル生成
	{
		const	seed = Math.random(); // ノイズのシード値
		const	altitude = 7;         // 標高の最大値
		const	threshold = -0.1;     // 海と陸を分けるノイズ閾値
		const	freq = 2.5;           // 合成するノイズの周波数倍率
		const	amp = 0.5;            // 合成するノイズの振幅

		const	mapSize = 1 << ZOOMLEVEL; // 地図メッシュの辺の長さ(=基準ズームレベルのタイル解像度)
		const	mapData = new Uint8Array(mapSize * mapSize); // 地図メッシュ配列
		const	noise = new Noise(seed); // 勾配ノイズ生成器

		// 基準ズームレベルの全タイル座標を走査
		// ※わかりやすさを優先し、同じ計算を無駄に繰り返していることに注意
		for (let y = 0, i = 0; y < mapSize; y++) {
			for (let x = 0; x < mapSize; x++) {
				// タイル座標を経緯度に変換
				const	n = Math.pow(2, ZOOMLEVEL);
				const	lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
				const	lng = x / n * 2 * Math.PI - Math.PI;

				// 経緯度を球面上の座標に変換
				const	cos_ = Math.cos(lat);
				const	posX = Math.cos(lng) * cos_;
				const	posY = Math.sin(lat);
				const	posZ = Math.sin(lng) * cos_;

				// 球面座標を入力として勾配ノイズを得る
				let	v = noise.simplex3(posX, posY, posZ);
				// さらに周波数・振幅を変えて合成
				v	+= amp * noise.simplex3(posX * freq, posY * freq, posZ * freq);

				// ノイズ値が閾値以下なら海(0)、でなければ陸の起伏
				v -= threshold;
				v = (v <= 0)? 0 : altitude * Math.min(1, v * 2);

				// 結果を地図メッシュ配列に格納
				mapData[i++] = v;
			}
		}

		// 生成した地図メッシュをサービスワーカーに送る
		const	registration = await navigator.serviceWorker.ready;
		registration.active.postMessage({
			mapData: mapData,
			zoomLevel: ZOOMLEVEL,
			tileSize: TILESIZE
		}, [mapData.buffer]);

		// サービスワーカーを眠らせにくくする?(効果は限定的)
		setInterval(async () => {
			if (registration.active) {
				registration.active.postMessage('keepalive');
			}
		}, 5000);
	}

	// MapLibreを起動
	const	map = new maplibregl.Map({
		container: 'map',
		maxZoom: ZOOMLEVEL
	});
	map.addSource('planet', {
		type: 'raster',
		tiles: ['/planet-tile/{z}/{x}/{y}'], // サービスワーカーが受け付けるタイルURL
		tileSize: TILESIZE
	});
	map.addLayer({
		id: 'layer-planet',
		type: 'raster',
		source: 'planet'
	});
	map.setProjection({type: 'globe'}); // 球投影
})

サービスワーカー: ./planet-sw.js

// タイル描画用データ
const	data = {
	mapData: null, // 地図メッシュ配列
	zoomLevel: 0,  // 地図メッシュのズームレベル
	tileSize: 0,   // 地図タイルサイズ(ピクセル)
	canvas: null,  // オフスクリーンキャンバス
	context: null, // キャンバスコンテキスト
	pixels: null   // キャンバスピクセル配列
};


// メッセージイベントリスナー
self.addEventListener('message', async (event) => {
	// メインスレッドから地図メッシュ配列等を受け取る
	if (event.data.mapData) {
		data.mapData = event.data.mapData;
		data.zoomLevel = event.data.zoomLevel;
		data.tileSize = event.data.tileSize;

		// オフスクリーンキャンバスを用意
		data.canvas = new OffscreenCanvas(data.tileSize, data.tileSize);
		data.context = data.canvas.getContext(
			'2d', { colorSpace: 'srgb', colorType: 'unorm8'}
		);
		data.pixels = data.context.getImageData(
			0, 0, data.tileSize, data.tileSize,
			{colorSpace: 'srgb', pixelFormat: 'rgba-unorm8'}
		);
	}
});


// フェッチイベントリスナー
self.addEventListener('fetch', (event) => {
	const	url = new URL(event.request.url);

	// リクエスト先URLのパスを調べる
	if (/^\/planet-tile\/[0-9]+\/[0-9]+\/[0-9]+$/.test(url.pathname)) {
		// もしタイル取得パスだったらタイル画像を生成して返す
		event.respondWith(makeTile(url.pathname));
	}
})


// タイル画像を生成しHTTPレスポンスとして返す
const makeTile = async (path) => {
	// 画像生成できない状況なら404を返す
	// ※アイドル時のリソース解放の結果でもあり得るが、その場合の復帰策は省略
	if (!data.mapData || !data.canvas) {
		return new Response(null, {
			'status': 404,
			headers: {'Cache-Control': 'no-store,must-revalidate'}
		});
	}

	// URLパスからXYZの値を得る
	const	xyz = pathToXyz(path);

	// キャンバスコンテキストにタイルを描画する
	{
		// RGBパレット(標高昇順に8色、0は海)
		const palette = [
			[100,213,223], [244,220,109], [137,170, 85], [ 56,105, 86],
			[216,121, 69], [130, 83, 65], [160,154,146], [245,238,228]
		];

		// ズームレベルに応じたスケールと範囲でピクセル描き込み
		const	buf = data.pixels.data;
		let	x, y, i, j, mul = Math.pow(2, data.zoomLevel - xyz.z);
		const	mapSize = 1 << data.zoomLevel;
		const	offset = Math.floor(xyz.y * mul) * mapSize + Math.floor(xyz.x * mul);
		mul /= data.tileSize;
		for (y = data.tileSize - 1; y >= 0 ; --y) {
			i = (y * data.tileSize) * 4;
			j = offset + Math.floor(y * mul) * mapSize;
			for (x = data.tileSize - 1; x >= 0 ; --x) {
				const	pal = palette[data.mapData[Math.floor(j)] || 0];
				buf[i++] = pal[0];
				buf[i++] = pal[1];
				buf[i++] = pal[2];
				buf[i++] = 0xff;
				j += mul;
			}
		}

		// キャンバスコンテキストに反映
		data.context.putImageData(data.pixels, 0, 0);
	}

	// キャンバスを画像ファイル形式に変換
	const	blob = await data.canvas.convertToBlob();

	// HTTPレスポンスとして返す
	return new Response(blob);
}


// URLパス(/{z}/{x}/{y})からXYZの値を得る
const pathToXyz = (path) => {
	const	params = path.split('/');
	let		len = params.length;

	const	y = parseInt(params[--len]);
	const	x = parseInt(params[--len]);
	const	z = parseInt(params[--len]);

	return {x: x, y: y, z: z};
}

勾配ノイズライブラリー: ./noisejs/index.js

https://github.com/xixixao/noisejs から「index.js」をダウンロードし、「./noisejs/index.js」として置いてください。3次元パーリンノイズ/シンプレックスノイズさえ使えれば別のものでもかまいません(呼び出しコードを適宜書き換えること)。

解説

短く、コメントもまめに入れているので、コードを読めば何をしているかわかるでしょう。肝である地図メッシュの生成周りについてだけ補足します。

地図メッシュとは

前々節で述べたとおり、メインスレッドの初期化時に「前回の記事の要領で地図メッシュをプロシージャル生成」しています。

ここでいう地図メッシュとは、基準と定めたズームレベルにおけるXYZ形式タイル単位の格子に、0〜7まで8段階の大まかな標高を格納するバイト配列です。

基準のズームレベルは、とりあえず9としました。XYZ形式のタイルは、ズームレベル0の1枚のタイルから始まり、レベルが1上がるごとにタイルの縦横が半分に分割されていきます。したがってズームレベル9ならタイルは512×512枚、「X=0511,Y=0511」の座標をとります。

これらのタイルそれぞれについて、XY座標を経緯度に、経緯度を惑星表面(球面)の3次元座標に変換します。そしてそれを3次元パーリンノイズ(正確にはシンプレックスノイズ)関数に与え、結果を加工して地図メッシュ配列に格納するのです。

パーリンノイズから標高へ

パーリンノイズ関数は原則として、-1.01.0の値を返します。地図メッシュに格納する際、これを0〜7の整数に加工します。あるいき値以下なら0(海)とし、それを上回るなら7までの整数にストレッチしてやるだけです。

ただし、球面の座標を素直に与えただけでは結果が単調すぎたため、周波数・振幅を変えたパーリンノイズをさらに合成(加算)しています。このへんは結果を見ながら適当に調整しただけで、とくに根拠はありません。

既知の問題

地図タイルが欠けてしまう

このページのウィンドウ(タブ)がしばらく隠れるなどすると、サービスワーカーが非アクティブになり、そのリソースすなわちタイル描画用のキャンバスが解放されてしまうことがあります。スマートな復帰方法はまだ模索中で、コードの焦点もぼやけかねないことから対策はしていません。もし地図のタイルが欠けたままになったら、リロードして再表示ねがいます。

おわりに

意義

ここでのプロシージャル生成処理は初歩も初歩、いわばビー玉にマーブル模様を描いてみたにすぎない原始的なもの。ではなぜわざわざ作ったか。汎用のウェブ地図ライブラリー上でも手軽に地形のプロシージャル生成ができる・架空の世界を創造できる、と分野の可能性を示すことに意義があると思ったからです。

模様つまり地形だの生態系だのは既成のプロシージャル技術を応用していくらでも作れるし、現に作られているでしょう。しかし単にそうして作られただけの惑星は、血の通った意味の豊かさを欠いており、どれだけ丹精されようと、それこそ“美しいビー玉”にしかわたしには見えません。

一方、ウェブ地図の枠組みであれば自然の美しさに加え、文化・歴史・政治・経済ほか幅広い社会情報・科学情報をもインタラクティブなレイヤーとして重ねることができます。それも、ゲームのように独自技術に閉じていないかたちで。

そうしたレイヤーまでプロシージャルに作って示せればよかったのですが、さすがに大変すぎてデモの範疇はんちゅうを大きく超えてしまいます。いや、ちょっとくらいなら作れるでしょうが、わたしの性格上あれもこれもととことん作り込まねば済まなくなってしまうのです。

所感

たった今でっちあげた大義名分はさておき、ただの手慰みでこれを作ってみて改めて気づいた、どころか衝撃を受けたのは、メルカトル図法の大胆さです。

冒頭の地図をメルカトル表示にし、限界までズームアウトしてみてください。南北の両極へ、赤道からちょっと離れたあたりからの伸長ぶりたるや、常軌を逸していませんか。

左に球投影、右にメルカトル投影された世界地図
同じ地図の球投影とメルカトル投影との比較

わたしたちがふだん見慣れている世界地図もこうなっているはずです(縦横比は異なるけれど)。でもたぶん生まれて初めてみる世界の姿がそれだったりして、むしろあれが大陸の本当の形だくらいに認識しているのではないでしょうか。知識としては歪んでいることくらい知っていながら、それと矛盾して平気なのがヒトの認識の不思議なところ。

が、見知らぬ惑星の世界地図として虚心坦懐に眺めると、やっぱりどうかしています。歪みが気になりすぎて大陸の形が目に入りません。あれが最初に生成されたとき、わたしはバグかと思いました。メルカトルの世界地図を初めて製図した人も、何かの間違いではないかと何度も確かめたのではと想像します。初めて見せられた人も二度見したことでしょう。

同時に感じたのは、たまたま赤道に近い日本は恵まれているということ。北極圏の国々は、もちろん当地にふさわしい地図をふだん使っているのでしょうが、グローバルな地図とすり合わせるのがいちいち面倒なのではないでしょうか。

技術的な観点でいえば、タイルあたりの情報密度があれだけ違ってしまうのも気になりますね。メッシュの解像度は緯度により可変にすべきかもしれません。

用語・注釈

「復帰方法はまだ模索中」について
じぱんぐライクではキャンバスの復帰処理をしているものの泥縄的で、たまに動作を痙攣けいれん的に繰り返してしまうなどベストな対策とはいえない。
大義名分について
本当は手慰みでもなく、これを作った真の理由は、このサイトにブログ風の体裁の「ノート」をこしらえた以上、記事が初めの1本だけでは淋しかったから。
「メルカトル図法の大胆さ」について
とはいえ経緯度に沿って、つまり東西南北にまっすぐ船を走らせて海岸線をプロットしていけば自然ああいう図になる道理ではある(簡単にいえばその「まっすぐ」さがメルカトル図法の特長)。むしろ、宇宙から見た丸い地球の姿のほうに驚くべきか。当時の地理学的な世界観がどうだったのか興味深い。