実習編の続き。汎用のウェブ地図ライブラリーで表示可能な架空の惑星の世界地図を、どこまでもズームインできるよう動的なプロシージャル生成技術で作ってみました。
目次
地図タイルの動的プロシージャル生成
前の記事で終わるはずの「惑星のつくりかた」でしたが、最後の最後で消化不良を起こしました。補遺につらつら書いたことが自分で気になり、やり残した気分になってしまったのです。
で、作り変えてみたのが上のプログラム。一見変わりないですね。でも、ズームインすると地形がどこまでも詳細に表示されます。ディテールこそ乏しいものの、前回の露骨なモザイクと違い、地形が常に一定の解像度を保っているのがわかるでしょう。
あの補遺で述べた、「サービスワーカー内でタイル描画の際に地形を直接プロシージャル生成する仕組み」にしたのです。簡単のため計算も描画も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×256=65,536回もCPUで計算するのはつらい。実際には少々間引いたほうがいいでしょう。このデモではズームレベルを8ではなく6だけ上げて(つまり64×64=4,096回の計算にして)、代わりに4×4ピクセルのブロック単位で描画しています。コードが本来以上にややこしく見えるのは、こうした調整のため。
4×4ピクセルの部分は、補完すればもっときれいに仕上がります。しかし、もし本格的に作るとなればどうせGPUを使うことになるので、ここではそこまでこだわりません。
補完といえば、球面座標もすべてを馬鹿正直にタイル座標から変換せず、適当に補完してしまえば軽くなるはず(微妙なデコボコが消えもていいならノイズ値を補完してもいい)。単純な割り算ではダメですが、できたら工夫してみてください。
たぶんおわり
以上です。地形らしい地形がなく、拡大できたところで面白くありませんが、あくまでもコード例として可能性は示せたかと。
可能性。こんなものがいったい何の役に立つのか。何かに奉仕するのではなく、作っている当人が楽しいのが空想の地図です。じぱんぐライクもそうでした。その意味で、「GISの道具による“作る楽しさ”」の可能性は少しだけ広げられた、と自負しています。
プログラミングができれば申し分ありませんが、コードをコピペしてプロシージャル生成のパラメーターをいじってみるだけでも、きっと(百人に一人くらいは)楽しめることでしょう。
用語・注釈
- プロシージャル(手続き型)生成
- データをアルゴリズムで生成する手法。手作業を省いたり、無数のバリエーションを生み出したりするために用いられる。
- MapLibre GL JSについて
- この仕組みはMapLibreに依存しておらず、他のウェブ地図ライブラリーでも実現できる。ただし、球投影に対応しているものは限られる。
- サービスワーカー
- サイト固有のローカルプロキシとして働く特殊なウェブワーカー(JavaScriptにおけるバックグラウンド実行スレッド)。オフラインキャッシュに用いられるのが典型。
- 勾配ノイズ
- 連続した値を入れると、なだらかに変化する乱数が出てくるノイズ関数。パーリンノイズはその代名詞。自然界には勾配ノイズ状の現象が多々あるため、プロシージャル生成によく用いられる。
- 作る楽しさについて
- 敵地を縦横無尽に攻略するシューティングゲーム『バンゲリングベイ』の作者は、そのゲームのためのレベルエディットつまり地図作りの最中に、地図作りそのものが楽しいことに気づいた。そうしてできた次作が『シムシティ』。地図ではなく、地図を作るプロセスを作品として表現したわけだ。「どんな地図ができたの?」「役に立つの?」そんな尺度が全てではない。