§ О текстурировании
Вначале было... пространство, точнее, Декарт, который это пространство придумал. Потом появилась точка, она была размером с 1 пиксель на экране 320x200. Ну и в общем, из точки сделался куб, потом его закрасили в разные цвета радуги, ну и конечно, сейчас надо затекстурировать эти грани Бытия.§ Параметры куба
Как и обычно, я использую стандартный куб, который ранее уже появлялся у меня в статьях и не один раз. Начнем писать методdrawCube
.1let vertex = [ // Список вершин куба, их 8 2 vec3(-1, 1, 1), vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), 3 vec3(-1, 1, -1), vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1) 4];Функция vec3 представляет из себя функцию:
1function vec3(x, y, z, u = 0, v = 0) { 2 return { 3 x: x, y: y, z: z, 4 u: u, v: v 5 }; 6}Однако, в отличии от предыдущих статей, помимо вершин, необходимо задать uv-маппинг, который будет нужен для текстурирования. Поскольку у меня не так много вариантов, всего лишь 4, то и задам всего лишь 4 точки текстурного маппинга.
1let uvface = [ 2 [0, 0], // 0 3 [255, 0], // 1 4 [255, 255], // 2 5 [ 0, 255], // 3 6];Почему 4? Дело в том, что я буду рисовать для куба квадрат с каждой стороны. Слева сверху натягивается текстура, которая находится в (0,0), справа сверху (255,0), внизу справа (255,255) и слева снизу (0,255) — это координаты текстуры. Маппинг текстур для куба самый простой.
Далее, определившись с маппингом текстур, необходимо определиться с обходом вершин. Ниже предложены 12 треугольников, описывающих куб по номерам вершин
vertex
и также, прицепленным к ним uvface
. Первые 3 точки идут вершины, следующие три точки - это текстурная координата каждой вершины. Это лишь один из многочисленных методов, которые можно использовать, я выбрал такой метод, так что если кто хочет, то может и сам придумать, как сделать задание этих данных.1let faces = [ 2// VTX UVF 3// A B C a b c 4 [1, 0, 3, 0, 1, 2], // Перед 5 [1, 3, 2, 0, 2, 3], 6 [5, 1, 2, 0, 1, 2], // Справа 7 [5, 2, 6, 0, 2, 3], 8 [2, 3, 7, 0, 1, 2], // Низ 9 [2, 7, 6, 0, 2, 3], 10 [0, 1, 5, 0, 1, 2], // Верх 11 [0, 5, 4, 0, 2, 3], 12 [0, 4, 7, 0, 1, 2], // Слева 13 [0, 7, 3, 0, 2, 3], 14 [4, 5, 6, 0, 1, 2], // Зад 15 [4, 6, 7, 0, 2, 3], 16];
§ Преобразование геометрии вершин
В этой статье я пойду наоборот, сверху вглубь, то есть, сначала обозначу методы, которые будут использоваться, а потом уже далее, реализую эти методы.Перед тем, как рендерить куб, надо выполнить над ним преобразования. Самый лучший способ сделать это — использовать матрицы 4x4. Можно, конечно, пойти тем путем, что и в прошлой статье и делать все через вращения и так далее, но преимущество использования матриц в том, что их достаточно подсчитать лишь однажды на всю сцену, а не пересчитывать каждый раз. Это намного быстрее работает.
Насчет матриц можно посмотреть в этой статье.
Все эти преобразования матриц очень важны для того, чтобы куб крутился в пространстве. Однако, после всего, нужно сделать, что точка вершины преобразовывалась, используя матрицу 4x4, потому ее нужно умножать с помощью метода
matrix_apply
(см. статью про матрицы), и быстро выполнить необходимые преобразования всех вершин куба:1let vtx = this.matrix_apply(vertex, matrix);Здесь matrix — это задаваемая внешним способом матрица поворотов и трансляции. Она вычисляется отдельно на сцену.
§ Проецирование и рендеринг
Вот теперь осталось самое сложное, это как раз растеризация с учетом текстур. Здесь требуется очень много вычислений и поэтому алгоритм становится довольно ресурсо-затратным.1let prj = this.project(vtx);После расчета новых положении вершин, теперь их необходимо спроецировать на экран с помощью такого метода. Проецирование массива векторов типа vec3.
1project(v) { 2 3 let out = []; 4 let ppd = this._height / 2; 5 6 for (let i = 0; i < v.length; i++) { 7 8 out.push({ 9 x: Math.floor(this._width / 2 + ppd*v[i].x / v[i].z), 10 y: Math.floor(this._height / 2 - ppd*v[i].y / v[i].z), 11 }); 12 } 13 14 return out; 15}На вход задаются массивы вектором v, по которым выполняется перспективная проекция и выдается результат в виде
vec2
.Теперь можно начинать отрисовку всех 12 треугольников:
1for (let i = 0; i < faces.length; i++) { 2 3 let P = []; 4 let V = []; 5 6 // Перебрать все 3 точки 7 for (let j = 0; j < 3; j++) { 8 9 let idx = faces[i][j]; // Номер vertex 10 let idf = faces[i][j + 3]; // Номер uv 11 12 // Добавить проецированную точку в треугольник 13 P.push({x:prj[idx].x, y:prj[idx].y}); 14 15 // Точки в пространстве 16 V.push({ 17 x: vtx[idx].x, y: vtx[idx].y, z: vtx[idx].z, 18 u: uvface[idf][0], v: uvface[idf][1], 19 }); 20 } 21 22 let R = this.compute(V); // Вычислить грань 23 this.raster(P, {R:R, T:this.tex}); // Растеризация 24}На этом, отрисовку куба можно считать законченным.
Как тут можно заметить, заполняются два массива P - точки, которые уже проецированы на экран, и массив V - это точки, которые необходимо будет вычислить с помощью функции compute. Эта функция необходима для вычисления позиции текстур и вообще, относится, так сказать, к фрагментному шейдеру. Результат вычисления передается в функцию
raster
.§ Вычисление параметров треугольника
Приведу реализацию методаcompute
, который по трем точкам вычисляет те параметры, которые будут использоваться для вычисления позиции текстур и вообще, положения точки, а также вектора нормали.- T (create) - массив вершин типа
vec3
для обработки - P (pivor) - точка отсчета (по умолчанию 0,0,0)
1compute(T, P = null) { 2 3 P = (P === null) ? vec3(0,0,0) : P; 4 5 let AB = vec3(T[1].x - T[0].x, T[1].y - T[0].y, T[1].z - T[0].z); 6 let AC = vec3(T[2].x - T[0].x, T[2].y - T[0].y, T[2].z - T[0].z); 7 let AP = vec3( P.x - T[0].x, P.y - T[0].y, P.z - T[0].z); 8 9 return { 10 11 // Параметры 12 U: vec3( 13 AC.y * AP.z - AC.z * AP.y, 14 AC.z * AP.x - AC.x * AP.z, 15 AC.x * AP.y - AC.y * AP.x 16 ), 17 V: vec3( 18 AB.z * AP.y - AB.y * AP.z, 19 AB.x * AP.z - AB.z * AP.x, 20 AB.y * AP.x - AB.x * AP.y 21 ), 22 D: vec3( 23 AB.z * AC.y - AB.y * AC.z, 24 AB.x * AC.z - AB.z * AC.x, 25 AB.y * AC.x - AB.x * AC.y 26 ), 27 28 // Вектор нормали 29 N: vec3( 30 AB.y * AC.z - AB.z * AC.y, 31 AB.z * AC.x - AB.x * AC.z, 32 AB.x * AC.y - AB.y * AC.x 33 ), 34 35 // Текстурные координаты 36 tu: vec3(T[0].u, T[1].u - T[0].u, T[2].u - T[0].u), 37 tv: vec3(T[0].v, T[1].v - T[0].v, T[2].v - T[0].v), 38 39 // Сохранить треугольник 40 a: T[0], AB: AB, AC: AC, AP: AP, 41 }; 42}
§ Код растеризации
Приведенный ниже код достаточно большой. Частично, он получен из предыдущего кода, который рисовал лишь сплошной цвет.В param могут задаваться следующие свойства:
- R - объект, полученный в результате
compute
- T - текстурный объект, код которого приведу далее
- color - некоторый базовый цвет, но при задании текстуры он не используется
1raster(vtx, param = null) { 2 3 // Проверка видимости грани 4 let AB = {x: vtx[1].x - vtx[0].x, y: vtx[1].y - vtx[0].y}; 5 let AC = {x: vtx[2].x - vtx[0].x, y: vtx[2].y - vtx[0].y}; 6 7 // Отбросить грань при обходе против часовой стрелки 8 if (AB.x*AC.y - AB.y*AC.x <= 0) { 9 return; 10 } 11 12 // Цвет для рисования 13 let color = param ? (param.color || 0xFFFFFF) : 0xFFFFFF; 14 15 // Текстурирование и точки пересечения с треугольником 16 let R = (param && typeof param.R === 'object') ? param.R : null; 17 let TXT = (param && typeof param.T === 'object') ? param.T : null; 18 let TM = null; 19 20 // Вычисления половин экрана 21 let [W2, H2] = [this._width >> 1, this._height >> 1]; 22 23 // Подготовка текстуры 24 if (R) { 25 TM = { 26 u: 0, uz: R.U.z*H2, 27 v: 0, vz: R.V.z*H2, 28 d: 0, dz: R.D.z*H2, 29 }; 30 } 31 32 // Сортировка по возрастанию 33 let p = vtx.sort(function (x, y) { return x.y < y.y ? -1 : 1; }); 34 35 // Главная сторона 36 let [Ax, Adx] = [p[0].x, 0]; 37 let [ACx, ACy] = [p[2].x - p[0].x, p[2].y - p[0].y]; 38 39 // Два треугольника 40 for (let i = 0; i < 2; i++) { 41 42 // Боковая сторона 43 let [Bx, Bdx] = [p[i].x, 0]; 44 let [AMx, AMy] = [p[i+1].x - p[i].x, p[i+1].y - p[i].y]; 45 46 // Отрисовка полутреугольника 47 for (let y = p[i].y; y < p[i+1].y + i; y++) { 48 49 // Находится за пределами 50 if (y >= this._height) break; 51 52 // Рисовать только если не за верхней границей экрана 53 if (y >= 0) { 54 55 let x1 = Math.min(Ax, Bx); 56 let x2 = Math.max(Ax, Bx); 57 58 // Концы отрезков не выходят за пределы рисования 59 if (x1 < this._width || x2 >= 0) { 60 61 x1 = Math.max(x1, 0); 62 x2 = Math.min(x2, this._width - 1); 63 64 // Запущена растеризация с текстурой 65 if (R) { 66 67 // Направление камеры всегда стандартное 68 let d = {x: x1 - W2, y: H2 - y}; 69 70 // Рассчитать новую позицию перед растеризаицией линии 71 TM.u = R.U.x*d.x + R.U.y*d.y + TM.uz; 72 TM.v = R.V.x*d.x + R.V.y*d.y + TM.vz; 73 TM.d = R.D.x*d.x + R.D.y*d.y + TM.dz; 74 } 75 76 // Прочертить линию 77 for (let x = x1; x <= x2; x++) { 78 79 // Стандартный цвет 80 let cl = color, u, v; 81 82 // Вычисление позиции (u,v) для (tx,ty) 83 if (R) { 84 85 [u, v] = [TM.u / TM.d, TM.v / TM.d]; 86 87 if (TXT) { 88 89 // Рассчитать позицию текстуры 90 let tx = R.tu.x + R.tu.y*u + R.tu.z*v; 91 let ty = R.tv.x + R.tv.y*u + R.tv.z*v; 92 93 cl = TXT.point(tx, ty); 94 } 95 } 96 97 // Рисование точки в буфере 98 this.pset(x, y, cl); 99 100 // Приращение +1 101 if (R) { TM.u += R.U.x; TM.v += R.V.x; TM.d += R.D.x; } 102 } 103 } 104 } 105 106 // Сдвиг точек x1,x2. Важно использовать trunc! 107 Adx += ACx; Ax += Math.trunc(Adx / ACy); Adx %= ACy; 108 Bdx += AMx; Bx += Math.trunc(Bdx / AMy); Bdx %= AMy; 109 } 110 } 111}Как можно отметить, кода довольно много. Частично он повторяется, но есть много добавлений.
- Если задан R, то вычисляется объект TM, в котором находятся начальные значения uz,vz,dz. Они рассчитываются раз на треугольник и не меняются на протяжении всего процесса отрисовки
- Добавлена проверка на рисование линии. Если линия находится за левой или правой границей экрана, то такая линия пропускается и не рисуется
if (x1 < this._width || x2 >= 0)
- Добавлено ограничение по x, если левый край линии находится за границей, то левый край начинается с 0. Аналогично с правым краем.
- При начале растеризации каждой линии рассчитываются стартовые параметры TM.u, TM.v и TM.d. Далее они будут просто инкрементироваться. Это сделано для повышения производительности. Тут же, рассчитывается значения объекта
d
, который нужен будет для вычисления текстур. - Для каждой точки считается
[u, v] = [TM.u / TM.d, TM.v / TM.d]
, что представляет из себя 3 деления. Это расточительно по ресурсам, но что поделать, без деления тут никак невозможно обойтись. После вычисления u,v, рассчитываются новые tx,ty, которые являются точками на текстуре. Извлекается точка текстуры черезcl = TXT.point(tx, ty)
- Выполняется приращение 3 переменных на каждой новой точке
§ Объект текстуры
В javascript достаточно удобно создавать на лету такого рода объекты:1texture_make(w, h) { 2 3 return { 4 w: w, 5 h: h, 6 data: new Uint32Array(w * h), 7 pset: function(x, y, cl) { 8 if (x >= 0 && y >= 0 && x < this.w && y < this.h) 9 this.data[this.w*Math.trunc(y) + Math.trunc(x)] = cl; 10 }, 11 point: function(x, y) { 12 if (x < 0) x = 0; else if (x >= this.w) x = this.w-1; 13 if (y < 0) y = 0; else if (y >= this.h) x = this.h-1; 14 return this.data[this.w*Math.trunc(y) + Math.trunc(x)]; 15 } 16 }; 17}В нем реализовано как создание буфера сразу необходимого размера, так и методы по управлению этим буфером.
- pset(x,y,cl) - рисуется точка в текстурном буфере
- point(x,y) - точка получается, при этом у нее есть ограничения, как можно отметить, если x,y превышают границы, то текстура берется с границ. Это сделано потому, что при рендеринге не всегда точно попадают текстуры в рисуемый треугольник из-за разности алгоритмов рисования треугольника. Это плохо, но я пока не знаю, как это быстро можно исправить.
1init() { 2 3 // Камера 4 this.camera = { 5 x: 0, y: 0, z: 3, 6 rx: 0, ry: 0, rz: 0 7 }; 8 9 // Создать текстуру 10 this.tex = this.texture_make(256, 256); 11 for (let y = 0; y < 256; y++) 12 for (let x = 0; x < 256; x++) { 13 let cl = (x ^ y) & 32 ? 10 : 1; 14 this.tex.pset(x, y, this.dos(cl)); 15 } 16}Да, матрица камеры будет вычисляться именно на основании значений из объекта
this.camera
.§ Вращение объекта
Наконец, пришло время запустить весь этот огромный код в работу.1loop() { 2 3 this.camera.rx += 0.01; 4 this.camera.ry += 0.02; 5 this.camera.rz += 0.03; 6 7 // Формировать матрицу 8 let camera = this.identity(); 9 camera = this.rotateX(camera, this.camera.rx); 10 camera = this.rotateY(camera, this.camera.ry); 11 camera = this.rotateZ(camera, this.camera.rz); 12 camera = this.translate(camera, this.camera); 13 14 // Рендер 15 this.cls(); 16 this.drawCube(camera); 17}Это метод для обновления фрейма. Сначала, поворачивается камера, а точнее говоря, куб, после этого создается матрица, которая получается путем умножения на единичную матрицу сначала матрицы поворотов по X,Y,Z и далее — матрицы трансляции, чтобы отодвинуть повернутый куб от камеры. После всего, вызывается метод drawCube, передается туда матрица камеры и объект рисуется.