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