За пределами яркости: ASCII-рендерер с учетом формы в Three.js | Кодропс

За пределами яркости: ASCII-рендерер с учетом формы в Three.js | Кодропс



Примечание редактора: Поскольку наша конференция Three.js продолжается, мы рады приветствовать Эдоардо Лунарди с увлекательным исследованием рендеринга ASCII. Выходя за рамки простого изменения яркости, Эдоардо показывает, как выборка с учетом формы, поиск глифов на основе графического процессора и Three.js могут преобразовать 3D-объект в удивительно детализированный интерактивный отпечаток ASCII.

🥖 Собирайте чемоданы. Париж зовет! Самая первая конференция Three.js пройдет в Париже. Использовать код CODROPS для скидка 15% и получить билет →

Каждый шейдер ASCII, который я читал, делает то же самое. Сэмплируйте сцену, вычислите яркость, внесите индекс в рампу, например .:-=+*#%@сделанный. Десять строк, и оно будет держаться до тех пор, пока у объекта не появится край.

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

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

Я перестроил знак Codrops как твердое тело, которое можно перетаскивать, полностью напечатанное в ASCII на графическом процессоре. Каждая символьная ячейка выбирает шесть точек внутри себя и десять в ячейках вокруг нее, строит вектор формы из шести значений, а затем ищет ближайшее совпадение во всех 95 печатных глифах. Каждая клетка, каждый кадр.

Метка печатается дважды из одной и той же позы: шкала яркости слева и поиск формы справа. Рампа превращает диагональный край в шум, отсортированный по яркости; поиск разбивает одно и то же ребро на символы, следующие за ним.

Три прохода, один поиск глифа

Рендерер выполняет три прохода на кадр, и их разделение делает поиск достаточно дешевым, чтобы его можно было вообще запустить.

renderer.setRenderTarget(this.#sceneTarget);
renderer.render(this.#scene, this.#camera);

this.#quad.material = this.#cellMaterial;
renderer.setRenderTarget(this.#cellTarget);
renderer.render(this.#frame, this.#frameCamera);

this.#quad.material = this.#postMaterial;
renderer.setRenderTarget(null);
renderer.render(this.#frame, this.#frameCamera);
Три цели для одного кадра: проход сцены с собственным освещением, целевая ячейка, окрашенная в ложный цвет в соответствии с индексом глифа, выбранным каждой ячейкой, и окончательный отпечаток.

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

Поиск выполняется один раз для каждой ячейки, а в базовой ячейке размером 6 на 10 пикселей CSS это один раз на 60 пикселей, а не один раз на пиксель. Размер целевой ячейки также определяется в ячейках, а не в пикселях устройства, поэтому стоимость остается неизменной по мере увеличения доли устройств.

Построение отметки как настоящая геометрия

Метка представляет собой линзу с прорезанной через нее каплей, а не диск, выдавленный по Z. Проход клетки считывает тон, а выдавливание почти ничего не дает для чтения.

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

Почти плоская экструзия, напечатанная рядом с куполообразной линзой, та же поза и камера. Плоские грани преобразуются в один тон каждая и печатаются как капля с контуром; куполообразные грани придают каждой клетке свой тон для чтения.

Каждая грань вырезана из сферы, проходящей через край и середину. Два ограничения, поэтому один радиус:

const DOME_RADIUS = (OUTER_RADIUS * OUTER_RADIUS + domeSag * domeSag) / (2 * domeSag);
const DOME_CENTRE = Math.sqrt(DOME_RADIUS * DOME_RADIUS - OUTER_RADIUS * OUTER_RADIUS) - RIM_DEPTH / 2;

const domeZ = (r) => Math.sqrt(Math.max(DOME_RADIUS * DOME_RADIUS - r * r, 0)) - DOME_CENTRE;

domeZ дает высоту любой грани на любом расстоянии от центра, а по внешнему радиусу она составляет ровно половину толщины обода.

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

function dropletRadius(theta) {
  const dy = Math.sin(theta);
  const turn = theta - TANGENT_FROM - Math.floor((theta - TANGENT_FROM) / TAU) * TAU;

  if (turn 
Контур как полярная функция: дуга лампочки, две касательные линии, встречающиеся на вершине, и точки касания, где одна переходит в другую. На основе тех же констант, на основе которых построена геометрия.

Math.abs на косинусе — это то, что позволяет правой касательной обслуживать обе стороны зеркально-симметричного контура. Количество сегментов кратно четырем, поэтому одна ступенька попадает точно в вершину, и этот угол остается острым, а не отшлифовывается при выборке.

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

const nz = (Math.abs(z) + DOME_CENTRE) * side;
const length = Math.hypot(x, y, nz) || 1;

return [
  [x, y, z],
  [x / length, y / length, nz / length],
];

192 сегмента по 22 квадрата каждый дают 4224 квадрата или 8448 треугольников, встроенных один раз в неиндексированный BufferGeometry. Кривизна — это то, что читает отпечаток, поэтому плоскую геометрию невозможно сохранить позже в шейдере.

Запекание атласа глифов

Атлас растеризуется в браузере во время выполнения, независимо от моноширинной грани таблицы стилей, разрешенной на холсте. 95 глифов, пробел через тильду, нарисованные в сетке 10 на 10 с 8 пикселями за обрез вокруг каждой ячейки, чтобы глиф, выходящий за пределы рамки, не обрезался до ложного края.

ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${weight} ${Math.floor(Math.min(cellH * 0.92, cellW / 0.58))}px ${font}`;

for (let glyph = 0; glyph 
Запеченный лист, прямо с исходного холста, с заштрихованными внутренними ячейками. Обрез вокруг каждого прямоугольника — это то, что предотвращает попадание выходящего за край глифа в ложный край.

Размер — это меньший из двух вариантов: 92 % высоты ячейки или ширина ячейки более 0,58, поэтому как высокие, так и широкие глифы попадают внутрь поля, не измеряя ничего для каждого глифа.

Лицо загружено document.fonts.load а не ждать через document.fonts.readyпоэтому атлас запекается из предполагаемой грани, а не из того, что сначала разрешает резервный стек. Неудачная выборка по-прежнему сохраняется, к чему бы она ни возвращалась.

Шесть баллов вместо одного среднего

Каждый глиф получает вектор из шести значений, описывающий, где находятся его чернила, измеренный в шести фиксированных точках внутри ячейки:

const INNER_SAMPLES = [
  [0.28, 0.26],
  [0.72, 0.14],
  [0.28, 0.56],
  [0.72, 0.44],
  [0.28, 0.86],
  [0.72, 0.74],
];

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

Каждое значение представляет собой покрытие альфа-канала глифа внутри диска с радиусом 0,26 высоты ячейки вокруг его точки, поэтому штрих, проходящий рядом с образцом, по-прежнему регистрируется, а не попадает между касаниями.

Нормализовано для каждой точки выборки, а не глобально

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

for (let sample = 0; sample  0) {
    for (let glyph = 0; glyph 

Каждый из шести слотов масштабируется по своему пику среди 95 символов, поэтому слот, который не заполнен ни одним глифом, по-прежнему охватывает весь диапазон, а плоский тон продолжает распространяться по словарю, а не сжиматься в один глиф.

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

Векторы атласа поставляются в виде одноканальной плавающей текстуры размером 6 на 95, по одной строке на глиф, считываемой с помощью texelFetch поэтому ничего не фильтруется на входе.

Поиск, по одному фрагменту на ячейку

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

vec4 sampleCircle(vec2 c) {
  vec2 middle = cellBase + vec2(c.x, 1.0 - c.y) * uCellPx;
  float r = uCellPx.y * 0.161;
  vec4 acc = fetchTap(middle);

  for (int k = 0; k 

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

Яркость получается после отмены предварительного умножения, а затем снова взвешивается по освещению:

float circleLum(vec4 acc) {
  vec3 straight = acc.rgb / max(acc.a, 1e-4);

  return clamp(dot(straight, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0) * acc.a;
}

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

Сосед касается, чтобы ячейка знала, на какой стороне края она находится.

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

Каждый внутренний образец сравнивается с самым ярким соседом, лежащим в том направлении, в котором он обращен, а затем опускается вниз, если он проигрывает:

float dirContrast(float value, float ext) {
  float peak = max(value, ext);

  if (peak 
Шесть внутренних выборок и десять внешних отводов построены на участке ячейки размером 3 на 3. Решаемая ячейка является центральной; каждый внешний кран находится внутри соседнего.

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

Затем сам поиск, простое линейное сканирование без раннего выхода:

int best = 0;
float bestD = 1e9;

for (int g = 0; g 

570 операций вычитания-квадрата-накопления на ячейку с текстурой, достаточно маленькой, чтобы ее можно было разместить в кеше, и 112 выборок текстур сверху для шестнадцати образцов дисков.

Победитель уходит в альфа-канал как float(best) / 255.0. 95 символов помещаются под 255, поэтому обычная 8-битная цель RGBA несет индекс, и нет необходимости во втором вложении или формате с плавающей запятой.

Композитинг без MIP-швов

В почтовом проходе есть одна ловушка, и все, что выбирает атлас на ячейку, попадет в одну и ту же ловушку.

UV-развертки Атласа скачут прерывисто на каждой границе ячейки. Одна ячейка содержит решетку из середины листа, следующая — букву L из угла, поэтому UV представляет собой пилообразную форму с резким разрывом на каждом краю. Пусть графический процессор самостоятельно принимает производные от этого UV, и каждый переход читается как минимизированная в ничто текстура, поэтому он достигает наименьшего MIP, и вдоль сетки появляются слабые линии шва. Хуже того, то, появятся ли они вообще, зависит от размера ячейки и соотношения устройств, поэтому ошибка появляется и исчезает по мере изменения макета, и именно поэтому ее легко пропустить.

vec2 atlasStep = uAtlasInner / uAtlasGrid;
float mask = textureGrad(tAtlas, atlasUv, dFdx(cellPos) * atlasStep, dFdy(cellPos) * atlasStep).a;

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

Сохранение стоимости привязанной к сетке ячеек

Стоимость здесь является функцией сетки ячеек, а не холста. Размер целевой сцены составляет двенадцать пикселей на строку ячеек:

const scale = SCENE_CELL_PX / cellHeight;
const sceneWidth = Math.max(Math.round(width * scale), 1);
const sceneHeight = Math.max(Math.round(height * scale), 1);

this.#renderer.setPixelRatio(dpr);
this.#renderer.setSize(width, height, false);

this.#sceneTarget.setSize(sceneWidth,...



Источник

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх