ルートトラッキングシーンのアニメーション作成方法
皆さんはMapboxのツイートなどで、ツール・ド・フランスの各ステージを紹介するアニメーション動画をご覧になりましたか?
スムーズなカメラの動き、衛星画像、3D地形により、見る人を没頭させ、選手が乗り越える標高差や距離をわかりやすく見せる事が出来ます。
Today stage 17 of @LeTour goes up into the Pyrenees with a dramatic climb to a high-altitude airport featured in the James Bond movie Tomorrow Never Dies. #TDF2022 pic.twitter.com/dhnF1XlLv0
— Mapbox (@Mapbox) July 20, 2022
今回は、このアニメーションの制作に関わるすべての工程をご紹介します。
①高所からルート周辺を拡大する
②地図上のルートをアニメーションで表示
③アニメーションしたルートの先端をカメラで追いかける
④カメラを滑らかにしたり、ゆっくり回転させたりして、より視覚的にわかりやすいシーンを作成する
⑤動画をエクスポートする
この動画のアプローチは、Mapbox GL JS ドキュメントにある Query Terrain Elevation を参考にしています。これは、3D 地形上で同様のルートトラッキングを行うものですが、正確なカメラ制御までは出来ません。
ルート表示
GeoJSONのデータでエンコードされたルートから選手の動きをアニメーション化するために、線の長さを少しずつ変化させる必要があります。
アニメーションは静止画の連続再生なので、前のフレームと比較し、必要な変更を加えて各フレームをプログラム的に構築することが課題です。 そのために、window.requestAnimationFrame()を使います。
時間の経過とともに線を動かすため、線のグラデーションのプロパティを変更します。
まず、アニメーションを開始してからの経過時間をアニメーションのプリセット時間で割って、animationPhaseの値を計算します。これにより、開始から終了までの各フレームについて0から1の間の値が得られます。
そして、これをsetPaintProperty()で適用します。
const animationPhase = (currentTime - startTime) / duration;
...
map.setPaintProperty(
"line",
"line-gradient", [
"step", ["line-progress"],
"yellow",
animationPhase,
"rgba(0, 0, 0, 0)",
]
);
この式は「線上の各点が現在の進行状況の点より前にある場合は黄色に、後にある場合は透明に着色する」ということです。animationPhase はフレームごとに 1 に近づいていくので、この setPaintProperty() メソッドが呼び出されるたびに、線の別の小さな塊が見えてくることになります。
このテクニックを使って、シンプルな2点のLineStringを3秒かけて徐々に動かす例を紹介します。
See the Pen line-progress animation example by Chris Whong (@chriswhong) on CodePen.
カメラを並走させる
線がフレームごとに姿を現すようになりました。次はカメラを追従させてみましょう。
ここではturf.jsを用いた三角法、そしてMapbox GL JSのFreeCamera APIが必要です。turf.distance()とturf.along()をanimationFrameと一緒に使って、線の長さに沿って正しい点を選びます。
// アニメーションの前に、線の長さを計算します。
const pathDistance = turf.lineDistance(path);
...
// animationPhase に基づいて、パスに沿った距離を計算します。
const[lng, lat] = turf.along(path, pathDistance * animationPhase).geometry
.coordinates;
カメラを制御するためには
・位置
・高度(どこにいるのか)
・ピッチ
・方位(どちらを向いているのか)
の4つを用意する必要があります。
今回の動画では、高度とピッチは一定なので、計算するのは方位とカメラの位置だけです。
カメラの方位は進路とは無関係で、映画のような絶妙な回転エフェクトを意識して一定の割合で変化させています。
const bearing = startBearing - animationPhase * 200.0;
方位、高度、ピッチ、見たい地点が分かっていれば、三角法でカメラの位置を推測することができます。
const computeCameraPosition = (
pitch,
bearing,
targetPosition,
altitude,
smooth = false
) => {
var bearingInRadian = bearing / 57.29;
var pitchInRadian = (90 - pitch) / 57.29;
var lngDiff =
((altitude / Math.tan(pitchInRadian)) *
Math.sin(-bearingInRadian)) /
70000; // ~70km/degree longitude
var latDiff =
((altitude / Math.tan(pitchInRadian)) *
Math.cos(-bearingInRadian)) /
110000 // 110km/degree latitude
var correctedLng = targetPosition.lng + lngDiff;
var correctedLat = targetPosition.lat - latDiff;
const newCameraPosition = {
lng: correctedLng,
lat: correctedLat
};
...
return newCameraPosition
}
注:経度からメートルへの変換は緯度に依存します。 上の関数は1度につき70kmの固定換算を使用しており、フランスでは十分な精度ですが、どこでも使えるというわけではありません。
computeCameraPosition() で行われている計算を説明するためのビジュアルを示します。 下図が地上の見たい場所と、高度、方位、ピッチです。カメラの新しい位置は、ターゲット位置からの相対的な x オフセットと y オフセットとして計算されます。
▲GeoGebraで作成した3Dチャート(https://www.geogebra.org/3d/z8czvzzw)
スムージング
今まで紹介したコードではでは、急カーブを含む経路をカメラが直接追いかけていました。この場合、カメラは直線の先端に焦点を合わせているため、アニメーションにブレが生じます。
直線補間は「レルプ」とも呼ばれ、カメラの動きがフレーム間で急激に変化しないようにすることで、カメラの動きを滑らかにすることができます。
//https://codepen.io/ma77os/pen/OJPVrPを参照
function lerp(start, end, amt) {
return (1 - amt) * start + amt * end
}
アンドレ・マットス氏によるこの実例はleap関数を用いて円をなめらかに動かす良い例です。マウスを動かすと、円がポインターに滑らかに追従する様子をご覧ください。Mapboxは、同じlerp関数を使って、ルートのアニメーションに滑らかさを加えました。
See the Pen LERP - Linear Interpolation by André Mattos (@ma77os) on CodePen.
地球儀を拡大する
各動画は、ルートアニメーションでどの部分を見るかを含めて、高い高度からのflyInから始まります。Mapbox GL JSには便利なflyTo()メソッドがあります。flyTo()の終了状態とFreeCamera APIコントロールの開始状態をシームレスに移行することが困難だったため、ここでは使用することが出来ませんでした。
代わりに、最初の地球儀のビューとトラックアニメーションの開始ビューの間でカメラを移行させるカスタム関数を作成しました。
前の位置と新しい線の先端をlerp関数に渡すと、「より滑らかな」新しい位置が得られます。つまり、見たい線の先端はフレームの中心にはないかもしれませんが、動きがあまり急でない限り、時間とともに中心に向かって戻る傾向があります。
動画をエクスポートする
動画をエクスポートするには、Mapbox GL JSの例で説明されているテクニックを使用します。mp4-encoder javascript ライブラリを使用し、Mapbox GL JS canvas の各レンダリングを使用してフレームを保存します。
function frame() {
// スタブ時間を 16.6ms 増やす(60fps)。
now += 1000 / 60;
mapboxgl.setNow(now);
const pixels = encoder.memory().subarray(ptr); // エンコーダのメモリにアクセスする
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); // エンコーダにピクセルを読み込む
encoder.encodeRGBPointer(); // フレームをエンコードする
}
map.on("render", frame); // フレーム単位の録画の設定
画面表示の大きさは、マップコンテナのCSS で制御します。16:9の動画では、1280px x 720px の寸法で表示します。正方形の動画(Instagram等で使用)の場合、寸法は1080px x 1080pxです。
ポストプロダクション
アニメーションが完成したら、それらのフレームを組み立て、mp4としてダウンロードします。最後に、出力されたmp4はCLIツールのffmpegを使って圧縮されます。
% ffmpeg -i my_video.mp4 my_video_compressed.mp4
これらのアニメーションを作成するために使用されたコードはGitHubのmapbox/impact-tools リポジトリに公開されており、参照や再利用が可能です。FreeCamera APIをいじり、Mapbox GL JSプロジェクトから高品質な動画をエクスポートして、Twitterで@mapboxをタグ付けして共有しましょう!
*本記事は、Mapbox Inc. Blogの翻訳記事です。