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

Содержание
-
...
В этой статье мы изучим рендеринг в текстуры с помощью фреймбуферов — основополагающую технику для эффектов постобработки. Холстом для этих эффектов часто служит простой полноэкранный прямоугольник. Мы будем использовать прямоугольник, который мы создали в предыдущей статье. Та реализация работает, но она содержит избыточные данные и может быть оптимизирована. Начнем с оптимизации и потом перейдем к рендерингу в текстуры.
Проблема с дублирующимися вершинами
В предыдущей версии полноэкранный квад был определен следующим образом:
javascript1const 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 эффективно переиспользовать вершинные данные.
javascript1// Только 4 уникальные вершины (8 чисел)2const positions = new Float32Array([3 -1, -1, // 0: Нижняя левая4 1, -1, // 1: Нижняя правая5 -1, 1, // 2: Верхняя левая6 1, 1, // 3: Верхняя правая7]);89// Индексный буфер: каждая группа из трех индексов определяет один треугольник10const indices = new Uint16Array([11 0, 1, 2, // Треугольник A: вершины 0 → 1 → 212 2, 1, 3, // Треугольник B: вершины 2 → 1 → 313]);
Визуальное представление
Диаграмма показывает, как квад разделен на два треугольника: синий треугольник соединяет вершины 0, 1 и 2, а зеленый использует вершины 2, 1 и 3. Оба треугольника разделяют диагональное ребро (пунктирная линия) и переиспользуют вершины 1 и 2. Это переиспользование вершин является ключом к эффективности индексной отрисовки.
Индексные буферы сокращают использование памяти с 12 до 8 чисел с плавающей точкой (сокращение на 33%) и уменьшают объём передаваемых данных с CPU на GPU. Общие вершины могут быть кэшированы и переиспользованы конвейером обработки вершин GPU, что позволяет избежать избыточных трансформаций. Для простого квада влияние на производительность незначительно. Для сложных мешей, где разделение вершин является обычным делом, индексные буферы значительно сокращают объем занимаемой памяти и улучшают производительность рендеринга.
Реализация
Чтобы использовать индексную отрисовку, нам нужно создать дополнительный буфер для хранения индексов. Этот буфер указывает WebGL, какие вершины использовать для каждого треугольника. Процесс заключается в привязке его в качестве буфера массива элементов, загрузке данных индексов и вызове drawElements
с соответствующими параметрами.
:
javascript1const indexBuffer = gl.createBuffer();2gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);3gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);45gl.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 включает несколько шагов: создание текстуры назначения, создание самого фреймбуфера и присоединение одного к другому:
javascript1/**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);1516 // 2. Создаем фреймбуфер17 const framebuffer = gl.createFramebuffer();18 gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);1920 // 3. Присоединяем текстуру к цветовой точке присоединения фреймбуфера21 gl.framebufferTexture2D(22 gl.FRAMEBUFFER, // Цель23 gl.COLOR_ATTACHMENT0, // Точка присоединения24 gl.TEXTURE_2D, // Цель текстуры25 texture, // Текстура для присоединения26 0 // Уровень мип-карты27 );2829 // 4. Проверяем, завершен ли фреймбуфер30 const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);31 if (status !== gl.FRAMEBUFFER_COMPLETE) {32 throw new Error(`Framebuffer не завершен: ${status}`);33 }3435 // Отвязываем объекты для порядка36 gl.bindTexture(gl.TEXTURE_2D, null);37 gl.bindFramebuffer(gl.FRAMEBUFFER, null);3839 return { texture, framebuffer };40}
Ключевой операцией здесь является gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
, которая перенаправляет все последующие команды рендеринга в FBO вместо экрана. Присоединенная текстура становится нашей целью рендеринга, эффективно создавая виртуальный холст. Проверка завершенности фреймбуфера гарантирует, что все настроено правильно — фреймбуфер может быть неполным, если присоединенные объекты имеют несовпадающие размеры или неподдерживаемые форматы.
Наконец, привязка null
к фреймбуферу возвращает рендеринг в стандартный фреймбуфер (на экран). Это важно, потому что после привязки пользовательского фреймбуфера весь рендеринг будет направляться в его текстуру до тех пор, пока вы явно не отвяжете его.
Пример: двухпроходный эффект оттенков серого
Применим эти концепции, реализовав эффект постобработки в оттенках серого. Мы выполним два прохода рендеринга:
- Проход 1: Нарисуем наш красочный UV-градиент из прошлой статьи в наш внеэкранный FBO.
- Проход 2: Нарисуем полноэкранный квад на холст, но используем текстуру из Прохода 1 в качестве входных данных и преобразуем ее в оттенки серого.
Шейдеры
Нам нужны два набора шейдеров. Первый рендерит нашу «сцену» (UV-градиент), а второй применяет эффект постобработки.
glsl1// Рендерит нашу исходную сцену. То же, что и в предыдущей статье.2const sceneVertSrc = `#version 300 es3 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 }`;910const sceneFragSrc = `#version 300 es11 precision highp float;12 in vec2 vUV;13 out vec4 outColor;14 void main() {15 outColor = vec4(vUV, 0.5, 1.0); // Красочный градиент16 }`;1718// Применяет эффект оттенков серого, читая из текстуры.19const postFxVertSrc = sceneVertSrc; // Мы можем переиспользовать тот же вершинный шейдер2021const postFxFragSrc = `#version 300 es22 precision highp float;23 in vec2 vUV;24 out vec4 outColor;25 uniform sampler2D uSceneTexture; // Наша внеэкранная текстура2627 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, а затем организовать два прохода рендеринга.
javascript1// --- В вашем коде инициализации ---2const sceneProgram = createProgram(gl, sceneVertSrc, sceneFragSrc);3const postFxProgram = createProgram(gl, postFxVertSrc, postFxFragSrc);45const { texture: sceneTexture, framebuffer } = createFramebuffer(gl);67// createFullScreenQuad из предыдущей статьи, теперь оптимизирован с индексной отрисовкой8const quadVAO = createFullScreenQuad(gl);910// --- В вашем цикле рендеринга ---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;1617 // Изменяем размер хранилища текстуры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 }2122 // --- ПРОХОД 1: Рендерим сцену во фреймбуфер ---2324 // Привязываем FBO как цель рендеринга25 gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);2627 // Устанавливаем viewport в размер текстуры28 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);2930 // Рендерим сцену31 gl.useProgram(sceneProgram);32 gl.bindVertexArray(quadVAO);33 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);3435 // --- ПРОХОД 2: Рендерим на экран с эффектом постобработки ---3637 // Отвязываем FBO, чтобы рендерить на холст38 gl.bindFramebuffer(gl.FRAMEBUFFER, null);3940 // Устанавливаем viewport в размер холста41 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);4243 // Используем шейдер постобработки и передаем текстуру сцены44 gl.useProgram(postFxProgram);45 gl.activeTexture(gl.TEXTURE0);46 gl.bindTexture(gl.TEXTURE_2D, sceneTexture);47 gl.uniform1i(gl.getUniformLocation(postFxProgram, 'uSceneTexture'), 0);4849 // Рендерим квад50 gl.bindVertexArray(quadVAO);51 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);5253 requestAnimationFrame(render);54}5556render();
Запуск этого кода не покажет красочный градиент. Вместо этого вы увидите его версию в оттенках серого — доказательство того, что мы успешно отрендерили в текстуру в первом проходе, а затем прочитали из этой текстуры во втором проходе, чтобы применить наш эффект постобработки.
Вы можете изучить полный пример на GitHub и посмотреть живую демонстрацию здесь. Демо включает сравнение на разделенном экране для визуализации эффекта в действии.
Как бы работал эффект размытия?
Шейдер оттенков серого читает из текстуры сцены один раз для каждого пикселя. Чтобы создать эффект размытия, используется похожая идея: для каждого пикселя мы бы брали несколько выборок из uSceneTexture
— одну в точке самого пикселя и несколько в небольшом радиусе вокруг него. Затем мы бы усреднили все эти цветовые выборки. Это усреднение и создает размытие прямоугольным фильтром (box blur) — технику размытия, где все выборки имеют одинаковый вес.
Мы придерживаемся более простого эффекта оттенков серого, чтобы сосредоточиться на самой настройке фреймбуфера.
Итог
Объекты фреймбуфера — одна из самых мощных функций современных графических API. Изучив их, вы больше не ограничены одним проходом рендеринга.
- FBO действуют как виртуальные экраны, позволяя вам рендерить в текстуры.
- Привязка FBO перенаправляет все команды отрисовки в его присоединенные текстуры.
- Привязка
null
переключает цель рендеринга обратно на буфер кадра по умолчанию. - Эта многопроходная техника является основой для постобработки, отложенного рендеринга (deferred rendering) и сложных GPGPU-симуляций.
Мы подготовили некторую основу для дальнейшей реализации эффекта симуляции жидкости. Теперь мы можем сохранять состояние вычисления в текстуру. Но что, если мы хотим создать цикл обратной связи, где мы постоянно читаем из текстуры, вычисляем новый результат и записываем его обратно? Для этого нам понадобится еще один трюк.
В следующей статье мы рассмотрим технику "Пинг-Понг" для создания итеративных циклов обратной связи, что позволит нам переносить цвет и скорость в симуляции жидкости.
Это часть моей серии статей о реализации интерактивных 3D-визуализаций с помощью WebGL 2.