Ольга Стефанишина
← Назад

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

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

Содержание

...

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

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

В этой статье мы рассмотрим технику рендеринга "пинг-понг" — паттерн, который устраняет это ограничение и создаёт циклы обратной связи в GPU-программировании. Мы будем основываться на коде из предыдущей статьи.

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

Проблема с ветвлением в шейдерах

В шейдере постобработки из предыдущей статьи использовался оператор if для условного применения эффекта:

glsl
1if (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 и сохранить тот же визуальный результат:

glsl
1#version 300 es
2precision highp float;
3
4in vec2 vUV;
5out vec4 outColor;
6
7uniform sampler2D sceneTexture;
8uniform float splitPosition;
9uniform bool enableEffect;
10
11void main() {
12    // Инвертируем координату Y для правильной выборки
13    vec2 flippedUV = vec2(vUV.x, 1.0 - vUV.y);
14    vec3 sceneColor = texture(sceneTexture, flippedUV).rgb;
15
16    // Вычисляем оттенки серого по формуле яркости
17    float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));
18    vec3 grayscaleColor = vec3(grayscale);
19
20    // Создаём маску: 0.0 до позиции разделения, 1.0 после
21    float mask = step(splitPosition, vUV.x) * float(enableEffect);
22
23    // Смешиваем исходный цвет и оттенки серого
24    vec3 finalColor = mix(sceneColor, grayscaleColor, mask);
25
26    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 нам нужны два фреймбуфера с идентичными конфигурациями, которые могут чередоваться между тем, чтобы быть источником текстуры и целью рендеринга.

Вот как структурировать настройку "пинг-понг":

javascript
1function createPingPongFramebuffers(gl, width, height) {
2 const fboA = createFramebuffer(gl, width, height);
3 const fboB = createFramebuffer(gl, width, height);
4
5 return {
6 read: fboA,
7 write: fboB,
8 swap: function () {
9 [this.read, this.write] = [this.write, this.read];
10 },
11 };
12}
13
14const fboPair = createPingPongFramebuffers(gl, gl.canvas.width, gl.canvas.height);

Это создаёт пару фреймбуферов с прикреплёнными к ним текстурами (используя функцию createFramebuffer из предыдущей статьи). В возвращаемом объекте свойство read указывает на фреймбуфер, содержащий текстуру предыдущего кадра; свойство write указывает на фреймбуфер, который будет принимать вывод текущего кадра; а метод swap обменивает эти ссылки.

После рендеринга каждого кадра вызов swap() подготавливает систему к следующей итерации — то, что только что было записано, становится источником для следующего чтения, а старый источник становится доступным для записи.

Пример: Итеративный эффект затухания

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

Шейдеры

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

glsl
1// 1. Шейдер начальной сцены (рисует круг)
2const initialSceneFragSrc = `#version 300 es
3 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 }`;
11
12// 2. Шейдер обратной связи (считывает предыдущий кадр и осветляет его)
13const feedbackFragSrc = `#version 300 es
14 precision highp float;
15 in vec2 vUV;
16 out vec4 outColor;
17 uniform sampler2D uPreviousFrame; // Texture from the 'read' FBO
18 void main() {
19 vec4 previousColor = texture(uPreviousFrame, vUV);
20 // Slowly fade the color
21 outColor = previousColor * 0.98;
22 }`;

Цикл рендеринга на JavaScript

Цикл рендеринга организует процесс: привязывание правильных FBO, отрисовка, обмен и, наконец, рендеринг результата на экран.

javascript
1// Настройка (упрощено)
2const fboPair = createPingPongFramebuffers(gl, width, height);
3
4// Рисуем начальную сцену один раз в fboPair.read
5// ...
6
7// Цикл рендеринга — акцент на паттерне "пинг-понг"
8function render() {
9 // --- Проход обратной связи ---
10 gl.bindFramebuffer(gl.FRAMEBUFFER, fboPair.write.framebuffer);
11 gl.useProgram(feedbackProgram); // Читаем из предыдущего кадра
12
13 gl.bindTexture(gl.TEXTURE_2D, fboPair.read.texture);
14 gl.uniform1i(previousFrameLocation, 0); // Рисуем полноэкранный квад
15
16 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // --- Проход вывода на экран ---
17
18 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 для следующего кадра
21
22 fboPair.swap();
23
24 requestAnimationFrame(render);
25}

Выполнение этого кода покажет оранжевый круг (или овал), который медленно затухает до чёрного. Вы можете изучить полный рабочий пример на GitHub.

Заключение

Мы рассмотрели технику пинг-понг — фундаментальный паттерн в GPGPU-программировании, который позволяет создавать эффекты, основанные на циклах обратной связи.

В следующей статье мы реализуем шаг адвекции.


Это часть моей серии статей по реализации интерактивных 3D-визуализаций с помощью WebGL 2.

Следующая статья: Симуляция жидкости в WebGL: Этап адвекции

Поговорим?