Pull to refresh

Знакомство фронтендера с WebGL: рефакторинг, анимация (часть 4)

Reading time 10 min
Views 4.3K

Это история в несколько частей:

Данную статью пишу спустя полтора года, поэтому налет свежести потерян.

Эта статья уже больше про код, меньше про страдания. Здесь по сути я выложу итоговый результат. Вот это красивое яблоко:

Соберем весь опыт

Так, настало время рефачить и добавить анимации с интерактивом. По задумке дизайнера, интерактива особо не было, просто реакция на мышку.

За 3 статьи у меня появилось много полезных инструментов которые нужно объединить в какой-то более или менее удобный интерфейс. Что у меня есть:

  • Фрагментный шейдер который умеет правильно зарисовывать грани.

  • Парсер .obj который поможет правильно разобрать модельку на вершины, uv и индексы.

  • Набор хелперов от WebGL Fundamentals.

  • animejs для анимации

  • gl-matrix для рассчетов матрицы

  • Сама моделька

На самом деле когда я уже закончил с правильным рендером модельки, я знал как буду рефачить код. В голове картина уже была.

Во-первых, это должно быть обязательно на классах! Почему на классах? Синтаксис красивый, а еще пригодится много контекста.

Во-вторых, я буду использовать для анимации animejs, легкая библиотека для анимации с удобным апи (для меня).

В-третьих, я буду парсить и кешировать модель загруженную модель в indexedDb (мне тогда казалось это крутой идеей и оптимизацией, а так же я просто хотел воспользоваться этим апи).

Написанной мной шейдер был универсальным, ему было все равно какую модель ему загрузить, главное чтоб faces (индексы) состояли из квадратов, потому что если будут из каких-нить треугольников или пятиугольников, то весь мой парсер и шейдер полетит к чертям.

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

Как вообще выглядит анимация в webgl?

  1. Делаешь расчет переменных.

  2. Загружаешь их в шейдер.

  3. Вызываешь drawArray, чтоб карта нарисовала на основе переменных новую картинку.

  4. Повторять 1 пункт до тех пор, пока нужна анимация.

Вот так вот, если в css нам достаточно поменять значение, то в webgl нам надо поменять значение, а потом запустить рендер.

Так же была еще особенность. Мне нужно было анимировать разные части модельки по-разному.

Как я модели делил на части.

Дизайнер нарисовал более сложные фигуры чем просто яблоко и он хотел, чтоб все каждая часть фигуры вертелась в нужную сторону. А для этого нужно было делить модели на части, то есть разделить огромный массив вершин по частям, на каждую часть применять свою матрицу и рисовать ее. В webgl по умолчанию полотно не стирается и если вы вызываете drawArray, то он просто нарисует пикселями поверх того, что уже было нарисовано. Благодаря этому, можно загружать часть модельки, что-то с ней делать, рисовать, а потом другую часть и так по кругу.

А как поделить модель на несколько деталей?

В 3д редакторах наборы треугольников можно собирать в группы, а при экспорте эти группы выделены с помощью символа o (object) в .obj, таким образом я мог распарсить модельку на несколько отдельных моделек.

Я выделил для себя 3 сущности:

  • Матрицы

  • Модели (Mesh)

  • Сам рендер

Начнем с простого, класса матрицы, я просто хотел какие-то красивые методы со знакомыми названиями, внутри которых будет магия матриц. Почему вообще матрицы высчитываем на стороне js, а не webgl? Потому, что матрицы нужно динамически подключат и отключать, а в статичных условиях шейдеров этого не сделать. Так как это математика, я посчитал, что должен кешировать результат вычислений. А потом с помощью метода получать результат работы.

import { glMatrix, mat4, vec3 } from "gl-matrix"; // либа которая поможет складывать

export class Matrix {
  // я решил добавить коллбек который будет вызываться каждый раз когда какой-то метод в классе вызывали.
  constructor(onUpdate = () => {}) {}

  // объект с матрицами, по сути кеш и поможет в правильном порядке собирать матрицы, а это очень важно!
  #matrices = {};

  // настройка камеры, до сих пор не разобрался как это работает, но позволяет разместить фигуру на нужной дистанции.
  setOrtho(left, right, bottom, top, near, far) {}

  // значение по умолчанию
  #scale = 1;
  // сеттер для управление scale
  setScale(ratio) {}

  // решил сделать геттор для получение актуального значения,
  // чтоб в будущем использовать для всяких рассчетов с анимацией.
  getScale() {}

  #translate = [0, 0, 0];
  /**
   * x, y, z смещение
   * @param {[number, number, number]}params
   */
  setTranslate(params) {}
  getTranslate() {}

  // ну и rotate, не объединял в один метод, потому, собирался анимировать каждое это значение отдельно.
  // если мне важно было знать для реализации translate и scale, то на rotate было все равно, поэтому геттеров не делал. Зачем?
  setRotateX(deg) {}
  setRotateY(deg) {}
  setRotateZ(deg) {}

  // перемножаем все матрицы и получаем итоговую которую нужно прокинуть в шейдер
  getCurrent() {}
}

Я решил не погружаться в реализацию методов, потому, что в конце выложу просто песочницу со всем кодом.

Дальше у нас класс для моделей. Я назвал его Mesh, потому что увидел такое название в Pixi.js. Mesh значит сетка, сетка треугольников. Поняли тему? Треугольники вершина всего!

Класс получает распарсенные вершины и uv, инициализирует в себе матрицу, а так же загружает в gl буферы данных, чтоб потом можно было легко меняться между ними. Буфер это место, куда можно загрузить данные, а потом прокидывать этот буфер в атрибуты. Буферов можно инициализировать несколько и они все будут храниться в памяти, тратить на переинициализацию времени потом не нужно будет.

import { Matrix } from "./Matrix";

export class Mesh {
  /**
   *
   * @param {Float32Array} positions
   * @param {Float32Array} uv
   */
  // получаем вершины и текстурные координаты
  constructor({ positions, uv }) {
    this.positions = positions;
    this.uv = uv;
    // указываем drawArrays сколько у нас треугольников, так как для треугольника всегда нужно 3 точки, то можем смело делить массив с точками на 3 и получим итоговое значение треугольников
    this.count = this.positions.length / 3;
    // личная матрица для модельки
    this.matrix = new Matrix();
  }

  // ссылочки на буфера
  positionsBuffer;
  uvBuffer;

  /**
   * @param {WebGLRenderingContext} gl
   */
  // придумал для себя такой способ как прокидывать контекст для модельки, мне хотелось чтоб нужные классы моделей были уже созданы, их нужно только прокинуть в класс рендера.
  attachRender(gl) {
    this.gl = gl;
  }

  /**
   * Создаем буфера, загружаем в них данные, храним эти буферы потом в классе, чтоб легко достать
   */
  initializeBuffers() {}
}

В общем больше ничего и не нужно. Получи вершины, загрузи в буфера и дай ссылки на них.

ModelRender

Вся основная нагрузка лежит на классе рендера. Он будет инициализировать в выданный ему canvas контекст webgl, переменные, атрибуты для шейдеров. Включать всякие экстеншены, настройки, хранить в себе модели которые ему нужно в данный момент отрендерить, настройки цвета, толщины линии моделки, следить за ресайзом экрана, настраивать глобальную камеру и в конце вызывать самый важный метод drawArrays! Благодаря этому волшебному методу, карта и начинает рисовать изображение на холсте.

// вершинный шейдер
precision mediump float;
uniform mat4 uMeshMatrix; // смещение модели
uniform mat4 uCameraMatrix; // мировая камера
attribute vec4 aPosition; // вершины
attribute vec2 aTextureCoords; // текстурные координаты
varying vec2 vTextureCoords; // интерполированные переменные,
// на каждый пиксель между вершинами передаются интерполированные значения текстурных координат в фрагментный шейдер
void main(){
    gl_Position = uCameraMatrix * uMeshMatrix * aPosition;
    vTextureCoords = aTextureCoords;
}
// фрагментный шейдер
#extension GL_OES_standard_derivatives : enable // включаем штуку которая делает наши линии красивыми
precision mediump float; // выставляем как нужно округлять float значения
uniform vec3 uLineColor; // цвет линии
uniform vec3 uBgColor; // цвет фона
uniform float uLineWidth; // ширина линии
varying vec2 vTextureCoords; // текстурные координаты которые нужны чтоб высчитать грани

// моя супер логика для расчета границ
// я не могу вспомнить как я это считал, кажется полтора года назад я был умней.
float border(vec2 uv, float uLineWidth, vec2 gap) {
  // переменная gap нужна была, чтоб сделать линии более плавными, она рассчитывается динамически на основе текстурных координат благодаря директиве
  // smoothstep получал точка A и точку B, а потом получал какое-то значение и на основе этого значения возвращал плавное соотношение между А И Б.
  vec2 xy0 = smoothstep(vec2(uLineWidth) - gap, vec2(uLineWidth) + gap, uv);
  vec2 xy1 = smoothstep(vec2(1. - uLineWidth) - gap, vec2(1. - uLineWidth) + gap, uv);
  vec2 xy = xy0 - xy1;
  return clamp(xy.x * xy.y, 0., 1.);
}
void main() {
  vec2 uv = vTextureCoords;
  vec2 fw = vec2(uLineWidth + 0.05);
  #ifdef GL_OES_standard_derivatives
  // прогрессивное улучшение, на случай если директива не работает
    fw = fwidth(uv);
  #endif
  // получаем коэффициент который потом используем чтоб красить в нужным цветом.
  float br = border(vTextureCoords, uLineWidth, fw);
  // mix смешивает цвета, если значение 1 вернет полный цвет uLineColor, если 0, то вернет uBgColor.
  // А если значение где-то посередине, то вернет какой-то общий цвет между ними двумя.
  // ВИВА МАТЕМАТИКА
  gl_FragColor = vec4(mix(uLineColor, uBgColor, br), 1.);
}
import { vertex, fragment } from "./shaders"; // никакой магии, там просто шейдеры в строках
import { Mesh } from "./Mesh";
import { Matrix } from "./Matrix";

import {
  createProgramFromTexts,
  resizeCanvasToDisplaySize,
  saveRectAspectRatio,
} from "./helpers";

export class ModelRender {
  // я предпочел чтоб класс сам создавал себе канвас, ему нужно просто указать куда его вставлять
  canvas = document.createElement("canvas");
  // получаем контекст, тут еще полифил для ie11
  #gl = this.canvas.getContext("webgl");
  // Получаем экстеншен, чтоб дальше его включать.
  // Тут возвращается номер, который потом используется чтоб понять какой экстеншен нужно включить.
  #derivatives = this.#gl.getExtension("OES_standard_derivatives");

  // Создаем программу из любимых шейдеров
  #program = createProgramFromTexts(this.#gl, vertex, fragment);

  // удобные мапы для работы со ссылками на атрибуты
  #attrs = {
    position: this.#gl.getAttribLocation(this.#program, "aPosition"),
    textureCoords: this.#gl.getAttribLocation(this.#program, "aTextureCoords"),
  };

  // мапа для работы ссылками на переменные
  #uniforms = {
    meshMatrixLocation: this.#gl.getUniformLocation(
      this.#program,
      "uMeshMatrix"
    ),
    ...
  };

  constructor() {
    const gl = this.#gl;
    // говорим карте проверять уровень треугольников, чтоб рисовалось только то что на переднем фоне
    gl.enable(gl.DEPTH_TEST);
    // загружаем программу в карту
    gl.useProgram(this.#program);
    // включаем атрибуты, ВСЕ НУЖНО ВКЛЮЧАТЬ
    gl.enableVertexAttribArray(this.#attrs.position);
    gl.enableVertexAttribArray(this.#attrs.textureCoords);
  }

  meshesByType = {};
  models = {};

  // я загружал сразу все модели в класс, а потом по ключу инициализировал нужную
  #initializeModel = (type) => {};

  // `div` элемент в который вставят `canvas`
  /** @type {HTMLElement} */
  holder;

  #modelName;
  // мне показалось прикольным переключаться между модельками с помощью сеттера
  set modelName(type) {}

  /**
   * Сделал геттер, который возвращал текущую модель. Нужно, чтоб была возможность анимировать модельку. Менять ей состояние матрицы.
   * @returns {Object<string, Mesh>}
   */
  get currentModel() {
      return this.meshesByType[this.#modelName]
  }

  // объект из которого читались цвет фона и линии, а так же толщина
  // я заранее знал на какой странице какие цвета должны быть, поэтому не выносил этот объект в Mesh.
  meshParams = {
    bgColor: 0,
    lineColor: 0,
    lineWidth: 0.01,
  };

  // хелпер который позволит правильно позицинировать модельку при ресайзе экран
  resize = () => {};

  // просто матрица по умолчанию
  #cameraMatrix = mat4.create();
  // создал матрицу для мировой камеры, которая при каждом изменении записывала текущие расчеты матрицы.
  cameraMatrix = new Matrix(() => {
      this.#cameraMatrix = this.cameraMatrix.getCurrent();
  });

  // вставляем канвас в контейнер, выставляем камеру по значением которые подбирал вручную
  init() {
    const gl = this.#gl;
    this.holder.appendChild(this.canvas);
    this.cameraMatrix.setOrtho(0, 70, 70, 0, 120, -120);
    this.resize() // важный момент, который поможет правильно выстроить канвас и прочее

    // записываем значения в переменные которые получил из meshParams
    const { uLineColorLocation, uBgColorLocation, uLineWidthLocation } =
      this.#uniforms;
    gl.uniform3fv(uLineColorLocation, this.meshParams.lineColor);
    gl.uniform3fv(uBgColorLocation, this.meshParams.bgColor);
    gl.uniform1f(uLineWidthLocation, this.meshParams.lineWidth);
  }

  /**
   * @param {Mesh} mesh
   */
  #renderMesh = (mesh) => {
    const gl = this.#gl;
    // ссылки на атрибуты
    const { position, textureCoords } = this.#attrs;
    // включаем буфер и записываем буфер фигуры в атрибут
    gl.bindBuffer(gl.ARRAY_BUFFER, mesh.positionsBuffer);
    gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, mesh.uvBuffer);
    gl.vertexAttribPointer(textureCoords, 2, gl.FLOAT, false, 0, 0);
    gl.uniformMatrix4fv(
      this.#uniforms.meshMatrixLocation,
      false,
      mesh.matrix.getCurrent()
    );
    // РИСУЕМ!
    gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
  };

  render() {
    const gl = this.#gl;
    // перед каждым рендером, очищаем полотно от всего.
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // на всякий случай всегда прокидываю актуальные значение, если произошел ресайз окна
    gl.uniformMatrix4fv(
      this.#uniforms.uCameraMatrixLocation,
      false,
      this.#cameraMatrix
    );

    // получил объект с фигурами
    const meshes = this.currentModel();
    // на каждую фигуры вызвал рендер фигуры
    Object.values(meshes).forEach(this.#renderMesh);
  }
}

Фух! Как много кода, честно говоря даже писать комментарий к каждому моменту устал.

Так, принцип работы получалсят такой:

  1. Загружаем модель .obj. Просто нужно получить доступ к его содержимому.

  2. Парсим содержимое с помощью функции который мне показали в конце 3 статьи.

  3. Получаем из этого массив вершин и faces которые поделены на части групп, а потом эти faces трансформируем в текстурные координаты.

  4. Прокидываем в класс ModelRender, он там сам все инициализирует в Mesh. Мб это не правильно? Наверно я должен был сам инициализировать Меши, а потом прокидывать их в рендер? Эх, поздняк метаться.

  5. Прокидываем настройки для модели, а потом меняем матрицу в моделе.

  6. Вызываем app.render()

  7. И вот у нас отрендеренная модель!

Для анимации, повторять 5-6 шаг до бесконечности.

let app = new ModelRender();

app.models = parsedModels; // объект с распарсенными моделями, { apple: { apple: { vertexes: [], uv: [] } }. Почему 2 apple? Потому, что код не учитывает, что у модельки не могут быть вложенных моделей.
app.holder = document.querySelector("#place");
app.modelName = "apple"; // указываем какую модель нужно рендерить
app.meshParams = appleParams; // цвет, толщина линии
app.init();
app.render(); // рендер с параметрами по умолчанию.
const model = app.currentModel();
const props = { x: 0 };
// Тут будет вся анимация.
anime({
  targets: props,
  easing: "linear",
  loop: true,
  x: { value: [0, 360], duration: 7e3 },
  update() {
    model.apple.setXRotate(props.x);
    app.render();
  },
});

С горем пополам написал рабочий пример

Вещи про которые я еще узнал когда, делал рендер

  1. IE11 почти полностью поддерживает webgl, но там, чтоб обратиться к контексту нужно canvas.getContext("experimental-webgl"), достаточно один раз его запросить и больше париться не надо.

  2. Так же в ie11 или для safari нужно указывать версию шейдеров, чтоб нормально работало: #version 100

  3. Мне нужно было очищать память при переходе между страницами, для этого у webgl есть экстеншен loseContext, который позволяет сбрасывать все буферы и прочее.

  4. Модель яблока экспортирована без перевернутой оси Y и поэтому яблоко по умолчанию вверх ногами. Другие модели дизайнер экспортировал с перевернутой осью Y и поэтому, там сразу же моделька правильно вставала.

  5. На мобилках webgl выгружаться из памяти автоматически, поэтому нужно подписываться на эвент:

app.canvas.addEventListener("webglcontextlost", () => {
  // your restore logic
  console.log("restore");
});

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

Спасибо всем, кто прочитал и всем кто тогда помог написать данный рендер, а также тем кто помог почистить от лишнего статью.

P.S. на конечный результат моих трудов можно посмотреть: https://digitalhorizon.vc/ (Это же не реклама?), посмотрите на все страницы кроме media и попробуйте перейти по плашкам с главной страницы на внутренние. Туда, я тоже запихнул webgl.

Tags:
Hubs:
+8
Comments 3
Comments Comments 3

Articles