Comments 33
Вот здесь есть пример, где все очень просто и ничего лишнего.
https://discourse.threejs.org/t/an-example-of-text-to-canvas-to-texture-to-material-to-mesh-not-too-difficult/13757
Хорошая статья, которая мне помогла. Одна неясность только. Если фонт генерируется с fontSize = 32, а пользователь например выбирает 16рх как начальный размер для своих текстов, то нужно пересчитывать всю геометрию фонта (оффсеты, ширину, и.п.) или можно просто передать матрицу трансформации c другим scaling фактором в vertex шейдер? Фонт размера 16px например уменьшает текст в два раза. Означает это что можно просто передать scaling matrix, уменьшая всё в два раза, и в остальном не менять все начальные вычисления по сдвигу букв и т.д.? Спасибо заранее.
vec2 v = screenPos + (a_vertices * vec2(nWidth, nHeight) + vec2(nXOffset, nYOffset) + vec2(advanceOffset, 0.0)) * a_size;
advanceOffset — так же сумма нормализованных значений xadvance (nAdvance) каждого последующего символа относительно начала координат.
От размеров квада на экране зависит следующее. Поскольку в текстуре закодировано не растровое изображение глифа, а расстояние до кривой, его описывающей, то шейдер должен позаботиться о всех аспектах растеризации, в том числе о сглаживании.
Самый простой вариант сглаживания, доступный в шейдере – рисовать пиксели, близкие к границам символа полупрозрачными. Нюанс в том, что шейдер работает в нормализованном пространстве символа. То есть, если мы подбираем параметры, определяющие «достаточно близко к границе», чтобы они примерно равнялись 1 пикселю для квада в 16 экранных пикселей, то на кваде в 64 пикселя это будет выглядеть уже не как сглаживание, а как размытие в 4 пикселя. Видимо, именно это в понимаете под «качеством».
Если весь текст в drawcall одного кегля, то вместе с матрицей можно рассчитывать и параметр сглаживания, передавая его так же в константу. Я делал прототип для произвольных экранных размеров квада так: в вершины писал дополнительно атрибут размеров квада в пикселях, а в шейдере считал параметр сглаживания по логистической кривой.
По тем же принципам определения пикселей вблизи границы, можно рисовать и окантовку – часто пример MSDF-шейдера ее содержит. У вас была какая-то хитрая задумка, которая потребовала рисовать окантовку в отдельный проход?
Задумка не хитрая, просто пока не понимаю можно ли рисовать окантовку вместе с основным текстом в один проход, разными цветами например.
Это будет что-то вроде передать два веса, рассчитать две opacity, а потом
gl_FragColor = (
color * fillAlpha * color.a
+ strokeColor * strokeColor.a * strokeAlpha * (1.0 - fillAlpha)
)
этот кусок шейдера взял отсюда.
АПИ то идеальное, это да. Но символы то рисуются по очереди слева направо. Это означает что окантовка следуещего символа может лежать частично поверх предыдущего символа, если окантовка достаточно жирная. У меня при одном проходе вот что получается:
https://i.imgur.com/QBUrkWI.png
Два прохода ещё не пробовал. Что думаете?
Блендинг я нашёл оптимальный и не хочу его менять. А 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 это вполне возможно.
Проблемы нет. Просто надо отдельным drawcall рендерить.
АПИ уникальный. Элементы можно декларативно в JSON описывать, без того чтобы код писать. Свой синтакс.
Декларативное описание разметки – не слишком уникальная фишка, есть разные движки, которые имеют такую возможность. Там по крайней мере можно многое подсмотреть.
PS изначально-то у вас что было, не с Flash мигрируете случайно?
Завтра вышлю что получилось с тeкстом.
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);
}
Хочу поделиться лайфхаком, добавьте 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
У нас своя 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
}
Рендеринг шрифтов для WebGL при помощи инструмента msdf-bmfont-xml и технологии MSDF