Знакомство фронтендера с WebGL: первые наброски

Вспоминаем ради чего мы начали изучать WebGL

После недельного чтения ресурса и экспериментов у меня появился кустарный REPL, в котором я мог быстро набрасывать шейдеры и другой код, чтоб поэкспериментировать и найти решение.

Я пишу статью, спустя 2 месяца после того как закончил работу над задачей и осталась только такая песочница ->

Вооружись знаниями и реплом, я пошел искать то, что сможет распарсить мне .obj файлик на вершины.
Единственное в интернете которое более/менее правильно распарсило мне файлик это был npm пакет
webgl-obj-loader. Хотя в нем был досадный баг который попортил мне немало крови.

Mesh: Fix bug with triangulation #66

qtip avatar
qtip posted on

The triangulate function splits an ngon into individual triangles. This commit fixes a bug where the mesh parsing code was ignoring the emitted triangles and instead using the original ngon indices.

Первые наброски

С помощью библиотеки, я сразу смог добиться какого-то результата в своей песочнице.

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 и зажить как человек. Выбросил его без каких либо сожалений.

Продолжение есть.

24