Всем привет! Я — тимлид команды по разработке десктопных приложений в компании Роджии Европа. Мы разрабатываем программные решения для нефтегазовой отрасли.
Так получилось, что в нашем флагманском продукте 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)
. Соответственно айтем сцены должен всегда иметь целочисленные координаты, чтобы избежать подобных артефактов.
В заключении приведу КДПВ
Всем спасибо за внимание!
Комментарии (10)
werewol
24.12.2019 13:16+1QtChart лучше не использовать. Возможно вам захочется между скважинами что то рисовать ( связи, кривые, пропластки), которые надо будет по разному соединять. И тогда будет переписывание практически с нуля.
З.Ы.: основано на личном опыте =)
KanuTaH
А QtCharts не пробовали воспользоваться для отрисовки графиков?
gshep Автор
в списке возможных альтернатив у нас он мелькал, но если мне не изменяет память, он тоже рисует (или рисовал тогда) на CPU.
KanuTaH
Это зависит:
doc.qt.io/qt-5/qabstractseries.html#useOpenGL-prop
gshep Автор
сейчас прочитал и вспомнил — из-за перечисленных там ограничений и не взяли.
в статье я среди плюсов упоминаю, что кривые можно мешать с контролами. Допустим нужен TextInput в слое определённого графика, но как ребёнок Scene — с этим велосипедом это возможно. Если соберёте самый первый черновой сэмпл graph — там на сцене как раз такая ситуация.
Добавлю также, что аналогичная ситуация справедлива и при отрисовке через FBO — мы могли каждый трек рисовать в FBO, потом сдёргивать текстуру и рисовать её в нужном прямоугольнике. В этом случае также встраивать контролы нельзя — только поверх (думаю под они не особо пригодятся).
Сразу ограничивать себя мы не стали.
Deranged
QtCharts не поддерживает виртуализацию данных. В сериях просто хранятся вектора семплов. И от этого на практике толку от него мало. Потому что обычно в источнике мноооооого семплов и что бы график работал быстро, надо узнать видимые диапазоны времени / значений по шкалам, выбрать соответствующие семплы и произвести их мат. обработку на стороне источника — понизить sample rate, схлопнуть накладывающиеся участки, добавить сглаживающий пост-фильтр и т.д. Еще хорошо бы иметь кешированные потоки семплов под разный LOD (Level Of Detail) с предфильтрацией. А уже потом можно быстренько нарисовать. В общем система получается навороченной. И в архитектуру без поддержки виртуализации вписывается очень плохо. Можно закостылить перехватом изменений положения вьюпорта и запускать асинхронную подготовку данных серий, где-то рисовать что, мол, грузим, подождите и т.д. Все равно придется постоянно спамить передачей векторов семплов в график. В общем решение несерьезное. У нас (команды) тоже были размышления на тему использования QChart в нашем продукте, но по вышеназванным причинам решили делать свой контрол. О чем не жалеем.
gshep Автор
для чего контрол написали: Widgets или QML/Quick?
Deranged
QML. QQuickPaintedItem. На подложке QPainter'ом рисуются шкалы, а сверху для вывода серий накладываются QImage с кешем отрисованных серий. Сами серии рисуются параллельно в фоновых потоках, перерисовка инициируется при изменении положения вьюпорта. В итоге получается эффект, что при увеличении масштаба на графике на какое-то время появляется «мыло», которое заменяется более четким изображением по мере перерисовки серий. Типа как при увеличении масштаба в картографических приложениях.
Самое главное, что в график «засовываются» источники «сырых» потоков семплов с простым интерфейсом а ля QFuture read(DateTime start, DateTime end, const std::function<void(Sample*, size_t)>&), а весь хардкор с кешированием, ресемплингом, распараллеливанием и т.д. уже реализован в графике. А самих источников обычно много разных видов. Архив такой, архив сякой, адаптер для внешних систем и т.д.
Знаю, что QQuickPaintedItem — это очень плохо, но рисовать шкалы на SceneGraph API это то еще удовольствие. Так же был тестовый вариант в котором данные серий считались как буфера точек, а выводились через QSGGeometryNode + GL_LINE_STRIP, но от этого варианта пришлось отказаться, т.к. требовался еще отключаемый вывод точек, меток, поддержка стилей пера (точка, тире). На Scene Graph это всё можно, но уж больно много геморроя. А профита не так уж и много, т.к. все равно требуется время CPU на подготовку буферов геометрии, upload на GPU и т.д. Может когда нибудь дойдут руки перевести на него, но на удивление даже при использовании софтверной отрисовки производительность очень хороша, благодаря мат. обработке и параллелизму, а учитывая тенденцию по наращиванию количества ядер CPU…
KanuTaH
QQuickPaintedItem может быть не так уж плох, если задать ему opengl framebuffer в качестве render target. Но, опять же, это зависит.