Всем привет!
Изначально тут должна была быть статья про кубик льда с отражением окружающего мира (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. Точно уверен, что где-то наврал с координатами, но пока не могу найди — где именно. Если вы найдете ошибку — напишите мне в комментариях.