Освещение куба с помощью WebGL. Модель освещения Blinn-Phong

Всем привет!

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

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

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

Перво-наперво нам необходимо создать куб. Куб состоит из шести граней и каждая из граней состоит из двух треугольников. Каждая вершина в треугольнике должна содержать: position, texcoord, normal, tangent, binormal, где:

  • position — позиция вершины в пространстве модели
  • texcoord — текстурные координаты
  • normal — нормаль поверхности (направление Z)
  • tangent — тангент поверхности (направление X)
  • binormal — бинормаль поверхности (направление Y)

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

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

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

  • создает матрицу трансформации из координат OpenGL (X — вправо, Y — вверх, Z — в камеру) в координаты грани (X — тангент, Y — бинормаль, Z — нормаль)
  • транформирует четыре вершины (левая верхняя, правая верхняя, левая нижняя, правая нижняя) в координаты грани с помощью этой матрицы
function createPlane(zDir, yDir)
{
    // Нормализуем полученные вектора на всякий случай
    vec3.normalize(zDir, zDir);
    vec3.normalize(yDir, yDir);
 
    // Векторное произведение дает нормаль 
    // к плоскости образованной двуми векторами
    var xDir = vec3.create();
    vec3.cross(xDir, yDir, zDir);
    vec3.normalize(xDir, xDir);
 
    // Матрица трансформации в пространство нормали
    var transform = mat3.fromValues(
        xDir[0], yDir[0], zDir[0],
        xDir[1], yDir[1], zDir[1],
        xDir[2], yDir[2], zDir[2]);
 
    mat3.transpose(transform, transform);
 
    // Четыре точки нашей грани
    var v0 = vec3.fromValues(-1, -1, 1);
    var v1 = vec3.fromValues(1, -1, 1);
    var v2 = vec3.fromValues(-1, 1, 1);
    var v3 = vec3.fromValues(1, 1, 1);
 
    // Трансформация нашей грани в пространстве
    vec3.transformMat3(v0, v0, transform);
    vec3.transformMat3(v1, v1, transform);
    vec3.transformMat3(v2, v2, transform);
    vec3.transformMat3(v3, v3, transform);
 
    // Текстурные координаты всех граней одинаковые    
    var uv0 = vec2.fromValues(0, 0);
    var uv1 = vec2.fromValues(1, 1);
 
    return [
        v0[0], v0[1], v0[2], uv0[0], uv0[1], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
        v1[0], v1[1], v1[2], uv1[0], uv0[1], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
        v2[0], v2[1], v2[2], uv0[0], uv1[1], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
 
        v1[0], v1[1], v1[2], uv1[0], uv0[0], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
        v3[0], v3[1], v3[2], uv1[0], uv1[1], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
        v2[0], v2[1], v2[2], uv0[0], uv1[1], zDir[0], zDir[1], zDir[2], yDir[0], yDir[1], yDir[2], xDir[0], xDir[1], xDir[2],
    ];
}

После запуска этой функции для шести сторон у нас получится куб размером 2x2x2 и центром в нуле:

var xPos = vec3.fromValues(1, 0, 0);
var xNeg = vec3.fromValues(-1, 0, 0);
var yPos = vec3.fromValues(0, 1, 0);
var yNeg = vec3.fromValues(0, -1, 0);
var zPos = vec3.fromValues(0, 0, 1);
var zNeg = vec3.fromValues(0, 0, -1);
 
var vertices = [];
vertices = vertices.concat(createPlane(zPos, yPos));
vertices = vertices.concat(createPlane(zNeg, yPos));
vertices = vertices.concat(createPlane(xPos, yPos));
vertices = vertices.concat(createPlane(xNeg, yPos));
vertices = vertices.concat(createPlane(yPos, zNeg));
vertices = vertices.concat(createPlane(yNeg, zNeg));

Для работы программы нам нужно три матрицы:

  • матрица модели (координаты модели в координаты нашего виртуального мира, ModelMatrix)
  • матрица вида (координаты нашего виртуального мира в координаты OpenGL, ViewMatrix)
  • матрица проекции (координаты OpenGL в плоскость экрана, ProjMatrix)
// Матрица проекции
mat4.perspective(
    projMatrix, 
    glMatrix.toRadian(45), 
    canvas.width / canvas.height, 
    1, 
    50);
 
// Единичная матрица для модели
mat4.identity(modelMatrix);
 
// Матрица трансформации камеры 
// (позиция камеры, направление, направление Y камеры)
mat4.lookAt(
    viewMatrix,
    vec3.fromValues(3, 2, 3),
    vec3.fromValues(0, 0, 0),
    vec3.fromValues(0, 1, 0));

Вращение куба происходит при помощи изменения ModelMatrix на каждом кадре:

function update(dt) {
    rotation -= settings.rotationSpeed * dt;
 
    if (rotation < -360) {
        rotation += 360;
    }
    if (rotation > 360) {
        rotation -= 360;
    }
 
    var a = glMatrix.toRadian(rotation);
 
    // Модель повернута на угол a относительно оси (0, 1, 0)
    mat4.identity(modelMatrix);
    mat4.rotate(modelMatrix, modelMatrix, a, vec3.fromValues(0, 1, 0));
 
    mat4.multiply(modelViewMatrix, viewMatrix, modelMatrix);
    mat4.multiply(mvpMatrix, projMatrix, modelViewMatrix);
 
}

В шейдер передаем матрицу ModelViewProj и матрицу ModelView. Первой матрицей переводим координаты модели в плоскость экрана, второй — в пространство OpenGL:

В вершинном шейдере для каждой вершины вычисляем два вектора: направление от вершины к камере и направление от вершины к источнику света. Оба вектора находятся в пространстве нормали поверхности модели:

// Позиция вершины в мировых координатах
vec3 worldSpaceVertex = (u_ModelView * vec4(a_Position, 1.0)).xyz;
// Направление от вершины к источнику света в мировых координатах
vec3 lightDir = normalize(vec3(0, 0, 10) - worldSpaceVertex);
// Направление от вершины к камере в мировых координатах
vec3 viewDir = normalize(vec3(0, 0, 10) - worldSpaceVertex);
 
// Нормаль в мировых координатах
vec3 trNormal = normalize((u_ModelView * vec4(a_Normal, 0.0)).xyz);
// Бинормаль в мировых координатах
vec3 trBinormal = normalize((u_ModelView * vec4(a_Binormal, 0.0)).xyz);
// Тангент в мировых координатах
vec3 trTangent = normalize((u_ModelView * vec4(a_Tangent, 0.0)).xyz);
// Матрица трансформации из мировых координат в пространстве грани
mat3 normalSpace = mat3(trTangent, trBinormal, trNormal);
 
// Текстурные координаты передаем как есть
v_TexCoord = a_TexCoord;
// Направление к источнику света в пространстве грани
v_LightDir = normalize(lightDir * normalSpace);
// Направление к камере в пространстве грани
v_ViewDir = normalize(viewDir * normalSpace);
 
// Позиция вершины в координатах экрана
gl_Position = u_MVPMatrix * vec4(a_Position, 1.0);

Затем во фрагментном шейдере берем нормаль из карты нормалей и получаем ambient освещение путём скалярного произведения нормали к вектору света. Для specular освещения используем модель Blinn-Phong. Для этого надо сложить вектор света и камеры, затем вычислить скалярное произведение полученного вектора и вектора нормали:

// Основной цвет
vec3 diffuseColor = texture2D(u_DiffuseTex, v_TexCoord).rgb;
 
// Нормаль берем из текстуры
vec3 normal = texture2D(u_NormalMap, v_TexCoord).rgb * 2.0 - 1.0;
normal = normalize(normal);
 
// Нормализуем направление от вершины к источнику света
vec3 lightDir = normalize(v_LightDir);
 
// Вычисляем освещенность данного участка
float lambertian = max(0.0, dot(lightDir, normal));
float specular = 0.0;
 
if (lambertian > 0.0) {
    // Способность отражать свет
    float s = texture2D(u_SpecularMap, v_TexCoord).r;
 
    vec3 viewDir = normalize(v_ViewDir);
    vec3 halfDir = normalize(lightDir + viewDir);
 
    float specAngle = max(dot(halfDir, normal), 0.0);
    specular = pow(specAngle, shininess * s);
}
 
// Затеняем основной цвет и прибавляем к нему блик
vec3 colorLinear = diffuseColor * lambertian + specularColor * specular;
gl_FragColor = vec4(colorLinear, 1.0);

Целиком исходник можно посмотреть по ссылке: shader.js. По традиции текстуры взял с сайта Käy’s Blog.

P.S. Точно уверен, что где-то наврал с координатами, но пока не могу найди — где именно. Если вы найдете ошибку — напишите мне в комментариях.

Комментариев нет

Добавить комментарий

Ваш e-mail не будет опубликован.