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

Нас дуже давно просили зробити API, який дозволяє вбудовувати Панорами Яндекса на свої сайти, і ми, нарешті, змогли це зробити. Навіть більше: наш API дає можливість створювати власні панорами.
У цьому пості я розповім, що взагалі треба знати, щоб робити такі віртуальні прогулянки. Чому зробити API для них було не так-то просто, як ми дозволяли різні встають на дорозі проблеми і докладно поясню, що ви зможете зробити за допомогою нашого API (більше, ніж може на перший погляд здатися).

Движок
Сервіс панорам запустився на Яндекс.Картах в далекому вересні 2009 року. Спочатку це були лише кілька панорам пам'яток і працювали вони, як ви, напевно, здогадуєтеся, Flash. З тих пір багато води витекло, панорам стало кілька мільйонів, почали швидко зростати мобільні платформи, а Flash туди так і не пробрався. Тому приблизно в 2013 році ми вирішили, що нам потрібна нова технологія. І основою для цієї технології став HTML5.
API, з якого ми починали, — це Canvas2D. Зараз це може здатися дивним, але в 2013 році цей вибір був цілком розумний. WebGL був стандартизований всього двома роками раніше, толком ще не добрався до мобільних (в iOS, наприклад, WebGL працював тільки у вже майже почівшем в бозі iAd), так і на десктопах працював не дуже стабільно. Читач може мені заперечити, що треба було все робити на 3D CSS, як це було тоді популярно. Але з допомогою 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 панорам поки що запущений в статусі бета. Вбудовуйте, тестуйте на своїх сайтах і в додатках, розповідайте нам про це клубике, групі у Вконтакті, Facebook або підтримку. Ось ЦІАН вже :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.