地図上にヒートマップを描く「裏」の技術
こんにちは、三代目ゆう と 広葉樹です。
私達はナビタイムジャパンで地図フレームワークエンジニアを担当しています。
先日、当社のウォーキングアプリ『ALKOO by NAVITIME』の「週間レポート」にて「散歩エリア」機能をリリースしました!
「散歩エリア」では、自分のウォーキングエリアの傾向を、地図上のヒートマップ形式で表現しています。
下図のように、頻繁にウォーキングをしている場所ほど、赤色に表示されるようになっています。
機能紹介
『ALKOO』の「散歩エリア」機能は、1週間ごとに自分が散歩した場所を地図上にヒートマップで表現する機能です。画像をご覧いただくと分かる通り、この機能によって、自分のウォーキングエリアを視覚的に楽しく振り返ることが可能で、ウォーキングのモチベーション維持にはもってこいの機能です。
実は、本機能のヒートマップ描画部分については、社内共通ライブラリであるところの地図フレームワークにて実装しており、任意の点群データさえあれば、ヒートマップとして地図上に描画することが可能です。
「散歩エリア」を含む一連機能である「週間レポート」の詳細については、それぞれ当社プレスリリースのほか、デザイナー目線でのnoteも執筆されておりますので、ぜひご覧ください。
開発経緯
こちらのヒートマップ機能は、ナビタイムジャパンの研究開発部門の1つである我々地図開発チームのアイデアから開発が始まりました。
これまで、当社の地図では、交通渋滞線や鉄道路線図をはじめ、目立つ建築物を3次元モデルで表示したり、天候情報や気温などをグリッド状にして地図上に表示したりなど、地図の上に情報を重ねる機能を多数開発してきました。
そこで、汎用的なヒートマップ機能があれば、共通ライブラリとして当社のアプリ開発者に役に立つのでは?という思いから、開発がスタートしました。
そこからまずはAndroid版のみで約3週間弱で開発を行い、これを『ALKOO』のサービス開発者に見てもらったところ、是非使ってみたいとの声をいただき、今回「週間レポート」機能の一部としてサービスリリースという形になりました。
実現方法
さて、ここではそんなヒートマップの描画実現方法についてご紹介したいと思います。
ヒートマップとオフスクリーンレンダリング
そもそもヒートマップとは、2次元データの分布や個々の値を濃淡または色などで可視化したものを指します。
今回開発した機能では、アプリから任意の緯度経度点列をデータとして入力してもらい、その点ごとの値や密集度に応じて色分けを行うという仕様を考えていました。
このとき、愚直な手法として、与えられた各点に対して任意の半径の円を描画するような実装が考えられます。しかし、この方法だと円同士が重なる箇所について単色の透過度や濃淡で表すことは出来ても、同じ透過度や濃淡で色分けをするといった見た目が表現できません。これでは、背景となる地図自体の視認性を確保する、より直感的な見た目とするといった観点からあまり実用的ではありませんでした。
そこで、オフスクリーンレンダリングと呼ばれる手法により、2ステップの描画を行うことで実現することが出来ます。
通常はOpenGLなどのグラフィックAPIによって描画命令を呼び出すと、描画対象としてコンテキストに紐づいている画面に対して処理が実行されます。しかし今回のように一度描画した結果をリアルタイムでさらに加工したい場合などは、直接画面に出力せずに画面の「裏」でレンダリングする必要があります。
そこで、メモリ上に確保した領域に対して描画結果を書き込むことでそれを実現する機能が各グラフィックAPIに用意されています。このメモリ上に確保した領域のことをフレームバッファと呼んだりします。
具体的には、以下の手順によって図のようなヒートマップの描画を実現することが出来ます。
まず、与えられたスポット点群を各点の中心から外に向かってグラデーションで透過するような任意の半径の円としてフレームバッファに描画します。これにより、円同士が重なった箇所は自然と不透明度の値が高くなるため、データの密な箇所ほど色が濃く、疎な箇所ほど薄くなる結果が得られます。
ステップ1で描画した結果は、フレームバッファを通じて一つの画像(以下テクスチャ)として表現されているため、今度はそのテクスチャをスクリーンに対して描画します。このとき、不透明度の値に応じて任意の色味としてマッピングするようなシェーダーを実装することでデータの密度を色分けによって表現する事ができるようになります。図の例では、0から1にかけて青→緑→赤と変化していくようにしています。なお、「シェーダー」についての詳細な説明については、過去に投稿した以下の記事も併せて参照ください。
以下に、サンプルを交えてAndroidおよびiOSでのオフスクリーンレンダリングの流れを簡単に示します。ただし、あくまでニュアンスを伝えるためのサンプルコードなので、必要な実装が足りていないことにご留意ください。
Androidでの実装方針
Androidでは地図描画にOpenGLES3.0を用いています。OpenGLESは2.0以降でフレームバッファの仕組みが使えるため、それによってオフスクリーンレンダリングを実現できます。
1.事前に画面(厳密には地図を描画するView)サイズに合わせたテクスチャとフレームバッファを構築し、カラーバッファとして用意したテクスチャをアタッチしておきます。
// テクスチャを生成してバインド
val textures = IntArray(1)
GLES30.glGenTextures(1, textures, 0)
val texture = textures[0]
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texture)
// 必要に応じてパラメータ設定など、中略
// 空データで大きさだけ指定
GLES30.glTexImage2D(
GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA,
width, height,
0, GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE,
null
)
// フレームバッファを生成してバインド
val buffers = IntArray(1)
GLES30.glGenFramebuffers(1, buffers, 0)
val frameBuffer = buffers[0]
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, frameBuffer)
// Color用テクスチャのアタッチ
GLES30.glFramebufferTexture2D(
GLES30.GL_FRAMEBUFFER,
GLES30.GL_COLOR_ATTACHMENT0,
GLES30.GL_TEXTURE_2D,
texture,
0)
2.描画タイミングで先述のフレームバッファをバインドした状態で点群をdrawします。
// フレームバッファのバインド
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, frameBufferId)
// フレームバッファのクリア
GLES30.glClearColor(red, green, blue, alpha)
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
// 任意の頂点バッファ等を渡してdrawを呼び出し(詳細は省略)
GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, count)
3.フレームバッファのバインドを解除して、先ほどのフレームバッファにアタッチされていたテクスチャを画面全体に対して描画します。
// フレームバッファのバインド解除
GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0)
// 各種頂点バッファのバインド(省略)
// テクスチャのバインドとユニフォームへの設定
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureId)
GLES30.glUniform1i(textureUniformId, 0)
// draw呼び出し
GLES30.glDrawArrays(GLES30.GL_TRIANGLES, 0, count)
4.最後に、シェーダーでは単にテクスチャを描画するのではなく、各ピクセルの色を個々の値として解釈して(例では不透明度の値を参照しています)、その値にあった色にマッピングすることで色として出力します。例として、フラグメントシェーダーの単純な実装を示します。
void main() {
vec4 texValue = texture(tex, vTexCoord);
float ratio = texValue.a;
if (ratio <= 0.0) {
discard;
}
vec4 baseColor = vec4(0.0);
if (ratio < 0.2) {
baseColor = vec4(1.0, 0.0, 0.0, 0.5);
} else if (ratio < 0.4) {
baseColor = vec4(1.0, 1.0, 0.0, 0.5);
} else if (ratio < 0.6) {
baseColor = vec4(0.0, 1.0, 0.0, 0.5);
} else if (ratio < 0.8) {
baseColor = vec4(0.0, 1.0, 1.0, 0.5);
} else if (ratio < 1.0) {
baseColor = vec4(0.0, 0.0, 1.0, 0.5);
}
fragColor = baseColor;
}
iOSでの実装方針
実装の手順としてはAndroidが先であったため、Androidの描画に合わせる形でiOSにも描画処理を適用していきました。
iOSではOpenGLES3.0ではなく、Appleから提供されているグラフィックスAPIであるMetalを用いることで、オフスクリーンレンダリングを含めた地図描画を実現しています。
1.まず、地図描画を行うViewの高さと幅を用いてテクスチャを生成します。次に、Androidと同じく、このテクスチャをアタッチしたフレームバッファを構築します。ここで、実はMetalではフレームバッファを使った描画しか行うことができず、レンダリングを行うコマンドの一連の流れに対して固有のフレームバッファが用意されている設計になっています。
そこで、レンダリングを行うコマンドに対して、オフスクリーン用に生成したテクスチャをそのまま指定するだけで、オフスクリーン描画用のレンダーオブジェクトが生成できます。
// MTLDeviceの生成などは中略
// テクスチャを生成
let offscreenColorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm,
width: width,
height: height,
mipmapped: false)
offscreenColorDesc.usage = [.renderTarget, .shaderRead, .shaderWrite]
offscreenColorDesc.storageMode = .private
let offscreenColorTexture = device.makeTexture(descriptor: offscreenColorDesc)
// depthStencilTextureについては中略
// オフスクリーンレンダリング用のテクスチャを用いてディスクリプタ生成
let offscreenRenderPassDesc = MTLRenderPassDescriptor()
// colorAttachments[0]がnilでないことを確認して設定
if let offscreenColorAttachment = offscreenRenderPassDesc.colorAttachments[0] {
offscreenColorAttachment.texture = offscreenColorTexture
offscreenColorAttachment.clearColor = MTLClearColorMake(red, green, blue, alpha)
offscreenColorAttachment.storeAction = .store
offscreenColorAttachment.loadAction = .clear // もしくは .load
}
// depthAttachmentおよびstencilAttachmentについては中略
2.描画タイミングで、現在のコマンドエンコーダー(実際にレンダリングを行うコマンド)に先述のフレームバッファに当て、点群をdrawします。
// コマンドバッファの生成は省略
// 現在のコマンドエンコーダーをフレームバッファにする(現在のコマンドエンコーダーの設定は省略)
frontCommandEncoder?.endEncoding()
frontCommandEncoder = nil
offscreenCommandEncoder = offscreenCommandBuffer?.makeRenderCommandEncoder(descriptor: offscreenRenderPassDesc)
// 任意の頂点バッファ等を渡してdrawを呼び出し(詳細は省略)
offscreenCommandEncoder?.drawPrimitives(type: .triangleStrip, vertexStart: first, vertexCount: count)
3.フレームバッファが当てられていたコマンドエンコーダーの設定をもとに戻して、先ほどのフレームバッファにアタッチされていたテクスチャを画面全体に対して描画します。
// コマンドエンコーダーからフレームバッファの設定解除
offscreenCommandEncoder?.endEncoding()
offscreenCommandEncoder = nil
frontCommandEncoder = frontCommandBuffer?.makeRenderCommandEncoder(descriptor: frontRenderPassDesc)
// 各種頂点バッファのバインド(省略)
// テクスチャのバインドとユニフォームへの設定(一部省略)
frontRenderPassDesc.colorAttachments[0].texture = frontColorTexture
frontCommandEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0) // vertexBufferは適切な値に置き換えてください
frontCommandEncoder?.setFragmentBytes(&alpha, length: MemoryLayout<Float>.size, index: 1) // alphaは適切な値に置き換えてください
frontCommandEncoder?.setFragmentBytes(&colorSize, length: MemoryLayout<Float>.size, index: 2) // colorSizeは適切な値に置き換えてください
frontCommandEncoder?.setFragmentSamplerState(samplerState, index: 0) // samplerStateは適切な値に置き換えてください
frontCommandEncoder?.setFragmentTexture(texture, index: 0) // textureは適切な値に置き換えてください
// draw呼び出し
frontCommandEncoder?.drawPrimitives(type: .triangleStrip, vertexStart: first, vertexCount: count)
4.Metalにおいても、シェーダーでの描画は各ピクセルの色ごとに解釈しています。以下に、Androidでの実装をiOSの実装(Metal)に落とし込んだ例を示します。
fragment float4 fragment_heat_map( 引数は省略 )
{
float4 texValue = tex.sample(sampler, varyings.vertexCoord);
float ratio = texValue.a;
if (ratio <= 0.0) {
discard_fragment();
}
float4 baseColor = float4(0.0f);
if (ratio < 0.2) {
baseColor = float4(1.0, 0.0, 0.0, 0.5);
} else if (ratio < 0.4) {
baseColor = float4(1.0, 1.0, 0.0, 0.5);
} else if (ratio < 0.6) {
baseColor = float4(0.0, 1.0, 0.0, 0.5);
} else if (ratio < 0.8) {
baseColor = float4(0.0, 1.0, 1.0, 0.5);
} else if (ratio < 1.0) {
baseColor = float4(0.0, 0.0, 1.0, 0.5);
}
return baseColor;
}
おわりに
今回は、『ALKOO by NAVITIME』の新機能である「散歩エリア」について、そのヒートマップ描画の技術についてお話させていただきました。
本機能は社内共通ライブラリで実装しているため、今後『ALKOO』のほかにもナビタイムジャパンの複数サービスで導入を検討しています。
今後も地図の新しい見せ方について考え、さらなる新機能の開発を進めていきたいと思います!