Знакомство фронтендера с WebGL: первые наброски (часть 2)
Это история в несколько частей:
Знакомство фронтендера с WebGL: первые наброски (часть 2)
Знакомство фронтендера с WebGL: рефакторинг, анимация (часть 4)
Вспоминаем ради чего мы начали изучать WebGL
После недельного чтения ресурса и экспериментов у меня появился кустарный REPL, в котором я мог быстро набрасывать шейдеры и другой код, чтоб поэкспериментировать и найти решение.
Я пишу статью, спустя 2 месяца после того как закончил работу над задачей и осталась только такая песочница ->
Вооружись знаниями и реплом, я пошел искать то, что сможет распарсить мне .obj файлик на вершины.Единственное в интернете которое более/менее правильно распарсило мне файлик это был npm пакет webgl-obj-loader.
Первые наброски
С помощью библиотеки, я сразу смог добиться какого-то результата в своей песочнице.
// vertex
attribute vec4 a_position; // объявляем переменную в которую будем прокидывать вершины яблока.
uniform mat4 u_matrix; // матрица которая будет нам помогать трансформировать модель
void main(){
gl_Position = u_matrix * a_position; // у glsl есть встроенные возможности по работе с матрицами. Тут он сам за нас перемножает вершины на матрицы и тем самым смещает их куда надо.
}
// fragment
precision mediump float; // точность для округления.
void main() {
gl_FragColor = vec4(1., 0., 0., 1.); // заливаем красным
}
Код
import { vertex, fragment } from './shaders'; // через parcel импортирует тексты
import { createCanvas, createProgramFromTexts } from "./helpers";
import { m4 } from "./matrix3d"; // после изучение webgl на webgl fund, мне в наследство досталась библиотека которая умеет работает с 3д матрицами.
import appleObj from "./apple.obj"; // моделька яблока
import * as OBJ from "webgl-obj-loader"; // наша либа которая распарсит obj
function main() {
const apple = new OBJ.Mesh(appleObj); // загружаем модель
const canvas = createCanvas(); // создаю canvas и вставляю в body
const gl = canvas.getContext("webgl"); // получаю контекст
const program = createProgramFromTexts(gl, vertex, fragment); // создаю программу из шейдеров
gl.useProgram(program); // линкую программу к контексту
// получаю ссылку на атрибут
const positionLocation = gl.getAttribLocation(program, "a_position");
// у либы была готовая функция, которая за меня создавала буфер и прокидывала распарсенные данные в буферы. Из .obj можно было достать не только вершины, но и другие координаты которые могут быть полезны.
OBJ.initMeshBuffers(gl, apple);
gl.enableVertexAttribArray(positionLocation); // активирую атрибут, зачем это делать не знаю, но не сделаешь, ничего не заработает.
gl.vertexAttribPointer(
positionLocation,
apple.vertexBuffer.itemSize, // либа сама определяла сколько нужно атрибуту брать чисел, чтоб получить вершину
gl.FLOAT,
false, // отключаем нормализацию (это чтоб не пыталось конвертировать числа больше 1 в 1. Аля 255 -> 0.255.
0,
0
); // объясняю как атрибуту парсить данные
// получаем ссылку на глобальную переменную которая будет доступна внутри шейдеров. В нее же мы будем прокидывать матрицы
const matrixLocation = gl.getUniformLocation(program, "u_matrix");
let translation = [canvas.width / 2, 400, 0]; // смещаю на центр экрана по вертикали и 400 px вниз
let rotation = [degToRad(180), degToRad(0), degToRad(0)]; // вращение по нулям
let scale = [5, 5, 5]; // увеличиваю модельку в 5 раз. scaleX, scaleY, scaleZ
// выставляю вью порт
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.enable(gl.DEPTH_TEST); // включаем специальный флаг, который заставляет проверять видеокарту уровень вложенности и если какой-то треугольник перекрывает другой, то другой не будет рисоваться, потому, что он не виден.
function drawScene() {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // очищаем канвас на каждый рендер
const matrix = m4.multiply(
m4.identity(), // создаем единичную матрицу. Матрицу у которой все значения по умолчанию.
m4.orthographic(
0,
gl.canvas.width,
gl.canvas.height,
0,
400,
-400
), // Создаем матрицу которая конвертирует неудобные размеры модельки яблока в координатное пространство -1 до 1.
m4.translation(...translation), // перемещаем модельку
m4.xRotation(rotation[0]), // крутим по X
m4.yRotation(rotation[1]), // крутим по Y
m4.zRotation(rotation[2]), // крутим по Z
m4.scaling(...scale) // увеличиваем модельку
); // перемножаем матрицы друг на друга, чтоб в конце получить 1 матрицу которую и прокинем в шейдер
gl.uniformMatrix4fv(matrixLocation, false, matrix); // прокидываем матрицу
// подключаю буфер с индексами
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, apple.indexBuffer);
// рисуем яблоко треугольниками с помощью индексов
gl.drawElements(
gl.TRIANGLES,
apple.indexBuffer.numItems,
gl.UNSIGNED_SHORT,
0
);
}
drawScene();
// Тут код который настраивает всякие слайдеры, чтоб изменять матрицы.
// ...
//
}
main();
На выход я получил такую штуку:
Мне кажется это маленький шаг для webgl, но огромный скачок для фронтендера.
Что такое index?
При работе с либой узнал про новую вещь: индексы.
На самом деле в .obj файле кроме вершин есть текстурные координаты, нормали, поверхности (faces).
Что это вообще такое?
Текстурные координаты это массив цифр который прокидывается в фрагментный шейдер и позволяет шейдеру понять где он вообще сейчас находится в модельке, чтоб наложить пиксель в зависимости от общего положения. Если их нет, то шейдер получается вообще изолированным и может только красить пиксели не зная где именно сейчас он закрашивает. Текстурные координаты прокидываются как атрибуты.
Нормали это тоже координаты, но их можно использовать в фрагментом шейдере, чтоб понять как рисовать тень от объекта(модель) в зависимости от того как должен падать свет на объект.
Поверхность - это массив индексов которые указывают на индекс в массиве вершин, текстур и нормалей. Поверхности это служебные данные для редактора моделей (аля cinema4d и других), которые позволяют объединять полигоны в квадраты и другие более сложные фигуры. В частности это нужно для того как рендерить именно модельку. Так вот индексы это и есть поверхности. Допустим мы прокинули в 2 атрибута данные от вершин и текстурных координат. И webgl смотрит на текущий индекс и по параметрам атрибутов (помните мы указывали size, сколько нужно брать чисел, чтоб получить вершину) берет из каждого атрибута нужный набор чисел и прокидывает их в шейдеры.
Дальше я попробовал изменить gl.TRIANGLES
на gl.LINES
. И получил следующий результат:
Мда, совершенно не то, что я ожидал. Где мои красивые линии как у дизайнера и че за треугольники. Я тогда впервые осознал простую истину, что все блин на треугольниках. В данной ситуации, я побежал в чат и тогда породил локальный мем.
у меня начали появляться подозрения, что рисовать фигуры можно только через треугольники.
Я просто не знал что делать дальше и спрашивал советов. Среди них было несколько:
- Используй в фрагментом шейдере uv, чтоб рисовать линии сам.
- Распарси .obj сам и получили нужные значения.
- Сделай uv разветку и натяну текстуру картинку.
Я не понял из 1 ответа, что такое uv, почему-то тогда мне никто не объяснил, что это и есть текстурные координаты. Да и где эти uv брать тоже было не понятно.
Из второе ответа, я тоже не понял что мне делать и какие значения использовать.
А третий ответ оказался хоть тоже загадочным, но мне объяснили что это значит. Нужно было через редактор моделей создать текстурные координаты и нарисовать под них текстуру.
В интернете я нашел гайды о том как сделать в cinema 4d uv разметку и там же нашел как нарисовать текстуру. В редакторе была возможность создать картинку и залить по граням поверхностей(faces) нужный цвет. Я считал, что это сразу решает мою проблему. Выплюнув texture.png и новый obj с uv (то есть так называются текстурные координаты).
Баг который попортил нервы
Я побежал читать статью на webgl fund как натянут текстуру. Кода стало больше, но не увидел сложностей. Сделал как в гайде и думал, щас будет все отлично!
// vertex
precision mediump float;
attribute vec4 a_position;
attribute vec2 a_texture_coords; // текстурные координаты из модели
uniform mat4 u_matrix;
varying vec2 v_texture_coords;
void main(){
gl_Position = u_matrix * a_position;
v_texture_coords = a_texture_coords; // прокидываем во фрагментный шейдер
}
// fragment
precision mediump float;
varying vec2 v_texture_coords; // координаты из вершины
uniform sampler2D u_texture; // текстура
void main(){
gl_FragColor = texture2D(u_texture, v_texture_coords);
}
//...
const textureCoordsLocation = gl.getAttribLocation(
program,
"a_texture_coords"
); // получили ссылку на новый атрибут
// ...
gl.enableVertexAttribArray(textureCoordsLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, apple.textureBuffer); // забиндили буфер которая выдала либа из модели
gl.vertexAttribPointer(
textureCoordsLocation,
apple.textureBuffer.itemSize,
gl.FLOAT,
false,
0,
0
);
const texture = gl.createTexture(); // запрашиваем место для текстуры
gl.bindTexture(gl.TEXTURE_2D, texture); // биндим
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 255, 255])
); // сначала прокидываем пустышку, пока грузится текстура
const image = new Image();
image.src = textureImg; // загружаем текстуру
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // какие-то неведомые настройки, чтоб все было круто
drawScene();
};
// ...
const textureLocation = gl.getUniformLocation(program, "u_texture");
function drawScene() {
// ...
gl.uniform1i(textureLocation, 0);
// ...
}
И после тонны кода получаем вот это чудовище:
Что за??
И вот тут у меня началась эпопея в целый рабочий день с целью решить проблему. Я мало, что понимал и считал, что это я косячу, а не либа которую использую. Сначала я на самом деле откатил код с текстурой и просто попробовал закрасить и получил опять какой-то невероятный результат.
Что за фигня?
Я тогда решил, что проблема в экспорте и вообще в том, что я делал с uv mapping. Поиграв пару часов с экспортом, я решил попробовать в blender экспортировать и о чудо, моделька починилась!
Потратив еще кучу часов в попытке разобраться, в чем же дело. Я заметил, что blender по умолчанию преобразовывал поверхности из 4 точек в поверхности из 3 точек. И когда я отключал данную функцию, то модельки опять ломались. И тогда, я понял, что проблема все это время была в библиотеке webgl-obj-loader. Она ломалась если ей подавали поверхности из 4 точек (на самом деле мне это объяснили в чате).
Я сразу побежал писать жалобу на проблему, а потом нашел pull request который правил эту багу и прикрепил его к своему issue.
Отказ от webgl-obj-loader
Посмотрев на результат мучительной работы, я понял, что это не то, чего я хотел. Линии были толстыми, плюс чем сильней было скругление, тем плотней становилась область.
Еще я понимал, что есть какое-то другое решение, потому что когда я открывал модельку в просмотрщиках моделей, они правильно рисовали результат и красиво прорисовали линии.
Видя это, я понимал, что все можно рассчитать программно, но не знал как...
И в это время появился рыцарь в сияющих доспехах и спас меня из логова бессилия. Он был тем кто предложил:
Распарси .obj сам и получили нужные значения.
На тот момент я не понимал, что вообще это значит и как мне это поможет. И человек накидал в песочнице пример на three.js.
Этот пример был светом. Я сразу же понял, что можно выкидывать webgl-obj-loader и зажить как человек. Выбросил его без каких либо сожалений.
Продолжение здесь.