(Эта запись изначально была опубликована на моем личном сайте.)

Современный OpenGL и, соответственно, WebGL сильно отличаются от устаревшего OpenGL, который я изучал в прошлом. Я понимаю, как работает растеризация, поэтому меня устраивают концепции. Однако в каждом учебнике, который я читал, вводились абстракции и вспомогательные функции, из-за чего мне было сложнее понять, какие части действительно являются ключевыми для API OpenGL.

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

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

Инициализация

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

<canvas id="container" width="500" height="500"></canvas>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>

Имея доступ к холсту, мы можем получить контекст рендеринга WebGL и инициализировать его чистый цвет. Цвета в мире OpenGL — это RGBA, где каждый компонент находится между 0 и 1. Чистый цвет — это тот, который используется для рисования холста в начале любого кадра, перерисовывающего сцену.

const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');
gl.clearColor(1, 1, 1, 1);

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

Скомпилировать шейдеры

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

  1. Вершинный шейдер, который запускается для каждой части ввода, выдавая одну 3D (на самом деле, 4D в однородных координатах) позиции для каждого ввода.
  2. Фрагментный шейдер, который запускается для каждого пикселя на экране, выводя, какой цвет должен быть у этого пикселя.

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

Оба шейдера обычно пишутся на GLSL (языке шейдеров OpenGL), который затем компилируется в машинный код для GPU. Затем машинный код отправляется на графический процессор, чтобы его можно было запустить во время процесса рендеринга. Я не буду тратить много времени на GLSL, так как пытаюсь показать только основы, но язык достаточно близок к C, чтобы быть знакомым большинству программистов.

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

const sourceV = `
  attribute vec3 position;
  varying vec4 color;
  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;
const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);
if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}

Здесь есть несколько переменных в коде GLSL, на которые стоит обратить внимание:

  1. атрибут с именем position. Атрибут по сути является вводом, и шейдер вызывается для каждого такого ввода.
  2. Вариант под названием color. Это выходные данные вершинного шейдера (по одному на вход) и входные данные фрагментного шейдера. К моменту передачи значения фрагментному шейдеру оно будет интерполировано на основе свойств растеризации.
  3. Значение gl_Position. По сути, результат вершинного шейдера, как и любое переменное значение. Этот особенный, потому что он используется для определения того, какие пиксели вообще нужно рисовать.

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

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

const sourceF = `
  precision mediump float;
  varying vec4 color;
  void main() {
    gl_FragColor = color;
  }
`;
const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);
if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}

Наконец, и вершинный, и фрагментный шейдеры связаны в одну программу OpenGL.

const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}
gl.useProgram(program);

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

Отправить входные данные в GPU

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

OpenGL предоставляет абстракцию, известную как Vertex Buffer Object (VBO). Я все еще выясняю, как все это работает, но в конечном итоге мы сделаем следующее, используя абстракцию:

  1. Сохраните последовательность байтов в памяти ЦП.
  2. Передайте байты в память графического процессора, используя уникальный буфер, созданный с использованием gl.createBuffer() и точки привязки gl.ARRAY_BUFFER.

У нас будет один VBO для каждой входной переменной (атрибута) в вершинном шейдере, хотя можно использовать один VBO для нескольких входов.

const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);

Как правило, вы указываете свою геометрию с любыми координатами, значимыми для вашего приложения, а затем используете серию преобразований в вершинном шейдере, чтобы поместить их в пространство отсечения OpenGL. Я не буду вдаваться в детали пространства отсечения (они связаны с однородными координатами), но пока X и Y варьируются от -1 до +1. Поскольку наш вершинный шейдер просто передает входные данные как есть, мы можем указать наши координаты непосредственно в пространстве отсечения.

Далее мы также свяжем буфер с одной из переменных в вершинном шейдере. Мы тут:

  1. Получите дескриптор переменной position из программы, которую мы создали выше.
  2. Скажите OpenGL, чтобы он считывал данные из точки привязки gl.ARRAY_BUFFER пакетами по 3 с определенными параметрами, такими как смещение и нулевой шаг.
const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);

Обратите внимание, что мы можем создать VBO и связать его с атрибутом вершинного шейдера таким образом, потому что мы делаем то и другое одно за другим. Если мы разделим эти две функции (например, создадим все VBO за один раз, а затем свяжем их с отдельными атрибутами), нам потребуется вызвать gl.bindBuffer(...) перед тем, как связать каждый VBO с соответствующим атрибутом.

Рисовать!

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

gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);

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

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

Трудной частью изучения OpenGL для меня было огромное количество шаблонов, необходимых для получения самого простого изображения на экране. Поскольку среда растеризации требует, чтобы мы обеспечивали функциональность 3D-рендеринга, а взаимодействие с графическим процессором требует многословия, есть много понятий, которые нужно изучить заранее. Я надеюсь, что эта статья покажет, что основы проще, чем в других учебниках!