О чем этот пример
Создание 3D-графики в 2D-движке — отличный способ понять основы линейной алгебры и компьютерной графики. В этой статье мы разберем официальный пример Phaser, который рисует и анимирует проволочный куб, используя только 2D-холст (`Graphics`) и матрицы поворота. Вы научитесь представлять 3D-объекты в виде вершин и ребер, применять к ним преобразования и анимировать результат с помощью твинов. Этот подход открывает двери для создания простых 3D-эффектов, HUD-элементов или визуализаций без использования тяжелых 3D-библиотек.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
const node0 = [-100, -100, -100];
const node1 = [-100, -100, 100];
const node2 = [-100, 100, -100];
const node3 = [-100, 100, 100];
const node4 = [ 100, -100, -100];
const node5 = [ 100, -100, 100];
const node6 = [ 100, 100, -100];
const node7 = [ 100, 100, 100];
const nodes = [node0, node1, node2, node3, node4, node5, node6, node7];
const edge0 = [0, 1];
const edge1 = [1, 3];
const edge2 = [3, 2];
const edge3 = [2, 0];
const edge4 = [4, 5];
const edge5 = [5, 7];
const edge6 = [7, 6];
const edge7 = [6, 4];
const edge8 = [0, 4];
const edge9 = [1, 5];
const edge10 = [2, 6];
const edge11 = [3, 7];
const edges = [edge0, edge1, edge2, edge3, edge4, edge5, edge6, edge7, edge8, edge9, edge10, edge11];
const t = {
x: -0.03490658503988659,
y: 0.05235987755982989,
z: -0.05235987755982989
};
class Example extends Phaser.Scene
{
graphics;
create ()
{
this.graphics = this.add.graphics({x: 400, y: 300});
this.rotateZ3D(0.5235987755982988);
this.rotateY3D(0.5235987755982988);
this.rotateX3D(0.5235987755982988);
this.tweens.add({
targets: [t],
duration: 6000,
x: 0.03490658503988659,
ease: Phaser.Math.Easing.Sine.InOut,
yoyo: true,
repeat: -1,
});
this.tweens.add({
targets: [t],
duration: 4000,
y: -0.05235987755982989,
ease: Phaser.Math.Easing.Sine.InOut,
yoyo: true,
repeat: -1,
});
this.tweens.add({
targets: [t],
duration: 4000,
z: 0.05235987755982989,
ease: Phaser.Math.Easing.Sine.InOut,
yoyo: true,
repeat: -1,
});
}
update ()
{
this.rotateX3D(t.x);
this.rotateY3D(t.y);
this.rotateZ3D(t.z);
this.graphics.clear();
this.graphics.lineStyle(2, 0x00ff00, 1.0);
this.graphics.beginPath();
for (let e = 0; e < edges.length; e++)
{
const n0 = edges[e][0];
const n1 = edges[e][1];
const node0 = nodes[n0];
const node1 = nodes[n1];
this.graphics.moveTo(node0[0], node0[1]);
this.graphics.lineTo(node1[0], node1[1]);
}
this.graphics.closePath();
this.graphics.strokePath();
}
rotateZ3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < nodes.length; n++)
{
const node = nodes[n];
const x = node[0];
const y = node[1];
node[0] = x * tc - y * ts;
node[1] = y * tc + x * ts;
}
}
rotateY3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < nodes.length; n++)
{
const node = nodes[n];
const x = node[0];
const z = node[2];
node[0] = x * tc - z * ts;
node[2] = z * tc + x * ts;
}
}
rotateX3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < nodes.length; n++)
{
const node = nodes[n];
const y = node[1];
const z = node[2];
node[1] = y * tc - z * ts;
node[2] = z * tc + y * ts;
}
}
}
const config = {
type: Phaser.WEBGL,
width: 800,
height: 600,
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Структура данных: вершины и ребра
Любой 3D-объект можно представить как набор точек (вершин) в пространстве и линий (ребер), которые их соединяют. В примере куб задан восемью вершинами в трехмерном пространстве и двенадцатью ребрами, образующими его грани.
Массив nodes хранит координаты [x, y, z] каждой вершины. Например, вершина node0 имеет координаты [-100, -100, -100]. Массив edges определяет связи между вершинами, храня индексы из массива nodes. Так, ребро edge0 соединяет вершины с индексами 0 и 1.
const node0 = [-100, -100, -100];
const nodes = [node0, node1, node2, node3, node4, node5, node6, node7];
const edge0 = [0, 1];
const edges = [edge0, edge1, edge2, edge3, edge4, edge5, edge6, edge7, edge8, edge9, edge10, edge11];
Инициализация сцены и Graphics
В методе create() создается основной объект для рисования — this.graphics. Объект Graphics в Phaser — это программный холст для рисования линий, фигур и заливок. Мы позиционируем его в центре экрана (x: 400, y: 300), который станет центром вращения нашего куба.
Сразу после создания объект поворачивается на 30 градусов (0.523598 радиан) вокруг каждой оси, чтобы куб изначально был виден под углом. Для поворота вызываются методы rotateZ3D, rotateY3D и rotateX3D.
create ()
{
this.graphics = this.add.graphics({x: 400, y: 300});
this.rotateZ3D(0.5235987755982988);
this.rotateY3D(0.5235987755982988);
this.rotateX3D(0.5235987755982988);
// ... твины
}
Анимация с помощью твинов
Для плавной анимации вращения используются твины Phaser. Целью твинов является объект `t, который хранит текущие углы поворота вокруг осей X, Y и Z. Каждый твин плавно меняет одно из значенийtот начального до конечного и обратно (yoyo: true), повторяясь бесконечно (repeat: -1). Разная длительность (duration`) твинов создает сложное, нециклическое вращение.
this.tweens.add({
targets: [t],
duration: 6000,
x: 0.03490658503988659,
ease: Phaser.Math.Easing.Sine.InOut,
yoyo: true,
repeat: -1,
});
Матрицы поворота: сердце 3D
Ключевая математика происходит в методах rotateX3D, rotateY3D и rotateZ3D. Каждый метод применяет матрицу поворота для всех вершин куба. Например, поворот вокруг оси Z на угол theta вычисляется по формулам:
- newX = x * cos(theta) - y * sin(theta)
- newY = y * cos(theta) + x * sin(theta)
Координата Z при этом не меняется. Аналогично работают повороты вокруг других осей. Эти методы модифицируют исходный массив nodes, что важно для производительности.
rotateZ3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < nodes.length; n++)
{
const node = nodes[n];
const x = node[0];
const y = node[1];
node[0] = x * tc - y * ts;
node[1] = y * tc + x * ts;
}
}
Отрисовка кадра в update()
В методе update() происходит магия каждого кадра. Сначала применяются текущие углы поворота из объекта `t. Затем холстgraphicsочищается вызовомclear(), чтобы стереть предыдущий кадр. Устанавливается стиль линии: толщина 2 пикселя, зеленый цвет (0x00ff00`).
Далее, в цикле перебираются все ребра. Для каждого ребра берутся индексы вершин, из массива nodes извлекаются их текущие 2D-координаты [x, y] (после всех поворотов координата Z игнорируется при отрисовке). Методы moveTo() и lineTo() объекта Graphics рисуют линию между этими точками.
update ()
{
this.rotateX3D(t.x);
this.rotateY3D(t.y);
this.rotateZ3D(t.z);
this.graphics.clear();
this.graphics.lineStyle(2, 0x00ff00, 1.0);
this.graphics.beginPath();
for (let e = 0; e < edges.length; e++)
{
const n0 = edges[e][0];
const n1 = edges[e][1];
const node0 = nodes[n0];
const node1 = nodes[n1];
this.graphics.moveTo(node0[0], node0[1]);
this.graphics.lineTo(node1[0], node1[1]);
}
this.graphics.closePath();
this.graphics.strokePath();
}
Что попробовать дальше
Вы только что реализовали основу программного 3D-рендеринга: хранение модели, применение преобразований и проекцию на 2D-плоскость. Для экспериментов попробуйте: изменить координаты вершин, чтобы получить другую фигуру (пирамиду, призму); добавить перспективу, поделив X и Y на Z; реализовать масштабирование или перемещение; раскрасить грани разными цветами, используя fillPath. Этот подход, несмотря на простоту, — фундамент для понимания более сложной 3D-графики.
