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

Рендеринг в текстуры с помощью Framebuffers

Слева — цветной градиент, справа — его монохромная версия, демонстрирующая эффект постобработки.
Слева — цветной градиент, справа — его монохромная версия, демонстрирующая эффект постобработки.

Содержание

В этой статье мы изучим рендеринг в текстуры с помощью фреймбуферов — основополагающую технику для эффектов постобработки. Холстом для этих эффектов часто служит простой полноэкранный прямоугольник. Мы будем использовать прямоугольник, который мы создали в предыдущей статье. Та реализация работает, но она содержит избыточные данные и может быть оптимизирована. Начнем с оптимизации и потом перейдем к рендерингу в текстуры.

Проблема с дублирующимися вершинами

В предыдущей версии полноэкранный квад был определен следующим образом:

javascript
1const positions = new Float32Array([
2 -1, -1, // Нижняя левая
3 1, -1, // Нижняя правая
4 -1, 1, // Верхняя левая
5 -1, 1, // Верхняя левая (дубликат!)
6 1, -1, // Нижняя правая (дубликат!)
7 1, 1, // Верхняя правая
8]);

Этот массив содержит 6 вершин (12 чисел с плавающей точкой). Вершина по индексу 3 является дубликатом вершины по индексу 2, а вершина по индексу 4 дублирует вершину по индексу 1.

Решение: индексные буферы

Используя индексный буфер (также известный как element array buffer), можно устранить избыточные вершинные данные. Индексный буфер хранит целочисленные ссылки на массив вершин, что позволяет хранить каждую уникальную вершину ровно один раз, при этом по-прежнему определяя произвольные примитивы. Индексный буфер действует как набор инструкций, который говорит GPU: Чтобы нарисовать первый треугольник, соедини вершины 0, 1 и 2; для второго треугольника соедини вершины 2, 1 и 3. Такой подход на основе инструкций позволяет GPU эффективно переиспользовать вершинные данные.

javascript
1// Только 4 уникальные вершины (8 чисел)
2const positions = new Float32Array([
3 -1, -1, // 0: Нижняя левая
4 1, -1, // 1: Нижняя правая
5 -1, 1, // 2: Верхняя левая
6 1, 1, // 3: Верхняя правая
7]);
8
9// Индексный буфер: каждая группа из трех индексов определяет один треугольник
10const indices = new Uint16Array([
11 0, 1, 2, // Треугольник A: вершины 0 → 1 → 2
12 2, 1, 3, // Треугольник B: вершины 2 → 1 → 3
13]);

Визуальное представление

WebGL квадрат разделён на два треугольника с общими вершинами для индексной отрисовки
WebGL квадрат разделён на два треугольника с общими вершинами для индексной отрисовки

Диаграмма показывает, как квад разделен на два треугольника: синий треугольник соединяет вершины 0, 1 и 2, а зеленый использует вершины 2, 1 и 3. Оба треугольника разделяют диагональное ребро (пунктирная линия) и переиспользуют вершины 1 и 2. Это переиспользование вершин является ключом к эффективности индексной отрисовки.

Индексные буферы сокращают использование памяти с 12 до 8 чисел с плавающей точкой (сокращение на 33%) и уменьшают объём передаваемых данных с CPU на GPU. Общие вершины могут быть кэшированы и переиспользованы конвейером обработки вершин GPU, что позволяет избежать избыточных трансформаций. Для простого квада влияние на производительность незначительно. Для сложных мешей, где разделение вершин является обычным делом, индексные буферы значительно сокращают объем занимаемой памяти и улучшают производительность рендеринга.

Реализация

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

javascript
1const indexBuffer = gl.createBuffer();
2gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
3gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
4
5gl.drawElements(
6 gl.TRIANGLES, // Тип примитива
7 6, // Количество индексов для отрисовки
8 gl.UNSIGNED_SHORT, // Тип данных индексов (16-битный)
9 0 // Смещение в байтах в индексном буфере
10);

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

Когда использовать индексную отрисовку

  • Всегда для мешей с общими вершинами (большинство 3D-моделей)
  • Иногда для 2D-фигур (как наш квад)
  • Редко для систем частиц или других случаев, где все вершины уникальны

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

Внеэкранный рендеринг

Рендеринг непосредственно на экран ограничивает нас эффектами, выполняемыми в один проход. Эффекты вроде размытия (blur) требуют чтения из ранее отрендеренных пикселей при генерации новых. Если сцена рендерится прямо на экран, эти пиксели нельзя прочитать заново для выполнения вычислений размытия в том же проходе. Чтобы реализовать размытие, нам нужно сначала отрендерить сцену в промежуточную текстуру, из которой мы сможем читать — эта техника называется внеэкранным рендерингом (off-screen rendering).

Это ключевая идея почти всех современных визуальных эффектов — от размытия до моделирования жидкостей в реальном времени. Внеэкранный рендеринг решает это ограничение, используя объекты фреймбуфера (Framebuffer Objects, FBO) для рендеринга в текстуры вместо экрана, что позволяет объединять несколько этапов отрисовки в цепочку.

При внеэкранном рендеринге мы меняем рабочий процесс:

  • Проход 1: Рендерим всю 3D-сцену не на экран, а в текстуру.
  • Проход 2: Рендерим простой полноэкранный квад на экран. В его фрагментном шейдере теперь можно «читать» из текстуры, сгенерированной в Проходе 1, сэмплировать ее несколько раз и усреднять результаты для создания размытия.

Этот многопроходный подход необходим для многих приложений GPGPU (General-Purpose computing on GPUs), включая симуляцию жидкости, к созданию которой мы движемся.

Что такое объект Фреймбуфера (FBO)?

Объект фреймбуфера (Framebuffer Object, FBO) в WebGL — это объект WebGL, который служит альтернативным местом назначения для рендеринга. По умолчанию WebGL рисует в стандартный буфер принадлежащий холсту, который отображается непосредственно в браузере. FBO - это объект, который хранит ссылки на текстуры и рендербуферы, позволяя операциям рендеринга записывать данные в эти текстуры вместо экрана.

Фреймбуфер состоит из точек прикрепления (attachment points), которые принимают различные выходные данные из конвейера рендеринга. Основной точкой присоединения является COLOR_ATTACHMENT0, которая получает цветовой вывод RGBA из фрагментных шейдеров. Дополнительные точки могут включать буферы глубины, буферы трафарета и несколько цветовых точек присоединения для продвинутых техник.

Создание Фреймбуфера

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

javascript
1/**
2 * Создает текстуру и фреймбуфер для рендеринга в нее.
3 * @param {WebGL2RenderingContext} gl Контекст WebGL2.
4 * @returns {{texture: WebGLTexture, framebuffer: WebGLFramebuffer}}
5 */
6function createFramebuffer(gl) {
7 // 1. Создаем текстуру, в которую будем рендерить
8 const texture = gl.createTexture();
9 gl.bindTexture(gl.TEXTURE_2D, texture);
10 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
11 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
12 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
13 // Выделяем память для текстуры. Мы изменим ее размер позже.
14 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
15
16 // 2. Создаем фреймбуфер
17 const framebuffer = gl.createFramebuffer();
18 gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
19
20 // 3. Присоединяем текстуру к цветовой точке присоединения фреймбуфера
21 gl.framebufferTexture2D(
22 gl.FRAMEBUFFER, // Цель
23 gl.COLOR_ATTACHMENT0, // Точка присоединения
24 gl.TEXTURE_2D, // Цель текстуры
25 texture, // Текстура для присоединения
26 0 // Уровень мип-карты
27 );
28
29 // 4. Проверяем, завершен ли фреймбуфер
30 const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
31 if (status !== gl.FRAMEBUFFER_COMPLETE) {
32 throw new Error(`Framebuffer не завершен: ${status}`);
33 }
34
35 // Отвязываем объекты для порядка
36 gl.bindTexture(gl.TEXTURE_2D, null);
37 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
38
39 return { texture, framebuffer };
40}

Ключевой операцией здесь является gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer), которая перенаправляет все последующие команды рендеринга в FBO вместо экрана. Присоединенная текстура становится нашей целью рендеринга, эффективно создавая виртуальный холст. Проверка завершенности фреймбуфера гарантирует, что все настроено правильно — фреймбуфер может быть неполным, если присоединенные объекты имеют несовпадающие размеры или неподдерживаемые форматы. Наконец, привязка null к фреймбуферу возвращает рендеринг в стандартный фреймбуфер (на экран). Это важно, потому что после привязки пользовательского фреймбуфера весь рендеринг будет направляться в его текстуру до тех пор, пока вы явно не отвяжете его.

Пример: двухпроходный эффект оттенков серого

Применим эти концепции, реализовав эффект постобработки в оттенках серого. Мы выполним два прохода рендеринга:

  • Проход 1: Нарисуем наш красочный UV-градиент из прошлой статьи в наш внеэкранный FBO.
  • Проход 2: Нарисуем полноэкранный квад на холст, но используем текстуру из Прохода 1 в качестве входных данных и преобразуем ее в оттенки серого.

Шейдеры

Нам нужны два набора шейдеров. Первый рендерит нашу «сцену» (UV-градиент), а второй применяет эффект постобработки.

glsl
1// Рендерит нашу исходную сцену. То же, что и в предыдущей статье.
2const sceneVertSrc = `#version 300 es
3 layout(location = 0) in vec2 aPosition;
4 out vec2 vUV;
5 void main() {
6 vUV = aPosition * 0.5 + 0.5;
7 gl_Position = vec4(aPosition, 0.0, 1.0);
8 }`;
9
10const sceneFragSrc = `#version 300 es
11 precision highp float;
12 in vec2 vUV;
13 out vec4 outColor;
14 void main() {
15 outColor = vec4(vUV, 0.5, 1.0); // Красочный градиент
16 }`;
17
18// Применяет эффект оттенков серого, читая из текстуры.
19const postFxVertSrc = sceneVertSrc; // Мы можем переиспользовать тот же вершинный шейдер
20
21const postFxFragSrc = `#version 300 es
22 precision highp float;
23 in vec2 vUV;
24 out vec4 outColor;
25 uniform sampler2D uSceneTexture; // Наша внеэкранная текстура
26
27 void main() {
28 vec3 sceneColor = texture(uSceneTexture, vUV).rgb;
29 // Простое преобразование в оттенки серого по формуле яркости
30 float grayscale = dot(sceneColor, vec3(0.299, 0.587, 0.114));
31 outColor = vec4(vec3(grayscale), 1.0);
32 }`;

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

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

javascript
1// --- В вашем коде инициализации ---
2const sceneProgram = createProgram(gl, sceneVertSrc, sceneFragSrc);
3const postFxProgram = createProgram(gl, postFxVertSrc, postFxFragSrc);
4
5const { texture: sceneTexture, framebuffer } = createFramebuffer(gl);
6
7// createFullScreenQuad из предыдущей статьи, теперь оптимизирован с индексной отрисовкой
8const quadVAO = createFullScreenQuad(gl);
9
10// --- В вашем цикле рендеринга ---
11function render() {
12 // Проверяем, нужно ли изменить размер холста и нашей текстуры фреймбуфера
13 if (gl.canvas.width !== gl.canvas.clientWidth || gl.canvas.height !== gl.canvas.clientHeight) {
14 gl.canvas.width = gl.canvas.clientWidth;
15 gl.canvas.height = gl.canvas.clientHeight;
16
17 // Изменяем размер хранилища текстуры
18 gl.bindTexture(gl.TEXTURE_2D, sceneTexture);
19 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.canvas.width, gl.canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
20 }
21
22 // --- ПРОХОД 1: Рендерим сцену во фреймбуфер ---
23
24 // Привязываем FBO как цель рендеринга
25 gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
26
27 // Устанавливаем viewport в размер текстуры
28 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
29
30 // Рендерим сцену
31 gl.useProgram(sceneProgram);
32 gl.bindVertexArray(quadVAO);
33 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
34
35 // --- ПРОХОД 2: Рендерим на экран с эффектом постобработки ---
36
37 // Отвязываем FBO, чтобы рендерить на холст
38 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
39
40 // Устанавливаем viewport в размер холста
41 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
42
43 // Используем шейдер постобработки и передаем текстуру сцены
44 gl.useProgram(postFxProgram);
45 gl.activeTexture(gl.TEXTURE0);
46 gl.bindTexture(gl.TEXTURE_2D, sceneTexture);
47 gl.uniform1i(gl.getUniformLocation(postFxProgram, 'uSceneTexture'), 0);
48
49 // Рендерим квад
50 gl.bindVertexArray(quadVAO);
51 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
52
53 requestAnimationFrame(render);
54}
55
56render();

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

Вы можете изучить полный пример на GitHub и посмотреть живую демонстрацию здесь. Демо включает сравнение на разделенном экране для визуализации эффекта в действии.

Как бы работал эффект размытия?

Шейдер оттенков серого читает из текстуры сцены один раз для каждого пикселя. Чтобы создать эффект размытия, используется похожая идея: для каждого пикселя мы бы брали несколько выборок из uSceneTexture — одну в точке самого пикселя и несколько в небольшом радиусе вокруг него. Затем мы бы усреднили все эти цветовые выборки. Это усреднение и создает размытие прямоугольным фильтром (box blur) — технику размытия, где все выборки имеют одинаковый вес. Мы придерживаемся более простого эффекта оттенков серого, чтобы сосредоточиться на самой настройке фреймбуфера.

Итог

Объекты фреймбуфера — одна из самых мощных функций современных графических API. Изучив их, вы больше не ограничены одним проходом рендеринга.

  • FBO действуют как виртуальные экраны, позволяя вам рендерить в текстуры.
  • Привязка FBO перенаправляет все команды отрисовки в его присоединенные текстуры.
  • Привязка null переключает цель рендеринга обратно на буфер кадра по умолчанию.
  • Эта многопроходная техника является основой для постобработки, отложенного рендеринга (deferred rendering) и сложных GPGPU-симуляций.

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

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


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

Поговорим?