§ О текстурировании

Вначале было... пространство, точнее, Декарт, который это пространство придумал. Потом появилась точка, она была размером с 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, передается туда матрица камеры и объект рисуется.