Простейшая программа

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

WebGL-контекст

До этого мы использовали контекст 2d, который давал доступ к некоторым относительно высокоуровневым примитивам. Сейчас мы будем использовать контекст webgl2, который даёт доступ к функциям OpenGL.

let GL = null;

onload = () => {
    ...
    const canvas = document.getElementById(`идентификатор`);
    canvas.width = 600;
    canvas.height = 600;

    GL = canvas.getContext(`webgl2`);
    ...
}

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

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

Минимальная анимация

Чтобы убедиться, что всё работает, в конец инициализации добавим

const renderFrame = time => {
    GL.clearColor((1 + Math.cos(time/1000))/2, 0, 0, 1);
    GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);

    requestAnimationFrame(renderFrame);
};

requestAnimationFrame(renderFrame);

Функция браузера requestAnimationFrame получает на вход функцию и откладывает её выполнение до того момента, как браузер не будет готов нарисовать очередной кадр (обычно браузер рисует кадры с частотой около 60 кадров в секунду).

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

Куб

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

function makeCube() {
    const v0 = [-1, -1, -1, 0];
    const v1 = [-1, -1,  1, 1];
    const v2 = [-1,  1, -1, 2];
    const v3 = [-1,  1,  1, 3];
    const v4 = [1,  -1, -1, 4];
    const v5 = [1,  -1,  1, 5];
    const v6 = [1,   1, -1, 6];
    const v7 = [1,   1,  1, 7];

    return Float32Array.from(
        v0.concat(v1).concat(v2)
        .concat(v1).concat(v2).concat(v3)
        .concat(v0).concat(v1).concat(v4)
        .concat(v1).concat(v4).concat(v5)
        .concat(v0).concat(v2).concat(v4)
        .concat(v2).concat(v4).concat(v6)
        .concat(v7).concat(v3).concat(v6)
        .concat(v3).concat(v6).concat(v2)
        .concat(v7).concat(v5).concat(v3)
        .concat(v5).concat(v3).concat(v1)
        .concat(v7).concat(v5).concat(v6)
        .concat(v5).concat(v6).concat(v4)
    ); 
}

Эта функция выдаёт типизированный массив (все его числа хранятся в памяти в стандартном формате одинарной точности). Именно такой массив затем можно записать в память видеокарты.

Для того, чтобы переписать эти данные в память видеокарты, можно воспользоваться следующим кодом:

const buffer = GL.createBuffer();

GL.bindBuffer(GL.ARRAY_BUFFER, buffer);
GL.bufferData(GL.ARRAY_BUFFER, makeCube(), GL.STATIC_DRAW);

Метод createBuffer создаёт (изначально пустой) массив в памяти видеокарты и возвращает его уникальный идентификатор, а bufferData выделяет память и копирует данные из оперативной памяти компьютера в видеопамять.

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

Далее мы ещё встретим несколько функций, которые работают именно с тем буфером, который последним был связан с назначением ARRAY_BUFFER.

Замечание На самом деле, createBuffer всего лишь ищет и возвращает неиспользованный идентификатор, а буфер создаётся во время вызова bindBuffer. Но WebGL этот факт от пользователя тщательно скрывает (и правильно делает). Тем не менее, если об этом помнить, то не будет соблазна один и тот же буфер привязывать при помощи bindBuffer к разным назначениям.

Вершинная и фрагментная программы

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

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

Сперва зададим разбиение буфера с вершинами треугольников граней куба на атрибуты:

const coord = 0;
const index = 1;

GL.enableVertexAttribArray(coord);
GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 16, 0);

GL.enableVertexAttribArray(index);
GL.vertexAttribPointer(index, 1, GL.FLOAT, false, 16, 12);

Метод vertexAttribPointer задаёт связь между указанным атрибутом и буфером, связанным в текущий момент с назначением ARRAY_BUFFER.

Его параметры:

  • номер атрибута (нумеруются от нуля и до GL.MAX_VERTEX_ATTRIBS - 1)
  • размерность атрибута (от 1 до 4)
  • формат данных в памяти
  • интерпретация целочисленных форматов (для формата FLOAT не играет роли)
  • расстояние между атрибутами соседних вершин в байтах (можно поставить 0; при этом расстояние будет автоматически вычислено как произведение размерности на размер одной единицы данных указанного формата)
  • номер начального байта атрибута начальной вершины

Формат FLOAT, как нетрудно заметить, требует 4 байта на число.

Теперь напишем вершинный и фрагментный шейдеры:

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

#version 300 es

in vec3 coord;
in float index;

out vec3 color;

void main() {
    vec3 position = coord + vec3(0, 0, 7);
    
    gl_Position = vec4(position.xy, -1, position.z);

    switch (int(index)) {
    case 0:
        color = vec3(0.3, 0.3, 0.3);
        break;
    case 1:
        color = vec3(0.3, 0.3, 0.7);
        break;
    case 2:
        color = vec3(0.3, 0.7, 0.3);
        break;
    case 3:
        color = vec3(0.3, 0.7, 0.7);
        break;
    case 4:
        color = vec3(0.7, 0.3, 0.3);
        break;
    case 5:
        color = vec3(0.7, 0.3, 0.7);
        break;
    case 6:
        color = vec3(0.7, 0.7, 0.3);
        break;
    case 7:
        color = vec3(0.7, 0.7, 0.7);
        break;
    default:
        color = vec3(0, 0, 0);
    }
}

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

#version 300 es
precision highp float;

in vec3 color;
out vec4 frag_color;

void main() {
    frag_color = vec4(color, 1);
}

Особенности шейдеров

Вершинный и фрагментный шейдеры пишутся на языке GLSL, весьма сильно напоминающем язык Си (но не являющимся таковым).

Ключевое отличие GLSL от Javascript и Python — статическая типизация. А именно, каждая переменная, каждая функция и каждая формула имеет тип (в динамически типизированных языках тип имеют только значения формул). В языке Си (и GLSL) объявления переменных выглядят как тип имя_переменной;

Функция main — точка входа в программу (именно она автоматически выполняется при запуске программы). Код вне функций (кроме объявления переменных) запрещён. В качестве типа возвращаемого значения у неё стоит «пустой» тип void.

Перед функцией main определено два входа-атрибута

  • in vec3 coord
  • in float index

и один выход

  • out vec3 color

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

Основная задача вершинного шейдера — выставить значение глобальной переменной gl_Position. Это — четырёхмерный вектор, компоненты которого имеют следующий смысл:

  • смещение вершины вправо от наблюдателя
  • смещение вершины вверх от наблюдателя
  • необработанная глубина вершины
  • смещение вершины вдаль от наблюдателя

Полученный треугольник проецируется на экран, который отождествляется с квадратом \(2\times 2\), удалённым от наблюдателя вдаль на 1. Центр этого квадрата имеет в качестве экранных координат пару нулей. Экранные координаты правого верхнего края — пара единиц.

Задача фрагментного шейдера — выставить цвет — значение глобальной переменной типа out vec4, название которой произвольно (и задаётся в соответствующей строчке программы).

В отличие от вершинного шейдера, во фрагментном требуется явно задать точность вычислений командой precision. Например, команда

precision highp float;

задаёт для чисел типа float точность не ниже стандартной одинарной (1 бит знака, не менее 8 бит экспоненты, не менее 23 бит мантиссы).

Расчёт глубины пикселя

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

GL.enable(GL.DEPTH_TEST);

Фрагментный шейдер может (но не обязан) выставить глубину пикселя — значение глобальной переменной gl_FragDepth. Оно должно быть в пределах от 0 до 1 (значения вне этих границ заменяются на соответствующую границу).

Если фрагментный шейдер не выставляет gl_FragDepth, то используется величина, вычисленная при помощи необработанных глубин соответствующей тройки вершин:

  • каждая необработанная глубина делится на её смещение вдаль
  • полученные глубины линейно интерполируются по спроецированному на экран треугольнику

Для того, чтобы таким образом вычисленные глубины пикселей получались линейно зависящими от реальных удалений соответствующих им точек от наблюдателя, необработанные глубины тоже должны линейно зависить от удаления вершин от наблюдателя. Говоря проще, если четвёртая координата вершины обозначена \(w\), то формула для вычисления необработанной глубины должна иметь вид

\[ \alpha w + \beta \]

При этом вычисленная глубина будет иметь вид

\[ \alpha + \frac{\beta}{W} \]

где \(W\) — смещение точки, соответствующей пикселю, вдаль от наблюдателя.

Вычисленная глубина допускается в пределах от -1 до 1 (и линейно отображается на диапазон от 0 до 1: если d — вычисленная глубина, то по умолчанию gl_FragDepth = (1.0+d)/2.0;).

Если не хочется задумываться над видом формулы, можно взять просто \(\alpha=0\) и \(\beta = -1\) или \(\alpha=\beta=1\).

При этом точки, расположенные между наблюдателем и экраном (напомним, что экран находится на удалении 1 от наблюдателя) рисоваться не будут.

Внимание! Независимо от того, включена ли проверка глубины, и выставлен ли явно gl_FragDepth, по умолчанию не рисуются все точки, у которых вычисленная глубина оказывается вне диапазона от -1 до 1.

Сборка программы

Для того, чтобы использовать вершинный и фрагментный шейдеры, их нужно «собрать» в единую «программу». Далее мы будем называть шейдеры и результат их сборки управляемыми программами, а программу, которая их собирает и запускает (и которую мы сейчас пишем на Javascript) — управляющей.

Собрать шейдеры можно сделать при помощи следующих функций:

function compileShader(source, type) {
    let glType = type;

    if (type === 'vertex') { glType = GL.VERTEX_SHADER; }
    else if (type === 'fragment') { glType = GL.FRAGMENT_SHADER; }

    const shader = GL.createShader(glType);

    GL.shaderSource(shader, source);
    GL.compileShader(shader);


    if (!GL.getShaderParameter(shader, GL.COMPILE_STATUS)) { 
        console.error(`SHADER TYPE ${type}`);
        console.error(GL.getShaderInfoLog(shader));

        return null;
    }

    return shader;
}


function buildProgram(vsSource, fsSource, attributes) {
    const vs = compileShader(vsSource, 'vertex');
    if (vs === null) { return null; }

    const fs = compileShader(fsSource, 'fragment');
    if (fs === null) { return null; }

    const program = GL.createProgram();

    for (const name in attributes) {
        const index = attributes[name];

        GL.bindAttribLocation(program, index, name);
    }
    
    GL.attachShader(program, vs);
    GL.attachShader(program, fs);
    GL.linkProgram(program);

    if (!GL.getProgramParameter(program, GL.LINK_STATUS)) { 
        console.error(GL.getProgramInfoLog(program));

        return null;
    }

    return program;
}

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

Исходники шейдеров можно передать в функцию сборки программы, вставив их прямо в Javascript-программу:

const VS_SRC = `#version 300 es
...
`;

const FS_SRC = `#version 300 es
...
`;

Обратите внимание на обратные кавычки текстовых литералов и расположение первой строчки программы.

Итого полный код настройки геометрии сцены имеет вид

const buffer = GL.createBuffer();

GL.bindBuffer(GL.ARRAY_BUFFER, buffer);
GL.bufferData(GL.ARRAY_BUFFER, makeCube(), GL.STATIC_DRAW);

const coord = 0;
const index = 1;

GL.enableVertexAttribArray(coord);
GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 16, 0);

GL.enableVertexAttribArray(index);
GL.vertexAttribPointer(index, 1, GL.FLOAT, false, 16, 12);

// В этот момент можно сделать GL.bindBuffer(GL.ARRAY_BUFFER, null);
// нужный буфер уже связан с теми атрибутами, которые использует программа.

const program = buildProgram(VS_SRC, FS_SRC, {
    coord, index
});

GL.useProgram(program);

GL.enable(GL.DEPTH_TEST);

Обратите внимание на последнюю строчку: если её забыть, то проверка глубины пикселей будет отключена (попробуйте её закомментировать и посмотреть, что получится).

Осталось сделать единственную вещь — отдать команду на рисование. Для этого нужно после очищения экрана (при помощи GL.clear) выполнить команду

GL.drawArrays(GL.TRIANGLES, 0, 36);

которая приводит к запуску текущей вершинно-фрагментной программы (выбранной при помощи useProgram). Входы drawArrays следующие:

  • способ разбиения вершин на треугольники (TRIANGLES — самый простой: каждые три вершины — очередной треугольник)
  • номер начальной вершины
  • количество вершин

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

Упрощение жизни

Часто при рисовании кадра используется много разных программ и много разных массивов данных.

Для того, чтобы по ходу рисования не заниматься нетривиальным переключением атрибутов и перепривязкой буферов, в OpenGL доступна абстракция, называемая VAO (Vertex Array Object).

VAO хранит в себе набор включённых атрибутов и их привязки к буферам.

Если написать функцию

function createCubeVAO(attributes) {
    const vao = GL.createVertexArray(); // слова Object в названии нет!

    GL.bindVertexArray(vao);

    const buffer = GL.createBuffer();

    GL.bindBuffer(GL.ARRAY_BUFFER, buffer);
    GL.bufferData(GL.ARRAY_BUFFER, makeCube(), GL.STATIC_DRAW);
   
    const { coord, index } = attributes;

    GL.enableVertexAttribArray(coord);
    GL.vertexAttribPointer(coord, 3, GL.FLOAT, false, 16, 0);

    GL.enableVertexAttribArray(index);
    GL.vertexAttribPointer(index, 1, GL.FLOAT, false, 16, 12);

    GL.bindVertexArray(null);

    return vao;
}

то далее весь код настройки геометрии сведётся к

function setupScene() {
    const attributes = {
        coord: 0,
        index: 1,
    };

    const program = buildProgram(VS_SRC, FS_SRC, attributes);

    const cube = createCubeVAO(attributes);  

    GL.enable(GL.DEPTH_TEST);

    return { program, cube };
}

а точка входа в программу примет вид

onload = () => {
    const canvas = document.getElementById("CANVAS");
    canvas.width = 600;
    canvas.height = 600;

    GL = canvas.getContext('webgl2');

    const { cube, program } = setupScene();
    
    const renderFrame = time => {
        GL.clearColor((1 + Math.cos(time/1000))/2, 0, 0, 1);
        GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);

        GL.useProgram(program);
        GL.bindVertexArray(cube);
        GL.drawArrays(GL.TRIANGLES, 0, 36);
        GL.bindVertexArray(null);

        requestAnimationFrame(renderFrame);
    };

    renderFrame(0);
}

Важно! Не забывайте снимать выбор текущего VAO при помощи bindVertexArray(null). Иначе рано или поздно Вы случайно соедините программу, использующую VAO, с программой, не использующей VAO, и часть, не использующая VAO, испортит тот VAO, с которого Вы забыли снять выбор.

Вращение куба

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

Изменим вершинный шейдер (фрагментный трогать не будем — он и так хороший):

#version 300 es

in vec3 coord;
in float index;

out vec3 color;

uniform vec3 axis;
uniform float angle;
uniform vec3 translation;

// t = t.x e12 + t.y e13 + t.z e23 + t.w e
// result = result.x e1 + result.y e2 + result.z e3 + result.w e123
vec4 lapply(vec4 t, vec3 v) {
    return vec4(t.w * v + vec3(
        + t.x * v.y + t.y * v.z,
        - t.x * v.x + t.z * v.z,
        - t.y * v.x - t.z * v.y
    ), t.x*v.z - t.y*v.y + t.z*v.x);
}

// v = v.x e1 + v.y e2 + v.z e3 + v.w e123
vec3 rapply(vec4 v, vec4 t) {
    return t.w * v.xyz + vec3(
        - t.x * v.y - t.y * v.z - t.z * v.w,
        + t.x * v.x - t.z * v.z + t.y * v.w,
        + t.y * v.x + t.z * v.y - t.x * v.w
    );
}

vec3 rotate(vec3 coord, vec3 axis, float angle) {
    axis = normalize(axis);
    float cosine_half = cos(angle / 2.0);
    float sine_half = sin(angle / 2.0);

    vec4 rot_r = vec4(
        sine_half * vec3(axis.z, -axis.y, axis.x),
        cosine_half
    );

    vec4 rot_l = vec4(-rot_r.xyz, rot_r.w);

    return rapply(lapply(rot_l, coord), rot_r);
}

void main() {
    vec3 position = rotate(coord, axis, angle) + translation;
    
    gl_Position = vec4(position.xy, -1, position.z);

    switch (int(index)) { /* всё как и прежде */ }
}

Теперь в нём появились три новые глобальные переменные и функция вращения вокруг заданной оси на заданный угол.

Также модифицируем setupScene:

function setupScene() {
    const attributes = {
        coord: 0,
        index: 1,
    };

    const program = buildProgram(VS_SRC, FS_SRC, attributes);

    const cube = createCubeVAO(attributes);  

    GL.enable(GL.DEPTH_TEST);

    const uniforms = {};

    for (const name of ['axis', 'angle', 'translation']) {
        uniforms[name] = GL.getUniformLocation(program, name);
    }

    return { program, cube, uniforms };
}

Здесь мы получаем идентификаторы соответствующих uniform-переменных из собранной программы (для uniform-ов нет аналога bindAttribLocation; да, впрочем, и не особо нужно — с uniform-ами не связано сложных процедур активации/деактивации и настройки атрибутов).

И, наконец, настройка сцены и рисование кадра примут вид

const { cube, program, uniforms } = setupScene();

const renderFrame = time => {
    GL.clearColor((1 + Math.cos(time/1000))/2, 0, 0, 1);
    GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);

    GL.useProgram(program);
    
    GL.bindVertexArray(cube);
    
    GL.uniform3f(uniforms.axis, 
        Math.cos(time/3000), 2*Math.cos(time/1700), 3
    );
    GL.uniform1f(uniforms.angle, time/1000);
    GL.uniform3f(uniforms.translation, 0, 0, 7);
    
    GL.drawArrays(GL.TRIANGLES, 0, 36);
    
    GL.bindVertexArray(null);

    requestAnimationFrame(renderFrame);
};

renderFrame(0);

В нём перед каждым актом рисования куба мы выставляем новые значения uniform-входов.

Неквадратный холст

Соглашение о том, что пространство проецируется на квадрат \(-1\leqslant x,y\leqslant 1\), \(w=1\) противоречит возможной неквадратности окна/холста, на котором отображается проекция. Причём неквадратный холст — совершенно типичная ситуация: например, почти у всех дисплеев высота и ширина различаются.

Стандартным выходом в такой ситуации является следующая последовательность действий:

  • исходя из каких-то соображений выбирают горизонтальный угол обзора (далее fov); например, у человеческих глаз угол обзора близок к развёрнутому; также популярными являются значения в 90 градусов и 60 градусов
  • после формирования горизонтальных координат вершины x и y по ним формируются модифицированные координаты mx и my, по которым и строится gl_Position
  • горизонтальная координата получается так: mx = x * tan(fov/2); заметим, что fov должен быть меньше развёрнутого угла; при этом значения fov больше 120 градусов без дополнительной обработки, моделирующей тот факт, что глаз не является плоским, могут выглядеть странно
  • вертикальная координата получается так: my = y * tan(fov/2) * aspect, где aspect — отношение горизонтального размера экрана к вертикальному; например, для разрешения \(800\times 600\) требуется aspect=4/3
  • если фрагментному шейдеру требуются координаты пикселя (например, для освещения), их нужно передавать как x, y, w (а не как mx, my ,w)

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

Теперь специфика WebGL (а также — десктопных версий OpenGL): если холст/окно изменяет размер, нужно новый размер явно сообщить OpenGL при помощи метода viewport, иначе картинка будет продолжать рисоваться со старым размером.

Проще всего банально каждый кадр первым делом вызывать следующую функцию

function processResize() {
    const width = GL.canvas.clientWidth;
    const height = GL.canvas.clientHeight;
    const aspect = width / height;

    if (GL.canvas.height !== height || GL.canvas.width !== width) {
        GL.canvas.width = width;
        GL.canvas.height = height;
        GL.viewport(0, 0, width, height); 
    }

    return aspect;
}

Соответственно, полученное из неё соотношение сторон передавать в вершинный шейдер, в котором домножать на него y-координату.

Например, для рассматриваемого нами вращения куба функция рисования кадра (для fov в 90 градусов) примет вид

const renderFrame = time => {
    const aspect = processResize();

    GL.clearColor((1 + Math.cos(time/1000))/2, 0, 0, 1);
    GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT);

    GL.useProgram(program);
    
    GL.bindVertexArray(cube);
    
    GL.uniform3f(uniforms.axis, 
        Math.cos(time/3000), 2*Math.cos(time/1700), 3
    );
    GL.uniform1f(uniforms.angle, time/1000);
    GL.uniform3f(uniforms.translation, 0, 0, 7);
    GL.uniform1f(uniforms.aspect, aspect);
    
    GL.drawArrays(GL.TRIANGLES, 0, 36);
    
    GL.bindVertexArray(null);

    requestAnimationFrame(renderFrame);
};

renderFrame(0);

А в вершинном шейдере должна появиться переменная uniform float aspect, использующаяся в строчке

gl_Position = vec4(position.x, position.y*aspect, -1, position.z);
// было gl_Position = vec4(position.xy, -1, position.z);

Также нужно не забыть добавить aspect в список названий uniform-переменных в функции setupScene.

Полезные функции GLSL

Кроме уже использованного выше normalize, в языке GLSL есть ещё несколько полезных функций. Перечислим некоторые из них.

Начнём с тригонометрии:

  • sin, cos, tan — без комментариев
  • asin, acos — вот этими функциями пользовать не нужно, потому что есть следующиая функция в двухаргументном варианте
  • atan — предпочтительно пользоваться двухаргументной версией atan(y,x), которая возвращает угол, который вектор с координатами (x, y) составляет с положительным направлением первой оси; самое главное при её использовании — не перепутать порядок аргументов
  • degrees, radians — функции для перевода радиан в градусы и обратно
  • exp, log, exp2, log2 — натуральные и двоичные экспонента и логарифм

Продолжим геометрическими функциями:

  • sqrt — квадратный корень
  • inversesqrt — число, обратное квадратному корню
  • length — длина вектора
  • distance — расстояние между точками
  • dot — скалярное произведение векторов
  • cross — векторное произведение векторов
  • normalize — масштабирует вектор так, чтобы его длина стала равной 1
  • reflect — отражает первый аргумент относительно плоскости, перпендикуляром к которой является второй аргумент; второй аргумент нужно предварительно нормализовать

Закончим различными ступенчатыми функциями:

  • abs, sign, floor, ceil, min, max — без комментариев
  • mod — остаток от деления первого аргумента на второй
  • fract — дробная часть (остаток от деления на 1)
  • clampclamp(x,a,b) выдаёт a, если x<a; выдаёт b, если x>b; x, если x между a и b
  • smoothstepsmoothstep(a,b,x) выдаёт 0, если x<a; выдаёт 1, если x>b; гладко интерполирует между 0 и 1, если x между a и b

Наиболее полезными, наверное, можно назвать dot и cross. Например, вышеприведённую функцию вращения можно переписать в терминах dot и cross следующим образом:

// t = t.z e12 - t.y e13 + t.x e23 + t.w e
vec4 lapply(vec4 t, vec3 v) {
    return vec4(t.w * v + cross(v, t.xyz), dot(v, t.xyz));
}

// v = v.x e1 + v.y e2 + v.z e3 + v.w e123
vec3 rapply(vec4 v, vec4 t) {
    return t.w * v.xyz - v.w * t.xyz + cross(t.xyz, v.xyz);
}

vec3 rotate(vec3 coord, vec3 axis, float angle) {
    axis = normalize(axis);
    float cosine_half = cos(angle / 2.0);
    float sine_half = sin(angle / 2.0);

    vec4 rot_r = vec4(sine_half * axis, cosine_half);
    vec4 rot_l = vec4(-rot_r.xyz, rot_r.w);

    return rapply(lapply(rot_l, coord), rot_r);
}

Такой простой вид достигается, как нетрудно видеть, засчёт использования для тензоров 2 ранга базиса \(e_{23}, -e_{13}, e_{12}\).

Также популярен базис, противоположный этому: \(I = -e_{23}\), \(J = e_{13}\), \(K = -e_{12}\). Тензоры \(I, J, K\) называются базисными кватернионами.

Дополнительно

Код с урока с медианами треугольника