Примечание редактора: Поскольку наша конференция Three.js продолжается, мы особенно рады, что Никколо Фэнтон присоединился к нам сегодня с завораживающим исследованием датамоша. Опираясь на видеокодеки и методы шейдеров, он показывает, как несколько тщательно нарушенных правил могут превратить сцену Three.js в нечто удивительно непредсказуемое.
🇫🇷 Пусть первый засчитается! Самая первая конференция Three.js пройдет в Париже, собрав сообщество на день переговоров, идей и связей. Если вы являетесь частью мира Three.js, не пропустите это. Использовать код CODROPS для скидка 15% и получить билет →
Видеокодек периодически кодирует внутреннее изображение, которое можно восстановить, не обращаясь к более раннему изображению. Между этими точками интеркодированные изображения прогнозируются на основе ранее реконструированных опорных изображений. На уровне блока кодер может выбрать смещение опорного изображения и закодировать остаточную коррекцию, или он может закодировать блок с использованием внутреннего предсказания, когда опорное предсказание бесполезно. Поскольку последовательные изображения часто похожи друг на друга, прогнозирование может сэкономить много данных.
Подавите внутреннее обновление при смене сцены, и декодер продолжит восстановление по ссылкам, которые все еще принадлежат исходящему кадру, одновременно обрабатывая данные, предназначенные для входящего. Пиксели старой сцены перетаскиваются до тех пор, пока позднее обновление не восстановит чистое изображение.
Видеохудожники уже давно используют варианты этого трюка с реальными файлами, используя такие инструменты, как шестнадцатеричные редакторы и модифицированные декодеры. Здесь я заимствую идею из реального времени: фрагментный шейдер действует как декодер сцены Three.js и по умолчанию подавляет обновление на время жеста.
Шаг 1: Декодер в одну строку
Вся реализация следует одной идее:
new frame = warp(previous frame, motion vectors) + residual
Предыдущий кадр означает последний реконструированный вывод декодера, а не текущий вывод средства рендеринга сцены. В этом проекте текстура движения хранит полученную из графики скорость экранного пространства: приближение поля смещения, которое может выбрать видеокодер, а не исходные векторы движения кодека или измерение физического движения поверхности.

Шаг 2: Самый маленький датамош, который работает
Начнем с петли обратной связи — той части, благодаря которой каждый поврежденный кадр становится источником следующего. На данный момент заполнитель предоставляет вектор движения.
Ключевое изменение заключается в том, где шейдер считывает свое изображение. В ходе постобработки обычно производится выборка кадра, который только что нарисовал рендерер. Вместо этого этот проход производит выборку собственного предыдущего вывода со смещением и смешивает его с небольшим количеством свежего рендеринга. С такой константой, как vec2(0.002, 0.0)история отклоняется в сторону, в то время как поступающая сцена постоянно пополняет ее, создавая стойкий след, а не бесконечно транслируемый, неизменный образ.
uniform sampler2D uScene; // this frame, as rendered
uniform sampler2D uHistory; // what we output last frame
uniform vec2 uMotion; // one constant vector for the whole screen
in vec2 vUv;
out vec4 fragColour;
void main() {
vec3 scene = texture(uScene, vUv).rgb;
vec3 dragged = texture(uHistory, vUv - uMotion).rgb;
// A small constant trickle of the fresh render keeps the loop stable;
// with the vector at zero the feedback settles back onto the scene.
fragColour = vec4(mix(dragged, scene, 0.12), 1.0);
}
WebGL накладывает одно ограничение на этот цикл: шейдер не может сэмплировать текстуру, прикрепленную к фреймбуферу, в который он в данный момент рисует. Поэтому обновление одного буфера истории невозможно. Мы храним два буфера, читаем из одного, записываем в другой и меняем их местами в конце каждого кадра.
Третья цель содержит визуализированную сцену для передачи декодирования в образец. Холст получает копию буфера истории, который мы только что написали. В псевдокоде расположение выглядит так:
// Read one target, write the other, then swap: the reference frame is
// always a texture nothing is currently drawing into.
pass.uniforms.uScene.value = colour.texture;
pass.uniforms.uHistory.value = history.read.texture;
pass.render(history.write); // decode into the spare target...
history.swap(); // ...which becomes next frame's reference
blit(history.read, null); // and also what reaches the canvas
Готовому проекту нужен только один явный буфер обратной связи, поскольку постобработка уже заменяет внутреннюю пару. Декодирование записывает вперед, затем проход копирования сохраняет результат в цели обратной связи. На диаграмме ниже показано это расположение, включая цель движения, введенную проходом скорости.

Эта нижняя петля создает эффект. Каждый кадр наследует повреждения предыдущего кадра, поэтому искажение продолжает нарастать до тех пор, пока вектор куда-то указывает.
Шаг 3: Скорость прохода
Постоянный вектор выглядит искусственным, поскольку каждому пикселю назначается одинаковое смещение. Средство рендеринга может определить, как видимая геометрия перемещается в пространстве экрана, поэтому мы используем это поле скоростей в качестве приближения, основанного на кодеке.
Мы рисуем сцену второй раз с переопределяющим материалом, который записывает скорость вместо цвета. Каждая вершина проецируется один раз с использованием текущей модели и матриц камеры, а затем еще раз с использованием матриц, сохраненных для того же объекта на один кадр раньше. Разница между этими проецируемыми позициями обеспечивает смещение, используемое эффектом.
uniform mat4 uPreviousModelMatrix; uniform mat4 uPreviousViewProjection; uniform float uHasPrevious;
varying vec4 vClipCurrent, vClipPrevious;
void main() {
vClipCurrent = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
// Without a stored previous matrix (first frame, object just added) the
// previous position is the current one: exactly zero velocity.
vClipPrevious = mix(vClipCurrent,
uPreviousViewProjection * uPreviousModelMatrix * vec4(position, 1.0), uHasPrevious);
gl_Position = vClipCurrent;
}
В одной детали легко ошибиться. Обе позиции клипа должны достигать фрагментного шейдера без разделения; перспективное разделение принадлежит этому фрагменту. Выполнение этого в вершинном шейдере кажется более дешевым и эквивалентным, но дает неверные значения. Положение экрана — это отношение, и интерполяция этого отношения отличается от деления двух интерполированных величин.
varying vec4 vClipCurrent, vClipPrevious;
void main() {
// The divide happens here, per fragment. Never in the vertex shader.
vec2 velocity = vClipPrevious.w
Шаг 4. Как убрать ключевой кадр
Пока что конвейер ведет себя как тщательно продуманное размытие изображения. Около четырех строк планирования превращают его в датамош.
Нажатие указателя, клавиши пробела или сенсорного ввода позволяет перейти к кадру с новой геометрией, палитрой и камерой. Декодер не получает никаких уведомлений. Он продолжает считывать последние выходные данные исходящего кадра, одновременно применяя векторы, измеренные из входящего. Это несоответствие приводит к размазыванию.
Порядок имеет значение: выполнение разреза после composer.render покупает один преднамеренный кадр задержки. Исходящий кадр попадает в цель обратной связи, когда входящий сначала визуализируется напротив него. А захват состояния предыдущего кадра сразу после монтажа означает, что сам прыжок камеры никогда не попадает в измерение скорости.
const gestureStart = active && !this.wasActive;
// A gesture never raises the keyframe. Only the periodic refresh does.
this.effect.set('uKeyframe', gopRefresh ? 1 : 0);
this.composer.render(deltaTime);
// The cut happens after the frame is on screen, so the feedback target still
// holds the outgoing shot when the incoming one is first rendered.
if ((gestureStart || gopRefresh) && controls.sceneCut && this.onCut()) {
this.camera.updateMatrixWorld(true);
}
// Store the post-cut state. The camera jump itself must never become a vector.
this.velocityPass.capturePreviousState();
this.effect.capturePreviousState();
Обновление также повышает uKeyframeпередавая декодеру чистый кадр.
После отпускания начинается короткое восстановление. По умолчанию более 370 мс счетчик изменяется от 0 до 1 и масштабирует движение и остаток вместе. Мазок замедляется до остановки, а затем растворяется в чистой штукатурке.
Шаг 5: Возвращаем деталь обратно
Настоящий кодер вычисляет остаток на основе прогноза с компенсацией движения, сделанного на основе восстановленных опорных изображений. Если бы я использовал полную разницу между текущим рендерингом и этим заведомо неправильным прогнозом, остаток устранил бы несоответствие и исправил бы датамош. Поэтому исправление должно оставаться заведомо неполным.
Используемая здесь версия сравнивает высокочастотную детализацию входящего рендеринга с деталями, уже присутствующими в прогнозе, полученном путем деформации предыдущего кадра. Он передает только ту часть, которая сильнее во входящем рендеринге. Этот вентиль делает коррекцию самоограничивающейся: как только прогноз имеет достаточно сильный фронт, остаток падает до нуля вместо того, чтобы снова добавлять тот же контур в контур обратной связи.
// High frequencies of the honest render, and of the prediction we just made.
float hc = dot(current - lowCurrent * 0.25, vec3(0.299, 0.587, 0.114));
float hp = dot(predicted - lowPredicted * 0.25, vec3(0.299, 0.587, 0.114));
// Once the prediction carries the stronger edge, the gate returns zero.
// The quantiser follows: under half a step there is nothing to send,
// so flat regions transmit nothing at all.
float steps = max(uResidualQuant, 1.0);
return vec3(floor((abs(hc) > abs(hp) ? hc - hp : 0.0) * steps + 0.5) / steps);
Остаток несет в себе сигнал яркости, подобный яркости, без цвета. Остаток RGB накладывает палитру нового снимка на старое изображение и делает удерживаемый кадр полупрозрачным.
Шаг 6. Замедление размытия обратной связи
Удерживая несколько секунд, изображение начинает смягчаться. Каждый вектор движения обычно указывает между пикселями текстуры, поэтому графический процессор смешивает соседние значения. Когда-то это сочетание безвредно, но этот эффект возвращает результат в следующий кадр. Повторение этого действия один раз для каждого кадра рендеринга шестьдесят раз в секунду со скоростью 60 кадров в секунду постепенно снижает контраст и детализацию.
Реконструкция Кэтмулла-Рома замедляет эту потерю. Он оценивает более четкое значение по окружающим пикселям, поэтому края сохраняются дольше, чем при обычной билинейной интерполяции. Цикл по-прежнему выполняет повторную выборку изображения в каждом кадре, а это означает, что некоторая мягкость остается неизбежной. Мы также ограничиваем результат допустимым цветовым диапазоном, поскольку даже небольшое превышение будет усилено обратной связью.
Шаг 7: Придаем ему вид сжатого
Теперь трубопровод образует гладкое жидкое пятно. Это может выглядеть красиво, хотя у битого видео другая визуальная грамматика: прямоугольники. Кодеки обрабатывают изображение блоками, и эффект должен отображать эту структуру.
Четыре механизма добавляют эту сжатую атмосферу, и все они контролируются uBlockiness.
Блоки. Шейдер считывает один вектор из центра каждой плитки и применяет его по всей плитке. При использовании блока размером 8 пикселей по умолчанию шестьдесят четыре пикселя имеют общий вектор.
Точность. Многие кодеки представляют движение яркости с точностью до субпикселя. Привязка к этой решетке превращает слайд в ступеньку. Без него соседние блоки могут отличаться на незначительные величины, что воспринимается как градиент. После защелкивания они совпадают или расходятся на полный прирост, создавая твердые разрывы между жесткими плитами. Я использую здесь слово «блок» в визуальном смысле: тайл 8×8 по умолчанию — это художественный выбор, а не утверждение о фиксированном размере макроблока одного кодека.
Пропускать. Ниже порогового значения движение блока затухает до нуля, поэтому его образец истории перестает смещаться. Остаток остается активным, что делает это удержанием, основанным на кодеке, а не буквальной реализацией пропущенного блока. Короткая рампа облегчает движение в это состояние, чтобы предотвратить видимый хлопок.
// 1. One vector for the whole tile, read at its centre. warp is the release
// fade, and mvUV is the block centre at full blockiness.
vec2 mvUV = mix(uv, blockUV, uBlockiness);
vec2 motion = rawMotionAt(mvUV) * uMotionGain * warp;
// 2. Snapped to a half- or quarter-pixel lattice ("Vector Precision"), so
// neighbours either match or differ by a whole step.
float mvSteps = max(uMvPrecision, 1.0);
vec2 motionPx = motion * resolution;
motion = mix(motionPx, floor(motionPx * mvSteps + 0.5) / mvSteps, uBlockiness) / resolution;
// 3. Under the threshold, motion fades to zero and the history sample holds.
// The residual path remains active later in the shader. The guard is
// load-bearing: smoothstep with equal edges is undefined in GLSL.
float skip = uSkipThreshold
Неправильный вектор. Четвертый механизм скрывается в первой строке фрагмента. В полном исходнике доля блок-центров в mvUV сдвиньте по диагонали и выберите вектор соседа. Это имитирует блокировку совпадения блоков на неправильном объекте.
Примечание о методе: ничего из этого…