これは 防災アプリ開発 Advent Calendar 2023 の9日目の記事です。
はじめに
こんにちは。
趣味で KyoshinEewViewer for ingen というPC向けのアプリを開発しています、ingen084 です。
知らない方に向けて簡単に説明すると、地図の上に地震などの情報を表示して、マウスやタッチでその地図を自由に操作することができるアプリです。
アプリ内のマップを表示する仕組みについては自力でデータから描画しており、快適な操作やアニメーションを行ったりするためになるべく高いパフォーマンスを出せるよう工夫を行っています。
この記事では地図の元データから画面に表示されるまでの流れを紹介できたらと思います。
(この記事は3年前に投稿した ライブラリ未使用で地図描画入門編(WPF) の発展版です。)
なぜ Skia を採用したのか
3年前の時点では WPF を使用していました。しかしかなり問題点が多く、
-
Freeze
しないとパフォーマンスがでない- Freeze した状態でも満足のいくパフォーマンスが出ない
- Freeze してしまうとパスオブジェクト(
Geometry
)が全く移動できなくなるため、Webメルカトル図法の利点を生かすことができない
- 地図描画部分に WPF 以外の技術を使用しはめ込もうとしても WPF の DirectX のバージョンの都合必ずテクスチャのコピーが必要となる
- カスタムフォントを使用するとメモリリークが発生するバグがある
といった問題がありました。そこで better WPF としての立ち位置になりそうな Avalonia に目を付けました。WPF と比べた長所は多く、XAML のかゆいところに手が届いていたり、マルチプラットフォーム対応といったところです。
Avalonia では描画のバックエンドとしていくつかの仕組みが利用できるようになっていますが、Chromium のバックエンドにもなっている Skia の利便性が高く、カスタムレンダーとして SKCanvas
のインスタンスを直接受け取ることもできます。
また Skia は 2D Canvas API としての利便性もかなり高いことや Avalonia 以外のフレームワークに移ったとしても描画ロジックが使い回せることなどを理由に Avalonia と合わせて採用することになりました。
大まかなマップ描画の流れ
本題に戻ります。地図データはすべて気象庁で公開されているシェープファイルを元にしています。
大まかな流れとしては、
- (アプリ外)シェープファイルを簡略化し TopoJSON として出力
- (アプリ外)TopoJSON を独自形式に変換
- (ここからアプリ内)独自形式を展開
- 画面の状態に合わせて投影法による座標に変換
- 投影法による座標をポリゴンの配列に変換、境界線はパスオブジェクトに
- 画面座標に変換しながら描画
といった感じです。
シェープファイルの簡略化と TopoJSON 化
生のGISデータは非常に細かく、地物を構成するポイントの密度がバラバラでそのまま描画に使用するには不適当です。
簡略化は npm パッケージの mapshaper
を使用します。
WebUI も用意されていますが、同一のパラメータで複数のファイルを処理するためコマンドラインを使用します。
今はこのスクリプトで生成しています。(開発PCが Windows のためバッチファイル)
@echo off
setlocal
start /wait mapshaper -i natural_earth/10m_cultural/ne_10m_admin_0_countries.shp snap -simplify .3 stats -clean -o out/日本以外の全地域.json format=topojson
del /Q /S tmp
mkdir tmp
start /wait mapshaper -i JMA/*.shp snap -simplify interval=50 stats -o tmp/ format=topojson
start /wait mapshaper -i tmp/*.json snap -clean -o out/ format=topojson
境界線の種類を判別するために隣接ポリゴンの計算が必要となる(詳細は後述)ため隣接ポリゴンを統合して保存できる TopoJSON への変換も合わせて行っています。
同じフォルダの JMA
ディレクトリにダウンロードした各シェープファイルを、 natural_earth
ディレクトリに日本以外の地域に加工した世界地図のシェープファイルを入れ、 out
ディレクトリに TopoJSON が出力される算段です。
いずれ完全自動化したいので GitHub Actions あたりで仕組みを作るかもしれません。
TopoJSON を独自形式に変換する
TopoJSON でもかなり容量は削減できていますが、テキストデータのためまだまだ無駄が多いです。
主に容量の削減・高速化とアプリ専用のデータ付加を目的として、 MessagePack を使用して独自形式に変換します。それに合わせて、
- アプリで使用するプロパティデータは地名コードのためだけのため、整数型に変換して保存する
- 境界線が県境なのか、海岸線なのかなどのデータを事前に計算して付加しておく
という作業をします。
境界線の計算について
TopoJSON には Arc の概念があります。
(3年前の記事の画像流用ごめん)
このように TopoJSON は線分を共通するポリゴンに対して共通部分を切り出して保存することにより容量の削減を行っています。
今回はその使用をポリゴン同士の隣接判定に使用して、各線分に対して
- 線分を使用しているポリゴンが1つのみ -> 海岸線
- 別の都道府県のポリゴンが線分を使用している -> 県境
- それ以外 -> 通常の境界線
といった形で分類を行っています。
実際のコードはこんな感じです。(複数のレイヤーを処理できるようにしているため少し複雑ですが。)
result.Arcs = json.GetArcs().Select((a, index) =>
{
var ta = new TopologyArc { Arc = a };
// 該当するPolyLineを使用しているポリゴンを取得
var refPolygons = result.Polygons.Where(p => p.Arcs.Any(x => x.Any(i => (i < 0 ? Math.Abs(i) - 1 : i) == index))).ToArray();
// 1つしか存在しなければそいつは海岸線
if (refPolygons.Length <= 1)
ta.Type = TopologyArcType.Coastline;
// ポリゴン自体が結合不可もしくは使用しているポリゴンがAreaCodeがnullでないかつ上3桁が違うものであれば県境
else if (layerType.GetMultiareaGroupNo() == 1 || refPolygons.Where(p => p.AreaCode != null).GroupBy(p => p.AreaCode / layerType.GetMultiareaGroupNo()).Count() > 1)
ta.Type = TopologyArcType.Admin;
// そうでもないなら一次細分区域
else
ta.Type = TopologyArcType.Area;
return ta;
}).ToArray();
これらのソースコードはここに置いていますので気になった方は覗いてみてください。
短く書きたかったのでプロパティの変換とかすごい読みづらいですが…。
独自形式を展開する
アプリ起動時に上のステップで変換した独自形式のマップを読み込みます。
TopoJSON(と同様の構造を持っている) 形式のデータは以下の流れで展開します。
- データをメモリ上に展開する
- 線分を取り出し座標を復元する
- ポリゴンを取り出し、構成されている線分を元に座標を復元する
ポリゴンを作成した時点で線分データは無視されがちですが、僕のアプリではポリゴンと線分は完全に分離して扱っています。
理由としては、1つのポリゴンに対して海岸線や県境など複数種類の境界線を持つため別々に描画させる必要があることや、雨雲レーダーなどで地形とは別の優先度を持たせることがあるためです。
(線が見づらいので改善の余地があります)
画面の状態に合わせて画面座標に変換
ここからは具体的に描画パスの内部へ入っていきます。
変換部分で描画に関連するのか?と思うかもしれませんが現状このアプリの座標変換は描画と同時にシングルスレッドで行っているため、描画開始時点で描画するべき範囲が判明する都合、描画時に各種変換を行っています。
このこと自体、ブラウザなど他プラットフォーム上で動作させる上で大きなオーバーヘッドとなっていることは認識しており、良い感じの解決方法を思いつき次第実装したい感じではあります。
描画するべき線分・ポリゴンをリストアップする
メモリ上に展開し座標を復元するのに合わせて各境界線・ポリゴンのバウンディングボックスを計算しておき、現在の画面上に表示されている地理座標の範囲に被るものをリストアップします。
投影法による座標に変換
リストアップした物を現在表示しようとしているズーム値を四捨五入して整数値に丸めてから投影法による座標に変換しキャッシュします。
ここで生じたズーム値のズレは描画時に画面座標に変換するときに修正します。
マップの投影法については以下のような抽象クラスを用意しており、メルカトル図法とミラー図法を使用できるようにしてあります。
public abstract class MapProjection
{
public static readonly MapProjection Default = new MillerProjection();
internal abstract PointD LatLngToPoint(Location location);
internal abstract Location PointToLatLng(PointD point);
public static PointD PointToPixel(PointD point, double zoom = 0)
=> new(point.X * Math.Pow(2, zoom), point.Y * Math.Pow(2, zoom));
public static PointD PixelToPoint(PointD point, double zoom = 0)
=> new(point.X / Math.Pow(2, zoom), point.Y / Math.Pow(2, zoom));
public PointD LatLngToPixel(Location loc, double zoom)
=> PointToPixel(LatLngToPoint(loc), zoom);
public Location PixelToLatLng(PointD pixel, double zoom)
=> PointToLatLng(PixelToPoint(pixel, zoom));
internal static double RadiansToDegrees(double rad)
=> rad / (Math.PI / 180);
internal static double DegreesToRadians(double deg)
=> deg * (Math.PI / 180);
}
アプリの設計的に緯度と経度が直角でなくなる投影法には対応できていませんが将来的には対応したい気持ちもあります。
合わせて、必要以上に細かくなりすぎないように簡略化を行います。
適切な粒度で簡略化を行わないとあまりにも簡略化しすぎるとカクカクした残念な地図になってしまいますし、細かくなりすぎると場合複雑な海岸線部分の境界線が凝縮されかえって見栄えが悪くなってしまうため適切な粒度を見つけ出す必要があります。
現状では Douglas-Peucker アルゴリズムを使用して簡略化していますが、最初の手順でシェープファイルから簡略化するときにある程度等間隔な構成点の配置になるように調整されているため、一定間隔で間引くだけである程度簡略化できるかもしれません。
簡略化の落とし穴
この簡略化ですが、大きな落とし穴があります。
境界線とポリゴン自体を別に扱っているため、
このような2つの線分から構築されるポリゴンを境界線と別々に簡略化すると、
このように境界線と全くかみ合わない簡略化がされてしまい地形色と境界線がずれてしまうことになります。
(さすがにこれはあまりにも極端にした例ですが!)
そのため、ポリゴンはマップデータの読み込み時に構成する座標を計算して完成としてしまうのではなく、実際に描画時に構成する線分を簡略化した上で完成させる必要があります。
描画対象をオブジェクトに変換する
描画対象の座標が出そろったところで、SkiaSharp に渡すための描画オブジェクトを作成します。
現状ではポリゴンは頂点データ( SKVertices
)、境界線はパスオブジェクト( SKPath
)を使用しています。
最初はすべて SKPath
にしてしまい描画させていたのですが頂点情報のキャッシュがうまく行われていないようで思うようにパフォーマンスが出ず、C# 側でテッセレーションしてしまい頂点データを直接渡した方が軽くなることが判明したためこのような形になっています。
境界線を SKPath
にしているのは線の描画を頂点を渡してやってもらう方法を僕が知らないためで、理解次第同じように頂点データを渡す形に変更したいと思っています。
テッセレーションは LibTessDotNet
を使用し、実際のコードは以下のような感じになっています。
private SKVertices? GetOrCreatePath(int zoom)
{
if (PathCache.TryGetValue(zoom, out var path))
return path;
var pointsList = CreatePointsCache(zoom);
if (pointsList == null)
return PathCache[zoom] = null;
var tess = new Tess();
foreach (var t in pointsList)
{
var vortexes = new ContourVertex[t.Length];
for (var j = 0; j < t.Length; j++)
vortexes[j].Position = new Vec3(t[j].X, t[j].Y, 0);
tess.AddContour(vortexes, ContourOrientation.Original);
}
tess.Tessellate(WindingRule.Positive, ElementType.Polygons, 3);
var points = new SKPoint[tess.ElementCount * 3];
for (var i = 0; i < points.Length; i += 3)
{
points[i] = new(tess.Vertices[tess.Elements[i]].Position.X, tess.Vertices[tess.Elements[i]].Position.Y);
points[i + 1] = new(tess.Vertices[tess.Elements[i + 1]].Position.X, tess.Vertices[tess.Elements[i + 1]].Position.Y);
points[i + 2] = new(tess.Vertices[tess.Elements[i + 2]].Position.X, tess.Vertices[tess.Elements[i + 2]].Position.Y);
}
return PathCache[zoom] = SKVertices.CreateCopy(SKVertexMode.Triangles, points, null, null);
}
new SKPoint[tess.ElementCount * 3]
のあたりがすごく ArrayPool
とか stackalloc
とか使いたい感じなんですが CreateCopy
が Span
に対応していないためこんな感じになってます…。
画面座標に変換しながら描画する
画面座標への変換は WPF の時は各描画対象のパスに対して Transform を使用していましたが、 Skia の場合は SKCanvas
単位で Rotate
や Translate
Scale
といった変換が利用できます。
すでに Avalonia 側で設定されているパラメータと競合しないよう Save
/Restore
を使用して座標の変換を行わせます。
canvas.Save();
try
{
// 使用するキャッシュのズーム
var baseZoom = (int)Math.Ceiling(param.Zoom);
// 実際のズームに合わせるためのスケール
var scale = Math.Pow(2, param.Zoom - baseZoom);
canvas.Scale((float)scale);
// 画面座標への変換
var leftTop = param.LeftTopLocation.CastLocation().ToPixel(baseZoom);
canvas.Translate((float)-leftTop.X, (float)-leftTop.Y);
// ここに描画処理
} finally {
canvas.Restore();
}
後は、各地物をキャンバスに対して描画させるだけです。
// ポリゴンを頂点情報から描画する
canvas.DrawVertices(poly, SKBlendMode.Modulate, paint);
// パスを描画する
canvas.DrawPath(path, paint);
レイヤー構造
現状のマップコントロールはマウスなどでの操作や指定座標へのナビゲーションによる座標の管理と描画のための各レイヤーの一覧を管理する責務を持っていて、今回のような地形の描画や震度情報・レーダー画像などはすべてレイヤー側の責務となっています。
形としては、以下のような抽象クラスを用意し、レイヤーはこのクラスを継承します。
public abstract class MapLayer
{
private List<MapControl> AttachedControls { get; } = [];
/// <summary>
/// コントロールをアタッチする
/// </summary>
/// <param name="control">アタッチするコントロール</param>
public void Attach(MapControl control) => AttachedControls.Add(control);
/// <summary>
/// コントロールをデタッチする
/// </summary>
/// <param name="control">デタッチするコントロール</param>
public void Detach(MapControl control) => AttachedControls.Remove(control);
/// <summary>
/// アタッチされているコントロールに再描画を要求する
/// </summary>
protected void RefreshRequest()
{
foreach (var control in AttachedControls.ToArray())
Dispatcher.UIThread.InvokeAsync(() => control.InvalidateVisual());
}
/// <summary>
/// 連続した更新が必要かどうか
/// 描画時にこのフラグが有効なレイヤーが存在している場合、次フレームの描画が予約される
/// </summary>
public abstract bool NeedPersistentUpdate { get; }
/// <summary>
/// リソースのキャッシュを更新する
/// レイヤー変更時必ず1度は呼ばれる
/// </summary>
/// <param name="targetControl">キャッシュ更新用のコントロール</param>
public abstract void RefreshResourceCache(Avalonia.Controls.Control targetControl);
/// <summary>
/// 描画を行う
/// </summary>
/// <param name="canvas">描画対象</param>
/// <param name="param">描画する範囲の情報</param>
/// <param name="isAnimating">アニメーション(ナビゲーション)中かどうか</param>
public abstract void Render(SKCanvas canvas, LayerRenderParameter param, bool isAnimating);
}
レイヤー側は特に意識せず複数の MapControl
からの使用にも対応でき、描画処理は引数のパラメータに応じて Render
関数内で行い、画面の更新が必要なときは RefreshRequest
を呼び出すだけで良い感じに処理できるようになっています。
実装例としてグリッド表示を行うレイヤーはこんな感じとなっています
public class GridLayer : MapLayer
{
private static readonly SKPaint GridPaint = new()
{
Style = SKPaintStyle.Stroke,
IsAntialias = true,
StrokeWidth = 1,
TextSize = 12,
Typeface = KyoshinEewViewerFonts.MainRegular,
Color = new SKColor(100, 100, 100, 100),
};
private const float LatInterval = 5;
private const float LngInterval = 5;
public override bool NeedPersistentUpdate => false;
public override void RefreshResourceCache(Avalonia.Controls.Control targetControl) { }
public override void Render(SKCanvas canvas, LayerRenderParameter param, bool isAnimating)
{
{
var origin = param.ViewAreaRect.Left - (param.ViewAreaRect.Left % LatInterval);
var count = (int)Math.Ceiling(param.ViewAreaRect.Width / LatInterval) + 1;
for (var i = 0; i < count; i++)
{
var lat = origin + LatInterval * i;
if (Math.Abs(lat) > 90)
continue;
var pix = new Location((float)lat, 0).ToPixel(param.Zoom);
var h = pix.Y - param.LeftTopPixel.Y;
canvas.DrawLine(new SKPoint(0, (float)h), new SKPoint((float)param.PixelBound.Width, (float)h), GridPaint);
canvas.DrawText(lat.ToString(), new SKPoint((float)param.Padding.Left, (float)h), GridPaint);
}
}
{
var origin = param.ViewAreaRect.Top - (param.ViewAreaRect.Top % LngInterval);
var count = (int)Math.Ceiling(param.ViewAreaRect.Height / LngInterval) + 1;
for (var i = 0; i < count; i++)
{
var lng = origin + LngInterval * i;
var pix = new Location(0, (float)lng).ToPixel(param.Zoom);
var w = pix.X - param.LeftTopPixel.X;
canvas.DrawLine(new SKPoint((float)w, 0), new SKPoint((float)w, (float)param.PixelBound.Height), GridPaint);
if (lng > 180)
lng -= 360;
if (lng < -180)
lng += 360;
canvas.DrawText(lng.ToString(), new SKPoint((float)w, (float)(param.PixelBound.Height - GridPaint.TextSize - param.Padding.Bottom)), GridPaint);
}
}
}
}
なお先ほど説明した Scale
や Translate
はレイヤーごとに表示したい物の自由度を上げるために各レイヤー側で実装する形となっています。
そのほかのパフォーマンスへの配慮
今のところという感じですが、連続したデータを扱うためどうしても頻繁に配列の確保と破棄が発生することが多く、無駄なGCによるフリーズなどが発生しがちです。そこで積極的に ArrayPool
を使用し配列を使い回せるように改修を進めています。
しかし元々そういったことを考えない設計になっていたので全体的な設計の最適化も含めて進めていく予定です。
おまけ1: タイルマップ画像の描画
現状では雨雲レーダーでしか使用していませんが、従来の 256x256 ピクセルの画像をタイル上に並べて表示する機能があります。
基本的にWebメルカトル図法におけるタイルの配置は簡単で、画面左上の緯度経度から表示するべきタイルの座標とそのずれを加味して画面全体に敷き詰めるだけです。
しかし現状このアプリではWebメルカトル図法ではなくミラー図法を使用(できるように)しているため、緯度によって高さが変わります。
縮尺が変わっているためわかりづらいですが、ミラー図法では高緯度の部分が縦に圧縮されるため日本地図がすこし平べったくなっていることがわかると思います。
先ほどの図を流用するとこんな感じ。
結局タイルはWebメルカトル図法による座標で定義されているため、ミラー図法に合わせて表示する場合は
- 上下の画面座標からミラー図法を使用して地理座標を算出する
- 地理座標をメルカトル図法上の座標に変換する
- タイル数などを計算
- タイル画像のメルカトル図法上の座標を各タイルごとに地理座標を経由しミラー図法上の座標に変換し合わせて変形描画させる
といった流れを取る必要があります。
さすがに毎タイル計算してはもったいないので、サイズが変わる横1列ごとに高さを計算して変形描画させます。
canvas.Save();
try
{
// 使用するキャッシュのズーム
var baseZoom = Provider.GetTileZoomLevel(param.Zoom);
// 実際のズームに合わせるためのスケール
var scale = Math.Pow(2, param.Zoom - baseZoom);
canvas.Scale((float)scale);
// 画面座標への変換
var leftTop = param.LeftTopLocation.CastLocation().ToPixel(baseZoom);
canvas.Translate((float)-leftTop.X, (float)-leftTop.Y);
// メルカトル図法でのピクセル座標を求める
var mercatorPixelRect = new RectD(
param.ViewAreaRect.TopLeft.CastLocation().ToPixel(baseZoom, MercatorProjection),
param.ViewAreaRect.BottomRight.CastLocation().ToPixel(baseZoom, MercatorProjection));
// タイルのオフセット
var xTileOffset = (int)(mercatorPixelRect.Left / MercatorProjection.TileSize);
var yTileOffset = (int)(mercatorPixelRect.Top / MercatorProjection.TileSize);
// 表示するタイルの数
var xTileCount = (int)(mercatorPixelRect.Width / MercatorProjection.TileSize) + 2;
var yTileCount = (int)(mercatorPixelRect.Height / MercatorProjection.TileSize) + 2;
// タイルを描画し始める左上のピクセル座標
var tileOrigin = new PointD(mercatorPixelRect.Left - (mercatorPixelRect.Left % MercatorProjection.TileSize), mercatorPixelRect.Top - (mercatorPixelRect.Top % MercatorProjection.TileSize));
for (var y = 0; y < yTileCount; y++)
{
if (yTileOffset + y < 0)
continue;
// 地理座標を経由してY列ごとに基準となるピクセルとタイルのサイズを計算する
var cy = (float)new PointD(0, tileOrigin.Y + y * MercatorProjection.TileSize).ToLocation(baseZoom, MercatorProjection).ToPixel(baseZoom).Y;
var ch = (float)Math.Abs(cy - new PointD(0, tileOrigin.Y + (y + 1) * MercatorProjection.TileSize).ToLocation(baseZoom, MercatorProjection).ToPixel(baseZoom).Y);
for (var x = 0; x < xTileCount; x++)
{
if (xTileOffset + x < 0)
continue;
// X軸はサイズが変わらないのでそのままメルカトル図法の座標を使う
var cx = (float)(tileOrigin.X + x * MercatorProjection.TileSize);
var tx = xTileOffset + x;
var ty = yTileOffset + y;
// 画像を取得
if (Provider.TryGetTileBitmap(baseZoom, tx, ty, isAnimating, out var image))
{
// 存在すれば変形しながら描画
if (image != null)
canvas.DrawBitmap(image, new SKRect(cx, cy, cx + MercatorProjection.TileSize, cy + ch));
}
else
{
// まだ画像が取得できてない場合はプレースホルダーを表示
canvas.DrawRect(cx, cy, MercatorProjection.TileSize, ch, PlaceHolderPaint);
}
}
}
}
finally
{
canvas.Restore();
}
縮小を挟んでいるためメルカトル図法で直接描画するのに比べると負荷がかかってしまいますが違和感なくミラー図法に落とし込むことができます。
今後の最適化案として、画像を読み込んだ時点で変形させてしまうのも良いかもしれません。
(が、描画時の縮小がGPU側で行われていることを考えると案外今のままの方がトータルでは軽い可能性はあります)
おまけ2: パフォーマンスを気にするコツ
本筋とは離れますが、便利な物は大体重い です。
それから反復して行う処理も大体ボトルネックになりがちです。
.NET における有名な例としては LINQ や IEnumerable にあたりますが、他にも通用します。
どんなに技術が進歩しても画面に表示するためには誰かが1ピクセルごとに色を塗っています。
1行で書けたり、ジオメトリやパスオブジェクトとして抽象化された概念を簡単に扱えるライブラリやフレームワークは多いですが、特殊な最適化がかかる場合など一部の例外を除くとライブラリが汎用的に処理を行ってくれているだけで、利用ケースに対して無駄な機能が多かったりします。
しかし、どのように頑張ったとしてもライブラリなどを使用した際の圧倒的な利便性(や保守性)には敵いません。
それも踏まえた上でボトルネックを確認しながら必要に応じて徐々に低レイヤーへ移していくのが正攻法といえるのではないでしょうか。
さいごに
これらの内容はほぼすべてこのプロジェクト内に記載されているコードの解説になります。
あまりきれいな物ではありませんが、興味のある方は参考にしてみていただけたらと思いますし、詳しい方・もっとよくできるのではといった提案など、どんどん指摘していただけると僕がとても助かります。
この記事では 線分
という単語をよく使用していますが、明らかに正しくない用法な気がするのでご注意ください。
英語であれば PolyLine もしくは Arc といった用語になるのでしょうか。日本語では何を使うのがいいんだろう…。