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

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

	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;
}
На вход задаются массивы вектором 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)
compute(T, P = null) {

	P = (P === null) ? vec3(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,
	};
}

§ Код растеризации

Приведенный ниже код достаточно большой. Частично, он получен из предыдущего кода, который рисовал лишь сплошной цвет.
В param могут задаваться следующие свойства:
  • R - объект, полученный в результате compute
  • T - текстурный объект, код которого приведу далее
  • color - некоторый базовый цвет, но при задании текстуры он не используется
raster(vtx, param = null) {

	// Проверка видимости грани
	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;
		}
	}
}
Как можно отметить, кода довольно много. Частично он повторяется, но есть много добавлений.
  • Если задан 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 достаточно удобно создавать на лету такого рода объекты:
texture_make(w, h) {

	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)];
		}
	};
}
В нем реализовано как создание буфера сразу необходимого размера, так и методы по управлению этим буфером.
  • pset(x,y,cl) - рисуется точка в текстурном буфере
  • point(x,y) - точка получается, при этом у нее есть ограничения, как можно отметить, если x,y превышают границы, то текстура берется с границ. Это сделано потому, что при рендеринге не всегда точно попадают текстуры в рисуемый треугольник из-за разности алгоритмов рисования треугольника. Это плохо, но я пока не знаю, как это быстро можно исправить.
Задам начальные параметры и создам текстуру:
init() {

	// Камера
	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));
	}
}
Да, матрица камеры будет вычисляться именно на основании значений из объекта this.camera.

§ Вращение объекта

Наконец, пришло время запустить весь этот огромный код в работу.
loop() {

	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);
}
Это метод для обновления фрейма. Сначала, поворачивается камера, а точнее говоря, куб, после этого создается матрица, которая получается путем умножения на единичную матрицу сначала матрицы поворотов по X,Y,Z и далее — матрицы трансляции, чтобы отодвинуть повернутый куб от камеры. После всего, вызывается метод drawCube, передается туда матрица камеры и объект рисуется.