4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

街の建物を「ワイヤーフレームの幽霊」としてスマートグラスに出す — PLATEAU × Even G2

4
Last updated at Posted at 2026-09-08

前回、Even Realities G2 に東京の電車・バスを映す「東京スカウター」を作った話を書きました。

今回はその続きで、国土交通省 PLATEAU の 3D 都市モデルを「線」だけに落として、歩いている方向にある建物をワイヤーフレームの立体としてメガネに出す アプリ「CITYGHOST」を作りました。

前作が「街を俯瞰する地図」だったのに対し、今回は「目の前の 1 棟を立体で」です。同じハードウェアでも、扱うデータが 3D になった途端に別の戦いが始まりました。この記事は主に PLATEAU を 90 バイト/棟まで削る話と、実装してみて初めて分かった落とし穴 の記録です。


0. Even Hubでリリースされました


1. TL;DR

  • PLATEAU の 3D Tiles(b3dm / Draco 圧縮)をビルド時に接地面の輪郭と高さだけへ落とす。LOD1 なら 1 棟 90 バイト
  • 7 市区 11,573 棟で 1.3 MB。プラグインに同梱するので実行時の通信はゼロ。network 権限すら宣言していない
  • ★頂点を溶接しないと、もっともらしい嘘の数字が出る★ 初版の設計は「1 棟 14.3 頂点」と書いていたが、実際は 平均 5.8 頂点
  • ★image container に渡すのは PNG。4bit にパックした生バイトではない★ これで半日溶かした
  • ★政令指定都市は区ごとに tileset が分かれている★ children[0] しか見ていなくて、大阪で「タイル 0 枚」を静かに引いた
  • 選び方を 3 モードに増やして、1 つに戻した。増やしたこと自体が設計の失敗だった
  • 実機で 3 回転すると止まる。まだ直っていません(§11)

2. CITYGHOST とは

歩いている方向 ±50° の範囲で、いちばん近い建物を 1 棟だけメガネに出します。その建物がゆっくり回り、実際の方位を向いた瞬間に一拍止まる(ヒッチ)ので、画面の中の向きと目の前の街の向きが結びつきます。

スクリーンショット 2026-09-09 0.36.02.png

原則は前作から引き継いだうえで、2 つ足しました。

1. Glance-first ちらっと見るだけで価値が出る
2. Glass-first 日常運用はメガネだけ
3. No input required 文字入力なし、メニュー深掘りなし
4. Self-explanatory 凡例なしで分かる
5. Quiet by default 近くに何も無ければ何も出さない
6. One at a time 同時に出すのは 1 棟だけ。密度で勝負しない
7. Offline by default 実行時にネットワークへ依存しない。地下でも動く

ナビゲーションはしません。 経路も曲がる方向も出しません。行きたくなったらスマホ側から Google マップ / Apple 地図に渡します。「案内は既存アプリで足りる」という判断の帰結です。


3. アーキテクチャ全景

前作といちばん違うのは、ビルド時と実行時がはっきり分かれていることです。

これがもたらすもの:

オフライン動作 地下・機内・圏外でも動く
PLATEAU の更新に壊されない 年度更新でエイリアスが変わってもビルドし直すだけ
API キーもプロキシも要らない 前作が ODPT のために必要とした CloudFront proxy に相当するものが存在しない
インフラがほぼ要らない ★実行時に必要な AWS リソースはゼロ★(§9)

4. PLATEAU を「線」にする

ここが今回の中核です。

PLATEAU の建築物モデルは 3D Tiles で配信されています。安定したエイリアスがあり、年度が変わっても URL が変わりません。

https://api.plateauview.mlit.go.jp/datacatalog/3dtiles/{code}-bldg-lod{n}-texture-latest/tileset.json

4.1 パイプライン

4.2 ★頂点を溶接しないと、もっともらしい嘘の数字が出る★

これが今回いちばん恥ずかしい間違いでした。

接地面の輪郭を取るには「1 回しか現れないエッジ(境界エッジ)」を集めれば良い、という定石があります。設計書の初版はこれで「1 棟あたり平均 14.3 頂点」と見積もっていました。

PLATEAU の b3dm の頂点は完全に分離しています。 実測した 1 タイルは:

頂点 37,068 = 3 × 12,356 三角形

つまり共有されている頂点がひとつも無い。この状態で「1 回しか現れないエッジ」を数えると、全部のエッジが 1 回しか現れないので、三角形数 × 3 が出てくるだけです。それを頂点数だと思い込んでいました。

先に 1 mm で量子化して溶接してから数えると:

溶接前 溶接後
1 棟あたりの環の頂点数 14.3(嘘) 平均 5.8 / 中央値 4

中央値 4 というのは、要するに PLATEAU の LOD1 建物の大半は四角い箱だということです。これが分かって初めて「1 棟 90 バイト」という設計が立ちました。

const WELD_MM = 0.001;
export function weld(position, indices) {
  // 1mm に量子化してキーを作り、同じ座標の頂点をひとつにまとめて面を張り替える
}

教訓: それらしい数字が出たときこそ、出どころを疑う。14.3 は「もっともらしい」ので通ってしまいました。

4.3 refine: ADD — 先に見つけたほうを採ると塔が途中で切れる

3D Tiles の refine: ADD は、子タイルが親タイルに足される方式です。つまり同じ建物が親タイルにも粗いコピーとして入っています。

東京タワーで実測した値:

三角形 復元される高さ
浅いタイル(親) 14 72 m
葉タイル 379 332 m

gml_id で重複を除くとき、素直に「先に見つけたほうを採用」すると、塔が 72 m で切れます。 三角形が多いほうを勝たせるように直しました。今のデータでは 1,334 棟がこれで差し替わっています。

// ★refine: ADD なので同じ建物が親タイルにも入っている★
const prev = best.get(b.gmlId);
if (prev) { if (b.tris > prev.tris) best.set(b.gmlId, b); continue; }

4.4 LOD1 に東京タワーは入っていない

これも実装するまで気づきませんでした。同じ gml_id・同じ bldg:measuredHeight(332.1 m)の建物なのに、LOD1 と LOD2 で別物が返ってきます。

fig-lod1-vs-lod2.png

LOD1 側は 42.1 m の箱、つまり足元の建屋だけです。塔そのものは LOD2 にしか入っていません。

「識別できること」が要件の建物は LOD2 で焼くしかない。 そこで街ごとに「一般建物は LOD1・ランドマークは LOD2」の 2 パスを回す形にしました。

LOD2 の特徴線(二面角がしきい値を超えるエッジ + 境界エッジ)を長い順に 90 本まで残すと、こうなります。

fig-landmarks.png

4.5 ★政令指定都市は区ごとに tileset が分かれている★

京都と大阪を足したときに踏みました。エイリアスを叩くと 200 が返り、root.children も空ではないのに、「重なるタイル 0 枚」で静かに何も返ってこない。

原因は、エイリアスの root.children が 1 つ 1 つ外部 tileset への参照(content.uri が .json)で、政令指定都市は区ごとに分かれていることでした。

コード 市 children の数
11100 さいたま市 10(LOD1)
26100 京都市 区ごと
27100 大阪市 区ごと
13103 港区 1(単独の区なので)

こちらのコードは children[0] しか見ていませんでした。港区は 1 枚しか無いので誰も気づかない。大阪で梅田(北区)を指定すると children[0] は浪速区なので 0 枚。京都は LOD2 だけ偶然当たり(children[0] が中京区)、LOD1 は右京区なので 0 枚 — 同じ市なのに LOD によって結果が変わる、という分かりにくい壊れ方をしていました。

bbox に重なる区だけを解決して合流するように直しました。

export async function resolveTilesets(code, lod, bbox) {
  const wrapper = await fetchJson(ALIAS(code, lod));
  const wanted = (wrapper.root?.children ?? [])
    .filter((c) => c.content?.uri && overlaps(c.boundingVolume?.region, bbox));
  // ...重なる区だけ取りに行く
}

教訓: 「200 が返ること」も「children が空でないこと」も、存在証明にならない。

4.6 wires.bin — 1 棟 90 バイト

座標 建物ローカル ENU、0.1 m 刻みの int16
高さ・標高 0.1 m 刻み
名前・住所 文字列プール(11,573 棟に対して 85 件しか無い)
LOD1 90 B / 棟
LOD2(特徴線あり) 1.1 KB / 棟

現在の同梱データ:

コード 市区 LOD1 LOD2
08220 茨城県つくば市 1,623 —
13103 東京都港区 801 23
11100 埼玉県さいたま市 1,973 33
14205 神奈川県藤沢市 2,808 161
26100 京都府京都市 2,180 65
26202 京都府舞鶴市 1,318 35
27100 大阪府大阪市 683 89

計 11,573 棟 / 1,295 KB(元タイルは 200 MB 以上。約 200 分の 1)。.ehpk にすると 684 KB です。

.ehpk のサイズ上限が気になって CLI を実測したところ、バイト数の上限はありませんでした(100 MB でも通る)。効くのはファイル数 65,535だけです(20,001 は通り、65,537 で Too many files in the project)。


5. 200×200 をどう作るか

5.1 image container 2 枚積み

G2 の image container は 1 枚 200×100 が上限。立体を回すには縦が足りないので、2 枚を縦に積んで 200×200 にしました。

正方形にする理由は、回転する立体は縦横比が変わるからです。横長にすると背の高い建物が入らず、縦長にすると平べったい建物が寂しくなります。

必ず 1 枚のオフスクリーン 200×200 に描いてから上下に割ります(別々に描くと境目がずれる)。

5.2 ★渡すのは PNG。4bit にパックした生バイトではない★

今回いちばん時間を溶かした落とし穴です。

シミュレータに繋ぐと、左のテキストは出るのに右の絵がまったく出ない。エラーも出ない。

原因は、updateImageRawData に 4bit にパックした生バイトを渡していたことでした。根拠は SDK の戻り値の定義そのものにあります。

ImageRawDataUpdateResult.imageToGray4Failed   // 图片转黑白图失败

gray4 への量子化はホスト側の仕事です。つまり渡すのは「ホストが画像としてデコードできるバイト列」でなければならない。画像でないものを渡すと、エラーにもならずに何も描かれません。

前作の 2 つの実装(map-renderer.ts と sonar-renderer.ts)を「PNG か生バイトか、選べる 2 経路」だと読み違えていたのですが、見直したらどちらも PNG を送っていました。4bit パッキングは描画側の内部表現であって、送出形式ではなかったのです。

// ★無彩色で書く★ ホストは輝度で 16 階調に落とすので、
//   R=G=B にしておけば 0–15 がそのまま階調として通る。
const v = (gray[i] & 0x0f) * 17
d[p] = d[p + 1] = d[p + 2] = v

緑で描いてはいけません。輝度が 0.587 倍になって階調が潰れます。

教訓: 「エラーも出ずに何も起きない」ときは、戻り値の型定義を読む。imageToGray4Failed という値がある時点で、ホストが decode していることは分かったはずでした。

5.3 デプスキューは水平成分だけを見る

線に明るさの差をつけて奥行きを出しています。最初は「視点からの真の深さ」で明るさを決めていましたが、高さが支配してしまって前後の手がかりが消えました(上が明るく下が暗いだけの絵になる)。

return {
  x: cxPix + scale * ex,
  y: cyPix - scale * (u * ct - nx * st),
  // ★奥行きは水平成分だけを見る★
  d: nx,
}

5.4 回転とヒッチ

なめらかには回しません。BLE 越しに 200×100 を 2 枚送る以上、なめらかな回転は物理的に無理があります。12° ずつカクッと回るのを仕様にしました。ホログラムらしく見えるので体験としてもこちらが良い。

そのうえで、画面の奥行きが自分から見た建物の方位に一致した瞬間に 1.5 秒止まります(ヒッチ)。これで方位が、矢印も方位テキストも読まずに伝わります。使うのは自分の位置と建物の位置だけなので、IMU も heading 推定も要りません。


6. 選び方 — 3 モードに増やして、1 つに戻した

設計の失敗として残しておきます。

初版は「見かけの高さ角(高さ ÷ 距離)が最大の 1 棟」で選んでいました。「100 m 先の 10 m のビルより、1 km 先の 332 m のタワーのほうが近くに感じる」という理屈です。都心では良く働きました。

つくば市で破綻しました。

高さ 距離 height/distance
目の前の物置 3 m 5 m 0.60
市立谷田部東中学校 11.7 m 300 m 0.04

物置が中学校に 15 倍差で圧勝します。 高さの差が小さい街では、見かけの高さ角は「どれだけ目の前にあるか」しか測れません。

そこで「一番近い」モードを足し、さらに「向いている方向」モードを足して 3 つにしました。これが誤りでした。 街ごとにユーザーが正解のモードを選ぶ設計は、原則 3(No input required)に正面から反しています。しかも「今どっちのルールか」を常に画面に出す必要が生まれ、ヘッダとスマホ画面の両方を食っていました。

★前方 ±50° の扇で切ると、そもそも高さで重み付けする理由が消えます★

        \   50°   /
          \     /          全方位から 1 棟を選ぼうとするから
            \ /            「遠くても大きいもの」が必要だった。
             ●  ← 自分       前方に絞れば、その中で一番近いものが
             ↑進行方向        素直に「目の前にあるもの」になる。
if (heading !== null && angleDiff(bearing, heading) > 50) continue
if (distanceM > 500 * radiusScale) continue
score = 1 / distanceM        // 高さは使わない

モード切替 UI は消えました。 1 → 3 → 1 の往復です。

残った出し分けは方位の有無だけです。GPS の heading は進行方向(course over ground)なので、立ち止まると null になります。そのときは扇を外して全方位の一番近いに落としますが、黙って切り替えると「なぜ違う建物が出たのか」が分からないので、ヘッダで区別します。

ヘッダ左 ヘッダ右(画面の上が指すもの)
方位あり 前方 ↑進む方向
方位なし 周囲 ↑北

G2 には IMU があり imuControl で値が取れますが、来るのは x / y / z の 3 値だけで、磁北基準の方位は取れません。SDK にコンパスの API は無く、x/y/z が加速度なのか姿勢角なのかも公開されていない。「頭がどっちを向いているか」を使うのは、実機で当たりを付けてからの課題です。


7. 「他にもある」を線で描く

1 棟しか出さないと、見ている側には**「今わざと絞っている」のか「他に何も無い」のかが区別できません。** 原則 6 の副作用です。

そこで、主役から外れた候補が左右どちらにあるかを、キャンバスの端に山形で出しました。明るさは主役に対する見かけの大きさの比、2 枚重なっていればその側に 2 件以上あります。

ここでグリフの罠を踏みかけました。 ◀(U+25C0) / ▶(U+25B6) は JIS X 0208 に無いので、G2 の LVGL フォントでは □ に化けます(前作の §4.4 で書いた話がそのまま効いてきました)。← → は出ますが、テキストコンテナは 200px の image の外側にあります。

キャンバスに線で描けばグリフの問題そのものが起きません。 建物の絵を描く関数には手を入れず、後から重ねる別モジュールにしました。


8. 多言語 — 「宣言できること」と「出せること」は別

app.json の supported_languages は en / de / fr / es / it / zh / ja / ko を受け付けます。しかし受け付けることと出せることは別でした。

G2 のテキストは LVGL の単一固定フォントで、収録グリフはおおむね ASCII + JIS X 0208 + ごく一部の記号しかありません。

出る 英数字・かな・漢字・□ ● ▲ ▼ ・ ↑ → ← ↓
出ない 絵文字、⚠(U+26A0)、▢(U+25A2)。□ に化けて glyph dsc. not found が大量に出る

ko(ハングル)と zh の簡体字専用グリフは JIS X 0208 に無いので化けます。 de/fr/es/it のアクセント付きラテン文字も収録が怪しく、未検証。というわけで ja / en に絞りました。

それ以外を出したいなら道はあります — image container に canvas で焼き込む(前作が 9–10px の小さい文字でやっている手)。任意のグリフが出せる代わりに、テキストの部分更新(flicker-free)が使えなくなります。

なお、ホストの UI 言語を読む API はありません。SDK の getLocalStorage('language') はただの KV ストアの例であって、Even App の言語設定ではない。端末ロケール(navigator.language)で決めるしかありませんでした。

固有名詞は翻訳していません。PLATEAU の gml:name / bldg:address は日本語しか入っておらず、ローマ字化には読みの辞書が要るためです。英語表示でも建物名と住所は日本語のまま出しています(フォントは出せる)。


9. CloudFront を建てかけて、捨てた

前作では ODPT の CORS プロキシとして CloudFront が必須でした。今回も同じ調子で terraform/ に S3 + CloudFront + OAC + ACM を書きました。理由は「make qr は LAN 内の dev server を指すので、外出先の実機で試せない。歩いて確かめる企画なのに」でした。

この前提が間違っていました。 Even Hub には Private builds があります。

  1. .ehpk を dev portal の Private builds にアップロード
  2. スマホの Even Realities App → Me → Apps → Private builds → Install
  3. 数秒でメガネに入り、Released のアプリと同じようにメガネのホームから起動する

サーバが要らない。 CITYGHOST は実行時に通信しないので、インストールさえ済めば電波の無いところでも歩き回れます。

したいこと 使うもの インフラ
手元で素早く回す(HMR) make dev + make qr(同じ Wi-Fi・http://) 不要
外を歩いて確かめる Private builds 不要
他人に配る Beta testing 不要
公開 Even Hub の審査 不要

書いた terraform は 一度も apply していません。 消さずに残しているのは、街を増やして起動時のメモリや landmarks.json(467 KB)の parse が効いてきたら、wires.bin だけを CDN から初回 1 回落とす形に変えるときに要るからです。

教訓: 前作で必要だったものが、今作でも必要とは限らない。「なぜ要るのか」を書き出すと、要らないことに気づけます。


10. 審査の準備で見つかった 3 つ

まだ提出していませんが、App Submission & QA Guidelines を読んで自己点検したら、そのままでは落ちる箇所が見つかりました。

10.1 min_sdk_version が足りていなかった

規則は「現在 0.0.14 以上」。こちらは 0.0.10 でした。SDK を 0.0.15 に上げ、min_app_version は app.json から削除しました(公式が「手で書かない、pack が SDK から導出する」と明記)。副作用として、対応する Even App の下限が 2.2.10 に上がります。

ついでに changelog を読んだら、0.0.11 でホスト側の位置情報 API が入っていました(startAppLocationUpdates)。これが次の項目に効きます。

10.2 ★ブラックスクリーン禁止★

「セットアップが要るときは、何をすればよいかをメガネ側に出すこと。黒い画面のままにしないこと」

本番ビルドは位置が来るまで名前欄が空で、実質これに抵触していました。4 状態すべてに文言を入れました。

状態 メガネに出るもの
起動直後(位置を待っている) 現在地を取得しています…
位置情報が拒否された 位置情報が許可されていません / …許可してください
12 秒経っても測位できない 現在地がまだ取れません / 空の見える場所でお待ちください
測位できたが範囲外 — 近くに登録された建物はありません —

もうひとつ、審査項目にこれがあります。

「スマホをロックし Even App を背面に回した状態で、メガネから起動したアプリが妥当な時間で描画されること。無限スピナー・ブラックスクリーンは不可」

WebView の navigator.geolocation は、まさにその状態で止まります。 10.1 で見つけたホスト側の測位 API に切り替えました。ホスト(Flutter)が測位して WebView に押し込む経路なので、バックグラウンドでも生き続ける見込みです(実機で要確認)。

10.3 アイコンは 24×24 のドット絵、しかもブラシが 2x2

portal の Create an icon は画像アップロードではなくマス目に描くエディタでした。24×24 で、ブラシが 2x2 しか無い — つまり 実効解像度は 12×12 です。1 マス幅の線が引けないので、ワイヤーフレーム(線画)は成立しません。

規則も 3 つ明文化されています。

  • モノクロ / グレースケールのみ。カラーは却下
  • foreground と background の両方を出す(null 不可)
  • 判読できること。「黒い落書き・ノイズな模様」は不可

塗り絵として描き直しました。ここでもうひとつ引っかかりました — 最初に描いた「丸い頭 + 縦の側面 + 等間隔な 3 つの波の裾 + 四角い目」は、パックマンのゴーストとほぼ同じ形でした。既存キャラクターに寄るのは避けたいので、系統ごと振り直して人魂型(頭があり尾を左下へ引く)にしています。

     0  1  2  3  4  5  6  7  8  9 10 11
 1   .  .  .  .  .  #  #  #  #  .  .  .
 2   .  .  .  .  #  #  #  #  #  #  .  .
 3   .  .  .  #  #  #  #  #  #  #  .  .
 4   .  .  .  #  #  .  #  #  .  #  .  .   ← 目(塗り残し)
 5   .  .  .  #  #  #  #  #  #  #  .  .
 6   .  .  .  #  #  #  #  #  #  .  .  .
 7   .  .  #  #  #  #  #  #  .  .  .  .
 8   .  .  #  #  #  #  #  .  .  .  .  .
 9   .  #  #  #  #  #  .  .  .  .  .  .
10   .  #  #  #  .  .  .  .  .  .  .  .

fig-icon.png


11. ★未解決★ 実機で 3 回転すると止まる

執筆時点で直っていません。 せっかくなので、今どこまで絞れているかを書いておきます。

症状は「ワイヤーの画像が 3 回転ぐらいで動きが止まる」。1 周は 29 × 200ms + ヒッチ 1500ms ≒ 7.3 秒なので、22 秒前後・画像にして 180 回の送信で止まっている計算になります。

回転側は容疑者から外れました。paused はどこからも立たず、ヒッチは毎周リセットされるので、Rotation が永久に止まる経路がありません。

残るのは送信側です。

await している Promise が settle しなければ inFlight が true のまま残り、以後のフレームは pending を書き換えるだけになります。タイムアウトを一切置いていないのが効いています。

なぜ応答が返らないのかは 2 説あります。

  1. BLE 越しの応答が 180 回に 1 回落ちる — SDK は postMessage の往復で、落ちたら永久に待つ
  2. 送りすぎ — 2 枚 × 5 fps = 10 images/sec。SDK のドキュメントには「グラスのメモリが限られているので画像を頻繁に送りすぎないこと」と書いてある。キューが埋まって死んでいる、という筋

3 回転もつ、というのはキューが埋まっていく挙動によく合います。

対策の方針は決まっていて、次はこれを入れます。

  • すべてのブリッジ呼び出しにタイムアウト(詰まらせない)
  • テキスト更新と画像送信をひとつのキューに直列化(BLE で交互に投げない)
  • 往復時間を測って送信間隔を自動で伸ばす(back off)
  • 連続失敗時に rebuildPageContainer で復帰

実機を持っていない状態で「たぶん動く」と書いたものは、だいたい動きません。 前作でも同じ結論に達したはずなのに、また同じところに来ました。


12. これから

  • §11 を直す(最優先)
  • 実機での fps 実測。遅ければ刻み角を 12° → 15° / 20° に落とす
  • tools/pick-landmarks.mjs — OSM から建物名を焼く。今は名前がほぼ空で、候補一覧が住所ばかりになる
  • 街を増やす。1 市区あたり 90 KB 前後なので、.ehpk の上限より先に起動時の JSON.parse とメモリが効いてくるはず
  • IMU の x/y/z が何なのかを実機で当てる。頭の向きが取れれば「前方」が本当の意味で成立する

13. まとめ

PLATEAU を線に落とす部分は、実測しないと分からないことばかりでした。溶接していない頂点、refine: ADD の粗いコピー、LOD1 に入っていない塔、区ごとに分かれた tileset。どれも「200 が返ってくる」「それらしい数字が出る」という理由で、間違ったまま先に進めてしまう種類の落とし穴です。

一方で G2 側は、前作で学んだグリフの制約がそのまま効いてきました。◀ が使えないと分かっていたから、線で描くほうへ早めに舵を切れました。同じハードで 2 本目を作る価値はここにあります。

設計の失敗としていちばん残しておきたいのは §6 です。選び方を増やしたこと自体が誤りで、扇で切ったら増やす理由が消えました。 機能を足す前に、前提を狭められないかを見るべきでした。

そして §11 のとおり、まだ実機で 3 回転しか回っていません。 続きはまた書きます。


参考リンク

Even Realities

PLATEAU

3D Tiles / Draco

前作


出典表記: 建物形状は国土交通省 PLATEAU(3D 都市モデル)を加工して作成しています(PDL1.0)。建物名の一部は © OpenStreetMap contributors(ODbL)によります。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?