В предыдущей статье, мы изучили немного самых базовых вещей об WebGPU. В этой статье мы изучим основы inter-stage переменных. Слово inter-stage правильнее всего перевести как межэтапные
Inter-stage переменные работают вместе с vertex shader’ми и fragment shader´ми.
Vertex shader выдает три позиции для растеризации треугольника. Также он может выдавать дополнительные данные для каждой из этих точек. Эти значения будут интерполированны между этими тремя точками. Интерполяция - нахождение средних значений между двумя точками.
Давайте сделаем небольшой пример. Мы начнем с шейдера треугольника из предыдущий статьи. Все что нам нужно сделать - изменить шейдер.
const module = device.createShaderModule({ - label: 'our hardcoded red triangle shaders', + label: 'our hardcoded rgb triangle shaders', code: ` + struct OurVertexShaderOutput { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + }; @vertex fn vs( @builtin(vertex_index) vertexIndex : u32 - ) -> @builtin(position) vec4f { + ) -> OurVertexShaderOutput { let pos = array( vec2f( 0.0, 0.5), // top center vec2f(-0.5, -0.5), // bottom left vec2f( 0.5, -0.5) // bottom right ); + var color = array<vec4f, 3>( + vec4f(1, 0, 0, 1), // red + vec4f(0, 1, 0, 1), // green + vec4f(0, 0, 1, 1), // blue + ); - return vec4f(pos[vertexIndex], 0.0, 1.0); + var vsOutput: OurVertexShaderOutput; + vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0); + vsOutput.color = color[vertexIndex]; + return vsOutput; } - @fragment fn fs() -> @location(0) vec4f { - return vec4f(1, 0, 0, 1); + @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f { + return fsInput.color; } `, });
Сначала мы создаем struct
. Это простой способ создания inter-stage переменных между vertex shader’ом и fragment shader’ом.
struct OurVertexShaderOutput { @builtin(position) position: vec4f, @location(0) color: vec4f, };
Далее мы говорим vertex shader’у вернуть эту структуру.
@vertex fn vs( @builtin(vertex_index) vertexIndex : u32 - ) -> @builtin(position) vec4f { + ) -> OurVertexShaderOutput {
Мы создаем массив с тремя цветами.
var color = array<vec4f, 3>( vec4f(1, 0, 0, 1), // red vec4f(0, 1, 0, 1), // green vec4f(0, 0, 1, 1), // blue );
И далее вместо того, чтобы вернуть просто vec4f
для позиции мы возвращаем экземпляр этой структуры с заполненными данными.
- return vec4f(pos[vertexIndex], 0.0, 1.0); + var vsOutput: OurVertexShaderOutput; + vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0); + vsOutput.color = color[vertexIndex]; + return vsOutput;
В fragment shader’е мы говорим взять одну из этих структур в качестве аргумента для функции.
@fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f { return fsInput.color; }
И просто возвращаем цвет.
Если мы запустим проект, то увидим, что каждый раз, когда видеокарта вызывает наш fragment shader, то треугольник окрашивается, которые интерполированны между этими тремя точками.
Inter-stage переменные - это самый частый способ интерполировать координаты текстуры через треугольник. Мы изучим это в статье о текстурах. Также интерполяцию используют для создания карты нормалей ( interpolating normals ) через треугольник. Мы изучим это в первой статье об освещении.
location
Важно понимать, что почти все в WebGPU работает через vertex shader и fragment shader через индексы. Для inter-stage переменный они соединяются по location индексу.
Чтобы обьяснить это давайте поменяем fragment shader и поставим vec4f
параметр в location(0)
вместо этой структуры.
@fragment fn fs(@location(0) color: vec4f) -> @location(0) vec4f { return color; }
Запускаем и видим, что все работает.
@builtin(position)
Это отмечает одну особенность. Наш шейдер, который использует те же самые структуры в обоих шейдерах имеет поле, которое называется position
, но у него нету location, потому что он возвращает его через @builtin(position)
.
struct OurVertexShaderOutput { * @builtin(position) position: vec4f, @location(0) color: vec4f, };
Это поле НЕ является inter-stage переменной. Переменная находится в builtin
. Это показывает, что @builtin(position)
имеет другое представление в vertex shader’е и fragment shader’е.
В vertex shader’e @builtin(position)
это значит, что видеокарте надо нарисовать треугольники/линии/точки.
В fragment shader’e @builtin(position)
является параметром. Данный параметр - координата пикселя для которой будут происходить расчеты цвета.
Координаты пикселя являются уникальными для каждой точки. Эти значение передаются в fragment shader как координаты центра пикселя.
Если бы текстура, которую мы рисовали имела бы размер 3 на 2 пикселя, то эти точки были бы координатами.
Мы можем поменять наш шейдер, чтобы использовать эти позиции. Для примера, давайте нарисуем шахматную доску.
const module = device.createShaderModule({ label: 'our hardcoded checkerboard triangle shaders', code: ` struct OurVertexShaderOutput { @builtin(position) position: vec4f, - @location(0) color: vec4f, }; @vertex fn vs( @builtin(vertex_index) vertexIndex : u32 ) -> OurVertexShaderOutput { let pos = array( vec2f( 0.0, 0.5), // top center vec2f(-0.5, -0.5), // bottom left vec2f( 0.5, -0.5) // bottom right ); - var color = array<vec4f, 3>( - vec4f(1, 0, 0, 1), // red - vec4f(0, 1, 0, 1), // green - vec4f(0, 0, 1, 1), // blue - ); var vsOutput: OurVertexShaderOutput; vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0); - vsOutput.color = color[vertexIndex]; return vsOutput; } @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f { - return fsInput.color; + let red = vec4f(1, 0, 0, 1); + let cyan = vec4f(0, 1, 1, 1); + + let grid = vec2u(fsInput.position.xy) / 8; + let checker = (grid.x + grid.y) % 2 == 1; + + return select(red, cyan, checker); } `, });
Код выше получает fsInput.position
, которая является @builtin(position)
и конвертирует xy
координаты в vec2u
, которые являются двумя целочисленными числами со знаком. Он может делить их на восемь и отдавать нам увеличенное значение каждых восьми пикселей.
Также это добавляет x
и y
сетку координат вместе, которые расчитываются в модуле 2 и сравнивает результаты с единицей.
Это будет нам возвращать булевую переменную, которая будет либо true либо false для каждого числа.
Наконец-то, мы используем WGSL функцию select
, которая берет два значение и выбирает одно. которое соответствует условию.
В JavaScript’e select
функцию мы пишем так
// Если условие равно false - вернется `a`, а в ином случае вернется `b` select = (a, b, condition) => condition ? b : a;
Но если вы не используете @builtin(position)
в fragment shader’e, то есть становится удобнее, так как это значит, что мы можем использовать одну и ту же структуры для обоих шейдеров. Важно подметить, что position
в структуре в каждом шейдере не связаны. Это абсолютно разные переменные.
Хотя как выше указано - в inter-stage переменных все завязано на @location(?)
.
Получается, что это довольно непривычно создавать разные структуры для обоих шейдеров.
Для того, чтобы улучшить это нужно понимать, что оба шейдера находиться в одном файле ( если отталкиваться от примера, то правильнее сказать в одной строке ) и это сделанно ради удобства. В будущем мы разобьем их на разные модули
- const module = device.createShaderModule({ - label: 'hardcoded checkerboard triangle shaders', + const vsModule = device.createShaderModule({ + label: 'hardcoded triangle', code: ` struct OurVertexShaderOutput { @builtin(position) position: vec4f, }; @vertex fn vs( @builtin(vertex_index) vertexIndex : u32 ) -> OurVertexShaderOutput { let pos = array( vec2f( 0.0, 0.5), // top center vec2f(-0.5, -0.5), // bottom left vec2f( 0.5, -0.5) // bottom right ); var vsOutput: OurVertexShaderOutput; vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0); return vsOutput; } + `, + }); + + const fsModule = device.createShaderModule({ + label: 'checkerboard', + code: ` - @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f { + @fragment fn fs(@builtin(position) pixelPosition: vec4f) -> @location(0) vec4f { let red = vec4f(1, 0, 0, 1); let cyan = vec4f(0, 1, 1, 1); - let grid = vec2u(fsInput.position.xy) / 8; + let grid = vec2u(pixelPosition.xy) / 8; let checker = (grid.x + grid.y) % 2 == 1; return select(red, cyan, checker); } `, });
И нам нужно обновить наше создание pipeline для того, чтобы использовать это
const pipeline = device.createRenderPipeline({ label: 'hardcoded checkerboard triangle pipeline', layout: 'auto', vertex: { - module, + module: vsModule, }, fragment: { - module, + module: fsModule, targets: [{ format: presentationFormat }], }, });
И это будет работать
Следует подчеркнуть, что оба шейдера находятся в одной строке, но для большинства примеров WebGPU это сделано чисто для удобства.
В реальных проектах WebGPU парсит WGSL, чтобы удоствореться в правильности написания шейдера. Далее, WebGPU ищет entryPoint
в шейдере. Здесь, он ищет части где entryPoint ссылается на что-либо и ничего другого. Это удобно, потому-что тебе не нужно указывать типы как структуры или биндинги или группы locations дважды. Если два или более шейдеров деляться своими биндингами или структурами или константами или функциями. Но с точки зрения WebGPU он запомнит их только один раз.
Подмечу: Это не стандартный способ создавать шахматную доску с использованием
@builtin(position)
. Шахматные доски или другие паттерны чаще всего создаются с помощью текстур. Но вы увидете проблему если измените размер окна. Потому-что шахматная доска основана на координатах пикселей нашего canvas’a. Это относится к canvas’y, но не относится к треугольнику.
Мы видим выше inter-stage переменные, вывод из vertex shader, который интерполируется в fragment shader’e. Там есть две настройки для интерполяции. Обычно, эти значение не меняются и это используется для очень необычных случаев, которые будут описаны в других статьях.
Типы интерполяции:
perspective
: Значение интерполируются в зависимости от перспективы (по умолчанию)linear
: Значение интерполируется линейно, без перспективыflat
: Значение не интерполируютсяВиды функции интерполяции:
center
: Интерполяция выполняется от центра пикселя (по умолчанию)centroid
: Интерполяция выполняется в точке, лежащей внутри всех выборок, охватываемых фрагментом внутри текущего примитива. Это значение одинаково для всех выборок в примитиве.sample
: Интерполяция выполняется для каждой выборки. Fragment shader вызывается один раз для каждой выборки при применении этого атрибута.first
: Used only with type = flat
. (default) The value comes from the first vertex of the primitive being drawneither
: Used only with type = flat
. The value comes from either the first or the last vertex of the primitive being drawn.Вы указываете эти аттрибуты. Для примера
@location(2) @interpolate(linear, center) myVariableFoo: vec4f; @location(3) @interpolate(flat) myVariableBar: vec4f;
Подмечу, что если inter-stage переменные являются целым числом - вам нужно установить интерполяцию на режим flat
.
Если вы устанавливаете интерполяцию на flat
, то значение, которое будет проходить через фрагментный шейдер является inter-stage переменной для первой вершины этого треугольника.
В следующей статье мы изучим что такое uniforms как другой путь отсылки данных в шейдер.