Как мы разрабатывали 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 позволил предоставить в течение нескольких часов:

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

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

    Заключение



    Разрабатывать AR-приложение было очень интересно, в процессе мы испробовали несколько готовых библиотек, но также нам пришлось вспомнить математику и много чего написать самим.

    Простой, на первый взгляд, проект потребовал немало рабочих часов на реализацию и доводку алгоритмов, несмотря на то, что мы использовали стандартный SDK от Apple.

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


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

    Lodoss Team

    59,00

    Сложные системы, мобильные приложения, дизайн и AR

    Поделиться публикацией
    Комментарии 4
      0

      Дополненная реальность — это прямоугольник с названием парка?
      Судя по вводной, я понял, что на экране должно было наложиться старинное изображение на картинку с камеры.

        0
        «Написали приложение дополненной реальности, а чтобы не изобретать велосипед решили взять готовое. Начинаем с того, что заходим в аппстор...»: Р
          0
          ну все :) чуть не заклевали пацанов :))))
            0
            Без ИИ или сложной предобработки мы смогли получить только карточку объекта с превьюшкой фотографии. Но это позволило загружать любые фотографии для произвольных объектов (надеюсь, что база из будет увеличиваться). В дальнейших планах ручная подготовка фотографий для накладывания их на реальные объекты, но это на будущее. В этой версии уже будут не фотографии, а вырезанные из них объекты, но тут будут проблемы с 3D.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое