Rendering and animating 3D markers using Android Google Maps SDK

Lucas Rodrigues
13 min readFeb 8, 2021

--

Sneak peek of the final results

So, have you ever spent hours staring at apps like Uber and wondered: “how do they render these nice 3D cars?”. Google Maps SDK in Android is limited in marker customization, and that makes our lives a little bit harder because we can’t just render a complex view out-of-the-box. But it is still possible, and I’m here today to show you my solution.

Bypassing the SDK limitation of static images as markers

So, the first problem we need to solve in order to render complex views is the need to create a bitmap to draw our marker. We can work around that by adding a layer above the map view that will listen to its camera position changes to refresh all the custom markers (position and zoom).

At first, our layer will be as simple as a FrameLayout with a bindTo method that receives a GoogleMap instance. We then use the OnCamereMoveListener to notify our markers that they need to refresh their position and zoom values to match the new camera position on the map:

class MapOverlayLayout(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

private var googleMap: GoogleMap? = null

fun bindTo(googleMap: GoogleMap) {
this.googleMap = googleMap

googleMap.setOnCameraMoveListener {
refreshMarkers()
}
}
private fun refreshMarkers() {
//TODO
}
}

When our map is loaded inside our activity/fragment with getMapAsync, we can bind it to our overlay:

binding.mapOverlay.bindTo(googleMap)

Now that we have our overlay listening to camera position changes, it's time to move forward.

Creating a custom view that reacts to Google Map camera position changes

So, the main logic behind our new custom view is that its position relative to its parent and scale values will vary based on the MapView camera position. This reflection is triggered by the MapOverlayLayout created above.

Let's begin by creating the MarkerView class. This class will inherit FrameLayout and will have the logic to, given a GoogleMap instance, be able to translate camera zoom and LatLng values to screen coordinates:

abstract class MarkerView(
context: Context,
coordinateOnMap: LatLng,
val googleMap: GoogleMap,
protected val content: View,
) : FrameLayout(context) {

companion object {
const val DEFAULT_REFERENCE_ZOOM = 17f
}

private var zoomOnScreen = (googleMap.cameraPosition?.zoom ?: DEFAULT_REFERENCE_ZOOM) / DEFAULT_REFERENCE_ZOOM.toInt()
set(value) {
if (field != value) {
field = value

post {
scaleX = value
scaleY = value
}
}
}

protected var coordinateOnScreen: Point = googleMap.projection.toScreenLocation(coordinateOnMap)
set(value) {
if (field != value) {
field = value

refresh()
}
}

var coordinateOnMap: LatLng = coordinateOnMap
set(value) {
if (field != value) {
field = value

updatePositionOnScreen()
}
}
private var calculatedFrameSize: Point? = nullfun updatePositionOnScreen() {
coordinateOnScreen = googleMap.projection.toScreenLocation(coordinateOnMap)
}

fun updateZoomOnScreen() {
zoomOnScreen = googleMap.cameraPosition.zoom / DEFAULT_REFERENCE_ZOOM.toInt()
}
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
refresh()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int {
super.onSizeChanged(w, h, oldw, oldh)

if (w != oldw || h != oldh) {
calculatedFrameSize = Point(w, h)
refresh()
}
}
}

Let's take a closer look at what's happening here:

companion object {
const val DEFAULT_REFERENCE_ZOOM = 17f
}

The DEFAULT_REFERENCE_ZOOM is the value in MapView zoom where the marker has scaleX and scaleY = 1. So we can tweak this value if we need bigger or smaller markers in general. When the camera position changes, the marker will have a new scale relative to the reference value.

protected var coordinateOnScreen: Point = googleMap.projection.toScreenLocation(coordinateOnMap)
set(value) {
if (field != value) {
field = value

refresh()
}
}

var coordinateOnMap: LatLng = coordinateOnMap
set(value) {
if (field != value) {
field = value

updatePositionOnScreen()
}
}
fun updatePositionOnScreen() {
coordinateOnScreen = googleMap.projection.toScreenLocation(coordinateOnMap)
}

When MarkerView.updatePositionOnScreen is triggered by our MapOverlayView, we can use GoogleMap's Projection method GoogleMap.Projection.toScreenLocation to translate the marker LatLng coordinates to screen x and y coordinates. The refresh method positions the view using the screen coordinates obtained, using left and top margins as anchors:

private fun refresh() {
val params = ((layoutParams as? LayoutParams) ?: LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT,
)).apply {
leftMargin = coordinateOnScreen.x - ((calculatedFrameSize?.x ?: 0) / 2)
topMargin = coordinateOnScreen.y - ((calculatedFrameSize?.y ?: 0) / 2)

}

super.setLayoutParams(params)
}

The frame size is divided by 2 in the margins because we want the marker to be centered on the anchor. This is similar to setting anchor(0, 0) in a regular Google Map marker.

Now that we have our custom marker class, we can go back to MapOverlayLayout and implement refreshMarkers:

val markers = hashMapOf<Any, MarkerView>()

private fun refreshMarkers() {
markers.forEach { markerView ->
post {
markerView.value.updatePositionOnScreen()
markerView.value.updateZoomOnScreen()

}
}
}
private fun addMarker(id: Any, view: MarkerView) {
post {
markers[id] = view
addView(view)
}
}

Now, each time the camera moves, all the markers update themselves to match the new camera constraints.

Animating marker position change

Now, let's assume we have a marker rendered on the map at coordinates (lat1, lng1) and we now need to go to coordinates (lat2, lng2) walking a straight path. To do that, we can use a ValueAnimator inside our marker, as follows:

open suspend fun animateToPosition(
endPosition: LatLng,
duration: Long,
) {
if (endPosition == coordinateOnMap) {
return
}

val startPosition = this.coordinateOnMap

suspendCoroutine<Unit> { continuation ->
ValueAnimator.ofFloat(0f, 1f).apply {
this.duration = duration
interpolator = null

addUpdateListener { animation ->
coordinateOnMap = LatLngInterpolator.Linear.interpolate(
animation.animatedFraction,
startPosition,
endPosition
)
}

doOnEnd {
continuation.resume(Unit)
}

doOnCancel {
continuation.resume(Unit)
}

post {
start()
}
}
}
}

To perform our animation, the LatLng is interpolated at each animation update and the new LatLng is set as our coordinateOnMap, triggering a visual update.

interface LatLngInterpolator {

fun interpolate(fraction: Float, a: LatLng, b: LatLng): LatLng

class Linear {
companion object : LatLngInterpolator {
override fun interpolate(fraction: Float, a: LatLng, b: LatLng): LatLng {
val lat = (b.latitude - a.latitude) * fraction + a.latitude
val lng = (b.longitude - a.longitude) * fraction + a.longitude
return LatLng(lat, lng)
}
}
}
}

Queuing position updates

In order to walk a smooth path given N location updates, we need a queue to manage the current running animation and prevent any update losses. The queue will be created inside MarkerView and any new position updates will be sent to the queue instead of animating directly:

class AnimationQueue(private val markerView: MarkerView) {private var timeSinceLastUpdate = System.currentTimeMillis()
private val items = mutableListOf<LocationUpdate>()
private val mutex = Mutex()

fun addToQueue(latLng: LatLng) {
items.add(
LocationUpdate(
latLng = latLng,
timeSinceLastUpdate = System.currentTimeMillis() - timeSinceLastUpdate
)
)

timeSinceLastUpdate = System.currentTimeMillis()

markerView.coroutineScope.launch {
runNextItem()
}
}

private suspend fun runNextItem() {
mutex.withLock {
while (items.isNotEmpty()) {
items.removeAt(0).let {
markerView.animateToPosition(
endPosition = it.latLng,
duration = it.timeSinceLastUpdate,
)
}
}
}
}
}

The queue then calls MarkerView's animateToPosition when required.

In MarkerView:

open fun onNewPosition(position: LatLng) {
animationQueue.addToQueue(position)
}

Rendering 3D markers

So far we've got: MapOverlayLayout and custom MarkerView supporting queued position change animation. Now, what we need is our 3D logic. The trick here is to not use real 3D models. Instead, we will go back to our good sprite sheet animations to give us the desired effect.

To do that, you will need to render 360 images of a 3D model on Blender or any 3D modeling software of your choice.

Once you generate each individual angle, you can use a sprite sheet tool to merge them all in one image, as follows:

Example of the sprite sheet used in the project (watermarked because it’s a private model designed for use by BitX Software House)

For my project, i used a reference size of 48dp to size each frame, so you need to resize your image accordingly to your use. At xxxhdpi, my image was about 3,648 x 3,648 pixels, resulting in a 19 x 19 matrix (one frame remains empty at the end, we can map the degree 360 to 0, as they are the same trigonometry-wise.

Now that we have our sprite sheet ready, let's create our rotation component: this class will be responsible for animating from angle ang1 to ang2 (in degrees) using the sheet created above.

Fortunately, Android SDK has a sprite animation mechanism out-of-the-box that we can use directly inside an ImageView instance. To use it, you will need to set the ImageView's scale type to "matrix". This type gives you the freedom to transform your image using a transformation matrix, defined by a Matrix object. We will use this object to apply a translation to the image based on its current angle.

To do this, we will need the starting point of the portion of the image that matches the given angle. The function is defined as follows:

private fun findAnglePositionOnImage(degree: Int): Point {
val (row, column) = when {
degree == 360 -> Pair(0, 0)
degree <= MATRIX_SIZE - 1 -> Pair(0, degree)
else -> Pair(degree / MATRIX_SIZE, degree % MATRIX_SIZE)
}

return Point(
column * frameSize.x,
row * frameSize.y,
)
}

In the method above, we first obtain the line and column of the angle in the 19 x 19 matrix, then we multiply each by the frameSize to get the origin point of the degree relative to our sprite sheet image. With this method, we can look up the following point:

findAnglePositionOnImage results in point (x, y) for the given degree
Example of findAnglePositionOnImage when angle to look up is 2 degrees

Having our logic to lookup a degree's position on the sprite sheet, we can write the logic to move the image inside our ImageView using the Matrix class:

fun jumpToAngle(degree: Int) {
val point = findAnglePositionOnImage(degree)

currentAngle = degree

markerImageView.imageMatrix = Matrix().apply {
setTranslate(-point.x.toFloat(), -point.y.toFloat())
}
}

With these methods ready, we can jump to any given angle inside our ImageView.

Animating 3D marker rotation

With the jumpToAngle method described above, we can now animate the rotation on our sprite sheet from angle ang1 to ang2. But first, we need to create a logic to decide whether our angle rotation will be clockwise or anti-clockwise because our animation must always pick the shortest path from angle ang1 to ang2.

For instance, let's say we have ang1 = 10 and ang2 = 350. For these parameters, we have two turn choices: clockwise = 340 and anti-clockwise = 20. In this case, we choose -20, the minus sign representing an anti-clockwise turn. So our formula is defined by:

private fun shortestAngleMovement(
from: Int,
to: Int
): Int {
val difference = to - from

val conjugate = if (difference >= 0) {
difference - 360
} else {
difference + 360
}

return if (conjugate.absoluteValue < difference.absoluteValue)
conjugate
else
difference
}

In the above formula, we calculate the difference between the two angles and then the conjugate angle. The conjugate, in trigonometry, is the angle whose sum with difference.absoluteValue is 360. The sign trade in the conjugate is made to identify which turn is clockwise and which is anti-clockwise.

Having the shortest turn working, we can begin to write our animation logic. First, we calculate the shortest turn. Then, we use its absolute value as the number of jumps (jumpToAngle described above) we will perform. Then, we begin a loop that, after some delay (based on a default duration value and the number of jumps required), perform a forward or backward jump, depending on whether the turn is clockwise or anti-clockwise:

suspend fun animate(to: Int) {
val degrees = shortestAngleMovement(currentAngle, to)
val degreeCount = degrees.absoluteValue
val isClockwiseTurn = degrees > 0

val turnDuration = (degreeCount * 2000L) / 360

for (i in 0..degreeCount) {
delay(turnDuration / degreeCount)
withContext(Dispatchers.Main) {
if (isClockwiseTurn) {
jumpToAngle(
if (currentAngle == 359)
0
else
currentAngle + 1
)
} else {
jumpToAngle(
if (currentAngle == 0)
359
else
currentAngle - 1
)
}
}
}
}

Let's take a closer look at some points:

val turnDuration = (degreeCount * 2000L) / 360

Here we define a reference duration: when the degree count is 360, the turn will last 2 seconds. Any value below it will last proportionally less.

if (isClockwiseTurn) {
jumpToAngle(
if (currentAngle == 359)
0

else
currentAngle + 1
)
} else {
jumpToAngle(
if (currentAngle == 0)
359

else
currentAngle - 1
)
}

In the two highlighted points, we "reset" the angle count, so when we reached 359 degrees and we need to move forward to 360, the currentAngle is set to 0. The same happens when we reach 0 in an anti-clockwise turn and we need to go backward.

With this, our rotation component is complete, and now we need to bind it to our MarkerView. To do this, we create a new class that Inherits MarkerView and will set our car sprite:

class CarRotation3DMarkerView(
context: Context,
googleMap: GoogleMap,
coordinateOnMap: LatLng,
initialBearing: Int
) : MarkerView(
context = context,
coordinateOnMap = coordinateOnMap,
googleMap = googleMap,
content = inflate(context, R.layout.map_overlay_car_marker, null),
) {

private val carBodyView: ImageView by lazy {
findViewById(R.id.carBody)
}

private val carFrameSize = Point(
context.resources.getDimension(R.dimen.frame_size).toInt(),
context.resources.getDimension(R.dimen.frame_size).toInt(),
)

private val rotationComponent by lazy {
Rotation3DComponent(
widgets = listOf(carBodyView),
frameSize = carFrameSize,
initialAngle = initialBearing,
)
}
init {
changeCarType(CarType.NORMAL)
}
fun changeCarType(toType: CarType) {
val width = carFrameSize.x * 19
val height = carFrameSize.y * 19

Glide.with(this)
.load(toType.body)
.override(width, height)
.into(carBodyView)
}

CarType is an enum that contains the id of the sprite drawable, in case you want to add more types and change them at runtime.

The XML file to inflate has the following contents:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">

<ImageView
android:id="@+id/carBody"
android:layout_width="@dimen/frame_size"
android:layout_height="@dimen/frame_size"
android:scaleType="matrix"
android:src="@drawable/car_type_normal_body"
android:visibility="visible" />
</FrameLayout>

In our class, we also need to override MarkerView.animateToPosition to include the rotation animation along with the movement:

override suspend fun animateToPosition(
endPosition: LatLng,
duration: Long,
) {
if (endPosition == coordinateOnMap) {
return
}

coroutineScope.launch {
rotationComponent.animate(
to = coordinateOnMap.angleInDegrees(
to = endPosition,
),
)
}

super.animateToPosition(endPosition, duration)
}

We use coroutineScope.launch rather than a direct call to make sure the rotation and position animations will run in parallel.

We also define an extension function in LatLng to calculate the angle in degrees between two map coordinates:

fun LatLng.angleInDegrees(to: LatLng): Int {
return ((SphericalUtil.computeHeading(this, to) + 360) % 360).roundToInt()
}

The method SphericalUtil.computeHeading returns an angle between -180 and 180 degrees. We sum this result to 360 and mod to 360 to get a normalized angle between 0 and 360.

Creating and updating marker positions on MapOverlayView

To summarize what we've got so far:

  • MapOverlayView (the layer above MapView instance to render custom markers);
  • MarkerView (the custom view that updates its position and scale based on MapView camera changes);
  • CarRotation3DMarkerView (child of MarkerView, contains an additional logic to trigger its sprite sheet animation);
  • AnimationQueue (queue to manage MarkerView's position changes);
  • Rotation3DComponent (the component that performs sprite sheet animations based on angles);

Now what we need to finish up everything is to insert methods to trigger position updates on single markers (for instance, a car marker that is walking down a path and receives new coordinates from server). So, inside MapOverlayView:

private fun getCarMarker(id: Any): MarkerView? {
return markers[id]
}

@Synchronized
fun createOrUpdateMarker(
id: Any,
position: LatLng,
rotation: Int = 0
): MarkerView? {
return getCarMarker(id)?.apply {
onNewPosition(position)
} ?: createCarMarker(
latLng = position,
id = id,
rotation = rotation,
)
}

private fun createCarMarker(
id: Any,
latLng: LatLng,
rotation: Int
): MarkerView? {
return googleMap?.let { googleMap ->
CarRotation3DMarkerView(
context = context,
googleMap = googleMap,
coordinateOnMap = latLng,
initialBearing = rotation
).apply {
addMarker(id, this)
}
}
}

So, in your activity, you can call:

binding.mapOverlay.createOrUpdateMarker(
id = id,
position = position
)

With this, you can run your project and test it!

Bonus: change car body color at runtime

We've got everything running, but what if we want to change the car color without coloring everything with a color filter? Well, with a little bit of extra work, we can do that!

The trick here is to insert a new sprite sheet above the full car sheet. This new sprite will contain only the parts of the car we do not want to tint. In my example:

Mask sprite sheet

As you can see, the image is very clean, as it contains only wheels and headlights. This image will be inserted in the car XML:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">

<ImageView
android:id="@+id/carBody"
android:layout_width="@dimen/frame_size"
android:layout_height="@dimen/frame_size"
android:scaleType="matrix"
android:src="@drawable/car_type_normal_body" />

<ImageView
android:id="@+id/carMask"
android:layout_width="@dimen/frame_size"
android:layout_height="@dimen/frame_size"
android:scaleType="matrix"
android:src="@drawable/car_type_normal_mask" />

</FrameLayout>

But that's not all: we also need to increment our rotation component to accept a list of ImageViews to rotate at the same time:

class Rotation3DComponent(
imageViewsToRotate: List<ImageView>,
initialAngle: Int = 0,
private val frameSize: Point,
) {
private val imageViewsToRotate = imageViewsToRotate.map { WeakReference(it) }

fun jumpToAngle(degree: Int) {
val point = findAnglePositionOnImage(degree)

imageViewsToRotate.forEach { weakReference ->
weakReference.get()?.let { imageView ->
currentAngle = degree

imageView.imageMatrix = Matrix().apply {
setTranslate(-point.x.toFloat(), -point.y.toFloat())
}
}
}
}

In our CarRotation3DMarkerView rotation component declaration:

private val carMaskView: ImageView by lazy {
findViewById(R.id.carMask)
}

private val rotationComponent by lazy {
Rotation3DComponent(
imageViewsToRotate = listOf(carBodyView, carMaskView),
frameSize = carFrameSize,
initialAngle = initialBearing,
)
}

And inside the CarRotation3DMarkerView.changeCarType method:

fun changeCarType(toModel: CarType) {
val width = carFrameSize.x * 19
val height = carFrameSize.y * 19

Glide.with(this)
.load(toModel.body)
.override(width, height)
.into(carBodyView)

Glide.with(this)
.load(toModel.mask)
.override(width, height)
.into(carMaskView)

}

To change car color, you simply need to apply a color filter in your carBodyView variable. The image will be tinted and the mask will remain the same:

fun changeCarColor(color: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
carBodyView
.drawable
.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
color,
BlendModeCompat.MODULATE,
)
} else {
carBodyView
.drawable
.setColorFilter(
color,
PorterDuff.Mode.MULTIPLY
)
}
}

And you're done

Now you should have all these components in your project. All you have to do to run is create your own sprite sheet and generate a Google Maps API Key, and you're good to go!

Final results!

The same logic can be used in a similar way in native iOS and Flutter, and i may write other articles covering each case.

Special thanks to BitX Software House for providing the 3D sprites!

--

--