§ О текстурировании
Вначале было... пространство, точнее, Декарт, который это пространство придумал. Потом появилась точка, она была размером с 1 пиксель на экране 320x200. Ну и в общем, из точки сделался куб, потом его закрасили в разные цвета радуги, ну и конечно, сейчас надо затекстурировать эти грани Бытия.§ Параметры куба
Как и обычно, я использую стандартный куб, который ранее уже появлялся у меня в статьях и не один раз. Начнем писать методdrawCube
.let vertex = [ // Список вершин куба, их 8 vec3(-1, 1, 1), vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, -1), vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1) ];Функция vec3 представляет из себя функцию:
function vec3(x, y, z, u = 0, v = 0) { return { x: x, y: y, z: z, u: u, v: v }; }Однако, в отличии от предыдущих статей, помимо вершин, необходимо задать uv-маппинг, который будет нужен для текстурирования. Поскольку у меня не так много вариантов, всего лишь 4, то и задам всего лишь 4 точки текстурного маппинга.
let uvface = [ [0, 0], // 0 [255, 0], // 1 [255, 255], // 2 [ 0, 255], // 3 ];Почему 4? Дело в том, что я буду рисовать для куба квадрат с каждой стороны. Слева сверху натягивается текстура, которая находится в (0,0), справа сверху (255,0), внизу справа (255,255) и слева снизу (0,255) — это координаты текстуры. Маппинг текстур для куба самый простой.
Далее, определившись с маппингом текстур, необходимо определиться с обходом вершин. Ниже предложены 12 треугольников, описывающих куб по номерам вершин
vertex
и также, прицепленным к ним uvface
. Первые 3 точки идут вершины, следующие три точки - это текстурная координата каждой вершины. Это лишь один из многочисленных методов, которые можно использовать, я выбрал такой метод, так что если кто хочет, то может и сам придумать, как сделать задание этих данных.let faces = [ // VTX UVF // A B C a b c [1, 0, 3, 0, 1, 2], // Перед [1, 3, 2, 0, 2, 3], [5, 1, 2, 0, 1, 2], // Справа [5, 2, 6, 0, 2, 3], [2, 3, 7, 0, 1, 2], // Низ [2, 7, 6, 0, 2, 3], [0, 1, 5, 0, 1, 2], // Верх [0, 5, 4, 0, 2, 3], [0, 4, 7, 0, 1, 2], // Слева [0, 7, 3, 0, 2, 3], [4, 5, 6, 0, 1, 2], // Зад [4, 6, 7, 0, 2, 3], ];
§ Преобразование геометрии вершин
В этой статье я пойду наоборот, сверху вглубь, то есть, сначала обозначу методы, которые будут использоваться, а потом уже далее, реализую эти методы.Перед тем, как рендерить куб, надо выполнить над ним преобразования. Самый лучший способ сделать это — использовать матрицы 4x4. Можно, конечно, пойти тем путем, что и в прошлой статье и делать все через вращения и так далее, но преимущество использования матриц в том, что их достаточно подсчитать лишь однажды на всю сцену, а не пересчитывать каждый раз. Это намного быстрее работает.
Насчет матриц можно посмотреть в этой статье.
Все эти преобразования матриц очень важны для того, чтобы куб крутился в пространстве. Однако, после всего, нужно сделать, что точка вершины преобразовывалась, используя матрицу 4x4, потому ее нужно умножать с помощью метода
matrix_apply
(см. статью про матрицы), и быстро выполнить необходимые преобразования всех вершин куба:let vtx = this.matrix_apply(vertex, matrix);Здесь matrix — это задаваемая внешним способом матрица поворотов и трансляции. Она вычисляется отдельно на сцену.
§ Проецирование и рендеринг
Вот теперь осталось самое сложное, это как раз растеризация с учетом текстур. Здесь требуется очень много вычислений и поэтому алгоритм становится довольно ресурсо-затратным.let prj = this.project(vtx);После расчета новых положении вершин, теперь их необходимо спроецировать на экран с помощью такого метода. Проецирование массива векторов типа vec3.
let out = []; let ppd = this._height / 2; for (let i = 0; i < v.length; i++) { out.push({ x: Math.floor(this._width / 2 + ppd*v[i].x / v[i].z), y: Math.floor(this._height / 2 - ppd*v[i].y / v[i].z), }); } return out; }project(v) { На вход задаются массивы вектором v, по которым выполняется перспективная проекция и выдается результат в виде
vec2
.Теперь можно начинать отрисовку всех 12 треугольников:
for (let i = 0; i < faces.length; i++) { let P = []; let V = []; // Перебрать все 3 точки for (let j = 0; j < 3; j++) { let idx = faces[i][j]; // Номер vertex let idf = faces[i][j + 3]; // Номер uv // Добавить проецированную точку в треугольник P.push({x:prj[idx].x, y:prj[idx].y}); // Точки в пространстве V.push({ x: vtx[idx].x, y: vtx[idx].y, z: vtx[idx].z, u: uvface[idf][0], v: uvface[idf][1], }); } let R = this.compute(V); // Вычислить грань this.raster(P, {R:R, T:this.tex}); // Растеризация }На этом, отрисовку куба можно считать законченным.
Как тут можно заметить, заполняются два массива P - точки, которые уже проецированы на экран, и массив V - это точки, которые необходимо будет вычислить с помощью функции compute. Эта функция необходима для вычисления позиции текстур и вообще, относится, так сказать, к фрагментному шейдеру. Результат вычисления передается в функцию
raster
.§ Вычисление параметров треугольника
Приведу реализацию методаcompute
, который по трем точкам вычисляет те параметры, которые будут использоваться для вычисления позиции текстур и вообще, положения точки, а также вектора нормали.- T (create) - массив вершин типа
vec3
для обработки - P (pivor) - точка отсчета (по умолчанию 0,0,0)
0,0,0) : P; let AB = vec3(T[1].x - T[0].x, T[1].y - T[0].y, T[1].z - T[0].z); let AC = vec3(T[2].x - T[0].x, T[2].y - T[0].y, T[2].z - T[0].z); let AP = vec3( P.x - T[0].x, P.y - T[0].y, P.z - T[0].z); return { // Параметры U: vec3( AC.y * AP.z - AC.z * AP.y, AC.z * AP.x - AC.x * AP.z, AC.x * AP.y - AC.y * AP.x ), V: vec3( AB.z * AP.y - AB.y * AP.z, AB.x * AP.z - AB.z * AP.x, AB.y * AP.x - AB.x * AP.y ), D: vec3( AB.z * AC.y - AB.y * AC.z, AB.x * AC.z - AB.z * AC.x, AB.y * AC.x - AB.x * AC.y ), // Вектор нормали N: vec3( AB.y * AC.z - AB.z * AC.y, AB.z * AC.x - AB.x * AC.z, AB.x * AC.y - AB.y * AC.x ), // Текстурные координаты tu: vec3(T[0].u, T[1].u - T[0].u, T[2].u - T[0].u), tv: vec3(T[0].v, T[1].v - T[0].v, T[2].v - T[0].v), // Сохранить треугольник a: T[0], AB: AB, AC: AC, AP: AP, }; }compute(T, P = null) { P = (P === null) ? vec3(
§ Код растеризации
Приведенный ниже код достаточно большой. Частично, он получен из предыдущего кода, который рисовал лишь сплошной цвет.В param могут задаваться следующие свойства:
- R - объект, полученный в результате
compute
- T - текстурный объект, код которого приведу далее
- color - некоторый базовый цвет, но при задании текстуры он не используется
// Проверка видимости грани let AB = {x: vtx[1].x - vtx[0].x, y: vtx[1].y - vtx[0].y}; let AC = {x: vtx[2].x - vtx[0].x, y: vtx[2].y - vtx[0].y}; // Отбросить грань при обходе против часовой стрелки if (AB.x*AC.y - AB.y*AC.x <= 0) { return; } // Цвет для рисования let color = param ? (param.color || 0xFFFFFF) : 0xFFFFFF; // Текстурирование и точки пересечения с треугольником let R = (param && typeof param.R === 'object') ? param.R : null; let TXT = (param && typeof param.T === 'object') ? param.T : null; let TM = null; // Вычисления половин экрана let [W2, H2] = [this._width >> 1, this._height >> 1]; // Подготовка текстуры if (R) { TM = { u: 0, uz: R.U.z*H2, v: 0, vz: R.V.z*H2, d: 0, dz: R.D.z*H2, }; } // Сортировка по возрастанию let p = vtx.sort(function (x, y) { return x.y < y.y ? -1 : 1; }); // Главная сторона let [Ax, Adx] = [p[0].x, 0]; let [ACx, ACy] = [p[2].x - p[0].x, p[2].y - p[0].y]; // Два треугольника for (let i = 0; i < 2; i++) { // Боковая сторона let [Bx, Bdx] = [p[i].x, 0]; let [AMx, AMy] = [p[i+1].x - p[i].x, p[i+1].y - p[i].y]; // Отрисовка полутреугольника for (let y = p[i].y; y < p[i+1].y + i; y++) { // Находится за пределами if (y >= this._height) break; // Рисовать только если не за верхней границей экрана if (y >= 0) { let x1 = Math.min(Ax, Bx); let x2 = Math.max(Ax, Bx); // Концы отрезков не выходят за пределы рисования if (x1 < this._width || x2 >= 0) { x1 = Math.max(x1, 0); x2 = Math.min(x2, this._width - 1); // Запущена растеризация с текстурой if (R) { // Направление камеры всегда стандартное let d = {x: x1 - W2, y: H2 - y}; // Рассчитать новую позицию перед растеризаицией линии TM.u = R.U.x*d.x + R.U.y*d.y + TM.uz; TM.v = R.V.x*d.x + R.V.y*d.y + TM.vz; TM.d = R.D.x*d.x + R.D.y*d.y + TM.dz; } // Прочертить линию for (let x = x1; x <= x2; x++) { // Стандартный цвет let cl = color, u, v; // Вычисление позиции (u,v) для (tx,ty) if (R) { [u, v] = [TM.u / TM.d, TM.v / TM.d]; if (TXT) { // Рассчитать позицию текстуры let tx = R.tu.x + R.tu.y*u + R.tu.z*v; let ty = R.tv.x + R.tv.y*u + R.tv.z*v; cl = TXT.point(tx, ty); } } // Рисование точки в буфере this.pset(x, y, cl); // Приращение +1 if (R) { TM.u += R.U.x; TM.v += R.V.x; TM.d += R.D.x; } } } } // Сдвиг точек x1,x2. Важно использовать trunc! Adx += ACx; Ax += Math.trunc(Adx / ACy); Adx %= ACy; Bdx += AMx; Bx += Math.trunc(Bdx / AMy); Bdx %= AMy; } } }raster(vtx, param = null) { Как можно отметить, кода довольно много. Частично он повторяется, но есть много добавлений.
- Если задан 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 достаточно удобно создавать на лету такого рода объекты:return { w: w, h: h, data: new Uint32Array(w * h), pset: function(x, y, cl) { if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.data[this.w*Math.trunc(y) + Math.trunc(x)] = cl; }, point: function(x, y) { if (x < 0) x = 0; else if (x >= this.w) x = this.w-1; if (y < 0) y = 0; else if (y >= this.h) x = this.h-1; return this.data[this.w*Math.trunc(y) + Math.trunc(x)]; } }; }texture_make(w, h) { В нем реализовано как создание буфера сразу необходимого размера, так и методы по управлению этим буфером.
- pset(x,y,cl) - рисуется точка в текстурном буфере
- point(x,y) - точка получается, при этом у нее есть ограничения, как можно отметить, если x,y превышают границы, то текстура берется с границ. Это сделано потому, что при рендеринге не всегда точно попадают текстуры в рисуемый треугольник из-за разности алгоритмов рисования треугольника. Это плохо, но я пока не знаю, как это быстро можно исправить.
// Камера this.camera = { x: 0, y: 0, z: 3, rx: 0, ry: 0, rz: 0 }; // Создать текстуру this.tex = this.texture_make(256, 256); for (let y = 0; y < 256; y++) for (let x = 0; x < 256; x++) { let cl = (x ^ y) & 32 ? 10 : 1; this.tex.pset(x, y, this.dos(cl)); } }init() { Да, матрица камеры будет вычисляться именно на основании значений из объекта
this.camera
.§ Вращение объекта
Наконец, пришло время запустить весь этот огромный код в работу.this.camera.rx += 0.01; this.camera.ry += 0.02; this.camera.rz += 0.03; // Формировать матрицу let camera = this.identity(); camera = this.rotateX(camera, this.camera.rx); camera = this.rotateY(camera, this.camera.ry); camera = this.rotateZ(camera, this.camera.rz); camera = this.translate(camera, this.camera); // Рендер this.cls(); this.drawCube(camera); }loop() { Это метод для обновления фрейма. Сначала, поворачивается камера, а точнее говоря, куб, после этого создается матрица, которая получается путем умножения на единичную матрицу сначала матрицы поворотов по X,Y,Z и далее — матрицы трансляции, чтобы отодвинуть повернутый куб от камеры. После всего, вызывается метод drawCube, передается туда матрица камеры и объект рисуется.