モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 
webgpu / instanced rendering

軌跡を、点群で描く
WebGPU で GPS ログを可視化する

2時間半歩いて、8,989点が記録されました。1秒に1点、11.12km。 これを地図に線として引くと、ただの経路になります。 一点ずつ、属性で色をつけて描いたら何が見えるか—— ログの銀河で作った描画を、 今度は自分の足跡に向けました。

WebGPU WGSL インスタンス描画 TCX 8,989点

[ § 0 ] 導入

線にすると、消えるもの

GPSログを可視化するとき、まず思いつくのは地図に線を引くことです。 実際それが目的に合っている場面は多い。どこを通ったかが分かります。

ただ、線にした瞬間に各点が持っていた情報が消えます。 速度、心拍、標高、時刻。1点ごとに5つの値があるのに、 線は「通った」ことしか表しません

記録されていた値範囲
座標(緯度・経度)南北 6,403m × 東西 1,275m
標高2.5 〜 38.7 m
心拍90 〜 135 bpm
速度(累積距離から算出)中央値 4.9 km/h
時刻2時間31分

点のまま描いて、属性を色に割り当てる。 それだけで、地図には出てこないものが見えるはずです。

[ § 1 ] データ

1秒に1点、という密度

使ったのは TCX ファイルです。ランニングウォッチの標準的な出力形式で、 <Trackpoint> ごとに時刻・座標・標高・累積距離・心拍が入っています。

exercise_tcx_file.tcx
<Trackpoint>
  <Time>2026-07-15T06:43:51+09:00</Time>
  <Position>
    <LatitudeDegrees>35.639818</LatitudeDegrees>
    <LongitudeDegrees>139.740607</LongitudeDegrees>
  </Position>
  <AltitudeMeters>6.11</AltitudeMeters>
  <DistanceMeters>0.0</DistanceMeters>
  <HeartRateBpm><Value>101</Value></HeartRateBpm>
</Trackpoint>
項目
トラックポイント数8,989 点
サンプリング間隔平均 1.01 秒(実質 1Hz)
総距離11.12 km
総時間2時間31分
点の間隔約 1.2 m
銀河との違い

アクセスログの可視化では、時刻 × サブネット × 分類の3軸でした。GPSは位置が2軸あり、そこに標高・心拍・速度・時刻が乗ります。次元が多いぶん、何を色に割り当てるかで見え方が変わります。

[ § 2 ] 転送

8,989点を、どう持たせるか

全点をブラウザへ渡したいのですが、JSONで素直に書くと 375KB になりました。 記事に埋め込むには重すぎます。

差分と整数化で圧縮する

GPSの点は前の点とほとんど変わりません。1秒で1.2mしか動かないからです。 そこで差分にして、さらに小数を整数へ寄せます。

圧縮の方針
// 緯度経度:最小値からの差を 1e-6 度単位の整数に(約0.11m精度)
la = Math.round((lat - la0) * 1e6);

// さらに前の点との差分をとる
d[i] = v[i] - v[i-1];

// 結果:ほとんどの値が -50〜50 の範囲に収まる
[2438,5032,61,101,0, -6,-10,0,0,42, 0,0,0,5,-42, ...]
形式サイズ点数
素直な JSON(小数のまま)375 KB8,989
差分+整数化106 KB8,989
2点に1点へ間引き56 KB4,495

3割以下に収まったので、間引かずに全点を載せることにしました。 復元は累積和を取るだけです。

復元
let la=0, lo=0, al=0, hr=0, sp=0;
for (let i=0; i<n; i++) {
  la += d[i*5]; lo += d[i*5+1]; al += d[i*5+2];
  hr += d[i*5+3]; sp += d[i*5+4];
  pts[i] = { lat: la0 + la/1e6, lon: lo0 + lo/1e6,
             alt: al/10, hr: hr, spd: sp/10 };
}
間引かなかった理由。 4,495点でも軌跡の形は変わりません。ただ滞留の密度が薄まります。 同じ場所に120点あるか60点あるかで、光り方が変わる。 密度そのものを見せたいので、全点を残しました。
[ § 3 ] 精度

float32 では、緯度が足りない

GPU が扱うのは f32 です。だから CPU 側も Float32Array で持てばよい—— そう考えて実装したら、距離が 5% ほど水増しされました

緯度経度の保持積算した距離
Float32Array11.57 km
Float64Array10.99 km
(TCX が記録した累積距離)11.12 km

刻みが、歩幅より粗い

原因は単純でした。緯度 35.63738 という値そのものが、float32 の精度を使い切っています

float32 で緯度を表すと
元の値    35.63738
float32   35.63737869262695   ← 誤差 0.146 m

// 隣り合う表現可能な値の間隔
最小刻み  0.425 m            ← この粒度でしか表せない

1秒ごとの点は、平均1.2m しか離れていません。 そこへ 0.4m 刻みの量子化が乗ると、 各点がわずかにずれ、その揺れが8,989回ぶん積み上がります。 経路の形は変わらないのに、長さだけが伸びる。

対処は、原点を引いてから float32 にすること。 緯度経度のまま扱うと、値の大部分が「日本のどこか」を表すのに消費されます。 先に基準点からのメートル差へ変換すれば、値は数千メートル台に収まり、 float32 でも 0.001m 未満の精度が保てます。
順序が大事
// ✗ 緯度経度のまま float32 にする
lat = new Float32Array(n);   // 刻み 0.4m

// ○ 差分をとってから float32 へ
lat = new Float64Array(n);   // CPU 側は倍精度
x   = (lon[i] - lon0) * mPerLon;   // メートルへ
gpuBuf[i] = x;                      // ここで初めて f32
GPU に渡す値が f32 でも、CPU 側まで f32 にする必要はありません。 精度が要るのは絶対座標を扱う段階で、 原点を引いた後の相対値なら f32 で十分です。 どこで精度が必要かを分けて考える—— GPU を使うときに繰り返し出てくる論点だと思います。
[ § 4 ] 実装

インスタンス描画で、点を撒く

8,989点それぞれに四角形を描きます。頂点バッファに 8,989 × 4 頂点を積むのではなく、 四角形1つぶんの頂点を、8,989回インスタンス描画します。

shader.wgsl — 頂点シェーダ
struct Uniforms {
  view   : vec4<f32>,   // 中心x, 中心y, ズーム, アスペクト
  params : vec4<f32>,   // 点サイズ, 色モード, 進捗, 総点数
};
@group(0) @binding(0) var<uniform> U : Uniforms;

// 各点の属性(storage buffer)
@group(0) @binding(1) var<storage, read> pts : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> att : array<vec4<f32>>;

@vertex
fn vs(@builtin(vertex_index) vi : u32,
      @builtin(instance_index) ii : u32) -> VSOut {
  // 四角形の4隅(トライアングルストリップ)
  let corner = array<vec2<f32>,4>(
    vec2(-1.0,-1.0), vec2(1.0,-1.0),
    vec2(-1.0, 1.0), vec2(1.0, 1.0));

  let p = pts[ii];                    // この点の位置
  let c = corner[vi];
  ...
}
WGSL の注意点。 layout:'auto' を使うと、シェーダ内で参照していないバインディングは黙って落とされます。 属性バッファを宣言だけして使わないと、バインドグループ生成時にエラーになります。 色モードで使い分ける場合も、すべてのバッファを何らかの形で参照する必要があります。 これは銀河の実装でも踏んだ点でした。

加算合成で、密度を光らせる

同じ場所に点が重なると明るくなるよう、加算ブレンドを使います。 滞留した場所ほど光る、という表現になります。

パイプラインの設定
blend: {
  color: { srcFactor: 'src-alpha',
           dstFactor: 'one',        // 加算
           operation: 'add' },
  alpha: { srcFactor: 'one',
           dstFactor: 'one', operation: 'add' },
}
[ § 5 ] ビューア

色を、切り替えてみる

実データです。ドラッグで移動、ピンチかホイールで拡大できます。 色の基準を変えると、同じ軌跡から違うものが見えます。

Interactive · WebGPU point cloud
GPS 軌跡ビューア
2026年7月15日の歩行記録(8,989点・11.12km・2時間31分)。WebGPU が使えない環境では Canvas 2D で描画します。
※ 距離は座標から積分した値のため、TCX が記録する累積距離(11.12km)とはわずかに異なります。
初期化中…
点サイズ 2.5
再生 100%
[ § 6 ] 観察

見えたもの

滞留が、光る

1Hzで記録しているので、止まっている間も点が打たれ続けます。 信号待ちで40秒止まれば、そこに40点が重なる。 加算合成では、それが明るい塊になります。

速度が 0.5km/h を下回る区間を数えると、15秒以上の停止が7箇所、 最長は40秒でした。信号1回ぶんです。 点の密集を見れば、どこで足を止めたかが分かります

「止まっていた時間」は、測り方で変わります。 閾値を 1km/h に緩めると20箇所・合計13分になります。 ゆっくり歩いているのか止まっているのか、速度だけでは切り分けられません。 点の重なりは、その曖昧さも含めて見せてくれます

坂が、現れる

標高で色を分けると、経路のどこが高いかが出ます。 差は36.2mですが、勾配は場所によって±10%を超えます

勾配
下位5%(下り)−10.3 %
中央値0.1 %
上位5%(上り)+12.6 %

速度は、意外と一定

中央値 4.9 km/h、95パーセンタイルでも 6.7 km/h。 歩行速度はほとんど変わりません。 色が大きく変わるのは、止まった場所と—— そして、明らかにおかしい数点です。

時速109kmで歩いている点があります。 4秒間、連続して30mずつ飛んでいる。もちろん歩けません。 GPSの測位が飛んだ跡です。全体で5点(0.06%)。 この話は次回に扱います。
[ § 7 ] まとめ

点のまま持つ、ということ

線を引くのは、点を捨てて経路だけ残す操作でした。 点のまま描くと、1点あたり5つの値がそのまま残ります。 色をどれに割り当てるかで、同じ軌跡が別のものに見える。

そして密度そのものが情報になる。 止まっていた時間は、点の重なりとして現れます。 間引いてしまうと、この情報が薄まる——だから全点を持ちました。

この記事のまとめ

  • 1Hz の GPS ログは、1点あたり座標・標高・心拍・速度・時刻を持つ。線にすると消える。
  • 差分+整数化で 375KB → 106KB。全8,989点を間引かずに載せられた。
  • インスタンス描画で、四角形1つを点数ぶん描く。頂点バッファは4頂点だけ。
  • 加算合成により、滞留した場所が明るく光る。密度が情報になる。
  • layout:'auto'参照していないバインディングを落とす。全バッファを使う。
  • 15秒以上の停止が7箇所、最長40秒。閾値を変えると20箇所・13分になる。
  • 歩行速度は中央値 4.9 km/h でほぼ一定。大きく外れる点は、測位の飛び
  • 緯度経度を float32 で持つと刻みが 0.425m になり、距離が5%水増しされた。原点を引いてから f32 にする。

関連記事

『軌跡を、点群で描く — WebGPU で GPS ログを可視化する』を公開しました。