Всем привет! Я — тимлид команды по разработке десктопных приложений в компании Роджии Европа. Мы разрабатываем программные решения для нефтегазовой отрасли.
Так получилось, что в нашем флагманском продукте StarSteer нет панели корреляции — классического инструмента проводчиков скважин. Задача долго откладывалась из-за других, более приоритетных, но осенью прошлого года мы наконец смогли к ней приступить.
Обходя вопросы наследия кодовой базы — о нём я упомяну в статье — был один фундаментальный вопрос — с помощью какой технологии делать? Однозначно нам был нужен OpenGL — который уже применяется в MapView и 3d view на базе OpenSceneGraph — но очевидно, что не голый, и с элементами графического интерфейса. OSG отвалился =(. Технологию, удовлетворяющую двум требованиям — граф сцены и GUI на OpenGL — я знал только одну — Qt QML/Quick. О том, что же у нас получилось и чем нам есть поделиться — внутри.
Вступление
Мы начала разрабатывать продукт осенью 2013 года. Каким набором библиотек пользоваться для меня, как фаната Qt, даже и вопроса не стояло. На тот момент мог возникнуть вопрос: использовать 4ю или 5ю (ещё довольно свежую) версию Qt. Мы выбрали пятую и с высоты своего полёта могу только сказать: слава богу!
Весь внешний вид разработан на QtGui/Widgets. Все сцены, где отображаются графики (гамма, пористость, сопротивление, прочее), сделаны на QGraphicsScene/View. Мой совет — не используйте эту связку для серьёзных вещей! Аргументы: скроллБары и не отключаемая снаружи (именно снаружи — без правок самой Qt) логика по центрированию сцены (qgraphicsview.cpp +458 и выше в этом же методе для горизонтали; qgraphicsview.cpp +3816 — какой контроль над матрицей). Если вам это не мешает, то используйте — много удобных штук из коробки.
Что ещё не использовать? NSIS.
Всё было отлично, продукт развивался, задачи делались, количество клиентов росло. Рефакторинг… В общем по прошествии некоторого времени нам стал мешать QGraphicsScene — оптимизировать отрисовку графиков в 40 000 точек мы не спешили, при включении "толстых" линий — всё это добро на ЦПУ тормозило очень сильно.
Попутно с этим устали разрабатывать ГУЙ на виджетах. Либо руками полностью в коде, либо чуть-чуть в дизайнере (из Креатора, .ui + .cpp). Захотелось самомоднейших штучек, вроде декларативного описания графического интерфейса.
Составили список технологий, на которых будем делать:
- по старинке на QGraphicsView/Scene;
- для каждого трека использовать отдельный голый QOpenGLWidget;
- всё окно реализовать на QOpenGLWidget, но GUI самим (или что-то подыскать);
- предыдущие два пункта + OSG соответственно;
- Qt QML/Quick,
всего шесть пунктов; обсуждали. Моя харизма перевесила и решили попробовать посмотреть, как поведёт себя прототип на QML.
Прототип
Я открыл пример scenegraph\graph. Посмотрел и закрыл =). Втуплял несколько дней, смотрел другие примеры, но ничего меня не приблежало к заветной цели.
А что было нужно то? Вот как может выглядеть панель корреляции:
- первый пример: Well correlation1.png (со страницы https://geosteering.ru/software/geosteering-office.html);
- второй: cp_1.png (со страницы https://geosteertech.com/products/geonaft/correlation-panel/);
- конечно же она имеется у гиганта Petrel;
- также в гугле по запросу (well) correlation panel.
Довольно простая структура — список треков скважин, внутри него треки для кривых, шкал; ну и по мелочи. Много текста, в будущем какие-нибудь контролы — кнопки, выпадающие списки, поля ввода и прочее.
Посмотрел sgengine, научился создавать два графа сцены и рисовать их в отведённом вьюпорте. Позже осознал, что при таком варианте QML/Quick не будет, тогда зачем мне всё это?
На самом деле, я уже не помню, какие наркотики, но почему-то я решил обратиться к основам компьютерной графики. Так вот на последних этапах растеризации все координаты сцены переводятся в НКУ (Нормализованные Координаты Устройства; более известные как NDC = Normalized Device Coordinates). Да, я слышал о том, что выход вершинного шейдера — это на самом деле clip space (пространство отсечения) и после происходит ещё аффинное искажение, но всё это для трёхмерного представления, а в 2Д всегда w = 1 и поэтому можно считать, что выход сразу в NDC.
Хорошо, NDC, дальше то что? То, что если ширина вашего окна 800 пикселей, то NDC-координата центра нулевого пикселя есть -1; координата центра 799-го равна 1. Короче, ndcX = -1 + 2 * i / 799. Теперь представим, что есть прямоугольник от 100 до 300 и я хочу отрисовать всю сцену не в целое окно, а в него. Используя вот эти обрывочные знания, я посчитаю ndcX100, ndcX300, потом прокину их в вершинный шейдер и там, после стандартных
gl_Position = matrix * position;
линейно "заверну" gl_Position.x в [ndcX100; ndcX300]. Аналогично поступаем для вертикальной составляющей. Такой трюк позволит порождать сцены в любом выбранном прямоугольнике сцены. Вот с этими знаниями пример graph и стал подвергаться изменениям. Посмотреть на приход можно здесь — graph; вся соль в shaders/line.vsh
.
SceneItem/Scene
Следующие три месяца было написание ТЗ, получилось 12 листов А4 =). Мы параллельно этому обдумывали архитектуру. Взяли MVC… он же MVP… он же Hierarchical MVC/MVP… или даже PAC — всё это условности, важна хорошая декомпозиция.
В общем, мы подготовили пример сцены. Исходники доступны тут — SceneSample. Получился некий framework для создания приложений с графиками на QtQML/Quick. Прошу не забывать, что этот код всё таки выступает в качестве примера. Да, уже в полуготовности и выглядит более-менее аккуратно, но не готов.
Scene — главный игрок. Этот класс следит за своими NDC-координатами и обновляет соответствующие матрицы. С ним плотно дружит SceneCamera. Следующая сущность, достойная упоминания — SceneItem. Сам по себе бесполезен, только содержит некую базовую логику; наследуйте ему — подобно LineStrip — и реализуйте необходимое. При этом в updatePaintNode
нужно использовать производные от SceneMaterial — FlatColorMaterial в качестве эталона. Остальные сущности тоже чем-то занимаются =), всякие манипуляторы, тулы. Многие из классов не прокинуты в QML и без C++ с такой "либой" не обойтись; вы же помните, что не готово?
Вторая сложность заключается в том, что если мы захотим использовать контролы внутри новой сцены, то не сможем этого сделать. Мы подумали, решили, что нам это не нужно и со спокойствием продолжили разработку.
Плюсы подхода:
- всё рисуется в одном графе сцены;
- мы не правили Qt — остаётся возможность добавлять на Сцену обычные QML-контролы так, что z-order между ними и кривыми (или другими SceneItem) корректный;
- меньшее расходование памяти в сравнении с другими подходами.
Минусы
- сложная убермашина;
- знания OpenGL и GLSL обязательны;
- полуготовое решение.
Конечно же при разработке мы столкнулись с некоторыми трудностями. Одной из них был
Баг с z-order
Когда мы первый раз попробовали отображать сцену с кривыми, то увидели такие картины:
С первого взгляда было не понятно, "мы же всё сделали правильно!" Смутно догадывались, что gl_Position.z почему-то ошибочный, но почему именно ночью понимать было тяжело. Мы не сдавались: подглядели, что Qt правит шейдеры и дописывает код по изменению gl_Position.z, подумали. Через некоторое время осенило: мы испортили данные матрицы по изменению z, а Qt в них передаёт свои значения! Таким образом происходит отображение значения Item.z из QML в z из OpenGL (SceneMaterial.cpp +20):
Баг с clip: true
Однажды в чат бизнес-команда присылает скрин, где пропала левая линия координатной сетки.
Наши Q&A помучали программу и нашли шаги стабильного воспроизведения: ставим масштабирование для монитора не кратное 100% и линии "мелькают". Артём посидел, подумал и нашёл, что когда clip: true
и айтем прямоугольный то используется glScissor, но его аргументы — целочисленные пиксельные координаты! У QML-х айтемов они вещественные и получалось, что растеризация линии попадала на следующий/предыдущий пиксель, а ножницы резали по текущему.
Починили сцену так: width: Math.round(metrics.width + leftPadding + 2 * rightPadding + 0.5)
. Соответственно айтем сцены должен всегда иметь целочисленные координаты, чтобы избежать подобных артефактов.
В заключении приведу КДПВ
Всем спасибо за внимание!