Pull to refresh

Lightcycle demo using WebGL (part 0)

Reading time 17 min
Views 4.2K

Вступление


Мне нравится осваивать новые технологии, делая то, чем раньше вообще не занимался. А еще мне нравится TRON. Оба фильма, кстати. Помню, еще до того, как я их посмотрел, в студенческие дремучие времена, я играл в Armagetron и фанател от гонок на светоциклах. После просмотра TRON: Legacy мне внезапно захотелось сделать свой Tron с гридом и изоморфами. Недолго думая, я запустил любимую Visual Studio Express и задумался — а чем это мое творение будет отличаться от свалки клонов «Трона»? Студия плавно закрылась, а мой энтузиазм несколько поутих. Ровно до того момента, как мне на глаза попалась какая-то статья о WebGL. Глаза снова загорелись, а руки сами потянулись к редактору. В голову как-то не приходила мысль, что последний раз я на JavaScript делал обработчик нажатия кнопки на зачет по какому-то предмету.

Итак, сегодня в программе:
  • Низкоуровневое программирование WebGL.
  • Рендеринг простого трехмерного объекта.
  • Подробные комментарии процесса разработки.
  • Много букв и код на JavaScript.
  • Бесплатная выпивка и приятная музыка.


Статья предназначена для тех, кому просто нечего делать и хочется почитать про то, как другие тратят свое время за компьютером вместо прогулок под теплым летним солнцем.


Подробнее о том, что нам хочется получить


В итоге должна получится HTML-страничка, при загрузке которой пользователь получит управление светоциклом, находящимся на бесконечной плоскости где-то в grid'е. Светоцикл должен уметь разгоняться, тормозить, плавно поворачивать и оставлять за собой стену из света. Я не мазохист, поэтому демка будет состоять не из одного файла. Разметка, скрипты, шейдеры, модели — все будет распихано по каталогам. Даже для одного маленького CSS-селектора будет выделен целый файл. Своего выделенного сервера у меня нет, поэтому демка распространяться будет в архиве через файлообменник.

Архитектура


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

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

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

Почему бы не отрисовать все за один заход, спросите вы? Из-за особенностей конвейера OpenGL. Подробнее немного ниже.

От этого будем отталкиваться. Более подробно расписывать смысла нет, да и пора бы написать что-то про реализацию.

Что вообще такое WebGL и как им пользоваться?


Очень много полезной информации для новичка можно почерпнуть на этом сайте. А вот эта ссылка ведет на страничку с подсказкой по функциям WebGL.

В двух словах WebGL — это набор биндингов OpenGL ES 2.0 для JavaScript. Технология эта еще активно разрабатывается, поэтому вменяемой и полной документации по ней еще нет. Но энтузиасты вовсю ее используют.

Пользоваться им очень просто. Нужно только бросить в тело HTML-документа тег &ltcanvas /&gt, выполнить метод элемента canvas.getContext("experimental-webgl") и тем самым получить объект, который используется для рендеринга WebGL. Давайте начнем писать код.

Инициализация движка


Все начинается с загрузки страницы. Установим коллбэк на это событие при помощи jQuery, ибо так проще всего. В коллбэке вызовем функции загрузки ресурсов, получения контекста GL и старта движка.
$(function() {
    loadResources();
    var gl = $("#viewport")[0].getContext("experimental-webgl");
    engineStartup(gl);
});

Затем нам нужно скомпилировать и скомпоновать шейдерные программы, которые будут использоваться в демке. Этим занимаются следующие функции:
function buildShaders(gl, count) {
    var shaders = [];
    
    for (var i = 0; i < 1; i++) {
        shaders[i] = composeProgram(gl,
                                    localStorage.getItem("step " + i + " vertex shader"),
                                    localStorage.getItem("step " + i + " fragment shader"));
    }

    return shaders;
}

// Взято с http://www.guciek.net/webgl_shortest/en
function composeProgram(gl, vertex_shader, fragment_shader) {
    var program = gl.createProgram();
    var addShader = function(type, source) {
        var shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            throw "Could not compile " +
            (type == gl.VERTEX_SHADER ? "vertex" : "fragment") +
            " shader:\n\n" + gl.getShaderInfoLog(shader);
        }
        
        gl.attachShader(program, shader);
    };
    addShader(gl.VERTEX_SHADER, vertex_shader);
    addShader(gl.FRAGMENT_SHADER, fragment_shader);
    gl.linkProgram(program);
    
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        throw "Could not link the shader program.";
    }
    
    return program;
}

Да-да, я использую локальное хранилище браузера для загрузки файлов с локальной машины. Вот такой я извращенец. Кстати, чтобы мой любимый Chrome мне это позволил, приходится запускать его так:
"chrome --allow-file-access-from-files".

Но лучше поговорим о коде и том, что он означает. Первая функция, думаю, ни у кого не вызовет интереса — в ней составляется массив шейдерных программ. А вот создание шейдерной программы, которое происходит во второй функции, гораздо интереснее. Для начала нужно понять, что же такое шейдер в WebGL.

Шейдер — это такая штука...


Шейдер — это программа, выполняемая на графическом процессоре в процессе обработки кадра. В OpenGL ES 2.0 существует два типа шейдеров — вершинные и пиксельные (vertex & fragment соответственно). Порядок выполнения операций в конвейере OpenGL подробно описан здесь, нам лишь нужно знать, что вершинный шейдер выполняется раньше пиксельного и оперирует всеми вершинами в конвейере. Пиксельный шейдер выполняется почти перед самым выводом кадра на экран для каждого пиксела в конвейере и может использовать данные, переданные вершинным шейдером. Комбинация этих двух шейдеров называется шейдерной программой. Одновременно графический процессор может выполнять только одну шейдерную программу, однако это отнюдь не значит, что мы ограничены двумя шейдерами на все. Шейдерные программы можно переключать во время рендеринга, меняя логику обработки всех последующих примитивов. Для того, чтобы делать это быстро, нужно предварительно скомпилировать все используемые шейдеры и скомпоновать из них шейдерные программы. Для хранения программ я не нашел ничего лучше, чем массив. Индекс массива соответствует шагу рендеринга, на котором используется шейдерная программа. Написание собственно шейдеров отложим немного, а пока рассмотрим функцию создания шейдерной программы из исходного кода подробнее.

Первым делом вызывается функция createProgram(), указывающая GL, что мы хотим создать шейдерную программу. Затем в эту самую программу мы добавляем вершинный и пиксельные шейдеры. Добавление происходит в четыре действия. Сначала создается объект для шейдера при помощи gl.createShader(), затем функцией shaderSource() задается его исходный код, после чего происходит компиляция compileShader() и добавление скомпилированного шейдера в программу с attachShader(). Два вызова функции и шейдерная программа содержит два готовых к употреблению шейдера. Теперь программу нужно скомпоновать при помощи linkProgram() — и ею можно пользоваться.

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

Написание шейдеров


Основная теоретическая информация о шейдерах у вас уже есть, поэтому здесь я опишу написание собственно шейдеров.

Шейдеры пишутся на C-подобном языке. Точнее, двух очень похожих языках — одном для вершинных и одном для пиксельных шейдеров. Почитать спеку по GLSL можно во-от тут. Основная программа может передавать шейдерам параметры при помощи uniform-переменных. Главная особенность этих переменных в том, что они не могут менять свое значение во время обработки примитива, что делает их главным способом связи между основной программой и шейдерной программой. Вершинный шейдер может принимать attribute-переменные, которые устанавливаются для каждой вершины в основной программе. Для связи между вершинным и пиксельным шейдерами используются varying-переменные, которые инициализируются вершинным шейдером, затем интерполируются по площади всего обрабатываемого примитива, а интерполированные значения могут быть использованы пиксельным шейдером.

Для примера приведу простую шейдерную программу, которая использует все три типа переменных.
// Vertex shader
attribute vec3 aVertexPosition;
attribute vec4 aVertexColor;

uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;

varying vec4 vColor;

void main()
{
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
    vColor = aVertexColor;
}

// Fragment shader
#ifdef GL_ES
precision highp float;
#endif

varying vec4 vColor;

void main()
{
    gl_FragColor = vColor;
}


Результат предсказуем — примитив с градиентом. Углубимся в процесс обработки.

Типы данных и переменные в шейдерах

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

Векторы могут быть двух-, трех- или четырехкомпонентные, каждая компонента — число с плавающей точкой. Первый и второй типы нам знакомы по школьному курсу математики, а вот последний заставляет задуматься — он что, в четырехмерном пространстве определен? В общем-то, если вы загоните в шейдер логику обработки четырехмерных координат, то может. В приложении к трехмерному пространству четвертая компонента вектора задает значение глубины для вершины. О том, что такое глубина в сцене — позже. Вообще, векторы в шейдерах используются не только для того, чтобы определять координаты в пространстве. Их можно использовать для хранения значений цвета, нормалей, текстурных координат, даты своего рождения… чего душе угодно. Причем стоит особо отметить важность вектора для передачи значений в шейдеры — память в буферах для передачи этих значений выделяется кратно четырехкомпонентному вектору. Значит, если надо передать 2-3-4 не связанных друг с другом числа с плавающей точкой в шейдер, наиболее экономично и правильно будет запихнуть их в один вектор. Мы еще вернемся к этому.

Матрицы — гораздо более интересный тип данных. Наверно вы не раз слышали, что матричные вычисления выполняются графическим процессором на порядок быстрее, чем универсальным? Вот то-то и оно. Матрицы мы будем использовать весьма интенсивно. Подробнее об операциях над матрицами и их эффектами при рендеринге чуть ниже. Сейчас вам нужно знать только то, что в приведенном вершинном шейдере используется две матрицы — перспективы и перемещения. Матрица перспективы определяет положение камеры и угол обзора. Матрица перемещения указывает, куда нужно переместить вершину. Так как шейдер выполняется для примитива, в итоге перемещается весь примитив, опционально поворачиваясь на какой-либо угол.

Наверняка вы заметили переменные gl_Position и gl_FragColor. В эти переменные шейдер заносит результат своего выполнения. gl_Position определяет положение вершины в трехмерном пространстве. gl_FragColor задает цвет пиксела, который мы видим на экране. Помните, что uniform-переменные интерполируются по всему примитиву, прежде чем попасть в пиксельный шейдер? Именно поэтому и возникает градиент на примитиве. Надеюсь, все более или менее понятно, потому что шейдеры, которые будут использоваться в демке, не в пример сложнее приведенных.

Рендеринг, матрицы и все-все-все


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

Теория

Для начала нужно осознать, как строится проекция сцены. Подробности на английском находятся здесь. Главная мысль — точка в пространстве проецируется с использованием матричных операций в точку на плоскости отображения (эта часть конвейера от нас скрыта и слава Б-гу ;). Те из вас, кто внимательно прочел статью по ссылке, возмутятся — дескать, а почему про камеру ничего не сказано? Дело в том, что камера в OpenGL всегда находится в одной точке — начале координат — и сонаправлена с отрицательной частью оси Z. Теперь, когда все остальные уже разминают пальцы, чтобы в комментариях порвать автора на мелкие полигоны, спешу объяснить «почему же тогда в квейке можно вертеть головой?». Дело в том, что так проще работать графическому процессору — формулы для проекции трехмерной точки очень сильно упрощаются, а как следствие — уменьшается количество требуемых вычислений. Ну или так производители графических ускорителей упрощают жизнь программистам, давая им полную свободу в реализации своей камеры. То есть, когда вы в квейке дергаете мышку — это на самом деле не камера вертится, а вся сцена вращается вокруг начала координат. Инквизиторам бы это понравилось. Так что различий между матрицей перспективы и матрицей перемещения нет — они обе перемещают точку в пространстве. Но матрица перемещения делает это только для того, чтобы переместить объект в пространстве, а матрица перспективы изменяет полученное пространство так, чтобы в кадр попала только нужная часть сцены. Зная то, что для отображения примитива в пространстве нужно задать две матрицы, можно задать вопрос — значит ли это, что более сложные модели, чайник к примеру, можно сохранить один раз и потом для перемещения всего объекта просто пересчитывать матрицы? Ответ положительный. Более того, OpenGL дает нам возможность сохранить набор вершин объекта прямо в памяти видеокарты при помощи вершинных буферов, экономя пропускную способность шины видеокарты.

Практика

И вот тут мы плавно подошли к описанию процесса рендеринга сцены. Для примера возьмем треугольник и квадрат с градиентом, но сделаем это не просто выводом статической картинки на экран, а позволим пользователю перемещать камеру.

Берем что-то простое и усложняем


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

Буферы, буферы...

Начнем с работы с буферами. Сама концепция очень проста — есть атрибуты вершинного шейдера и есть массивы со значениями этих атрибутов. Количество элементов в массивах для одного примитива должно совпадать. При помощи функции createBuffer() заявляем подсистеме GL, что мы хотим выделить место под массив значений. Затем при помощи bindBuffer() выбираем только что созданный буфер. Это очень важно, ибо одновременно может быть выбран только один буфер, так что если необходимо обработать несколько буферов — нужно последовательно выбрать их и произвести необходимые действия, только так. Но в буфере должно что-то храниться, поэтому вызовем bufferData() и укажем значения массива и его размер. В коде это выглядит так (кусок функции создания буферов):
    var buffers = [];
    buffers[0] = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers[0]);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
        -1.0,  0.0,  0.0,
         0.0,  1.0,  0.0,
         0.0, -1.0,  0.0]), gl.STATIC_DRAW);
    buffers[0].itemSize = 3;
    buffers[0].itemCount = 3;
    buffers[0].attributeLocation = gl.getAttribLocation(shaders[0], "aVertexPosition");
    gl.enableVertexAttribArray(buffers[0].attributeLocation);

Переменные itemSize, itemCount и attributeLocation используются при рендеринге. Не будем пока заострять на них внимание. При помощи функции getAttribLocation() сохраняются позиции двух атрибутов вершинного шейдера для последующего использования при рендеринге. Функция же enableVertexAttribArray() делает ровно то, чего от нее ожидаешь.

Может, нарисуем уже что-нибудь?

Собственно, на данный момент нам достаточно одной шейдерной программы и двух буферов — один содержит положение вершин примитива, а другой — их цвета в формате RGBA float32 (я не ошибся, 32-х битное число с плавающей точкой на канал). Можно забить на все эти матрицы и вьюпорты (viewport) и просто нарисовать треугольник. Результат, конечно, будет не ахти.

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

Снова матрицы

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

Для начала нужно понять, что же такое перемещение точки в пространстве. Для примера возьмем точку А(1; 1; 1) и попробуем переместить ее на +1 по оси Z. После долгих и мучительных попыток сосчитать что-то в уме, я использовал калькулятор и получил точку А(1; 1; 2). Это очень простая операция, так как при перемещении точки по одной или нескольким осям нужно всего лишь прибавить число к соответствующей компоненте точки. А давайте теперь попробуем повернуть точку А(1; 1; 0) на 45 градусов в плоскости XY относительно положительного направления оси X. Мучительно скрипя мозгами, можно вспомнить кое-что похожее из высшей математики. По представленной ссылке достаточно подробно описаны кватернионы и их применение, рекомендую к прочтению. Англочитающие могут пройти на википедию, чтобы узнать еще больше теории. Но для работы нам нужно знать только то, что матрица 4х4 может содержать в себе информацию о вращении точки относительно каждой из трех осей и перемещении точки в пространстве. Большего нам и не нужно, комбинацией этих четырех преобразований можно переместить любую точку так, как нам вздумается. И не только точку, а целый примитив и даже объект.

Таки повернем злосчастную точку А(1; 1; 0), но сделаем это в коде. Для начала нужно создать матрицу преобразований. Этот процесс состоит из нескольких этапов. Сначала создадим единичную матрицу (identity matrix), которая означает, что никаких преобразований к точке применяться не будет. Эта матрица содержит единицы в главной диагонали и нули в остальных позициях. Затем к единичной матрице нужно последовательно применить требуемые преобразования — перемещение (функция translate()), вращение (функция rotate() соответственно) и масштабирование (функция scale()). В итоге получим матрицу, комбинирующую все примененные к ней преобразования. В коде это выглядит так:
    var matrix = mat4.create();
    mat4.identity(matrix);
    matrix.rotate(matrix, Math.PI / 4, [1, 0, 0]);

В итоге получим матрицу, умножение которой на вектор, представляющий собой исходную точку в пространстве, даст в результате искомую точку. Это действие производится в вершинном шейдере, причем это так же просто, как умножить два числа. Серьезно, умножение матрицы на вектор (да и матрицы на матрицу, и вектора на вектор тоже) в GLSL представлено простым оператором "*":
attribute vec3 aVertexPosition;
attribute vec4 aVertexColor;

uniform mat4 matrix;

varying vec4 vColor;

void main()
{
    gl_Position = matrix * vec4(aVertexPosition, 1.0);
    vColor = aVertexColor;
}

Не забываем, что матрица преобразований передается в вершинный шейдер как uniform-переменная.

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

Подробнее о камере

Если перемещение примитивов уже не так мудрено, то работа с камерой грозит добить ваш мозг окончательно. Но не так страшен черт, как его малюют. Фактически, это все те же родные преобразования, только с другой точки зрения (простите за каламбур). Как упоминалось выше, камера в OpenGL находится в начале координат. Чтобы некий объект очутился на экране, нужно подвинуть его в пространстве на отрицательную часть оси Z. Чтобы повернуть камеру на 90 градусов вверх (по оси X то бишь), нужно повернуть сцену на 90 градусов вниз относительно начала координат по все той же оси X. Имитация движения от объекта производится простым перемещением его от начала координат по оси Z. Масштабирование нам пока не потребуется. Самым важным свойством камеры является то, что матрица камеры применяется ко всем объектам в сцене.

glMatrix.js помогает нам в создании матрицы для камеры. Есть аж целых три функции для разных проекций: perspective(), ortho() и frustrum(). Мы будем пока что использовать perspective(). Но никто не запрещает создать свою матрицу с Нео и Тринити произвольными параметрами.

И все-таки он вертится!

Следующий код заставит наш треугольник вращаться. При этом вы заметите, что он стал несколько меньше — это эффект перспективы.
// Я знаю, что использование массивов в коде выглядит уродливо,
// но плодить кучу переменных мне тоже не хотелось.
// Приму в дар советы по улучшению кода.
function drawFrame(gl, shaders, buffers, matrices) {
    gl.viewport(0, 0, gl.canvas.clientWidth, gl.canvas.clientHeight);
    gl.clearColor(0.0, 0.0, 0.0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(shaders[0]);
    mat4.perspective(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.1, 100.0, matrices[0]);
    mat4.rotate(matrices[1], Math.PI / 100, [1, 1, 1]);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers[0]);
    gl.vertexAttribPointer(buffers[0].attributeLocation, buffers[0].itemSize, gl.FLOAT, false, 0, 0);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers[1]);
    gl.vertexAttribPointer(buffers[1].attributeLocation, buffers[1].itemSize, gl.FLOAT, false, 0, 0);
    gl.uniformMatrix4fv(matrices[0].uniformLocation, false, matrices[0]);
    gl.uniformMatrix4fv(matrices[1].uniformLocation, false, matrices[1]);
    gl.drawArrays(gl.TRIANGLES, 0, buffers[0].itemCount);
}

Подготовительные действия уже описаны, кроме одного — использования getUniformLocation() для получения расположения uniform-переменных нашего шейдера, но так как ее применение простое донельзя — тормозить не будем. Исходник, кстати, можно взять тут.

Разберем код. Первый вызов функции viewport() устанавливает размер области вывода изображения на нашем холсте. Функции clearColor() и clear() очищают область вывода заданным цветом. Операции с матрицами описаны ранее, параметры функций описывать не буду — их можно найти на странице библиотеки. С помощью знакомой нам функции bindBuffer() выбираются массивы с координатами и цветами вершин, которые затем при помощи vertexAttribPointer() устанавливаются в качестве источника для attribute-переменных вершинного шейдера. Затем следуют два вызова uniformMatrix4fv(), которые задают две uniform-матрицы для шейдеров: первая — матрица камеры, вторая — преобразования примитива. Ну и в конце концов drawArrays() выводит на экран наш треугольник.

Финальный аккорд


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

Представление модели в OpenGL

Вообще-то OpenGL не знает, что такое модель. Он оперирует только примитивами и их массивами. Задачей программиста является представить модель какого-либо формата в виде набора примитивов, которые можно скормить конвейеру GL и получить ожидаемый результат в виде проекции этой модели на экране. Вы не поверите, но для отображения сложного меша нам почти не понадобится изменять код рендеринга. Фактически, нужно только добавить кое-какие действия для отображения модели в виде каркаса. Но для начала более пристально взглянем на функцию drawArrays(). Она принимает три аргумента — первый определяет, как интерпретировать выбранный вершинный буфер, второй указывает на индекс элемента, с которого нужно начать обработку, а третий задает количество обрабатываемых вершин. Проще говоря, заполнив вершинный буфер координатами вершин всех треугольников модели нам нужно просто увеличить количество обрабатываемых вершин — и все будет пучком.

Опытный разработчик сразу укажет на недостаток подхода — чрезмерное использование памяти для хранения дублирующихся вершин. Дело в том, что в OpenGL есть два способа сократить использование памяти и ускорить вывод примитивов. Первый — это еще одна функция для вывода примитивов — drawElements(). Главное ее отличие состоит в том, что она оперирует не просто координатами вершин, а массивом индексов вершин и массивом собственно вершин. Таким образом для вывода двух треугольников с двумя общими вершинами потребуется предварительно сохранить в памяти видеокарты массив вершин, а затем передать функции drawElements() массив индексов вершин, которые мы хотим использовать для вывода примитива. Учитывая то, что размер индекса может быть аж два байта, потребление памяти упадет. Этот способ хорош, но только для очень большого количества дублирующихся вершин в разных моделях. В нашем случае лучше всего было бы использовать параметр TRIANGLE_STRIP вместо TRIANGLES в вызове drawArrays(). Полоса треугольников представляет собой обычный массив с вершинами, однако интерпретируется он иначе. Первый треугольник будет состоять из вершин с индексами [0,1,2], как и в случае и TRIANGLES. Однако вершинами второго треугольника будут [1,2,3], тогда как в случае с TRIANGLES второй треугольник задается вершинами с индексами [3,4,5]. Тоже неплохая экономия памяти. Модель, которую я использую (взято, кстати, отсюда), состоит из массива вершин и массива треугольников, причем вершины треугольников адресуются индексами в первом массиве. Поэтому самым правильным вариантом будет использование drawElements() с параметром TRIANGLES. Ради упрощения задачи я не буду сортировать треугольники так, чтобы они составили полосу, но в будущем это необходимо.

Рендеринг модели

Модель в формате JSON состоит из массива вершин, массива индексов, количества вершин и количества треугольников. Парсинг выполняется средствами jQuery. Код рендеринга изменился не сильно, отличие только в самом конце — при выводе объекта. Шейдеры так вообще не изменились.
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers[2]);
    gl.drawElements(gl.TRIANGLES, buffers[2].itemCount, gl.UNSIGNED_SHORT, 0);

Третий буфер содержит в себе массив индексов. Его нужно выбрать в GL перед вызовом drawElements(). Стоит отметить, что хоть буфер вершин и буфер цветов вершин заполняются при помощи распарсенного JSON'а, но на код это почти не повлияло. Цвета вершин считаются случайным образом, можно забавно раскрасить модель, если все компоненты выставить в случайные значения. Исходник можно получить отсюда. А вот что он может показать:


Злоключение


Эту статью я начал писать как способ упорядочить свои мысли при разработке. Не стоит рассматривать ее, как туториал или теоретические выкладки — и то, и другое можно найти по ссылкам в статье. Однако, если кому-то описанное покажется интересным или даже окажется полезным — я буду рад. Честно.

Если хабру интересна поднятая тема, я напишу продолжение. Возможно, что не скоро — сейчас не располагаю большим количеством свободного времени на хобби — но напишу. В следующей серии:
  • Буфер глубины и буфер трафарета.
  • Отражения и текстуры.
  • Управление.
  • Изучение более высокоуровневой библиотеки для WebGL.


Если в комментариях скастуются гуру JavaScript — с радостью выслушаю советы по улучшению кода и дальнейшей разработке.

Ссылок на использованные материалы не будет, ибо они есть в теле статьи, а вытаскивать лень.
Tags:
Hubs:
+77
Comments 19
Comments Comments 19

Articles