Pull to refresh

Comments 33

Очень интересно, спасибо. Сам недавно решал похожую задачу, использовал canvas текстуру в THREE.js. Это когда обычный html canvas элемент превращается в текстуру и далее передается в шейдер.
Вот здесь есть пример, где все очень просто и ничего лишнего.
https://discourse.threejs.org/t/an-example-of-text-to-canvas-to-texture-to-material-to-mesh-not-too-difficult/13757
Выриант с канвасом мне не подходил, по той простой причине, что в моем случае на карте может отображаться скажем 1000 текстовых меток, которые динамически меняют свои значения, в том числе масштабируются и крутятся в зависимости от расстояния до них. Для статики канвас(билборды), вполне годится я думаю, однако если хочется более качественный шрифт, а меток не сильно много, то еще можно div с текстом проецировать прямо на экран. Этот способ, конечно более затратный.

Хорошая статья, которая мне помогла. Одна неясность только. Если фонт генерируется с fontSize = 32, а пользователь например выбирает 16рх как начальный размер для своих текстов, то нужно пересчитывать всю геометрию фонта (оффсеты, ширину, и.п.) или можно просто передать матрицу трансформации c другим scaling фактором в vertex шейдер? Фонт размера 16px например уменьшает текст в два раза. Означает это что можно просто передать scaling matrix, уменьшая всё в два раза, и в остальном не менять все начальные вычисления по сдвигу букв и т.д.? Спасибо заранее.

Пересчитывать не нужно! Разве что, посмотреть ради эксперимента, как изменится качество отрисованного текста. Все параметры(метрики) шрифта нормализуются, скажем для 32 размера некий параметр смещения 16, то для 16 размера этот-же параметр будет равен 8, нормализованное значение параметра соответственно будет равно 0.5. т.к. мы передаем в шейдер единичные размеры то спокойно их умножаем на соответствующие коэфициенты, и масштабируем. Поэтому для одного и того-же шрифта с разными параметрами размера при генерации мы должны получить одни и те-же параметры для шейдера. Единственное на что влияет — это на качество и с этим стоит поиграться. Вобщем мы передаем scaling matrix, c учетом того, что мы используем нормализованные метрики для a_gliphParam и advanceOffset в строке:

vec2 v = screenPos + (a_vertices * vec2(nWidth, nHeight) + vec2(nXOffset, nYOffset) + vec2(advanceOffset, 0.0)) * a_size;


advanceOffset — так же сумма нормализованных значений xadvance (nAdvance) каждого последующего символа относительно начала координат.

Добавлю: природа MDSF как раз такова, чтобы отображаться одинаково на любых масштабах, размер шрифта в атласе просто регулирует уровень сохраняемой детализации. Поэтому иногда технологию сравнивают с векторными форматами.
От размеров квада на экране зависит следующее. Поскольку в текстуре закодировано не растровое изображение глифа, а расстояние до кривой, его описывающей, то шейдер должен позаботиться о всех аспектах растеризации, в том числе о сглаживании.
Самый простой вариант сглаживания, доступный в шейдере – рисовать пиксели, близкие к границам символа полупрозрачными. Нюанс в том, что шейдер работает в нормализованном пространстве символа. То есть, если мы подбираем параметры, определяющие «достаточно близко к границе», чтобы они примерно равнялись 1 пикселю для квада в 16 экранных пикселей, то на кваде в 64 пикселя это будет выглядеть уже не как сглаживание, а как размытие в 4 пикселя. Видимо, именно это в понимаете под «качеством».
Если весь текст в drawcall одного кегля, то вместе с матрицей можно рассчитывать и параметр сглаживания, передавая его так же в константу. Я делал прототип для произвольных экранных размеров квада так: в вершины писал дополнительно атрибут размеров квада в пикселях, а в шейдере считал параметр сглаживания по логистической кривой.
По тем же принципам определения пикселей вблизи границы, можно рисовать и окантовку – часто пример MSDF-шейдера ее содержит. У вас была какая-то хитрая задумка, которая потребовала рисовать окантовку в отдельный проход?
Окантовку рисую в первом проходе (в примере шейдера параметр weight), вторым проходом рисую тот-же текст с weight=0.01. Естественно включена прозрачность glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA).

Задумка не хитрая, просто пока не понимаю можно ли рисовать окантовку вместе с основным текстом в один проход, разными цветами например.
Конечно, можно. Я же говорю, суть SDF-шейдера сводится к тому, чтобы посчитать цвет в зависимости от расстояния до границы символа. Это идеальное же апи, чтобы рисовать окантовку! Если расстояние до границы меньше 0 – рисуем заливку, если больше 0, но меньше толщины обводки – рисуем обводку.
Это будет что-то вроде передать два веса, рассчитать две opacity, а потом

gl_FragColor = (
color * fillAlpha * color.a
+ strokeColor * strokeColor.a * strokeAlpha * (1.0 - fillAlpha)
) 

этот кусок шейдера взял отсюда.

АПИ то идеальное, это да. Но символы то рисуются по очереди слева направо. Это означает что окантовка следуещего символа может лежать частично поверх предыдущего символа, если окантовка достаточно жирная. У меня при одном проходе вот что получается:


https://i.imgur.com/QBUrkWI.png


Два прохода ещё не пробовал. Что думаете?

Не решал такой задачи. Сомневаюсь, что есть универсальное хорошее решение. В ситуациях с конкретными обстоятельствами можно подойти творчески. Например, если буквы всегда темнее, и во время их отрисовки они не блендятся с фоном (отрисовка идет спереди или используется отдельный выходной буфер) – можно попробовать GL_MIN из Blend_Equations. Если поизучать ссылку, могут еще какие-то идеи в голову прийти. Главное, не слишком увлекаться. Во-первых, определить, точно ли это является критичным. Если да, то в конечном итоге решение с двумя проходами может оказаться оптимальным – оно достаточно простое, а один лишний drawcall – не то, над чем стоит заморачиваться.

Блендинг я нашёл оптимальный и не хочу его менять. А drawcall у меня один. Я просто пихаю одни и те же координаты дважды в array для одного drawcall. Вместе с координатами я пихаю один flag (0 или 1), чтобы знать в шейдере какой это проход — для окантовки (которая сначала рисуется) или для самого глифа. Вообщем то просто. Да, больше информации в шейдер передаётся, но это не критически. Как известно на перформанс влияет кол-во drawcalls (в общем случае кол-во вызовов WebGL функций). А тут как раз один drawcall на один текст.

А зачем геометрию-то два раза пихать? Шейдер, на который я приводил ссылку выше, рисует ровно то же, без дублирования геометрии, и без флага.
Количество drawcall-ов на производительность, конечно, влияет, как и многие другие факторы. Причем, на разном железе, определяющие факторы (ботлнек) могут быть разными. Слепо стремиться к уменьшению количества дроуколов не всегда хорошая идея. Если у вас весь текст на экране рисуется за раз, а не как куча отдельных label, которые сортируются независимо, то экономия в 1 дроукол на всю отрисовку – это не то, на чем стоит фокусироваться.
Если вам нужно решить проблему наслоения в общем виде, то вполне возможно, что стоит рисовать в 2 drawcall, как у автора статьи.

У нас каждый "элементарный" shape рисуется в один drawcall. Текст shape это элементарный shape. Все сложные фигуры сконструированы из элементарных. Я всё ещё так и не понял в чём преимущества двух drawcalls. В конечном итоге передаётся примерно одинаковое кол-во информации в шейдер. Или я что-то не улавливаю? Проблема как я сказал, как отрисовать толстую окантовку так, чтобы она не наслаивалась сверху на сами символы. Она должна быть под символами. DEPTH_TEST для 3-й координаты я активировать не могу.

А и действительно, фрагментный шейдер же должен заполнять в том порядке, в котором треугольники в индексбуфере переданы. У вас геометрия как дублируется, каждая буква по два раза? А если сначала заполнить все индексы окантовки, а потом все индексы заливки?
Идея двух дроколов – сначала отрисовать всю окантовку, потом все заполнение.
Что по перформансу лучше – будет зависеть. В том числе, от юзкейсов. Если текст сам часто меняется, то удвоенная геометрия будет создавать небольшой оверхэд на генерацию, если текст меняется очень редко, то его можно сбатчить в один буфер, даже из разных нод. В этом случае 2 дроукола будут вообще на весь текст.

Индексбуфер ещё не пробовал. Это конечно вариант чтобы не дублировать геометрию. Но создаёт дополнительную нагрузку на CPU (их же надо ещё создать). Про батч тоже думал, это пока не реализовано. Это называется в WebGL interleaving. У нас очень много текстов, тысячи могут быть, и статические и динамические (меняющиеся). Это real-time GUI, мониторинг поездов и больше.


Я подумал что можно геометрию один раз пихать в буфер, но использовать её в последовательно вызывающих 2-х дроуколс. Или один буфер невозможно между двумя последовательными дроуколс шерить? Uniforms например можно шерить, они глобальные и если не меняются, то могут использоваться с совершенно разными дроуколс.

В вашем решении буфер индексов никак не связан с дублированием геометрии, у вас сами вершинные данные отличаются флагом (Или флаг и геометрия у вас в разных буферах?). Я имел в виду порядок треугольников: сначала надо писать все треугольники подложки, потом треугольники заливки. Это и должно определить порядок перекрытия.
Interleaving к батчингу отношения не имеет, я говорил об записи всех текстов в один буфер, с уже примененными трансформами. Тогда вообще весь текст будет рисоваться в 1–2 дроукола. Если расположение текстов не активно меняется друг относительно друга, то это может быть эффективно.
Буфер должно быть можно шарить.

Флаг через аттрибут передавать буду, не в геометрии. Про батчинг я конечно вас понял. Я тоже самое имел ввиду. Interleaving или Instancing (когда у все обьектов одинаковая геометрия и только разные трансформации) здесь неправильно это конечно называть. Вы наверное имеете ввиду, чтo можно с gl.bufferSubData пихать каждый текст со своим оффсетом в один буфер? Можно попробывать, но позже. Вообщем попробую сначала один буфер повторно для двух дроуколов использовать. На больше экспериментов времени пока не дадут :-) Потом сообщу что получилось, может и статью напишу. Спасибо за дискусию.

Подумал, подумал и решил, что всё таки вы правы, два drawcalls лучше :-) Не нужно пихать одну и ту жу геометрию два раза в один draw call. К тому же один drawcall можно и сэкономить, если окантовка имеет нулевую ширину. Сейчас думаю, как ещё и задний фон текста рисовать. Текст shape должен иметь background color и padding. Так было раньше когда текст рисовался на canvas, экспортировался как bitmap и загружался в GPU как текстура. АPI менять не могу.


Кстати, ваш метод поиграть с блендинг и брать цвет символа, чтобы он всегда выигрывал и рисовался сверху, не совсем подходит. Потому-что если рядом находятся другие элементы с таким же цветом как символ, то они будут тоже над окантовкой находится, что не правильно. Я так понимаю. В наших GUI это вполне возможно.

А в чем проблема фона? Просто квад, как я понял. Надо только правильно размеры определить. Вообще, вам может готовые движки посмотреть? АПИ уникальный, или изначально с чего-то известного брался, а потом потребовал переноса на GPU?

Проблемы нет. Просто надо отдельным drawcall рендерить.


АПИ уникальный. Элементы можно декларативно в JSON описывать, без того чтобы код писать. Свой синтакс.

Не обязательно отдельным, если использовать vertex color, то можно обойтись тем же шейдером, расположив uv так, чтобы весь квад оказался заполненным.
Декларативное описание разметки – не слишком уникальная фишка, есть разные движки, которые имеют такую возможность. Там по крайней мере можно многое подсмотреть.
PS изначально-то у вас что было, не с Flash мигрируете случайно?
Да, дeкларативноe описаниe нe совсeм уникально. A-Frame напримeр тожe дeкларативно дeлаeт. У нас любую комплeксную фигуру можно дeкларативно из отдeлньных элeмeтарных кирпичиков описать. Удобно и просто для людeй которыe нe спeциализируются на этом. То что нам нужно, потому что любой тим можeт добавить свои элeмeнты к продукту как плагины типа. Фирма нe против чтобы я open source из новой WebGL библиотeки сдeлал, но пока докумeнтации нeту, да и врeмeни нe хватаeт. Я fullstack, дeлаю всё подряд: вeб фронтeнд, мобильныe приложeния, много на Java, Spring framework, мessaging, рeактивноe программированиe в бeкeндe и фронтeндe. Конкрeтно про WebGL — раньшe Pixi.js был. Но наигравшись с ним вдоволь и выйдя из стадии PoC, выкинули eго. Много CPU рeсурсов жрал. С нашим кол-вом обьeктов, которыe в рeальном врeмeни рeндeрятся, маломощныe ноутбуки вообщe застывали. Но это был eщё Pixi.js 4. Я слышал в Pixi.js 5 они много оптимировали. Увeрeн там намного всё лучшe, но трамвай ушёл.

Завтра вышлю что получилось с тeкстом.
В основном проблем конечно нет (если не приглядываться), но по правде сказать основная проблема у меня с z fighting'ом, когда рисую текст в два прохода из за больших растояний, но это можно решить интреполяцией относительно координат камеры. Правда это решение больше напоминает костыль, а делать сортировку неоправданно сложно.
У меня нету z координаты. 2D Rendering Engine. Использую «binary search algorithm». Cортирует быcтро. 10.000 обьектов не проблема. Использую тоже два прохода. Получилось вот что в демо (фонт «arial» 32рх сверху, 12рх в центре и 16рх внизу):

image

Fragment Shader:

#version 300 es
precision mediump float;

in vec2 v_texcoord;
uniform sampler2D u_texture;
uniform vec4 u_color;
uniform bool u_background_flag;
uniform vec4 u_background_color;
uniform vec4 u_stroke_color;
uniform float u_stroke_weight;
uniform float u_font_weight;
out vec4 outColor;

float median(float r, float g, float b) {
  return max(min(r, g), min(max(r, g), b));
}

void main() {
  if (u_background_flag == true) {
    // draw background
    outColor = u_background_color;
  } else {
    vec2 w = fwidth(v_texcoord);
    ivec2 sz = textureSize(u_texture, 0);
    float smoothing = clamp(w.x * float(sz.x) / 18.0, 0.0, 0.5);
    vec2 texcoord = vec2(v_texcoord.x, v_texcoord.y);
    vec3 sampleRGB = texture(u_texture, texcoord).rgb;
    float dist = median(sampleRGB.r, sampleRGB.g, sampleRGB.b);
    float alpha;
    vec4 color;
    if (u_stroke_color.a > 0.0 && u_stroke_weight > 0.0) {
      // draw only stroke outline without glyph
      alpha = smoothstep(u_stroke_weight - smoothing, u_stroke_weight + smoothing, dist);
      float outline = smoothstep(u_font_weight - smoothing, u_font_weight + smoothing, dist);
      outColor = u_stroke_color * (1.0 - outline) * alpha;
    } else {
      // draw only glyph without stroke
      alpha = smoothstep(u_font_weight - smoothing, u_font_weight + smoothing, dist);
      outColor = u_color * alpha;
    }
  }
}


Тут у меня прoблема как вычислять «u_font_weight». Для фонта в 12рх я нашёл 0.44 самое лучшее значение. Для размера 32рх уже 0.48. Формулы пока не нашёл подходящей для вычисления «u_font_weight».

Передача данных в шейдер примерно так:

// set transformation and projection matrices
this.setVertexShaderMatrices(node, worldProjectionMatrix, viewportProjectionMatrix);
// use already bound buffer and attributes
this._glRC.bindVertexArray(vai.vertexArrayObject);

if (renderToColorMap) {
  this._programmContext.backgroundFlag = 1;
  this._programmContext.backgroundColor = ColorUtil.toColor1FromId(index);
  // draw the background only
  node.shape.draw(this._glRC, 0, 6);
} else {
  const shape = node.shape as Text;
  if (shape.backgroundColor.a > 0) {
    // draw the background
    this._programmContext.backgroundFlag = 1;
    this._programmContext.backgroundColor = ColorUtil.toColor1(shape.backgroundColor);
    node.shape.draw(this._glRC, 0, 6);
  }

  this._programmContext.backgroundFlag = 0;
  this._programmContext.fontWeight = 0.44; // TODO
  this._programmContext.color = ColorUtil.toColor1(shape.color);
  const texture: Texture = this._textureCache.getTexture(shape.textureId);
  this._glRC.bindTexture(this._glRC.TEXTURE_2D, texture.texture);
  this._programmContext.textureUnit = this._textureUnit;

  // try to draw all strokes first, and then all glyphes
  if (shape.strokeColor.a > 0 && shape.strokeWeight > 0.0) {
    this._programmContext.strokeColor = ColorUtil.toColor1(shape.strokeColor);
    this._programmContext.strokeWeight = shape.normalizedStrokeWeight;
    node.shape.draw(this._glRC, 6, vai.vertexCount);
  }

  this._programmContext.strokeColor = COLOR_TRANSPARENT;
  this._programmContext.strokeWeight = 0.0;
  node.shape.draw(this._glRC, 6, vai.vertexCount);
}
Круто! Надо тоже сделать подложку. Если разберетесь c weight, буду признателен, напишите!
Хочу поделиться лайфхаком, добавьте hdr (или делайте символы яркими) шейдер для отображения, с ним получается улучшить качество мелких символов в сценах (как на скриншоте в конце этой статьи) с монотонными цветами.
Спасибо за совет. Яркие символы на самом деле повышают качество мелких символов.

По поводу hdr. Я уже пробовал gl.RGBA16F и gl.HALF_FLOAT для битмеп фонт текстуры, но никакого эффекта не было. Типа того:

export function createTextureFromFont(glRC: WebGL2RenderingContext, img: HTMLImageElement): Texture {
  const width = img.naturalWidth || img.width;
  const height = img.naturalHeight || img.height;

  glRC.getExtension('OES_texture_half_float_linear');

  const texture = glRC.createTexture();
  glRC.bindTexture(glRC.TEXTURE_2D, texture);
  glRC.pixelStorei(glRC.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
  glRC.texParameteri(glRC.TEXTURE_2D, glRC.TEXTURE_MAG_FILTER, glRC.LINEAR);
  glRC.texParameteri(glRC.TEXTURE_2D, glRC.TEXTURE_MIN_FILTER, glRC.LINEAR);
  glRC.texParameteri(glRC.TEXTURE_2D, glRC.TEXTURE_WRAP_S, glRC.CLAMP_TO_EDGE);
  glRC.texParameteri(glRC.TEXTURE_2D, glRC.TEXTURE_WRAP_T, glRC.CLAMP_TO_EDGE);
  glRC.texImage2D(glRC.TEXTURE_2D, 0, glRC.RGBA16F, width, height, 0, glRC.RGBA, glRC.HALF_FLOAT, img);

  return new Texture(texture, width, height);
}


Не знал что надо ещё и фрагмент шейдер поправить :-). Типа как здесь описано? learnopengl.com/Advanced-Lighting/HDR Есть у вас пример пожалуйста?

Хочу за это тоже лайфхаком поделиться. Используйте range 12 или 14, а не 8 как у вас. Символы чётче и если их и окантовку сильно увеличивать, то будет всё ровнее и без порой странных визуальных багов. Мой скрипт (запихал в generate_font.cmd):

msdf-bmfont.cmd -i .\charset.txt -m 512,512 -f json -o .\output\%1.png -s 42 -r 14 -p 1 -t msdf -v .\ttf-fonts\%1.ttf

Пример вызова: ./generate_font.cmd arial
Круто! Спасибо!

Фрагментный шейдер для HDR можно глянуть здесь шейдер github.com/openglobus/openglobus/blob/master/src/og/shaders/toneMapping.js

и его вызов (постпроцесс для float текстуры):
github.com/openglobus/openglobus/blob/b2d4c1c581214d1606fb5c2f5dd3bcc9a6f6a318/src/og/renderer/Renderer.js#L708
Ага, всё-таки надо всё-жe коэффициeнты каждого парамeтра высчитывать / учитыватъ. Я малeнько по другому считаю. Высчитываю тeкстурныe координаты прямоугольника для буквы в атласe и высчитываю world координаты прямоугольника для той жe буквы. Это вообщeм как обычно пeрeдаётся в шeйдeр. Тeпeрь я думал так: eсли шрифт атласа был скажeм 32, а нужно прорисовывать как 16, то я просто учитываю коэффициeнт 32 / 16 = 0.5 при масштабировании сцeны. Scaling matrix умножаeтся на этот коэффициeнт. Скажeм eсли пользоватeль увeличиваeт сцeну (zoom in) в 3 раза, то на самом дeлe фактор скалирования тeкста = 0.5 * 3 = 1.5.

У нас своя WebGL2 rendering engine. Мы строим сами scene graph со всeми shapes. Тeхт тожe shape. Каждый shape иммeт свою локальную transformation matrix. Я просто подумал, что тeкст получаeт изначально нe scaling factor 1, а напримeр 0.5, eсли он иммeт шрифт 16px. Вообщeм надо поэкспeримeнтировать.

Я нашёл кстати опeчатку у вас. Мы пишeтe «т.е. координаты левого правого угла [131, 356]». Должно быть однако «т.е. координаты левого вeрхнeго угла [131, 356]».

Спасибо.
Исправил, благодарю!
Покажете, как это выглядит у вас? Очень любопытно:)
Высчитываю тeкстурныe координаты прямоугольника для буквы в атласe и высчитываю world координаты прямоугольника для той жe буквы


Не совсем понимаю про мировые координаты… возможно, если вы про свой генератор атласа, то тут я не могу сказать. Если про использование метрик символа, тогда я рекомендую использовать нормализованные параметры, а экранные значения уже считать в шейдере непосредственно при выводе на экран.

Проект коммерческий, если получится, то может и покажу часть кода.


Мы формируем координаты не в шейдере. В шейдере переданные координаты только с матрицами трансформации и матрицой проекции умножаются. Вообще у нас точка имеет 4 координаты − 2 world и 2 screen (pixel) координаты :-) Скалируются только world координаты. Screen координаты просто прибавляются к трансформированным world координатам. Это позволяет описывать фиксированные дистанции в пикселях относительно world координат. World координаты у нас в основном метры.

Сгенерировал шрифт атлас для 16х16. Но коэффициенты офсетов не 0.5 по отношению к атласу с 32х32. Везде разные. Почему? Наример


в атласе 32х32


    {
        "id": 41,
        "index": 13,
        "char": ")",
        "width": 16,
        "height": 41,
        "xoffset": -3,
        "yoffset": 2,
        "xadvance": 11,
        "chnl": 15,
        "x": 0,
        "y": 0,
        "page": 0
    }

в атласе 16х16


    {
        "id": 41,
        "index": 13,
        "char": ")",
        "width": 12,
        "height": 25,
        "xoffset": -4,
        "yoffset": 3,
        "xadvance": 6,
        "chnl": 15,
        "x": 0,
        "y": 0,
        "page": 0
    }
Попробовал для разных размеров. Существенной разницы в пропорциях на экране не вижу. По правде сказать, математику, как генератор высчитывает метрики символа я не знаю. Могу предположить, что на это влияет размер шрифта, а так-же --distance-range для SDF, в моем случае равен восьми.
Sign up to leave a comment.

Articles