Как работает размытие в видеоиграх

от автора

Размытие — базовый строительный блок множества эффектов постобработки в видеоиграх, без него не обходятся красивые современные GUI. Оно используется в эффектах Depth of Field, Bloom или панелях с эффектом матового стекла современных пользовательских интерфейсов.

Texture coordinates, also called UV Coordinates or UVs for short

Эффект Bloom — один из множества способов применения алгоритмов размытия

Концептуально реализовать размытие легко, его принцип сводится к тому или иному способу усреднения цветов в заданном радиусе. Однако для того, чтобы выполнять его в реальном времени, понадобились десятки лет исследований и экспериментов в computer science и математике. В этой статье мы поэтапно разберём их; можно назвать это путешествием во времени в сфере программирования графики.

В оригинале статьи техники размытия реализованы в реальном времени благодаря использованию GPU и возможностям WebGL браузера.

Начало: пока никакого размытия

В контексте постобработки видеоигр 3D-сцена отрисовывается (рендерится) и сохраняется в промежуточное изображение — буфер кадров. В свою очередь, этот буфер кадров обрабатывается для реализации различных эффектов. Так как эта обработка происходит после рендеринга 3D-сцены, она называется постобработкой. И она происходит много раз в секунду.

При использовании некоторых техник буферы кадров могут содержать неграфические данные, а эффекты постобработки наподобие цветовой коррекции или тональной коррекции даже не требуют промежуточных буферов кадров: реализовать их можно разными способами (см. видео с 35:20)

Нам важен именно этот момент: у нас уже есть буфер кадров после отрисовки 3D-сцены. Мы используем сцену из мода под названием NEOTOKYO°. При каждой реализации размытия в canvas, создаваемых при помощи WebGL 1.0, будет выполняться рендеринг с нативным разрешением вашего устройства. У каждого примера есть элементы управления настройками, а ниже представлены соответствующие фрагменты кода.

Сцена

Сцена
Свет

Свет
Bloom

Bloom
Фрагментный шейдер размытия
/* Это "фрагментный шейдер" размытия - программа, выполняемая на GPU.   В *этой* статье фрагментный шейдер размытия выполняется по одному разу   для каждого пикселя вывода canvas *//* Требуется для шейдеров WebGL 1 и на некоторых платформах может   ни на что не влиять.   На будущее: в сильных размытиях может быть множество незначительных влияний   различных цветов, поэтому мы задаём здесь "highp", то есть максимум. */precision highp float;/* UV-координаты, передаваемые из вершинного шейдера "simpleQuad.vs".   Они сообщают текущему пикселю вывода, откуда считывать текстуру. */varying vec2 uv;/* Ввод lightBrightness. Яркость света используется во фрагментном шейдере   размытия, а не в значении, применяемом на предыдущем этапе, из-за ограничений   передачи точности цвета. */uniform float lightBrightness;/* Текстурный ввод */uniform sampler2D texture;/* Функция "main", исполняемая в GPU */void main() {/* gl_FragColor - это вывод нашего шейдера. texture2D - это считывание текстуры,   выполняемое на основании текущей координаты 'uv'. Then multiplied by   our lightBrightness value (a multiplier with eg. 1.0 at 100%, 0.5 at 50%)   In "scene" mode, this value is locked to 1.0 so it has no effect */gl_FragColor = texture2D(texture, uv) * lightBrightness;}
WebGL Javascript
import * as util from '../utility.js'export async function setupSimple() {/* Инициализация */const WebGLBox = document.getElementById('WebGLBox-Simple');const canvas = WebGLBox.querySelector('canvas');/* Размер круга */const radius = 0.12;/* Основной контекст WebGL 1.0 */const gl = canvas.getContext('webgl', {preserveDrawingBuffer: false,antialias: false,alpha: false,});/* Состояние и объекты */const ctx = {/* Состояние для рендеринга */mode: "scene",flags: { isRendering: false, buffersInitialized: false, initComplete: false, benchMode: false },/* Текстуры */tex: { sdr: null, selfIllum: null, frame: null, frameFinal: null },/* Буферы кадров */fb: { scene: null, final: null },/* Шейдеры и местоположения соответствующих ресурсов */shd: {scene: { handle: null, uniforms: { offset: null, radius: null } },blur: { handle: null, uniforms: { frameSizeRCP: null, samplePosMult: null, lightBrightness: null } },bloom: { handle: null, uniforms: { offset: null, radius: null, texture: null, textureAdd: null } }}};/* Элементы UI */const ui = {display: {spinner: canvas.parentElement.querySelector('svg', canvas.parentElement),contextLoss: canvas.parentElement.querySelector('div', canvas.parentElement),fps: WebGLBox.querySelector('#fps'),ms: WebGLBox.querySelector('#ms'),width: WebGLBox.querySelector('#width'),height: WebGLBox.querySelector('#height'),},rendering: {animate: WebGLBox.querySelector('#animateCheck'),modes: WebGLBox.querySelectorAll('input[type="radio"]'),lightBrightness: WebGLBox.querySelector('#lightBrightness'),lightBrightnessReset: WebGLBox.querySelector('#lightBrightnessReset'),}};/* Шейдеры */const circleAnimation = await util.fetchShader("shader/circleAnimation.vs");const simpleTexture = await util.fetchShader("shader/simpleTexture.fs");const bloomVert = await util.fetchShader("shader/bloom.vs");const bloomFrag = await util.fetchShader("shader/bloom.fs");const simpleQuad = await util.fetchShader("shader/simpleQuad.vs");const noBlurYetFrag = await util.fetchShader("shader/noBlurYet.fs");/* Элементы, вызывающие перерисовку в режиме без анимации */ui.rendering.lightBrightness.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });/* События */ui.rendering.animate.addEventListener("change", () => {if (ui.rendering.animate.checked)startRendering();else {ui.display.fps.value = "-";ui.display.ms.value = "-";ctx.flags.isRendering = false;redraw()}});canvas.addEventListener("webglcontextlost", () => {ui.display.contextLoss.style.display = "block";});/* Режим рендеринга */ui.rendering.modes.forEach(radio => {/* Принудительно задаём сцену, чтобы устранить баг перезагрузки в Firefox для Android */if (radio.value === "scene")radio.checked = true;radio.addEventListener('change', (event) => {ctx.mode = event.target.value;ui.rendering.lightBrightness.disabled = ctx.mode === "scene";ui.rendering.lightBrightnessReset.disabled = ctx.mode === "scene";if (!ui.rendering.animate.checked) redraw();});});/* Draw Texture Shader */ctx.shd.scene = util.compileAndLinkShader(gl, circleAnimation, simpleTexture, ["offset", "radius"]);/* Рисуем шейдер bloom */ctx.shd.bloom = util.compileAndLinkShader(gl, bloomVert, bloomFrag, ["texture", "textureAdd", "offset", "radius"]);/* Вспомогательная функция для рекомпиляции */function reCompileBlurShader() {ctx.shd.blur = util.compileAndLinkShader(gl, simpleQuad, noBlurYetFrag, ["lightBrightness"]);}/* Шейдер размытия */reCompileBlurShader()/* Отправляем вершины в GPU */util.bindUnitQuad(gl);async function setupTextureBuffers() {ui.display.spinner.style.display = "block";ctx.flags.buffersInitialized = true;ctx.flags.initComplete = false;gl.deleteFramebuffer(ctx.fb.scene);gl.deleteFramebuffer(ctx.fb.final);[ctx.fb.scene, ctx.tex.frame] = util.setupFramebuffer(gl, canvas.width, canvas.height);[ctx.fb.final, ctx.tex.frameFinal] = util.setupFramebuffer(gl, canvas.width, canvas.height);let [base, selfIllum] = await Promise.all([fetch("/dual-kawase/img/SDR_No_Sprite.png"),fetch("/dual-kawase/img/Selfillumination.png")]);let [baseBlob, selfIllumBlob] = await Promise.all([base.blob(), selfIllum.blob()]);let [baseBitmap, selfIllumBitmap] = await Promise.all([createImageBitmap(baseBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" }),createImageBitmap(selfIllumBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" })]);ctx.tex.sdr = util.setupTexture(gl, null, null, ctx.tex.sdr, gl.LINEAR, baseBitmap);ctx.tex.selfIllum = util.setupTexture(gl, null, null, ctx.tex.selfIllum, gl.LINEAR, selfIllumBitmap);baseBitmap.close();selfIllumBitmap.close();ctx.flags.initComplete = true;ui.display.spinner.style.display = "none";}let prevNow = performance.now();let lastStatsUpdate = prevNow;let fpsEMA = 60;let msEMA = 16;async function redraw() {if (!ctx.flags.buffersInitialized)await setupTextureBuffers();if (!ctx.flags.initComplete)return;/* Статистика UI */ui.display.width.value = canvas.width;ui.display.height.value = canvas.height;/* Круговое движение */let radiusSwitch = ui.rendering.animate.checked ? radius : 0.0;let speed = (performance.now() / 10000) % Math.PI * 2;const offset = [radiusSwitch * Math.cos(speed), radiusSwitch * Math.sin(speed)];gl.useProgram(ctx.shd.scene.handle);const texture = ctx.mode == "scene" ? ctx.tex.sdr : ctx.tex.selfIllum;gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, texture);gl.uniform2fv(ctx.shd.scene.uniforms.offset, offset);gl.uniform1f(ctx.shd.scene.uniforms.radius, radiusSwitch);/* Настройка буфера кадров постобработки */gl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.scene);gl.viewport(0, 0, canvas.width, canvas.height);/* Вызов отрисовки */gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);/* Размытие при нативном разрешении */gl.useProgram(ctx.shd.blur.handle);const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.lightBrightness, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);if (ctx.mode == "bloom") {/* Теперь выполняем композитинг bloom на экран */gl.bindFramebuffer(gl.FRAMEBUFFER, null);gl.useProgram(ctx.shd.bloom.handle);gl.uniform2fv(ctx.shd.bloom.uniforms.offset, offset);gl.uniform1f(ctx.shd.bloom.uniforms.radius, radiusSwitch);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.sdr);gl.uniform1i(ctx.shd.bloom.uniforms.texture, 0);gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frameFinal);gl.uniform1i(ctx.shd.bloom.uniforms.textureAdd, 1);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);}/* Просим синхронизацию CPU-GPU предотвращать перегрузку GPU при композитинге.   На самом деле, это с большой вероятностью приведёт к сбросу, но, похоже,    помогает на различных устройствах с низким FPS */gl.finish();const now = performance.now();let dt = now - prevNow;if (dt > 0) {const instFPS = 1000 / dt;const ALPHA = 0.05;fpsEMA = ALPHA * instFPS + (1 - ALPHA) * fpsEMA;msEMA = ALPHA * dt + (1 - ALPHA) * msEMA;}prevNow = now;if (ui.rendering.animate.checked && now - lastStatsUpdate >= 1000) {ui.display.fps.value = fpsEMA.toFixed(0);ui.display.ms.value = msEMA.toFixed(2);lastStatsUpdate = now;}}let animationFrameId;/* Рендеринг с нативным разрешением */function nativeResize() {const [width, height] = util.getNativeSize(canvas);if (width && canvas.width !== width || height && canvas.height !== height) {canvas.width = width;canvas.height = height;if (!ctx.flags.benchMode) {stopRendering();startRendering();}if (!ui.rendering.animate.checked)redraw();}}/* Событие изменения размеров */nativeResize();let resizePending = false;window.addEventListener('resize', () => {if (!resizePending) {resizePending = true;requestAnimationFrame(() => {resizePending = false;nativeResize();});}});function renderLoop() {if (ctx.flags.isRendering && ui.rendering.animate.checked) {redraw();animationFrameId = requestAnimationFrame(renderLoop);}}function startRendering() {/* Начинаем рендеринг, когда canvas видим */ctx.flags.isRendering = true;renderLoop();}function stopRendering() {/* Прекращаем вызов другой перерисовки */ctx.flags.isRendering = false;cancelAnimationFrame(animationFrameId);/* Заставляем конвейер рендеринга синхронизироваться с CPU перед тем, как мы вмешаемся в него */gl.finish();/* Удаляем буферы, чтобы освободить память */gl.deleteTexture(ctx.tex.sdr); ctx.tex.sdr = null;gl.deleteTexture(ctx.tex.selfIllum); ctx.tex.selfIllum = null;gl.deleteTexture(ctx.tex.frame); ctx.tex.frame = null;gl.deleteTexture(ctx.tex.frameFinal); ctx.tex.frameFinal = null;gl.deleteFramebuffer(ctx.fb.scene); ctx.fb.scene = null;gl.deleteFramebuffer(ctx.fb.final); ctx.fb.final = null;ctx.flags.buffersInitialized = false;ctx.flags.initComplete = false;ui.display.fps.value = "-";ui.display.ms.value = "-";}function handleIntersection(entries) {entries.forEach(entry => {if (entry.isIntersecting) {if (!ctx.flags.isRendering && !ctx.flags.benchMode) startRendering();} else {stopRendering();}});}/* Выполняем рендеринг только тогда, когда canvas находится на экране */let observer = new IntersectionObserver(handleIntersection);observer.observe(canvas);}

Пока размытие не реализовано, поэтому практически ничего не происходит. В оригинале статьи над примером есть кнопка Animate , позволяющая перемещать сцену для демонстрации проблем последующих алгоритмов. Движение происходит до применения размытия, например, до перемещения игрока. Размытие можно посмотреть в различных сценариях использования благодаря трём режимам:

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

  • В режиме Сцена размытие применяется ко всему изображению

  • В режиме Свет мы видим и размываем только излучающие части сцены, иногда называемые Self-Illumination

    • Также в этом режиме активируется ползунок lightBrightness, при помощи которого можно настраивать энергию источников освещения

  • В режиме Bloom мы используем исходную сцену и добавляем поверх размытые источники освещения из предыдущего режима для создания атмосферной сцены. При этом применяется эффект Bloom — важный сценарий использования размытия в 3D-реального времени

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

Также на canvas указывается разрешение и количество кадров в секунду/время, затрачиваемое на кадр. Очень важный аспект этой системы — производительность.

В оригинале статьи частота кадров будет ограничена частотой обновления вашего экрана, скорее всего, это будут 60 fps / 16,6 мс. Ниже мы займёмся настоящим бенчмаркингом

Технический анализ 

Для понимания этой статьи знание кода GPU необязательно, но если вы решите разобраться глубже, оно вам потребуется

Мы реализуем алгоритмы размытия в виде фрагментного шейдера, написанного на GLSL. Если вкратце, то фрагментный шейдер — это код, параллельно выполняемый на GPU для каждого пикселя вывода. Входные данные изображений в шейдерах называются текстурами. У этих текстур есть координаты, часто называемые UV-координатами, именно эти числа нам и важны.

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

Texture coordinates, also called UV Coordinates or UVs for short

Текстурные координаты, также называемые UV-координатами или UV. Обратите внимание, что изображение выглядит сжатым.

UV-координаты определяют позицию, которую мы считываем в изображении, левый нижний угол имеет координаты 0,0, а правый верхний — координаты 1,1. Ни UV-координаты, ни сами шейдеры не имеют никакой концепции разрешения изображения, разрешения экрана или соотношения сторон. Если мы хотим обращаться к отдельным пикселям, мы сами должны выразить их в UV-координатах.

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

Буфер кадров передаётся во фрагментный шейдер в строке uniform sampler2D texture в виде текстуры. При помощи шейдера размытия мы отрисовываем полноэкранный четырёхугольник (Full Screen Quad) — прямоугольник, покрывающий весь canvas, соответствующий 0,0 в левом нижнем углу и 1,1 в правом верхнем углу UV-координаты varying vec2 uv для чтения из текстуры.

Соотношение сторон и разрешение текстуры такие же, как и соотношение сторон и разрешение выходного canvas, то есть соотношение пикселей между ними равно 1:1. Ответственные за это этапы графического конвейера и вершинный шейдер в контексте нашей статьи не важны.

Фрагментный шейдер размытия получает доступ к цвету текстуры при помощи texture2D(texture, uv) в соответствующей позиции выходного пикселя. В последующих примерах мы будем выполнять чтение из соседних пикселей, для чего нам понадобится вычислять смещение UV-координат — десятичную дробь, соответствующую одному шагу пикселя, получаемую по формуле 1 / canvasResolution

Код фрагментного шейдера можно воспринимать, как команды по созданию конкретного выходного пикселя.

Поначалу программирование графики обладает уникальной сложностью из-за огромного количества правил и ограничений, накладываемых оборудованием, графическими API и конвейером рендеринга. Но в то же время оно раскрывает невероятный потенциал, потому что другие ограничения устраняются. Давайте начнём разбираться, как графические программисты используют этот потенциал.

Box Blur 

С точки зрения программиста, самый простой способ усреднения соседних пикселей — это цикл for. Фрагментный шейдер выражает следующее: «смотрим на Y пикселей вверх и вних, на X влево и вправо, а потом усредняем цвета». Чем сильнее должно быть размытие, тем больше должен быть kernelSize, то есть границы нашего цикла for.

/* Считываем из текстуры Y пикселей выше и ниже */for (int y = -kernel_size; y <= kernel_size; ++y) {/* Считываем из текстуры X пикселей слева и справа */for (int x = -kernel_size; x <= kernel_size; ++x) {/* Смещение от текущего пикселя, определяющее, какой пиксель считывать */vec2 offset = vec2(x, y) * samplePosMult * frameSizeRCP;/* Считываем и суммируем вклад цвета этого пикселя */sum += texture2D(texture, uv + offset);}}

Чем больше цикл for, тем больше считываний текстуры мы выполняем на один выходной пиксель. Каждое чтение текстуры часто называют «texture tap», и ниже также будет отображаться общее количество таких «tap».

Сцена

Сцена
Свет

Свет
Bloom

Bloom
Фрагментный шейдер размытия
/* Выставляем точность вычислений с плавающей запятой на highp, если поддерживается.   При больших размерах ядра на пиксель влияет множество цветов, а потому требуется   максимальная точность, чтобы избежать отсечения.   Требуется в шейдерах WebGL 1, а на некоторых платформах может ни на что не влиять */precision highp float;/* UV-координаты, передаваемые из вершинного шейдера */varying vec2 uv;/* Значение, обратное разрешению. Для перехода к следующему пикселю нам нужно вычислить   `UV Coordinate / frameSize`. На оборудовании деление выполняется чуть медленнее,   чем умножение. Так как шейдер выполняется попиксельно, мы избежим операции деления   для каждого пикселя, вычислив обратную величину 1 / frameSize и передавая её   в шейдер. Очень популярная микрооптимизация в программировании графики */uniform vec2 frameSizeRCP;uniform float samplePosMult; /* Умножаем, чтобы сила размытия превысила размер ядра */uniform float bloomStrength; /* сила bloom */uniform sampler2D texture;/* `KERNEL_SIZE` добавляется при компиляции */const int kernel_size = KERNEL_SIZE;void main() {/* Переменная для хранения конечного цвета для текущего пикселя */vec4 sum = vec4(0.0);/* Величина одной стороны сэмплируемого квадрата */const int size = 2 * kernel_size + 1;/* Общее количество сэмплов, которые мы считаем */const float totalSamples = float(size * size);/* Считываем из текстуры Y пикселей сверху и снизу */for (int y = -kernel_size; y <= kernel_size; ++y) {/* Считываем из текстуры X пикселей слева и справа */for (int x = -kernel_size; x <= kernel_size; ++x) {/* Смещение от текущего пикселя, определяющее, какой пиксель считывать */vec2 offset = vec2(x, y) * samplePosMult * frameSizeRCP;/* Считываем и прибавляем вклад этого пикселя */sum += texture2D(texture, uv + offset);}}/* Возвращаем сумму, поделённую на количество сэмплов (нормализация) */gl_FragColor = (sum / totalSamples) * bloomStrength;}
WebGL Javascript
import * as util from '../utility.js'export async function setupBoxBlur() {/* Инициализация */const WebGLBox = document.getElementById('WebGLBox-BoxBlur');const WebGLBoxDetail = document.getElementById('WebGLBox-BoxBlurDetail');const canvas = WebGLBox.querySelector('canvas');/* Размер вращения окружности */const radius = 0.12;/* Основной контекст WebGL 1.0 */const gl = canvas.getContext('webgl', {preserveDrawingBuffer: false,antialias: false,alpha: false,});/* Состояние и объекты */const ctx = {/* Состояние для рендеринга */mode: "scene",flags: { isRendering: false, buffersInitialized: false, initComplete: false, benchMode: false },/* Текстуры */tex: { sdr: null, selfIllum: null, frame: null, frameFinal: null },/* Буферы кадров */fb: { scene: null, final: null },/* Шейдеры и расположение соответствующих ресурсов */shd: {scene: { handle: null, uniforms: { offset: null, radius: null } },blur: { handle: null, uniforms: { frameSizeRCP: null, samplePosMult: null, bloomStrength: null } },bloom: { handle: null, uniforms: { offset: null, radius: null, texture: null, textureAdd: null } }}};/* Элементы UI */const ui = {display: {spinner: canvas.parentElement.querySelector('svg', canvas.parentElement),contextLoss: canvas.parentElement.querySelector('div', canvas.parentElement),fps: WebGLBox.querySelector('#fps'),ms: WebGLBox.querySelector('#ms'),width: WebGLBox.querySelector('#width'),height: WebGLBox.querySelector('#height'),tapsCount: WebGLBox.querySelector('#taps'),},blur: {kernelSize: WebGLBox.querySelector('#sizeRange'),samplePos: WebGLBox.querySelector('#samplePosRange'),samplePosReset: WebGLBox.querySelector('#samplePosRangeReset'),},rendering: {animate: WebGLBox.querySelector('#animateCheck'),modes: WebGLBox.querySelectorAll('input[type="radio"]'),lightBrightness: WebGLBox.querySelector('#lightBrightness'),lightBrightnessReset: WebGLBox.querySelector('#lightBrightnessReset'),},benchmark: {button: WebGLBox.querySelector('#benchmark'),label: WebGLBox.querySelector('#benchmarkLabel'),iterOut: WebGLBox.querySelector('#iterOut'),renderer: WebGLBoxDetail.querySelector('#renderer'),iterTime: WebGLBoxDetail.querySelector('#iterTime'),tapsCount: WebGLBoxDetail.querySelector('#tapsCountBench'),iterations: WebGLBox.querySelector('#iterations')}};/* Шейдеры */const circleAnimation = await util.fetchShader("shader/circleAnimation.vs");const simpleTexture = await util.fetchShader("shader/simpleTexture.fs");const bloomVert = await util.fetchShader("shader/bloom.vs");const bloomFrag = await util.fetchShader("shader/bloom.fs");const simpleQuad = await util.fetchShader("shader/simpleQuad.vs");const boxBlurFrag = await util.fetchShader("shader/boxBlur.fs");/* Элементы, вызывающие перерисовку в режиме без анимации */ui.blur.kernelSize.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.blur.samplePos.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.rendering.lightBrightness.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });/* События */ui.rendering.animate.addEventListener("change", () => {if (ui.rendering.animate.checked)startRendering();else {ui.display.fps.value = "-";ui.display.ms.value = "-";ctx.flags.isRendering = false;redraw()}});canvas.addEventListener("webglcontextlost", () => {ui.display.contextLoss.style.display = "block";});ui.blur.kernelSize.addEventListener('input', () => {reCompileBlurShader(ui.blur.kernelSize.value);ui.blur.samplePos.disabled = ui.blur.kernelSize.value == 0;ui.blur.samplePosReset.disabled = ui.blur.kernelSize.value == 0;});/* Режим рендеринга */ui.rendering.modes.forEach(radio => {/* Принудительно присваиваем значение scene, чтобы устранить баг перезагрузки в Firefox Android */if (radio.value === "scene")radio.checked = true;radio.addEventListener('change', (event) => {ctx.mode = event.target.value;ui.rendering.lightBrightness.disabled = ctx.mode === "scene";ui.rendering.lightBrightnessReset.disabled = ctx.mode === "scene";if (!ui.rendering.animate.checked) redraw();});});ui.benchmark.button.addEventListener("click", () => {ctx.flags.benchMode = true;stopRendering();ui.display.spinner.style.display = "block";ui.benchmark.button.disabled = true;/* Запускаем воркера (модуль ES) */const worker = new Worker("./js/benchmark/boxBlurBenchmark.js", { type: "module" });/* передаём все данные, которые нужны воркеру */worker.postMessage({iterations: ui.benchmark.iterOut.value,blurShaderSrc: boxBlurFrag,kernelSize: ui.blur.kernelSize.value,samplePos: ui.blur.samplePos.value});/* Бенчмарк */worker.addEventListener("message", (event) => {if (event.data.type !== "done") return;ui.benchmark.label.textContent = event.data.benchText;ui.benchmark.tapsCount.textContent = event.data.tapsCount;ui.benchmark.iterTime.textContent = event.data.iterationText;ui.benchmark.renderer.textContent = event.data.renderer;worker.terminate();ui.benchmark.button.disabled = false;ctx.flags.benchMode = false;if (ui.rendering.animate.checked)startRendering();elseredraw();});});ui.benchmark.iterations.addEventListener("change", (event) => {ui.benchmark.iterOut.value = event.target.value;ui.benchmark.label.textContent = "Benchmark";});/* Шейдер отрисовки текстур */ctx.shd.scene = util.compileAndLinkShader(gl, circleAnimation, simpleTexture, ["offset", "radius"]);/* Шейдер отрисовки bloom */ctx.shd.bloom = util.compileAndLinkShader(gl, bloomVert, bloomFrag, ["texture", "textureAdd", "offset", "radius"]);/* Вспомонательная функция для перекомпиляции */function reCompileBlurShader(blurSize) {ctx.shd.blur = util.compileAndLinkShader(gl, simpleQuad, boxBlurFrag, ["frameSizeRCP", "samplePosMult", "bloomStrength"], "#define KERNEL_SIZE " + blurSize + '\n');}/* Шейдер размытия */reCompileBlurShader(ui.blur.kernelSize.value)/* Отправляем вершины в GPU */util.bindUnitQuad(gl);async function setupTextureBuffers() {ui.display.spinner.style.display = "block";ctx.flags.buffersInitialized = true;ctx.flags.initComplete = false;gl.deleteFramebuffer(ctx.fb.scene);gl.deleteFramebuffer(ctx.fb.final);[ctx.fb.scene, ctx.tex.frame] = util.setupFramebuffer(gl, canvas.width, canvas.height);[ctx.fb.final, ctx.tex.frameFinal] = util.setupFramebuffer(gl, canvas.width, canvas.height);let [base, selfIllum] = await Promise.all([fetch("/dual-kawase/img/SDR_No_Sprite.png"),fetch("/dual-kawase/img/Selfillumination.png")]);let [baseBlob, selfIllumBlob] = await Promise.all([base.blob(), selfIllum.blob()]);let [baseBitmap, selfIllumBitmap] = await Promise.all([createImageBitmap(baseBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" }),createImageBitmap(selfIllumBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" })]);ctx.tex.sdr = util.setupTexture(gl, null, null, ctx.tex.sdr, gl.LINEAR, baseBitmap);ctx.tex.selfIllum = util.setupTexture(gl, null, null, ctx.tex.selfIllum, gl.LINEAR, selfIllumBitmap);baseBitmap.close();selfIllumBitmap.close();ctx.flags.initComplete = true;ui.display.spinner.style.display = "none";}let prevNow = performance.now();let lastStatsUpdate = prevNow;let fpsEMA = 60;let msEMA = 16;async function redraw() {if (!ctx.flags.buffersInitialized)await setupTextureBuffers();if (!ctx.flags.initComplete)return;/* UI статистики */const KernelSizeSide = ui.blur.kernelSize.value * 2 + 1;const tapsNewText = (canvas.width * canvas.height * KernelSizeSide * KernelSizeSide / 1000000).toFixed(1) + " Million";ui.display.tapsCount.value = tapsNewText;ui.display.width.value = canvas.width;ui.display.height.value = canvas.height;/* Круговое движение */let radiusSwitch = ui.rendering.animate.checked ? radius : 0.0;let speed = (performance.now() / 10000) % Math.PI * 2;const offset = [radiusSwitch * Math.cos(speed), radiusSwitch * Math.sin(speed)];gl.useProgram(ctx.shd.scene.handle);const texture = ctx.mode == "scene" ? ctx.tex.sdr : ctx.tex.selfIllum;gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, texture);gl.uniform2fv(ctx.shd.scene.uniforms.offset, offset);gl.uniform1f(ctx.shd.scene.uniforms.radius, radiusSwitch);/* Подготовка буфера кадров постобработки */gl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.scene);gl.viewport(0, 0, canvas.width, canvas.height);/* Вызов отрисовки */gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);/* Box blur с нативным разрешением */gl.useProgram(ctx.shd.blur.handle);const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);if (ctx.mode == "bloom") {/* Теперь выполняем композитинг bloom на экран */gl.bindFramebuffer(gl.FRAMEBUFFER, null);gl.useProgram(ctx.shd.bloom.handle);gl.uniform2fv(ctx.shd.bloom.uniforms.offset, offset);gl.uniform1f(ctx.shd.bloom.uniforms.radius, radiusSwitch);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.sdr);gl.uniform1i(ctx.shd.bloom.uniforms.texture, 0);gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frameFinal);gl.uniform1i(ctx.shd.bloom.uniforms.textureAdd, 1);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);}/* Запрашиваем синхронизацию CPU и GPU, чтобы предотвратить перегрузку GPU во время композитинга.   На самом деле это, скорее всего, приведёт к сбросу,   но помогает на разных устройствах при низких FPS */gl.finish();const now = performance.now();let dt = now - prevNow;if (dt > 0) {const instFPS = 1000 / dt;const ALPHA = 0.05;fpsEMA = ALPHA * instFPS + (1 - ALPHA) * fpsEMA;msEMA = ALPHA * dt + (1 - ALPHA) * msEMA;}prevNow = now;if (ui.rendering.animate.checked && now - lastStatsUpdate >= 1000) {ui.display.fps.value = fpsEMA.toFixed(0);ui.display.ms.value = msEMA.toFixed(2);lastStatsUpdate = now;}}let animationFrameId;/* Рендеринг при нативном разрешении */function nativeResize() {const [width, height] = util.getNativeSize(canvas);if (width && canvas.width !== width || height && canvas.height !== height) {canvas.width = width;canvas.height = height;if (!ctx.flags.benchMode) {stopRendering();startRendering();}if (!ui.rendering.animate.checked)redraw();}}/* Событие изменения размера */nativeResize();let resizePending = false;window.addEventListener('resize', () => {if (!resizePending) {resizePending = true;requestAnimationFrame(() => {resizePending = false;nativeResize();});}});function renderLoop() {if (ctx.flags.isRendering && ui.rendering.animate.checked) {redraw();animationFrameId = requestAnimationFrame(renderLoop);}}function startRendering() {/* Начинаем рендеринг, когда виден canvas */ctx.flags.isRendering = true;renderLoop();}function stopRendering() {/* Предотвращаем вызов ещё одной перерисовки */ctx.flags.isRendering = false;cancelAnimationFrame(animationFrameId);/* Заставляем конвейер рендеринга синхронизироваться с CPU, прежде чем вмешаемся в него */gl.finish();/* Удаляем буферы, чтобы освободить память */gl.deleteTexture(ctx.tex.sdr); ctx.tex.sdr = null;gl.deleteTexture(ctx.tex.selfIllum); ctx.tex.selfIllum = null;gl.deleteTexture(ctx.tex.frame); ctx.tex.frame = null;gl.deleteTexture(ctx.tex.frameFinal); ctx.tex.frameFinal = null;gl.deleteFramebuffer(ctx.fb.scene); ctx.fb.scene = null;gl.deleteFramebuffer(ctx.fb.final); ctx.fb.final = null;ctx.flags.buffersInitialized = false;ctx.flags.initComplete = false;ui.display.fps.value = "-";ui.display.ms.value = "-";}function handleIntersection(entries) {entries.forEach(entry => {if (entry.isIntersecting) {if (!ctx.flags.isRendering && !ctx.flags.benchMode) startRendering();} else {stopRendering();}});}/* Выполняем рендеринг, только когда canvas находится на экране */let observer = new IntersectionObserver(handleIntersection);observer.observe(canvas);}

Результат выглядит не очень красиво. Чем сильнее размытие, тем более «квадратными» становятся элементы изображения. Это вызвано тем, что мы считываем и усредняем текстуру по квадрату. Источники освещения буквально превращаются в квадраты, особенно в режиме bloom с сильной lightBrightness и большим kernelSize.

Производительность тоже очень плоха. При больших kernelSize количество Texture Taps взлетает до небес, а производительность падает. Мобильные устройства начинают сильно тормозить. Даже самые быстрые графические карты PC при увеличении kernelSize и зуме статьи на PC начинают не поспевать за частотой обновления экрана.

Провал по всем фронтам. И некрасиво, и тормозит.

Ещё есть этот samplePosMultiplier. Похоже, он тоже увеличивает силу размытия, не увеличивая при этом textureTaps и не снижая производительность (или немного снижая производительность на некоторых устройствах). Но если значение станет слишком большим, мы получим артефакты в виде повторяющихся паттернов. Давайте поэкспериментируем со схематическим примером (в оригинале статьи он интерактивен):

  • Белый центральный квадрат обозначает выходной пиксель

  • Серые квадраты — это пиксели, которые мы будем считывать с текущим kernelSize и без изменения samplePosMult

  • чёрные точки — это реальные считывания текстуры на каждый выходной пиксель, то есть позиции «сэмплов»

kernelSize 3x3, samplePosMult 100%

kernelSize 3×3, samplePosMult 100%
kernelSize 17x17, samplePosMult 170%

kernelSize 17×17, samplePosMult 170%

Можно сказать, что это изображение — «непрерывный 2D-сигнал». Когда мы считываем текстуру в определённой координате, мы сэмплируем «сигнал изображения» в этой координате. Как говорилось выше, мы используем UV-координаты и не ограничены такими понятиями, как «позиции пикселей». Где мы помещаем сэмплы, зависит полностью от нас.

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

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

Если переборщить, появятся некрасивые повторяющиеся паттерны. Разумеется, при этом возникают фундаментальные вопросы, например, о том, откуда берутся такие артефакты и что вообще подразумевается под чтением между двумя пикселями. И кроме того нам нужно решить проблему производительности и квадратности размытия! Но сначала…

Что такое ядро? 

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

В случае размытия веса ядра должны в сумме давать 1. Если это не так, то нужно или осветлить, или затемнить изображение. Для этого используется этап нормализации. В примере с box blur это выполнятся при помощи деления суммированного цвета пикселя на totalSamples, то есть на общее количество взятых сэмплов. Простое выражение для вычисления среднего.

То же самое можно выразить, как веса ядра — число, умножаемое на каждый сэмпл в этой позиции. Так как в box blur все сэмплы имеют одинаковые веса, вне зависимости от позиции, все веса одинаковы. Это визуализировано ниже. Чем больше размер ядра, тем меньше веса.

kernelSize 3x3, samplePosMult 100%

kernelSize 3×3, samplePosMult 100%
kernelSize 7x7, samplePosMult 200%

kernelSize 7×7, samplePosMult 200%

Ядра, применяемые к краям изображения, выполняют считывание из областей «снаружи» изображения с UV-координатами меньше 0,0 или больше 1,1. К счастью, GPU сам обрабатывает этот случай и мы можем решать, что произойдёт с этими внешними сэмплами при помощи выбора режима обёртывания текстуры (Texture Wrapping).

Texture Wrapping Modes and results on blurring

Режимы Texture Wrapping и их влияние на размытие (обратите внимание на просачивание чёрного цвета) Наверху: буфер кадров. Внизу: нормали буфера кадров с применённым сильным размытием

Среди прочего, мы можем задать используемый сплошной цвет, или ограничиться (clamp) цветом ближайшего края. Если мы выберем сплошной цвет, то получим просачивание цвета по краям. Поэтому почти для всей постобработки используется ограничение цвета краёв, потому что оно препятствует странному поведению по краям. В этой статье сделано так же.

Можно заметить чёрное «пятно» по низу, увеличивающееся при высоких уровнях размытия. Конкретно здесь оно возникает из-за того, что линии плиток пола совпадают с нижним краем, распространяя чёрный цвет в бесконечность.

Свёртка — это на удивление глубокое математическое понятие. На канале 3blue1brown есть прекрасное видео о нём, в том числе оно касается и темы обработки изображений. Теоретически, мы не отходим от свёрток. Можно разделить наш код и выразить их в виде весов и ядер. При работе с box blur и циклом for это было довольно просто!

🂭
undefined…

embedd.srv.habr.com

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

Гауссово размытие 

Самый известный из алгоритмов размытия — гауссово размытие. В нём для определения весов сэмплов внутри ядра используется нормальное распределение, а новая переменная sigma σ управляет тем, насколько плоской будет кривая. За исключением генерации весов ядер алгоритм ничем не отличается от алгоритма box blur.

Формула весов гауссова размытия для точки (x,y) (источник)

Формула весов гауссова размытия для точки (x,y) (источник)

Для вычисления весов точки (x,y) используется приведённая выше формула. Гауссова формула имеет содержит множитель взвешивания 1/(2πσ²). Однако в коде ничего подобного нет. Формула выражает гауссову кривую как непрерывную функцию, которая движется в бесконечность. Но наш код и его цикл for дискретны и конечны.

float gaussianWeight(float x, float y, float sigma){/* e ^ ( - (x² + y²) / 2 σ² ) */return exp(-(x * x + y * y) / (2.0 * sigma * sigma));}

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

Как и в случае с box blur, в конце веса суммируются и делятся вместо члена 1/√(2πσ²) , предварительно вычисляющего веса. sigma управляет резкостью кривой, а значит, и силой размытия, но разве этим занимается не kernelSize? Поэкспериментируйте со всеми этими значениями в оригинале статьи, чтобы понять, как они себя ведут.

Сцена

Сцена
Свет

Свет
Bloom

Bloom
Фрагментный шейдер размытия
/* Выставляем точность вычислений с плавающей запятой на highp, если поддерживается.   При больших размерах ядра на пиксель влияет множество цветов, а потому требуется   максимальная точность, чтобы избежать отсечения.   Требуется в шейдерах WebGL 1, а на некоторых платформах может ни на что не влиять */precision highp float;/* UV-координаты, передаваемые из вершинного шейдера */varying vec2 uv;uniform vec2 frameSizeRCP; /* Величина, обратная разрешению */uniform float samplePosMult; /* Умножение, чтобы сила размытия была больше размера ядра */uniform float sigma;uniform float bloomStrength; /* Сила bloom */uniform sampler2D texture;/* `KERNEL_SIZE` добавляется при компиляции */const int kernel_size = KERNEL_SIZE;float gaussianWeight(float x, float y, float sigma){/* e ^ ( - (x² + y²) / 2 σ² ) */return exp(-(x * x + y * y) / (2.0 * sigma * sigma));}void main() {/* Переменная для хранения окончательного цвета текущего пикселя */vec4 sum = vec4(0.0);/* Сумма всех весов */float weightSum = 0.0;/* Величина одной стороны сэмплируемого квадрата */const int size = 2 * kernel_size + 1;/* Общее количество сэмплов, которое мы будем считывать */const float totalSamples = float(size * size);/* Считываем из текстуры Y пикселей сверху и снизу */for (int y = -kernel_size; y <= kernel_size; ++y) {/* Считываем из текстуры X пикселей слева и справа */for (int x = -kernel_size; x <= kernel_size; ++x) {/* Вычисляем требуемый вес */float w = gaussianWeight(float(x), float(y), sigma);/* Смещение от текущего пикселя, определяющее, какой пиксель считывать */vec2 offset  = vec2(x, y) * samplePosMult * frameSizeRCP;/* Считываем и прибавляем взвешенный вклад этого пикселя */sum += texture2D(texture, uv + offset) * w;weightSum += w;}}/* Возвращаем сумму, поделённую на количество сэмплов (нормализация) */gl_FragColor = (sum / weightSum) * bloomStrength;}
WebGL Javascript
import * as util from '../utility.js'export async function setupGaussianBlur() {/* Инициализация */const WebGLBox = document.getElementById('WebGLBox-GaussianBlur');const WebGLBoxDetail = document.getElementById('WebGLBox-GaussianBlurDetail');const canvas = WebGLBox.querySelector('canvas');/* Размер вращения круга */const radius = 0.12;/* Основной контекст WebGL 1.0 */const gl = canvas.getContext('webgl', {preserveDrawingBuffer: false,antialias: false,alpha: false,});/* Состояние и объекты */const ctx = {/* Состояние рендеринга */mode: "scene",flags: { isRendering: false, buffersInitialized: false, initComplete: false, benchMode: false },/* Текстуры */tex: { sdr: null, selfIllum: null, frame: null, frameFinal: null },/* Буферы кадров */fb: { scene: null, final: null },/* Шейдеры и расположение соответствующих ресурсов */shd: {scene: { handle: null, uniforms: { offset: null, radius: null } },blur: { handle: null, uniforms: { frameSizeRCP: null, samplePosMult: null, sigma: null, bloomStrength: null } },bloom: { handle: null, uniforms: { offset: null, radius: null, texture: null, textureAdd: null } }}};/* Элементы UI */const ui = {display: {spinner: canvas.parentElement.querySelector('svg', canvas.parentElement),contextLoss: canvas.parentElement.querySelector('div', canvas.parentElement),fps: WebGLBox.querySelector('#fps'),ms: WebGLBox.querySelector('#ms'),width: WebGLBox.querySelector('#width'),height: WebGLBox.querySelector('#height'),tapsCount: WebGLBox.querySelector('#taps'),},blur: {kernelSize: WebGLBox.querySelector('#sizeRange'),sigma: WebGLBox.querySelector('#sigmaRange'),samplePos: WebGLBox.querySelector('#samplePosRange'),samplePosReset: WebGLBox.querySelector('#samplePosRangeReset'),},rendering: {animate: WebGLBox.querySelector('#animateCheck'),modes: WebGLBox.querySelectorAll('input[type="radio"]'),lightBrightness: WebGLBox.querySelector('#lightBrightness'),lightBrightnessReset: WebGLBox.querySelector('#lightBrightnessReset'),},benchmark: {button: WebGLBox.querySelector('#benchmark'),label: WebGLBox.querySelector('#benchmarkLabel'),iterOut: WebGLBox.querySelector('#iterOut'),renderer: WebGLBoxDetail.querySelector('#renderer'),iterTime: WebGLBoxDetail.querySelector('#iterTime'),tapsCount: WebGLBoxDetail.querySelector('#tapsCountBench'),iterations: WebGLBox.querySelector('#iterations')}};/* Шейдеры */const circleAnimation = await util.fetchShader("shader/circleAnimation.vs");const simpleTexture = await util.fetchShader("shader/simpleTexture.fs");const bloomVert = await util.fetchShader("shader/bloom.vs");const bloomFrag = await util.fetchShader("shader/bloom.fs");const simpleQuad = await util.fetchShader("shader/simpleQuad.vs");const gaussianBlurFrag = await util.fetchShader("shader/gaussianBlur.fs");/* Элементы, вызывающие перерисовку в режиме без анимации */ui.blur.kernelSize.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.blur.sigma.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.blur.samplePos.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.rendering.lightBrightness.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });/* События */ui.rendering.animate.addEventListener("change", () => {if (ui.rendering.animate.checked)startRendering();else {ui.display.fps.value = "-";ui.display.ms.value = "-";ctx.flags.isRendering = false;redraw()}});canvas.addEventListener("webglcontextlost", () => {ui.display.contextLoss.style.display = "block";});ui.blur.kernelSize.addEventListener('input', () => {reCompileBlurShader(ui.blur.kernelSize.value);ui.blur.samplePos.disabled = ui.blur.kernelSize.value == 0;ui.blur.samplePosReset.disabled = ui.blur.kernelSize.value == 0;});/* Режим рендеринга */ui.rendering.modes.forEach(radio => {/* Принудительно присваиваем значение scene, чтобы устранить баг перезагрузки в Firefox Android */if (radio.value === "scene")radio.checked = true;radio.addEventListener('change', (event) => {ctx.mode = event.target.value;ui.rendering.lightBrightness.disabled = ctx.mode === "scene";ui.rendering.lightBrightnessReset.disabled = ctx.mode === "scene";if (!ui.rendering.animate.checked) redraw();});});ui.benchmark.button.addEventListener("click", () => {ctx.flags.benchMode = true;stopRendering();ui.display.spinner.style.display = "block";ui.benchmark.button.disabled = true;/* Запускаем воркера (модуль ES) */const worker = new Worker("./js/benchmark/gaussianBlurBenchmark.js", { type: "module" });/* Передаём все данные, которые нужны воркеру */worker.postMessage({iterations: ui.benchmark.iterOut.value,blurShaderSrc: gaussianBlurFrag,kernelSize: ui.blur.kernelSize.value,samplePos: ui.blur.samplePos.value,sigma: ui.blur.sigma.value});/* Бенчмарк */worker.addEventListener("message", (event) => {if (event.data.type !== "done") return;ui.benchmark.label.textContent = event.data.benchText;ui.benchmark.tapsCount.textContent = event.data.tapsCount;ui.benchmark.iterTime.textContent = event.data.iterationText;ui.benchmark.renderer.textContent = event.data.renderer;worker.terminate();ui.benchmark.button.disabled = false;ctx.flags.benchMode = false;if (ui.rendering.animate.checked)startRendering();elseredraw();});});ui.benchmark.iterations.addEventListener("change", (event) => {ui.benchmark.iterOut.value = event.target.value;ui.benchmark.label.textContent = "Benchmark";});/* Шейдер отрисовки текстур */ctx.shd.scene = util.compileAndLinkShader(gl, circleAnimation, simpleTexture, ["offset", "radius"]);/* Шейдер отрисовки bloom */ctx.shd.bloom = util.compileAndLinkShader(gl, bloomVert, bloomFrag, ["texture", "textureAdd", "offset", "radius"]);/* Вспомогательная функция для перекомпиляции */function reCompileBlurShader(blurSize) {ctx.shd.blur = util.compileAndLinkShader(gl, simpleQuad, gaussianBlurFrag, ["frameSizeRCP", "samplePosMult", "bloomStrength", "sigma"], "#define KERNEL_SIZE " + blurSize + '\n');}/* Шейдер размытия */reCompileBlurShader(ui.blur.kernelSize.value)/* Передаём вершины в GPU */util.bindUnitQuad(gl);async function setupTextureBuffers() {ui.display.spinner.style.display = "block";ctx.flags.buffersInitialized = true;ctx.flags.initComplete = false;gl.deleteFramebuffer(ctx.fb.scene);gl.deleteFramebuffer(ctx.fb.final);[ctx.fb.scene, ctx.tex.frame] = util.setupFramebuffer(gl, canvas.width, canvas.height);[ctx.fb.final, ctx.tex.frameFinal] = util.setupFramebuffer(gl, canvas.width, canvas.height);let [base, selfIllum] = await Promise.all([fetch("/dual-kawase/img/SDR_No_Sprite.png"),fetch("/dual-kawase/img/Selfillumination.png")]);let [baseBlob, selfIllumBlob] = await Promise.all([base.blob(), selfIllum.blob()]);let [baseBitmap, selfIllumBitmap] = await Promise.all([createImageBitmap(baseBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" }),createImageBitmap(selfIllumBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" })]);ctx.tex.sdr = util.setupTexture(gl, null, null, ctx.tex.sdr, gl.LINEAR, baseBitmap);ctx.tex.selfIllum = util.setupTexture(gl, null, null, ctx.tex.selfIllum, gl.LINEAR, selfIllumBitmap);baseBitmap.close();selfIllumBitmap.close();ctx.flags.initComplete = true;ui.display.spinner.style.display = "none";}let prevNow = performance.now();let lastStatsUpdate = prevNow;let fpsEMA = 60;let msEMA = 16;async function redraw() {if (!ctx.flags.buffersInitialized)await setupTextureBuffers();if (!ctx.flags.initComplete)return;/* UI статистики */const KernelSizeSide = ui.blur.kernelSize.value * 2 + 1;const tapsNewText = (canvas.width * canvas.height * KernelSizeSide * KernelSizeSide / 1000000).toFixed(1) + " Million";ui.display.tapsCount.value = tapsNewText;ui.display.width.value = canvas.width;ui.display.height.value = canvas.height;/* Круговое движение */let radiusSwitch = ui.rendering.animate.checked ? radius : 0.0;let speed = (performance.now() / 10000) % Math.PI * 2;const offset = [radiusSwitch * Math.cos(speed), radiusSwitch * Math.sin(speed)];gl.useProgram(ctx.shd.scene.handle);const texture = ctx.mode == "scene" ? ctx.tex.sdr : ctx.tex.selfIllum;gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, texture);gl.uniform2fv(ctx.shd.scene.uniforms.offset, offset);gl.uniform1f(ctx.shd.scene.uniforms.radius, radiusSwitch);/* Подготовка буфера кадров постобработки */gl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.scene);gl.viewport(0, 0, canvas.width, canvas.height);/* Вызов отрисовки */gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);/* Гауссово размытие при нативном разрешении */gl.useProgram(ctx.shd.blur.handle);const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.uniform1f(ctx.shd.blur.uniforms.sigma, Math.max(ui.blur.kernelSize.value / ui.blur.sigma.value, 0.001));gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);if (ctx.mode == "bloom") {/* Now do the bloom composition to the screen */gl.bindFramebuffer(gl.FRAMEBUFFER, null);gl.useProgram(ctx.shd.bloom.handle);gl.uniform2fv(ctx.shd.bloom.uniforms.offset, offset);gl.uniform1f(ctx.shd.bloom.uniforms.radius, radiusSwitch);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.sdr);gl.uniform1i(ctx.shd.bloom.uniforms.texture, 0);gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frameFinal);gl.uniform1i(ctx.shd.bloom.uniforms.textureAdd, 1);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);}/* Запрашиваем синхронизацию CPU и GPU, чтобы предотвратить перегрузку GPU во время композитинга.   На самом деле это, скорее всего, приведёт к сбросу,   но помогает на разных устройствах при низких FPS */gl.finish();const now = performance.now();let dt = now - prevNow;if (dt > 0) {const instFPS = 1000 / dt;const ALPHA = 0.05;fpsEMA = ALPHA * instFPS + (1 - ALPHA) * fpsEMA;msEMA = ALPHA * dt + (1 - ALPHA) * msEMA;}prevNow = now;if (ui.rendering.animate.checked && now - lastStatsUpdate >= 1000) {ui.display.fps.value = fpsEMA.toFixed(0);ui.display.ms.value = msEMA.toFixed(2);lastStatsUpdate = now;}}let animationFrameId;/* Рендеринг с нативным разрешением */function nativeResize() {const [width, height] = util.getNativeSize(canvas);if (width && canvas.width !== width || height && canvas.height !== height) {canvas.width = width;canvas.height = height;if (!ctx.flags.benchMode) {stopRendering();startRendering();}if (!ui.rendering.animate.checked)redraw();}}/* Событие изменения размера */nativeResize();let resizePending = false;window.addEventListener('resize', () => {if (!resizePending) {resizePending = true;requestAnimationFrame(() => {resizePending = false;nativeResize();});}});function renderLoop() {if (ctx.flags.isRendering && ui.rendering.animate.checked) {redraw();animationFrameId = requestAnimationFrame(renderLoop);}}function startRendering() {/* Начинаем рендеринг, когда canvas видим */ctx.flags.isRendering = true;renderLoop();}function stopRendering() {/* Останавливаем вызов ещё одной перерисовки */ctx.flags.isRendering = false;cancelAnimationFrame(animationFrameId);/* Принудительно синхронизируем конвейер рендеринга с CPU, прежде чем вмешаемся в него */gl.finish();/* Удаляем буферы, чтобы освободить память */gl.deleteTexture(ctx.tex.sdr); ctx.tex.sdr = null;gl.deleteTexture(ctx.tex.selfIllum); ctx.tex.selfIllum = null;gl.deleteTexture(ctx.tex.frame); ctx.tex.frame = null;gl.deleteTexture(ctx.tex.frameFinal); ctx.tex.frameFinal = null;gl.deleteFramebuffer(ctx.fb.scene); ctx.fb.scene = null;gl.deleteFramebuffer(ctx.fb.final); ctx.fb.final = null;ctx.flags.buffersInitialized = false;ctx.flags.initComplete = false;ui.display.fps.value = "-";ui.display.ms.value = "-";}function handleIntersection(entries) {entries.forEach(entry => {if (entry.isIntersecting) {if (!ctx.flags.isRendering && !ctx.flags.benchMode) startRendering();} else {stopRendering();}});}/* Выполняем рендеринг только тогда, когда canvas находится на экране */let observer = new IntersectionObserver(handleIntersection);observer.observe(canvas);}

Размытие выглядит гораздо плавнее, чем box blur, элементы кажутся более «округлыми» благодаря плавному ответному сигналу кривой. Однако если sigma будет слишком малой, то снова появятся артефакты, похожие на артефакты box blur.

Давайте разберёмся, какой смысл у этих значений и как они взаимодействуют. На показанной ниже визуализации представлено ядро с весами, выраженными в виде высоты. Существуют два режима взаимодействия с sigma при изменении kernelSize и два способа выражения sigma.

Абсолютная sigma. kernelSize 7×7, sigma ±3.00σ, 1.00px

Абсолютная sigma. kernelSize 7×7, sigma ±3.00σ, 1.00px
Абсолютная sigma. kernelSize 11×11 sigma ±1.80σ, 2.78px

Абсолютная sigma. kernelSize 11×11 sigma ±1.80σ, 2.78px
Относительная sigma. kernelSize 7×7 sigma ±2.10σ, 1.43px

Относительная sigma. kernelSize 7×7 sigma ±2.10σ, 1.43px
Относительная sigma. kernelSize 25×25 sigma ±3.00σ, 4.00px

Относительная sigma. kernelSize 25×25 sigma ±3.00σ, 4.00px

sigma определяет то, насколько плоской будет математическая кривая, движущаяся в бесконечность. Но у нашего алгоритма kernelSize ограничен. Там, где ядро останавливается, пиксели больше не вносят никакого вклада, поэтому из-за отсечки возникают артефакты, похожие на артефакты box blur. В контексте обработки изображений гауссово размытие можно настроить двумя способами…

Малая сигма, а значит, и плоская кривая наряду с малым размером ядра — это, по сути, и есть box blur.

Способ 1: абсолютная сигмаsigma — это абсолютное значение в пикселях, не зависящее от kernelSize, а kernelSize используется в качестве «окна кривой»

Способ 2: sigma выражается относительно текущего kernelSize. Из практических соображений в статье используется этот способ.

Как бы то ни было, бесконечная гауссова кривая должна иметь где-то отсечку. Если sigma слишком мала, мы получим артефакты. Если sigma слишком велика, то мы впустую потратим эффективность размытия, потому что та же воспринимаемая сила размытия требует ядер большего размера, а значит, и больших циклов for с более низкой производительностью. Любому ПО приходится идти на компромиссы, жертвуя художественной составляющей.

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

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

Биномиальные ядра похожи на гауссовы с точки зрения частотной характеристики. Не буду углубляться в эту тему, просто имейте в виду, что можно выбирать ядра по разным математическим критериям, стремясь получить разные характеристики ответного сигнала. Кстати, а что же подразумевается под «похожи на гауссовы»? И почему это важно?

Что такое гауссианоподобие? 

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

Bokeh blur, gaussian blur comparison

Bokeh blur, gaussian blur comparison

При имитации объективов и/или создании Depth of Field используется боке-размытие, также называемое Lens Blur или Cinematic Blur. Такой тип размытия и есть конечный визуальный эффект. Сложности и решения этих категорий сильно связаны, но для них используются разные алгоритмы.

В этой сфере алгоритмы становятся очень изобретательными, и все они имеют разные компромиссы и внешний вид. В некоторых для сэмплирования применяется распределение Пуассона, в других — неординарные трюки: Computerphile записал видео о создании боке-размытия на основе комплексных чисел — потрясающем пересечении с теорией чисел.

🂭
undefined…

embedd.srv.habr.com

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

Производительность

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

Было бы странно, если бы мы не могли в этой статье замерять производительность. У каждого WebGL Box есть функция бенчмаркинга, размывающая случайный шум с фиксированным разрешением 1600x1200, выбранными параметрами размытия и нагрузкой с фиксированным количеством итераций (эту особенность мы пока не рассматривали).

Программирование графики реального времени — это часто больше измерения, чем само программирование.

Лучше всего проводить бенчмаркинг, измеряя время исполнения шейдеров. Это можно делать в браузерах, но не на всех платформах. К счастью, существует классический способ с «простаиванием графического конвейера» — принудительным ожиданием завершения всех команд.

На всех платформах простаивание гарантировано реализуется командой gl.readPixels(). Любопытно, что команда gl.finish(), которая должна использоваться для этого по стандартам, просто игнорируется на мобильных устройствах Apple.

В оригинале статьи ниже есть кнопка для разблокирования функции бенчмаркинга. Она позволяет запустить бенчмарк с заранее заданной нагрузкой в отдельном воркере браузера. Есть только одна проблема: браузеры очень сердятся, если полностью нагружать GPU таким образом.

Если графический конвейер будет работать слишком долго, не отвечая ничего браузеру, то браузеры просто отключат доступ GPU ко всей странице до перезагрузки вкладки. Если же отвечать браузеру, то замеренные результаты будут бесполезны; к тому внутри WebGL мы не можем останавливать GPU после того, как ему переданы команды.

⚠️ Особенно важно для мобильных: увеличивайте kernelSize и итерации медленно. В предыдущих алгоритмах масштабирование производительности kernelSize намеренно сделано плохим, так что с ними будьте особенно аккуратны.

Время исполнения не должно быть больше 2 секунд; в противном случае браузер заблокирует доступ GPU к странице, отключит все примеры размытия до перезапуска браузера. В Safari iOS для этого потребуется перейти в App Switcher, перезагрузки страницы будет недостаточно.

iOS и iPad OS особенно строги, они не включают доступ GPU даже при перезагрузке вкладки. Необходимо будет перейти к App Switcher (двойное нажатие на кнопку Home), свайпнуть Safari вверх, чтобы закрыть его, а затем перезапустить заново.

Что мы оптимизируем? 

В показанных выше Box Blur и гауссовом размытии мы замеряем масштабирование производительности с kernelSize очень некачественно. Если выразить это в «О» большом, то масштабирование производительности будет выглядеть, как O(pixelCount * kernelSize²). То есть количество texture tap относительно kernelSize увеличивается квадратично. Нужно с этим что-то делать.

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

Несмотря на то, что гауссово размытие в нашей реализации вычисляет ядро полностью с нуля, производительность box blur и гауссова размытия при увеличенном количестве итераций очень близка друг к другу. На самом деле, если вычислять эти ядра заранее, то можно получить одинаковую производительность.

Но разве алгоритм гауссова размытия не сложнее?

В отличие от чипов, выпускавшихся десятки лет назад, современные графические карты имеют очень быструю арифметику, но относительно медленный доступ к памяти. При подобных нагрузках самым медленным аспектом становится доступ к памяти; в нашем случае это texture tap. Чем больше количество tap, тем медленнее алгоритм.

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

Вы можете заметить (особенно на персональных компьютерах), что увеличение samplePosMultiplier отрицательно сказывается на производительности (в определённой мере), хотя значение texture tap остаётся тем же.

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

Это ключевые параметры, к которым стремятся программисты графики при написании фрагментных шейдеров: Texture Taps и использование кэша. Есть и ещё один, которого мы скоро коснёмся. Очевидно, что размытие — это медленно. Пришла пора ускориться!

Разделяемое гауссово размытие

Мы всё ещё не до конца рассмотрели классику алгоритмов размытия. Осталась ещё одна фундаментальная концепция — «разделяемость свёрток». Некоторые свёртки, например, наш Box Blur, гауссово размытие и упомянутая выше биномиальная фильтрация, можно выполнять за два отдельных прохода двумя отдельными 1D-ядрами.

Разбитая на части формула весов гауссова размытия

Разбитая на части формула весов гауссова размытия

Не все свёртки можно разделять. В контексте программирования графики: если можно выразить веса ядра в виде формулы с осями X, Y, и выделить X и Y в две отдельные формулы, то вы получили разделяемость 2D-ядра и можете выполнять свёртку в два прохода, сильно экономя на texture tap.

В некоторых крупнобюджетных видеоиграх использовались эффекты с ядрами, которые невозможно разделить, но ради повышения производительности они всё равно выполнялись в два прохода плюс + 1D-ядро, потому что получавшиеся артефакты посчитали не такими уж плохими.

Computerphile очень хорошо объяснил концепцию разделяемости в контексте обработки 2D-изображений, так что если вам интересно более формальное объяснение, то посмотрите видео.

🂭
undefined…

embedd.srv.habr.com

Ниже представлено наше гауссово размытие, но в разделяемой версии. Мы видим проход 1 и проход 2, а также окончательный результат. То же визуальное качество, что и в нашем гауссовом размытии, те же настройки, но всё значительно быстрее, потому что теперь количество требуемых texture tap не возрастает квадратично.

Пример с kernelSize 19×19

Сцена: проход 1

Сцена: проход 1
Сцена: проход 2

Сцена: проход 2
Сцена: оба прохода

Сцена: оба прохода
Свет: проход 1

Свет: проход 1
Свет: проход 2

Свет: проход 2
Свет: оба прохода

Свет: оба прохода
Bloom: проход 1

Bloom: проход 1
Bloom: проход 2

Bloom: проход 2
Bloom: оба прохода

Bloom: оба прохода
Фрагментный шейдер размытия
/* Выставляем точность вычислений с плавающей запятой на highp, если поддерживается.   При больших размерах ядра на пиксель влияет множество цветов, а потому требуется   максимальная точность, чтобы избежать отсечения.   Требуется в шейдерах WebGL 1, а на некоторых платформах может ни на что не влиять */precision highp float;/* UV-координаты, передаваемые из вершинного шейдера */varying vec2 uv;uniform vec2 frameSizeRCP; /* Величина, обратная разрешению */uniform float samplePosMult; /* Умножение, чтобы сила размытия была больше размера ядра */uniform float sigma;uniform vec2 direction; /* Вектор направления: (1,0) для горизонтального, (0,1) для вертикального */uniform float bloomStrength; /* сила bloom */uniform sampler2D texture;/* `KERNEL_SIZE` добавляется при компиляции */const int kernel_size = KERNEL_SIZE;float gaussianWeight(float x, float sigma){/* e ^ ( - x² / 2 σ² ) */return exp(-(x * x) / (2.0 * sigma * sigma));}void main() {/* Переменная для хранения окончательного цвета текущего пикселя */vec4 sum = vec4(0.0);/* Сумма всех весов */float weightSum = 0.0;/* Величина одной стороны сэмплируемого квадрата */const int size = 2 * kernel_size + 1;/* Выполняем сэмплирование вдоль вектора направления (горизонтального или вертикального) */for (int i = -kernel_size; i <= kernel_size; ++i) {/* Вычисляем требуемый этому 1D-сэмплу вес */float w = gaussianWeight(float(i), sigma);/* Смещаемся от текущего пикселя вдоль указанного направления */vec2 offset = vec2(i) * direction * samplePosMult * frameSizeRCP;/* Считываем и прибавляем взвешенный вклад этого пикселя */sum += texture2D(texture, uv + offset) * w;weightSum += w;}/* Возвращаем сумму, поделённую на суммарный вес (нормализация) */gl_FragColor = (sum / weightSum) * bloomStrength;}
WebGL Javascript
import * as util from '../utility.js'export async function setupGaussianSeparableBlur() {/* Инициализация */const WebGLBox = document.getElementById('WebGLBox-GaussianSeparableBlur');const canvas = WebGLBox.querySelector('canvas');/* Размер вращения круга */const radius = 0.12;/* Основной контекст WebGL 1.0 */const gl = canvas.getContext('webgl', {preserveDrawingBuffer: false,antialias: false,alpha: false,});/* Состояние и объекты */const ctx = {/* Состояние рендеринга */mode: "scene",passMode: "pass1",flags: { isRendering: false, buffersInitialized: false, initComplete: false, benchMode: false },/* Текстуры */tex: { sdr: null, selfIllum: null, frame: null, frameIntermediate: null, frameFinal: null },/* Буферы кадров */fb: { scene: null, intermediate: null, final: null },/* Шейдеры и местоположение соответствующих ресурсов */shd: {scene: { handle: null, uniforms: { offset: null, radius: null } },blur: { handle: null, uniforms: { frameSizeRCP: null, samplePosMult: null, sigma: null, bloomStrength: null, direction: null } },bloom: { handle: null, uniforms: { offset: null, radius: null, texture: null, textureAdd: null } }}};/* Элементы UI */const ui = {display: {spinner: canvas.parentElement.querySelector('svg', canvas.parentElement),contextLoss: canvas.parentElement.querySelector('div', canvas.parentElement),fps: WebGLBox.querySelector('#fps'),ms: WebGLBox.querySelector('#ms'),width: WebGLBox.querySelector('#width'),height: WebGLBox.querySelector('#height'),tapsCount: WebGLBox.querySelector('#taps'),},blur: {kernelSize: WebGLBox.querySelector('#sizeRange'),sigma: WebGLBox.querySelector('#sigmaRange'),samplePos: WebGLBox.querySelector('#samplePosRange'),samplePosReset: WebGLBox.querySelector('#samplePosRangeReset'),},rendering: {animate: WebGLBox.querySelector('#animateCheck'),modes: WebGLBox.querySelectorAll('input[name="modeGaussSep"]'),passModes: WebGLBox.querySelectorAll('input[name="passMode"]'),lightBrightness: WebGLBox.querySelector('#lightBrightness'),lightBrightnessReset: WebGLBox.querySelector('#lightBrightnessReset'),},benchmark: {button: WebGLBox.querySelector('#benchmark'),label: WebGLBox.querySelector('#benchmarkLabel'),iterOut: WebGLBox.querySelector('#iterOut'),renderer: document.getElementById('WebGLBox-GaussianSeparableBlurDetail').querySelector('#renderer'),passMode: document.getElementById('WebGLBox-GaussianSeparableBlurDetail').querySelector('#passMode'),iterTime: document.getElementById('WebGLBox-GaussianSeparableBlurDetail').querySelector('#iterTime'),tapsCount: document.getElementById('WebGLBox-GaussianSeparableBlurDetail').querySelector('#tapsCountBench'),iterations: WebGLBox.querySelector('#iterations')}};/* Шейдеры */const circleAnimation = await util.fetchShader("shader/circleAnimation.vs");const simpleTexture = await util.fetchShader("shader/simpleTexture.fs");const bloomVert = await util.fetchShader("shader/bloom.vs");const bloomFrag = await util.fetchShader("shader/bloom.fs");const simpleQuad = await util.fetchShader("shader/simpleQuad.vs");const gaussianBlurFrag = await util.fetchShader("shader/gaussianBlurSeparable.fs");/* Элементы, вызывающие перерисовку в режиме без анимаций */ui.blur.kernelSize.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.blur.sigma.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.blur.samplePos.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });ui.rendering.lightBrightness.addEventListener('input', () => { if (!ui.rendering.animate.checked) redraw() });/* События */ui.rendering.animate.addEventListener("change", () => {if (ui.rendering.animate.checked)startRendering();else {ui.display.fps.value = "-";ui.display.ms.value = "-";ctx.flags.isRendering = false;redraw()}});canvas.addEventListener("webglcontextlost", () => {ui.display.contextLoss.style.display = "block";});ui.blur.kernelSize.addEventListener('input', () => {reCompileBlurShader(ui.blur.kernelSize.value);ui.blur.samplePos.disabled = ui.blur.kernelSize.value == 0;ui.blur.samplePosReset.disabled = ui.blur.kernelSize.value == 0;});/* Режим рендеринга */ui.rendering.modes.forEach(radio => {/* Принудительно присваиваем значение scene, чтобы устранить баг перезагрузки в Firefox Android */if (radio.value === "scene")radio.checked = true;radio.addEventListener('change', (event) => {ctx.mode = event.target.value;ui.rendering.lightBrightness.disabled = ctx.mode === "scene";ui.rendering.lightBrightnessReset.disabled = ctx.mode === "scene";if (!ui.rendering.animate.checked) redraw();});});/* Режим прохода */ui.rendering.passModes.forEach(radio => {/* Принудительно присваиваем значение pass1, чтобы устранить баг перезагрузки в Firefox Android */if (radio.value === "pass1")radio.checked = true;radio.addEventListener('change', (event) => {ctx.passMode = event.target.value;if (!ui.rendering.animate.checked) redraw();});});ui.benchmark.button.addEventListener("click", () => {ctx.flags.benchMode = true;stopRendering();ui.display.spinner.style.display = "block";ui.benchmark.button.disabled = true;/* Запуск воркера (ES-module) */const worker = new Worker("./js/benchmark/gaussianSeparableBlurBenchmark.js", { type: "module" });/* Передача всех данных, нужных воркеру */worker.postMessage({iterations: ui.benchmark.iterOut.value,blurShaderSrc: gaussianBlurFrag,kernelSize: ui.blur.kernelSize.value,samplePos: ui.blur.samplePos.value,sigma: ui.blur.sigma.value,passMode: ctx.passMode});/* Бенчмарк */worker.addEventListener("message", (event) => {if (event.data.type !== "done") return;ui.benchmark.label.textContent = event.data.benchText;ui.benchmark.tapsCount.textContent = event.data.tapsCount;ui.benchmark.iterTime.textContent = event.data.iterationText;ui.benchmark.renderer.textContent = event.data.renderer;ui.benchmark.passMode.textContent = event.data.passMode;worker.terminate();ui.benchmark.button.disabled = false;ctx.flags.benchMode = false;if (ui.rendering.animate.checked)startRendering();elseredraw();});});ui.benchmark.iterations.addEventListener("change", (event) => {ui.benchmark.iterOut.value = event.target.value;ui.benchmark.label.textContent = "Benchmark";});/* Отрисовка шейдера текстур */ctx.shd.scene = util.compileAndLinkShader(gl, circleAnimation, simpleTexture, ["offset", "radius"]);/* Отрисовка шейдера bloom */ctx.shd.bloom = util.compileAndLinkShader(gl, bloomVert, bloomFrag, ["texture", "textureAdd", "offset", "radius"]);/* Вспомогательная функция для рекомпиляции */function reCompileBlurShader(blurSize) {ctx.shd.blur = util.compileAndLinkShader(gl, simpleQuad, gaussianBlurFrag, ["frameSizeRCP", "samplePosMult", "bloomStrength", "sigma", "direction"], "#define KERNEL_SIZE " + blurSize + '\n');}/* Шейдер размытия */reCompileBlurShader(ui.blur.kernelSize.value)/* Передаём вершины в GPU */util.bindUnitQuad(gl);async function setupTextureBuffers() {ui.display.spinner.style.display = "block";ctx.flags.buffersInitialized = true;ctx.flags.initComplete = false;gl.deleteFramebuffer(ctx.fb.scene);gl.deleteFramebuffer(ctx.fb.intermediate);gl.deleteFramebuffer(ctx.fb.final);[ctx.fb.scene, ctx.tex.frame] = util.setupFramebuffer(gl, canvas.width, canvas.height);[ctx.fb.intermediate, ctx.tex.frameIntermediate] = util.setupFramebuffer(gl, canvas.width, canvas.height);[ctx.fb.final, ctx.tex.frameFinal] = util.setupFramebuffer(gl, canvas.width, canvas.height);// Сбрасываем промежуточную текстуру, чтобы избежать уведомлений при ленивой инициализацииgl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.intermediate);gl.clearColor(0.0, 0.0, 0.0, 1.0);gl.clear(gl.COLOR_BUFFER_BIT);let [base, selfIllum] = await Promise.all([fetch("/dual-kawase/img/SDR_No_Sprite.png"),fetch("/dual-kawase/img/Selfillumination.png")]);let [baseBlob, selfIllumBlob] = await Promise.all([base.blob(), selfIllum.blob()]);let [baseBitmap, selfIllumBitmap] = await Promise.all([createImageBitmap(baseBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" }),createImageBitmap(selfIllumBlob, { colorSpaceConversion: 'none', resizeWidth: canvas.width * 1.12, resizeHeight: canvas.height * 1.12, resizeQuality: "high" })]);ctx.tex.sdr = util.setupTexture(gl, null, null, ctx.tex.sdr, gl.LINEAR, baseBitmap);ctx.tex.selfIllum = util.setupTexture(gl, null, null, ctx.tex.selfIllum, gl.LINEAR, selfIllumBitmap);baseBitmap.close();selfIllumBitmap.close();ctx.flags.initComplete = true;ui.display.spinner.style.display = "none";}let prevNow = performance.now();let lastStatsUpdate = prevNow;let fpsEMA = 60;let msEMA = 16;async function redraw() {if (!ctx.flags.buffersInitialized)await setupTextureBuffers();if (!ctx.flags.initComplete)return;/* Статистика UI  */const KernelSizeSide = ui.blur.kernelSize.value * 2 + 1;/* Разделяемое размытие: pass1/pass2 = 1 проход, combined = 2 прохода */const samplesPerPixel = ctx.passMode == "combined" ? KernelSizeSide * 2 : KernelSizeSide;const tapsNewText = (canvas.width * canvas.height * samplesPerPixel / 1000000).toFixed(1) + " Million";ui.display.tapsCount.value = tapsNewText;ui.display.width.value = canvas.width;ui.display.height.value = canvas.height;/* Круговое движение Motion */let radiusSwitch = ui.rendering.animate.checked ? radius : 0.0;let speed = (performance.now() / 10000) % Math.PI * 2;const offset = [radiusSwitch * Math.cos(speed), radiusSwitch * Math.sin(speed)];gl.useProgram(ctx.shd.scene.handle);const texture = ctx.mode == "scene" ? ctx.tex.sdr : ctx.tex.selfIllum;gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, texture);gl.uniform2fv(ctx.shd.scene.uniforms.offset, offset);gl.uniform1f(ctx.shd.scene.uniforms.radius, radiusSwitch);/* Подготовка буфера кадров постобработки */gl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.scene);gl.viewport(0, 0, canvas.width, canvas.height);/* Вызов отрисовки */gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);/* Реализация разделяемого гауссова размытия */gl.useProgram(ctx.shd.blur.handle);if (ctx.passMode == "pass1") {/* Только Pass 1: горизонтальное размытие выводится сразу на экран */const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.uniform2f(ctx.shd.blur.uniforms.direction, 1.0, 0.0); // Горизонтальное направлениеgl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.uniform1f(ctx.shd.blur.uniforms.sigma, Math.max(ui.blur.kernelSize.value / ui.blur.sigma.value, 0.001));gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);} else if (ctx.passMode == "pass2") {/* Только Pass 2: вертикальное размытие выводится сразу на экран */const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.uniform2f(ctx.shd.blur.uniforms.direction, 0.0, 1.0); // Вертикальное направлениеgl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.uniform1f(ctx.shd.blur.uniforms.sigma, Math.max(ui.blur.kernelSize.value / ui.blur.sigma.value, 0.001));gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);} else {/* Combined: двухпроходное разделяемое размытие *//* Pass 1: горизонтальное размытие в промежуточный буфер */gl.bindFramebuffer(gl.FRAMEBUFFER, ctx.fb.intermediate);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.uniform2f(ctx.shd.blur.uniforms.direction, 1.0, 0.0); // Горизонтальное направлениеgl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frame);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.uniform1f(ctx.shd.blur.uniforms.sigma, Math.max(ui.blur.kernelSize.value / ui.blur.sigma.value, 0.001));gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);/* Pass 2: вертикальное размытие в final */const finalFB = ctx.mode == "bloom" ? ctx.fb.final : null;gl.bindFramebuffer(gl.FRAMEBUFFER, finalFB);gl.viewport(0, 0, canvas.width, canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.bloomStrength, ctx.mode == "scene" ? 1.0 : ui.rendering.lightBrightness.value);gl.uniform2f(ctx.shd.blur.uniforms.direction, 0.0, 1.0); // Вертикальное направлениеgl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frameIntermediate);gl.uniform2f(ctx.shd.blur.uniforms.frameSizeRCP, 1.0 / canvas.width, 1.0 / canvas.height);gl.uniform1f(ctx.shd.blur.uniforms.samplePosMult, ui.blur.samplePos.value);gl.uniform1f(ctx.shd.blur.uniforms.sigma, Math.max(ui.blur.kernelSize.value / ui.blur.sigma.value, 0.001));gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);}if (ctx.mode == "bloom") {/* Выполняем композитинг bloom на экран */gl.bindFramebuffer(gl.FRAMEBUFFER, null);gl.useProgram(ctx.shd.bloom.handle);gl.uniform2fv(ctx.shd.bloom.uniforms.offset, offset);gl.uniform1f(ctx.shd.bloom.uniforms.radius, radiusSwitch);gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.sdr);gl.uniform1i(ctx.shd.bloom.uniforms.texture, 0);gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D, ctx.tex.frameFinal);gl.uniform1i(ctx.shd.bloom.uniforms.textureAdd, 1);gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);}/* Просим синхронизацию CPU-GPU предотвращать перегрузку GPU при композитинге.   На самом деле, это с большой вероятностью приведёт к сбросу, но, похоже,    помогает на различных устройствах с низким FPS */gl.finish();const now = performance.now();let dt = now - prevNow;if (dt > 0) {const instFPS = 1000 / dt;const ALPHA = 0.05;fpsEMA = ALPHA * instFPS + (1 - ALPHA) * fpsEMA;msEMA = ALPHA * dt + (1 - ALPHA) * msEMA;}prevNow = now;if (ui.rendering.animate.checked && now - lastStatsUpdate >= 1000) {ui.display.fps.value = fpsEMA.toFixed(0);ui.display.ms.value = msEMA.toFixed(2);lastStatsUpdate = now;}}let animationFrameId;/* Рендеринг с нативным разрешением */function nativeResize() {const [width, height] = util.getNativeSize(canvas);if (width && canvas.width !== width || height && canvas.height !== height) {canvas.width = width;canvas.height = height;if (!ctx.flags.benchMode) {stopRendering();startRendering();}if (!ui.rendering.animate.checked)redraw();}}/* Событие изменения размера */nativeResize();let resizePending = false;window.addEventListener('resize', () => {if (!resizePending) {resizePending = true;requestAnimationFrame(() => {resizePending = false;nativeResize();});}});function renderLoop() {if (ctx.flags.isRendering && ui.rendering.animate.checked) {redraw();animationFrameId = requestAnimationFrame(renderLoop);}}function startRendering() {/* Начинаем рендеринг, когда canvas видим */ctx.flags.isRendering = true;renderLoop();}function stopRendering() {/* Останавливаем вызов ещё одной перерисовки */ctx.flags.isRendering = false;cancelAnimationFrame(animationFrameId);/* Принудительно синхронизируем конвейер рендеринга с CPU, прежде чем вмешаемся в него */gl.finish();/* Удаляем буферы, чтобы освободить память */gl.deleteTexture(ctx.tex.sdr); ctx.tex.sdr = null;gl.deleteTexture(ctx.tex.selfIllum); ctx.tex.selfIllum = null;gl.deleteTexture(ctx.tex.frame); ctx.tex.frame = null;gl.deleteTexture(ctx.tex.frameIntermediate); ctx.tex.frameIntermediate = null;gl.deleteTexture(ctx.tex.frameFinal); ctx.tex.frameFinal = null;gl.deleteFramebuffer(ctx.fb.scene); ctx.fb.scene = null;gl.deleteFramebuffer(ctx.fb.intermediate); ctx.fb.intermediate = null;gl.deleteFramebuffer(ctx.fb.final); ctx.fb.final = null;ctx.flags.buffersInitialized = false;ctx.flags.initComplete = false;ui.display.fps.value = "-";ui.display.ms.value = "-";}function handleIntersection(entries) {entries.forEach(entry => {if (entry.isIntersecting) {if (!ctx.flags.isRendering && !ctx.flags.benchMode) startRendering();} else {stopRendering();}});}/* Выполняем рендеринг только тогда, когда canvas находится на экране */let observer = new IntersectionObserver(handleIntersection);observer.observe(canvas);}

Если выполнить бенчмарк производительности, то можно увидеть огромный скачок производительности по сравнению с нашим гауссовым размытием! Но здесь всё равно приходится идти на компромисс, пусть и не такой очевидный. Чтобы работать с двумя проходами, мы выполняем запись в новый буфер кадров. Помните, мы говорили о том, что современные чипы имеют высокую скорость, но скорость доступа к памяти относительно неё не такая большая?

В случае современных игр на экранах 4k выполнение нескольких проходов означает запись в память 8,2 миллиона пикселей только для того, чтобы считать их снова. При уменьшении ядер на дисплеях высокого разрешения разделяемое ядро не всегда может быть быстрее. Но при увеличении ядер оно почти всегда быстрее. Насколько мы можем ускориться при таком огромном ускорении?

Продолжение следует…

ссылка на оригинал статьи https://habr.com/ru/articles/1041710/