はじめに
deck.gl には移動経路をアニメーション表示するための TripsLayer が用意されています。しかし、単純に線が動くだけでは味気なかったため、今回は線ではなくアイコンを動かす表現を実装しました。その過程で、Layer のアクセサやシェーダー拡張の仕組みも理解できたので、あわせて紹介します。
使用する移動経路データ
使用する移動経路データには、公式サンプルと同じ NYC タクシーデータ を利用します。データは車種(vendor)、移動経路(path)、訪問時間(timestamps)で構成されています。
[
{ "vendor": 0, "path": [[lon1, lat1], [lon2, lat2], ...], "timestamps": [t1, t2, ...] },
{ "vendor": 0, "path": [[lon1, lat1], [lon2, lat2], ...], "timestamps": [t1, t2, ...] },
...
]
TripsLayer は、この path と timestamps をもとに経過時間における車両の位置を自動計算(線形補間)してくれるレイヤーです。ただし、現在時刻そのもの(経過時間)を管理するのは自前で実装する必要があります。
経過時間の管理方法
経過時間の更新には setInterval や requestAnimationFrame などが使えますが、今回は公式サンプルと同じく popmotion を使用しました。
const [time, setTime] = React.useState(0);
React.useEffect(() => {
const animation = animate({
from: 0,
to: 1800,
duration: 1800 * 60,
repeat: Infinity,
onUpdate: setTime, // latest => { setTime(latest); },
});
return () => animation.stop();
}, []);
useEffect で初回レンダリング時にアニメーションを開始し、onUpdate で定期的に経過時間を更新し続けます。
TripsLayer
まずは公式サンプルと同じように、TripsLayer を使って車両の移動経路を描画します。
TripsLayer とは
TripsLayer は、移動経路(path)と訪問時間(timestamps)をもとに、指定した currentTime における車両の位置を線形補間して描画するレイヤーです。
TripsLayer の使い方
TripsLayer を利用するには、次の情報を指定します。
- data: データ配列、URL、非同期関数(Async / Async Iterator)
- getPath: 車両の移動経路
- getTimestamps: 各地点の訪問時間
- currentTime: 現在時間(アニメーションの進行状況にあたる値)
アクセサ(getPath や getColor など)は、関数だけでなく固定値も指定できます。また data には URL や Async Iterator をそのまま渡せるため、TripsLayer 側で自動的に読み込みが行われます。
これらを踏まえ、TripsLayer の設定は以下のようになります。
new TripsLayer({
id: "trips",
data: "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/trips/trips-v7.json",
getPath: d => d.path,
getTimestamps: d => d.timestamps,
// 車種をもとに線の色を取得
getColor: d => (d.vendor === 0 ? [253, 128, 93] : [23, 184, 190]),
currentTime: time,
});
完成図
先ほどの経過時間の処理と TripsLayer を組み合わせた動作例を、CodePen にまとめました。
See the Pen TripsLayer by 鮫氷新一(fukahire) (@fukahire-the-selector) on CodePen.
TripsLayer の問題
TripsLayer は、移動経路を線としてアニメーションさせる用途には便利ですが、線以外の見た目を動かすことができません。今回は線ではなくアイコンそのものをアニメーションさせたいので、ここからは IconLayer をベースにしたカスタムレイヤーを作成し、アイコンの移動処理を自前で実装していきます。
IconLayer
では本題の、TripsLayer の線の代わりにアイコンを動かしてみます。今回は IconLayer を使って実装します。
IconLayer とは
IconLayer はアイコンを表示するためのレイヤーです。主に以下の設定が必要になります。
- data: 使用するデータ配列(TripsLayer の data と同様)
- getPosition: 車両の位置
- iconAtlas: アイコンをまとめた画像(テクスチャアトラス)、画像の URL
- iconMapping: iconAtlas 内の各アイコンの座標情報
- getIcon: 使用するアイコン名
- getSize: アイコンサイズ
基本形は以下のようになります。
new IconLayer({
id: "icon",
data: "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/trips/trips-v7.json",
getPosition: d => d.path[0] // 車両の移動開始座標を設定,
getColor: d => (d.vendor === 0 ? [253, 128, 93] : [23, 184, 190]),
getSize: 40,
iconAtlas: "icon-atlas.png",
iconMapping: {
"yellow": {x: 0, y: 0, width: 128, height: 128, mask: false},
"green": {x: 128, y: 0, width: 128, height: 128, mask: false},
},
});
iconAtlas、iconMapping について補足
iconAtlas は、複数のアイコンをタイル状に並べた画像です(いわゆるテクスチャアトラス)。たとえば以下のように4つのアイコンをまとめた画像を用意します。
iconMapping では、iconAtlas 内で各アイコンがどの位置にあり、どのサイズなのかを定義します。上記の画像が 256x256 で、各アイコンが 128x128 の場合は次のようになります。
new IconLayer({
iconAtlas: "icon-atlas.png",
iconMapping: {
"red": {"x": 0, "y": 0, "width": 128, "height": 128, "mask": false},
"blue": {"x": 128, "y": 0, "width": 128, "height": 128, "mask": false},
"green": {"x": 0, "y": 128, "width": 128, "height": 128, "mask": false},
"yellow": {"x": 128, "y": 128, "width": 128, "height": 128, "mask": false}
},
);
なお、mask を true にすると getColor で指定した色でアイコンが塗りつぶされます。
アイコンを動かす
ここから、アイコンに動きを加えるための処理を追加します。まずはパフォーマンスよりも分かりやすさを優先した実装を行います。
位置を線形補間する
経過時間と移動経路・訪問時刻をもとに現在地を線形補間します。
- 経過時間から、車両がどの移動区間にいるかを判定する
- その区間における移動進捗率を求める
- 進捗率と区間の始点・終点から現在地を線形補間する
愚直に書くと、次のようになります。
new IconLayer({
// 現在地のアクセサに処理を追加する
getPosition: d => {
// どの区間にも属していない場合は即時終了
if (time < d.timestamps[0] || d.timestamps.at(-1) < time) return null;
// どの区間にいるか判定する
const index = d.timestamps.findIndex((t, i) => d.timestamps[i] <= time && time <= d.timestamps[i + 1]);
// 区間における進捗率を計算する
const progress = (time - d.timestamps[index]) / (d.timestamps[index + 1] - d.timestamps[index]);
// 緯度・経度ごとに現在地を線形補間する
const lon = d.path[index][0] + progress * (d.path[index + 1][0] - d.path[index][0]);
const lat = d.path[index][1] + progress * (d.path[index + 1][1] - d.path[index][1]);
return [lon, lat];
},
}
ただし、このままでは、time が更新されても getPosition が再評価されず、アイコンは動きません。アクセサは原則として初回レンダリング時に1回しか評価されないためです。再評価させるには追加の仕組みが必要です。
再評価タイミングの追加
deck.gl のアクセサはコストが高いため、デフォルトでは再評価されません。しかし、updateTriggers を使うことで特定のプロパティが更新されたときに再評価を促すことができます。
経過時間に応じて getPosition を再評価するには、次のように設定します。
new IconLayer({
updateTriggers: {
// アクセサ: [依存するプロパティ] の形で定義する
getPosition: [time],
},
});
不要なデータを非表示にする
車両によっては、ある時間帯に登場していないものがあります。これらを非表示にするため DataFilterExtension を利用できます。
new IconLayer({
// フィルターしたい値を取得
// 今回は区間外なら 0、区間内なら 1 を返す
getFilterValue : d => d.timestamps[0] <= time && time <= d.timestamps[d.timestamps.length - 1] ? 1 : 0,
// getFilterValue の値が [start, end] の範囲内に収まっていれば表示する
filterRange: [1, 1],
// データフィルターを使うことを宣言する
// filterSize はフィルターに使う値数
extensions: [new DataFilterExtension({filterSize: 1})],
});
※なお、この処理が無くても、getPosition が null を返すためアイコンは実質的に非表示になります。
完成図
全てを組み合わせると以下のようになります(公式サイトのマーカーアイコンを使用)。
See the Pen TripsIconLayer js version by 鮫氷新一(fukahire) (@fukahire-the-selector) on CodePen.
GPU / WebGL を使いパフォーマンスを改善する
ここからは一歩踏み込んで、GPU(WebGL)のシェーダーを使って IconLayer の補間処理を実装します。直前の実装では現在地の線形補間や表示・非表示判定を JavaScript(CPU)側で行っていましたが、この方式はデータ量が増えると CPU がボトルネックになり描画性能が低下します。そこで、これらの計算を WebGL 上のシェーダー(GLSL)で並列実行させ、描画パフォーマンスを改善します。
カスタムレイヤーの作り方
今回のように入力データを加工した上で別レイヤーに渡す場合、deck.gl のカスタムレイヤー機能を使います。主な選択肢は次の3つです。
- 複合レイヤー(CompositeLayer): 既存レイヤーの組み合わせやインターフェース変換に使う
- サブクラス: 既存レイヤーを継承して機能を拡張する
- ゼロから実装: 全く新しいレイヤーを実装する
今回の実装では data を加工して別レイヤーへ渡す必要があるため、複合レイヤーを使用します。また、IconLayer に現在地計算などの処理を追加するため、合わせてサブクラス化も行います。
移動経路を区間ごとに分割する
WebGL で補間処理を行う際、JS 側から渡すデータは固定長である必要があります。可変長配列(各車両ごとの path / timestamps)をそのまま渡すことは現実的でないため、移動経路とタイムスタンプを「区間ごと(始点→終点)」に展開して固定長に変換します。このようにすると各区間をシェーダー側で一律処理できます。
const data = [];
for (const d of this.props.data) {
const path = this.props.getPath(d);
const timestamps = this.props.getTimestamps(d);
const length = Math.min(path.length, timestamps.length);
for (let i = 0; i < length - 1; i++) {
data.push({
...d, // もともとの車両のプロパティも追加
instancePositions: [...path[i], 0],
instanceNextPositions: [...path[i + 1], 0],
instanceTimestamp: timestamps[i],
instanceNextTimestamp: timestamps[i + 1],
});
}
}
CompositeLayer でデータを変換して渡す
CompositeLayer 内で区間分割を行い、その結果をサブレイヤー(拡張した IconLayer)へ渡します。
class TripsIconLayer extends CompositeLayer {
static layerName = 'TripsIconLayer';
// レイヤー更新時の処理
updateState({props, oldProps, context, changeFlags}) {
const subLayerData = [];
// data が更新されたら変換処理を合わせて行う
if (changeFlags.dataChanged) {
// 何らかの変換処理
props.data.map(d => subLayerData.add(...));
}
this.setState({subLayerData: subLayerData});
}
renderLayers() {
// サブレイヤーに変換したデータを渡す
const { subLayerData } = this.state;
return new _TripsIconLayer({
data: subLayerData,
});
}
}
IconLayer をサブクラス化してシェーダー処理を差し替える
サブクラス化した IconLayer で、インスタンス属性(attribute)と全体共通値(uniform)をシェーダーに渡し、頂点シェーダーで補間、フラグメントシェーダーで可視判定を行います。
属性(attribute)を追加する
まずは各インスタンスごとに必要な属性を AttributeManager に登録します。ここでは位置・次の位置・区間の開始時刻・終了時刻を属性として追加します。
// レイヤー追加時に1度だけ実行される
initializeState() {
super.initializeState();
const attributeManager = this.getAttributeManager();
// プロパティの属性を追加する
attributeManager.addInstanced({
instancePositions: { size: 3, type: 'float64', fp64: this.use64bitPositions(), transition: true, accessor: "getInstancePositions" },
instanceNextPositions: { size: 3, type: 'float64', fp64: this.use64bitPositions(), transition: true, accessor: "getInstanceNextPositions" },
instanceTimestamp: { size: 1, accessor: "getInstanceTimestamp" },
instanceNextTimestamp: { size: 1, accessor: "getInstanceNextTimestamp" },
});
}
※必要に応じて type, fp64, transition 等のオプションを追加します。fp64 は高精度が必要な場合に利用しますが、パフォーマンスが低下します。基本的には不要です。
全インスタンス共通の値(uniform)を渡す
経過時間は全頂点で共通の値なので uniform として渡します。deck.gl のシェーダーモジュール経由で渡すやり方は以下の通りです。
getShaders() {
// shader modules に uniform の変数を追加する
const shaders = super.getShaders();
shaders.modules = [...shaders.modules, {
// モジュール名
name: 'trips',
// 頂点シェーダーへ追加
vs: `uniform tripsUniforms { float currentTime; } trips;`,
uniformTypes: {
// 型
currentTime: 'f32',
},
}];
return shaders;
}
draw(param) {
const { currentTime } = this.props;
const model = this.state.model;
// shader に currentTime を渡す
model.shaderInputs.setProps({ trips: { currentTime } });
super.draw(params);
}
シェーダーへ処理を注入する
deck.gl の shaders.inject を使うと、既存シェーダーに安全に処理を追加できます。ここでは頂点シェーダーで現在位置の線形補間を行い、フラグメントシェーダーで区間外アイコンを破棄(非表示)します。
頂点側(vs): trips.currentTime と instanceTimestamp / instanceNextTimestamp から進捗率を計算し、mix() で補間した位置を決定します。最後に既存の位置計算を上書きして描画位置を補正します。
フラグメント側(fs): 頂点側で計算した進捗率 vProgress を受け取り、範囲外なら discard で描画をスキップします。
getShaders() {
const shaders = super.getShaders();
// shader にアイコンの移動処理を注入
shaders.inject = {
// vs の変数を追加
"vs:#decl": `\
in vec3 instanceNextPositions;
in vec3 instanceNextPositions64Low;
in float instanceTimestamp;
in float instanceNextTimestamp;
out float vProgress; // 移動の進捗率、0未満は移動未済、1以上は移動完了
vec3 offsetCommon;
vec3 progressPosition; // 進捗率に応じた移動位置
vec3 progressPosition64Low;
`,
// vs の冒頭に追加
"vs:#main-start": `\
// 現在時間から移動の進捗率を計算する
vProgress = (trips.currentTime - instanceTimestamp) / (instanceNextTimestamp - instanceTimestamp);
// 移動の進捗率に応じて移動場所を線形補間する
progressPosition = mix(instancePositions, instanceNextPositions, vProgress);
progressPosition64Low = mix(instancePositions64Low, instanceNextPositions64Low, vProgress);
`,
"vs:DECKGL_FILTER_SIZE": `\
// icon.billboard = fales の場合に使用する size (= offset_common )を保存しておく
offsetCommon = size;
`,
// gl_position の計算直後に追加(position を更新すると gl_position が更新される)
"vs:DECKGL_FILTER_GL_POSITION": `\
// 補間した移動場所を設定
geometry.worldPosition = progressPosition;
if (icon.billboard) {
// 補間した場所を元に座標を計算
position = project_position_to_clipspace(progressPosition, progressPosition64Low, vec3(0.0), geometry.position);
} else {
// 補間した場所と保存した offset_common を元に座標を計算
position = project_position_to_clipspace(progressPosition, progressPosition64Low, offsetCommon, geometry.position);
}
`,
};
完成図
上記の長々とした処理を実装すると、補間・可視判定を GPU 側で高速に処理する TripsIconLayer が完成し、大量アイコンの同時アニメーションでも描画が滑らかになります。
See the Pen TripsIconLayer glsl version by 鮫氷新一(fukahire) (@fukahire-the-selector) on CodePen.
感想
WebGL を使って IconLayer を拡張すると、GPU を利用できるため描画パフォーマンスは向上しますが、そのぶん実装コストも増えます。今回のサンプル程度の数百件規模であれば、GPU の恩恵を体感できる場面はほとんどありません。そのため、常に WebGL レベルの拡張が最適とは限りません。
ただし、扱うデータ量が増えると JavaScript だけでは処理が追いつかなくなります。こうしたケースでは Leaflet などもカバーしづらいため、GPU を活用できる deck.gl の強みが活きてきます。
