Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 3): Ребра и первая фигура с заливкой
Содержание
- Введение
- Что на самом деле представляет собой сканирующее преобразование
- Ребро — отрезок, подготовленный для поиска пересечений
- Число обхода
- Растеризатор
- Демонстрационный пример
- Структура проекта
- Заключение и дальнейшие планы
Введение
Во второй части мы создали слой геометрии. Мы можем описать прямоугольник, звезду, глиф шрифта или произвольную фигуру с 2 000 вершин. Все эти фигуры представлены одинаково: точками с координатами типа double, сгруппированными в контуры и хранящимися в плоском массиве. Но мы не могли нарисовать ничего из этого. В демонстрационном примере отображались вершины и каркасное представление фигур — ничего большего библиотека пока не умела.
Сегодня фигуры получат заливку. Для этого достаточно одной функции, которая ничего не знает ни о прямоугольниках, ни о звездах. Передайте ей путь и цвет — и она зальет путь. Вот и весь публичный интерфейс растеризатора:
g_ras.Fill(path, color, buffer, width, height);
Каждая фигура в демонстрационном примере этой части рисуется именно этим вызовом и никаким другим. Только в первом ряду расположены шесть фигур; кроме них, в примере есть глиф, веер из тонких треугольников и цветок с 2 000 ребрами. Сравните с первой частью, где для треугольника и круга требовались две отдельные процедуры без единой общей строчки кода.
Для этого нужны две составляющие. Первая — это ребро: отрезок, представленный в форме, позволяющей ответить на единственный вопрос, который задает построчный рендерер. Вторая — это сам построчный проход. В ходе этого прохода буфер обрабатывается строка за строкой: находятся точки пересечения с обводкой, а затем по числу обхода определяются интервалы внутри фигуры.
Обе эти идеи появились раньше, чем большинство программ, которыми вы пользуетесь. Построчная заливка появилась еще в 1960-х годах, и с тех пор заменять этот метод не понадобилось. В следующих частях сглаживание, правила заливки, отсечение и таблица активных ребер усовершенствуют четыре шага, описанные сегодня, но не заменят их.
У этой версии три намеренных ограничения, каждое из которых еще раз отмечено в соответствующем месте кода:
- для каждой строки берется только одна точка выборки, поэтому любое ребро, не являющееся строго горизонтальным или вертикальным, имеет ступенчатый вид;
- пиксели перезаписываются без смешивания цветов, поэтому полупрозрачный цвет не смешивается с уже имеющимся под ним изображением;
- реализовано только ненулевое правило заливки, поэтому буква O заливается целиком, если направление обхода внутреннего контура не противоположно направлению обхода внешнего.
Это не упрощения, за которые нужно оправдываться. За каждым ограничением стоит отдельная идея. Если смешать все эти идеи в одной статье, растеризатор и будет казаться магией, а не механизмом.
Вот как выглядит библиотека по итогам сегодняшней работы:
Color.mqh Часть 1 тип цвета ARGB, uint 0xAARRGGBB
Surface.mqh Часть 1 пиксельный буфер, связанный с одним графическим объектом
Path.mqh Часть 2 точки, контуры, MoveTo / LineTo / Close
и флаг замыкания для каждого контура
Часть 3 + SCairoEdge, CCairoEdgeList, BuildEdges <- сегодня
Raster.mqh Часть 3 построчная заливка <- сегодня
Что на самом деле представляет собой сканирующее преобразование
Путь — это математическая обводка с бесконечной точностью. Буфер представляет собой сетку из маленьких квадратиков, и каждый квадратик может содержать ровно один цвет. Сканирующее преобразование — это определение цвета каждого квадратика по обводке, не привязанной к сетке.
В такой формулировке кажется, что к решению задачи невозможно подступиться. Обводка может иметь совершенно любую форму, а сетка состоит из сотен тысяч ячеек. Чтобы справиться с задачей, нужно перестать рассматривать фигуру целиком и перейти к рассмотрению одной горизонтальной линии за другой.
Проведите горизонтальную линию через середину одной строки пикселей. Эта линия либо вообще не пересекает фигуру, либо входит в фигуру и выходит из нее. Это может происходить несколько раз, если фигура вогнутая или имеет более одного контура. Между точками входа и выхода каждый пиксель на этой линии находится внутри фигуры. Таким образом, двумерный вопрос "какие пиксели находятся внутри этой фигуры?" превращается в одномерный вопрос, задаваемый один раз для каждой строки: где эта линия входит в фигуру и выходит из нее?
На этот вопрос есть точный ответ, а его вычисление требует мало ресурсов. Линия пересекает отрезок не более одного раза, а для вычисления точки пересечения нужны одно вычитание, одно умножение и одно сложение. Проделайте это для каждого отрезка, отсортируйте результаты — и задача для этой строки решена.
Весь алгоритм для каждой строки:
1. провести горизонтальную линию через середину строки; 2. найти все ребра, которые пересекает эта линия, и координаты x пересечений; 3. отсортировать точки пересечения слева направо; 4. пройти по точкам пересечения, отслеживая число обхода, и закрасить интервалы внутри фигуры.
Четыре шага. Далее в статье подробно разобраны эти четыре шага и один подготовительный этап, который ускоряет шаг 2.
Ребро — отрезок, подготовленный для поиска пересечений
Шаг 2 выполняется один раз для каждого ребра в каждой строке. Для фигуры высотой 400 пикселей с 200 отрезками эта операция выполняется 80 000 раз за одну заливку. Поэтому способ представления отрезка при хранении важнее всего остального в этой статье.
Путь хранит отрезок в том виде, в каком он был задан: от вершины, где находилось перо, до вершины, к которой оно переместилось. Такое представление подходит для построения геометрии, но не для запросов к ней. Поэтому перед заливкой каждый отрезок один раз преобразуется в ребро. Это тот же отрезок, но представленный так, чтобы сразу ответить на вопрос "при каком x он пересекает горизонтальную линию на уровне y?".
//+-------------------------------------------------------------------+ //| SCairoEdge - one segment, pre-processed for scanline queries. | //+-------------------------------------------------------------------+ struct SCairoEdge { double ytop; // smaller y double ybot; // larger y double xtop; // x at ytop double dxdy; // slope: how far x moves per unit of y int dir; // +1 the original segment ran downward, -1 upward //--- x where this edge crosses the horizontal line at y. //--- One multiply and one add - which is the whole reason the //--- struct is laid out like this. double XAt(const double y) const { return xtop + (y - ytop) * dxdy; } };
Именно ради метода XAt() структура устроена таким образом. Одно вычитание, одно умножение, одно сложение и никаких ветвлений. Деление для вычисления dxdy выполняется один раз, при построении ребра, а не для каждой строки.
Два решения в этих шести строчках кода заслуживают более подробного разбора. Оба решения кажутся произвольными, но каждое из них обоснованно.
Каждое ребро хранится сверху вниз. Независимо от направления исходного отрезка меньшее значение y сохраняется в поле ytop ребра. Поэтому проверка "пересекает ли линия на уровне y это ребро?" всегда сводится к условию y >= ytop && y < ybot. Сначала определять, какой конец ребра верхний, а какой нижний, не нужно. Чтобы убрать из внутреннего цикла рендерера ветвление, выполняемое 80 000 раз для каждой фигуры, стоит немного позаботиться о представлении ребра при его построении.
Направление не отбрасывается. При развороте отрезка теряется важная информация: в каком направлении обводка проходилась в этом месте — вниз или вверх. Этот бит информации сохраняется в поле dir, и это не просто служебные данные. Это вклад данного ребра в число обхода. Без него мы все равно могли бы заливать простые фигуры по правилу четности. С ним мы получаем ненулевое правило заливки: самоперекрывающиеся области объединяются, а наличие отверстий определяется направлением обхода.

Рис. 1. Один отрезок — в представлении пути и в списке ребер
На рисунке 1 показан отрезок, направленный вверх по экрану. Перо находилось ниже и переместилось выше, поэтому y0 > y1 . Слева показано то, что хранит Path.mqh. Справа показано ребро. Геометрия не изменилась: те же две конечные точки и тот же отрезок. Но стрелка развернута так, чтобы ytop действительно соответствовал верхнему концу. В поле dir записано значение -1, чтобы отметить этот разворот. Красная линия — это линия сканирования, а точка на ней соответствует координате x, которую возвращает метод XAt().
Построение ребра//+-------------------------------------------------------------------+ //| Turn one segment into one edge. | //+-------------------------------------------------------------------+ void CCairoEdgeList::Add(const double x0, const double y0, const double x1, const double y1) { //--- a horizontal segment is crossed by no scanline, so it can //--- never contribute a crossing. Dropping it here keeps the //--- rasterizer's inner loop free of a special case. if(y0 == y1) return; const int i = m_count; //--- grow in blocks of 64, not one slot at a time. A refused //--- resize drops the edge instead of writing past the end. if(ArraySize(m_edges) < m_count + 1) if(ArrayResize(m_edges, m_count + 1, 64) < m_count + 1) return; m_count++; if(y0 < y1) { //--- the segment runs downward in screen space m_edges[i].dir = 1; m_edges[i].ytop = y0; m_edges[i].ybot = y1; m_edges[i].xtop = x0; m_edges[i].dxdy = (x1 - x0) / (y1 - y0); } else { //--- the segment runs upward: store it flipped, and REMEMBER //--- the flip in dir - that memory is the winding number m_edges[i].dir = -1; m_edges[i].ytop = y1; m_edges[i].ybot = y0; m_edges[i].xtop = x1; m_edges[i].dxdy = (x0 - x1) / (y0 - y1); } }
Ранний выход при обработке горизонтального отрезка окупается с лихвой. Горизонтальный отрезок параллелен всем линиям сканирования, поэтому ни одна из них не пересекает его и он не добавляет пересечений. Если отбросить его на этом этапе, во внутреннем цикле его уже не придется учитывать. Что еще важнее, это исключает деление на ноль при вычислении dxdy. Одна и та же строчка кода исключает горизонтальные отрезки и защищает от деления на ноль.
Эти две ветви зеркальны. Обратите внимание: значение dxdy в обеих ветвях одинаково. Выражения (x1 - x0) / (y1 - y0) и (x0 - x1) / (y0 - y1) дают одно и то же число, поскольку знаки меняются и в числителе, и в знаменателе. Отличается только начальная точка, поскольку в xtop должна храниться координата x того конца, который теперь находится сверху.
Резервирование памяти на 64 элемента — тот же прием, который во второй части применялся к массиву точек, по той же причине и с тем же выигрышем. Для пути из 2 000 отрезков память выделяется около тридцати раз вместо 2 000.
Значение, возвращаемое функцией ArrayResize() проверяется по той же причине, что и во второй части. Если память не выделена, а выполнение кода продолжается, на следующей строчке выполняется попытка записи за пределы массива, и среда выполнения языка MQL5 останавливает программу с ошибкой выхода за пределы массива. Здесь же ребро не добавляется. В этом случае фигура может быть залита не полностью, но это меньшая проблема, чем остановка программы.
Список ребер//+-------------------------------------------------------------------+ //| CCairoEdgeList - a growable array of edges, reused between fills. | //+-------------------------------------------------------------------+ class CCairoEdgeList { public: SCairoEdge m_edges[]; int m_count; CCairoEdgeList(); void Clear(); void Reserve(const int capacity); void Add(const double x0, const double y0, const double x1, const double y1); }; //+-------------------------------------------------------------------+ //| Constructor | //+-------------------------------------------------------------------+ CCairoEdgeList::CCairoEdgeList() { m_count = 0; ArrayResize(m_edges, 0); } //+-------------------------------------------------------------------+ //| Reset the count. The allocation is kept on purpose. | //+-------------------------------------------------------------------+ void CCairoEdgeList::Clear() { m_count = 0; } //+-------------------------------------------------------------------+ //| Ask for room for capacity edges before the first one arrives. | //+-------------------------------------------------------------------+ void CCairoEdgeList::Reserve(const int capacity) { if(capacity > ArraySize(m_edges)) ArrayResize(m_edges, capacity); }
Члены класса открыты, а сам класс намеренно сделан простым. Это буфер, по которому проходит растеризатор, а не абстракция, диктующая свои правила. Как мы выяснили в первой части, в языке MQL5 нет типа "указатель на массив", поэтому метод доступа, возвращающий указатель на m_edges, невозможно написать, даже если бы мы этого захотели.
Метод Clear() сбрасывает счетчик и не затрагивает массив. Благодаря этому приему повторные заливки требуют мало ресурсов. После обработки нескольких фигур объем выделенной памяти достигает максимума и больше не растет. Если бы метод Clear() еще и освобождал память, при каждой следующей заливке ее пришлось бы выделять заново.
Здесь, как и в самом пути, различаются число записанных элементов и емкость массива. Значение m_count — это число записанных ребер. ArraySize(m_edges) — это емкость массива; она всегда не меньше числа записанных ребер. При обходе списка нельзя использовать ArraySize() как число записанных ребер.
Метод Reserve() предусмотрен здесь по той же причине, что и ReservePoints() в классе пути. Вызывающий код часто знает итоговое число ребер еще до добавления первого из них. Однократное выделение памяти нужного размера обходится дешевле, чем несколько последовательных выделений с увеличением объема. Неудачная попытка изменить размер здесь не нарушает работу: метод Add() все равно проверяет размер массива при каждом добавлении, поэтому список просто растет медленнее.
Где создается замыкающий отрезок
Метод Close() из второй части помечает контур как замкнутый. Он не добавляет вершину и никогда этого не делал: замыкающий отрезок не хранится в массиве точек пути. Вот функция, которая его формирует.
//+-------------------------------------------------------------------+ //| BuildEdges - turn every contour into edges. | //+-------------------------------------------------------------------+ void CCairoPath::BuildEdges(CCairoEdgeList &out) { out.Clear(); //--- a path that was refused memory is not geometry we can trust, //--- and half a shape on screen is worse than no shape at all if(!IsValid()) return; //--- each contour emits exactly as many edges as it has vertices, //--- so the total is known before the loop starts out.Reserve(m_point_count); for(int c = 0; c < m_contour_count; c++) { const int start = m_starts[c]; const int count = ContourLength(c); //--- one vertex encloses no area, so it produces no edges if(count < 2) continue; for(int i = 0; i < count; i++) { const int a = start + i; const int b = start + ((i + 1) % count); // wrap = auto-close out.Add(m_points[a].x, m_points[a].y, m_points[b].x, m_points[b].y); } } }
Все "замыкание" сводится к ( i + 1 ) % count. Для последней вершины контура это выражение дает индекс первой, и так формируется отрезок, который нигде не хранился. Именно поэтому в цикле используется условие i < count, а не i < count - 1. Прямоугольник с четырьмя вершинами образует четыре ребра, а не три.
Теперь прочитайте цикл еще раз и обратите внимание на то, что он не проверяет. Цикл вообще не проверяет флаг замыкания. Здесь замыкаются все контуры, в том числе и те, которые автор оставил открытыми. Это правильное поведение, а не недосмотр. Для заливки нужна граница без разрывов, потому что у незамкнутой обводки нет внутренней области. Поэтому библиотека заливает открытый контур так, как если бы он был замкнут. Вместо этого она могла бы отклонять открытые контуры, однако в Cairo, SVG и HTML Canvas принято неявное замыкание.
Таким образом, путь и заливка дают два разных ответа об одном и том же контуре, и оба верны. Флаг отражает то, что задал автор. В одной из следующих частей код построения обводки будет проверять его, чтобы выбрать между соединением отрезков и двумя концевыми окончаниями. Этот цикл определяет, что должна делать заливка. В демонстрационном примере одновременно показано и то и другое. Безымянная клякса в ряду A создана методом AddPolyline(). В пути она отмечена как открытый контур, но все равно заливается целиком.
Есть еще один важный момент: на каком этапе нужно сохранить флаг. После завершения BuildEdges() код следующих этапов не может отличить открытые контуры от замкнутых: в ребрах эта информация не хранится. Именно поэтому флаг нужно сохранять еще на этапе построения пути, реализованном во второй части. Впоследствии его невозможно восстановить, проверив, совпадают ли первая и последняя вершины.
В начале функции выполняются две короткие защитные проверки. Проверка IsValid() не позволяет строить ребра по пути, при построении которого не удалось выделить память: неполная обводка даст искаженную фигуру вместо отсутствия заливки. Затем метод Reserve() одним вызовом запрашивает память для всего массива ребер. Каждый контур дает не больше ребер, чем в нем вершин, поэтому еще до начала цикла значение m_point_count можно использовать как гарантированную верхнюю границу числа ребер.
Обратите также внимание: эта функция — последний этап пайплайна, на котором еще доступна информация о контурах. Она обходит плоский массив точек, используя индексы начала контуров из второй части, и формирует единый список ребер без разделения на контуры. С этого момента растеризатор не может определить, сколько контуров было у пути, в каком порядке они были записаны и какая вершина к какому контуру относилась. В этом нет необходимости. Вся необходимая информация о структуре фигуры уже закодирована в поле dir отдельных ребер.
Число обхода
На шаге 4 определяется, какие интервалы линии сканирования находятся внутри фигуры.
После сортировки пересечений для строки мы получаем список координат x, в которых линия сканирования пересекает обводку, в порядке слева направо. Очевидное правило — чередовать состояния: снаружи, внутри, снаружи, внутри. Заливайте каждый второй интервал. Для простой фигуры это отлично работает, и именно так действует правило четности.
Этот подход перестает работать, как только фигура перекрывает саму себя.
Представьте два квадрата в одном пути, смещенные так, что они перекрываются посередине. Линия сканирования, проходящая через область перекрытия, пересекает четыре ребра. При чередовании первый интервал считается внутренним, второй (область перекрытия) — внешним, а третий — снова внутренним. В результате в области перекрытия двух фигур появляется отверстие, хотя обе предполагалось залить целиком. Любой, кто рисовал толстую линию в виде цепочки перекрывающихся четырехугольников, видел именно это: прозрачную выемку на каждом соединении.
Число обхода решает эту проблему за счет подсчета, а не чередования. Начните снаружи фигуры, установив счетчик в ноль. При каждом пересечении прибавляйте значение dir ребра: +1, если исходный отрезок был направлен вниз, и -1, если вверх. Интервал после точки пересечения находится внутри фигуры, если значение счетчика не равно нулю.
Проследим этот процесс на двух перекрывающихся квадратах. Оба квадрата обходятся в одном направлении. При входе в первый квадрат к счетчику прибавляется +1, значит, мы находимся внутри. При входе во второй квадрат, пока мы еще внутри первого, прибавляется еще +1, и получается 2. Это не ноль, поэтому мы по-прежнему находимся внутри, и область перекрытия заливается. При выходе из первого квадрата вычитается 1: значение счетчика снова равно 1, и мы все еще внутри. При выходе из второго квадрата значение счетчика возвращается к 0: мы снаружи. Два квадрата сливаются в одну сплошную область без шва, чего и ожидает любой, кто их рисует.
Теперь задайте обход второго квадрата в противоположном направлении. Теперь при входе в него прибавляется -1 вместо +1, поэтому в области перекрытия значение счетчика равно +1 -1 = 0. Это означает "снаружи", и отверстие появляется именно там, где эти две фигуры пересекаются.

Рис. 2. Те же два квадрата — при одинаковом направлении обхода они сливаются, при противоположном — их вклады в области перекрытия взаимно компенсируются
Рисунок 2 передает всю идею в одном изображении. Обе половины рисунка получены применением самого ненулевого правила заливки, а не ручной отрисовкой ожидаемого результата. Стрелки на каждой обводке показывают направление ее обхода. Слева оба квадрата обходятся по часовой стрелке, а область перекрытия сплошь залита. Справа второй квадрат обходится в противоположном направлении, а область перекрытия полностью вырезана. Геометрия одинакова, вызов заливки одинаков, отличается лишь порядок записи вершин.
Именно это имелось в виду во второй части: единый порядок обхода вершин во вспомогательных функциях построения фигур — соглашение, которое стоит соблюдать. При ненулевом правиле заливки важно относительное направление обхода двух контуров: контур отверстия нужно обходить в направлении, противоположном направлению обхода охватывающей его обводки. Библиотека никогда не меняет направление обхода, поэтому направление контуров относительно друг друга остается под контролем вызывающего кода. Кроме того, этот механизм намеренно используется для создания колец, рамок и букв с внутрибуквенными просветами.
Растеризатор
Файл Raster.mqh содержит класс CCairoRasterizer, основной метод которого — Fill().
//+-------------------------------------------------------------------+ //| CCairoRasterizer | //+-------------------------------------------------------------------+ class CCairoRasterizer { private: CCairoEdgeList m_edges; // edges of the path being filled double m_cross_x[]; // where this scanline crosses each edge int m_cross_dir[]; // and in which direction bool EnsureCapacity(const int edge_count); void SortCrossings(const int n); public: CCairoRasterizer(); void Fill(CCairoPath &path, const uint argb, uint &buffer[], const int width, const int height); };
Класс хранит рабочие массивы данных о точках пересечениях с линией сканирования и использует их повторно. Их емкость увеличивается по мере необходимости и сохраняется между заливками, что позволяет избежать повторных выделений памяти при повторном использовании одного и того же экземпляра растеризатора.
//+-------------------------------------------------------------------+ //| Grow the scratch arrays if needed. They are never shrunk. | //+-------------------------------------------------------------------+ bool CCairoRasterizer::EnsureCapacity(const int edge_count) { if(ArraySize(m_cross_x) >= edge_count) return true; if(ArrayResize(m_cross_x, edge_count) < edge_count) return false; if(ArrayResize(m_cross_dir, edge_count) < edge_count) return false; return true; }
Задать размер массивов по числу ребер — простое и достаточное решение. Одна горизонтальная линия не может пересекать больше ребер, чем содержит путь.
Проверяются результаты обеих операций изменения размера. Продолжение работы после неудачного выделения памяти привело бы к остановке программы с ошибкой выхода за пределы массива. Обратите внимание: предварительная проверка учитывает только размер m_cross_x, то есть предполагает, что размеры обоих массивов всегда увеличиваются одновременно.
Сортировка пересечений//+-------------------------------------------------------------------+ //| Sort this scanline's crossings from left to right. | //+-------------------------------------------------------------------+ void CCairoRasterizer::SortCrossings(const int n) { for(int i = 1; i < n; i++) { const double key_x = m_cross_x[i]; const int key_dir = m_cross_dir[i]; int j = i - 1; while(j >= 0 && m_cross_x[j] > key_x) { m_cross_x[j + 1] = m_cross_x[j]; m_cross_dir[j + 1] = m_cross_dir[j]; j--; } m_cross_x[j + 1] = key_x; m_cross_dir[j + 1] = key_dir; } }
Здесь подходит сортировка вставками, поскольку число пересечений на одной линии сканирования обычно невелико.
Значение n — это не число ребер в пути. Это число ребер, пересекающих одну горизонтальную линию. То есть две точки пересечения для треугольника, две — для круга, четыре — для кольца или шеврона и редко больше дюжины даже для детализированной иконки. При таком числе элементов сортировка вставками обычно быстрее универсальной сортировки: она не требует предварительной подготовки, рекурсии и обращений к памяти за пределами самого массива. Быстрая сортировка восьми элементов тратит больше времени на выбор действий, чем на сами действия.
У сортировки вставками есть еще одно преимущество, которое в этой версии пока не используется. На соседних линиях сканирования порядок пересечений почти одинаков. Точки пересечения немного смещаются по горизонтали от строки к строке, но редко меняются местами. Здесь точки пересечения заново собираются для каждой строки в порядке следования ребер в списке, поэтому отсортированный порядок предыдущей строки не используется повторно. Таблица активных ребер в девятой части сохраняет этот порядок, а время работы сортировки вставками на почти отсортированных данных близко к линейному.
Важное условие: x и dir должны перемещаться вместе. Это две части одной записи, разнесенные по двум массивам ради скорости. Перестановка одного значения без другого приведет к неверному расчету числа обхода без сообщения об ошибке. В результате фигура заливается правильно на большинстве строк, но на некоторых заливка инвертируется.
Заливка, шаг за шагом
Сначала сигнатура:
//+-------------------------------------------------------------------+ //| Fill `path` with the color `argb` into an ARGB buffer. | //+-------------------------------------------------------------------+ void CCairoRasterizer::Fill(CCairoPath &path, const uint argb, uint &buffer[], const int width, const int height) {
Буфер содержит width * height элементов типа uint в формате 0xAARRGGBB, расположенных построчно. Именно такой буфер хранит объект CCairoSurface и принимает функция ResourceCreate, поэтому преобразование нигде в пайплайне не требуется. Метод Fill() не проверяет размер буфера, поэтому вызывающий код должен передать буфер, содержащий не менее width * height элементов. Путь передается по ссылке, поскольку иначе в языке MQL5 он был бы скопирован.
На шаге 0 контуры преобразуются в ребра:
//--- STEP 0 - turn contours into edges. path.BuildEdges(m_edges); const int edge_count = m_edges.m_count; if(edge_count == 0) return; if(!EnsureCapacity(edge_count)) return; // no scratch room - draw nothing
Код после этой строчки уже не знает, какую именно фигуру рисует. В этом одном предложении — вся архитектурная идея библиотеки.
На шаге 1 определяется, какие строки может затронуть фигура:
//--- STEP 1 - which rows can this shape possibly touch? double ymin = 1e18; double ymax = -1e18; for(int i = 0; i < edge_count; i++) { if(m_edges.m_edges[i].ytop < ymin) ymin = m_edges.m_edges[i].ytop; if(m_edges.m_edges[i].ybot > ymax) ymax = m_edges.m_edges[i].ybot; } int iy0 = (int)MathFloor(ymin); int iy1 = (int)MathCeil(ymax); if(iy0 < 0) iy0 = 0; if(iy1 > height) iy1 = height; //--- entirely above or below the buffer if(iy1 <= iy0) return;
Это наименее затратная оптимизация в движке и одна из самых важных. Без нее при заливке значка высотой 16 пикселей на панели высотой 800 пикселей пришлось бы пройти все 800 строк и проверить каждое ребро в каждой из них. Пришлось бы обработать 784 лишние строки лишь для того, чтобы выяснить, что в них нечего рисовать. С этой оптимизацией затраты на обработку фигуры зависят от числа охватываемых ею строк, умноженного на число ее ребер, а не от размера буфера.
Именно благодаря этому панель с множеством небольших виджетов становится практически применимой. Для панели с шестьюдесятью небольшими элементами выполняются шестьдесят небольших объемов работы, а не шестьдесят проходов по всему буферу. Здесь также впервые появляется идея, которую мы подробно разовьем в девятой части: не обрабатывайте пиксель, если можно доказать, что в этом нет необходимости.
Ограничение диапазона строк интервалом [0, height) — единственная проверка границ, необходимая циклу по строкам. Если выполнить это здесь один раз, внутренний цикл сможет записывать в буфер, вообще не проверяя y.
На шаге 2 выполняется проход по строкам:
//--- STEP 2 - walk the rows. for(int iy = iy0; iy < iy1; iy++) { //--- Sample at the CENTER of the row, never at its edge. const double yc = iy + 0.5;
Берите выборку в центре строки, а не на ее границе. Если граница фигуры точно совпадает с границей пикселя и выборка берется там же, возникает неоднозначность. При малейшем смещении фигуры результат проверки меняется между "покрыто" и "не покрыто", что на анимированной панели проявляется как мигание строки. Если сместить выборку на полпикселя вниз, от целочисленных координат, на которых обычно задается геометрия, такие случаи становятся редкими. Оставшиеся разрешает приведенная ниже проверка принадлежности полуоткрытому интервалу. Это то же прибавление + 0.5, которое использовалось в первой части при отрисовке треугольника вручную, и по той же причине.
На шаге 3 находятся точки пересечения:
//--- STEP 3 - find every edge this line crosses. int n = 0; for(int i = 0; i < edge_count; i++) { if(yc >= m_edges.m_edges[i].ytop && yc < m_edges.m_edges[i].ybot) { m_cross_x[n] = m_edges.m_edges[i].XAt(yc); m_cross_dir[n] = m_edges.m_edges[i].dir; n++; } } //--- fewer than two crossings can never enclose an interior if(n < 2) continue; SortCrossings(n);
Проверяется принадлежность полуоткрытому интервалу [ytop, ybot). Эта асимметрия — не мелочь, которую можно пропустить. Это самый распространенный источник ошибок в растеризаторе, написанном вручную.
Два ребра контура сходятся в общей вершине. Если бы в проверяемый интервал включались оба его конца, линия сканирования, проходящая точно через эту вершину, учитывала бы оба ребра и считала пересечение дважды. Тогда на всей оставшейся части строки число обхода было бы неверным — со сдвигом на единицу в неправильную сторону. В результате получается отверстие размером в один пиксель или тонкая яркая полоса, уходящая за край фигуры. Там, где обводка проходит через вершину, полуоткрытый интервал учитывает такое пересечение ровно один раз. Ребро, заканчивающееся в этой точке, не проходит проверку < ybot, а начинающееся в ней ребро проходит проверку >= ytop. В локальной верхней вершине учитываются оба ребра, а в локальной нижней — ни одно. В обоих случаях их суммарный вклад равен нулю, и число обхода остается правильным.
Из-за особенностей алгоритма эта ошибка возникает редко, что и делает ее опасной. Она возникает только тогда, когда вершина попадает точно в центр строки, поэтому при беглом тестировании эту ошибку можно не заметить. Позже она может проявиться на конкретной фигуре в определенном положении.
На шаге 4 выполняется проход слева направо:
//--- STEP 4 - sweep left to right, tracking the WINDING NUMBER. int winding = 0; for(int k = 0; k < n - 1; k++) { winding += m_cross_dir[k]; if(winding == 0) continue; // this stretch is outside //--- paint from crossing k to crossing k+1 //--- Rounding to whole pixels is what makes this version //--- jagged: the span really begins at, say, x = 10.3, and //--- we are pretending it begins at 10. Future parts stops //--- pretending. int xa = (int)MathRound(m_cross_x[k]); int xb = (int)MathRound(m_cross_x[k + 1]); if(xa < 0) xa = 0; if(xb > width) xb = width; const int row_base = iy * width; for(int x = xa; x < xb; x++) buffer[row_base + x] = argb; } } }
Цикл выполняется до n - 1, поскольку он обрабатывает интервал между пересечением k и пересечением k + 1. После последней точки пересечения обрабатываемого интервала нет.

Рис. 3. Одна строка через шеврон — четыре точки пересечения, а ниже — график изменения числа обхода
На рисунке 3 этот цикл показан графически. Здесь выбран шеврон, потому что это вогнутая фигура. В пределах одной строки мы оказываемся внутри фигуры, затем снаружи, а затем снова внутри. У четырех пересечений указаны соответствующие значения dir, а ступенчатый график ниже показывает, как меняется число обхода при движении вправо. На двух заштрихованных интервалах число обхода не равно нулю — именно они и закрашиваются.
Две строчки кода на этом шаге заслуживают пояснения.
Именно округление концов интервала функцией MathRound приводит к появлению "ступеней" в этой версии. На самом деле интервал начинается, скажем, при x = 10,3, а мы считаем, что он начинается при x = 10. Дробные части координат, которые мы так тщательно сохраняли во второй части, отбрасываются именно здесь, в самый последний момент. В следующих частях мы перестанем округлять эти координаты, и одно это изменение устранит всю ступенчатость в демонстрационном примере.
Присваивание buffer[row_base + x] = argb перезаписывает пиксель. Прежнее содержимое пикселя теряется независимо от того, насколько прозрачен записываемый цвет. Для смешивания цветов нужен тот же механизм, что и для сглаживания: в обоих случаях вычисления используют дробный весовой коэффициент. Поэтому обе возможности появятся вместе в следующих частях.
Демонстрационный пример
В Demo03_FirstFill.mq5 все фигуры рисуются с помощью одной вспомогательной функции — в этом и заключается суть:
//+-------------------------------------------------------------------+ //| One call for every shape in this demo. | //+-------------------------------------------------------------------+ void Fill(CCairoPath &p, const uint argb) { if(!p.IsValid()) { Print("Cairo5: path incomplete - out of memory while building it"); return; } g_ras.Fill(p, argb, g_surface.m_px, g_surface.Width(), g_surface.Height()); }
Обратите внимание: в списке аргументов нет никаких указаний на тип фигуры. Для рисования каждой из приведенных ниже фигур вызывается только эта функция.
Проверяется только флаг состояния пути из второй части. При отказе в выделении памяти путь сохраняет признак ошибки, а не пытается записать данные за пределами массива. Поэтому вызывающий код может один раз, в конце построения, проверить, все ли запрошенные данные были добавлены. Здесь для этого требуется одно сравнение на фигуру. В результате фигура либо отображается правильно, либо не отображается вовсе. Неполная фигура — худший из двух исходов: это выглядит как ошибка отрисовки, а не как проблема с памятью.
Ряд A — шесть фигур, одна функция. Прямоугольник, созданный методом AddRect(), треугольник — AddTriangle(), звезда с десятью вершинами — AddPolygon(), клякса с семью вершинами — AddPolyline() и многоугольник с тремястами сторонами:
//--- and a 300-sided polygon, which is what a circle really is. //--- Part 7 will compute these vertices for us; today we do it //--- by hand, and the rasterizer cannot tell the difference. const int n = 300; double cx[], cy[]; ArrayResize(cx, n); ArrayResize(cy, n); for(int i = 0; i < n; i++) { const double a = 2.0 * M_PI * i / n; cx[i] = 830 + 52 * MathCos(a); cy[i] = oy + 55 + 52 * MathSin(a); } CCairoPath circle; circle.ReservePoints(n); // the count is known: one allocation circle.AddPolygon(cx, cy, n); Fill(circle, DEMO_RED);
Последняя фигура — круг. Обратите внимание, как мало библиотека знает об этой фигуре: нет ни примитива круга, ни кода для построения дуг, ни отдельной ветви обработки в Raster.mqh. В программе есть цикл, который вычисляет координаты трехсот точек на окружности и передает их как многоугольник. Растеризатор получает триста ребер — как и для шестерни с тремястами сторонами — и выполняет заливку тем же способом. В седьмой части код сам рассчитает вершины и выберет их число. Это не затронет рендерер.
Здесь стоит выработать полезную привычку — заранее вызывать ReservePoints(). В приведенном выше цикле заранее известно число вершин — триста. Поэтому память для всех них можно выделить еще до записи первой вершины, а не увеличивать размер массива пять раз по ходу работы. В седьмой части код будет делать то же самое при вычислении вершин дуги.
Клякса включена в демонстрационный пример по той же причине, что и круг, но показывает ту же идею с другой стороны. Это безымянная фигура с семью произвольными вершинами, и она заливается так же правильно, как прямоугольник. Она также наглядно показывает то, о чем говорилось в предыдущем разделе:
//--- an arbitrary blob nobody has a name for, to make the point //--- that the rasterizer does not care. This one is built with //--- AddPolyline, so the path records it as an OPEN contour - //--- and it still fills solid, because BuildEdges joins the last //--- vertex back to the first for every contour. Fill has no //--- other option; the flag is there for the stroking in a later //--- part, which does have a choice to make. double bx[7], by[7]; bx[0] = 590; by[0] = oy + 20; bx[1] = 690; by[1] = oy; bx[2] = 730; by[2] = oy + 60; bx[3] = 690; by[3] = oy + 105; bx[4] = 640; by[4] = oy + 70; bx[5] = 615; by[5] = oy + 100; bx[6] = 575; by[6] = oy + 65; CCairoPath blob; blob.AddPolyline(bx, by, 7); PrintFormat("the blob: %d contour, closed = %s - and it fills anyway", blob.ContourCount(), blob.IsContourClosed(0) ? "true" : "false"); Fill(blob, DEMO_INK_SOFT);
Метод AddPolyline() не вызывает Close(), поэтому в пути контур отмечен как открытый, и это отражено в журнале. На экране клякса все равно залита целиком, потому что функция BuildEdges() соединила ее последнюю вершину с первой. Путь сохраняет заданный автором признак замыкания, а при заливке контур замыкается неявно.
Ряд B — эксперимент с числом обхода. Два перекрывающихся квадрата рисуются дважды; во второй раз направление обхода второго контура меняется на противоположное. Число контуров и вершин заранее известно — два и восемь соответственно. Поэтому память резервируется двумя вызовами, одним для контуров и одним для вершин:
//--- LEFT: both squares clockwise on screen CCairoPath same; same.ReserveContours(2); same.ReservePoints(8);
Затем идет второй путь, у которого второй контур обходится в противоположном направлении:
//--- note the vertex order: bottom-left, bottom-right, top-right, //--- top-left - the opposite rotation to the contour above opposite.MoveTo(420, oy + 165); opposite.LineTo(530, oy + 165); opposite.LineTo(530, oy + 55); opposite.LineTo(420, oy + 55); opposite.Close();
Это тот же пример с рисунка 2, но теперь он выполняется в терминале средствами библиотеки, а не скрипта на Python. Запустите программу и измените одну из этих четырех строчек кода, чтобы увидеть, как отверстие появляется и исчезает.
Ряд C — глиф. Буква A из второй части, теперь с заливкой. Оба контура обходятся в одном направлении, поэтому область внутрибуквенного просвета не остается незалитой. Буква превращается в сплошное пятно, хотя выглядеть так не должна. Затем в демонстрационном примере буква рисуется снова, но направление обхода контура внутрибуквенного просвета меняется на противоположное. Так получается правильная буква A.
Первый вариант — не ошибка демонстрационного примера. Он отражает реальные возможности движка на этом этапе. При использовании только ненулевого правила заливки глиф шрифта заливается целиком, если вызывающий код не задаст для контура внутрибуквенного просвета противоположное направление обхода. Правило четности позволит получать отверстия независимо от того, какой порядок обхода вершин задан в вызывающем коде.
Ряд D — ступенчатый. Веер из семи тонких треугольников с пологими наклонными сторонами:
//+-------------------------------------------------------------------+ //| D. THE STAIRCASE, MADE OBVIOUS | //+-------------------------------------------------------------------+ void RowStaircase(const double ox, const double oy) { //--- ONE path for all seven triangles. Clear() empties it without //--- giving the memory back, so the first triangle pays for the //--- allocation and the other six reuse it. This is the loop a //--- dashboard runs on every tick, in miniature. CCairoPath t; for(int i = 0; i < 7; i++) { const double a = -M_PI * 0.5 + M_PI * 0.5 * i / 6.0; t.Clear(); t.AddTriangle(ox, oy + 120, ox + 150 * MathCos(a), oy + 120 + 150 * MathSin(a), ox + 150 * MathCos(a + 0.09), oy + 120 + 150 * MathSin(a + 0.09)); Fill(t, CairoLerp(DEMO_BLUE, DEMO_AMBER, i / 6.0)); } //--- the shape is finished with, so hand the memory back t.Release(); }
Один и тот же путь строится семь раз, а не семь разных путей по одному разу. Метод Clear() очищает путь, но сохраняет выделенную память. Поэтому память выделяется при построении первого треугольника, а для остальных шести используется повторно. В конце метод Release() освобождает память, поскольку работа с этим путем завершена. Для демонстрационного примера с семью треугольниками разница между этими двумя вызовами невелика, но для панели, которая перестраивает геометрию на каждом тике, она существенна.
Малые углы выбраны намеренно. Как было показано в первой части, ребро под углом 45 градусов смещается на один пиксель по вертикали на каждый пиксель по горизонтали и выглядит как достаточно ровная линия. Пологое ребро идет горизонтально три или четыре пикселя, а затем опускается сразу на целый пиксель — такую ступенчатость невозможно не заметить. Именно с этим рядом нужно будет сравнить результат в следующей части.
Цветок. Наконец, один путь с 2 000 вершинами — с замером времени:
//--- one shape with a great many edges, to show that complexity //--- costs time inside our buffer and nothing at all on the chart const int n = 2000; double xs[], ys[]; ArrayResize(xs, n); ArrayResize(ys, n); for(int i = 0; i < n; i++) { const double a = 2.0 * M_PI * i / n; const double r = 70.0 + 22.0 * MathSin(a * 11.0); xs[i] = 700 + r * MathCos(a); ys[i] = 510 + r * MathSin(a); } CCairoPath flower; flower.AddPolygon(xs, ys, n); //--- these vertices came out of arithmetic, so check them once //--- before they reach the rasterizer. A NaN found here has an //--- address; the same NaN found by the fill is a blank panel. if(!flower.IsFinite()) Print("Cairo5: the flower has a coordinate that is not a number"); else { const ulong t0 = GetMicrosecondCount(); Fill(flower, DEMO_BLUE); const ulong t1 = GetMicrosecondCount(); PrintFormat("a %d-edge path filled in %d us", n, (int)(t1 - t0)); }
Здесь IsFinite() проверяет координаты, полученные в результате вычислений, до передачи пути растеризатору. Эта проверка необязательна и выполняется там, где генерируется геометрия, а не при каждой заливке. Сам метод Fill() не проверяет координаты на NaN и бесконечность, поэтому за эту проверку отвечает вызывающий код.
Пример с 2 000 ребрами включен, чтобы измерить производительность растеризатора при обработке относительно большого пути. Результат по-прежнему выводится через тот же единственный графический объект, который используется в остальных примерах.
Для измерения времени используется GetMicrosecondCount — по той же причине, о которой говорилось в первой части. В Windows значение, возвращаемое GetTickCount, меняется с шагом около 15,6 мс, поэтому такой замер дал бы либо 0, либо 15 мс — ни один результат не был бы информативным.
На что обратить внимание 
Рис. 4. Demo03_FirstFill на графике с цветовой схемой Black On White — все фигуры залиты целиком
На рисунке 4 показан общий вид. Каждая залитая область на нем получена вызовом одного и того же метода Fill(). Рассмотрите ряд A: многоугольник с тремястами сторонами справа при таком размере неотличим от круга. Обратите внимание и на безымянную кляксу рядом: она выглядит так же аккуратно, как прямоугольник, хотя задана незамкнутой ломаной.

Рис. 5. Ступенчатый ряд в увеличенном масштабе — каждый граничный пиксель либо закрашен полностью, либо не закрашен вовсе
Именно рисунок 5 стоит запомнить. При увеличении без сглаживания видно, что каждый граничный пиксель имеет либо цвет фигуры, либо цвет фона — без смешивания. Промежуточных значений нигде нет, потому что растеризатор их вообще не вычислял. Сравните треугольники с малым наклоном ребер внизу веера с треугольниками с более крутыми ребрами вверху. Чем меньше угол наклона, тем длиннее горизонтальные участки ступенек и тем хуже выглядит результат.
В журнал выводятся состояние кляксы и результаты двух замеров времени:
=== Part 3: one Fill() for every shape === the blob: 1 contour, closed = false - and it fills anyway a 2000-edge path filled in 1673 us whole 920x640 frame in 4412 us, 1 chart object
На вашем компьютере эти числа будут другими, и абсолютные значения важны меньше, чем общая картина. Отрисовка цветка с 2 000 ребрами занимает заметную долю времени отрисовки всего кадра, потому что на шаге 3 каждое ребро пока проверяется для каждой строки. Для фигуры с 2 000 ребрами, охватывающей 180 строк, выполняются 360 000 проверок попадания в диапазон, чтобы найти, возможно, всего четыре точки пересечения в каждой строке. Именно в этом заключается наивность такого растеризатора. Таблица активных ребер, которую мы введем в девятой части, избавит от этих лишних проверок.
Структура проекта
В приложенном архиве содержится полный исходный код этой части. Распакуйте его в MQL5/, сохранив структуру папок, и скомпилируйте файл Demo03_FirstFill.mq5. Редактировать пути не требуется.
MQL5/
├── Include/
│ └── CairoG2D/
│ ├── Color.mqh // the ARGB color type
│ ├── Surface.mqh // pixel buffer bound to one chart object
│ ├── Path.mqh // the path, and now the edges
│ ├── Raster.mqh // the scanline fill
│ └── DemoTheme.mqh // the demos' shared palette - NOT library code
└── Experts/
└── CairoG2D/
└── Article 03/
└── Demo03_FirstFill.mq5 // every shape through one Fill() Структура каталогов остается такой же, как во второй части, и в дальнейшем меняться не будет. Заголовочные файлы библиотеки находятся в папке MQL5/Include/CairoG2D/, а демонстрационные примеры — в папке MQL5/Experts/CairoG2D/.
| # | Файл | Каталог | Содержимое |
|---|---|---|---|
| 1 | Color.mqh | MQL5/Include/CairoG2D | Тип цвета ARGB, функции формирования цвета, интерполяция |
| 2 | Surface.mqh | MQL5/Include/CairoG2D | CCairoSurface — вывод пиксельного буфера на график в виде растрового изображения |
| 3 | Path.mqh | MQL5/Include/CairoG2D | Путь, а также SCairoEdge, CCairoEdgeList и BuildEdges() |
| 4 | Raster.mqh | MQL5/Include/CairoG2D | CCairoRasterizer — построчная заливка |
| 5 | DemoTheme.mqh | MQL5/Include/CairoG2D | Палитра, настройка графика, подписи — не входят в состав библиотеки |
| 6 | Demo03_FirstFill.mq5 | MQL5/Experts/CairoG2D/Article 03/ | Шесть фигур, эксперимент с числом обхода, ступенчатость |
| 7 | Cairo-Style Library - Part 03.zip | Архив, содержащий все прикрепленные файлы и пути к ним относительно корневой папки терминала. |
Path.mqh — единственный файл из предыдущих частей, который мы изменяем в этой части. При этом модель пути в нем остается прежней. В конец файла добавлены SCairoEdge, CCairoEdgeList и BuildEdges(). Хранение данных, операции и методы доступа из второй части работают точно так же, как раньше. Демонстрационный пример второй части также полностью работает без единой правки.
Заключение и дальнейшие шаги
Теперь библиотека может заливать любую фигуру, обводку которой можно описать отрезками. Для этого используется одна функция, в которой нет никакого кода для отдельных типов фигур. Прямоугольник, звезда, глиф, цветок с 2 000 ребрами и безымянная фигура — все они проходят одни и те же этапы обработки. Контуры преобразуются в ребра, каждое ребро проверяется один раз для каждой строки, точки пересечения сортируются, а по числу обхода определяется, какие интервалы находятся внутри фигуры.
Стоит еще раз перечислить элементы, благодаря которым это стало возможным: каждый из них останется основой для дальнейшей работы в следующих частях серии:
- ребро — отрезок, представленный так, что вычисление XAt(y) требует одного вычитания, одного умножения и одного сложения;
- хранение концов ребра в порядке сверху вниз с сохранением исходного направления, благодаря чему по четырем произвольным точкам пересечения можно однозначно определить внутренние и внешние интервалы;
- число обхода, которое позволяет объединять перекрывающиеся контуры, а не создавать отверстия в местах перекрытия, и при необходимости получать намеренно заданные отверстия;
- а также ограничение диапазона по оси Y, благодаря которому затраты зависят от числа строк, охватываемых фигурой, а не от размера буфера.
Стоит также отметить, что осталось без изменений. Путь из второй части остался прежним. Ребра строятся из пути по мере необходимости и отбрасываются после заливки. Поэтому слой геометрии ничего не знает о пикселях, а хранящийся в пути флаг замыкания остается неизменным для операций, которым он нужен.
У текущей реализации остаются два заметных недостатка, и оба видны на рисунке 5. Ребра выглядят ступенчато, а при заливке пиксели перезаписываются без смешивания цветов. Третье ограничение — поддержка только ненулевого правила заливки — станет темой пятой части.
Мы займемся обеими проблемами одновременно, поскольку они оказались взаимосвязаны. Проверка "да/нет" в центре строки заменяется измерением. Вместо проверки, находится ли пиксель внутри фигуры, мы определяем, какая его доля находится внутри. Эта величина и есть покрытие пикселя. Координаты концов интервала больше не округляются, а цвет частично покрытых пикселей на его концах учитывается пропорционально покрытию. Ступенчатая граница сменяется сглаженной.
Далее покрытие нужно учесть при смешивании цветов — для этого используется альфа-композиция. При покрытии 0,3 цвет пикселя должен смешиваться с уже записанным в буфере цветом с весом 30%, а не просто заменять его. Мы напишем функцию CairoBlendOver, разберем вычисления на конкретных числах и вернемся к вопросу о представлении цвета без предумножения и с предумножением на альфа-значение, который обозначили в первой части и отложили на потом.
Это даст самое заметное улучшение изображения во всей серии. Те же демонстрационные фигуры, те же пути, те же четыре шага — и ребра, которые выглядят как ребра.
Проект поддерживается на MQL5 Algo Forge. Для каждой части серии подготовлен отдельный релиз с кодом, зафиксированным именно в том виде, который разбирается в этой статье. Поэтому скачивайте релиз, соответствующий той статье, которую читаете.
Часть 3 находится здесь: https://forge.mql5.io/SandroBegashvil/CairoG2D/releases/tag/part-03
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/24165
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Создание графической библиотеки для MetaTrader 5 по образцу Cairo (Часть 4): Сглаживание, покрытие и композиция
Автоматизация торговых стратегий в MQL5 (Часть 53): Разворотные модели "двойная вершина" и "двойное дно"
За рамками REST и ZeroMQ: Создание моста Protocol Buffers между MetaTrader 5 и Python с выходом на gRPC для инференса в реальном времени
Алгоритм опыления цветов — Flower Pollination Algorithm (FPA)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования