Как мы разрабатывали AR-приложение для обзора исторических мест

от автора


Недавно мы объединяли технологии старинные с технологиями современными, что из этого получилось читайте под катом.

Augmented Reality

Приложения с дополненной реальностью в качестве гидов по городам — тема хорошо известная и реализованная многими разработчиками. Это направление использования AR появилось одним из первых, так как позволяет использовать все очевидные возможности дополненной реальности: показывать пользователям информацию о зданиях, давать справку о работе учреждения и знакомить с достопримечательностями. На последнем хакатоне, который проводился внутри компании, было представлено несколько проектов с применением дополненной реальности, и нам пришла в голову идея создать AR-приложение, которое покажет, как выглядели достопримечательность или историческое место в прошлом. Для этого — объединить современные технологии дополненной реальности со старинными фотографиями. Например, оказавшись перед Исаакиевским собором, можно будет навести на него камеру смартфона и увидеть его первое деревянное здание, которое было разобрано в 1715 году.

Механика работы такая: приложение отображает заданные исторические места и достопримечательности города на карте, выводит на экран краткую информацию о них, при помощи нотификаций оповещает пользователя о том, что он находится неподалеку от интересной точки. Когда человек приближается к историческому памятнику на расстояние 40 метров, становится доступен AR-режим. При этом открывается камера, и краткая информация об объектах отображается прямо в окружающем пользователя пространстве. Последний имеет возможность взаимодействовать с виртуальными объектами: прикоснувшись к карточке исторического места, можно перейти к просмотру альбома с изображениями.

Казалось бы, приложение очень простое, однако и тут не обошлось без подводных камней. Не буду утомлять вас рассказом о реализации тривиальных вещей вроде загрузки данных с сервера или отображения точек на карте, перейду сразу к функциям, вызвавшим проблемы.

Проблема 1. Плавающие точки

Итак, первым делом нужно было разместить в пространстве точки-маркеры в соответствии с реальным расположением исторических мест относительно текущей локации и направления взгляда пользователя.

Для начала решили воспользоваться уже готовой библиотекой для iOS: ARKit-CoreLocation. Проект лежит на GitHub в свободном доступе, содержит помимо кода основных классов примеры интеграции и позволяет выполнить интересующую нас задачу за пару часов. Необходимо только скормить библиотеке координаты точек и изображение, используемое в качестве маркера.

Неудивительно, что за эту легкость пришлось заплатить. Точки-маркеры постоянно плавали в пространстве: то забирались под потолок, то отрисовывались где-то под ногами. Далеко не каждый пользователь согласился бы несколько минут ловить AR-объект в фокус, чтобы ознакомиться с интересующей его информацией.

Как выяснилось, с этим багом библиотеки столкнулись многие, однако решение до сих пор не найдено. Код на GitHub, к сожалению, не обновлялся уже более полугода, так что пришлось идти в обход.

Попробовали в координатах вместо фиксированной высоты над уровнем моря использовать altitude, которую LocationManager возвращал для текущего положения пользователя. Однако полностью проблему это не устранило. Данные, поступающие от Location Manager, начинали прыгать с разбросом до 60 метров, стоило только покрутить устройство в руках. В результате картинка получалась нестабильной, что нас, конечно, снова не устроило.

В итоге решено было отказаться от библиотеки ARKit-CoreLocation и разместить точки в пространстве самостоятельно. Очень сильно в этом помогла статья ARKit and CoreLocation, написанная Кристофером Веб-Оренштейном. Пришлось потратить немного больше времени и освежить в памяти некоторые математические аспекты, но результат того стоил: AR-объекты наконец-то оказались на своих местах. После этого осталось только разбросать их по оси Y, чтобы надписи и точки было проще прочитать, да поставить соответствие между расстоянием от текущего положения до точки и координатой Z AR-объекта, чтобы информация о ближайших исторических местах оказалась на переднем плане.

Понадобилось рассчитать новую позицию SCNNode в пространстве, ориентируясь на координаты:

let place = PlaceNode() let locationTransform = MatrixHelper.transformMatrix(for: matrix_identity_float4x4, originLocation: curUserLocation, location: nodeLocation, yPosition: pin.yPos, shouldScaleByDistance: false) let nodeAnchor = ARAnchor(transform: locationTransform) scene.session.add(anchor: nodeAnchor) scene.scene.rootNode.addChildNode(place) 

В класс MatrixHelper вынесли вспомогательные функции:

class MatrixHelper {  static func transformMatrix(for matrix: simd_float4x4, originLocation: CLLocation, location: CLLocation, yPosition: Float) -> simd_float4x4 {     	let distanceToPoint = Float(location.distance(from: originLocation))     	let distanceToNode = (10 + distanceToPoint/1000.0)     	let bearing = GLKMathDegreesToRadians(Float(originLocation.coordinate.direction(to: location.coordinate)))     	let position = vector_float4(0.0, yPosition, -distanceToNode, 0.0)     	let translationMatrix = MatrixHelper.translationMatrix(with: matrix_identity_float4x4, for: position)     	let rotationMatrix = MatrixHelper.rotateAroundY(with: matrix_identity_float4x4, for: bearing)     	let transformMatrix = simd_mul(rotationMatrix, translationMatrix)     	return simd_mul(matrix, transformMatrix) 	}  static func translationMatrix(with matrix: matrix_float4x4, for translation : vector_float4) -> matrix_float4x4 {     	var matrix = matrix     	matrix.columns.3 = translation     	return matrix 	}  static func rotateAroundY(with matrix: matrix_float4x4, for degrees: Float) -> matrix_float4x4 {     	var matrix : matrix_float4x4 = matrix    	      	matrix.columns.0.x = cos(degrees)     	matrix.columns.0.z = -sin(degrees)    	      	matrix.columns.2.x = sin(degrees)     	matrix.columns.2.z = cos(degrees)     	return matrix.inverse 	} } 

Для расчета азимута добавили расширение CLLocationCoordinate2D

extension CLLocationCoordinate2D {     	func calculateBearing(to coordinate: CLLocationCoordinate2D) -> Double {     	let a = sin(coordinate.longitude.toRadians() - longitude.toRadians()) * cos(coordinate.latitude.toRadians())     	let b = cos(latitude.toRadians()) * sin(coordinate.latitude.toRadians()) - sin(latitude.toRadians()) * cos(coordinate.latitude.toRadians()) * cos(coordinate.longitude.toRadians() - longitude.toRadians())     	return atan2(a, b) 	}      	func direction(to coordinate: CLLocationCoordinate2D) -> CLLocationDirection {     	return self.calculateBearing(to: coordinate).toDegrees() 	} }  

Проблема 2. Избыток AR-объектов

Следующей проблемой, с которой мы столкнулись, было огромное количество AR-объектов. В нашем городе немало исторических мест и достопримечательностей, поэтому плашки с информацией сливались и наползали одна на другую. Пользователю с большим трудом удалось бы разобрать часть надписей, и это могло произвести отталкивающее впечатление. Посовещавшись, решили ограничить количество одновременно отображаемых AR-объектов, оставив только точки в радиусе 500 метров от текущего местоположения.

Тем не менее, в некоторых районах концентрация точек все равно была слишком велика. Поэтому для увеличения наглядности решили использовать кластеризацию. На экране карты эта особенность доступна по умолчанию благодаря логике, заложенной в MapKit, а вот в AR-режиме реализовывать ее пришлось вручную.

В основу кластеризации положили расстояние от текущего положения до цели. Таким образом, если точка попадала в зону с радиусом, равным половине расстояния между пользователем и предыдущей достопримечательностью из списка, она просто скрывалась и входила в состав кластера. При приближении к ней пользователя расстояние уменьшалось, соответственно уменьшался и радиус зоны кластера, поэтому достопримечательности, расположенные неподалеку, не сливались в кластеры. Чтобы визуально отличать кластеры от одиночных точек, решили изменить цвет маркера и вместо названия места отображать в AR количество объектов.

image

Для обеспечения интерактивности AR-объектов на ARSCNView повесили UITapGestureRecognizer и в обработчике при помощи метода hitTest проверяли, на какой из объектов SCNNode нажал пользователь. Если это оказывалась фотография расположенной неподалеку достопримечательности, приложение открывало соответствующий альбом в полноэкранном режиме.

Проблема 3. Радар

В ходе реализации приложения необходимо было показать точки на небольшом радаре. По идее, недоразумений с этим быть не должно было, ведь азимут и расстояние до точки мы уже вычислили, даже успели преобразовать их в 3D координаты. Оставалось только разместить точки в двумерном пространстве на экране.

Чтобы не изобретать велосипед, обратились к библиотеке Radar, открытый код которой опубликован на GitHub. Яркое превью и гибкие настройки примера обнадеживали, однако на деле точки оказались смещены относительно истинного расположения в пространстве. Провозившись некоторое время в попытках исправить формулы, обратились к менее красивому, но более надежному варианту, описанному в приложении iPhone Augmented Reality Toolkit:

func place(dot: Dot) {     	var y: CGFloat = 0.0     	var x: CGFloat = 0.0    		 	if degree < 0 {                 	degree += 360             }     	let bearing = dot.bearing.toRadians()    	 	let radius: CGFloat = 60.0 // radius of the radar view      	if (bearing > 0 && bearing < .pi / 2) {         	//the 1 quadrant of the radar         	x = radius + CGFloat(cosf(Float((.pi / 2) - bearing)) * Float(dot.distance))         	y = radius - CGFloat(sinf(Float((.pi / 2) - bearing)) * Float(dot.distance))     	} else if (bearing > .pi / 2.0 && bearing < .pi) {         	//the 2 quadrant of the radar         	x = radius + CGFloat(cosf(Float(bearing - (.pi / 2))) * Float(dot.distance))         	y = radius + CGFloat(sinf(Float(bearing - (.pi / 2))) * Float(dot.distance))     	} else if (bearing > .pi && bearing < (3 * .pi / 2)) {         	//the 3 quadrant of the radar         	x = radius - CGFloat(cosf(Float((3 * .pi / 2) - bearing)) * Float(dot.distance))         	y = radius + CGFloat(sinf(Float((3 * .pi / 2) - bearing)) * Float(dot.distance))     	} else if (bearing > (3 * .pi / 2.0) && bearing < (2 * .pi)) {         	//the 4 quadrant of the radar         	x = radius - CGFloat(cosf(Float(bearing - (3 * .pi / 2))) * Float(dot.distance))         	y = radius - CGFloat(sinf(Float(bearing - (3 * .pi / 2))) * Float(dot.distance))     	} else if (bearing == 0) {         	x = radius         	y = radius - CGFloat(dot.distance)     	} else if (bearing == .pi / 2) {         	x = radius + CGFloat(dot.distance)         	y = radius     	} else if (bearing == .pi) {         	x = radius         	y = radius + CGFloat(dot.distance)     	} else if (bearing == 3 * .pi / 2) {         	x = radius - CGFloat(dot.distance)         	y = radius     	} else {         	x = radius         	y = radius - CGFloat(dot.distance)     	}    	      	let newPosition = CGPoint(x: x, y: y)    	      	dot.layer.position = newPosition 

Backend

Осталось решить проблему хранения точек и фотографий. Для этих целей было решено использовать Contentful, и в текущей реализации проекта он нас полностью устроил.


На момент разрботки мобильного приложения все бекендеры были заняты на коммерческих проектах, а contentful позволил предоставить в течение нескольких часов:

  • мобильному разработчику — удобный бекенд
  • контент-менеджеру — удобную админку для заполнения данных

Подобную реализацию бекенда изначально использовали и команды, которые участвовали в хакатоне (упоминался в начале статьи), что в очередной раз доказывает, что такие вещи, как хакатоны, позволяют отвлечься от решения своих насущных задач на проектах, дают возможность покреативить и попробовать что-то новенькое.

Заключение

Недавно мы выложили приложение в AppStore. Вот как оно выглядит в работе.

Пока что у нас в базе есть точки только для Таганрога, однако, мы готовы к расширению «зоны покрытия».


ссылка на оригинал статьи https://habr.com/post/421067/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *