Zipanguelike

惑星のつくりかた:補習編

実習編の続き。汎用のウェブ地図ライブラリーで表示可能な架空の惑星の世界地図を、どこまでもズームインできるよう動的なプロシージャル生成技術で作ってみました。

目次

  1. 地図タイルの動的プロシージャル生成
  2. コード
  3. 解説
  4. たぶんおわり
  5. 用語・注釈

地図タイルの動的プロシージャル生成

前の記事で終わるはずの「惑星のつくりかた」でしたが、最後の最後で消化不良を起こしました。補遺につらつら書いたことが自分で気になり、やり残した気分になってしまったのです。

で、作り変えてみたのが上のプログラム。一見変わりないですね。でも、ズームインすると地形がどこまでも詳細に表示されます。ディテールこそ乏しいものの、前回の露骨なモザイクと違い、地形が常に一定の解像度を保っているのがわかるでしょう。

あの補遺で述べた、「サービスワーカー内でタイル描画の際に地形を直接プロシージャル生成する仕組み」にしたのです。簡単のため計算も描画もCPUでしており相応に重いですが、解像度を少々下げ、十分な速度で動くようにしたつもり。

機器によってはやはり苦しいでしょうが、こうした処理はGPUに任せないと非実用的だよ、と示す反面教師と捉えてください。とはいえゲームのように毎フレーム負荷がかかり続けるものではないし、リモート地図タイルサーバーからの画像ロードと同程度までの遅延なら許容範囲でしょう。それよりもずっと長く待たされているとしたら、おそらく機器の買い替えどきです。

なお、極が見えるように球を傾けるとタイルの解像度(表示ズームレベル)が下がりますが、これも補遺に書いたとおり。前のプログラムより現象がわかりやすいと思います。

コード

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

HTML: ./index.html

<html>
	<head>
		<!-- MapLibre GL JS(v5.20で動作確認済) -->
		<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>

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

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

// サービスワーカー登録
if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register(
		'./planet2-sw.js',	// サービスワーカー
		{scope: './'} // スコープ(※本当は絶対パスにしたほうが安全)
	).then(() => {
		// いま登録したサービスワーカーが不活性ならリロード
		if (!navigator.serviceWorker.controller
			|| !navigator.serviceWorker.controller.scriptURL.match(/planet2-sw/)
		) {
			window.location.reload();
		}
	});
}


// 初期化
document.addEventListener('DOMContentLoaded', async (event) => {
	const	SEED = Math.random(); // ノイズのシード値
	const	DIVIDE = 6;           // 地図タイル解像度(タイルサイズの分割回数)
	const	TILESIZE = 256;       // ラスタータイルサイズ(ピクセル)

	// パラメーターをサービスワーカーに送る
	const	registration = await navigator.serviceWorker.ready;
	registration.active.postMessage({
		seed: SEED,
		divide: DIVIDE,
		tileSize: TILESIZE
	});

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

	// MapLibreを起動
	const	map = new maplibregl.Map({
		container: 'map', // 地図を入れる要素のid
		attributionControl: false
	});
	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'}); // 球投影にする(お好みで)
	// map.addControl(new maplibregl.NavigationControl()); // 操作ボタン
	// map.addControl(new maplibregl.ScaleControl({maxWidth: 80, unit: 'metric'})); // 縮尺目盛り
})

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

// タイル描画用データ
const	data = {
	seed: 0,       // ノイズのシード値
	divide: 6,     // 地図タイル解像度(タイルサイズの分割回数)
	tileSize: 256, // 地図タイルサイズ(ピクセル)
	canvas: null,  // オフスクリーンキャンバス
	context: null, // キャンバスコンテキスト
	pixels: null   // キャンバスピクセルバッファー
};


// メッセージイベントリスナー
self.addEventListener('message', async (event) => {
	// メインスレッドからパラメーターを受け取る
	if (event.data.tileSize) {
		data.seed = event.data.seed;
		data.divide = event.data.divide;
		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.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 = [
			[115,240,228], [239,205,115], [164,170, 52], [ 36,105, 50],
			[255,137, 35], [169, 68,  0], [194,178,150], [247,237,231]
		];
		const	altitude = 7;     // 標高の最大値
		const	threshold = -0.2; // 海と陸を分けるノイズ閾値

		// XYZを分割する(ズームレベルをdivideぶん上げた座標にする)
		xyz.x <<= data.divide;
		xyz.y <<= data.divide;
		xyz.z += data.divide;

		// タイル表面のXYZ座標を走査し勾配ノイズを得てピクセル描き込み
		// ※わかりやすさのため最適化はしていない、というか本来GPUを使うべき

		const	buf = data.pixels.data; // ピクセル配列
		const	reso = 1 << data.divide; // 実質的なタイル解像度
		const	rect = data.tileSize / reso; // ピクセル描画単位
		noise.seed(data.seed); // ノイズ初期化

		for (let y = 0; y < reso; y++) {
			for (let x = 0; x < reso; x++) {
				// XYZから経緯度を得る
				const	coords = xyzToCoords({x: xyz.x + x, y: xyz.y + y, z: xyz.z})

				// 経緯度から球面座標を得る
				const	pos = coordsToPos(coords);

				// 球面座標を入力として勾配ノイズを得る
				let	v = noise.simplex3(pos.x, pos.y, pos.z);
				// さらに周波数・振幅をテキトーに変えて合成
				for (let i = 0, a = 0.5, f = 2.5; i < 4; i++) {
					v	+= a * noise.simplex3(pos.x * f, pos.y * f, pos.z * f);
					a *= 0.2;
					f *= Math.pow(28, i);
				}

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

				// ピクセルをブロック単位で描く
				const	pal = palette[v];
				for (let y_ = 0; y_ < rect; y_++) {
					let	i = ((y * rect + y_) * data.tileSize + x * rect) * 4;
					for (let x_ = 0; x_ < rect; x_++) {
						buf[i++] = pal[0];
						buf[i++] = pal[1];
						buf[i++] = pal[2];
						buf[i++] = 0xff;
					}
				}
			}
		}

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

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

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


// 経緯度(ラジアン)を球面上の座標に変換
const coordsToPos = (coords) => {
	const	cos_ = Math.cos(coords.lat);
	const	x = Math.cos(coords.lng) * cos_;
	const	y = Math.sin(coords.lat);
	const	z = Math.sin(coords.lng) * cos_;

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


// XYZの値から経緯度を得る(ラジアン単位)
const xyzToCoords = (xyz) => {
	const	n = Math.pow(2, xyz.z);
	const	lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * xyz.y / n)));
	const	lng = xyz.x / n * 2 * Math.PI - Math.PI;

	return {lat: lat, lng: lng};
}


// 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};
}


// 以下、勾配ノイズ関数(https://github.com/josephg/noisejs より)
/* A speed-improved perlin and simplex noise algorithms for 2D. Based on example code by Stefan Gustavson (stegu@itn.liu.se). Optimisations by Peter Eastman (peastman@drizzle.stanford.edu). Better rank ordering method by Stefan Gustavson in 2012. Converted to Javascript by Joseph Gentle.
 * Version 2012-03-09
 * This code was placed in the public domain by its original author, Stefan Gustavson. You may use it as you see fit, but attribution is appreciated. */
!function(t){var o=t.noise={};function r(t,o,r){this.x=t,this.y=o,this.z=r}r.prototype.dot2=function(t,o){return this.x*t+this.y*o},r.prototype.dot3=function(t,o,r){return this.x*t+this.y*o+this.z*r};var n=[new r(1,1,0),new r(-1,1,0),new r(1,-1,0),new r(-1,-1,0),new r(1,0,1),new r(-1,0,1),new r(1,0,-1),new r(-1,0,-1),new r(0,1,1),new r(0,-1,1),new r(0,1,-1),new r(0,-1,-1)],e=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180],a=new Array(512),i=new Array(512);o.seed=function(t){t>0&&t<1&&(t*=65536),(t=Math.floor(t))<256&&(t|=t<<8);for(var o=0;o<256;o++){var r;r=1&o?e[o]^255&t:e[o]^t>>8&255,a[o]=a[o+256]=r,i[o]=i[o+256]=n[r%12]}},o.seed(0);var d=.5*(Math.sqrt(3)-1),f=(3-Math.sqrt(3))/6,h=1/6;function u(t){return t*t*t*(t*(6*t-15)+10)}function s(t,o,r){return(1-r)*t+r*o}o.simplex2=function(t,o){var r,n,e=(t+o)*d,h=Math.floor(t+e),u=Math.floor(o+e),s=(h+u)*f,l=t-h+s,w=o-u+s;l>w?(r=1,n=0):(r=0,n=1);var v=l-r+f,M=w-n+f,c=l-1+2*f,p=w-1+2*f,y=i[(h&=255)+a[u&=255]],x=i[h+r+a[u+n]],m=i[h+1+a[u+1]],q=.5-l*l-w*w,z=.5-v*v-M*M,A=.5-c*c-p*p;return 70*((q<0?0:(q*=q)*q*y.dot2(l,w))+(z<0?0:(z*=z)*z*x.dot2(v,M))+(A<0?0:(A*=A)*A*m.dot2(c,p)))},o.simplex3=function(t,o,r){var n,e,d,f,u,s,l=.3333333333333333*(t+o+r),w=Math.floor(t+l),v=Math.floor(o+l),M=Math.floor(r+l),c=(w+v+M)*h,p=t-w+c,y=o-v+c,x=r-M+c;p>=y?y>=x?(n=1,e=0,d=0,f=1,u=1,s=0):p>=x?(n=1,e=0,d=0,f=1,u=0,s=1):(n=0,e=0,d=1,f=1,u=0,s=1):y<x?(n=0,e=0,d=1,f=0,u=1,s=1):p<x?(n=0,e=1,d=0,f=0,u=1,s=1):(n=0,e=1,d=0,f=1,u=1,s=0);var m=p-n+h,q=y-e+h,z=x-d+h,A=p-f+2*h,b=y-u+2*h,g=x-s+2*h,j=p-1+.5,k=y-1+.5,B=x-1+.5,C=i[(w&=255)+a[(v&=255)+a[M&=255]]],D=i[w+n+a[v+e+a[M+d]]],E=i[w+f+a[v+u+a[M+s]]],F=i[w+1+a[v+1+a[M+1]]],G=.6-p*p-y*y-x*x,H=.6-m*m-q*q-z*z,I=.6-A*A-b*b-g*g,J=.6-j*j-k*k-B*B;return 32*((G<0?0:(G*=G)*G*C.dot3(p,y,x))+(H<0?0:(H*=H)*H*D.dot3(m,q,z))+(I<0?0:(I*=I)*I*E.dot3(A,b,g))+(J<0?0:(J*=J)*J*F.dot3(j,k,B)))},o.perlin2=function(t,o){var r=Math.floor(t),n=Math.floor(o);t-=r,o-=n;var e=i[(r&=255)+a[n&=255]].dot2(t,o),d=i[r+a[n+1]].dot2(t,o-1),f=i[r+1+a[n]].dot2(t-1,o),h=i[r+1+a[n+1]].dot2(t-1,o-1),l=u(t);return s(s(e,f,l),s(d,h,l),u(o))},o.perlin3=function(t,o,r){var n=Math.floor(t),e=Math.floor(o),d=Math.floor(r);t-=n,o-=e,r-=d;var f=i[(n&=255)+a[(e&=255)+a[d&=255]]].dot3(t,o,r),h=i[n+a[e+a[d+1]]].dot3(t,o,r-1),l=i[n+a[e+1+a[d]]].dot3(t,o-1,r),w=i[n+a[e+1+a[d+1]]].dot3(t,o-1,r-1),v=i[n+1+a[e+a[d]]].dot3(t-1,o,r),M=i[n+1+a[e+a[d+1]]].dot3(t-1,o,r-1),c=i[n+1+a[e+1+a[d]]].dot3(t-1,o-1,r),p=i[n+1+a[e+1+a[d+1]]].dot3(t-1,o-1,r-1),y=u(t),x=u(o),m=u(r);return s(s(s(f,v,y),s(h,M,y),m),s(s(l,c,y),s(w,p,y),m),x)}}(this);

勾配ノイズライブラリー

前回と同じく https://github.com/josephg/noisejs の「perlin.js」を使います。ただし独立したファイルはなく、上のコード./planet2-sw.jsの末尾にミニファイ(記述を短縮)して含めています。

サービスワーカーはオプションでモジュールインポートに対応しています。そこでモジュール型の別の勾配ノイズライブラリーを使おうとしたのですが、わたしの環境ではなぜか謎のエラーが起きて解消できず、らちが明かないのでこうしました。ちなみにこのperlin.jsはパブリックドメインです。

もちろん、3次元パーリンノイズ/シンプレックスノイズさえ使えれば別のライブラリーでも自前の関数でもかまいません(呼び出しコードを適宜書き換えること)。

解説

前回のプログラムを理解しているなら、今回の変更点はだいたい想像がつくと思います。構成や処理の流れは基本的に同じ。違うのは下記の2点です。

何もしないメインスレッド

メインスレッドの仕事は、せいぜいサービスワーカーとMapLibre GL JSを起動するだけ。前回と違い、地図メッシュのプロシージャル生成はしません。むろんMapLibreは走っているものの、開発者としてはとくに何もすることがありません。シンプルでいいですね。

サービスワーカーでの地図タイル生成

今回の肝。パーリンノイズを利用した地形のプロシージャル生成を、サービスワーカーがタイルを描く際に行います。

方法のおさらい

基礎は前回と同じ。XYZ形式タイル座標を経緯度に、経緯度を球面座標に変換し、その3次元座標を3次元パーリンノイズ関数に与えることで、地形の標高と見なすなだらかなノイズ値を得るのです。

サービスワーカーがいま描こうとしているタイルのXYZ形式タイル座標は、リクエストURLから得られますね。

タイル表面を網羅する座標

しかし、リクエストURLから得られる座標が指すのは、タイル正方形の左上(北西)隅の1点のみです。

タイルのラスター(ビットマップ)サイズが256ピクセル四方だとして、各ピクセル用のパーリンノイズを得るには、256×256ピクセルそれぞれのXYZ形式タイル座標が必要です。タイルの左上1点だけからそれらを求めるには、いったいどうすればいいのでしょうか。

簡単です。ズームレベルを8段階上げて計算すればいいのです。ズームレベルは1上がるごとにタイルが縦横半分に分割されるルールでした。したがって、8上がるとタイルは元の256×256倍に分割されます。

いま仮に、リクエストされたタイル座標が(X:3,Y:2,Z:3)だったとします。ズームレベルを8上げると、タイル座標は(X:768,Y:512,Z:11)になりますね。XとYは256倍し、Zには8をそのまま加えるだけです。

この新たな座標の、元の右隣・下隣のタイルまでの座標群(X:768〜1023,Y:512〜767,Z:11)が、リクエストされたタイルの表面の256×256ピクセルに相当するのです。

ちょっと手加減

描くべきタイル表面を網羅するXYZ形式タイル座標がわかったので、あとは素直に1点ずつ計算してピクセルを描いていけばいいだけ。

ただ、あの座標変換と3次元パーリンノイズを256×25665,536回もCPUで計算するのはつらい。実際には少々間引いたほうがいいでしょう。このデモではズームレベルを8ではなく6だけ上げて(つまり64×644,096回の計算にして)、代わりに4×4ピクセルのブロック単位で描画しています。コードが本来以上にややこしく見えるのは、こうした調整のため。

4×4ピクセルの部分は、補完すればもっときれいに仕上がります。しかし、もし本格的に作るとなればどうせGPUを使うことになるので、ここではそこまでこだわりません。

補完といえば、球面座標もすべてを馬鹿正直にタイル座標から変換せず、適当に補完してしまえば軽くなるはず(微妙なデコボコが消えもていいならノイズ値を補完してもいい)。単純な割り算ではダメですが、できたら工夫してみてください。

たぶんおわり

以上です。地形らしい地形がなく、拡大できたところで面白くありませんが、あくまでもコード例として可能性は示せたかと。

可能性。こんなものがいったい何の役に立つのか。何かに奉仕するのではなく、作っている当人が楽しいのが空想の地図です。じぱんぐライクもそうでした。その意味で、「GISの道具による“作る楽しさ”」の可能性は少しだけ広げられた、と自負しています。

プログラミングができれば申し分ありませんが、コードをコピペしてプロシージャル生成のパラメーターをいじってみるだけでも、きっと(百人に一人くらいは)楽しめることでしょう。

用語・注釈

プロシージャル(手続き型)生成
データをアルゴリズムで生成する手法。手作業を省いたり、無数のバリエーションを生み出したりするために用いられる。
MapLibre GL JSについて
この仕組みはMapLibreに依存しておらず、他のウェブ地図ライブラリーでも実現できる。ただし、球投影に対応しているものは限られる。
サービスワーカー
サイト固有のローカルプロキシとして働く特殊なウェブワーカー(JavaScriptにおけるバックグラウンド実行スレッド)。オフラインキャッシュに用いられるのが典型。
勾配ノイズ
連続した値を入れると、なだらかに変化する乱数が出てくるノイズ関数。パーリンノイズはその代名詞。自然界には勾配ノイズ状の現象が多々あるため、プロシージャル生成によく用いられる。
作る楽しさについて
敵地を縦横無尽に攻略するシューティングゲーム『バンゲリングベイ』の作者は、そのゲームのためのレベルエディットつまり地図作りの最中に、地図作りそのものが楽しいことに気づいた。そうしてできた次作が『シムシティ』。地図ではなく、地図を作るプロセスを作品として表現したわけだ。「どんな地図ができたの?」「役に立つの?」そんな尺度が全てではない。