В этой статье предполагается, что вы уже прочитали многие другие статьи, начиная с основ. Если вы их не читали, начните с них.
Традиционно WebGL-приложения помещают геометрические данные в буферы. Затем с помощью атрибутов эти данные автоматически подаются из буферов в вершинный шейдер, где программист пишет код для преобразования их в clip space.
Слово традиционно здесь важно. Это всего лишь традиция
делать так. Это вовсе не требование. WebGL не
заботится о том, как мы это делаем, ему важно только, чтобы наш вершинный шейдер
присваивал координаты clip space переменной gl_Position
.
Давайте нарисуем куб с текстурой, используя код, похожий на примеры из статьи о текстурах. Говорят, что нам нужно как минимум 24 уникальные вершины. Это потому, что, хотя у куба всего 8 угловых позиций, один и тот же угол используется на 3 разных гранях, и для каждой грани нужны свои текстурные координаты.
На диаграмме выше видно, что для левой грани угол 3 требует текстурных координат 1,1, а для правой грани тот же угол 3 требует координат 0,1. Для верхней грани понадобятся ещё другие координаты.
Обычно это реализуется так: из 8 угловых позиций делают 24 вершины
// front
{ pos: [-1, -1, 1], uv: [0, 1], }, // 0
{ pos: [ 1, -1, 1], uv: [1, 1], }, // 1
{ pos: [-1, 1, 1], uv: [0, 0], }, // 2
{ pos: [ 1, 1, 1], uv: [1, 0], }, // 3
// right
{ pos: [ 1, -1, 1], uv: [0, 1], }, // 4
{ pos: [ 1, -1, -1], uv: [1, 1], }, // 5
{ pos: [ 1, 1, 1], uv: [0, 0], }, // 6
{ pos: [ 1, 1, -1], uv: [1, 0], }, // 7
// back
{ pos: [ 1, -1, -1], uv: [0, 1], }, // 8
{ pos: [-1, -1, -1], uv: [1, 1], }, // 9
{ pos: [ 1, 1, -1], uv: [0, 0], }, // 10
{ pos: [-1, 1, -1], uv: [1, 0], }, // 11
// left
{ pos: [-1, -1, -1], uv: [0, 1], }, // 12
{ pos: [-1, -1, 1], uv: [1, 1], }, // 13
{ pos: [-1, 1, -1], uv: [0, 0], }, // 14
{ pos: [-1, 1, 1], uv: [1, 0], }, // 15
// top
{ pos: [ 1, 1, -1], uv: [0, 1], }, // 16
{ pos: [-1, 1, -1], uv: [1, 1], }, // 17
{ pos: [ 1, 1, 1], uv: [0, 0], }, // 18
{ pos: [-1, 1, 1], uv: [1, 0], }, // 19
// bottom
{ pos: [ 1, -1, 1], uv: [0, 1], }, // 20
{ pos: [-1, -1, 1], uv: [1, 1], }, // 21
{ pos: [ 1, -1, -1], uv: [0, 0], }, // 22
{ pos: [-1, -1, -1], uv: [1, 0], }, // 23
Эти позиции и текстурные координаты кладутся в буферы и подаются в вершинный шейдер через атрибуты.
Но обязательно ли делать именно так? А что если мы хотим оставить только 8 углов и 4 текстурные координаты? Например:
positions = [
-1, -1, 1, // 0
1, -1, 1, // 1
-1, 1, 1, // 2
1, 1, 1, // 3
-1, -1, -1, // 4
1, -1, -1, // 5
-1, 1, -1, // 6
1, 1, -1, // 7
];
uvs = [
0, 0, // 0
1, 0, // 1
0, 1, // 2
1, 1, // 3
];
А для каждой из 24 вершин мы бы указывали, какие из них использовать.
positionIndexUVIndex = [
// front
0, 1, // 0
1, 3, // 1
2, 0, // 2
3, 2, // 3
// right
1, 1, // 4
5, 3, // 5
3, 0, // 6
7, 2, // 7
// back
5, 1, // 8
4, 3, // 9
7, 0, // 10
6, 2, // 11
// left
4, 1, // 12
0, 3, // 13
6, 0, // 14
2, 2, // 15
// top
7, 1, // 16
6, 3, // 17
3, 0, // 18
2, 2, // 19
// bottom
1, 1, // 20
0, 3, // 21
5, 0, // 22
4, 2, // 23
];
Можно ли использовать это на GPU? Почему бы и нет!?
Мы загрузим позиции и текстурные координаты каждую в свою текстуру, как рассматривалось в статье о data-текстурах.
function makeDataTexture(gl, data, numComponents) {
// расширяем данные до 4 значений на пиксель
const numElements = data.length / numComponents;
const expandedData = new Float32Array(numElements * 4);
for (let i = 0; i < numElements; ++i) {
const srcOff = i * numComponents;
const dstOff = i * 4;
for (let j = 0; j < numComponents; ++j) {
expandedData[dstOff + j] = data[srcOff + j];
}
}
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0, // mip уровень
gl.RGBA32F, // формат
numElements, // ширина
1, // высота
0, // граница
gl.RGBA, // формат
gl.FLOAT, // тип
expandedData,
);
// фильтрация не нужна
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
return tex;
}
const positionTexture = makeDataTexture(gl, positions, 3);
const texcoordTexture = makeDataTexture(gl, uvs, 2);
Поскольку в текстуре может быть до 4 значений на пиксель, функция makeDataTexture
расширяет любые данные до 4 значений на пиксель.
Далее создаём vertex array для хранения состояния атрибутов
// создаём vertex array object для хранения состояния атрибутов
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
Теперь нужно загрузить индексы позиций и texcoord в буфер.
// Создаём буфер для индексов позиций и UV
const positionIndexUVIndexBuffer = gl.createBuffer();
// Биндим его к ARRAY_BUFFER (думаем об этом как ARRAY_BUFFER = positionBuffer)
gl.bindBuffer(gl.ARRAY_BUFFER, positionIndexUVIndexBuffer);
// Кладём индексы позиций и texcoord в буфер
gl.bufferData(gl.ARRAY_BUFFER, new Uint32Array(positionIndexUVIndex), gl.STATIC_DRAW);
и настраиваем атрибут
// Включаем атрибут индекса позиции
gl.enableVertexAttribArray(posTexIndexLoc);
// Говорим атрибуту индекса позиции/texcoord, как забирать данные из буфера
// positionIndexUVIndexBuffer (ARRAY_BUFFER)
{
const size = 2; // 2 компонента на итерацию
const type = gl.INT; // данные - 32-битные целые числа
const stride = 0; // 0 = двигаться вперёд на size * sizeof(type) каждый раз для получения следующей позиции
const offset = 0; // начинать с начала буфера
gl.vertexAttribIPointer(
posTexIndexLoc, size, type, stride, offset);
}
Обратите внимание, что мы вызываем gl.vertexAttribIPointer
, а не gl.vertexAttribPointer
.
I
означает integer и используется для целочисленных и беззнаковых целочисленных атрибутов.
Также заметьте, что size равен 2, поскольку на вершину приходится 1 индекс позиции и 1 индекс texcoord.
Хотя нам нужно только 24 вершины, мы всё равно должны рисовать 6 граней, 12 треугольников каждая, 3 вершины на треугольник = 36 вершин. Чтобы указать, какие 6 вершин использовать для каждой грани, мы будем использовать индексы вершин.
const indices = [
0, 1, 2, 2, 1, 3, // front
4, 5, 6, 6, 5, 7, // right
8, 9, 10, 10, 9, 11, // back
12, 13, 14, 14, 13, 15, // left
16, 17, 18, 18, 17, 19, // top
20, 21, 22, 22, 21, 23, // bottom
];
// Создаём индексный буфер
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// Кладём индексы в буфер
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
Поскольку мы хотим нарисовать изображение на самом кубе, нам нужна 3-я текстура
с этим изображением. Давайте просто создадим ещё одну 4x4 data-текстуру с шахматной доской.
Мы будем использовать gl.LUMINANCE
как формат, поскольку тогда нам нужен только один байт на пиксель.
// Создаём текстуру-шахматку
const checkerTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, checkerTexture);
// Заполняем текстуру 4x4 серой шахматкой
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
4,
4,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
new Uint8Array([
0xDD, 0x99, 0xDD, 0xAA,
0x88, 0xCC, 0x88, 0xDD,
0xCC, 0x88, 0xCC, 0xAA,
0x88, 0xCC, 0x88, 0xCC,
]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
Переходим к вершинному шейдеру… Мы можем получить пиксель из текстуры так:
vec4 color = texelFetch(sampler2D tex, ivec2 pixelCoord, int mipLevel);
Итак, по целочисленным координатам пикселя код выше извлечёт значение пикселя.
Используя функцию texelFetch
, мы можем взять 1D индекс массива
и найти значение в 2D текстуре так:
vec4 getValueByIndexFromTexture(sampler2D tex, int index) {
int texWidth = textureSize(tex, 0).x;
int col = index % texWidth;
int row = index / texWidth;
return texelFetch(tex, ivec2(col, row), 0);
}
Итак, учитывая эту функцию, вот наш шейдер:
#version 300 es
in ivec2 positionAndTexcoordIndices;
uniform sampler2D positionTexture;
uniform sampler2D texcoordTexture;
uniform mat4 u_matrix;
out vec2 v_texcoord;
vec4 getValueByIndexFromTexture(sampler2D tex, int index) {
int texWidth = textureSize(tex, 0).x;
int col = index % texWidth;
int row = index / texWidth;
return texelFetch(tex, ivec2(col, row), 0);
}
void main() {
int positionIndex = positionAndTexcoordIndices.x;
vec3 position = getValueByIndexFromTexture(
positionTexture, positionIndex).xyz;
// Умножаем позицию на матрицу
gl_Position = u_matrix * vec4(position, 1);
int texcoordIndex = positionAndTexcoordIndices.y;
vec2 texcoord = getValueByIndexFromTexture(
texcoordTexture, texcoordIndex).xy;
// Передаём texcoord в фрагментный шейдер
v_texcoord = texcoord;
}
Внизу это по сути тот же шейдер, который мы использовали
в статье о текстурах.
Мы умножаем position
на u_matrix
и выводим
texcoord в v_texcoord
для передачи в фрагментный шейдер.
Разница только в том, как мы получаем position и texcoord. Мы используем переданные индексы и получаем эти значения из соответствующих текстур.
Чтобы использовать шейдер, нужно найти все локации:
// настраиваем GLSL программу
const program = webglUtils.createProgramFromSources(gl, [vs, fs]);
// ищем, куда должны идти вершинные данные
const posTexIndexLoc = gl.getAttribLocation(
program, "positionAndTexcoordIndices");
// ищем uniform'ы
const matrixLoc = gl.getUniformLocation(program, "u_matrix");
const positionTexLoc = gl.getUniformLocation(program, "positionTexture");
const texcoordTexLoc = gl.getUniformLocation(program, "texcoordTexture");
const u_textureLoc = gl.getUniformLocation(program, "u_texture");
Во время рендеринга настраиваем атрибуты:
// Говорим использовать нашу программу (пару шейдеров)
gl.useProgram(program);
// Устанавливаем буфер и состояние атрибутов
gl.bindVertexArray(vao);
Затем нужно привязать все 3 текстуры и настроить все uniform’ы:
// Устанавливаем матрицу
gl.uniformMatrix4fv(matrixLoc, false, matrix);
// кладём текстуру позиций на texture unit 0
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, positionTexture);
// Говорим шейдеру использовать texture unit 0 для positionTexture
gl.uniform1i(positionTexLoc, 0);
// кладём текстуру texcoord на texture unit 1
gl.activeTexture(gl.TEXTURE0 + 1);
gl.bindTexture(gl.TEXTURE_2D, texcoordTexture);
// Говорим шейдеру использовать texture unit 1 для texcoordTexture
gl.uniform1i(texcoordTexLoc, 1);
// кладём текстуру-шахматку на texture unit 2
gl.activeTexture(gl.TEXTURE0 + 2);
gl.bindTexture(gl.TEXTURE_2D, checkerTexture);
// Говорим шейдеру использовать texture unit 2 для u_texture
gl.uniform1i(u_textureLoc, 2);
И наконец рисуем:
// Рисуем геометрию
gl.drawElements(gl.TRIANGLES, 6 * 6, gl.UNSIGNED_SHORT, 0);
И получаем куб с текстурой, используя только 8 позиций и 4 текстурные координаты:
Несколько вещей для заметки. Код ленивый и использует 1D текстуры для позиций и текстурных координат. Текстуры могут быть только такой ширины. Насколько широкими - зависит от машины, что можно запросить с помощью:
const maxSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
Если бы мы хотели обработать больше данных, чем это, нам нужно было бы выбрать какой-то размер текстуры, который подходит нашим данным, и распределить данные по нескольким строкам, возможно дополняя последнюю строку, чтобы получился прямоугольник.
Ещё одна вещь, которую мы делаем здесь - используем 2 текстуры, одну для позиций, одну для текстурных координат. Нет причин, по которым мы не могли бы положить оба данных в ту же текстуру либо чередуя:
pos,uv,pos,uv,pos,uv...
либо в разных местах в текстуре:
pos,pos,pos,...
uv, uv, uv,...
Нам просто пришлось бы изменить математику в вершинном шейдере, которая вычисляет, как их извлекать из текстуры.
Возникает вопрос: стоит ли делать такие вещи? Ответ: “зависит от обстоятельств”. В зависимости от GPU это может быть медленнее, чем более традиционный способ.
Цель этой статьи была в том, чтобы ещё раз указать,
что WebGL не заботится о том, как вы устанавливаете gl_Position
с
координатами clip space, и не заботится о том, как вы
выводите цвет. Ему важно только, чтобы вы их установили.
Текстуры - это действительно просто 2D массивы данных с произвольным доступом.
Когда у вас есть проблема, которую вы хотите решить в WebGL, помните, что WebGL просто запускает шейдеры, и эти шейдеры имеют доступ к данным через uniform’ы (глобальные переменные), атрибуты (данные, которые приходят за итерацию вершинного шейдера), и текстуры (2D массивы с произвольным доступом). Не позволяйте традиционным способам использования WebGL помешать вам увидеть настоящую гибкость, которая там есть.