Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 1): Зачем терминалу собственный 2D-рендерер
Содержание
- Введение
- Проблема любого интерфейса на языке MQL5
- Cairo — библиотека, на которой построено удивительно много программ
- Наглядное объяснение модели рисования
- Что мы собираемся создать
- Шаг 1 — единое представление цвета
- Шаг 2 — поверхность: пиксели на графике
- Шаг 3 — облегченный демонстрационный шаблон
- Наши первые пиксели
- Структура проекта
- План разработки
- Заключение и планы на вторую часть
Введение
Современным торговым интерфейсам нужно больше, чем просто сетка прямоугольников: карточки со скругленными углами, кольца и кольцевые диаграммы, полупрозрачные слои, плавные градиенты и произвольные многоугольники — все это стандартные элементы пользовательского интерфейса. Возможностей нативных графических объектов языка MQL5 и даже стандартного класса CCanvas недостаточно: углы остаются прямыми, границы заливок не сглаживаются, цвета фона объектов не поддерживают альфа-прозрачность, для геометрии доступны только целочисленные координаты, а рост числа объектов может быстро снизить отзывчивость интерфейса. При этом мы хотим обойтись средствами самого терминала — без DLL и внешних инструментов — и вывести результат в виде одного графического объекта.
В этой серии мы пойдем другим путем. Вместо того чтобы придумывать новую архитектуру, мы заимствуем компактную, проверенную на практике архитектуру Cairo. Основная идея этой архитектуры — разделить геометрию (пути) и источники цвета, затем применить к источнику попиксельную маску покрытия и выполнить композицию с целевой поверхностью. Такой подход дает общую основу для качественного, масштабируемого рендеринга разных фигур. В следующих частях мы реализуем эту модель полностью на языке MQL5, продвигаясь небольшими шагами и проверяя результат каждого из них.
В первой части закладывается основа рендеринга: единое представление цвета в формате ARGB и пиксельная поверхность с возможностью повторного использования, привязанная к одному объекту OBJ_BITMAP_LABEL. В последующих частях будет меняться способ выбора и окрашивания пикселей, а механизм их вывода на график останется прежним.
Проблема любого интерфейса на языке MQL5
Представьте панель: карточки со скругленными углами, цветной индикатор состояния в форме капсулы, кольцевой индикатор прогресса и небольшой график эквити с плавной градиентной подложкой под кривой. Ничего необычного. А теперь попробуйте нарисовать такую панель средствами языка MQL5.
Нативные графические объекты
OBJ_RECTANGLE_LABEL, OBJ_LABEL, OBJ_BUTTON, OBJ_EDIT. Терминал быстро отрисовывает их сам — нам не нужно писать код рендеринга. Для текста и полей ввода они по-прежнему подходят, и в этой серии мы не будем их заменять.
Но для рисования фигур их возможностей почти сразу перестает хватать:
| Что требуется | Нативные графические объекты |
|---|---|
| Прямоугольник | Да |
| Скругленные углы | Нет — углы всегда прямые |
| Произвольный многоугольник в пиксельных координатах | Нет |
| Гладкий край (со сглаживанием) | Нет |
| Градиентная заливка | Нет |
| Полупрозрачная заливка | Нет — OBJPROP_BGCOLOR не поддерживает альфа-канал |
| Толстая рамка или рамка с заданным стилем | Нет — только простая обводка толщиной в один пиксель |
| Кольцо, "бублик", любая фигура с отверстием | Нет |
Есть и вторая проблема, менее заметная, но со временем существенная, — количество объектов. Нативный графический объект — это не просто рисунок. Это сущность, которой управляет терминал: у нее есть имя, свойства и область проверки попадания курсора, и она участвует в каждой перерисовке графика. Для таблицы из сорока строк по шесть ячеек в каждой требуется уже более пятисот объектов, а отзывчивость интерфейса резко снижается задолго до достижения внутренних ограничений терминала. При каком именно числе объектов начинается замедление, зависит от сборки терминала, компьютера и частоты перерисовки. Поэтому считайте это наблюдением, а не документированным пороговым значением.
CCanvas
Класс CCanvas из стандартной библиотеки дает заметно больше возможностей; именно с него обычно начинают создание графического пользовательского интерфейса на языке MQL5. Он предоставляет буфер пикселей, формат ARGB с поддержкой альфа-канала, несколько графических примитивов и, что особенно важно, вывод всего изображения через один графический объект независимо от числа нарисованных элементов. В этой серии используется тот же механизм вывода: ResourceCreate и объект OBJ_BITMAP_LABEL.
Ограничения CCanvas:
- Границы заливок не сглаживаются. Именно это ограничение становится решающим. Методы FillPolygon, FillCircle и аналогичные им определяют, закрашивать ли пиксель, с помощью проверки с ответом "да" или "нет": находится ли центр пикселя внутри фигуры? Любой край, который не является ни горизонтальным, ни вертикальным, превращается в лесенку. Методы сглаженной обводки LineAA и PolygonAA рисуют тонкую линию поверх фигуры, но не сглаживают границу самой заливки. Они совершенно не помогают, если фигура расположена на прозрачном фоне, как в случае плавающей панели со скругленными углами.
- Нет прямоугольников со скругленными углами, а значит, и современных карточек, меток-чипов, вкладок или кнопок.
- Нет градиентов.
- Нет правил заливки, поэтому не получится аккуратно нарисовать кольцо. Чтобы нарисовать кольцо, приходится дважды вызывать FillCircle и надеяться, что стык не будет заметен.
- Доступны только целочисленные координаты, поэтому нельзя разместить элемент на координате x = 10,35. Это исключает плавную анимацию и субпиксельное позиционирование.
Это не означает, что CCanvas — плохая библиотека. Она предназначена для другого круга задач и отлично с ними справляется. Для информационных панелей из прямоугольников, текста и простых линий-разделителей CCanvas — практичный выбор. Библиотека поставляется вместе с терминалом, и собственный рендерер для таких задач был бы излишним усложнением. Он становится оправданным, когда приходится преодолевать перечисленные ограничения.
Создаем собственный рендерер
Третий вариант — написать полноценный 2D-рендерер. Результат по-прежнему выводится через объект OBJ_BITMAP_LABEL; вся разница — в том, как определяется цвет каждого пикселя. При правильно выбранной архитектуре задача не так велика, как кажется. Вместо того чтобы придумывать архитектуру с нуля, стоит позаимствовать ту, которая уже проверена двадцатью годами использования.
Cairo — библиотека, на которой построено удивительно много программ
Cairo — библиотека двумерной векторной графики, написанная на языке C и выпущенная в 2003 году Китом Паккардом и Карлом Уортом. С тех пор Cairo стала одной из самых распространенных графических библиотек в мире, хотя большинство людей, которые пользуются ею ежедневно, даже не слышали ее названия.
Где используется
- GTK и вся среда рабочего стола GNOME. Каждый виджет GTK, по которому вы когда-либо щелкали, был отрисован с помощью Cairo. Это самая масштабная область применения библиотеки, значительно превосходящая остальные.
- Редактор SVG Inkscape. Использование Cairo здесь закономерно: ее модель рисования и модель SVG почти совпадают.
- Poppler — рендерер PDF, лежащий в основе большинства программ просмотра PDF для Linux.
- Mozilla Firefox много лет использовал Cairo в качестве подсистемы рендеринга. Matplotlib, WebKit, Scribus и множество других научных и издательских программ продолжают использовать эту библиотеку.

Рис. 1. Рабочий стол GNOME: все рамки, значки и глифы растеризованы с помощью Cairo

Рис. 2. Библиотека графического пользовательского интерфейса Elements на языке C++, построенная на той же модели рисования
Почему Cairo выбрали для стольких проектов
На то есть четыре причины, и каждая из них важна и для нашей задачи в MetaTrader.
-
Одна модель рисования — множество вариантов вывода. Cairo отделяет то, что нужно нарисовать, от того, куда это выводится. Один и тот же код рисования формирует изображение в памяти, документ PDF или SVG либо выводит результат в окно; бэкенд определяет, куда направить вывод. Именно поэтому приложение GNOME печатает документ, который на бумаге выглядит так же, как и на экране.
-
Работает при любом размере. Рисунок хранится в виде геометрии, а не пикселей, поэтому один и тот же код дает четкое изображение и при размере 16 пикселей, и при размере 1 600 пикселей. Для нас это важно: торговая панель должна корректно выглядеть и на ноутбуке с экраном 1080p, и на мониторе 4K.
-
Качество без дополнительных усилий. Cairo сглаживает все края, корректно смешивает цвета с учетом альфа-канала и корректно обрабатывает сложные случаи: самопересекающиеся обводки, отверстия и полупрозрачные фигуры, наложенные друг на друга. Корректная обработка этих случаев составляет большую часть работы над рендерером, и именно сложности с ней чаще всего заставляют разработчиков отказаться от собственного решения.
-
Очень небольшой набор идей. Именно это для нас самое важное. Весь программный интерфейс (API) Cairo основан примерно на пяти понятиях, и именно это позволяет сохранить компактность ядра. Вот конкретный ориентир: к концу седьмой части движок будет поддерживать пути, ребра, построчную заливку, сглаживание, композицию, оба правила заливки, спрямление кривых Безье, дуги и прямоугольники со скругленными углами. Градиенты, отсечение и слой виджетов увеличат объем реализации, но сама модель останется прежней.
Не слишком ли медленно работает программный рендерер?
Очевидное возражение: растровый бэкенд Cairo выполняет все операции на центральном процессоре (CPU), обрабатывая пиксель за пикселем. Разве это не слишком медленно?
На практике — нет, и объяснение напрямую связано с особенностями торговой панели. Ускорение с помощью графического процессора (GPU) дает выигрыш, когда все изображение перерисовывается шестьдесят раз в секунду. Так работают игры. С пользовательским интерфейсом все наоборот: почти все время он статичен. Важно не то, "сколько пикселей в секунду" обрабатывается, а то, "во что обходится изменение". Для работы с интерфейсом вполне достаточно рендерера, который обрабатывает только пиксели, покрываемые фигурой. В девятой части это утверждение проверяется измерениями.
Наглядное объяснение модели рисования
Рассмотрим эту модель в терминах Cairo. Здесь стоит двигаться не спеша: каждая последующая статья серии подробно раскрывает один из аспектов этого раздела.
Перо и путь
Представьте себе перо на листе бумаги. С пером можно выполнить три действия:
- move_to(x, y) — поднять перо и опустить его в другом месте;
- line_to(x, y) — провести пером по прямой линии к новой точке;
- close_path() — провести пером обратно к точке, где начался этот штрих.
Повторите эти действия несколько раз — и получите обводку фигуры. Эта обводка и есть путь — чистая геометрия: ни цвета, ни толщины, ни пикселей. Это инструкция, а не рисунок.
Создаем путь шаг за шагом

Рис. 3. Построение пути — move_to перемещает перо без рисования, line_to проводит линию, close_path замыкает путь.
Следуйте стрелкам на рисунке 3 по порядку — и вы поймете всю суть. Стоит обратить внимание на пунктирную стрелку: move_to не оставляет следа. Именно поэтому один путь может содержать несколько отдельных обводок — контуров. Благодаря этому позднее мы сможем рисовать буквы с отверстиями, кольца и кольцевые диаграммы, выполняя для каждого такого элемента одну операцию рисования.
Еще две команды позволяют рисовать все остальное:
- curve_to(c1, c2, end) — провести пером по кубической кривой Безье, изгиб которой задают две контрольные точки;
- arc(cx, cy, r, a1, a2) — провести пером по дуге окружности.

Рис. 4. Команды для кривых — curve_to задает изгиб по двум управляющим маркерам, arc проводит дугу окружности
К рисунку 4 мы вернемся в шестой части. Сейчас важно понять следующее: контрольные точки кривой Безье не лежат на самой кривой. Они действуют как магниты: при перемещении одной из них кривая изгибается в ее сторону. Поэтому дизайнер придает кривой нужную форму, перетаскивая управляющие маркеры, а не вводя координаты.
Источник, маска и целевая поверхность
Это центральная идея Cairo, и она изящнее, чем кажется на первый взгляд. В основе Cairo лежит не команда "залить эту фигуру этим цветом". Вместо этого используются три слоя:
- Источник — бесконечная цветовая плоскость. Она может быть одноцветной, содержать градиент или повторяющееся изображение.
- Маска — трафарет, созданный на основе пути и определяющий, какая доля цвета источника пропускается в каждом пикселе. Значение 0 означает, что цвет источника не пропускается, 1 — что он пропускается полностью; допустимы и все промежуточные значения.
- Целевая поверхность — уже имеющиеся пиксели.
Тогда рисование можно описать одной фразой: наложить цвет источника через маску на целевую поверхность.

Рис. 5. Источник, маска и целевая поверхность — три слоя любой операции рисования.
На рисунке 5 показана важная для этой статьи схема: такое разделение обеспечивает независимость компонентов. Маска не зависит от того, представляет ли источник сплошной цвет или фотографию; источник не зависит от того, имеет ли маска форму прямоугольника или глифа шрифта. Добавьте новый тип источника — скажем, радиальный градиент, — и его можно будет использовать для любой имеющейся фигуры без изменений в коде ее построения. В этом и заключается разница между библиотекой, которая развивается, и той, что теряет гибкость.
Мы реализуем именно такое разделение: маска задает значение покрытия для каждого пикселя, а источник цвета определяет цвет в точке (x, y).
Покрытие — что такое сглаживание на самом деле
Сглаживание обеспечивается именно маской. Начнем с несоответствия. Путь — это математически заданная обводка с бесконечной точностью: линия может проходить через точку (12,3719…; 40,5). Экран представляет собой сетку из квадратов, каждый из которых может иметь один цвет. Растеризация определяет цвет каждого квадрата при условии, что обводка не привязана к сетке.
Проверка "да/нет". Возьмите точку точно в центре пикселя. Находится ли эта точка внутри обводки? Если да — закрасьте пиксель; если нет — оставьте его как есть. Ответ — 0 или 1, промежуточных значений нет.
Измерение покрытия. Вместо этого спросите: какая доля площади этого пикселя находится внутри обводки? Теперь ответ — число от 0 до 1, и это число и есть покрытие.
Рассмотрим пример с конкретными значениями. Возьмем пиксель, который граница фигуры пересекает так, что 30% его площади оказывается внутри фигуры. Его покрытие равно 0,30, а итоговый цвет представляет собой смесь из 70% цвета фона и 30% цвета фигуры:
shape color = (31, 111, 235) a blue
background color = (255, 255, 255) white
coverage = 0.30
red = 255 + (31 - 255) * 0.30 = 187
green = 255 + (111 - 255) * 0.30 = 212
blue = 255 + (235 - 255) * 0.30 = 249
result = (187, 212, 249) a very pale blue
Сделайте это для каждого пикселя вдоль границы — и ступеньки исчезнут. Ступенька высотой в целый пиксель сглаживается за счет двух или трех соседних пикселей: вклад цвета фигуры в каждый из них пропорционален доле площади пикселя, занятой фигурой. В этом и заключается сглаживание.

Рис. 6. Одна и та же граница: слева — проверка "да/нет", справа — учет покрытия.
Обратите внимание на граничные пиксели на рисунке 6, а не на фигуру в целом. На изображении слева всего два значения цвета; справа — возможно, около дюжины. Все промежуточные значения сосредоточены вдоль границы, а области внутри и снаружи фигуры остаются неизменными.
Собираем модель воедино 
Рис. 7. Пайплайн целиком — путь, ребра, покрытие, источник цвета, буфер
Рисунок 7 — это карта для следующих девяти статей. Главное следствие, ради которого стоит заимствовать эту архитектуру, — отсутствие отдельного кода рендеринга для каждого типа фигур. Прямоугольник, окружность, звезда, глиф шрифта и SVG-значок с 2 000 ребер сводятся к одному и тому же — списку прямолинейных отрезков, который обрабатывается одним и тем же циклом заливки. Чтобы добавить новый тип фигуры, достаточно написать небольшую вспомогательную функцию, которая формирует отрезки; изменять рендерер не требуется.
Что мы собираемся создать
Это единственный раздел серии, в котором мы забегаем вперед. Две приведенные ниже программы используют готовую библиотеку и приложены к статье, чтобы вы могли ознакомиться с ними уже сегодня.
Сравнение. Preview_ThreeWays трижды рисует один и тот же набор элементов — панель с рамкой, пятиконечную звезду и две перекрывающиеся полупрозрачные плашки — тремя способами: с помощью нативных графических объектов, CCanvas и готового движка. Во всех трех вариантах используются одни и те же координаты вершин звезды, поэтому сравнивается именно рендеринг, а не то, кто нарисовал звезду красивее.

Рис. 8. Один рисунок, три рендерера — нативные графические объекты, CCanvas, наш движок.
При обычном размере панели детали границ слишком мелкие для оценки, поэтому на рисунке 9 показан увеличенный фрагмент.

Рис. 9. Луч звезды крупным планом: у CCanvas — ступеньки, у движка — сглаженные края.
Держите рисунок 9 в уме при чтении серии: все, что мы будем делать в частях 3-5, нужно для превращения его левой половины в правую. Увеличение выполнено методом ближайшего соседа, поэтому на рисунке видны исходные пиксели в увеличенном виде.
| Нативные графические объекты | CCanvas | Эта серия | |
|---|---|---|---|
| Число используемых графических объектов | 4 | 1 | 1 |
| Скругленные углы | нет | нет | да, отдельно для каждого угла |
| Произвольный многоугольник | нет | да | да |
| Сглаженная граница заливки | нет | нет | да |
| Субпиксельная геометрия | нет | нет | да |
| Наложение полупрозрачных фигур | нет | да | да |
| Градиент | нет | нет | да |
Конечная цель. Showcase_Dashboard отрисовывает полноценную торговую панель — карточки со скругленными углами, кольцевые индикаторы прогресса, кольцевую диаграмму с мягким свечением, кривую эквити с градиентной заливкой под ней, индикаторы состояния в форме капсул, столбчатую диаграмму — в одном графическом объекте.

Рис. 10. Итог разработки — полноценная торговая панель в одном объекте OBJ_BITMAP_LABEL.
Рисунок 10 уместен уже в первой, а не в десятой части: он наглядно показывает, сколько графических объектов потребуется. Если бы эту панель собирали из нативных графических объектов, их потребовалось бы несколько сотен, но даже тогда в ней не было бы скругленных углов, колец и градиентов. При нашем подходе терминалу требуется всего один объект.
А теперь вернемся к началу и напишем немного кода.
Шаг 1 — единое представление цвета
Почти каждая ошибка в работе с графикой, которую мне приходилось искать в коде MQL5, была связана с форматом представления цвета. Поэтому сначала определимся с ним. Во всей библиотеке используется единое представление цвета:
uint 0xAARRGGBB
Альфа-канал, красный, зеленый, синий — по одному байту на каждый канал, именно в таком порядке. В значении 0xFF1F6FEB альфа-канал равен 255 (FF), красный — 31 (1F), зеленый — 111 (6F), синий — 235 (EB).
Именно такое представление цвета возвращает функция ColorToARGB, и именно такого формата пиксельных данных ожидает функция ResourceCreate при использовании COLOR_FORMAT_ARGB_NORMALIZE. Поэтому при передаче пиксельных данных между нашим рендерером и терминалом преобразование формата не потребуется — ни разу за всю серию.
Встроенный в MQL5 тип color использует другое представление. Это 0x00BBGGRR — синий, зеленый, красный, без альфа-канала. Если перепутать эти два представления, красный и синий цвета в графическом интерфейсе поменяются местами. Это типичная ошибка, которую легко пропустить при проверке: код выглядит корректно. Правило этой библиотеки: внутри нее цвет всегда представлен значением типа uint в формате ARGB, а значения типа color принимаются только на входе.
Вот начало файла Color.mqh — защита от повторного включения, константа прозрачного цвета и макросы для извлечения значений каналов:
#ifndef CAIRO5_COLOR_MQH #define CAIRO5_COLOR_MQH //--- A fully transparent color. #define CAIRO_TRANSPARENT ((uint)0x00000000) //+-------------------------------------------------------------------+ //| Channel accessors. | //+-------------------------------------------------------------------+ #define CAIRO_A(c) (((uint)(c) >> 24) & 0xFF) #define CAIRO_R(c) (((uint)(c) >> 16) & 0xFF) #define CAIRO_G(c) (((uint)(c) >> 8) & 0xFF) #define CAIRO_B(c) (((uint)(c) ) & 0xFF)
Каждый макрос сдвигает нужный байт в младшие разряды и обнуляет остальные биты с помощью маски: CAIRO_R(0xFF1F6FEB) сдвигает значение вправо на 16 бит и применяет маску 0xFF, получая 31.
Они намеренно реализованы как макросы #define, а не как функции: начиная с четвертой части операции извлечения каналов выполняются в самом внутреннем цикле для каждого пикселя при композиции — почти 300 000 раз за одну перерисовку панели размером 720×400. Однако к этому утверждению следует относиться осторожно: Язык MQL5 не дает такого контроля над встраиванием функций, как C, а основная часть затрат времени с не меньшей вероятностью может приходиться на обмен данными с памятью. Макросы выбраны потому, что они просты, а используемые здесь аргументы не имеют побочных эффектов, а не потому, что выигрыш в производительности доказан.
Далее — объявления. Язык MQL5 не требует предварительных объявлений для свободных функций в том же файле, но их общий список позволяет показать весь интерфейс файла в восьми строчках:
//+-------------------------------------------------------------------+ //| Free functions - declarations | //+-------------------------------------------------------------------+ uint CairoArgb(const uint a, const uint r, const uint g, const uint b); uint CairoRgb(const uint r, const uint g, const uint b); uint CairoHex(const uint bgr, const uint alpha = 0xFF); uint CairoFromMql(const color clr, const uint alpha = 255); color CairoToMql(const uint argb); uint CairoWithAlpha(const uint argb, const uint alpha); uint CairoWithOpacity(const uint argb, const double opacity); uint CairoLerp(const uint from, const uint to, const double t);
Эти две функции выполняют обратную операцию — сдвигают значения каналов в соответствующие разрядные поля и объединяют их:
//+-------------------------------------------------------------------+ //| Pack a, r, g, b (each 0..255) into 0xAARRGGBB. | //+-------------------------------------------------------------------+ uint CairoArgb(const uint a, const uint r, const uint g, const uint b) { return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); } //+-------------------------------------------------------------------+ //| Pack r, g, b as fully opaque. The everyday constructor. | //+-------------------------------------------------------------------+ uint CairoRgb(const uint r, const uint g, const uint b) { return CairoArgb(255, r, g, b); }
Операция & 0xFF для каждого аргумента нужна не для красоты. Параметры имеют тип uint, поэтому вызывающий код может передать значение 300. Без маски лишние биты попадут в следующий по старшинству канал и изменят уже другую цветовую составляющую.
Полезная функция, о которой стоит рассказать
Функция CairoHex кажется лишней, пока вы ею не воспользуетесь:
//+-------------------------------------------------------------------+ //| Take a literal written in MQL5 `color` (BGR) order plus an alpha, | //| and return the library's 0xAARRGGBB. | //+-------------------------------------------------------------------+ uint CairoHex(const uint bgr, const uint alpha = 0xFF) { const uint r = (bgr) & 0xFF; const uint g = (bgr >> 8) & 0xFF; const uint b = (bgr >> 16) & 0xFF; return CairoArgb(alpha, r, g, b); }
При внимательном чтении тела функции может показаться, что в нем ошибка: функция извлекает младший байт и использует его как значение красного канала. В этом-то и дело. MetaEditor показывает цветной квадрат рядом с шестнадцатеричным литералом с префиксом 0x, интерпретируя значение как цвет типа color языка MQL5, то есть в формате BGR. Запишите значение в таком порядке — квадрат покажет нужный цвет, а CairoHex переставит байты обратно. Так можно выбирать цвета на глаз прямо в редакторе и при этом хранить их в правильном формате ARGB:
CairoHex( 0x0000FF ) // editor square: red -> we get red CairoHex( 0xFF0000 ) // editor square: blue -> we get blue CairoHex( 0xF6823B, 0x33 ) // the same color at 20% alphaПреобразование между форматами
Две функции преобразуют цвета между внутренним форматом библиотеки и встроенным типом color языка MQL5. В остальной библиотеке тип color не используется:
//+-------------------------------------------------------------------+ //| Boundary conversion: MQL5 `color` (BGR) -> ARGB. | //| Use this for anything arriving from an `input color` parameter. | //+-------------------------------------------------------------------+ uint CairoFromMql(const color clr, const uint alpha = 255) { //--- ColorToARGB already performs the BGR -> RGB swap for us return ((alpha & 0xFF) << 24) | (ColorToARGB(clr, 255) & 0x00FFFFFF); } //+-------------------------------------------------------------------+ //| Boundary conversion: ARGB -> MQL5 `color` (BGR). Alpha is lost. | //| Only needed when feeding a native chart object, which cannot | //| express alpha at all. | //+-------------------------------------------------------------------+ color CairoToMql(const uint argb) { return (color)((CAIRO_B(argb) << 16) | (CAIRO_G(argb) << 8) | CAIRO_R(argb)); }
Входной параметр типа color передают в функцию CairoFromMql. Она использует ColorToARGB для перестановки байтов, а затем подставляет заданное нами значение альфа-канала. CairoToMql выполняет обратное преобразование с потерей альфа-канала, поскольку нативный графический объект не поддерживает прозрачность. Именно из-за потери альфа-канала преобразование оформлено как отдельная именованная функция, а не как приведение типа: эта потеря должна быть заметна в коде.
Изменение альфа-канала цвета//+-------------------------------------------------------------------+ //| Replace the alpha channel, keep the RGB. | //+-------------------------------------------------------------------+ uint CairoWithAlpha(const uint argb, const uint alpha) { return (argb & 0x00FFFFFF) | ((alpha & 0xFF) << 24); } //+-------------------------------------------------------------------+ //| Scale the EXISTING alpha by `opacity` (0.0 .. 1.0). | //+-------------------------------------------------------------------+ uint CairoWithOpacity(const uint argb, const double opacity) { double o = opacity; if(o < 0.0) o = 0.0; if(o > 1.0) o = 1.0; const uint a = (uint)(CAIRO_A(argb) * o + 0.5); return CairoWithAlpha(argb, a); }
Может показаться, что эти функции дублируют друг друга, но CairoWithAlpha заменяет значение альфа-канала, а CairoWithOpacity умножает уже имеющееся значение на коэффициент. Уменьшим непрозрачность наполовину прозрачного цвета до 50% от исходной: замена даст непрозрачность 50%, то есть выше исходной, а умножение на коэффициент — 25%. Последовательные операции общего уменьшения непрозрачности корректно сочетаются только при умножении коэффициентов.
Прибавление 0.5 перед приведением типа обеспечивает округление. При преобразовании в целый тип язык MQL5 отбрасывает дробную часть. Поэтому без округления повторное уменьшение непрозрачности будет постепенно давать результат прозрачнее расчетного.
И еще немного математики
CairoLerp выполняет интерполяцию между двумя цветами, включая их альфа-каналы:
//+-------------------------------------------------------------------+ //| Linear interpolation between two colors, alpha included. | //| t = 0 returns `from`, t = 1 returns `to`. | //+-------------------------------------------------------------------+ uint CairoLerp(const uint from, const uint to, const double t) { double k = t; if(k < 0.0) k = 0.0; if(k > 1.0) k = 1.0; const uint a = (uint)(CAIRO_A(from) + (CAIRO_A(to) - (double)CAIRO_A(from)) * k + 0.5); const uint r = (uint)(CAIRO_R(from) + (CAIRO_R(to) - (double)CAIRO_R(from)) * k + 0.5); const uint g = (uint)(CAIRO_G(from) + (CAIRO_G(to) - (double)CAIRO_G(from)) * k + 0.5); const uint b = (uint)(CAIRO_B(from) + (CAIRO_B(to) - (double)CAIRO_B(from)) * k + 0.5); return CairoArgb(a, r, g, b); } #endif // CAIRO5_COLOR_MQH
Lerp — сокращение от linear interpolation (линейная интерполяция). Выражение from + (to - from) * k применяется к каждому каналу отдельно и дает from при k = 0 и to при k = 1.
Приведение к типу double в середине каждой строчки действительно необходимо. Макросы извлечения каналов дают значения типа uint. При беззнаковом вычитании вместо отрицательного результата получается большое положительное число — около четырех миллиардов. Если значение канала уменьшается от from к to, без такого приведения результат вычитания будет неверным. Из-за такой ошибки каждый пятый градиент приобретает неестественно яркие цвета.
Все градиенты в готовой библиотеке строятся с помощью этой единственной функции. Здесь намеренно отсутствует альфа-смешивание — наложение полупрозрачного цвета на уже имеющиеся пиксели. Альфа-смешивание использует общие механизмы со сглаживанием, поэтому оба механизма мы реализуем в четвертой части. Пока мы только перезаписываем пиксели.
Шаг 2 — поверхность для вывода пикселей на график
Второй файл служит связующим звеном с MetaTrader. Поверхность управляет тремя компонентами:
- массивом пикселей с элементами типа uint — нашим холстом;
- динамическим ресурсом, содержащим копию этого массива;
- одним объектом OBJ_BITMAP_LABEL на графике, отображающим этот ресурс.
Вот полный код класса:
#ifndef CAIRO5_SURFACE_MQH #define CAIRO5_SURFACE_MQH #include "Color.mqh" //+-------------------------------------------------------------------+ //| Build a name that is unique to one surface on one chart. | //+-------------------------------------------------------------------+ string CairoSurfaceName(const string base, const long chart_id = 0, const int subwin = 0) { const long id = (chart_id == 0) ? ChartID() : chart_id; return StringFormat("g2d_%s_%I64u_%d", base, (ulong)id, subwin); } //+-------------------------------------------------------------------+ //| CCairoSurface | //+-------------------------------------------------------------------+ class CCairoSurface { private: int m_width; int m_height; string m_object; // chart object name string m_resource; // dynamic resource name ("::something") long m_chart; int m_subwin; bool m_created; bool m_bound; // is OBJPROP_BMPFILE already set? void Reset(); // back to the constructed state public: //----------------------------------------------------------------- // THE PIXEL BUFFER IS PUBLIC, ON PURPOSE. //----------------------------------------------------------------- uint m_px[]; // the ARGB pixel buffer, row-major CCairoSurface(); ~CCairoSurface(); //--- lifecycle bool Create(const string name, const int x, const int y, const int w, const int h, const long chart_id = 0, const int subwin = 0); void Destroy(); //--- geometry and state int Width() { return m_width; } int Height() { return m_height; } bool IsCreated() { return m_created; } //--- drawing support void Clear(const uint argb = CAIRO_TRANSPARENT); void Move(const int x, const int y); //--- push the buffer to the chart bool Flush(const bool redraw = true); };
Одну строчку в этом объявлении стоит обосновать: массив m_px объявлен с доступом public. В языке MQL5 нет типа "указатель на массив", поэтому объявление uint Pixels() не компилируется. К массиву необходимо обращаться напрямую и передавать его по ссылке в растеризатор, который мы напишем в третьей части:
ras.Fill( path, color, surface.m_px, surface.Width(), surface.Height() );
Такой же подход используется в стандартной библиотеке для CCanvas::m_pixels. Когда язык не позволяет что-то скрыть, лучше оставить это на виду, чем скрывать неумело.
Конструктор и деструктор короткие. Деструктор вызывает Destroy, поэтому ресурсы поверхности, объявленной как глобальный объект, освобождаются автоматически, даже если программа завершается по ветке выполнения, в которой забыли явно вызвать очистку:
//+-------------------------------------------------------------------+ //| Constructor | //+-------------------------------------------------------------------+ CCairoSurface::CCairoSurface() { m_width = 0; m_height = 0; m_chart = 0; m_subwin = 0; m_created = false; m_bound = false; } //+-------------------------------------------------------------------+ //| Destructor | //+-------------------------------------------------------------------+ CCairoSurface::~CCairoSurface() { Destroy(); }
Метод Create в основном выполняет служебные операции, но каждая его строчка имеет свое назначение:
//+-------------------------------------------------------------------+ //| Allocate the buffer, create the chart object, and bind the two | //| together through a dynamic resource. | //+-------------------------------------------------------------------+ bool CCairoSurface::Create(const string name, const int x, const int y, const int w, const int h, const long chart_id, const int subwin) { Destroy(); if(w <= 0 || h <= 0 || name == "") return false; m_width = w; m_height = h; m_chart = chart_id; m_subwin = subwin; m_object = name; m_resource = "::" + name; //--- from here on, every failure has to undo what came before it. //--- Reset() is enough while nothing exists on the chart yet; once //--- the object is created, only Destroy() will do. if(ArrayResize(m_px, w * h) != w * h) { Reset(); return false; } Clear(); if(!ObjectCreate(m_chart, m_object, OBJ_BITMAP_LABEL, m_subwin, 0, 0)) { Reset(); return false; } ObjectSetInteger(m_chart, m_object, OBJPROP_CORNER, CORNER_LEFT_UPPER); ObjectSetInteger(m_chart, m_object, OBJPROP_ANCHOR, ANCHOR_LEFT_UPPER); ObjectSetInteger(m_chart, m_object, OBJPROP_XDISTANCE, x); ObjectSetInteger(m_chart, m_object, OBJPROP_YDISTANCE, y); ObjectSetInteger(m_chart, m_object, OBJPROP_XSIZE, w); ObjectSetInteger(m_chart, m_object, OBJPROP_YSIZE, h); ObjectSetInteger(m_chart, m_object, OBJPROP_BACK, false); ObjectSetInteger(m_chart, m_object, OBJPROP_SELECTABLE, false); ObjectSetInteger(m_chart, m_object, OBJPROP_HIDDEN, true); //--- Flush() refuses to run on a surface that is not created, so the //--- flag has to go up before the first upload rather than after it. //--- If that upload fails we are in a half-built state, and the only //--- honest answer is to tear the whole thing down again. m_created = true; if(!Flush(false)) { Destroy(); return false; } return true; }
В этой функции стоит выделить три строчки:
- Начальные символы "::" обозначают динамический ресурс — он хранится в памяти выполняемой программы EX5, а не в файле на диске.
- Функция ArrayResize выделяет память для width × height элементов — по одному значению типа uint на пиксель. Для панели размером 720×400 это чуть больше мегабайта.
- Значения CORNER_LEFT_UPPER и ANCHOR_LEFT_UPPER задают привязку растрового изображения к левому верхнему углу окна графика с отступами в пикселях, поэтому панель не смещается при прокрутке графика.
- Завершающий вызов Flush( false ) сразу передает содержимое пустого буфера в графический ресурс: объект OBJ_BITMAP_LABEL ничего не отображает, пока этот ресурс не создан.
Destroy и две небольшие вспомогательные функции:
//+-------------------------------------------------------------------+ //| Remove the chart object and release the resource. | //+-------------------------------------------------------------------+ void CCairoSurface::Destroy() { if(m_created) { ObjectDelete(m_chart, m_object); if(m_resource != "") ResourceFree(m_resource); } Reset(); } //+-------------------------------------------------------------------+ //| Drop every field back to the constructed state, and give the | //| pixel memory back to the terminal. | //+-------------------------------------------------------------------+ void CCairoSurface::Reset() { m_width = 0; m_height = 0; m_object = ""; m_resource = ""; m_chart = 0; m_subwin = 0; m_created = false; m_bound = false; ArrayResize(m_px, 0); } //+-------------------------------------------------------------------+ //| Fill the whole buffer with one value. | //+-------------------------------------------------------------------+ void CCairoSurface::Clear(const uint argb) { ArrayFill(m_px, 0, ArraySize(m_px), argb); } //+-------------------------------------------------------------------+ //| Move the bitmap on the chart. No repaint needed - the pixels are | //| unchanged, only the object's position is. | //+-------------------------------------------------------------------+ void CCairoSurface::Move(const int x, const int y) { if(!m_created) return; ObjectSetInteger(m_chart, m_object, OBJPROP_XDISTANCE, x); ObjectSetInteger(m_chart, m_object, OBJPROP_YDISTANCE, y); }
Метод Destroy вызывает и ResourceFree, и ObjectDelete. Если удалить только объект, копия пиксельного буфера в графическом ресурсе останется в памяти терминала. Такую утечку легко не заметить: на графике уже ничего не видно. По умолчанию метод Clear заполняет буфер прозрачным цветом, а не черным: обычно вокруг скругленных углов панели должен быть виден график. Метод Move перемещает панель, не изменяя пиксели и не передавая буфер в ресурс повторно. Это будет важно в десятой части.
Теперь рассмотрим интересную функцию — передачу буфера в ресурс:
//+-------------------------------------------------------------------+ //| Upload the buffer to the terminal and point the object at it. | //| | //| ResourceCreate copies the array, so the buffer may be modified | //| again immediately afterwards. | //+-------------------------------------------------------------------+ bool CCairoSurface::Flush(const bool redraw) { if(!m_created) return false; if(!ResourceCreate(m_resource, m_px, m_width, m_height, 0, 0, 0, COLOR_FORMAT_ARGB_NORMALIZE)) return false; if(!m_bound) { if(!ObjectSetString(m_chart, m_object, OBJPROP_BMPFILE, m_resource)) return false; m_bound = true; } if(redraw) ChartRedraw(m_chart); return true; } #endif // CAIRO5_SURFACE_MQH
Формат цвета. COLOR_FORMAT_ARGB_NORMALIZE учитывает альфа-канал, а значения цветовых каналов должны передаваться без предварительного умножения на альфа-канал. Если вместо этого передать COLOR_FORMAT_XRGB_NOALPHA, все прозрачные пиксели будут отброшены без предупреждения, а панель превратится в непрозрачный прямоугольник без каких-либо сообщений об ошибке.
ResourceCreate копирует массив, поэтому буфер можно сразу же снова изменять — именно поэтому двойное буферирование не требуется. Перерисовка опциональна: основные затраты здесь приходятся на вызов ChartRedraw. Если программа вызывает Flush для нескольких поверхностей, перерисовку лучше выполнить один раз в конце.
Важно: динамический ресурс связан с программой EX5, а не с отдельным графиком. Два экземпляра одного советника на разных графиках, каждый из которых создает ресурс "::panel", используют общий буфер ресурса. На обоих графиках отображаются данные, переданные в него последними. Именно для предотвращения такого конфликта предназначена приведенная выше функция CairoSurfaceName.
Шаг 3 — светлый шаблон для демонстрационных примеров
Прежде чем перейти к самой демонстрации, рассмотрим один небольшой файл, который не входит в состав библиотеки. Все демонстрационные примеры этой серии выполнены в едином стиле: светлая панель, темный текст и знакомые всем торговые цвета. У такого оформления две причины, и ни одна из них не связана с украшательством.
Поэтому палитра, настройки графика и вспомогательные функции для подписей собраны в одном файле DemoTheme.mqh:
#ifndef CAIRO5_DEMOTHEME_MQH #define CAIRO5_DEMOTHEME_MQH //+-------------------------------------------------------------------+ //| 1. THE PALETTE | //+-------------------------------------------------------------------+ #define DEMO_BG ((uint)0xFFFFFFFF) // panel background, plain white #define DEMO_PANEL ((uint)0xFFF6F8FA) // a card inside the panel #define DEMO_BORDER ((uint)0xFFC8CDD4) // 1 px frames and separators #define DEMO_INK ((uint)0xFF1B1F24) // headings and anything that reads #define DEMO_INK_SOFT ((uint)0xFF57606A) // secondary labels #define DEMO_BLUE ((uint)0xFF1F6FEB) // positive / buy / the main accent #define DEMO_GREEN ((uint)0xFF1A7F37) // positive, when blue is taken #define DEMO_RED ((uint)0xFFCF222E) // negative / sell / fail #define DEMO_AMBER ((uint)0xFFBF8700) // warning, threshold #define DEMO_GREY ((uint)0xFF8C959F) // no data, disabled, invalid //--- the caption font, kept in one place so every figure matches #define DEMO_FONT "Segoe UI" #define DEMO_FONT_SIZE 9 #define DEMO_TAG "g2d_demo_lbl_" //+-------------------------------------------------------------------+ //| 2. THE CHART | //| | //| Put the chart into the scheme the site asks for: white paper, | //| black ink, no grid, no volumes, no clutter. Call this once from | //| OnInit and the screenshot is already half composed. | //+-------------------------------------------------------------------+ void DemoUseLightChart(const long chart = 0) { ChartSetInteger(chart, CHART_COLOR_BACKGROUND, clrWhite); ChartSetInteger(chart, CHART_COLOR_FOREGROUND, clrBlack); ChartSetInteger(chart, CHART_COLOR_GRID, clrWhite); ChartSetInteger(chart, CHART_COLOR_CHART_UP, clrBlack); ChartSetInteger(chart, CHART_COLOR_CHART_DOWN, clrBlack); ChartSetInteger(chart, CHART_COLOR_CHART_LINE, clrBlack); ChartSetInteger(chart, CHART_COLOR_CANDLE_BULL, clrWhite); ChartSetInteger(chart, CHART_COLOR_CANDLE_BEAR, clrBlack); ChartSetInteger(chart, CHART_COLOR_BID, clrWhite); ChartSetInteger(chart, CHART_COLOR_ASK, clrWhite); ChartSetInteger(chart, CHART_COLOR_LAST, clrWhite); ChartSetInteger(chart, CHART_COLOR_STOP_LEVEL, clrWhite); ChartSetInteger(chart, CHART_SHOW_GRID, false); ChartSetInteger(chart, CHART_SHOW_VOLUMES, CHART_VOLUME_HIDE); ChartSetInteger(chart, CHART_SHOW_PERIOD_SEP, false); ChartSetInteger(chart, CHART_SHOW_OBJECT_DESCR, false); ChartSetInteger(chart, CHART_SHOW_ONE_CLICK, false); ChartSetInteger(chart, CHART_SHOW_TRADE_LEVELS, false); } //+-------------------------------------------------------------------+ //| 3. CAPTIONS | //+-------------------------------------------------------------------+ void DemoLabel(const string id, const int x, const int y, const string text, const uint argb = DEMO_INK, const int size = DEMO_FONT_SIZE, const long chart = 0) { const string name = DEMO_TAG + id; if(ObjectFind(chart, name) < 0) ObjectCreate(chart, name, OBJ_LABEL, 0, 0, 0); //--- OBJ_LABEL takes an MQL5 `color`, which has no alpha channel, //--- so we hand it the RGB part only const color clr = (color)(((argb) & 0xFF) << 16 | ((argb >> 8) & 0xFF) << 8 | ((argb >> 16) & 0xFF)); ObjectSetInteger(chart, name, OBJPROP_CORNER, CORNER_LEFT_UPPER); ObjectSetInteger(chart, name, OBJPROP_ANCHOR, ANCHOR_LEFT_UPPER); ObjectSetInteger(chart, name, OBJPROP_XDISTANCE, x); ObjectSetInteger(chart, name, OBJPROP_YDISTANCE, y); ObjectSetInteger(chart, name, OBJPROP_COLOR, clr); ObjectSetInteger(chart, name, OBJPROP_FONTSIZE, size); ObjectSetInteger(chart, name, OBJPROP_SELECTABLE, false); ObjectSetInteger(chart, name, OBJPROP_HIDDEN, true); ObjectSetString(chart, name, OBJPROP_FONT, DEMO_FONT); ObjectSetString(chart, name, OBJPROP_TEXT, text); } //+-------------------------------------------------------------------+ //| Remove every caption this file created. | //+-------------------------------------------------------------------+ void DemoLabelsClear(const long chart = 0) { ObjectsDeleteAll(chart, DEMO_TAG, -1, -1); } #endif // CAIRO5_DEMOTHEME_MQH
Наши первые пиксели
Теперь у нас есть все необходимое, чтобы вывести что-нибудь на экран, и ничего лишнего. Нет ни пути, ни растеризатора, нет даже функции для рисования прямоугольника. Поэтому в Demo01_FirstPixels.mq5 мы задаем каждый пиксель вручную. Начнем с подключения файлов, геометрии и двух переключателей:
#include <CairoG2D/Color.mqh> #include <CairoG2D/Surface.mqh> #include <CairoG2D/DemoTheme.mqh> //--- panel geometry, in chart pixels from the upper-left corner #define UI_X 40 #define UI_Y 40 #define UI_W 720 #define UI_H 400 //--- captions are annotation for the article figures, not drawing. //--- Turn them off and the panel is still exactly one chart object. input bool InpShowCaptions = true; // Draw the explanatory captions input bool InpSetLightChart = true; // Switch the chart to Black On White CCairoSurface g_surface;
Далее — функция, на которой строится все остальное:
//+-------------------------------------------------------------------+ //| Write one pixel, with bounds checking. | //+-------------------------------------------------------------------+ void SetPixel(const int x, const int y, const uint argb) { if(x < 0 || y < 0 || x >= g_surface.Width() || y >= g_surface.Height()) return; g_surface.m_px[ y * g_surface.Width() + x ] = argb; }
y * width + x — это выражение лежит в основе хранения растровой графики в памяти: оно преобразует двумерные координаты пикселя (x, y) в индекс линейного буфера. Буфер представляет собой один длинный массив, в котором строки изображения расположены последовательно. Поэтому пиксель (0, 0) имеет индекс 0, а пиксель (0, 1) — индекс 720. Положение пикселя в буфере вычисляется, а не находится поиском.
Проверка границ обязательна. Без нее часть фигуры, выходящая за правый край, не исчезает, а появляется слева в следующей строке, образуя характерный диагональный след. Обратите также внимание на то, чего SetPixel не делает: он лишь перезаписывает значение пикселя. Смешивание нового цвета с уже имеющимся — это альфа-смешивание, которое мы реализуем в четвертой части.
A. Прямоугольник без хитростей//+-------------------------------------------------------------------+ //| A. A SOLID RECTANGLE, THE HONEST WAY | //+-------------------------------------------------------------------+ void RectSolid(const int x, const int y, const int w, const int h, const uint argb) { for(int row = y; row < y + h; row++) for(int col = x; col < x + w; col++) SetPixel(col, row, argb); }
Два вложенных цикла, никаких хитростей. Обратите внимание: понятие фигуры здесь вообще не используется. В буфере не хранится информация о нарисованном прямоугольнике — меняются лишь значения отдельных пикселей. Все дальнейшее развитие библиотеки направлено на более совершенное решение задачи, которую эти два цикла решают самым простым способом: какие пиксели принадлежат рисуемой фигуре? Обводку толщиной в один пиксель составим из четырех тонких прямоугольников — лучшего способа у нас пока нет:
//+-------------------------------------------------------------------+ //| A one-pixel outline, drawn as four thin rectangles because we | //| have no better tool yet. In Part 5 this becomes a single path | //| filled with the even-odd rule. | //+-------------------------------------------------------------------+ void RectOutline(const int x, const int y, const int w, const int h, const uint argb) { RectSolid(x, y, w, 1, argb); RectSolid(x, y + h - 1, w, 1, argb); RectSolid(x, y, 1, h, argb); RectSolid(x + w - 1, y, 1, h, argb); }
Запомним эту функцию. В пятой части эта функция больше не понадобится: вместо нее мы используем один путь с двумя контурами и правило заливки "чет-нечет". Этот подход также позволит рисовать рамки любой толщины, кольца и отверстия.
B. Градиент//+-------------------------------------------------------------------+ //| B. A VERTICAL GRADIENT | //+-------------------------------------------------------------------+ void RectGradient(const int x, const int y, const int w, const int h, const uint top, const uint bottom) { if(w <= 0 || h <= 0) return; //--- a one-row gradient has no distance to interpolate ACROSS, and //--- (h - 1) below would be zero. There is no sensible blend of two //--- colors over a single row, so the top color wins. if(h == 1) { RectSolid(x, y, w, 1, top); return; } for(int row = 0; row < h; row++) { const uint c = CairoLerp(top, bottom, (double)row / (h - 1)); for(int col = 0; col < w; col++) SetPixel(x + col, y + row, c); } }
Те же два цикла, но цвет вычисляется один раз для каждой строки изображения. Значение выражения ( double )row / ( h - 1 ) изменяется от 0 в верхней строке до ровно 1 в нижней. Вычитание единицы в знаменателе обеспечивает точное совпадение цвета последней строки с конечным цветом градиента. Приведение типа необходимо: иначе в языке MQL5 будет выполнено целочисленное деление и для каждой строки получится 0.
Здесь уже появляется идея источника, показанного на рисунке 5: цвет пикселя может зависеть от его положения. Пока правило вычисления цвета задано прямо внутри цикла. В восьмой части мы оформим это правило как отдельный объект — источник цвета. Одноцветная заливка станет частным случаем: такой источник возвращает один и тот же цвет для любой точки.
C и D. Две фигуры, объясняющие, зачем нужна остальная серия//+-------------------------------------------------------------------+ //| C. A DIAGONAL EDGE, DECIDED PIXEL BY PIXEL | //+-------------------------------------------------------------------+ void TriangleHardEdge(const int x, const int y, const int w, const int h, const uint argb) { if(w < 2 || h < 2) return; //--- rise over run, measured between the two corner pixel centers const double slope = (double)(h - 1) / (double)(w - 1); for(int row = 0; row < h; row++) { for(int col = 0; col < w; col++) { //--- the pixel's CENTER, which is what makes this a //--- yes/no test rather than a measurement const double px = col + 0.5; const double py = row + 0.5; //--- below the diagonal, so inside the lower-left triangle? if(py - 0.5 >= (px - 0.5) * slope) SetPixel(x + col, y + row, argb); } } }
Здесь важно именно прибавление 0,5. Пиксель — это не точка, а маленький квадрат. Пиксель с индексом 7 занимает интервал от x = 7 до x = 8, поэтому координата его центра равна 7,5. Если вместо центра проверять угол пикселя, вся фигура сместится на полпикселя вверх и влево. На прямоугольнике это незаметно, а на круге — очень заметно.
Ширина прямоугольной области намеренно больше ее высоты. При наклоне 45 градусов каждому смещению на один пиксель по горизонтали соответствует смещение на один пиксель вниз, поэтому граница с таким шагом выглядит почти как прямая линия. В демонстрации используется область размером 200×68 с наклоном примерно 1:3: граница идет горизонтально на протяжении трех пикселей, а затем опускается сразу на целый пиксель. Чем меньше наклон, тем заметнее ступеньки. Поэтому рендеринг без сглаживания хуже всего выглядит на длинных пологих линиях, например на кривой эквити.
Диагональ задается между центрами пикселей. Отсюда и слагаемые - 0.5, и значение наклона (h - 1) / (w - 1). Если записать условие очевидным способом — py >= px * h / w, — линия пройдет через внешние углы, а вершина треугольника окажется срезанной.
Для круга выполняется аналогичная проверка, но по другой формуле:
//+-------------------------------------------------------------------+ //| D. A CIRCLE, ALSO PIXEL BY PIXEL | //+-------------------------------------------------------------------+ void CircleHardEdge(const int cx, const int cy, const int r, const uint argb) { for(int row = cy - r; row <= cy + r; row++) { for(int col = cx - r; col <= cx + r; col++) { const double dx = (col + 0.5) - cx; const double dy = (row + 0.5) - cy; if(dx * dx + dy * dy <= (double)r * r) SetPixel(col, row, argb); } } }
Это проверка по теореме Пифагора, только без извлечения квадратного корня. Сравнение dx² + dy² с r² дает тот же результат, что и сравнение расстояния с радиусом, но позволяет избежать вызова MathSqrt для каждого пикселя ограничивающего прямоугольника — около 7 700 вызовов при радиусе 44. Этот прием снова встретится в седьмой части.
E. Альфа-канал действительно работает//+-------------------------------------------------------------------+ //| E. THE ALPHA CHANNEL IS REAL | //+-------------------------------------------------------------------+ void AlphaStrip(const int x, const int y, const int w, const int h) { //--- a chequerboard, so the transparency is visible against //--- something rather than guessed at for(int row = 0; row < h; row++) for(int col = 0; col < w; col++) { const bool dark = (((col / 10) + (row / 10)) % 2 == 0); SetPixel(x + col, y + row, dark ? CairoRgb(230, 233, 236) : DEMO_BG); } //--- eight swatches, from fully opaque down to fully transparent for(int i = 0; i < 8; i++) { const double opacity = 1.0 - i / 7.0; const uint c = CairoWithOpacity(DEMO_BLUE, opacity); RectSolid(x + 16 + i * 80, y + 16, 60, h - 32, c); } }
Непрозрачность цветовых образцов уменьшается от 1,0 до 0,0 за семь равных шагов. CairoWithOpacity умножает исходное значение альфа-канала на коэффициент, а DEMO_BLUE изначально полностью непрозрачен, поэтому восьмой образец действительно полностью невидим.
Стоит четко обозначить одно различие, поскольку именно здесь чаще всего ошибаются: мы ничего не смешиваем. Каждый следующий образец записывается в буфер с меньшим значением альфа-канала, заменяя прежние значения пикселей. Смешивание происходит в другом месте — терминал выполняет его, когда накладывает наше растровое изображение поверх графика.
Формирование кадра//+-------------------------------------------------------------------+ //| Compose the frame. | //+-------------------------------------------------------------------+ void Render() { //--- start from a plain white panel: the chart uses the standard //--- Black On White scheme, so the panel must not fight it g_surface.Clear(DEMO_BG); //--- A: five flat rectangles, in the roles every demo agrees on RectSolid(20, 30, 124, 56, DEMO_BLUE); RectSolid(154, 30, 124, 56, DEMO_GREEN); RectSolid(288, 30, 124, 56, DEMO_RED); RectSolid(422, 30, 124, 56, DEMO_AMBER); RectSolid(556, 30, 124, 56, DEMO_GREY); //--- B: a gradient, computed per row RectGradient(20, 130, 280, 88, DEMO_BG, DEMO_BLUE); RectOutline(20, 130, 280, 88, DEMO_BORDER); //--- C and D: hard-edged shapes - the problem to be solved. //--- The triangle is 200 x 68, a slope of about 1 in 3, so each //--- step of the staircase is three pixels long. TriangleHardEdge(330, 140, 200, 68, DEMO_BLUE); CircleHardEdge(610, 174, 44, DEMO_BLUE); //--- E: alpha AlphaStrip(20, 258, 660, 116); RectOutline(20, 258, 660, 116, DEMO_BORDER); //--- a frame around the whole panel, so it reads as one card RectOutline(0, 0, UI_W, UI_H, DEMO_BORDER); //--- one upload: the terminal is left holding ONE object g_surface.Flush(); }
Каждый вызов функции рисования в этом коде записывает данные в память. К терминалу обращается ровно одна строчка — завершающий вызов Flush.
Подписи выводятся отдельно. Их положение вычисляется сложением координат начала панели и координат относительно нее, поэтому при перемещении панели каждая подпись остается рядом со своей областью:
//+-------------------------------------------------------------------+ //| The captions. Positions are panel coordinates plus the panel | //| origin, so a caption always sits where its region is. | //+-------------------------------------------------------------------+ void Captions() { DemoLabel("a", UI_X + 20, UI_Y + 10, "A. Solid fill - one color written into every pixel"); DemoLabel("a1", UI_X + 20, UI_Y + 90, "BLUE / buy", DEMO_INK_SOFT, 8); DemoLabel("a2", UI_X + 154, UI_Y + 90, "GREEN / pass", DEMO_INK_SOFT, 8); DemoLabel("a3", UI_X + 288, UI_Y + 90, "RED / sell", DEMO_INK_SOFT, 8); DemoLabel("a4", UI_X + 422, UI_Y + 90, "AMBER / limit", DEMO_INK_SOFT, 8); DemoLabel("a5", UI_X + 556, UI_Y + 90, "GREY / no data", DEMO_INK_SOFT, 8); DemoLabel("b", UI_X + 20, UI_Y + 112, "B. Color as a function of position"); DemoLabel("c", UI_X + 330, UI_Y + 112, "C. Triangle, slope 1 in 3"); DemoLabel("d", UI_X + 566, UI_Y + 112, "D. Circle"); DemoLabel("cd", UI_X + 330, UI_Y + 228, "both edges decided by a yes/no test - look for the steps", DEMO_INK_SOFT, 8); DemoLabel("e", UI_X + 20, UI_Y + 238, "E. Real transparency - 100% opaque on the left, 0% on the right"); }
И три обработчика событий, которые связывают все это воедино:
//+-------------------------------------------------------------------+ //| Expert initialization function | //+-------------------------------------------------------------------+ int OnInit() { if(InpSetLightChart) DemoUseLightChart(); //--- NOT a bare "demo01". A dynamic resource belongs to the PROGRAM, //--- not to the chart, so two copies of this EA on two charts would //--- otherwise fight over one buffer. CairoSurfaceName folds the //--- chart id and the subwindow in for us. const string surface = CairoSurfaceName("demo01"); if(!g_surface.Create(surface, UI_X, UI_Y, UI_W, UI_H)) { Print("Cairo G2D: failed to create the surface"); return INIT_FAILED; } //--- GetMicrosecondCount, never GetTickCount: the latter has a //--- 15.625 ms resolution on Windows and would report this entire //--- frame as either 0 ms or 15 ms, which tells you nothing const ulong t0 = GetMicrosecondCount(); Render(); const ulong t1 = GetMicrosecondCount(); if(InpShowCaptions) Captions(); ChartRedraw(); PrintFormat("Cairo G2D Part 1: %d x %d = %d pixels written in %d us, " "using %d chart object for the drawing", UI_W, UI_H, UI_W * UI_H, (int)(t1 - t0), 1); return INIT_SUCCEEDED; } //+-------------------------------------------------------------------+ //| Expert deinitialization function | //+-------------------------------------------------------------------+ void OnDeinit(const int reason) { g_surface.Destroy(); DemoLabelsClear(); ChartRedraw(); } //+-------------------------------------------------------------------+ //| Expert tick function | //+-------------------------------------------------------------------+ void OnTick() { } //+-------------------------------------------------------------------+
Для измерения времени используется GetMicrosecondCount, а не GetTickCount: в Windows второй счетчик обновляется с шагом около 15,6 миллисекунды. Поэтому при отрисовке кадра за две миллисекунды измерение покажет либо 0, либо 15 миллисекунд — ни одно из этих значений не отражает реальную длительность.
Что показывает демонстрационный пример

Рис. 11. Demo01_FirstPixels на графике с цветовой схемой Black On White — области A-E.
На рисунке 11 показан общий вид: демонстрационный пример действительно работает в MetaTrader на обычном графике, а панель отображается поверх свечей. При таком размере качество границ не оценить, да это и не требуется.

Рис. 12. Треугольник и круг крупным планом — каждый пиксель либо закрашен полностью, либо не закрашен вовсе.
На рисунке 12 показан результат, который мы хотим улучшить в этой серии статей. Сравните рисунок 12 с рисунком 6: его левая половина показывает тот же результат, но в виде схемы, а не изображения, полученного программно. Затем сравните с рисунком 9 — именно к такому результату мы стремимся. Во-первых, границы ступенчатые: проверка "да/нет" не дает промежуточных значений, поэтому их нет и на изображении. В четвертой части мы заменим эту проверку измерением покрытия пикселя.
Во-вторых — и это главный вывод об архитектуре — у этих двух функций нет общего кода. Ни одной строчки. У функций TriangleHardEdge и CircleHardEdge разные циклы, границы перебора и проверки. Ни одну из них нельзя использовать для рисования другой фигуры. Для прямоугольника со скругленными углами понадобилась бы третья функция, для эллипса — четвертая, и у каждой были бы свои ошибки на границах. Именно этой ловушки позволяет избежать модель Cairo. В третьей части мы заменим все эти функции одной, которая заливает область, заданную любой обводкой.
Полоса образцов в области E показывает еще одну особенность: у каждого следующего образца значение альфа-канала меньше, и сквозь него просвечивает шахматный фон. Объект OBJ_RECTANGLE_LABEL не позволяет получить такой результат ни при каких настройках, поскольку свойство OBJPROP_BGCOLOR не предусматривает значения альфа-канала.
Структура проекта
Все материалы этой части находятся в приложенном архиве. Распакуйте файлы в папку MQL5/ вашего терминала, сохранив исходную структуру, и скомпилируйте файл Demo01_FirstPixels.mq5. Редактировать пути не требуется.
MQL5/
├── Include/
│ └── CairoG2D/
│ ├── Color.mqh // the ARGB color type
│ ├── Surface.mqh // pixel buffer bound to one chart object
│ └── DemoTheme.mqh // the demos' shared palette - NOT library code
└── Experts/
└── CairoG2D/
└── Article 01/
└── Demo01_FirstPixels.mq5 Во всех частях серии используется одна и та же структура: библиотека находится в Include/CairoG2D/ и расширяется по мере разработки, а для демонстрационного примера каждой части выделена отдельная папка в Experts/CairoG2D/. Пути подключения файлов не меняются от одной части к другой.
| # | Файл | Каталог | Содержимое |
|---|---|---|---|
| 1 | Color.mqh | MQL5/Include/CairoG2D | Тип цвета ARGB, функции формирования цвета, интерполяция |
| 2 | Surface.mqh | MQL5/Include/CairoG2D | CCairoSurface — вывод пиксельного буфера на график в виде растрового изображения |
| 3 | DemoTheme.mqh | MQL5/Include/CairoG2D | Палитра, настройка графика, подписи — не входит в состав библиотеки |
| 4 | Demo01_FirstPixels.mq5 | MQL5/Experts/CairoG2D/Article 01/ | Каждый пиксель задается вручную |
| 5 | Cairo Style Library - Part 01.zip | Архив со всеми приложенными файлами и структурой каталогов относительно корневой папки терминала |
Примечание: программы Preview_ThreeWays и Showcase_Dashboard приложены только для предварительного знакомства с возможностями готовой библиотеки. В первой части мы создаем не эти программы. Мы вернемся к ним, когда реализуем используемые в них возможности.
Архив ZIP: исходные коды доступны в приложенном ZIP-файле.
Дорожная карта
Ниже приведен план всей серии, чтобы было понятно место каждого компонента.
| Часть | Тема | Возможности движка после этой части |
|---|---|---|
| 1 | Зачем нужен собственный рендерер, модель Cairo, цвет и поверхность | Выводить на график пиксели, заданные вручную |
| 2 | Точки, контуры и путь | Описывать геометрию: MoveTo, LineTo, Close, контуры |
| 3 | Ребра и первая залитая фигура | Заливать область внутри любой обводки одной функцией — сплошным цветом, со ступенчатыми границами |
| 4 | Сглаживание — покрытие вместо проверки "да/нет" | Сглаживать границы под любым углом, выполнять альфа-смешивание |
| 5 | Правила заливки — отверстия, кольца и рамки | Строить полноценные рамки, кольца и обводки с помощью заливки |
| 6 | Кривые | CurveTo, адаптивное спрямление, QuadTo |
| 7 | Дуги, окружности и прямоугольники со скругленными углами | Фигуры, из которых на практике состоит интерфейс |
| 8 | Источник цвета — от одноцветной заливки до градиентов | Линейные и радиальные градиенты на любой фигуре |
| 9 | Производительность | Отсечение, таблица активных ребер, тесты производительности |
| 10 | Набор инструментов для создания виджетов | Панели, кнопки, таблицы, значки — полноценный дашборд |
Заключение и планы на вторую часть
Результат этой части — работающая основа с возможностью повторного использования, которую можно сразу запустить и расширять.
- Единый формат представления цвета внутри библиотеки — 0xAARRGGBB (альфа-канал, красный, зеленый, синий), а также вспомогательные функции для преобразования, изменения альфа-канала и интерполяции (Color.mqh). Именно этот контракт графический движок соблюдает на всех этапах работы.
- Абстракция Surface, которая управляет пиксельным буфером ARGB, динамическим ресурсом в памяти и ровно одним объектом OBJ_BITMAP_LABEL на графике (Surface.mqh). Поверхность передает содержимое буфера в терминал один раз за кадр, поэтому терминалу достаточно одного объекта независимо от того, сколько вы рисуете.
- Готовый к запуску демонстрационный пример (Demo01_FirstPixels), в котором каждый пиксель задается вручную. Он подтверждает сохранение альфа-канала и показывает ступенчатые границы, устранению которых посвящены дальнейшие части серии.
На практике это означает, что теперь вы управляете каждым пикселем и значением его альфа-канала на реальном графике, используя только код MQL5. Пока рендерер просто перезаписывает пиксели — сглаживание, альфа-смешивание и универсальный механизм заливки фигур мы намеренно реализуем на следующих этапах. Во второй части мы создадим первый ключевой компонент движка — путь. Мы определим точки (с координатами двойной точности), реализуем MoveTo()/LineTo()/Close(), обеспечим поддержку нескольких контуров в одном пути и добавим простые вспомогательные функции для построения фигур. Так вы сможете увидеть их геометрию еще до того, как в третьей части мы реализуем заливку.
Проект поддерживается в Algo Forge. Для каждой части серии подготовлен отдельный релиз с кодом, зафиксированным именно в том виде, который разбирается в этой статье. Поэтому скачивайте релиз, соответствующий той статье, которую читаете.
Часть 1 находится здесь: https://forge.mql5.io/SandroBegashvil/CairoG2D/releases/tag/part-01
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/23818
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Как обнаруживать и нормализовать объекты графика в MQL5 (часть 1): Создание движка обнаружения объектов графика
Автоматизация графических паттернов в MQL5 (Часть 1): Мультитаймфреймовый движок для анализа свинговой структуры
Автоматизация графических паттернов в MQL5 (Часть 2): Паттерны "двойная вершина" и "двойное дно"
Нейросети в трейдинге: Диффузионная генерация торговых планов (Планировщик)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования