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

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

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


Равносторонний треугольник, зелёный сверху, чёрный в нижнем левом углу и красный в нижнем правом, с интерполированными между точками цветами. Чуть более яркая версия чёрного треугольника [перевод на Хабре].

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


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

<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>

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

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

gl.clearColor(1, 1, 1, 1);

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

Компиляция шейдеров


В своей основе OpenGL является фреймворком растеризации, в котором мы должны принимать решение о том, как реализовать всё, кроме растеризации. Следовательно, в GPU должны выполняться как минимум два этапа кода:

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

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

Оба шейдера обычно пишутся на GLSL (OpenGL Shading Language), который затем компилируется в машинный код для GPU. Далее машинный код передаётся 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. Атрибут (attribute) с названием position. По сути, атрибут является входящими данными, и шейдер вызывается для каждого такого элемента входящих данных.
  2. Varying под названием color. Это выходные данные из вершинного шейдера (по одному элементу для каждого элемента входящих данных) и входящие данные для фрагментного шейдера. К моменту передачи значения фрагментному шейдеру они интерполируются на основании свойств растеризации.
  3. Значение gl_Position. По сути, это выходные данные из вершинного шейдера, как и любое varying-значение. Это значение особое, потому что оно используется для определения того, нужно ли вообще отрисовывать пиксели

Также существует тип переменных uniform, который является константой во всём множестве вызовов вершинного шейдера. Такие 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, что хотим выполнять заданные выше шейдеры. Теперь нам осталось только создать входящие данные и дать GPU обработать эти данные.

Отправка входящих данных в GPU


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

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

  1. Сохранять последовательность данных в памяти центрального процессора (CPU).
  2. Передавать байты в память GPU через уникальный буфер, созданный с помощью 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);

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

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

  1. Получаем дескриптор переменной position из созданной выше программы.
  2. Приказываем OpenGL считать данные из точки привязки gl.ARRAY_BUFFER в группах по 3 с определёнными параметрами, например, со смещением и шагом (stride) 0.


const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);

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

Отрисовка!


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

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

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



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


Окончательная сильно упрощённая последовательность этапов, необходимых для отображения треугольника

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