Интерактивные 3D-карты своими руками
Если 3D-графика не ваша основная специализация, все равно, возможно, вам придется или, как в случае автора этого доклада Александра Амосова (@s9k), захочется с ней работать. Порог входа не так высок, можно взять технологию WebGL доступную в браузере, популярный фрэймворк Three.js и небольшой компанией единомышленников сделать своими руками интересный проект. Именно на примере такого проекта, начатого в рамках хакатона Avito, и разберем основные этапы, обратим внимание на проблемные места, и, наконец, замотивируемся на создание чего-то такого же прикольного, как эта карта офиса.
Проект, кстати, интересен и сам по себе. В современных больших опенспейсах ориентироваться в пространстве, находить нужных людей или переговорки зачастую очень сложно. Конечно, можно сделать схему в Excel, но не всегда это супер хорошее решение. У Александра Амосова появилась идея сделать это более удобно, которой он поделился на Frontend Conf, а ниже расшифровка его доклада.
История
Пару слов о проекте. Наша компания активно развивается, появляются новые сотрудники, и встает банальная проблема навигации в офисе.
До недавнего времени были схематичные карты, например, как эта карта одного из этажей, где показано, что где находится. Но она фиксированная, а с течением времени что-то меняется, люди пересаживаются в разные отделы, и карта становится неактуальной.
Еще большей проблемой было найти нужного сотрудника. Сначала была мощная таблица в Confluence, где ячейки — это места сотрудников, в которые заносятся их имена. Если с воображением не очень хорошо, это не очень удобно. На представленном изображении таблица (слева) и карта (справа) соответствуют одному и тому же месту в офисе. Конечно, схему можно сделать с помощью таблицы, это будет очень дешевое и быстрое решение, но крайне ненаглядное.
Появилась идея сделать это более удобно, чтобы новые сотрудники без затруднений находили то, что им нужно.
Это трехмерная карта, на которой есть все этажи, можно задать поиск человека и узнать, где он находится, получить его контакты и банально найти нужную переговорку.
2D vs 3D
Когда проект только зарождался, естественно не обошлось без споров. Мне говорили: «Зачем 3D? Это все непонятно. Все привыкли к двумерным картам! Их можно сделать быстро и легко. Давай двумерные делать!»
Почему была выбрана трехмерная реализация?
Изначальная двумерная svg-схема выглядела бы на странице не очень красиво. Даже при отсутствии больших ресурсов благодаря 3D, теням и перспективе, карта смотрится более нарядно.
• Это наглядно
Такие карты, мне кажется, более наглядны, потому что мы существуем все же в трехмерном пространстве. Можно повернуть так, как тебе нравится, и сориентироваться — это очень удобно.
• Потому что я могу
Это самая главная причина — я это могу, мне это интересно, в отличие от двумерной реализации, которую я и не стал бы делать.
С чего все начинается?
Создание модели
Тут есть варианты:
• Найти моделлера (опционально)
Это, наверное, самый идеальный вариант — найти человека, который разбирается в 3D MAX и в подобных программах, чтобы он все сделал, а вы бы просто взяли эту модель.
• Найти готовые модели (опционально)
Если такого человека нет, можно найти в интернете готовые модели.
• Освоить 3D-редактор самому
Этот вариант на самом деле обязателен (нет слова «опционально» в скобках) — потому что вам в любом случае это нужно. Просто вопрос уровня погружения. Даже если у вас есть моделлер, то, скорее всего, вам все же придется, что-то подредактировать самому или хотя бы открыть посмотреть в редакторе, чтобы проверить, что модель сделана как надо. Поэтому этот пункт необходим.
Я использовал трехмерный редактор Blender.
Наверное, все слышали про 3D MAX — программу для трехмерного редактирования. Это ее аналог, только из мира OpenSource. Этот редактор бесплатен, есть под все основные платформы и он постоянно развивается. В нем есть классная фишка — все, что можно сделать с помощью Blender, можно сделать с помощью обычного python-скрипта.
Можно навести курсор на какой-то элемент интерфейса, и во всплывающем окне покажется, какой метод надо выполнить, чтобы получить данные, или чтобы сработало определенное действие.
Еще одна классная фишка в том, что он по умолчанию поддерживает импорт из SVG.
У меня как раз исходный материал был SVG, поэтому мне это очень подошло. Можно просто импортировать схему, преобразовать в треугольники и задать высоту стены. Единственное, что не было стульев, на которых находятся сотрудники, их пришлось расставлять в Blender вручную.
В принципе, эти места все одинаковые, поэтому их нужно было клонировать с небольшим сдвигом. Удобнее всего расставлять места, когда ты смотришь сверху, но оказалось, что, когда смотришь сверху вроде бы все в порядке, а на самом деле они могут находиться на разных уровнях.
Плюс, когда клонируешь места, можно нечаянно склонировать так называемую геометрию. Геометрия — это точки, из которых состоит каркас. Дублирование — это не очень хорошо, так как модель начинает занимать больше места на диске.
Тут на помощь приходит одна из фишек, а именно то, что можно использовать python-скрипт.
import bpy
geometry = bpy.data.meshes['place']
for obj in bpy.context.selected_objects:
obj.rotation_euler.x = 0
obj.rotation_euler.y = 0
obj.data = geometry
obj.location.z = 0
Мы просто берем геометрию места, пробегаемся по выделенным объектам, сбрасываем поворот, указываем, чтобы они все ссылались на одну и ту же геометрию и высоту (ось Z направлена вверх) сбрасываем до 0.
Тени
Еще в Blender были запечены тени. Естественно, с затенениями объекты выглядят более объемно. При использовании техники затенения в реальном времени Ambient Occlusion (взаимное затенение объектов), отрисовка будет подтормаживать, поэтому лучше всего запечь тени заранее.
На рисунке показаны заранее запеченные тени: справа — текстура, слева — текстурные координаты, по которым эта текстура накладывается на объект.
Еще один пример, как выглядят текстурные координаты.
После нехитрых действий нужно нажать кнопку «Bake», выбрать взаимное затенение и запечь. Это не реальные тени, потому что я не очень хорошо разбираюсь в трехмерных редакторах, и мне сложно было расставить красиво источники света, чтобы все было фактурно и классно. Поэтому взаимное затенение вполне подходящий вариант.
Можно сравнить, как выглядит плоская реализация и картина с тенями.
С моделями разобрались, считаем, что модель есть, и теперь нужно это встроить в браузер, чтобы все могли пользоваться.
WebGL
На помощь приходит спецификация WebGL.
Немножко истории о том, что это такое. WebGL основана на спецификации OpenGL for Embedded Systems версии 2.0, и по сути повторяет ее с небольшими изменениями. OpenGL for Embedded Systems в свою очередь является подмножеством спецификаций OpenGL, только рассчитана на мобильные устройства. То есть там вырезано лишнее.
WebGL версии 2.0 тоже поддерживается браузерами, но пока еще не так распространен. Также сейчас обсуждается WebGL Next, но непонятно, на что он будет ориентироваться, и будет ли вообще. Может, на Vulkan — пока непонятно.
WebGL 1.0 поддерживается везде: на iOS, Android и т.д. С этим проблем нет.
Для сравнения, WebGL 2.0 сейчас поддерживается не очень хорошо, поэтому пока подождем.
2D vs 3D [2]
Для того, чтобы вникнуть в WebGL, сравним двух- и трехмерные реализации. Сделаем простой тест: как будет выглядеть двух- и трехмерные реализации обычного красного квадратика.
Двумерная реализация на Canvas довольно простая:
Есть элемент canvas, мы берем его двумерный контекст, определяем стиль заполнения, и рисуем квадрат.
Трехмерная реализация вроде бы поначалу похожа: также есть элемент canvas, берется контекст с помощью canvas.getContext («webgl»). Но вот далее идут отличия. Сейчас попробую объяснить. Можно сильно не углубляться в реализацию и не пугаться непонятных терминов, далее я объясню как можно это все упростить.
Что нужно дальше сделать:
1. Вершинный шейдер — скомпилировать и передать.
const vertexShaderSource = `
attribute vec4 position;
void main() {
gl_Position = position;
}
`;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
2. Фрагментный шейдер, который просто на выходе дает красный цвет — опять скомпилировать и передать
const fragmentShaderSource = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, vertexShaderSource);
gl.compileShader(fragmentShader);
3. Программа — мы связываем оба шейдера с программой и используем ее.
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
4. Вершинный буфер, в котором задаются координаты вершин квадрата, после создания нужно забиндить, передать координаты вершин, связать атрибут позиции из вершинного шейдера с вершинным буфером, и использовать его.
const vertices = new Float32Array([
-0.5, 0.5, -0.5, -0.5,
0.5, 0.5, 0.5, -0.5
]);
const vertexSize = 2;
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const attrPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(attrPosition, vertexSize, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(attrPosition);
5. Потом нам нужно все это отрисовать.
const vertices = new Float32Array([
-0.5, 0.5, -0.5, -0.5,
0.5, 0.5, 0.5, -0.5
]);
const vertexSize = 2;
// …
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length / vertexSize);
Так «легко» получился красный квадратик!
Вы, наверное, поняли, что WebGL не так прост. Он довольно низкоуровневый, есть куча методов, причем в них миллион аргументов. И это еще упрощенный код — там нет никаких проверок на ошибки, которые необходимы в реальном коде.
Если хочется вникнуть в нативный WebGL, есть отличная книга на русском языке Коичи Мацуда, Роджер Ли «WebGL: программирование трехмерной графики».
Рекомендую почитать, там все доступно рассказывается с самых азов. Начиная, например, с отрисовки простейшего треугольника, и заканчивая созданием полноценных трехмерных объектов с тенями.
Если нет времени в этом разбираться, а его скорее всего его нет — даже проект, который я делал, был реализован в рамках хакатона за пару дней (первый прототип с 2 этажами). Поэтому на помощь приходят любимые (или не любимые) фреймворки.
Я использовал Three.js, как самый популярный и используемый WebGL фрэймворк. Самое главное преимущество этого фреймворкоа в том, что они создают удобные абстракции, с помощью которых можно все сделать быстрее и с меньшим количеством кода, не вникая в низкоуровневый API. Можно за весь проект вообще не написать ни одного шейдера.
Этот пример взят из Readme Three.js:
var scene, camera, renderer, geometry, material, mesh;
init();
animate();
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.z = 1000;
geometry = new THREE.BoxGeometry( 200, 200, 200 );
material = new THREE.MeshBasicMaterial( { color: 0xff0000, wireframe: true } );
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
}
function animate() {
requestAnimationFrame( animate );
mesh.rotation.y += 0.02;
renderer.render( scene, camera );
}
Скопировав этот код в свой браузер, по сути, мы уже имеем полноценную трехмерную визуализацию. Причем это не просто какой-то обычный квадрат, а именно трехмерный куб, который еще и вращается.
Рассмотрим этот пример
- У нас есть некая сцена (
scene
) — это виртуальное пространство. Этих сцен может быть несколько, но обычно она одна. Если сцен несколько, то они существуют как параллельные миры. - Есть камера (
camera
) — та точка, откуда мы будем снимать и получать картинку на экран. - Далее идет участок кода, где мы создаем объект. Тут создается геометрия куба (
geometry
) и его материал (material
), в данном случае просто плоского красного цвета. Когда мы объединяем каркас куба и накладываем на него материал, то получаем красный куб (mesh
), который далее помещаем на сцену (наше виртуальное пространство). - Следующий этап — создается объект renderer, в нем создается объект canvas и происходит взаимодействие с WebGL. В данном случае мы задаем размер, чтобы отрисовка происходила во весь экран, и добавляем canvas в body на страницу.
- После чего нам просто нужно вызывать функцию
animate()
, которая поrequestAnimationFrame
вызывает сама себя — плюс кубик вращается.
Таким образом у нас есть полноценная визуализация.
Если взять участок кода, где мы создаем куб, его легко заменить на код, где есть loader.
var loader = new THREE.ObjectLoader();
loader.load('model.json', function(mesh) {
scene.add(mesh);
});
Мы вызываем метод load для того, чтобы загрузить модель, и уже на стенд добавляем не абстрактный куб, а именно нашу модель, и видим ее в браузере. Это вообще уже круто!
Чтобы получить модель, нужно чтобы модель была определенного формата. В Three.js есть уже готовый exporter, который нужно подключить (по умолчанию в Blender его нет) и с его помощью экспортировать модель в формат, в котором можно уже ее импортировать на сцену.
Но картинка получилась статичная, а нам хочется покрутить модель, посмотреть ее с разных сторон.
В Three.js для этого уже есть OrbitControls. Он лежит не в основной папке Three.js, а в examples (). Там лежит очень много всего, что не входит в core-код, но можно дополнительно подключить.
Мы подключаем OrbitControls, и создаем их инстанс передавая объект камеры:
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.z = 1000;
new THREE.OrbitControls(camera);
Теперь наша камера управляется OrbitControls. После этого можно вращать нашу модель мышкой. Мы зажимаем левую кнопку мышки и начинаем ее двигать, также можно приближать и отдалять объект.
Это пример из Three.js. Там как раз есть загрузка модели и controls, в принципе, это уже полноценная визуализация. Если мы продаем, например, часы, можно сделать модель или найти ее в интернете, вставить в браузер и пользователь сможет ее приблизить и рассмотреть с разных сторон.
Чтобы подробнее вникнуть в мир трехмерной графики на основе использования фрэймворка Three.js, есть отличный курс на Udacity. Он очень веселый, правда, на английском языке.
Как работает приложение
Когда мы освоили азы, нам хочется большего — реализовать работу полноценного приложения — не просто крутить-вертеть, а подключить дополнительные функции.
Я разделил приложение на 2 части.
Viewer
Это часть, которая отвечает за работу с Canvas и WebGL. В нем содержится:
- Модель с запеченными тенями;
- Происходит рейкастинг объектов — механизм определения, в какой элемент трехмерного пространства мы попали кликом по canvas. Это нужно, допустим, для выделения мест на плане или получения информации по иконке.
- Спрайты для иконок — это своеобразные двухмерные картинки в трехмерном пространстве. Если вы играли в первые трехмерные шутеры, то помните, что, например, в Doom 1 и 2 все противники были сделаны спрайтами, то есть двумерными и всегда смотрели на вас.
UI
Интерфейс достаточно стандартный и накладывается поверх canvas, в нем есть поиск, авторизация и перемещение по этажам. Таким образом, этажи можно выделять не только непосредственно кликая на них в трехмерном пространстве, но и с помощью классического двухмерного интерфейса.
Я выбрал два независимых блока, потому что мне это показалось удобно — если вдруг у кого-то не поддерживается WebGL, можно быстро подменить Viewer на альтернативный, например, вообще заменить двумерной реализацией со статичной картинкой. UI может остаться тот же — допустим, подсвечивать места на этой двумерной картинке.
Все управляется через Redux store. Если происходит какое-либо действие, создается action. В данном случае мы можем выделить этаж, как на трехмерной схеме, кликнув на этаж, так и в двумерном интерфейсе. Соответственно произойдет action, обновится состояние приложения, и потом распространится на обе части: на Viewer и на UI.
Казалось бы, все отлично: есть модель, есть интерактивность, есть интерфейс. Но, скорее всего, в этот момент при наличии достаточно большого приложения, вы столкнетесь с проблемой, что пользователи скажут: «Все тормозит!».
Все тормозит
Расскажу, с какими проблемами столкнулся я, и что мне помогло их решить.
Первая проблема была в тормозах в Safari на macOS. Причем, если пробовать профилировать, то вроде бы requestAnimatioFrame
выдает честные 60 FPS и вроде бы, все должно быть хорошо, но по видео видно, что все происходит рывками. Причем это только в Safari — в Chrome все отлично, и в FireFox нормально.
Я долго разбирался, в чем может быть дело. Отключал все, что только можно — все равно тормозит. Потом я взял пример из Three.js с кубиком, который показывал выше, запустил его, и он тоже тормозит!
Оказалось, что дело вот в чем. В WebGL поддерживается аппаратное сглаживание, не требующее никаких усилий, чтобы его включить. Можно просто указать antialias: true — будь то чистый WebGL или Three.js, и картинка становится лучше. Причем из-за того, что это сглаживание аппаратное, оно работает достаточно быстро, никаких проблем я никогда с ним не замечал до этого момента.
Но именно в Safari на Retina экранах mac-буков с огромным разрешением и, как всегда, слабой видеокартой, обнаружилась эта проблема. Помогло отключение аппаратного сглаживания для таких устройств в этом браузере.
Другая проблема, на которую еще жаловались была в том, что при запуске приложения, кулер компьютера начинает неистово крутиться и шуметь.
Тут все оказалось просто. В примере, который я показывал, есть функция animate()
, которая по requestAnimationFrame
вызывает сама себя. Дело все в том, что нам это в принципе не нужно. Допустим, мы повернули модель — картинка статична, ничего не происходит, но при этом видеокарта мучается, 60 раз в секунду срабатывает requestAnimationFrame
(в идеальном случае). Ничего не происходит, но мы зазря мучаем видеокарту заставляя перерисовывать сцену снова и снова.
Выход очевиден: не рендерить, когда не нужно. Начинаем вращать камеру, все движется, мы запускаем рендеринг. Как только мы отпустили кнопку мыши, перестали вращать камеру, можно остановить рендер.
Если состояние приложения изменилось, допустим, мы через двумерный интерфейс выбрали какое-то место и его нужно подсветить, мы можем вообще просто один кадр отрендерить и все — оно уже подсвечено.
Я уже упоминал про Raycasting. Когда мы кликаем на canvas, в трехмерном пространстве создается вектор и просчитывается, какие треугольники, из которых состоят объекты, этот вектор пересекает. Соответственно, какой первый объект мы пересекли, тот и выделяем. В Three.js это выглядит так:
- есть raycaster,
- передаем координаты и камеру, и объекты, по которым нужно проверить пересечение,
- в итоге получаем пересечения.
Но если у нас довольно большие модели, состоящие из большого количества треугольников, то это может занять много времени. По сути, нужно перебрать все треугольники, из которых состоят все объекты. Поэтому используется другой способ. Заранее в Blender можно создать некий объект, который аппроксимирует объект, но состоит из очень маленького количества треугольников (показано красным на рисунке выше).
Мы его можем сделать невидимым или вообще расположить на отдельной сцене, но raycasting происходит именно с ним и намного быстрее.
Причем если для этажей пришлось их создавать вручную — потому, что тут сложная геометрия, то для мест можно сгенерировать автоматически в JS коде. Берем место, просчитываем у него максимальную позицию по осям x и y (по z даже не надо), и готово. У нас есть квадратик, состоящий из 2 треугольников — и все.
Использование техники Instancing дало самый большой прирост производительности (результат — слева). До использования Instancing было 365 draw calls (отрисовок на видео карте), а после — 51. Хотя это тоже довольно много, наверное, за счет иконок.
В чем смысл? Помните, я рассказывал, что есть места, которые ссылаются на одну и ту же геометрию — просто разные instance находятся в разных местах с разным цветом. Когда есть такие однотипные объекты, мы можем применить эту технику и с помощью нее снизить количество отрисовок на видеокарте. Эти все места объединяются в один объект, который отрендеривается за раз — за один проход.
Инстансинг входит в спецификацию WebGL 2, в первом же WebGL он доступен через extension, который поддерживается и есть во всех современных браузерах.
Попробую объяснить, как это работает. Допустим, по умолчанию есть 4 места в 4 разных позициях 4 разных цветов. Обычно мы передаем эти данные через uniform — это «переменный», которые распространяются на shader программу целиком. Если эти места объединены в один Batch, то места будут одного цвета и находиться в одном месте скученно.
Поэтому передавать именно эти данные: цвет, позицию мест. нужно через attribute. Это параметр, который передается для каждой вершины отдельно.
Что стоит знать про Instancing:
- Он сильно повышает производительность. Если у вас есть много однотипных предметов и все тормозит, скорее всего, он даст большой прирост.
- Но это сложно. Примеры из Three.js можно посмотреть здесь.
Следующая проблема, с которой вы столкнетесь — появится вот такая картинка (в Chrome). Это обычно происходит, когда не хватает видеопамяти.
Что может помочь?
Упрощение модели
При преобразовании svg картинки в трехмерный объект, Blender создает много лишних точек. Объекты получаются, конечно, хорошо сглажены, но, скорее всего, это особо не видно. А каждая лишняя точка — это расход видеопамяти и снижение производительности.
Оптимизация текстур
Обычно в трехмерных играх используются максимально низкополигональные модели с небольшим количеством треугольников. Чтобы нейтрализовать это, красивой картинки добиваются путем использования большого количества текстур. К сожалению, это становится проблемой в браузере из-за того, что под вкладку может выделяться не так много памяти и при ее нехватке вкладка крэшится. Поэтому существуют техники для сокращения количества текстур и их оптимизации.
Одна их техник предполагает объединение нескольких текстур в одну. Допустим, если есть 3 черно-белые текстуры, их можно разбить по каналам и объединить в одну: первую текстуру передать в красный канал, вторую в зеленый и третью — в синий. Получится, что в итоге все это будет занимать в 3 раза меньше видеопамяти.
Сжатые текстуры
Есть разные форматы: DXT, PVRTC, ETC, которые позволяют сделать так, чтобы текстура размером 1024×1024 на видеокарте занимала видеопамяти как текстура 512×512, то есть в 4 раза меньше.
Казалось бы, крутой профит, но есть большие ограничения:
- Разные форматы (DXT — поддерживается только на десктопе, PVRTC — на iOS, ETC — на Android). В общем случае, скорее всего, вам придется держать 4 разные текстуры (под каждый формат, и четвертую несжатую) и проверять, какой формат поддерживает ваша система. Если ни один форматов не поддерживается, то уже использовать оригинальную несжатую текстуру.
- Ухудшение картинки. Степень искажения зависит от исходной картинки.
- Проблемы с прозрачностью, когда на границе прозрачной области появляются артефакты.
- Больший объем файлов (но можно применить gzip). Полностью черная PNG картинка размером 1024×1024 скачается мгновенно, но на видеопамяти каждая точка будет отжирать память. С сжатыми картинками наблюдается обратная ситуация — сам файл текстур может весить больше, но на видеопамяти он будет меньше занимать места.
Что касается качества сжатых текстур — котик не сильно пострадал от сжатия.
На текстурах такого вида уже видны артефакты — тень сливается с самой надписью. Но для пестрых текстур (например, травы) это отлично подходит.
Самый главный полезный и простой совет — не гонитесь за реализмом! Обратите внимание, в инди-играх почти никогда нет супер-реалистичной графики. Она схематичная — красочные, веселые, но малополигональные человечки бегают, зато все весело и прикольно, работает быстро и требует меньше ресурсов для разработки.
Последнее, что хотел сказать. Если будете делать 3D, то не делайте проект в одиночку. Находите соратников. Я делал проект в рамках хакатона, мне помогало много хороших людей. Одному сложно, а когда вы вместе, это и мотивирует, и быстрее все получается.
Полезные ссылки:
✓ Курс по трехмерной графике на Udacity
✓ Книга «Программирование трехмерной графики»
✓ Примеры threejs
✓ Видео про рендеринг текста в WebGL (слайды)
✓ Видео про производительность WebGL (статья)
Контакты:
https://twitter.com/gc_s9k
s9k0@ya.ru
Время летит незаметно, и до фестиваля конференций РИТ++ осталось совсем немного, напомним он пройдет 28 и 29 мая в Сколково. Скоро мы опубликуем программу, а пока приводим небольшую подборку заявок FrontEnd Conf:
Забронировать билеты еще можно, но, не забывайте, цена неуклонно растет.