Заметки за 2023 год
Вращение объекта в WebGL
В детстве я проводил много времени, играя в культовую игру Elite на ZX Spectrum. Это было нечто большее, чем просто развлечение; это было погружение в уникальный мир космических приключений, где каждая космическая стыковка казалась подвигом. Одним из самых запоминающихся моментов игры была заставка с вращающимся космическим кораблем Cobra MK3, который стал иконой для целого поколения геймеров.
Спустя годы, вооруженный ностальгией и желанием воссоздать кусочек этой магии, я решил написать программу, которая позволила бы насладиться вращающимся кораблем Cobra MK3 прямо в браузере. Для выполнения данной задачи мною был выбран WebGL — эффективный инструмент для разработки и визуализации 3D-графики непосредственно в веб-браузере, стараясь при этом минимизировать применение внешних плагинов или библиотек.
Разработка началась с изучения основ WebGL и понимания того, как можно манипулировать 3D-объектами и создавать эффект вращения. Без использования сторонних библиотек для работы с 3D графикой задача становилась более сложной, но в то же время более интересной, так как это позволило глубже погрузиться в механизмы работы с графикой на низком уровне.
Шаг 1: Инициализация WebGL
Инициализация WebGL является первым шагом в создании 3D-графики на веб-странице.
Для начала нужно создать элемент <canvas> на веб-странице, так как именно на нем будет отображаться наша 3D-графика. Элемент canvas действует как “холст”, на котором WebGL может рисовать.
Демонстрация запускается непосредственно внутри заметки, поэтому весь код оформлен как функция mountCobra. Она получает элемент демонстрации, находит в нем область для вывода и помещает туда созданный canvas. Атрибут aria-hidden скрывает сам холст от программ чтения с экрана, поскольку доступное название уже задано для содержащей его области.
export default function mountCobra(demo) {
// Создаём элемент canvas внутри области текущей демонстрации
const stage = demo.querySelector("[data-js-demo-stage]");
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
stage.replaceChildren(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 с ребрами модели. Для заливки корабля также понадобились массивы faces и faces2, описывающие треугольные грани.
Массив vertices был заполнен координатами каждой вершины корабля в трехмерном пространстве, представляя основу для будущего 3D-объекта. Каждый элемент этого массива содержал тройку чисел, обозначающих положение вершины в пространстве по осям X, Y, и Z.
Следующим шагом было заполнение массива 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
];
Для рисования залитой модели одних ребер недостаточно. WebGL должен знать, из каких треугольников состоит поверхность корабля. Каждый такой треугольник представлен тремя индексами вершин. Основные грани корпуса я поместил в массив faces, а грани дюз — в отдельный массив faces2, чтобы позднее нарисовать их другим цветом.
// Определяем грани корабля Cobra MK3 для заливки
var faces = [
20, 2, 1, // Face 0
20, 0, 2, // Face 1
20, 11, 0, // Face 2
20, 1, 10, // Face 3
11, 20, 10, // Face 4
4, 6, 0, // Face 5
3, 1, 5, // Face 6
5, 8, 3, // Face 7
6, 4, 7, // Face 8
2, 5, 1, // Face 9
2, 0, 6, // Face 10
9, 2, 6, // Face 11
9, 5, 2, // Face 12
7, 4, 11, // Face 13
8, 10, 3, // Face 14
10, 1, 3, // Face 15
11, 4, 0, // Face 16
7, 11, 6, // Face 17
8, 5, 10, // Face 18
9, 10, 5, // Face 19
10, 9, 11, // Face 20
9, 6, 11, // Face 21
];
// Определяем грани для дюз корабля Cobra MK3 для заливки другим цветом
var faces2 = [
27, 25, 26, // Face 22
23, 22, 24, // Face 23
18, 19, 12, // Face 24
12, 13, 18, // Face 25
16, 17, 15, // Face 26
17, 14, 15, // Face 27
];
Шаг 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 = `
precision mediump float;
uniform vec4 uColor;
void main() {
gl_FragColor = uColor;
}
`;
В данном примере:
precision mediump float;- задает среднюю точность вычислений с числами с плавающей точкой, необходимую фрагментному шейдеру WebGL.uniform vec4 uColor;- uniform-переменная, через которую JavaScript передает текущий цвет. Первые три значения обозначают компоненты RGB, а четвертое - альфа-канал, определяющий прозрачность фрагмента.gl_FragColor- это встроенная переменная, которая определяет конечный цвет фрагмента. Присваивая ей значениеuColor, можно одним шейдером рисовать белые ребра, темный корпус и желтые дюзы корабля.
Эти простые шейдеры создают основу для визуализации 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-пространстве.
Метод gl.bufferData принимает типизированный массив, поэтому обычный JavaScript-массив vertices преобразуется в Float32Array. Координаты вершин содержат дробные значения после нормализации, для хранения которых подходит 32-битное число с плавающей точкой.
// Создание буфера вершин
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
Создание буферов индексов позволяет указывать порядок, в котором уже сохраненные вершины должны быть использованы при рисовании линий и составлении треугольников. Это уменьшает количество необходимых данных, поскольку одна вершина может быть переиспользована в нескольких ребрах или гранях фигуры.
Для каждого из трех массивов индексов создается отдельный буфер. Индексы являются целыми положительными числами и преобразуются в Uint16Array. Позже это позволит независимо нарисовать ребра, корпус и дюзы корабля.
// Создание буфера индексов, который описывает рёбра модели
const edgeIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, edgeIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(edges), gl.STATIC_DRAW);
// Создание буфера индексов, который описывает грани модели
const faceIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, faceIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(faces), gl.STATIC_DRAW);
// Создание буфера индексов, который описывает грани дюз корабля
const face2IndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, face2IndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(faces2), gl.STATIC_DRAW);
Следующий код связывает атрибут вершинных данных с определенным атрибутом в вершинном шейдере и настраивает, как WebGL интерпретирует эти данные при рендеринге.
// Получение локации атрибута позиции из шейдерной программы
const positionAttributeLocation = gl.getAttribLocation(shaderProgram, 'position');
gl.vertexAttribPointer(positionAttributeLocation, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(positionAttributeLocation);
Для изменения цвета перед каждым вызовом отрисовки нужно также получить ссылку на uniform-переменную uColor из фрагментного шейдера.
// Получение ссылки на uniform-переменную цвета
const colorUniformLocation = gl.getUniformLocation(shaderProgram, 'uColor');
Матрицы проекции и модели-вида изначально устанавливаются в единичное состояние. Значение animationFrame будет хранить идентификатор очередного кадра анимации, чтобы при завершении работы его можно было отменить.
let projectionMatrix = createIdentityMatrix();
let modelViewMatrix = createIdentityMatrix();
let animationFrame = 0;
Настройка матрицы проекции определяет «объектив камеры», через который будет восприниматься сцена. Это включает угол обзора (FOV), соотношение сторон, а также ближнюю и дальнюю плоскости отсечения.
Размер canvas задается отдельно от его размера на странице. Чтобы изображение оставалось четким на экранах с высокой плотностью пикселей, ширина и высота умножаются на devicePixelRatio. Я ограничил его значением 2, так как дальнейшее увеличение разрешения заметно повышает нагрузку, но почти не влияет на вид тонких линий модели.
После изменения размера canvas необходимо обновить область вывода WebGL с помощью gl.viewport и заново вычислить соотношение сторон для перспективной матрицы.
function setProjectionMatrix() {
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(stage.clientWidth, 1);
const height = Math.max(stage.clientHeight, 1);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
// Обновление области просмотра WebGL для новых размеров холста
gl.viewport(0, 0, canvas.width, canvas.height);
// Обновление матрицы проекции с новым соотношением сторон
const fov = Math.PI / 4; // Угол обзора 45 градусов
const aspect = width / height;
const near = 0.1; // Ближняя плоскость отсечения
const far = 100.0; // Дальняя плоскость отсечения
projectionMatrix = createPerspectiveMatrix(fov, aspect, near, far);
}
Шаг 6: Анимация и отрисовка
После завершения всей подготовительной работы можно перейти к созданию движения. Функция animate вызывается для каждого кадра и каждый раз начинает с единичной матрицы модели-вида. Благодаря этому трансформации предыдущего кадра не накапливаются, а положение корабля полностью определяется текущим временем.
Сначала модель смещается на 1.2 единицы в глубину сцены, чтобы она оказалась перед камерой. Затем к ней применяются три вращения: постоянный наклон вокруг оси X и зависящие от времени повороты вокруг осей Y и Z. Разная скорость и направление этих вращений создают движение, напоминающее заставку оригинальной Elite.
// Анимация вращения
function animate() {
// Сброс modelViewMatrix к идентичной, чтобы избежать накопления трансформаций
modelViewMatrix = createIdentityMatrix();
// Применяем трансляцию для "отдаления" камеры
const translationMatrix = createTranslationMatrix(0, 0, -1.2);
modelViewMatrix = multiplyMatrices(translationMatrix, modelViewMatrix);
const angle = Date.now() / 1000;
modelViewMatrix = multiplyMatrices(rotateXMatrix(2.0), modelViewMatrix);
modelViewMatrix = multiplyMatrices(rotateYMatrix(-0.5*angle), modelViewMatrix);
modelViewMatrix = multiplyMatrices(rotateZMatrix(angle), modelViewMatrix);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(
gl.getUniformLocation(shaderProgram, 'modelViewMatrix'),
false,
modelViewMatrix
);
gl.uniformMatrix4fv(
gl.getUniformLocation(shaderProgram, 'projectionMatrix'),
false,
projectionMatrix
);
drawEdges();
gl.polygonOffset(0.5, 0.5);
gl.uniform4f(colorUniformLocation, 0.05, 0.05, 0.05, 1.0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, faceIndexBuffer);
gl.drawElements(gl.TRIANGLES, faces.length, gl.UNSIGNED_SHORT, 0);
gl.polygonOffset(0.25, 0.25);
gl.uniform4f(colorUniformLocation, 1.0, 1.0, 0.0, 1.0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, face2IndexBuffer);
gl.drawElements(gl.TRIANGLES, faces2.length, gl.UNSIGNED_SHORT, 0);
animationFrame = requestAnimationFrame(animate);
}
Перед рисованием кадра включается проверка глубины, а буферы цвета и глубины очищаются. Затем текущие матрицы передаются в вершинный шейдер с помощью gl.uniformMatrix4fv.
Модель рисуется в три этапа. Сначала вызывается функция drawEdges, создающая белый проволочный каркас. Затем с помощью gl.TRIANGLES темным цветом заполняется корпус, после чего желтым цветом рисуются грани дюз. Функция gl.polygonOffset немного смещает залитые полигоны по глубине и помогает избежать мерцания линий, возникающего, когда ребра и грани находятся практически в одной плоскости.
function drawEdges() {
// Отключаем смещение полигона, чтобы избежать его применения к рёбрам
gl.disable(gl.POLYGON_OFFSET_FILL);
// Устанавливаем белый цвет для рисования рёбер
gl.uniform4f(colorUniformLocation, 1.0, 1.0, 1.0, 1.0);
// Привязываем буфер индексов рёбер
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, edgeIndexBuffer);
// Выполняем рисование всех рёбер как линий
gl.drawElements(gl.LINES, edges.length, gl.UNSIGNED_SHORT, 0);
// Включаем смещение для последующего рисования полигонов
gl.enable(gl.POLYGON_OFFSET_FILL);
}
В конце animate передает саму себя в requestAnimationFrame. Браузер вызовет функцию перед выводом следующего кадра, благодаря чему анимация будет синхронизирована с обновлением экрана.
Остается выполнить первоначальную настройку проекции и запустить цикл. ResizeObserver вызывает setProjectionMatrix при каждом изменении размеров области демонстрации, поэтому корабль сохраняет правильные пропорции как на большом экране, так и на мобильном устройстве.
const resizeObserver = new ResizeObserver(setProjectionMatrix);
setProjectionMatrix();
resizeObserver.observe(stage);
animationFrame = requestAnimationFrame(animate);
Функция mountCobra, с которой начинался пример, возвращает функцию очистки. При уходе со страницы она отменяет запланированный кадр и отключает наблюдение за размером элемента, не оставляя работающих обработчиков в памяти браузера.
return function cleanup() {
cancelAnimationFrame(animationFrame);
resizeObserver.disconnect();
};
Результат работы программы можно увидеть ниже.
Бонус
Скан листовки помогающий идентифицировать контуры кораблей, которая прилагалась к оригинальной кассете с «Elite».
