はじめに
PLATEAU の 3D Tiles は、京都市の中京区・東山区・下京区の建築物 LOD3 だけで 880 MB あります(pipeline/README.md のデータセット表より。都市設備などを足した入力 8 件の合計は約 960 MB)。これをビルド工程もサーバーもなしに、スマートフォンでも開けることを目指した作品(実機での動作は本稿では確認していません)にするとき、どこを削ってどこを残せばよいのでしょうか。しかも置き場所の最初の候補は、pack.mjs のコメントによると、バイナリの拡張子を配信できないホストでした。
題材は、京都の先斗町・鴨川・祇園白川をドバトの目で歩いて飛ぶブラウザ作品「鳩の目、京都。」です。筆者が Claude Fable 5.2 に依頼して実装してもらった作品で、依存は three.js r170 だけです。AI とのやり取りは別の記事で扱うので、この記事ではコードと数値に絞ります。
- 実際に動くもの: 「鳩の目、京都。」を開く(Claude アーティファクト)
- ソースコード: kai-kou/hato-no-me-kyoto
対象読者は、three.js や WebGL で大きな 3D データをブラウザへ配信したい開発者と、PLATEAU のデータを Web で使いたい方です。
TL;DR
- 頂点は、位置と UV を Uint16、法線を Int8 に量子化し、頂点色のアルファに環境遮蔽(AO)を焼き込んでいます。実行時にライトマップは使いません
- 独自の pak 形式(deflate-raw)を base64 の
.txtにして配り、ブラウザで base64 を手書きのループで戻してからDecompressionStream('deflate-raw')で展開します。配布中のcore.txtを復号すると、646,087 三角形・1,931,839 頂点が入っていました - テクスチャは「見えている割合」で密度を決めてアトラスを詰め直し、距離に応じて 4 段階の WebP を出し入れします
- ハトの目の 320° パノラマは、CubeCamera で 6 面を描いてから全画面シェーダで方位角と仰角に引き直しています
3D Tiles から .txt までの全体像
変換は Node.js の 6 段のパイプラインで行い、最後に base64 化の後処理を挟みます。段ごとの説明はリポジトリの README に譲り、この記事では段 4(テクスチャ)と段 6(書き出し)、それを読むブラウザ側を追います。
出力は 226 ファイル、約 54 MB です。起動時に読むのは約 21 MB で、残りは背景で追いかけます(README の規模表より)。
頂点を 16 bit と 8 bit に量子化する
書き出しの addMesh は、メッシュごとに AABB(軸に沿った外接箱)を取り、その箱の中で位置を 0〜65535 に正規化します。
// pipeline/export.mjs:28-34(整形・抜粋)
const sx = Math.max(1e-3, x1 - x0) /* sy, sz も同様 */;
const P = new Uint16Array(nv * 3);
const N = new Int8Array(nv * 3 + ((4 - (nv * 3) % 4) % 4)); // 4 バイト境界まで詰め物
const C = new Uint8Array(nv * 4);
for (let i = 0; i < nv; i++) {
P[i * 3] = Math.round((x - x0) / sx * 65535); // y, z も同様
N[i * 3] = Math.round(nx * 127); // 単位化した法線
C[i * 4 + 3] = Math.round(ao * 255); // RGBA の A に AO
}
const it = { bb: [x0, y0, z0, x1, y1, z1].map(v => +v.toFixed(3)), nv,
pos: pak.add(P), nor: pak.add(N), col: pak.add(C) };
// UV も 0〜1 を Uint16 に。インデックスは頂点数 65535 以下なら Uint16
it.idx = nv <= 65535 ? pak.add(Uint16Array.from(m.idx)) : pak.add(Uint32Array.from(m.idx));
量子化の 1 段は「AABB の幅 ÷ 65535」です。周囲の簡略表示の建物は 260 m 四方のセルでメッシュを分けているので、1 段はおよそ 4 mm になります。テクスチャのない街路設備は種類ごと・48 m セルごとに分けており、こちらは 1 mm 前後です。分割の細かさが、そのまま精度とインデックス幅(Uint16 で済むか)を決めています。
テクスチャ付きの頂点で比べると、Float32 なら位置・法線・UV で 32 バイトのところ、この形式は位置 6・法線 3・UV 4 の 13 バイトです。頂点色の 4 バイト(AO の入れ物)を足しても 17 バイトで、Float32 に頂点色を足した 36 バイトの半分弱です。
ブラウザ側は次のように戻します。
// app/js/world.js:414-420(整形・抜粋)
const b = it.bb, P = pak.view(Uint16Array, it.pos), pos = new Float32Array(nv * 3);
const sx = (b[3] - b[0]) / 65535; // sy, sz も同様
for (let i = 0; i < nv; i++) pos[i * 3] = b[0] + P[i * 3] * sx; // y, z も同様
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.BufferAttribute(new Int8Array(pak.buf, pak.base + it.nor[0], nv * 3), 3, true));
g.setAttribute('color', new THREE.BufferAttribute(pak.view(Uint8Array, it.col), 4, true));
if (it.uv) g.setAttribute('uv', new THREE.BufferAttribute(pak.view(Uint16Array, it.uv), 2, true));
法線・色・UV は normalized: true(第 3 引数)の BufferAttribute に TypedArray のビューを直接渡しており、コピーが発生しません。法線だけ view() を使わず長さを nv * 3 で切っているのは、書き出し側で 4 バイト境界まで詰め物をしているためです。
頂点 AO を頂点色のアルファに焼き込む
AO(環境遮蔽、物陰が暗くなる効果)は、段 3 で作った 25 cm 格子の高さラスタを使って頂点ごとに計算します。
// pipeline/export.mjs:14-19(整形・抜粋)
const DIRS = []; for (let a = 0; a < 8; a++) DIRS.push([Math.cos(a * Math.PI / 4), Math.sin(a * Math.PI / 4)]);
function aoAt(x, y, z, nx, ny, nz) {
const wall = Math.abs(ny) < 0.6, /* hx, hz: 水平方向の法線 */
px = x + nx * 0.22, py = y + ny * 0.22 + 0.03, pz = z + nz * 0.22;
let vis = 0, ws = 0;
for (const [dx, dz] of DIRS) {
const w = wall ? Math.max(0, dx * hx + dz * hz) + 0.12 : 1; let ms = 0;
for (const d of [0.5, 1, 2, 4, 8]) {
const s = (heightAt(px + dx * d, pz + dz * d) - py) / d; if (s > ms) ms = s;
}
vis += w * (1 - ms / Math.sqrt(1 + ms * ms)); ws += w;
}
let a = vis / ws;
if (wall && y - groundAt(x + hx * 0.3, z + hz * 0.3) < 0.35) a *= 0.82; // 壁の足元
return Math.max(0, Math.min(1, 0.28 + 0.72 * Math.pow(a, 0.9)));
}
8 方向それぞれで 0.5〜8 m 先の高さを見て、最大の勾配 ms(= 水平線の仰角 θ の tan)を取ります。ms / sqrt(1 + ms²) は sin θ なので、各方向の値は 1 − sin θ です。これは、その方位の空の立体角に対する「見える割合」に当たります(余弦の重みは掛けていません)。壁は法線の向きに近い方向ほど重みを大きくし、地面から 35 cm 以内はさらに 0.82 倍します。最終値は 0.28〜1 に収めているので、真っ黒にはなりません。
実行時は、この値を間接光に強く、直射光に弱く掛けています。
// app/js/world.js:122(抜粋)
float aoK = mix(1.0, hAO, 0.86); reflectedLight.indirectDiffuse *= aoK;
reflectedLight.directDiffuse *= mix(1.0, hAO, 0.25);
焼き込みのコストは書き出し時に一度だけで、実行時の追加テクスチャもありません。路地の奥や軒下が暗く沈むのは、主にこの 4 バイト目の効果です。
pak 形式と、バイナリを配れないホストへの base64 .txt
書き出し側
pak は「8 バイトのヘッダ、JSON、ペイロード」を連結して deflate-raw で固めただけの形式です。
// pipeline/export.mjs:22-24(整形・抜粋)
class Pak {
add(arr) { // 各配列を 4 バイト境界に揃えて追記し、[offset, 要素数] を返す
const b = Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength), pad = (4 - (this.off % 4)) % 4;
if (pad) { this.parts.push(Buffer.alloc(pad)); this.off += pad; }
const r = [this.off, arr.length]; this.parts.push(b); this.off += b.length; return r;
}
write(file, extra = {}) {
const js = Buffer.from(JSON.stringify({ items: this.items, ...extra }), 'utf8'), pad = (4 - (js.length % 4)) % 4;
const head = Buffer.alloc(8); head.writeUInt32LE(0x4b415048, 0); head.writeUInt32LE(js.length + pad, 4);
const raw = Buffer.concat([head, js, Buffer.alloc(pad, 0x20), ...this.parts]); // JSON の後ろは空白で詰める
fs.writeFileSync(file, zlib.deflateRawSync(raw, { level: 9 }));
}
}
JSON の後ろを空白(0x20)で詰めているので、JSON.parse はそのまま通り、ペイロードの先頭も 4 バイト境界に乗ります。ペイロード内の各配列も 4 バイト境界から始まるので、ブラウザ側では new Uint32Array(buf, offset, length) のようなビューをコピーなしで作れます。
ここまでで .bin ができますが、アプリは .bin を読みません。後処理の pack.mjs が base64 の .txt に置き換えます。
// pipeline/pack.mjs:2, 7-10(抜粋)
// The first host of the app could not serve binary file extensions, so the app reads d/*.txt and t/{b,m}.txt.
for (const [dir, ext] of [['d', '.bin'], ['t', '.pak']]) for (const f of fs.readdirSync(`${OUT}/${dir}`)) {
if (!f.endsWith(ext)) continue;
const src = `${OUT}/${dir}/${f}`, dst = src.slice(0, -ext.length) + '.txt';
fs.writeFileSync(dst, fs.readFileSync(src).toString('base64')); fs.rmSync(src);
}
読み込み側
ブラウザでは、fetch で受けたバイト列を 256 要素の逆引き表で base64 から戻し、DecompressionStream で展開します。atob は使わず、受け取った Uint8Array のまま処理しています。
// app/js/util.js:23-26, 28-35, 37-41(整形・抜粋)
export async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw'); const w = ds.writable.getWriter(); w.write(bytes); w.close();
return await new Response(ds.readable).arrayBuffer();
}
const B64 = new Uint8Array(256);
for (let i = 0; i < 64; i++) B64['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.charCodeAt(i)] = i;
export function b64bytes(src) { // src は Uint8Array のまま。末尾の = と改行・空白を除いて 4 文字 → 3 バイト
/* ... */
for (; i + 3 < n; i += 4) {
const v = (B64[src[i]] << 18) | (B64[src[i + 1]] << 12) | (B64[src[i + 2]] << 6) | B64[src[i + 3]];
out[o++] = v >> 16; out[o++] = (v >> 8) & 255; out[o++] = v & 255;
}
/* 端数 2〜3 文字の処理 */
}
export async function loadPak(url, onProg, expect) {
const buf = await inflateRaw(await fetchData(url, onProg, expect)), dv = new DataView(buf), jl = dv.getUint32(4, true);
const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, jl))), base = 8 + jl;
return { json, buf, base, view(T, ref) { return new T(buf, base + ref[0], ref[1]); } };
}
view(T, ref) は JSON の [offset, 要素数] を受けて TypedArray を返すだけなので、メッシュを何百個並べても、展開後の ArrayBuffer 1 本を共有します。
配布データを復号して数え直した
記事のために、配布中の app/d/*.txt を Node.js(v22)で復号し、中身を数え直しました。ブラウザと同じ順序(base64 → deflate-raw 展開 → ヘッダの JSON)を Node 標準の Buffer と zlib でなぞるスクリプトです。
// pakdump.mjs(記事用に作成)
import fs from 'node:fs'; import zlib from 'node:zlib';
const dir = process.argv[2];
for (const f of ['core', 'ctx', 'nav']) {
const b64 = fs.readFileSync(`${dir}/d/${f}.txt`, 'utf8').replace(/\s/g, '');
const bin = Buffer.from(b64, 'base64'), raw = zlib.inflateRawSync(bin);
const jl = raw.readUInt32LE(4), j = JSON.parse(raw.subarray(8, 8 + jl).toString('utf8'));
let tris = 0, verts = 0; for (const it of j.items) { verts += it.nv; tris += it.ni / 3; }
console.log(f, 'txt', b64.length, 'bin', bin.length, 'raw', raw.length, 'items', j.items?.length, 'verts', verts, 'tris', Math.round(tris));
}
| ファイル | .txt | 展開後 | 頂点 | 三角形 |
|---|---|---|---|---|
core.txt(舞台の中心) |
15,216,300 B | 約 33.6 MB | 1,931,839 | 646,087 |
ctx.txt(周囲の簡略表示) |
約 5.7 MB | 約 9.3 MB | 622,524 | 207,508 |
nav.txt(歩行・高さのグリッド) |
約 3.0 MB | 約 14.2 MB | - | - |
core は 195 メッシュで、内訳はテクスチャ付き LOD3 が 63、LOD2 が 23、街路設備(テクスチャ付き)が 15、テクスチャなし街路設備が 89 などです。三角形数は README の「約 64.6 万三角形」と一致しました。core.txt の 15,216,300 バイトは、world.js:407 に進捗表示用の期待値として書かれた数値とも一致します。
base64 化前の core.bin は 11,412,225 バイトで、.txt との比は 1.333 です。deflate で 33.6 MB を約 34% に縮め、base64 で 33% 戻している計算になります。
読み込みの順番は次のとおりです(world.js:402-411)。
テクスチャ予算: 見えている割合でアトラスを詰め直す
段 4 の atlas.mjs は、PLATEAU の元のテクスチャアトラスを場所ごとに作り直します。UV の連結成分(チャート)ごとに縮小率を決め、地物を Morton 曲線(Z-order 曲線)の順に並べてから 2048 px のアトラスへ詰め直します。近い地物が同じアトラスに入るので、アトラス単位で「近いものだけ高解像度」が成立します。
// pipeline/atlas.mjs:51-52(整形・抜粋)
for (const c of charts) {
let s = BASE_SCALE; const visShare = c.vis / Math.max(1e-6, c.area);
if (visShare < 0.25) s *= (c.roof >= c.hid ? ROOF_SCALE : HIDDEN_SCALE); // 既定はどちらも 0.25
const dens = Math.sqrt(w * h / Math.max(0.01, c.area)); // 1 m あたりの画素数
while (dens * s > MAXDENS && s > 1 / 16) s /= 2; // 既定 128 px/m
}
vis と hid は、壁の三角形の重心から法線方向へ 0.45 m から 0.2 m 刻み(上限 1.25 m)で探り、高さラスタ上で隣の建物に当たるかどうかで分けた面積です(atlas.mjs:42-44)。見える面が 25% 未満のチャートは、屋根が多ければ屋根として、そうでなければ隠れた壁として 1/4 に縮めます。
詰め直したアトラスは 4 段階の WebP にします。
// pipeline/atlas.mjs:94([名前, 縮小率, WebP 品質])
const lv = [['u', 1, 80], ['h', 2, 78], ['m', 4, 76], ['b', 8, 74]];
実行時は、カメラからアトラスの AABB までの距離で段階を選びます(world.js:75-76)。
let want = d < Q.texU ? 3 : d < Q.texH ? 2 : d < Q.texM ? 1 : 0; if (want > Q.texMax) want = Q.texMax;
高画質の設定では 24 m 以内が最高段階、70 m 以内が高、190 m 以内が中です。中・軽の設定は texMax: 2 で最高段階を読みません(main.js:14-16)。リポジトリの docs/article.md によると、各段階の合計は最小 1.2 MB、中 3.2 MB、高 6.6 MB(101 枚)、最高 17.3 MB(101 枚)です。最小と中は {名前: [offset, length]} の索引を付けて 1 ファイルにまとめ、高と最高は個別の WebP として必要な分だけ取りに行きます。WebP はすでに圧縮済みなので、こちらは deflate を掛けずに連結だけしています(export.mjs:86)。
ハトの目の 320° パノラマを CubeCamera で描く
ドバトの視野は左右合わせて約 320° とされ、両眼で重なる帯は最大 27° です(27° は Martin & Young 1983 の計測で、出典は NOTICE.md にあります)。人間向けの 60° のカメラでは、この広さは出せません。そこで頭の位置に CubeCamera を置いて 6 面を描き、全画面のシェーダで引き直しています。

ヘッドレス Chrome(ソフトウェア描画・q=low)で撮影した、ハトの目のパノラマ。地形・建物は国土交通省 Project PLATEAU と国土地理院の地理院タイルを加工して作成しています。遠景の地表の色には、Landsat8 画像(GSI, TSIC, GEO Grid/AIST、courtesy of the U.S. Geological Survey)と海底地形(GEBCO)に由来するデータが含まれる場合があります。
// app/js/post.js:40-48(整形)
uniform samplerCube cube; uniform vec3 uF, uR, uUp; uniform float asp, hfov, vfov, binoc, rows;
void main(){ float yaw, pit;
if (rows > 1.5) { // 縦持ち: 上段に左目、下段に右目
float top = step(0.5, vUv.y), v = fract(vUv.y * 2.0);
yaw = mix(mix(-0.2618, -hfov * 0.5, top), mix(hfov * 0.5, 0.2618, top), vUv.x); pit = (v - 0.5) * vfov;
} else { yaw = (vUv.x - 0.5) * hfov; pit = (vUv.y - 0.5) * vfov; }
vec3 d = cos(pit) * (cos(yaw) * uF + sin(yaw) * uR) + sin(pit) * uUp;
vec4 c = textureCube(cube, d);
float ay = abs(yaw);
c.rgb *= 1.0 - smoothstep(hfov * 0.5 - 0.14, hfov * 0.5, ay); // 視野の端は死角へ
c.rgb *= 1.0 + 0.06 * (1.0 - smoothstep(binoc - 0.02, binoc + 0.02, ay)); // 両眼視の帯を 6% 明るく
if (rows > 1.5) c.rgb *= smoothstep(0.0, 0.004, abs(vUv.y - 0.5)); // 上下段の境目
gl_FragColor = c; }
画面の横を方位角、縦を仰角に線形に割り当てる正距円筒型の展開です。binoc は 0.2356 rad(13.5°)で、左右合わせて 27° の帯になります。縦持ち(幅 ÷ 高さが 0.9 未満、game.js:156)では上下 2 段に分け、各段が −160°〜+15° と −15°〜+160° を受け持ちます。
// app/js/post.js:74-77(整形・抜粋)
if (!this.cubeRT) {
this.cubeRT = new THREE.WebGLCubeRenderTarget(Q.panoSize, { type: this.type, generateMipmaps: false });
this.cubeCam = new THREE.CubeCamera(0.012, 6000, this.cubeRT); // near は 1.2 cm
}
this.cubeCam.position.copy(eye); this.cubeCam.updateMatrixWorld(); this.cubeCam.update(r, scene);
const hf = 320 * Math.PI / 180, rows = this.panoRows || 1;
u.vfov.value = rows > 1 ? Math.min(1.75, (hf / 2 + 0.2618) * (this.h / 2) / this.w) : Math.min(2.0, hf * this.h / this.w);
縦の画角は、横 320° と同じ角度密度になるよう h / w を掛け、2.0 rad(約 115°)で頭打ちにしています。1280×720 なら計算上 3.14 rad になるので、2.0 rad が使われます。
CubeCamera はシーンを 6 回描きます。面の解像度は画質ごとに 768 / 512 / 384 px(main.js:14-16)で、高画質なら 768² × 6 ≈ 354 万画素と、1280×720(約 92 万画素)の約 3.8 倍を描く計算です。軽では 384² × 6 ≈ 88 万画素に収まります。なお、パノラマ表示中は水面の反射パスを描きません(main.js:47-49)。
確認した範囲と、確認していないこと
執筆にあたり、次の 3 点を実行して確認しました。
-
配布データの復号: 上の
pakdump.mjsで、三角形数・頂点数・ファイルサイズを数え直しました -
構文チェック: リポジトリの
tools/jscheck.shを実行し、all modules parseの出力を確認しました(node --checkは.jsをモジュールとして扱わないため、.mjsに写して調べるスクリプトです) -
ヘッドレス描画:
app/をpython3 -m http.serverで配信し、ヘッドレス Chrome(SwiftShader によるソフトウェア描画)で?auto=1&m=eye&t=9.6&q=lowを撮影しました。ハトの目のパノラマが描かれ、「左目の視野」「両眼視 約27°」「右目の視野」「死角」のラベルとくちばしが出ることを画像で確認しました
google-chrome --headless=new --no-sandbox --use-angle=swiftshader --use-gl=angle \
--enable-unsafe-swiftshader --ignore-gpu-blocklist --window-size=1280,720 \
--virtual-time-budget=120000 --screenshot=shot_eye.png \
"http://localhost:8765/?auto=1&m=eye&t=9.6&q=low"
一方、次の点は確認していません。
- パイプライン(段 1〜6)の再実行。入力の 3D Tiles(約 1 GB)を取得していません。pipeline/README.md も、確かめたのは最後の段(export と pack)の出力がバイト単位で一致することだけだと書いています
- 実機の GPU でのフレームレート、スマートフォンでの動作
- Playwright を使う 3 エンジンの自動チェック(
tools/run_checks.sh)と、ブラウザのコンソールエラー
コードを読み直して気づいたこと
記事のためにコードを読み直すと、手を入れる余地が 3 つありました。いずれも未検証の改善候補です。
1 つ目は、位置の量子化が転送サイズにしか効いていない点です。法線・色・UV は正規化属性として GPU へそのまま渡していますが、位置だけは読み込み時に Float32 へ戻しています(world.js:414-416)。core の 1,931,839 頂点なら、Float32 の位置だけで約 23 MB の頂点バッファになる計算です。Uint16 の正規化属性のまま渡し、AABB の原点と幅をメッシュの position と scale に持たせれば半分にできるはずです。ただし、当たり判定など位置配列を CPU で使う箇所との兼ね合いは調べていません。
2 つ目は、base64 化が独立した後処理になっている点です。pack.mjs のコメントによると、アプリの最初のホストはバイナリ拡張子を配れませんでした。バイナリを配れる静的ホスティングに移すなら、pack.mjs を外し、読み込み側の fetchData を fetchBytes に、参照パスを .bin に差し替えるだけで、core だけでファイルサイズが約 3.8 MB(15.2 MB − 11.4 MB)小さくなります(配信時の転送量では、ホストの圧縮設定しだいで差が変わるため未確認です)。制約への対処を変換パイプライン本体に混ぜず、外せる形で残しておいたのは良い分け方だと筆者は考えています。
3 つ目は細かい点で、読み込み側はヘッダのマジックナンバー(0x4b415048)を検査していません(util.js:38 はオフセット 4 の JSON 長だけを読みます)。別のファイルを取り違えても JSON.parse まで進んでから失敗します。また、AABB は小数 3 桁に丸めて保存しているのに、量子化は丸める前の値で行っているので、復元位置は最大 0.5 mm ほどずれます。ハトの目の near が 1.2 cm であることを考えると、実害はない大きさです。
おわりに
約 1 GB の 3D Tiles を 54 MB にした手段は、特別なライブラリではなく、量子化・焼き込み・見えない面の切り捨て・距離による出し入れという素朴な手の組み合わせでした。その上で「バイナリを置けない」というホストの制約を、base64 の後処理と DecompressionStream の 2 か所だけで吸収しています。
PLATEAU のデータを Web で見せたい場合、3D Tiles をそのままストリーミングする方法のほかに、範囲を絞って実行時専用の形式へ作り直す道があります。作品は こちらのアーティファクト で動かせます。ソースは GitHub にあります。
出典・ライセンス・参考リンク
本作品は、国土交通省 Project PLATEAU の 3D 都市モデル(京都市、2025 年度)と、国土地理院の地理院タイル(標高タイル、シームレス空中写真)を加工して作成したものです。国土交通省、国土地理院、京都市が内容を保証するものではありません。データごとの出典と利用条件は NOTICE.md にあります。コードの抜粋は MIT License(Copyright (c) 2026 kai-kou)のもとにあります。PLATEAU・地理院由来のデータ(app/d と app/t)と、それを使った画像は、元データの利用条件に従います。