Заметки за 2023 год
Вращение объекта в WebGL
В детстве я проводил много времени, играя в культовую игру Elite на ZX Spectrum. Это было нечто большее, чем просто развлечение; это было погружение в уникальный мир космических приключений, где каждая космическая стыковка казалась подвигом. Одним из самых запоминающихся моментов игры была заставка с вращающимся космическим кораблем Cobra MK3, который стал иконой для целого поколения геймеров.
Спустя годы, вооруженный ностальгией и желанием воссоздать кусочек этой магии, я решил написать программу, которая позволила бы насладиться вращающимся кораблем Cobra MK3 прямо в браузере. Для выполнения данной задачи мною был выбран WebGL — эффективный инструмент для разработки и визуализации 3D-графики непосредственно в веб-браузере, стараясь при этом минимизировать применение внешних плагинов или библиотек.
Разработка началась с изучения основ WebGL и понимания того, как можно манипулировать 3D-объектами и создавать эффект вращения. Без использования сторонних библиотек для работы с 3D графикой задача становилась более сложной, но в то же время более интересной, так как это позволило глубже погрузиться в механизмы работы с графикой на низком уровне.
Шаг 1: Инициализация WebGL
Инициализация WebGL является первым шагом в создании 3D-графики на веб-странице.
Для начала нужно получить доступ к элементу <canvas> на веб-странице, так как именно на нем будет отображаться наша 3D-графика. Элемент canvas действует как “холст”, на котором WebGL может рисовать. Это предполагает, что в HTML-документе уже есть элемент <canvas> с id="webgl-canvas".
// Получаем элемент canvas
const canvas = document.getElementById('webgl-canvas');
После получения элемента canvas следующим шагом является запрос контекста WebGL для этого canvas. Контекст WebGL позволяет взаимодействовать с графическим оборудованием через браузер, используя API WebGL. В результате переменная gl содержит ссылку на контекст WebGL, который мы будем использовать для всей последующей работы с 3D-графикой.
// Инициализируем WebGL контекст
const gl = canvas.getContext('webgl');
Не все браузеры или их версии могут поддерживать WebGL, или же эта поддержка может быть отключена в настройках. Поэтому важно проверить, успешно ли был получен контекст WebGL, и если нет, завершить программу.
// Проверяем, поддерживается ли WebGL
if (!gl) {
throw new Error('WebGL не поддерживается вашим браузером');
}
Шаг 2: Подготовка данных вершин и индексов
Чтобы воплотить вращающийся корабль Cobra MK3 в WebGL, первым шагом была подготовка данных вершин и индексов, необходимых для создания 3D модели. Этот процесс требовал точных исходных данных, чтобы модель как можно более точно соответствовала оригинальному дизайну, который запомнился многим поклонникам игры Elite. Искал я эти данные с надеждой найти готовые чертежи или даже оригинальную 3D модель корабля, что значительно упростило бы задачу.
Поиски привели меня на сайт bbcelite.com, ресурс, посвященный исходным кодам игры Elite для BBC Micro. Этот сайт оказался настоящей кладезью информации: на нем были представлены не только исходные коды игры, но и чертежи различных космических кораблей, включая желанный Cobra MK3.

Используя найденные данные, я создал два массива: vertices (вершины) и edges (грани).
Массив vertices был заполнен координатами каждой вершины корабля в трехмерном пространстве, представляя основу для будущего 3D-объекта. Каждый элемент этого массива содержал тройку чисел, обозначающих положение вершины в пространстве по осям X, Y, и Z.
Следующим шагом было заполнение массива edges, который описывал связи между вершинами, тем самым определяя грани корабля. Каждый элемент массива edges состоял из пар индексов вершин из массива vertices, формируя линии, которые в совокупности создавали полигональную сетку модели корабля.
Эти массивы стали основой для визуализации модели в WebGL. С помощью данных вершин и граней я смог точно воссоздать форму корабля Cobra MK3, сохраняя его уникальные черты и детали, видимые в оригинальной заставке игры Elite.
// Определяем вершины корабля Cobra MK3,
var vertices = [
32, 0, 76, // Vertex 0
-32, 0, 76, // Vertex 1
0, 26, 24, // Vertex 2
-120, -3, -8, // Vertex 3
120, -3, -8, // Vertex 4
-88, 16, -40, // Vertex 5
88, 16, -40, // Vertex 6
128, -8, -40, // Vertex 7
-128, -8, -40, // Vertex 8
0, 26, -40, // Vertex 9
-32, -24, -40, // Vertex 10
32, -24, -40, // Vertex 11
-36, 8, -40, // Vertex 12
-8, 12, -40, // Vertex 13
8, 12, -40, // Vertex 14
36, 8, -40, // Vertex 15
36, -12, -40, // Vertex 16
8, -16, -40, // Vertex 17
-8, -16, -40, // Vertex 18
-36, -12, -40, // Vertex 19
0, 0, 76, // Vertex 20
0, 0, 90, // Vertex 21
-80, -6, -40, // Vertex 22
-80, 6, -40, // Vertex 23
-88, 0, -40, // Vertex 24
80, 6, -40, // Vertex 25
88, 0, -40, // Vertex 26
80, -6, -40, // Vertex 27
];
// Нормализуем вершины в диапазон от -0.5 до 0.5
vertices.forEach(function(val, index, vertices) {
vertices[index] = val/256;
});
// Определяем грани корабля Cobra MK3 как пары индексов вершин
var edges = [
0, 1, // Edge 0
0, 4, // Edge 1
1, 3, // Edge 2
3, 8, // Edge 3
4, 7, // Edge 4
6, 7, // Edge 5
6, 9, // Edge 6
5, 9, // Edge 7
5, 8, // Edge 8
2, 5, // Edge 9
2, 6, // Edge 10
3, 5, // Edge 11
4, 6, // Edge 12
1, 2, // Edge 13
0, 2, // Edge 14
8, 10, // Edge 15
10, 11, // Edge 16
7, 11, // Edge 17
1, 10, // Edge 18
0, 11, // Edge 19
1, 5, // Edge 20
0, 6, // Edge 21
20, 21, // Edge 22
12, 13, // Edge 23
18, 19, // Edge 24
14, 15, // Edge 25
16, 17, // Edge 26
15, 16, // Edge 27
14, 17, // Edge 28
13, 18, // Edge 29
12, 19, // Edge 30
2, 9, // Edge 31
22, 24, // Edge 32
23, 24, // Edge 33
22, 23, // Edge 34
25, 26, // Edge 35
26, 27, // Edge 36
25, 27, // Edge 37
];
Шаг 3: Работа с матрицами
В процессе разработки я столкнулся с необходимостью глубокого погружения в математику и механику работы с 3D-графикой. Одним из ключевых решений на этом пути стало отказ от использования сторонних библиотек для работы с 3D графикой. Это решение накладывало дополнительные обязательства по реализации базовых функций для работы с матрицами, которые являются основой для всех преобразований в трехмерном пространстве. Я написал следующие функции:
createIdentityMatrix()
Эта функция создает единичную матрицу, которая в 3D-графике используется как начальное состояние для преобразований. Единичная матрица служит основой, гарантируя, что объект не будет изменен до применения к нему каких-либо трансформаций.
// Функция создания единичной матрицы
function createIdentityMatrix() {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
multiplyMatrices()
Функция умножения матриц критически важна, поскольку большинство 3D-преобразований требует последовательного применения нескольких матриц к объекту. Умножение матриц позволяет комбинировать эти преобразования в одно, обеспечивая эффективное и точное управление объектом в 3D-пространстве.
// Функция умножения матриц
function multiplyMatrices(a, b) {
const result = new Array(16);
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
result[i * 4 + j] = 0;
for (let k = 0; k < 4; k++) {
result[i * 4 + j] += a[i * 4 + k] * b[k * 4 + j];
}
}
}
return result;
}
rotateXMatrix(), rotateYMatrix(), rotateZMatrix()
Функции создания матриц вращения по осям X, Y, и Z предоставляют возможность вращать объекты вокруг этих осей. Вращение является одним из основных действий при работе с 3D-моделями, и эти функции позволяют точно контролировать угол и направление вращения.
// Функция создания матрицы вращения вокруг оси X
function rotateXMatrix(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
1, 0, 0, 0,
0, c, -s, 0,
0, s, c, 0,
0, 0, 0, 1
];
}
// Функция создания матрицы вращения вокруг оси Y
function rotateYMatrix(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
c, 0, s, 0,
0, 1, 0, 0,
-s, 0, c, 0,
0, 0, 0, 1
];
}
// Функция создания матрицы вращения вокруг оси Z
function rotateZMatrix(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [
c, -s, 0, 0,
s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
createTranslationMatrix()
Функция создания матрицы трансляции позволяет перемещать объекты в пространстве, задавая новые координаты для их положения. Это основа для любых анимаций и динамических сцен, где объекты не остаются неподвижными.
// Функция создания матрицы трансляции
function createTranslationMatrix(tx, ty, tz) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1
];
}
createPerspectiveMatrix()
Матрица перспективы преобразует трехмерную сцену таким образом, чтобы она корректно отображалась на двумерном экране, имитируя перспективу человеческого зрения. Это ключевой элемент для создания реалистичного визуального восприятия глубины и пространства.
// Функция создания матрицы перспективной проекции
function createPerspectiveMatrix(fov, aspect, near, far) {
const f = 1.0 / Math.tan(fov / 2);
const rangeInv = 1 / (near - far);
return [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (near + far) * rangeInv, -1,
0, 0, near * far * rangeInv * 2, 0
];
}
Шаг 4: Настройка шейдеров
Вершинные и фрагментные шейдеры являются двумя основными типами шейдеров, используемыми в WebGL для определения визуального представления 3D-объектов.
Вершинный шейдер
Вершинный шейдер обрабатывает каждую вершину модели индивидуально. Он отвечает за расчет конечного положения вершин на экране, а также может использоваться для передачи данных о вершинах к фрагментному шейдеру для дальнейших вычислений.
// Вершинный шейдер
const vertexShaderSource = `
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
В данном примере:
attribute vec3 position;- это атрибут, который получает позицию вершины из буфера вершин. Каждая вершина модели представлена своими координатами x, y и z.uniform mat4 modelViewMatrix;иuniform mat4 projectionMatrix;- это uniform-переменные, которые получают матрицы модели-вида и проекции соответственно. Эти матрицы используются для трансформации вершин в пространство камеры и далее в пространство проекции.gl_Position- это встроенная переменная, которая хранит конечное положение вершины в нормализованном пространстве координат. Расчетgl_Positionвключает умножение вектора позиции вершины на матрицы модели-вида и проекции, что преобразует вершину из локального пространства объекта в пространство экрана.
Фрагментный шейдер
Фрагментный шейдер работает с каждым пикселем (или «фрагментом») растеризованного объекта. Он определяет цвет и другие характеристики пикселя на основе различных факторов, таких как освещение, текстура и данные, переданные от вершинного шейдера.
const fragmentShaderSource = `
void main() {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); // Белый цвет
}
`;
В данном примере:
gl_FragColor- это встроенная переменная, которая определяет конечный цвет фрагмента. Здесь задается белый цвет с помощью вектора vec4, где первые три значения указывают на компоненты цвета RGB, а четвертое значение - это альфа-канал, определяющий прозрачность фрагмента.
Эти простые шейдеры создают основу для визуализации 3D-модели в WebGL, обеспечивая расчет позиции каждой вершины и установку единообразного цвета для всех фрагментов модели.
Создание и компиляция шейдеров
Функция createShader предназначена для создания и компиляции отдельного шейдера. Компиляция шейдера включает в себя проверку на наличие ошибок. Если компиляция не удалась, выводится сообщение об ошибке, что позволяет разработчику быстро идентифицировать и исправить проблему.
// Функция для создания и компиляции шейдера
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert('Ошибка компиляции шейдера: ' + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
Создание шейдерной программы
После компиляции шейдеров следующим шагом является их объединение с помощью функции createShaderProgram. Эта функция создает шейдерную программу, прикрепляет к ней вершинный и фрагментный шейдеры, а затем выполняет линковку. Линковка шейдерной программы критически важна, поскольку в этом процессе WebGL устанавливает, как вершинный и фрагментный шейдеры будут взаимодействовать друг с другом.
Проверка успешности линковки обеспечивает уверенность в том, что шейдерная программа готова к использованию. В случае обнаружения ошибок линковки, аналогично компиляции шейдера, выводится сообщение об ошибке, указывающее на проблемы, которые необходимо решить.
// Функция для создания и линковки шейдерной программы
function createShaderProgram(gl, vertexShaderSource, fragmentShaderSource) {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert('Ошибка линковки шейдерной программы: ' + gl.getProgramInfoLog(shaderProgram));
return null;
}
return shaderProgram;
}
После успешного создания и линковки шейдерной программы, она устанавливается в качестве активной программы в контексте WebGL с помощью вызова gl.useProgram(shaderProgram). Это позволяет начать рендеринг сцены с использованием заданных шейдеров.
// Создание и линковка шейдерной программы
const shaderProgram = createShaderProgram(gl, vertexShaderSource, fragmentShaderSource);
gl.useProgram(shaderProgram);
Шаг 5: Создание буферов и инициализация матриц
Продолжая процесс инициализации и настройки среды WebGL для рендеринга 3D-модели, следующим шагом является создание и заполнения буфера вершин и граней, настройка атрибутов вершин и инициализация матриц проекции и модели-вида.
Буфер вершин используется для хранения вершинных данных, таких как координаты точек, цвета, текстурные координаты и т.д., которые необходимы для рисования фигур в 3D-пространстве.
// Создание буфера вершин
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
Создание буфера индексов позволяет оптимизировать отрисовку, указывая порядок, в котором вершины должны быть использованы при составлении треугольников. Это уменьшает количество необходимых данных, поскольку вершины могут быть переиспользованы для создания различных граней фигуры.
// Создание буфера индексов (который описывает грани модели)
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, edges, gl.STATIC_DRAW);
Следующий код связывает атрибут вершинных данных с определенным атрибутом в вершинном шейдере и настраивает, как WebGL интерпретирует эти данные при рендеринге.
// Получение локации атрибута позиции из шейдерной программы
const positionAttributeLocation = gl.getAttribLocation(shaderProgram, 'position');
gl.vertexAttribPointer(positionAttributeLocation, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(positionAttributeLocation);
Настройка матрицы проекции определяет «объектив камеры», через который будет восприниматься сцена. Это включает угол обзора (FOV), соотношение сторон, а также ближнюю и дальнюю плоскости отсечения. Здесь инициализируются параметры камеры, которые передаются в функцию создания матрицы перспективной проекции.
// Инициализация матрицы проекции
const fov = Math.PI / 4; // 45 градусов
const aspect = canvas.width / canvas.height;
const near = 0.1;
const far = 100.0;
const projectionMatrix = createPerspectiveMatrix(fov, aspect, near, far);
Инициализация матрицы модели-вида с единичной матрицей устанавливает начальное положение и ориентацию объекта в пространстве. Это “нейтральное” состояние, относительно которого будут применяться все последующие трансформации (вращения, смещения и масштабирования).
// Инициализация матрицы модели-вида
let modelViewMatrix = createIdentityMatrix();
Шаг 6: Анимация
Бонус
Скан листовки помогающий идентифицировать контуры кораблей, которая прилагалась к оригинальной кассете с «Elite».
