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

Свой .obj парсер, свой webgl

Первое, что я сделал адаптировал код из песочницы и использовал gl.LINES.

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

Показав дизайнеру, я ожидал услышать: "все идеально, ты отлично поработал!".
Но услышал: "выглядит круто! А теперь добавь текстуры, модель не должна просвечиваться".

И тут я понял, что gl.LINES мне никак не помогут с решением задачи. Я пошел не совсем туда. Мне почему-то казалось, что самое важное это линии, но потом понял, что должен был залить цветом модельку и выделить на ней грани поверхностей другим цветом.

Я понял, что мне все же нужны uv (текстурные координаты), потому, что без них невозможно закрашивать фигуру правильно, но те uv который генерировал редактор моделей не подходили для закрашивания. Там была какая-то своя логика по генерации координат.

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

const uv4 = [[0, 0], [1, 0], [1, 1], [0, 1]]; // захаркоженные координаты текстур

// функция которая парсит .obj и выплевывает вершины с текстурными координатами.
export function getVBForVSTFromObj(obj) {
  const preLines = obj.split(/[\r\n]/).filter(s => s.length);

  // функция которая отдавала все строки по первому вхождению
  const exNs = (a, fchar) =>
    a
      .filter(s => s[0] === fchar)
      .map(s =>
        s
          .split(" ")
          .filter(s => s.length)
          .slice(1)
          .map(Number)
      );

  // та же функция что выше, только для поверхностей (faces) и дополнительно парсила сами поверхности
  const exFs = s =>
    s
      .filter(s => s[0] === "f")
      .map(s =>
        s
          .split(/\s+/)
          .filter(s => s.length)
          .slice(1)
          .map(s => s.split("/").map(Number))
      );

  const vertexList = exNs(preLines, "v"); // получаем все вершины
  const faceList = exFs(preLines); // все поверхности

  const filteredFaceList = faceList.filter(is => is.length === 4); // собираем поверхности только с 4 точками, т.е. квады
  const vertexes = filteredFaceList
    .map(is => {
      const [v0, v1, v2, v3] = is.map(i => vertexList[i[0] - 1]);
      return [[v0, v1, v2], [v0, v2, v3]];
    }) // склеиваем треугольники 
    .flat(4);


  const uvs = Array.from({ length: filteredFaceList.length }, () => [
    [uv4[0], uv4[1], uv4[2]],
    [uv4[0], uv4[2], uv4[3]]
  ]).flat(4); // собираем текстурные координаты под каждую поверхность

  return [vertexes, uvs];
}

Дальше, я обновил фрагментный шейдер:

precision mediump float;

varying vec2 v_texture_coords; // текстурные координаты из вершинного шейдера
// define позволяет определять константы
#define FN (0.07) // толщина линии, просто какой-то размер, подбирался на глаз
#define LINE_COLOR vec4(1,0,0,1) // цвет линии. красный.
#define BACKGROUND_COLOR vec4(1,1,1,1) // остальной цвет. белый.

void main() {
  if ( 
    v_texture_coords.x < FN || v_texture_coords.x > 1.0-FN ||
    v_texture_coords.y < FN || v_texture_coords.y > 1.0-FN 
  )
    // если мы находимся на самом краю поверхности, то рисуем выставляем цвет линии
    gl_FragColor = LINE_COLOR;
  else 
    gl_FragColor = BACKGROUND_COLOR;
}

И, о боже! Вот он результат который я так хотел. Да грубо, линии жесткие, но это шаг вперед. Дальше я переписал код шейдера на smoothstep (специальная функция которая позволяет делать линейную интерполяцию) и поменял еще стиль нейминга переменных.

// fragment
precision mediump float;
uniform vec3 uLineColor; // теперь цвета и прочее передаю js, а не выставляю константы
uniform vec3 uBgColor; // теперь получаю цвет яблока через переменную.
uniform float uLineWidth; // ширину линии тоже получаю через переменную.

varying vec2 vTextureCoords;

// функция которая высчитала на основе uv и "порога" и сколько должна идти плавность
// то есть через threshold я говорил где должен быть один цвет, а потом начинается другой, а с помощью gap определял долго должен идти линейный переход. Чем выше gap, тем сильнее размытость.
// и которая позволяет не выходить за пределы от 0 до 1
float calcFactor(vec2 uv, float threshold, float gap) {
  return clamp(
    smoothstep(threshold - gap, threshold + gap, uv.x) + smoothstep(threshold - gap, threshold + gap, uv.y), 0., 
    1.
  );
}

void main() {
  float threshold = 1. - uLineWidth;
  float gap = uLineWidth + .05; // число опять подбиралось на вкус
  float factor = calcFactor(vTextureCoords, threshold, gap);
  // функция mix на основе 3 аргумента выплевывает 1 аргумент или 2, линейно интерпретируя.
  gl_FragColor = mix(vec4(uLineColor, 1.), vec4(uBgColor, 1.), 1. - factor);
}

Красота! Я доволен собой, а дизайнер моей работой. Да есть какие-то мелочи, но это лучшее что я смог тогда родить.

Хотя особо внимательные сразу заметят, что размеры квадратов стали больше, чем на прошлой "грубой" версии.
А я был не особо внимательным, поэтому заметил это только спустя 2 недели. Возможно, эйфория от успеха вскружила мне голову...

Доработка шейдера

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

Мне было не понятно, почему у меня такая крупная сетка на яблоке, хотя в cinema4d и блендере, она довольно мелкая.
Плюс, я решил поделиться со своими переживаниями с коллегой на работе, и когда я ему начал объяснять как работает мой шейдер, я понял, что уже и не помню как я вообще до него допер и при попытке объяснить ему, я начал по новой экспериментировать с шейдером.

Для начала я вспомнил трюк из уроков по шейдерам и просто закидывал цвета на основе x координаты и получил для себя интересный результат.

Я понял, что все это время у меня была мелкая сетка, но я почему-то игнорировал ее. Поиграв еще, я наконец-то понял, что зарисовал только 2 грани из 4 у каждой поверхности, что привело к тому, что у меня такая крупная сетка.

У меня не получалось используя `step` и прочее, реализовать нужную мне сетку, я получал какой-то бред.

Тогда, я решил сначала написать топорно и родил такой шейдер.

// part of fragment shader
if (vTextureCoords.x > uLineWidth && vTextureCoords.x < 1.0 - uLineWidth && vTextureCoords.y > uLineWidth && vTextureCoords.y < 1.0 - uLineWidth) {
    gl_FragColor = vec4(uBgColor, 1.);
} else {
    gl_FragColor = vec4(uLineColor, 1.);
}

Я наконец получил нужный результат.

Дальше, за час вместе с докой по функциям из webgl. Я смог переписать код как-то по модному чтоль.

// part of fragment shader
float border(vec2 uv, float uLineWidth, vec2 gap) {
  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);

  float br = border(vTextureCoords, uLineWidth, fw);
  gl_FragColor = vec4(mix(uLineColor, uBgColor, br), 1.);
}

Я получил мелкую сетку. Ура!

Почти, почти!
Почти, почти!

Но, у меня оставалась проблема, что чем ближе к краю, тем хуже различаются линии.
Насчет этого вопроса, я обратился за помощью в чат и мне рассказали про OES_standard_derivatives экстеншена для webgl. Экстеншены это что-то вроде плагинов, которые добавляли в glsl новые функции или включали какие-то возможности в рендере. Добавив в код шейдера fwidth (не забывайте включать экстеншены, до того как соберете программу, а то буду проблемы), функцию которая появилась после подключение экстеншена. Я добился того, чего хотел.

// part of fragment shader  
#ifdef GL_OES_standard_derivatives
    fw = fwidth(uv);
#endif

И вот оно!

Самое красивое яблоко на свете
Самое красивое яблоко на свете

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