Пользователь
0,0
рейтинг
2 апреля 2014 в 08:53

Разработка → Signed Distance Field или как сделать из растра вектор

Речь сегодня пойдёт о генерации изображений с картой расстояний (Signed Distance Field). Данный вид изображений примечателен тем, что фактически позволяет получить «векторную» графику на видеоускорителе, причём даром. Одной из первых данный метод растеризации предложила компания Valve в игре Team Fortress 2 для масштабируемых декалей в 2007 году, но до сих пор он не пользуется особой популярностью, хотя позволяет рендерить прекрасного качества шрифты, используя текстуру всего 256х256 точек. Данный метод прекрасно подходит для современных экранов высокой чёткости и позволяет серьёзно сэкономить на текстурах в играх, он не требователен к железу и прекрасно работает на смартфонах.



Хитрость заключается в создании такой специально подготовленной карты расстояний, что при использовании простейшего шейдера получается идеальная векторная картинка. Более того, с помощью шейдеров можно получить эффекты тени, свечения, объёма и т. п.

Как же создавать такие изображения? Очень просто, ImageMagick позволяет сделать это одной командой:

convert in.png -filter Jinc -resize 400% -threshold 30% \( +clone -negate -morphology Distance Euclidean -level 50%,-50% \) -morphology Distance Euclidean -compose Plus -composite -level 45%,55% -resize 25% out.png

На этом можно было бы поставить точку, но так полноценного топика не получится. Что ж, под катом — описание быстрого алгоритма расчёта SDF, пример на C++ и немного шейдеров для OpenGL.

Что это было за заклинание?


Первая команда в начале данного поста — это рецепт генерации SDF из любого черно-белого растрового контура. Он основан на новой возможности ImageMagick: морфологии. Среди морфологических преобразований присутствует и вычисление карты расстояний.

Расчёт карты расстояний — простейший алгоритм. Он работает на монохромных изображениях, где пиксель либо чёрный, либо белый. Один из цветов считаем внутренним, другой — внешним (как больше нравится, на этой картинке с гепардом чёрный пиксель будет внутренним). Иногда их называют цветами фона и переднего плана. Для каждого «внутреннего» пикселя изображения нужно найти ближайший к нему «внешний» пиксель и установить значение яркости этого пикселя как евклидово расстояние до ближайшего «внешнего» пикселя. То есть нужно вычислить расстояния до всех «внешних» пикселей изображения и выбрать наименьшее из них. Получившаяся карта расстояний называется Distance Field (DF), но она пока нам не подходит. Чтобы получить SDF (Signed DF), инвертируем изображение, повторяем алгоритм, снова инвертируем и складываем с предыдущим результатом.

Значение интенсивности не обязательно устанавливать точно в значение расстояния: можно масштабировать «расплывчатость» изображения в зависимости от потребностей. В частности, для рендеринга чётких контуров лучше использовать менее размытую карту, а для таких спецэффектов как тень или свечение — лучше увеличить карту расстояний:



И хотя ImageMagick не самый быстрый и простой способ сделать подобную карту, я думаю это лучший вариант, так как ImageMagick присутствует практически на всех операционных системах и нередко используется разработчиками для конвейерной обработки спрайтов. Достаточно немного доделать скрипт и поставить генерацию изображений на поток.

Рассмотрим, как это работает. Если просто взять и применить операцию morphology к нашему изображению, получим не лучший результат:

convert nosdf.png -morphology Distance Euclidean sdf.png



Малевич? Нет, просто негры ночью воруют уголь не хватает контрастности, её можно быстро вытянуть параметром -auto-level:

convert nosdf.png -morphology Distance Euclidean -auto-level sdf.png



Сразу бросается в глаза недостаток: карта расстояний генерируется только снаружи. Это следствие того, что сам алгоритм тоже двухпроходный, повторим то же самое для негатива:

convert nosdf.png -negate -morphology Distance Euclidean -auto-level sdf.png



Теперь обратная ситуация — не хватает карты снаружи.

Осталось объединить эти два алгоритма, используя промежуточный слой, докрутить контрастность и получаем зубодробительный скрипт из начала поста:

convert in.png -filter Jinc -resize 400% -threshold 30%  \( +clone -negate -morphology Distance Euclidean -level 50%,-50% \) -morphology Distance Euclidean -compose Plus -composite -level 45%,55% -resize 25% out.png

Некоторые пояснения:
-resize 400% — увеличиваем исходное изображение, чтобы устранить зубчатые края. Алгоритм работает только для черно-белых изображений и хотелось бы хоть как-то учитывать антиалиасинг. Но я бы порекомендовал всегда иметь под рукой оригинал четырёхкратного размера или больше. Valve, например, для демонстрации использует 4K изображение, из которого получает SDF размером 64x64. Это конечно уже перебор. Я нахожу приемлемым соотношение 8:1.
-level 45%,55% — можно регулировать степень размывания карты расстояний, по умолчанию она уж очень расплывчатая.
-filter Jinc и -threshold 30% — экспериментально, данный фильтр и порог обеспечивает наилучшее соответствие исходному изображению. Под спойлером скрипт и исходник для желающих проверить.
Скрипт для поиска наилучшей метрики PSNR
Естественно, единственно верного варианта быть не может, но я оставил Jinc 30% как наиболее средний вариант, дающий приемлемый результат.

Изображение:

Скрипт:
#!/bin/sh

convert orig.png -resize 25% .orig-downscaled.png
convert orig.png -threshold 50% .orig-threshold.png
SIZE=$(identify orig.png| cut -d' ' -f3)

MAX=0.0
MAXRES=""
for filter in $(convert -list filter)
do
	for threshold in $(seq 1 99)
	do
		convert .orig-downscaled.png -filter $filter -resize $SIZE! -threshold $threshold% .tmp.png
		PSNR=$(compare -metric PSNR .orig-threshold.png .tmp.png /dev/null 2>&1)
		if [ "$(echo "$MAX < $PSNR" | bc -l)" = "1" ]
		then
			MAXRES="$PSNR $filter $threshold"
			echo $MAXRES
			MAX=$PSNR
		fi
		rm .tmp.png
	done
done

rm .orig-threshold.png .orig-downscaled.png



Хорошо, если есть оригинал более высокого разрешения — тогда можно обойтись без трюка с увеличением масштаба и масштабировать уже только в меньшую сторону:
convert in.png -threshold 50%  \( +clone -negate -morphology Distance Euclidean -level 50%,-50% \) -morphology Distance Euclidean -compose Plus -composite -level 45%,55% -filter Jinc -resize 10% out.png

Обратите внимание, что фильтр Jinc «переехал» в конец цепочки, так как призван улучшить качество сэмплирования при уменьшении размера карты. Также не стоит убирать -threshold 50% — Euclidean некорректно работает для не-монохромных изображений.

Некоторые спорные вопросы.

Есть ли смысл «вытягивать» контраст? Вообще теоретически при увеличении контраста увеличивается дельта семплов, из которых потом аппаратным методом интерполяции рассчитывается антиалиасинг. Если коротко — вытягивать нужно, особенно если планируется выводить чёткие сглаженные контуры и не так важны эффекты вроде теней. Если исходная карта будет не только растягиваться, но и сжиматься, сильно увлекаться не стоит — в противном случае при уменьшении картинки будет портиться антиалиасинг, достигаемый за счёт размытых краёв SDF.

Как качество зависит от разрешения SDF-карты? Я постарался построить график зависимости PSNR от разрешения карты и контрастности. В целом качество увеличивается, но ещё сильно зависит от контрастности карты. Оценить зависимости можно на графике:


Здесь Scale — это масштаб в процентах от исходника, Level — насколько сильно был «вытянут» контраст. Можно сделать вывод, что от масштаба зависимость не очень уж и линейная, 30% будет весьма компромиссным вариантом, а контрастность довольно сильно влияет на качество контура.

Насколько сильно влияет на качество размер фильтра Euclidean? Увеличение размера фильтра даёт прирост в 0,1 дБ +- копейки, на мой взгляд это несущественно.

Насколько сильно можно «ужать» исходное изображение? Это сильно зависит от формы. SDF не любит острые углы, а такая плавная картинка как гепард из примера превосходно чувствует себя даже в миниатюрном масштабе:



Реализация быстрого алгоритма на C++


Алгоритм прост, но его реализация «в лоб» будет работать часами: по сути нужно для каждого пикселя просканировать всё изображение. O(N^2) нас совершенно не устраивает. Но умные люди уже подумали и придумали алгоритм точного (!) расчёта DF, который работает за O(N). Осталось распространить задачу до SDF, что довольно просто (см. предыдущий пример).

Суть. Вместо того чтобы считать для каждого пикселя расстояние, выполним два последовательных прохода по изображению, просто инкрементируя расстояние при определённых условиях. Это напоминает алгоритм быстрого Box-размывания изображения. Матан можно почерпнуть из [2], я же постараюсь объяснить на пальцах.

Пикселем p я буду называть элемент массива N*M, составленный из исходного изображения. Пиксель — это следующая структура:
{
    x, y - это покоординатное расстояние
    f - квадрат Евклидова расстояния
}

Как видно, здесь ничего нет о яркости и т.п. — оно и не нужно. Массив формируется так:
Если пиксель исходного изображения светлый, то
x = y = 9999
f = 9999 * 9999

Если пиксель исходного изображения тёмный, то
x = y = f = 0

У каждого пикселя есть 8 соседей, пронумеруем их таким образом:
2 3 4
1 p 5
8 7 6

Далее введём две вспомогательные функции. Функция h нужна для вычисления расстояния Евклида между пикселем и соседом, функция G — для вычисления нового значения расстояния по компонентам.
h(p, q) {
    if q - сосед 1 или 5 {return 2 * q.x + 1}
    if q - сосед 3 или 7 {return 2 * q.y + 1}
    в остальных случаях {return 2 * (q.x + q.y + 1)}
}

G(p, q) {
    if q - сосед 1 или 5 {return (1, 0)}
    if q - сосед 3 или 7 {return (0, 1)}
    в остальных случаях {return (1, 1)}
}

Первый проход. Данный проход выполняется в прямом порядке (от левого верхнего угла изображения к правому нижнему). Псевдокод:
для каждого пикселя p изображения {
    для каждого соседа q от 1 до 4 {
        if (h(p, q) + q.f < p.f) {
            p.f = h(p, q) + q.f
            (p.x, p.y) = (q.x + q.y) + G(p, q)
        }
    }
}

Второй проход. Данный проход выполняется в обратном порядке (от правого нижнего угла изображения к левому верхнему). Псевдокод:
для каждого пикселя p изображения {
    для каждого соседа q от 5 до 8 {
        if (h(p, q) + q.f < p.f) {
            p.f = h(p, q) + q.f
            (p.x, p.y) = (q.x + q.y) + G(p, q)
        }
    }
}

Алгоритм нужно повторить для негатива исходного изображения. Потом для двух полученных карт нужно произвести окончательное вычисление расстояния и вычитание для объединения двух карт DF в одну SDF:

d1 = sqrt(p1.f + 1);
d2 = sqrt(p2.f + 1);
d = d1 - d2;

Изначально в структуре мы хранили квадрат Евклидова расстояния, поэтому нужно извелчь корень. Зачем нужно прибавить единицу — не спрашивайте, результат эмпирический и без него получается криво:) Финальная карта SDF — результат вычитания второй из первой, далее нужно отмасштабировать значение как нравится.

На мой взгляд даже попытка на пальцах объяснить, как это работает, выглядит очень запутанной, поэтому я приведу исходный код на C++. В качестве входного изображения я использовал QImage из Qt, чтобы не портить наглядность процесса. Исходник основан на источнике [3], но там есть баги.

Исходник
#include <QPainter>
#include <stdio.h>
#include <math.h>

struct Point
{
    short dx, dy;
    int f;
};

struct Grid
{
    int w, h;
    Point *grid;
};

Point pointInside = { 0, 0, 0 };
Point pointEmpty = { 9999, 9999, 9999*9999 };
Grid grid[2];

static inline Point Get(Grid &g, int x, int y)
{
    return g.grid[y * (g.w + 2) + x];
}

static inline void Put(Grid &g, int x, int y, const Point &p)
{
    g.grid[y * (g.w + 2) + x] = p;
}

static inline void Compare(Grid &g, Point &p, int x, int y, int offsetx, int offsety)
{
    int add;
    Point other = Get(g, x + offsetx, y + offsety);
    if(offsety == 0) {
        add = 2 * other.dx + 1;
    }
    else if(offsetx == 0) {
        add = 2 * other.dy + 1;
    }
    else {
        add = 2 * (other.dy + other.dx + 1);
    }
    other.f += add;
    if (other.f < p.f)
    {
        p.f = other.f;
        if(offsety == 0) {
            p.dx = other.dx + 1;
            p.dy = other.dy;
        }
        else if(offsetx == 0) {
            p.dy = other.dy + 1;
            p.dx = other.dx;
        }
        else {
            p.dy = other.dy + 1;
            p.dx = other.dx + 1;
        }
    }
}

static void GenerateSDF(Grid &g)
{
    for (int y = 1; y <= g.h; y++)
    {
        for (int x = 1; x <= g.w; x++)
        {
            Point p = Get(g, x, y);
            Compare(g, p, x, y, -1,  0);
            Compare(g, p, x, y,  0, -1);
            Compare(g, p, x, y, -1, -1);
            Compare(g, p, x, y,  1, -1);
            Put(g, x, y, p);
        }
    }

    for(int y = g.h; y > 0; y--)
    {
        for(int x = g.w; x > 0; x--)
        {
            Point p = Get(g, x, y);
            Compare(g, p, x, y,  1,  0);
            Compare(g, p, x, y,  0,  1);
            Compare(g, p, x, y, -1,  1);
            Compare(g, p, x, y,  1,  1);
            Put(g, x, y, p);
        }
    }
}

static void dfcalculate(QImage *img, int distanceFieldScale)
{
    int x, y;
    int w = img->width(), h = img->height();
    grid[0].w = grid[1].w = w;
    grid[0].h = grid[1].h = h;
    grid[0].grid = (Point*)malloc(sizeof(Point) * (w + 2) * (h + 2));
    grid[1].grid = (Point*)malloc(sizeof(Point) * (w + 2) * (h + 2));
    /* create 1-pixel gap */
    for(x = 0; x < w + 2; x++)
    {
        Put(grid[0], x, 0, pointInside);
        Put(grid[1], x, 0, pointEmpty);
    }
    for(y = 1; y <= h; y++)
    {
        Put(grid[0], 0, y, pointInside);
        Put(grid[1], 0, y, pointEmpty);
        for(x = 1; x <= w; x++)
        {
            if(qGreen(img->pixel(x - 1, y - 1)) > 128)
            {
                Put(grid[0], x, y, pointEmpty);
                Put(grid[1], x, y, pointInside);
            }
            else
            {
                Put(grid[0], x, y, pointInside);
                Put(grid[1], x, y, pointEmpty);
            }
        }
        Put(grid[0], w + 1, y, pointInside);
        Put(grid[1], w + 1, y, pointEmpty);
    }
    for(x = 0; x < w + 2; x++)
    {
        Put(grid[0], x, h + 1, pointInside);
        Put(grid[1], x, h + 1, pointEmpty);
    }
    GenerateSDF(grid[0]);
    GenerateSDF(grid[1]);
    for(y = 1; y <= h; y++)
        for(x = 1; x <= w; x++)
        {
            double dist1 = sqrt((double)(Get(grid[0], x, y).f + 1));
            double dist2 = sqrt((double)(Get(grid[1], x, y).f + 1));
            double dist = dist1 - dist2;
            // Clamp and scale
            int c = dist * 64 / distanceFieldScale + 128;
            if(c < 0) c = 0;
            if(c > 255) c = 255;
            img->setPixel(x - 1, y - 1, qRgb(c,c,c));
        }
    free(grid[0].grid);
    free(grid[1].grid);
}


Здесь используется хитрость: так как оба прохода используют «окно» шириной в 1 пиксель, я добавляю однопиксельный бордюр вкоруг исходного изображения, чтобы избежать проверки границ. Для негатива бордюр тоже нужно изменить на противоположное значение, чего не было учтено в [3].

Полноценный рабочий алгоритм можно посмотреть в генераторе растровых шрифтов с открытым исходным кодом UBFG. Пример результата:



Шейдерим


Идея обратного преобразования (из SDF в растровый контур) основана на увеличении контрастности до такой степени, что «размытые» края не будут видны. Изменяя контрастность SDF можно получить различные эффекты, а также регулировать качество антиалиасинга изображения. В качестве примера возьмём исходник:



Это тоже SDF, просто сильно сжатый и уменьшенный в размере. Увеличим его в 16 раз:



Теперь, чтобы получить красивый контур, увеличим контрастность. Я для этих целей использовал GIMP:



Чтобы увидеть результат использования SDF в реальном времени, без шейдеров не обойтись. Простейший альфа-тест также может применяться, но он рубит на корню антиалиасинг. Однако используемый шейдер представляет собой всего пару инструкций и фактически не влияет на производительность. Более того, учитывая что шейдеры сейчас дешёвые, а память/кэш дорогие — можно получить неплохое ускорение за счёт экономии видеопамяти.

Теперь посмотрим, как это дело можно использовать в OpenGL. Все примеры будут даны в виде чистого GLSL исходного кода. Опробовать можно в любом шейдерном редакторе. Все примеры я тестировал в редакторе Kick.js, поскольку это единственный редактор, который позволяет загрузить свои текстуры.

Простейший быстрый вариант

precision highp float;
uniform sampler2D tex;
const float contrast = 40.;
void main(void)
{
	vec3 c = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.3).xxx;
	gl_FragColor = vec4((c-0.5)*contrast,1.0);
}

Здесь просто вытягиваем контраст относительно среднего значения (0.5). Сила контраста должна варьироваться в зависимости от масштаба текстуры и размазанности карты DF — параметр подбирается экспериментально и задаётся через uniform с множителем масштаба.

Немного улучшить качество можно фильтром smoothstep:

precision highp float;
uniform sampler2D tex;
const float threshold = .01;
void main(void)
{
	vec3 c = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.3).xxx;
	vec3 res = smoothstep(.5-threshold, .5+threshold, c);
	gl_FragColor = vec4(res,1.0);
}

Здесь порог также нужно подобрать. smoothstep чуть медленнее на старых видеокартах и телефонах.

Эффект outline

Чтобы получить такой эффект, нужно взять два порога и инвертировать цвет:
precision highp float;
uniform sampler2D tex;
const float contrast = 20.;
void main(void)
{
	vec3 c = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.35).xxx;
	vec3 c1 = (c-.45) * contrast;
	vec3 c2 = 1.-(c-.5) * contrast;
	vec3 res = mix(c1, c2, (c-.5)*contrast);
	gl_FragColor = vec4(res,1.0);
}

Результат:

Эффект свечения и тени

Чуть похимичить над предыдущим примером — и получаем эффект свечения:
precision highp float;
uniform sampler2D tex;
const float contrast = 20.;
const float glow = 2.;
void main(void)
{
    vec3 c = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.35).xxx;
    vec3 c1 = clamp((c-.5)*contrast,0.,1.);
    vec3 c2 = clamp(1.-(c-.5)/glow, 0., 1.);
    vec3 res = 1.-mix(c1, c2, (c-.5)*contrast);
    gl_FragColor = vec4(res,1.0);
}

Чтобы получилась тень, нужно взять цвет для glow со смещением:
precision highp float;
uniform sampler2D tex;
const float contrast = 20.;
const float glow = 2.;
void main(void)
{
    vec3 c = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.35).xxx;
    vec3 gc = texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.35 + vec2(-0.02,0.02)).xxx;
    vec3 c1 = clamp((c-.5)*contrast,0.,1.);
    vec3 c2 = clamp(1.-(gc-.5)/glow, 0., 1.);
    vec3 res = 1.-mix(c1, c2, (c-.5)*contrast);
    gl_FragColor = vec4(res,1.0);
}

Результат:


Результат может показаться не ахти, но это потому что я использовал слишком маленькую карту:



Ссылки


[1] Improved Alpha-Tested Magnification for Vector Textures and Special Effects — та самая статья от Valve.
[2] Frank Y. Shih, Yi-Ta Wu. Fast Euclidean distance transformation in two scans using a 3x3 neighborhood — китайцы? Нет, всего лишь университет Нью Джерси.
[3] www.codersnotes.com/notes/signed-distance-fields — тут тоже довольно быстрый алгоритм, но к сожалению его автор допустил несколько ошибок и присутствует умножение, что чуть медленнее алгоритма, представленного в этой статье.
[4] contourtextures.wikidot.com — ещё одна реализация расчёта SDF, но её преимущество в том, что может учитывать сглаживание краёв для определения ближайших точек. Насчёт производительности ничего не говорится, но хорош, когда нет возможности получить оригинал высокого разрешения (с другой стороны можно просто обойтись трюком с upscale). Если был опыт использования — отписывайтесь в комментариях.
[5] gpuhacks.wordpress.com/2013/07/08/signed-distance-field-rendering-of-color-bit-planes — метод рендеринга цветных векторных изображений (подходит для небольшого количества цветов).
[6] distance.sourceforge.net — интересный ресурс, на котором сравниваются различные алгоритмы подсчёта SDF.

upd. Спасибо замечанию Bas1l, алгоритм всё-таки не совсем точный и может давать ошибки на вычислении расстояния до ближайших соседей из-за ошибки в доказательстве. В этой статье представлена улучшенная версия алгоритма.

upd2. От пользователя achkasov замечание по части шейдеров. В случае резких переходов на SDF карте могут появляться заплывы и неравномерный антиалиасинг. Подробнее об эффекте и о том, как с этим бороться: iquilezles.org/www/articles/distance/distance.htm

Изменив шейдр, получаем значительное улучшение в области, кхм, хвоста:



Код GLSL
precision highp float;
uniform sampler2D tex;
const float contrast = 2.;

float f(vec2 p)
{
	return texture2D(tex,p).x - 0.5;
}

vec2 grad(vec2 p)
{
    vec2 h = vec2( 4./256.0, 0.0 );
    return vec2( f(p+h.xy) - f(p-h.xy),
                 f(p+h.yx) - f(p-h.yx) )/(2.0*h.x);
}

void main(void)
{
	vec2 p = gl_FragCoord.xy/vec2(256., 128.)*.35;
    //float c = texture2D(tex,p).x;
    float v = f(p);
    vec2  g = grad(p);
    float c = (v)/length(g);
    float res = c * 300.;
    gl_FragColor = vec4(res,res,res, 1.0);
}

Павел @RPG
карма
57,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (61)

  • 0
    Этот метод годится только для монохромных изображений?
    Интересно было бы копнуть в сторону более двух цветов.
    • +2
      gpuhacks.wordpress.com/2013/07/08/signed-distance-field-rendering-of-color-bit-planes/

      Идея та же, просто разные цвета сохраняются в разных картах. Можно развить идею и использовать все 4 канала текстуры (RGBA) для четырёх различных цветов и комбинировать всё это в шейдере. Естественно число цветов ограничено, как и в векторном редакторе: каждый контур может иметь либо свой цвет, либо градиент, либо текстуру. Кстати говоря, SDF с градиентом сочетается очень хорошо (в RGB храним градиент, в альфе — SDF).
      • +1
        Спасибо за ссылку! Использовать 24 одно-битных плана мне и в голову не приходило :)
  • +4
    Это же идеальный метод для рендера шрифтов. Плюс получаем базу для создания шейдеров для всевозможных эффектов типа контуров, теней и свечения. Пойду впиливать в проект. Ведь можно заимствовать код? )
    • +6
      Со шрифтов всё и начиналось, этот алгоритм изначально делался для генератора растровых шрифтов. Код нужно использовать — на то он и открытый:)
  • +1
    Годный пост. :) Пойду пилить в проект.

    в С++ коде заметил дефайну с циклом. От неё можно было бы избавиться, если параметры функции объявить как constexpr (C++11).
    • 0
      Учту, спасибо. На определённых тестах дефайн почему-то показал себя лучше, а больше я не разбирался. Хотя в этом define вроде не было цикла, о котором речь? Compare?
      • 0
        Да, Compare. Меня смутил do { } while. А ноль в конце я как-то не заметил. :)
        • 0
          Это старомодная сишная обёртка для дефайна, чтобы его без проблем можно было использовать как функцию. Но поскольку обещал привести пример на Си++, а тут помесь с Си — проверю производительность обоих вариантов.

          Так, ассемблерный выхлоп inline ничем не отличается от define. Безо всяких cast. Не знаю, что на меня нашло…
          • 0
            Об inline дебагер не спотыкается, а вот с макоросами-псевдофункциями беда, да инлайновые шаблонные функции более громосткие (хотя как сказать — переходы строк экранировать хоть в них не нужно), но зато точно не предоставят сюрпризов при трассировке.
            • 0
              Я «за» inline всеми конечностями. В конкретно данном случае в define не было необходимости, а кажущийся прирост производительности скорее всего был ошибкой. Возможно я static или inline забыл в тот момент дописать или ещё чего. Пофиксил код в посте, чтобы никого не смущать больше.

              хотя как сказать — переходы строк экранировать хоть в них не нужно

              А у меня редактор это автоматически делает:) Вот и не лень было этим заниматься.
  • 0
    Я правильно понял что под «векторностью» тут подразумевают двухцветность?
    • 0
      Под «векторностью» понимается shape — т. е. форма или контур. Задача раскрашивания контура — это уже тема целой отдельной статьи.
      • 0
        Т.е. к векторной графике, векторизации растра, когда картинка в итоге получается запомнена как массив из отрезков, сплайнов и т.п. точек с и их координатами, эта «векторность» отношения не имеет, и речь целиком и полностью только про растр? Откуда тут пошла векторность?
        • 0
          Картинка сохраняется в формате, который ближе всего видеокартам — в виде растрового изображения, но из неё восстанавливается растровый контур любого разрешения без потерь качества. Если совсем свысока взглянуть на любой векторный формат — это математическое описание изображения, по которому можно восстановить растр с любым требуемым разрешением. А кто сказал, что матрица интенсивностей, положенная в основу метода — не математическое описание?:) К сложившемуся термину «вектор» как набору кривых это конечно не имеет отношения.
          • 0
            А что ж тогда здесь вектором названо?
  • 0
    А зачем здесь
    texture2D(tex,gl_FragCoord.xy/vec2(256., 128.)*.3
    Вы множите на 0.3? Получается если текстура 256х128 вы будете использовать только треть текстуры.
    • 0
      Это масштаб — текстура растягивается как раз на эту треть с целью демонстрации отсутствия искажений при масштабировании. В данном случае мы увидим только кошачий хвост. Почему в примерах такие дикие цифры? Я тестировал в онлайн-редакторе Kick.js, подбирая значения, что называется, «на лету».
      • 0
        Ну тогда бы могли продефайнить/юниформом сделать с соответствующим именем, раз уж все равно множите.

        И честно говоря, я все равно не могу увидеть в «простейшем варианте» ничего кроме выборки «темноты» пикселя, и его обработки контрастом. За счёт сильного контраста выделяются линии. Возможно все правильно, я просто не понял алгоритма работы.

        В том же kick.js есть кнопочка «Share» можете добавить «живые» примеры к статье (как я понял с текстурами).
        • 0
          Алгоритм шейдера в общем виде и есть повышение контраста. Контраст повышается настолько, чтобы контур был сглаженным (эффект антиалиасинга). Аналогичную процедуру можно провернуть над SDF-картой в каком-нибудь ГИМПе. Откройте SDF, увеличьте контраст — это и будет результат работы шейдера.

          В том же kick.js есть кнопочка «Share»

          А для этого там надо регистрироваться:)
          • 0
            А для этого там надо регистрироваться:)

            Ужас то какой.
  • 0
    Что-то не очень понятно, что значит получить «векторную графику на видеоускорителе»? Я так понял, оригинальное изображение растровое. И не могли бы вы объяснить, каким образом после фактически размытия изображения после увеличения в 30 раз оно оказалось четче, чем оригинальное (картинка в начале)?
    • 0
      Если говорить максимально простым языком, то информация о контуре изображения была закодирована не одним пикселом, а группой пикселей на некоторой площади. Вот так и получается, что особым образом обрабатывая такую группу пикселей можно получить достаточно четкий контур более высокого разрешения.
    • +3
      Вот зарегистрируется автор на kick.js, добавит живой пример и увидите :)

      Когда вы текстуру разрешением 64х64 пытаетесь растянуть до 640х640, каждый пиксель который отобразится на экране линейно интерполируется между двумя ближайшими пикселями, которые есть в текстуре. Допустим, вам необходимо получить цвет 175 пикселя по горизонтали (и какого то там по вертикали). Для этого 640 / 64 = 10 (каждый 10*i-й пиксель для 640х640 это i-й пиксель 64х64). 175/10 = 17,5 Целая часть 17, значит из текстуры берем цвет 17го пикселя, и 18 го, и смешиваем их в пропорции 0,5 (дробная часть). [Математически это все в 2-3 операции происходит]

      А если есть линейная интерполяция, есть градиент, а значит есть размытие.
      А размытие есть ни что иное как плавный переход от одного цвета к другому.
      Разность свечения между самим ярким и самым темным пикселем — есть контраст.
      Увеличивая контрастность изображения мы теряем оттенки (градиент/размытость), но тем самым увеличиваем четкость. И казалось бы должны снова появится кубики, но вместо них мы видим линии — это потому что цвета все же интерполируются.

      По сути можно те же фокусы и с обычной монохромной картинкой проделывать. Но без размытости контуры будут выглядеть чересчур зубчато.

      И я в общем-то согласен, что это ни какая не векторная графика, а растр.
      • +2
        Т.е., насколько я понимаю, мы вначале размываем изображение, делая линию контура так сказать, «срединной линией» размывки, потом увеличиваем изображение. Так как изображение размыто, лесенки становятся не так заметны, потому что при увеличении разрешения, грубо говоря, интерполируются не яркости 0 и 100 с шагом 10, а яркости 50 и 60 с шагом 1. А так как при увеличении «срединная линия» никуда не сдвинулась, мы потом, повышая контрастность, все пиксели, которые отличаются от нее на, предположим, -2 градации (т.е. темнее, они оказались внутри контура пумы) делаем черными, а которые на +2 градации (чуть светлее, снаружи контура) — светлыми.

        Причем, если бы мы растягивали оригинальное изображение, то ввиду шага в 10 градаций яркости мы не смогли бы подойти к границе на 2 градации — либо на 10б либо на 0.
        • 0
          То есть, увеличивая мелкое монохромное изображение, есть резон его перед увеличением размыть хитрым способом, а после увеличения вернуть контраст, вместо того, чтобы просто увеличить? Контур получается более гладкий?
          • 0
            Можно увеличить обычное изображение с альфа-каналом и потом увеличить контрастность, чтобы скрыть артефакты интерполяции, но есть два НО:

            • при уменьшении того же изображения будут видны эффекты «лесенки» из-за особенностей интерполяции;
            • при увеличении изображения появится крупная заметная «лесенка» опять же из-за особенностей интерполяции на видеокартах.

            Подробнее об этих артефактах и недостатках можно посмотреть в оригинальной публикации Valve (есть в конце статьи). Описываемая техника позволяет сильно уменьшить оригинальное изображение по сравнению с оригиналом (экономия видеопамяти) и масштабировать как «вверх» так и «вниз» без значительных искажений. Контур при этом получается плавным и чётким с бесплатным антиалиасингом.
            • 0
              Ну, то есть «да», плюс хранить это размытое можно весьма сильно уменьшенным. Клёво, однако!
  • +1
    Я думаю, важно будет заметить, что алгоритм, используемый в статье, может делать ошибки в определении расстояний до ближайших черных пикселей. Его ошибки оцениваются вот в этой статье (Table II, колонка SWEDT). Причина для ошибок, насколько я понимаю, в том, что в доказательстве у авторов статьи [2] в вашем списке есть ошибка: в разделе 4 (доказательство) они предполагают, что ближайший черный пиксель для соседа будет таким же, как у текущего пикселя. Что, очевидно, не так.
  • +2
    И еще вы пишете «Зачем нужно прибавить единицу — не спрашивайте, результат эмпирический и без него получается криво». Возможно (хотя я внимательно не считал), это потому, что вы добавляете границу шириной в один пиксель перед работой алгоритма.
    • 0
      Это точно не из-за границы. Ошибку с единицей я заметил ещё раньше ошибки с однопиксельным бордюром (спасибо матрице самсунга:). Получается едва заметный перепад яркости. А однопиксельный бордюр нужен 1) для производительности 2) для того, чтобы примыкающие к краю контуры не засвечивались (очень сложно объяснить этот эффект, его тоже сложно заметить на глаз). Ещё более странно то, что единицу надо прибавить до извлечения квадратного корня. Тут уже нужно лезть в дебри доказательства, которое я признаться читал по диагонали.

      По поводу ошибки в алгоритме — спасибо, добавил в статью. Не думал, что спустя 10 лет вышел «апдейт» публикации. Видно, что простора для развития идеи ещё очень много.
      • +3
        Замечу, что этот апдейт вышел все-таки через 5 лет, не через 10 и, кроме того, это статья от других авторов.

        После прочтения вашей статьи я осознал, что именно эту задачу мне предстоит решить в течение ближайших дней (но в 3D)—спасибо вам огромное за статью!—поэтому я немного почитал научные статьи по этой теме. Это оказалась довольно обширная область. И вот что я нашел: есть два очень неплохих обзора алгоритмов EDT: 2008 года и 2011 года.

        В обзоре 2008 года рассматриваются только точные алгоритмы, поэтому вашего нет. В нем можно сразу перейти к заключению: из точных быстрее всего (и примерно одинаково) работают алгоритмы Maurer'а и Meijster'а. Алгоритм Маурера, судя по всему, очень сложный для реализации, а вот алгоритм Мейстера выглядит очень привлекательным.

        В обзоре 2011 есть точные и неточные алгоритмы. В обзоре самые ценные—таблица 3 и картинка 4. Тот алгоритм, что вы используете, назыавется там EDT-2, он действительно дает ошибки. Что интересно, авторы показывают, что этот алгоритм работает медленне алгоритма Маурера (и, соответственно, Мейстера). Там представлен еще один точный алгоритм поновее—LLT, он описан в этой статье. Но он реально сложный (преобразования Лежандра, то-се) и лишь чуть-чуть быстрее алгоритма Маурера (и, соответственно, Мейстера). Авторы рассматривают еще один супер-алгоритм, FEED, но если присмотреться, то это алгоритм самих авторов, и он не очень популярен. Его основной недостаток—нужно заранее знать или оценить максимальную euclidean distance в картинке (чем она меньше, тем он быстрее). Поэтому его можно не рассматривать :-).

        Исходя из всего этого, лично я пока собираюсь остановиться на алгоритме Мейстера. Может быть, еще посмотрю внимательнее на LLT. И может быть, даже опубликую отдельный пост с этими изысканиями.

        P.S. В статье про LLT есть еще один алгоритм, NEP, быстрее чем LLT, но он, если почитать, будет работать только с выпуклыми объектами на картинке, так что в общем случае не подходит.
        • 0
          Спасибо за детальный обзор статей по теме:) В статье, которую я смотрел стояла дата 2003 год. Наверное, есть эффективные алгоритмы (есть один с учетом антилалиасинга, который я представил в ссылках), но это всё делалось в рамках OpenSource проекта генератора шрифтов UBFG, поэтому я не сильно вдавался в детали алгоритмов или сравнительный анализ. Если получится сделать быстрее — contributions are welcome:) Когда я делал этот алгоритм для UBFG, я просто задумался, а можно ли это сделать имеющимися OpenSource средствами? Оказалось, что ImageMagick умеет это делать, скорость и качество для большинства разработчиков являются приемлемыми.
        • +2
          P.P.S. Совсем забыл: у обзора 2008 года есть сопутствующий сайт с картинками и исходным кодом.
          • 0
            Вот исходный код — совсем другое дело:)

            Можно будет сравнить на реальном проекте, использующем SDF.
  • +2
    Что только не придумают, — лишь бы аппаратно векторную графику не реализовывать!
    • 0
      А каким образом можно аппаратно реализовать векторную графику? Может, подкините статей?
      • 0
        www.khronos.org/openvg/

        Зря иронизируете, уже целый стандарт есть, причём ему сто лет. NVIDIA тоже что-то пилит. Но опять же это не совсем аппаратная реализация, как и OpenGL, а стандарт. Тот же OpenGL имеет кучу нюансов, аппаратно может поддерживаться лишь операция рисования точек и линий, а остальные недостающие части стандарта реализует драйвер.
        • 0
          Ну почему же сразу иронизирую :) Тема-то интересная, вот и хочется узнать побольше. Спасибо, кстати за ссылку. Однако, назвать это аппаратной векторной графикой у меня язык не поворачивается. Ведь, насколько я понял, это всего лишь расширенные операции по рисованию кривых Безье и заливке фигур в дополнение к операциям рисования точек и линий. Чем оно отличается от того же OpenGL, в котором всё это давно уже есть, мне пока решительно не понятно.
          • 0
            Чем оно отличается от того же OpenGL, в котором всё это давно уже есть, мне пока решительно не понятно.

            Да собственно ничем. Аппаратных устройств такого рода я пока не встречал (но возможно они существуют, в рамках каких-то специализированных приложений). Дело Кроноса — дать стандарт, а как его будут реализовывать — через OpenGL или аппаратно — головная боль производителей.
            • 0
              Почти все андроиды поддерживают openVG. И это именно аппаратное решение. Фактически openVG нужны те же наборы команд что и для OpenGL (те что в шейдере). В OpenGL ничего нет — в том то и дело. Кривую вы в нем просто так не нарисуете, заливка тела фигуры — будьте добры триангулировать… и т.д. openVG же, напротив, все это делает.
      • 0
        Можно попробовать вершинный шейдер написать, чтобы делал триангуляцию специально подготовленных полигонов, точно как упоминаемая ниже разработка NVideo, суть которой сводится к триангуляции кривых безье на GPU и заливка получившихся фигур градиентами и сплошными цветами, — ну а что еще надо!?
        Проблема конечно отраслевая, не то чтобы техническая…
        • 0
          Там не вершинный шейдер и не триангуляция. Там пиксельным шейдером по квадратической/кубической формуле определяется принадлежность текущего пикселя телу или нет. Похоже вы бегло ознакомились со статьей и прочитали только «специальный случай» для которого требовалась триангуляция (разбиение одной кривой в треугольнике на 2 меньших).
          • 0
            И вообще — как это на вершинном шейдере возможно выполнить триангуляцию?
            • 0
              Наверное, автор хотел сказать, в геометрическом шейдере? Там можно, если мне не изменяет память. Но если я правильно понял предлагаемый метод, триангуляцию вообще можно делать на CPU, треугольники играют роль узлов кривых, а интерполяция — в фрагментном шейдере.
              • 0
                Совершенно верно, за исключением того что без триангуляции в 90% можно обойтись. Она нужна только когда одна кривая накладывается на другую. В фрагментном шейдере не совсем интерполяция, скорее там именно построение самой кривой. В упрощенном виде там так
                if (texCoord.y-texCoord.x*texCoord.x > 0) discard;

                Что есть решение уравнение параболы y = x^2 (квадратическая кривая)
                • 0
                  Под триангуляцией наверное тут следует понимать перевод кривых Безье в треугольники с учётом заполнения контура. Не исключено, что можно в офф-тайме как и в случае с SDF «запекать» растровые контуры. Остаётся главная проблема — антиалиасинг, что будет нагрузкой на фрагментный шейдр.
                  • 0
                    Кривая — это точка начала и конца, и одна опорная. Это и есть треугольник — никакой триагуляции.

                    Исключено — нельзя «запекать». Разве что будете рисовать в фрейм буфер с целью получения текстуры :)) Зачем их запекать?? Одно умножение куда быстрее выборки из текстуры.

                    Антиалиасинг в статье также детально описан.

                    Похоже никто не читает ссылок которые дает
                    • 0
                      Это не моя ссылка:) Про «запекание» я имел ввиду полученные треугольники сохранять в виде меша — это гораздо компактнее, чем тот же SDF, к тому же их не обязательно грузить в видеопамять. Но треугольники как-то надо получить из исходного вектора (SVG например). Метод растеризации более-менее понятен, а вот как этот набор треугольников получить? Сразу встаёт вопрос синтезирующего софта. Я всё-таки так понимаю часть треуглольников — «внутренние» — получены как раз триангуляцией, а другая часть — внутренний и внешний вектор, к которому и применяется описываемый алгоритм. Поправьте если мой английский совсем плох:
                      Next, we triangulate the interior of the closed path and form a triangle for each quadratic Bézier curve.
                      • 0
                        суть которой сводится к триангуляции кривых безье на GPU


                        Я говорю об этом. Нет там никакой триангуляции кривых безье. Есть триангуляция тела фигуры, мноугольника, SVG path…. Сами кривые не триангулируются (если только не попадают под тот случай когда один треугольник с кривой пересекает другой). Автор наверное представил себе, что кривая строится из множества линий, кои и триангулируются в вершинном (!) шейдере.
  • 0
    Я невнимательно читал, или в статье не описано, как по полученному SDF получить собственно отмасштабированную картинку?
    • 0
      Собственно, в статье есть примеры шейдеров на GLSL. Рисуем картинку любого размера с этим шейдером — из-за интерполяции текстур и выкручивания контраста получаем четкую и гладкую картинку.
      • 0
        Построение SDF объяснялось алгоритмически и затем был приведён пример кода — совершенно верный подход. Для восстановление картинки был приведён только фрагмент кода, без объяснений? Нелогично.
        • +1
          Вообще верное замечание, но алгоритм восстановления картинки из SDF-карты тривиален: увеличиваем или уменьшаем масштаб SDF-карты (хоть на 1600%) и увеличиваем контрастность до значения, позволяющего увидеть четкий контур с антиалиасингом. Это можно проделать в любом графическом редакторе, просто я данный момент не проиллюстрировал и упомянул вскользь:

          Здесь просто вытягиваем контраст относительно среднего значения (0.5).

          Степень изменения контраста линейно зависит от масштаба и степени расплывчатости исходника, простыми словами, проще подогнать.
          • 0
            Ну, я почти уверен, что если прочитать всю статью и заодно ссылки, то этот момент дествительно станет тривиален. Но вы же понимаете, что в нашем мире тексты в интернете читаются по-другому. Быстро, по поверхности, понять о чём пишут. Картинки с SDF дают возможность за пару мгновений осознать сущность преобразования. Картинка с восстановлением и обозначенной линией контура на фоне отмасштабированного SDF позволила бы за пару мгновений осознать шаг реконструкции.
            • +4
              Добавил в статью:

              • 0
                Вот это реально круто, спасибо :)
  • +1
    Это одна из модификаций грубого CDA — 8SSEDT. Основная идея — храним дельту по X и Y до ближайшего соседа. По этой дельте мы сможем рассчиатать правильную дистанцию
    CDA считает по красной ломаной линии, т.к. просто складывает расстояния на итерациях
    image
    а 8SSEDT вычисляет честно гипотенузу.
    У вас не приведено откуда берется вот такая «магия»:
    h(p, q) {
        if q - сосед 1 или 5 {return 2 * q.x + 1}
        if q - сосед 3 или 7 {return 2 * q.y + 1}
        в остальных случаях {return 2 * (q.x + q.y + 1)}
    }
    

    Я хочу объяснить. Так как мы храним дельты по осям: dx и dy, а сравнивать нам нужно дистанцию (и записывать минимальную), то нам придется каждый раз вычислять квадрат расстояния dx*dx+dy*dy, чтобы мы могли его сравнить. Авторы решили сэкономить на этом. Для этого они дополинтельно решили хранить квадрат расстояния. Т.е. теперь каждый пиксель несет в себе (dx, dy, Distance^2). Ок, для соседей теперь мы не будем считать расстояние, но мы будем считать новое расстояние каждый раз, когда будем записывать его в текущий пиксель. Авторы соптимизировали и эту часть:
    Пусть новый пиксель у нас находится в координатах (dx+1, dy). Квадрат расстояния до него будет (dx+1)^2+dy^2 = dx*dx + 2*dx + 1 + dy*dy = (dx*dx + dy*dy) + 2*dx + 1
    А поскольку (dx*dx + dy*dy) = старое расстояние Distace^2, то можно к нему просто прибавить 2*dx + 1, чтобы получить новую дистанцию.
    Вот откуда взялось 2 * q.x + 1 и 2 * (q.x + q.y + 1)
    А теперь суть. Данный подход выливается вот в такую реализацию:
    static inline void Compare(Grid &g, Point &p, int x, int y, int offsetx, int offsety)
    {
        int add;
        Point other = Get(g, x + offsetx, y + offsety);
        if(offsety == 0) {
            add = 2 * other.dx + 1;
        }
        else if(offsetx == 0) {
            add = 2 * other.dy + 1;
        }
        else {
            add = 2 * (other.dy + other.dx + 1);
        }
        other.f += add;
        if (other.f < p.f)
        {
            p.f = other.f;
            if(offsety == 0) {
                p.dx = other.dx + 1;
                p.dy = other.dy;
            }
            else if(offsetx == 0) {
                p.dy = other.dy + 1;
                p.dx = other.dx;
            }
            else {
                p.dy = other.dy + 1;
                p.dx = other.dx + 1;
            }
        }
    }
    

    Так как алгоритм писался в бородатые годы, то ради экономии операций тут куча ветвлений, и как результат — потенциальных ошибок branch prediction. Мне кажется гораздо быстрее будет подсчитать квадрат расстояния налету:
    static inline void Compare(Grid &g, Point &p, int x, int y, int offsetx, int offsety)
    {
        Point newPoint;
        Point other = Get(g, x + offsetx, y + offsety);
        newPoint.dx = (other.dx + abs(offsetx));
        newPoint.dy = (other.dy + abs(offsety));
        newPoint.f = newPoint.dx*newPoint.dx + newPoint.dy*newPoint.dy;
        if (newPoint.f < p.f)
        {
            p = newPoint;
        }
    }
    

    А так же возможно будет быстрее вообще избавиться от хранения квадрата дистанции в изображении. Выкидываем поле f из структуры:
    struct Point
    {
        short dx, dy;
    };
    

    а функцию Compare переписываем вот так:
    static inline void Compare(Grid &g, Point &p, int x, int y, int offsetx, int offsety)
    {
        Point newPoint;
        int newDist;
        Point other = Get(g, x + offsetx, y + offsety);
        newPoint.dx = (other.dx + abs(offsetx));
        newPoint.dy = (other.dy + abs(offsety));
        newDist = newPoint.dx*newPoint.dx + newPoint.dy*newPoint.dy;
        if ( newDist < (p.dx*p.dx + p.dy*p.dy) )
        {
            p = newPoint;
        }
    }
    
  • 0
    Так как мы храним дельты по осям: dx и dy, а сравнивать нам нужно дистанцию (и записывать минимальную), то нам придется каждый раз вычислять квадрат расстояния dx*dx+dy*dy, чтобы мы могли его сравнить. Авторы решили сэкономить на этом.

    Это на самом деле я и есть автор этого бреда. С математической точки зрения это выглядит как неоптимизированный код, но на самом деле эта функция написана специально под компилятор (ветвления выкидываются путём подстановки констант) — то есть в финальном скомпилированном варианте не будет практически ни единого ветвления. И доп. переменную с квадратом расстояния тоже делал я:) Поскольку мне надо было добиться скорости алгоритма менее 1 секунды для изображения 4к, получился вот такой монстр. Сделать лучше при той же производительности не получилось (этот код быстрее где-то на 15%).

    Но комментарий не мешало бы оставить, так как с бородатых лет я уже сам не помню, что я тут натворил. Причем я потом нашёл такую же оптимизацию в китайской статье, которую приводил в ссылках, но там сам алгоритм неточный.

    P.S. То что сейчас в гите — сильно устарело.
    • 0
      Я смотрел ссылку которую вы приводили в моем посте: www.ee.bgu.ac.il/~dinstein/stip2002/LeymarieLevineDistTrans_cvgip92.pdf
      Вот эти 2 * (q.dx + q.dy + 1) и 2 * q.dx + 1 предполагают экономию в пару операций умножения и одно сложение, но при этом требуют хранить дистанцию. Копчиком чую, что на современном железе проще сделать пару умножений и сложений вместо лишней записи/чтения значения. Вам не сложно проверить на скорость вариант без f в Point, который я предложил выше?
      • 0
        Эта ссылка — оригинальные методы 4SED и 8SED 1992 года. Я нашёл оптимизированный, но неправильный метод. Ещё полезно почитать статью где все алгоритмы собраны — там вроде бы всё правильно и присутствует сравнение, исходя из которого я сделал вывод что 8SED самый быстрый и ленивый из методов, дающих мизерную ошибку (с дейкстрой и графами уж точно не захочется заморачиваться).

        Проверил, если не кэшировать дистанцию, время выполнения функции GenerateSDF увеличивается почти в 2 раза. И это я ещё не считаю того, что в конце нужно извлечь корень из дистанции, без кэша это ещё две допполнительные операции умножения. На 4к изображении разница уж очень велика: 0.22 сек против 0.4 сек (не считая копирования в Qt текстуру).

        Вот текущий немного кривой код: gist.github.com/scriptum/e5a5961e71465bc67181

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.