Рендеринг с сохранением состояния с помощью техники "пинг-понг"

Содержание
- Оптимизация производительности фрагментного шейдера
- Создание цикла обратной связи
- Техника "Пинг-Понг"
- Пример: Итеративный эффект затухания
- Заключение
...
Предыдущая статья о рендеринге в текстуры с помощью фреймбуферов заложила основу многопроходного рендеринга с использованием объекта фреймбуфера. В ней было показано, как рендерить в текстуру, а затем использовать её для постобработки, но только для эффектов, которые преобразуют каждый кадр независимо.
Это ограничение означает, что мы не можем создавать итеративные эффекты, которые зависят от данных предыдущего кадра с течением времени. При симуляции жидкостей, например, необходима эта возможность ссылаться на прошлое состояние.
В этой статье мы рассмотрим технику рендеринга "пинг-понг" — паттерн, который устраняет это ограничение и создаёт циклы обратной связи в GPU-программировании. Мы будем основываться на коде из предыдущей статьи.
В симуляциях жидкостей, к которым мы движемся, выполняются сотни итераций в секунду, поэтому каждое улучшение производительности имеет значение. Сначала оптимизируем шейдер постобработки из предыдущей статьи.
Проблема с ветвлением в шейдерах
В шейдере постобработки из предыдущей статьи использовался оператор if
для условного применения эффекта:
glsl1if (uEnableEffect && vUV.x > uSplitPosition) {2 float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));3 outColor = vec4(vec3(grayscale), 1.0);4} else {5 outColor = vec4(sceneColor, 1.0);6}
Ветвления в шейдерах могут негативно сказаться на производительности. GPU выполняют шейдеры группами, называемыми варпами (NVIDIA) или волновыми фронтами (AMD), обычно содержащими 32 или 64 потока, где все потоки должны выполнять одну и ту же инструкцию. Когда потоки внутри варпа выбирают разные пути ветвления, GPU должен последовательно выполнять их — запуская каждый путь отдельно, и некоторые потоки ждут, пока другие выполняются. Это называется дивергенцией.
Хотя этот шейдер не будет использоваться в примере текущей статьи, давайте посмотрим, как бы он выглядел без ветвления, потому что, хотя операторы if
распространены в большинстве языков программирования, программирование шейдеров часто требует других подходов для поддержания производительности. И мы будем использовать эти техники позже.
Это ветвление может быть устранено с помощью встроенных GLSL-функций.
Решение: Безветвиевой подход
Мы можем исключить оператор if
и сохранить тот же визуальный результат:
glsl1#version 300 es2precision highp float;34in vec2 vUV;5out vec4 outColor;67uniform sampler2D sceneTexture;8uniform float splitPosition;9uniform bool enableEffect;1011void main() {12 // Инвертируем координату Y для правильной выборки13 vec2 flippedUV = vec2(vUV.x, 1.0 - vUV.y);14 vec3 sceneColor = texture(sceneTexture, flippedUV).rgb;1516 // Вычисляем оттенки серого по формуле яркости17 float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));18 vec3 grayscaleColor = vec3(grayscale);1920 // Создаём маску: 0.0 до позиции разделения, 1.0 после21 float mask = step(splitPosition, vUV.x) * float(enableEffect);2223 // Смешиваем исходный цвет и оттенки серого24 vec3 finalColor = mix(sceneColor, grayscaleColor, mask);2526 outColor = vec4(finalColor, 1.0);27}
Функция step(edge, x)
возвращает 0.0
, когда x < edge
(исходный цвет), и 1.0
, когда x >= edge
(цвет в оттенках серого).
Функция mix()
интерполирует между двумя цветами на основе этого коэффициента.
float()
— функция преобразования типов, которая преобразует другие типы данных в float
.
dot()
— вычисляет скалярное произведение двух векторов путём умножения соответствующих компонентов и их суммирования.
Коэффициенты vec3(0.299, 0.587, 0.114)
— это формула яркости, она представляет, как человеческие глаза воспринимают яркость. Это стандартные коэффициенты для sRGB (Rec. 709).
Это устраняет ветвление и сохраняет тот же визуальный результат, гарантируя, что все потоки в варпе выполняют одни и те же инструкции для оптимальной производительности GPU. Это решение выполняет больше вычислений в целом, но оно избегает дивергенции и часто работает быстрее на GPU. В симуляциях жидкостей, где шейдеры выполняются сотни раз за итерацию, эти улучшения действительно имеют значение.
Теперь перейдём к главной задаче: созданию эффектов, которые развиваются со временем. Для этого требуется особый шаблон рендеринга, называемый циклом обратной связи.
Создание цикла обратной связи
Цикл обратной связи в GPU-программировании — это шаблон рендеринга, при котором вывод каждого кадра зависит от состояния предыдущего кадра. Вычисленный результат возвращается в следующую итерацию, создавая непрерывную эволюцию данных с течением времени.
Многие GPU-эффекты основаны на циклах обратной связи:
- Размытие в движении (Motion blur): Смешивание текущего кадра с накопленными предыдущими кадрами.
- Симуляция жидкостей: Обновление скорости на основе предыдущей скорости и давления.
- Распространение тепла: Распространение температуры с использованием предыдущего распределения температуры.
- Следы частиц: Накопление позиций частиц, которые сохраняются и затухают.
Для создания цикла обратной связи нужно использовать текстуру предыдущего кадра в качестве входных данных при генерации нового кадра. Но WebGL запрещает чтение из текстуры во время рендеринга в неё. Попытка привязать текстуру одновременно как входную и как целевую для рендеринга приводит к неопределённому поведению. Спецификация OpenGL явно запрещает такую конфигурацию, и реализации могут выдавать искажённый вывод, приводить к сбоям драйвера или аппаратным исключениям.
Техника "Пинг-Понг"
Техника «пинг-понг» — это стандартное решение для реализации циклов обратной связи на GPU. Вместо попыток читать и записывать в одну и ту же текстуру, она использует две текстуры, которые чередуются между ролями чтения и записи каждый кадр. Это может быть реализовано несколькими способами, но наиболее распространенный подход — использовать два идентичных фреймбуфера, каждый со своей прикрепленной текстурой, которые меняются ролями: пока один предоставляет предыдущее состояние для чтения, другой получает новое состояние. После каждого кадра они обмениваются ролями — отсюда и название "пинг-понг". Этот обмен предотвращает конфликты чтения/записи, сохраняя при этом непрерывную эволюцию состояния, необходимую для циклов обратной связи.
В рамках этой инфраструктуры шейдеры выступают в качестве вычислительного движка. Каждый кадр шейдер считывает данные из текущей текстуры "для чтения", применяет вашу логику симуляции (затухание, размытие, физику и т. д.) и записывает результат в фреймбуфер "для записи". Техника "пинг-понг" управляет состоянием, в то время как ваш шейдер определяет, что на самом деле происходит с этим состоянием.
Реализация техники "Пинг-Понг"
Реализация требует управления двумя FBO и механизмом для смены их ролей. В терминах WebGL нам нужны два фреймбуфера с идентичными конфигурациями, которые могут чередоваться между тем, чтобы быть источником текстуры и целью рендеринга.
Вот как структурировать настройку "пинг-понг":
javascript1function createPingPongFramebuffers(gl, width, height) {2 const fboA = createFramebuffer(gl, width, height);3 const fboB = createFramebuffer(gl, width, height);45 return {6 read: fboA,7 write: fboB,8 swap: function () {9 [this.read, this.write] = [this.write, this.read];10 },11 };12}1314const fboPair = createPingPongFramebuffers(gl, gl.canvas.width, gl.canvas.height);
Это создаёт пару фреймбуферов с прикреплёнными к ним текстурами (используя функцию createFramebuffer
из предыдущей статьи). В возвращаемом объекте свойство read
указывает на фреймбуфер, содержащий текстуру предыдущего кадра; свойство write
указывает на фреймбуфер, который будет принимать вывод текущего кадра; а метод swap
обменивает эти ссылки.
После рендеринга каждого кадра вызов swap()
подготавливает систему к следующей итерации — то, что только что было записано, становится источником для следующего чтения, а старый источник становится доступным для записи.
Пример: Итеративный эффект затухания
Давайте посмотрим, как создать простой эффект для демонстрации техники. Мы отрисуем начальный оранжевый круг в один из фреймбуферов. Затем, в цикле, мы будем непрерывно читать текстуру предыдущего кадра и умножать её на 0.98, создавая постепенный эффект затухания в чёрный.
Шейдеры
Нам нужны два шейдера: один для отрисовки начальной фигуры, и один для итеративной обработки.
glsl1// 1. Шейдер начальной сцены (рисует круг)2const initialSceneFragSrc = `#version 300 es3 precision highp float;4 in vec2 vUV;5 out vec4 outColor;6 void main() {7 float dist = distance(vUV, vec2(0.5));8 float circle = 1.0 - smoothstep(0.2, 0.21, dist);9 outColor = vec4(circle * 1.0, circle * 0.5, circle * 0.1, 1.0);10 }`;1112// 2. Шейдер обратной связи (считывает предыдущий кадр и осветляет его)13const feedbackFragSrc = `#version 300 es14 precision highp float;15 in vec2 vUV;16 out vec4 outColor;17 uniform sampler2D uPreviousFrame; // Texture from the 'read' FBO18 void main() {19 vec4 previousColor = texture(uPreviousFrame, vUV);20 // Slowly fade the color21 outColor = previousColor * 0.98;22 }`;
Цикл рендеринга на JavaScript
Цикл рендеринга организует процесс: привязывание правильных FBO, отрисовка, обмен и, наконец, рендеринг результата на экран.
javascript1// Настройка (упрощено)2const fboPair = createPingPongFramebuffers(gl, width, height);34// Рисуем начальную сцену один раз в fboPair.read5// ...67// Цикл рендеринга — акцент на паттерне "пинг-понг"8function render() {9 // --- Проход обратной связи ---10 gl.bindFramebuffer(gl.FRAMEBUFFER, fboPair.write.framebuffer);11 gl.useProgram(feedbackProgram); // Читаем из предыдущего кадра1213 gl.bindTexture(gl.TEXTURE_2D, fboPair.read.texture);14 gl.uniform1i(previousFrameLocation, 0); // Рисуем полноэкранный квад1516 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // --- Проход вывода на экран ---1718 gl.bindFramebuffer(gl.FRAMEBUFFER, null);19 gl.bindTexture(gl.TEXTURE_2D, fboPair.write.texture);20 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // Обмениваем FBO для следующего кадра2122 fboPair.swap();2324 requestAnimationFrame(render);25}
Выполнение этого кода покажет оранжевый круг (или овал), который медленно затухает до чёрного. Вы можете изучить полный рабочий пример на GitHub.
Заключение
Мы рассмотрели технику пинг-понг — фундаментальный паттерн в GPGPU-программировании, который позволяет создавать эффекты, основанные на циклах обратной связи.
В следующей статье мы реализуем шаг адвекции.
Это часть моей серии статей по реализации интерактивных 3D-визуализаций с помощью WebGL 2.
Следующая статья: Симуляция жидкости в WebGL: Этап адвекции