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

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
13-Июл-2021 18:33

Это история в несколько частей:
Данную статью пишу спустя полтора года, поэтому налет свежести потерян.Эта статья уже больше про код, меньше про страдания. Здесь по сути я выложу итоговый результат. Вот это красивое яблоко:Извините, данный ресурс не поддреживается. :( Соберем весь опытТак, настало время рефачить и добавить анимации с интерактивом. По задумке дизайнера, интерактива особо не было, просто реакция на мышку.За 3 статьи у меня появилось много полезных инструментов которые нужно объединить в какой-то более или менее удобный интерфейс. Что у меня есть:
  • Фрагментный шейдер который умеет правильно зарисовывать грани.
  • Парсер .obj который поможет правильно разобрать модельку на вершины, uv и индексы.
  • Набор хелперов от WebGL Fundamentals.
  • animejs для анимации
  • gl-matrix для рассчетов матрицы
  • Сама моделька
На самом деле когда я уже закончил с правильным рендером модельки, я знал как буду рефачить код. В голове картина уже была.
Во-первых, это должно быть обязательно на классах! Почему на классах? Синтаксис красивый, а еще пригодится много контекста.
Во-вторых, я буду использовать для анимации animejs, легкая библиотека для анимации с удобным апи (для меня).
В-третьих, я буду парсить и кешировать модель загруженную модель в indexedDb (мне тогда казалось это крутой идеей и оптимизацией, а так же я просто хотел воспользоваться этим апи).Написанной мной шейдер был универсальным, ему было все равно какую модель ему загрузить, главное чтоб faces (индексы) состояли из квадратов, потому что если будут из каких-нить треугольников или пятиугольников, то весь мой парсер и шейдер полетит к чертям.По песочнице можно было понять, что вопроса как именно смещать фигуру для анимации у меня не было. Я просто высчитываю матрицу и загружаю ее в вершинный шейдер, а он уже сам все там рассчитывает и высчитывает точки.Как вообще выглядит анимация в webgl?
  • Делаешь расчет переменных.
  • Загружаешь их в шейдер.
  • Вызываешь drawArray, чтоб карта нарисовала на основе переменных новую картинку.
  • Повторять 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);
  }
}
Фух! Как много кода, честно говоря даже писать комментарий к каждому моменту устал.Так, принцип работы получалсят такой:
  • Загружаем модель .obj. Просто нужно получить доступ к его содержимому.
  • Парсим содержимое с помощью функции который мне показали в конце 3 статьи.
  • Получаем из этого массив вершин и faces которые поделены на части групп, а потом эти faces трансформируем в текстурные координаты.
  • Прокидываем в класс ModelRender, он там сам все инициализирует в Mesh. Мб это не правильно? Наверно я должен был сам инициализировать Меши, а потом прокидывать их в рендер? Эх, поздняк метаться.
  • Прокидываем настройки для модели, а потом меняем матрицу в моделе.
  • Вызываем app.render()
  • И вот у нас отрендеренная модель!
Для анимации, повторять 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();
  },
});
С горем пополам написал рабочий примерИзвините, данный ресурс не поддреживается. :( Вещи про которые я еще узнал когда, делал рендер
  • IE11 почти полностью поддерживает webgl, но там, чтоб обратиться к контексту нужно canvas.getContext("experimental-webgl"), достаточно один раз его запросить и больше париться не надо.
  • Так же в ie11 или для safari нужно указывать версию шейдеров, чтоб нормально работало: #version 100
  • Мне нужно было очищать память при переходе между страницами, для этого у webgl есть экстеншен loseContext, который позволяет сбрасывать все буферы и прочее.
  • Модель яблока экспортирована без перевернутой оси Y и поэтому яблоко по умолчанию вверх ногами. Другие модели дизайнер экспортировал с перевернутой осью Y и поэтому, там сразу же моделька правильно вставала.
  • На мобилках webgl выгружаться из памяти автоматически, поэтому нужно подписываться на эвент:
app.canvas.addEventListener("webglcontextlost", () => {
  // your restore logic
  console.log("restore");
});
В общем то и все, это было очень сложно, мне помогало очень много людей. Когда я вернулся спустя полтора года к статье, я вообще ничего не мог понять. Но все же, фронт может справиться с webgl, если захочет. Пока писал статьи, упустил наверно много моментов, но я старался выписать как можно больше моментов которые меня зацепили.Спасибо всем, кто прочитал и всем кто тогда помог написать данный рендер, а также тем кто помог почистить от лишнего статью.P.S. на конечный результат моих трудов можно посмотреть: https://digitalhorizon.vc/ (Это же не реклама?), посмотрите на все страницы кроме media и попробуйте перейти по плашкам с главной страницы на внутренние. Туда, я тоже запихнул webgl.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_webgl, #_webgl, #_javascript, #_vanilla (ванилла), #_frontend (фронтенд), #_render (рендер), #_javascript, #_webgl
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 13:03
Часовой пояс: UTC + 5