Using textures in WebGL

Сейчас наша программа рисует вращающийся объёмный куб - давайте натянем на него текстуру вместо заливки граней одним цветом.

Загрузка текстур

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

Примечание: Важно помнить, что загрузка текстур следует правилам кросс-доменности (en-US), что означает, что вы можете загружать текстуры только с сайтов, для которых ваш контент является CORS доверенным. См. подробности в секции "Кросс-доменные текстуры" ниже.

Код для загрузки текстур выглядит так::

//
// Инициализация текстуры и загрузка изображения.
// Когда загрузка изображения завершена - копируем его в текстуру.
//
function loadTexture(gl, url) {
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // Так как изображение будет загружено из интернета,
  // может потребоваться время для полной загрузки.
  // Поэтому сначала мы помещаем в текстуру единственный пиксель, чтобы
  // её можно было использовать сразу. После завершения загрузки
  // изображения мы обновим текстуру.
  const level = 0;
  const internalFormat = gl.RGBA;
  const width = 1;
  const height = 1;
  const border = 0;
  const srcFormat = gl.RGBA;
  const srcType = gl.UNSIGNED_BYTE;
  const pixel = new Uint8Array([0, 0, 255, 255]);  // непрозрачный синий
  gl.texImage2D(gl.TEXTURE_2D, level, internalFormat,
                width, height, border, srcFormat, srcType,
                pixel);

  const image = new Image();
  image.onload = function() {
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, level, internalFormat,
                  srcFormat, srcType, image);

    // У WebGL1 иные требования к изображениям, имеющим размер степени 2,
    // и к не имеющим размер степени 2, поэтому проверяем, что изображение
    // имеет размер степени 2 в обеих измерениях.
    if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
       // Размер соответствует степени 2. Создаём MIP'ы.
       gl.generateMipmap(gl.TEXTURE_2D);
    } else {
       // Размер не соответствует степени 2.
       // Отключаем MIP'ы и устанавливаем натяжение по краям
       gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
       gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
       gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    }
  };
  image.src = url;

  return texture;
}

function isPowerOf2(value) {
  return (value & (value - 1)) == 0;
}

Функция loadTexture() начинается с создания объекта WebGL texture вызовом функции createTexture() (en-US). Сначала функция создаёт текстуру из единственного голубого пикселя, используя texImage2D() (en-US). Таким образом текстура может быть использована сразу (как сплошной голубой цвет) при том, что загрузка изображения может занять некоторое время.

Чтобы загрузить текстуру из файла изображения, функция создаёт объект Image и присваивает атрибуту src адрес, с которого мы хотим загрузить текстуру. Функция, которую мы назначили на событие image.onload,будет вызвана после завершения загрузки изображения. В этот момент мы вызываем texImage2D() (en-US), используя загруженное изображение как исходник для текстуры. Затем мы устанавливаем фильтрацию и натяжение, исходя из того, является ли размер изображения степенью 2 или нет.

В WebGL1 изображения размера, не являющегося степенью 2, могут использовать только NEAREST или LINEAR фильтрацию, и для них нельзя создать mipmap. Также для таких изображений мы должны установить натяжение CLAMP_TO_EDGE. С другой стороны, если изображение имеет размер степени 2 по обеим осям, WebGL может производить более качественную фильтрацию, использовать mipmap и режимы натяжения REPEAT или MIRRORED_REPEAT.

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

Мипмаппинг и UV-повторение могут быть отключены с помощью texParameteri() (en-US). Так вы сможете использовать текстуры с размером, не являющимся степенью 2 (NPOT - non-power-of-two), ценой отключения мипмаппинга, UV-натяжения, UV-повторения, и вам самому придётся контролировать, как именно устройство будет обрабатывать текстуру.

// также разрешено gl.NEAREST вместо gl.LINEAR, но не mipmap.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// Не допускаем повторения по s-координате.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
// Не допускаем повторения по t-координате.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

Повторим, что с этими параметрами совместимые WebGL устройства будут допускать использование текстур с любым разрешением (вплоть до максимального). Без подобной настройки WebGL потерпит неудачу при загрузке NPOT-текстур, и вернёт прозрачный чёрный цвет rgba(0,0,0,0).

Для загрузки изображения добавим вызов loadTexture() в функцию main(). Код можно разместить после вызова initBuffers(gl).

// Загрузка текстуры
const texture = loadTexture(gl, 'cubetexture.png');

Отображение текстуры на гранях

Сейчас текстура загружена и готова к использованию. Но сначала мы должны установить соответствие между координатами текстуры и гранями нашего куба. Нужно заменить весь предыдущий код, который устанавливал цвета граней в initBuffers().

  const textureCoordBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer);

  const textureCoordinates = [
    // Front
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
    // Back
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
    // Top
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
    // Bottom
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
    // Right
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
    // Left
    0.0,  0.0,
    1.0,  0.0,
    1.0,  1.0,
    0.0,  1.0,
  ];

  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates),
                gl.STATIC_DRAW);

...
  return {
    position: positionBuffer,
    textureCoord: textureCoordBuffer,
    indices: indexBuffer,
  };

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

Массив textureCoordinates определяет координаты текстуры, соответствующие каждой вершине каждой грани. Заметьте, что координаты текстуры лежат в промежутке между 0.0 и 1.0. Размерность текстуры нормализуется в пределах между 0.0 и 1.0, независимо от реального размера изображения.

После определения массива координат текстуры, мы копируем его в буфер, и теперь WebGL имеет данные для отрисовки.

Обновление шейдеров

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

Вершинный шейдер

Заменяем вершинный шейдер, чтобы он получал координаты текстуры вместо цвета.

  const vsSource = `
    attribute vec4 aVertexPosition;
    attribute vec2 aTextureCoord;

    uniform mat4 uModelViewMatrix;
    uniform mat4 uProjectionMatrix;

    varying highp vec2 vTextureCoord;

    void main(void) {
      gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
      vTextureCoord = aTextureCoord;
    }
  `;

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

Фрагментный шейдер

Также нужно обновить фрагментный шейдер:

  const fsSource = `
    varying highp vec2 vTextureCoord;

    uniform sampler2D uSampler;

    void main(void) {
      gl_FragColor = texture2D(uSampler, vTextureCoord);
    }
  `;

Вместо задания цветового значения цвету фрагмента, цвет фрагмента рассчитывается из текселя (пикселя внутри текстуры), основываясь на значении vTextureCoord, которое интерполируется между вершинами (как ранее интерполировалось значение цвета).

Атрибуты и uniform-переменные

Так как мы изменили атрибуты и добавили uniform-переменные, нам нужно получить их расположение

  const programInfo = {
    program: shaderProgram,
    attribLocations: {
      vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
      textureCoord: gl.getAttribLocation(shaderProgram, 'aTextureCoord'),
    },
    uniformLocations: {
      projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix'),
      modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix'),
      uSampler: gl.getUniformLocation(shaderProgram, 'uSampler'),
    },
  };

Рисование текстурированного куба

Сделаем несколько простых изменений в функции drawScene().

Во-первых, удаляем код, который определял цветовые буферы, и заменяем его на:

// Указываем WebGL, как извлечь текстурные координаты из буффера
{
    const num = 2; // каждая координата состоит из 2 значений
    const type = gl.FLOAT; // данные в буфере имеют тип 32 bit float
    const normalize = false; // не нормализуем
    const stride = 0; // сколько байт между одним набором данных и следующим
    const offset = 0; // стартовая позиция в байтах внутри набора данных
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
    gl.vertexAttribPointer(programInfo.attribLocations.textureCoord, num, type, normalize, stride, offset);
    gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
}

Затем добавляем код, который отображает текстуру на гранях, прямо перед отрисовкой:

  // Указываем WebGL, что мы используем текстурный регистр 0
  gl.activeTexture(gl.TEXTURE0);

  // Связываем текстуру с регистром 0
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // Указываем шейдеру, что мы связали текстуру с текстурным регистром 0
  gl.uniform1i(programInfo.uniformLocations.uSampler, 0);

WebGL имеет минимум 8 текстурных регистров; первый из них gl.TEXTURE0. Мы указываем, что хотим использовать регистр 0. Затем мы вызываем функцию bindTexture(), которая связывает текстуру с TEXTURE_2D регистра 0. Наконец мы сообщаем шейдеру, что для uSampler используется текстурный регистр 0.

В завершение, добавляем аргумент texture в функцию drawScene().

drawScene(gl, programInfo, buffers, texture, deltaTime);
...
function drawScene(gl, programInfo, buffers, texture, deltaTime) {

Сейчас вращающийся куб должен иметь текстуру на гранях.

Посмотреть код примера полностью | Открыть демо в новом окне

Кросс-доменные текстуры

Загрузка кросс-доменных текстур контролируется правилами кросс-доменного доступа. Чтобы загрузить текстуру с другого домена, она должна быть CORS доверенной. См. детали в статье HTTP access control.

В статье на hacks.mozilla.org есть объяснение с примером, как использовать изображения CORS для создания WebGL текстур.

Примечание: Поддержка CORS для текстур WebGL и атрибут crossOrigin для элементов изображений реализованы в Gecko 8.0.

Tainted (только-для-записи) 2D canvas нельзя использовать в качестве текстур WebGL. Например, 2D <canvas> становится "tainted", когда на ней отрисовано кросс-доменное изображение.

Примечание: Поддержка CORS для Canvas 2D drawImage реализована в Gecko 9.0. Это значит, что использование CORS доверенных кросс-доменных изображений больше не делает 2D canvas "tained" (только-для-записи), и вы можете использовать такую 2D canvas как исходник для текстур WebGL.

Примечание: Поддержка CORS для кросс-доменного видео и атрибут crossorigin для HTML-элемента <video> реализованы в Gecko 12.0.