API Яндекс.Панорам: как сделать свою виртуальную прогулку или просто довести человека от метро

в 13:14, , рубрики: API карт, api яндекс, javascript, Блог компании Яндекс, Геоинформационные сервисы, панорамы, Разработка веб-сайтов, Яндекс API

Нас очень давно просили сделать API, который позволяет встраивать Панорамы Яндекса на свои сайты, и мы, наконец, смогли это сделать. Даже больше: наш API даёт возможность создавать собственные панорамы.

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

API Яндекс.Панорам: как сделать свою виртуальную прогулку или просто довести человека от метро - 1

Движок

Сервис панорам запустился на Яндекс.Картах в далеком сентябре 2009 года. Поначалу это были лишь несколько панорам достопримечательностей и работали они, как вы, наверное, догадываетесь, на Flash. С тех пор много воды утекло, панорам стало несколько миллионов, начали быстро расти мобильные платформы, а Flash туда так и не пробрался. Поэтому примерно в 2013 году мы решили, что нам нужна новая технология. И основой для этой технологии стал HTML5.

API, с которого мы начинали, — это Canvas2D. Сейчас это может показаться странным, но в 2013 году этот выбор был вполне разумен. WebGL был стандартизован всего двумя годами раньше, толком еще не добрался до мобильных (в iOS, например, WebGL работал только в уже почти почившем в бозе iAd), да и на десктопах работал не очень стабильно. Читатель может мне возразить, что надо было все делать на CSS 3D, как это было тогда популярно. Но с помощью CSS 3D можно нарисовать только кубическую панораму, в то время как все панорамы Яндекса сферические (хранящиеся в равнопромежуточной проекции).

Это же было и самой главной технической трудностью в разработке. Дело в том, что корректно и точно спроецировать сферическую панораму на экран непросто из-за нелинейности этого преобразования. Наивная реализация такой проекции требовала бы целого вороха тригонометрических вычислений на каждый пиксель экрана — ведь нужно найти соответствующую ему точку в панорамном изображении и определить его цвет. Кроме того, Canvas 2D не предоставляет эффективного способа манипулировать каждым пикселем изображения по отдельности.

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

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

Алгоритм рендеринга на Canvas2D

Написав все это и запустив, я увидел нечто, хорошо описываемое словом «слайдшоу». Фреймрейт оказался совершенно неприемлемым. Попрофилировав, я нашел, что больше всего времени отъедают функции save() и restore() Canvas 2D контекста. Откуда они взялись? Из особенности работы с обрезкой в Canvas2D. К сожалению, чтобы иметь возможность сбросить текущую обрезку и выставить новую, необходимо сохранить перед выставлением состояние контекста (это как раз save()), а после всего необходимого рисования восстановить сохраненное состояние (а это уже restore()). А так как эти операции работают со всем состоянием
контекста, они недешевы. Кроме того, обрезку-то мы делаем каждый раз совершенно одинаковую (после инициализации разбиение сферы на треугольники и их наложение на панорамное изображение не меняются). Есть смысл это закешировать!

Сказано — сделано. После генерации триангулированной сферы мы «вырезаем» каждый треугольник из панорамного изображения и сохраняем его в отдельном кеше-канвасе. Остальная часть такого кэша при этом остается прозрачной. После такой оптимизации удалось получать 30—60 кадров в секунду даже на мобильных устройствах. Из этого опыта можно извлечь следующий урок: при разработке рендеринга на Canvas 2D все, что можно, кэшируйте и пререндерите. А если что-то вдруг нельзя — делайте так, чтоб было можно, и тоже пререндерите.

Нарезка кэшей треугольников

У любого кеширования (как и у многих вещей в этой жизни) есть и обратная сторона: неизбежно растет потребление памяти. Именно это и произошло с рендерингом панорамы. Возросшие аппетиты породили множество проблем. Самыми заметными можно назвать падения браузеров даже на декстопных платформах, а также довольно медленный старт. В конце концов, устав бороться с этими проблемами, мы отказались от репроецирования панорамного изображения на Canvas 2D и пошли другим путем. Но он уже совершенно не интересный :)

Однако еще до того мы начали смотреть с сторону WebGL. К этому нас подтолкнули разные причины, главной из которых, пожалуй, была iOS 8, в который WebGL заработал в Safari.

Главной проблемой при разработке WebGL-версии рендеринга был размер панорам. Панорамное изображение целиком не лезло ни в одну текстуру. Эту проблему мы снова решали, руководствуясь принципом «руководствуйся старыми как мир принципами», и разделили панорамную сферу на сектора. Каждому сектору соответствует своя текстура. При этом для экономии памяти и ресурсов GPU невидимые сектора полностью удаляются. Когда они должны на экране появиться вновь, данные для них снова перезагружаются (обычно из кеша браузера).

Встраивание панорам

Встраивание панорам с помощью API Карт начинается с подключения нужных модулей. Это можно сделать двумя способами: либо указав их в параметре load при подключении API, либо с помощью модульной системы (в скором времени мы добавим модули панорам в загружаемый по умолчанию набор модулей).

<!-- Указываем нужные модули панорам при подключении API. -->
<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU&amp;load=panorama.locate,panorama.Player"></script>
<script>
    // Подключение с использованием модульной системы.
    ymaps.modules.require([
        'panorama.createPlayer',
        'panorama.isSupported'
    ])
        .done(function () {
            // ...
        });
</script>

Перед началом работы с панорамами необходимо убедиться, что браузер пользователя поддерживается движком. Это можно сделать с помощью функции ymaps.panorama.isSupported:

if (!ymaps.panorama.isSupported()) {
    // Показываем пользователю сообщение, скрываем функциональность
    // панорам, etc.
} else {
    // Работаем с панорамами.
}

Чтобы открыть панораму, нам сначала надо получить ее описание с сервера. Это делается с помощью функции ymaps.panorama.locate:

ymaps.panorama.locate(
    [0, 0] // координаты панорамы
).done(function (panoramas) {
    // ...
});

Результатом, которым разрешится промис, возвращаемый вызовом ymaps.panorama.locate, будет массив панорам, находящихся в некоторой окрестности переданной точки. Если ни одной такой панорамы нет, массив будет пуст. Если таких панорам будет найдено несколько, они будут отсортированы по расстоянию от переданной точки. Первая при этом будет ближайшей.

Еще можно запрашивать воздушные панорамы:

ymaps.panorama.locate(
    [0, 0], // координаты панорамы
    { layer: 'yandex#airPanorama' }
).done(function (panoramas) {
    // ...
});

Получив описание панорамы, мы можем создать плеер, который отобразит ее на странице:

var player = new ymaps.panorama.Player(
        'player', // ID элемента, в котором будет открыт плеер
        panorama  // Панорама, которую мы хотим отобразить в плеере
    );

И мы увидим на странице:

Плеер панорам

Самый быстрый и простой способ открыть панораму — это функция ymaps.panorama.createPlayer:

ymaps.panorama.createPlayer(
    'player', // ID DOM-элемента, в котором будет открыт плеер
    [0, 0]    // Координаты панорамы, которую мы хотим открыть
).done(function (player) {
    // ...
});

При этом можно указать одну или несколько опций третьим параметром:

ymaps.panorama.createPlayer(
    'player',
    [0, 0],
    {
        // Слой, в котором искать панораму
        layer: 'yandex#airPanorama',
        // Направление взгляда
        direction: [120, 10],
        // Угловой размер видимой части панорамы
        span: [90, 90],
        // Набор контролов
        controls: ['zoomControl', 'closeControl']
    }
).done(function (player) {
    // ...
});

После создания плеер предоставляет компактный API для управления отображением панорам и подписки на пользовательские события. Но, как мне кажется, это не самая интересная возможность API панорам.

Свои панорамы

Пожалуй, самая интересная возможность, которую дает API, это создание собственных панорам и встраивание их на сайт.

Любая панорама начинается со съемки и подготовки панорамного изображения. Для съемки можно воспользоваться специальным устройством, обычным фотоаппаратом или даже смартфоном. Главное, чтобы результатом съемки и склейки была сферическая панорама в равнопромежуточной проекции. Например, стандартное приложение камеры на Android умеет снимать и склеивать панорамы в нужной проекции. Именно им мы и воспользовались для съемки панорам нашего уютного опенспейса.

После того как мы сняли панорамное изображение, необходимо его подготовить к отображению в плеере. Для этого нам нужно разрезать его на тайлы. Кроме того, мы можем подготовить изображения нескольких разных размеров для разных уровней мастабирования. Плеер будет выбирать самый подходящий уровень для текущего размера DOM-элемента, в котором открыт плеер, и угловых размеров видимой области панорамы. А если самый маленький уровень меньше 2048 пикселей по ширине изображения, он будет использован для создания «эффекта прогрессивного джипега». Плеер подгрузит тайлы этого уровня с самым высоким приоритетом и будет показывать их там, где нет тайлов лучшего качества (например, если они еще не загружены или были вытеснены из памяти и пока не перезагрузились).

Для нарезки изображений на тайлы можно воспользоваться любым ПО (при наличии определенной усидчивости — хоть Paint). Размеры тайлов должны быть степенями двойки (те из вас, кто работал с WebGL, думаю, догадываются, откуда растут ноги у этого ограничения). Я воспользовался ImageMagick:

# Сначала немного растянем исходное изображение так, чтоб оно разбивалось
# на целое число тайлов по горизонтали (по вертикали это, кстати, не обязательно).
$ convert src.jpg -resize 7168x3584 hq.jpg

# Разрежем изображение на тайлы размером 512 на 512 пискелей.
$ convert hq.jpg -crop 512x512 
    -set filename:tile "%[fx:page.x/512]-%[fx:page.y/512]" 
    "hq/%[filename:tile].jpg"

# Подготовим уровень масштабирования низкого качества для «эффекта
# прогрессивного джипега». Он будет состоять из одного тайла, поэтому его
# не надо будет разрезать.
$ convert hq.jpg -resize 512x256 lq/0-0.jpg

Давайте наконец напишем уже какой-то код для нашей панорамы. API — это система связанных между собой интерфейсов. Эти интерфейсы описывают объект панорамы и все связанные с ним.

Интерфейсы

Давайте теперь разберем эту картинку по сущностям.

Объект панорамы должен реализовывать интерфейс IPanorama. Чтобы написать свой класс панорамы было проще, сделан абстрактный класс ymaps.panorama.Base. Он предоставляет разумные реализации по умолчанию для некоторых методов IPanorama, а также метод validate, который проверяет, удовлетворяет ли панорама ограничениям, накладываемым плеером (например, является ли указанный размер тайлов степенью двойки). Давайте им и воспользуемся.

function Panorama () {
    Panorama.superclass.constructor.call(this);
    // ...
    // И проверим, что мы все делаем правильно.
    this.validate();
}

ymaps.util.defineClass(Panorama, ymaps.panorama.Base, {
    // Методы, возвращающие данные панорамы.
});

Начнем мы с того, что опишем плееру геометрию панорамы. Для этого нужно реализовать метод getAngularBBox, возвращающий, судя по документации, массив из четырёх чисел. Каков смысл этих чисел? Чтобы ответить на этот вопрос, вспомним про то, что панорама у нам сферическая, то есть наложенная на сферу. Чтобы описать положение панорамного изображения на сфере, необходимо выбрать некоторые «опорные» точки. Обычно для прямоугольника (а панорамное изображение, не будучи на сфере, как раз им и является, как и любое изображение в компьютере) выбирают координаты двух его противоположных углов. В нашем случае этот подход продолжает работать и на сфере, ведь вертикальные стороны изображения становятся при наложении меридианами, а горизонтальные – параллелями. Это значит, что каждая сторона прямоугольника имеет собственную угловую координату, общую для всех точек этой стороны. Именно эти координаты сторон и составляют массив, возвращаемый методом getAngularBBox, определяя своего рода сферический прямоугольник, ограничивающий панораму (отсюда и названия метода).

Плеер накладывает важное ограничение на геометрию панорамы (а значит — на само панорамное изображение): панорамное изображение должно смыкаться на сфере по горизонтали, образуя полный круг. Для значений, возвращаемых методом getAngularBBox, это значит, что разница между правой и левой угловой границей панорамы должна составлять 2π. Что касается вертикальных границ, то они могут быть любыми.

Панорама, которую мы сняли смартфоном, не только полная по горизонтали, но и по вертикали, то есть от полюса до полюса. Поэтому границами панорамы на сфере будут интервалы [π/2, -π/2] по вертикали (от верхнего полюса до нижнего) и [0, 2π] по горизонтали (тут мы для простоты полагаем, что направление на стык панорамы совпало с направлением на север, что, конечно же, в действительности не так). Получается вот такой код:

getAngularBBox: function () {
    return [
        0.5 * Math.PI,
        2 * Math.PI,
        -0.5 * Math.PI,
        0
    ];
}

Также нужно реализовать методы, возвращающие позицию панорамы и систему координат, в которой она задана. Эти данные будут использованы плеером для корректного позиционирования в сцене объектов, связанных с панорамой (о них ниже).

getPosition: function () {
    // Для простоты выберем начало координат в качестве положения панорамы, ...
    return [0, 0];
},
getCoordSystem: function () {
    // ...а системой координат выберем декартову, чтоб наша панорама не
    // плавала где-то в Гвинейском заливе.
    return ymaps.coordSystem.cartesian;
},

Теперь мы опишем сами панорамные изображения — как они разрезаны на тайлы и где эти тайлы лежат. Для этого нам надо реализовать методы getTileSize и getTileLevels. С первым все очевидно: он возвращает размер тайлов.

getTileSize: function () {
    return [512, 512];
}

getTileLevels возвращает массив объектов-описаний уровней масштабирования панорамного изображения. Их у нас, напомню, было два: высокого (относительно) и низкого качества. Каждый такой объект-описание должен реализовывать интерфейс IPanoramaTileLevel, состоящий из двух методов: getImageSize и getTileUrl. Для простоты не будем заводить отдельный класс для этого, просто вернем объекты с нужными методами.

getTileLevels: function () {
    return [
        {
            getTileUrl: function (x, y) {
                return '/hq/' + x + '-' + y + '.jpg';
            },

            getImageSize: function () {
                return [7168, 3584];
            }
        },
        {
            getTileUrl: function (x, y) {
                return '/lq/' + x + '-' + y + '.jpg';
            },

            getImageSize: function () {
                return [512, 256];
            }
        }
    ];
}

На этом минимальное описание панорамы готово, и плеер сможет ее отобразить:

var player = new ymaps.panorama.Player('player', new Panorama());

Кстати, такое минимальное описание панорамы можно сделать быстрее и проще с функцией-хелпером ymaps.panorama.Base.createPanorama:

var player = new ymaps.panorama.Player(
    'player',
    ymaps.panorama.Base.createPanorama({
        angularBBox: [
            0.5 * Math.PI,
            2 * Math.PI,
            -0.5 * Math.PI,
            0
        ],
        position: [0, 0],
        coordSystem: ymaps.coordSystem.cartesian,
        name: 'Наша супер-панорама',
        tileSize: [512, 512],
        tileLevels: [
            {
                getTileUrl: function (x, y) {
                    return '/hq/' + x + '-' + y + '.jpg';
                },

                getImageSize: function () {
                    return [7168, 3584];
                }
            },
            {
                getTileUrl: function (x, y) {
                    return '/lq/' + x + '-' + y + '.jpg';
                },

                getImageSize: function () {
                    return [512, 256];
                }
            }
        ]
    })
);

Кроме собственно самой панорамы плеер умеет отображать три вида объектов: маркеры, переходы и связи.

Маркеры позволяют обозначать объекты на панораме (например, маркеры с номерами домов на панорамах Яндекса). Объект маркера должен реализовывать интерфейс IPanoramaMarker. Этот интерфейс содержит всего три метода: getIconSet, getPosition и getPanorama. Назначение последних двух вполне понятно из их названий. Первый же я вижу необходимым пояснить. Дело в том, что маркер – это интерактивный элемент. Он меняет состояние, реагируя на пользовательские события. Эти состояния и то, как они изменяются по событиям в UI, можно писать такой диаграммой:

Состояния маркера

Например, маркер, обозначающий дом. Вот его состояние по умолчанию, состояние при наведенном курсоре и раскрытое состояние:

Состояния маркера

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

getMarkers: function () {
    // Как и с уровнями масштабирования, не будем создавать отдельный
    // класс, вернем объект.
    var panorama = this;
    return [{
        properties: new ymaps.data.Manager(),

        getPosition: function () {
            return [10, 10];
        },

        getPanorama: function () {
            return panorama;
        },

        getIconSet: function () {
            return ymaps.vow.resolve({
                'default': {
                    image: defaultMarkerIcon,
                    offset: [0, 0]
                },
                hovered: {
                    image: hoveredMarkerIcon,
                    offset: [0, 0]
                }
                // Кстати, не обязательно указывать все состояния,
                // обязательно только `default`.
            });
        }
    }];
}

Переходы — это те самые стрелки, по клику на которые плеер переходит на соседнюю панораму. Объекты, описывающие переходы, должны реализовывать интерфейс IPanoramaThorougfare:

getThoroughfares: function () {
    // И опять мы поленимся писать классы :)
    var panorama = this;
    return [{
        properties: new ymaps.data.Manager(),

        getDirection: function () {
            // Переходы задаются не координатами, а направлением на
            // панораму, с которой переход связывает текущую.
            return [Math.PI, 0];
        },

        getPanorama: function () {
            return panorama;
        },

        getConnectedPanorama: function () {
            // Именно этот метод будет вызван при переходе.
            // Кстати, свою панораму вполне можно связать с панорамой
            // Яндекса, получив ее с помощью метода ymaps.panorama.locate.
            return ymaps.panorama.locate(/* ... */)
                .then(function (panoramas) {
                    if (panoramas.lengths == 0) {
                        return ymaps.vow.reject();
                    }
                    return panoramas[0];
                });
        }
    }];
}

Связи являются своего рода гибридом маркеров и переходов: выглядят они как первые, а ведут себя как последние. В коде они реализуются точно так же, как и маркеры, но с добавлением метода getConnectedPanorama (см. IPanoramaConnection).

Вместо заключения

API панорам пока что запущен в статусе бета. Встраивайте, тестируйте на своих сайтах и приложениях, рассказывайте нам об этом в клубике, группе во ВКонтакте, Фейсбуке или через поддержку. Вот ЦИАН уже :)

Автор: Яндекс

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js