О чем этот пример
В 2D-движках отрисовка 3-мерных объектов — нетривиальная задача. В этом примере показан классический подход: использование множества 2D-спрайтов для представления вершин 3D-модели и их корректная сортировка по оси Z для создания иллюзии глубины. Этот метод полезен для создания простых 3D-визуализаций, частиц или HUD-элементов с перспективой без использования тяжеловесного WebGL-рендерера. Вы научитесь парсить модели из OBJ-файлов, применять матричные преобразования для вращения и управлять глубиной отрисовки (`depth`) спрайтов в реальном времени.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
const t = {
x: -0.003490658503988659,
y: 0.003490658503988659,
z: -0.003490658503988659
};
class Example extends Phaser.Scene
{
constructor ()
{
super();
this.models = [];
this.balls = [];
this.maxVerts = 0;
this.m = 0;
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('ball', 'assets/sprites/shinyball.png');
this.load.text('bevelledcube', 'assets/text/bevelledcube.obj');
this.load.text('geosphere', 'assets/text/geosphere.obj');
this.load.text('implodedcube', 'assets/text/implodedcube.obj');
this.load.text('spike', 'assets/text/spike.obj');
this.load.text('torus', 'assets/text/torus.obj');
}
create ()
{
this.graphics = this.add.graphics(0, 0);
this.models.push(this.parseObj(this.cache.text.get('geosphere')));
this.models.push(this.parseObj(this.cache.text.get('bevelledcube')));
this.models.push(this.parseObj(this.cache.text.get('spike')));
this.models.push(this.parseObj(this.cache.text.get('implodedcube')));
this.models.push(this.parseObj(this.cache.text.get('torus')));
this.model = this.models[0];
// Create sprites for each vert
for (let i = 0; i < this.maxVerts; i++)
{
const ball = this.add.image(0, 0, 'ball');
ball.visible = (i < this.model.verts.length);
this.balls.push(ball);
}
this.tweens.add({
targets: t,
repeat: -1,
yoyo: true,
ease: 'Sine.easeInOut',
props: {
x: {
value: 0.003490658503988659,
duration: 20000
},
y: {
value: -0.003490658503988659,
duration: 30000
},
z: {
value: 0.003490658503988659,
duration: 15000
},
}
});
this.input.keyboard.on('keydown-SPACE', function () {
this.m++;
if (this.m === this.models.length)
{
this.m = 0;
}
this.model = this.models[this.m];
// Update the balls
for (let i = 0; i < this.balls.length; i++)
{
this.balls[i].visible = (i < this.model.verts.length);
}
}, this);
}
update ()
{
this.rotateX3D(t.x);
this.rotateY3D(t.y);
this.rotateZ3D(t.z);
this.draw();
}
draw ()
{
const centerX = 400;
const centerY = 300;
const scale = 200;
this.graphics.clear();
this.graphics.lineStyle(1, 0x00ff00, 0.4);
this.graphics.beginPath();
for (let i = 0; i < this.model.faces.length; i++)
{
const face = this.model.faces[i];
const v0 = this.model.verts[face[0] - 1];
const v1 = this.model.verts[face[1] - 1];
const v2 = this.model.verts[face[2] - 1];
const v3 = this.model.verts[face[3] - 1];
if (v0 && v1 && v2 && v3)
{
this.drawLine(centerX + v0.x * scale, centerY - v0.y * scale, centerX + v1.x * scale, centerY - v1.y * scale);
this.drawLine(centerX + v1.x * scale, centerY - v1.y * scale, centerX + v2.x * scale, centerY - v2.y * scale);
this.drawLine(centerX + v2.x * scale, centerY - v2.y * scale, centerX + v3.x * scale, centerY - v3.y * scale);
this.drawLine(centerX + v3.x * scale, centerY - v3.y * scale, centerX + v0.x * scale, centerY - v0.y * scale);
}
}
this.graphics.closePath();
this.graphics.strokePath();
for (let i = 0; i < this.model.verts.length; i++)
{
this.balls[i].x = centerX + this.model.verts[i].x * scale;
this.balls[i].y = centerY - this.model.verts[i].y * scale;
this.balls[i].depth = this.model.verts[i].z;
}
}
drawLine (x0, y0, x1, y1)
{
this.graphics.moveTo(x0, y0);
this.graphics.lineTo(x1, y1);
}
isCcw (v0, v1, v2)
{
return (v1.x - v0.x) * (v2.y - v0.y) - (v1.y - v0.y) * (v2.x - v0.x) >= 0;
}
rotateX3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < this.model.verts.length; n++)
{
const vert = this.model.verts[n];
const y = vert.y;
const z = vert.z;
vert.y = y * tc - z * ts;
vert.z = z * tc + y * ts;
}
}
rotateY3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < this.model.verts.length; n++)
{
const vert = this.model.verts[n];
const x = vert.x;
const z = vert.z;
vert.x = x * tc - z * ts;
vert.z = z * tc + x * ts;
}
}
rotateZ3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < this.model.verts.length; n++)
{
const vert = this.model.verts[n];
const x = vert.x;
const y = vert.y;
vert.x = x * tc - y * ts;
vert.y = y * tc + x * ts;
}
}
// Parses out tris and quads from the obj file
parseObj (text)
{
const verts = [];
const faces = [];
// split the text into lines
const lines = text.replace('\r', '').split('\n');
const count = lines.length;
for (let i = 0; i < count; i++)
{
const line = lines[i];
if (line[0] === 'v')
{
// lines that start with 'v' are vertices
let tokens = line.split(' ');
verts.push({
x: parseFloat(tokens[1]),
y: parseFloat(tokens[2]),
z: parseFloat(tokens[3])
});
}
else if (line[0] === 'f')
{
// lines that start with 'f' are faces
const tokens = line.split(' ');
const face = [
parseInt(tokens[1], 10),
parseInt(tokens[2], 10),
parseInt(tokens[3], 10),
parseInt(tokens[4], 10)
];
faces.push(face);
if (face[0] < 0)
{
face[0] = verts.length + face[0];
}
if (face[1] < 0)
{
face[1] = verts.length + face[1];
}
if (face[2] < 0)
{
face[2] = verts.length + face[2];
}
if (!face[3])
{
face[3] = face[2];
}
else if (face[3] < 0)
{
face[3] = verts.length + face[3];
}
}
}
if (verts.length > this.maxVerts)
{
this.maxVerts = verts.length;
}
return {
verts: verts,
faces: faces
};
}
}
const config = {
type: Phaser.WEBGL,
width: 800,
height: 600,
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Загрузка и парсинг 3D-моделей
Основа примера — 3D-модели в формате OBJ, загружаемые как текстовые ресурсы. Функция parseObj разбирает файл, извлекая вершины (`v) и полигоны (f). Для хранения данных используется простой объект с массивамиvertsиfaces`.
parseObj (text)
{
const verts = [];
const faces = [];
const lines = text.replace('\r', '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line[0] === 'v') {
let tokens = line.split(' ');
verts.push({
x: parseFloat(tokens[1]),
y: parseFloat(tokens[2]),
z: parseFloat(tokens[3])
});
}
else if (line[0] === 'f') {
const tokens = line.split(' ');
const face = [
parseInt(tokens[1], 10),
parseInt(tokens[2], 10),
parseInt(tokens[3], 10),
parseInt(tokens[4], 10)
];
faces.push(face);
}
}
return { verts: verts, faces: faces };
}
В методе create модели загружаются и сохраняются в массив this.models. Переменная this.maxVerts отслеживает максимальное количество вершин среди всех моделей, чтобы создать достаточное количество спрайтов-шаров ('ball') заранее. Видимость каждого шара управляется в зависимости от того, используется ли соответствующая вершина в активной модели.
Вращение модели и преобразование координат
Для анимации используется объект `tс углами поворота по осям X, Y, Z. На него навешан бесконечный tween, плавно меняющий значения. Вupdateвызываются методыrotateX3D,rotateY3DиrotateZ3D`, которые применяют матрицу поворота к каждой вершине активной модели.
rotateX3D (theta)
{
const ts = Math.sin(theta);
const tc = Math.cos(theta);
for (let n = 0; n < this.model.verts.length; n++) {
const vert = this.model.verts[n];
const y = vert.y;
const z = vert.z;
vert.y = y * tc - z * ts;
vert.z = z * tc + y * ts;
}
}
Это классическое умножение вектора на матрицу поворота. После преобразования 3D-координаты вершин проецируются на 2D-экран в методе draw. Ключевой момент: координата `yинвертируется (centerY - v0.y * scale`), потому что в системе координат Canvas/Phaser ось Y направлена вниз, а в 3D — вверх.
Отрисовка каркаса и управление глубиной спрайтов
Метод draw выполняет две задачи: отрисовывает зелёный каркас модели через Graphics и позиционирует спрайты-шары в местах вершин. Каркас рисуется по четырём точкам каждого полигона (quad), соединяя линии между вершинами.
this.graphics.lineStyle(1, 0x00ff00, 0.4);
this.graphics.beginPath();
// ... обход faces и рисование линий через drawLine
this.graphics.closePath();
this.graphics.strokePath();
Самая важная для сортировки по глубине строка — это присвоение свойства depth каждому спрайту-шару:
this.balls[i].depth = this.model.verts[i].z;
В Phaser свойство depth объекта (унаследованное от Phaser.GameObjects.Image) определяет порядок отрисовки: объекты с большим значением depth рисуются поверх объектов с меньшим значением. Поскольку мы используем Z-координату вершины как глубину, шары, которые "ближе" к камере (с большим Z), будут отрисованы поверх тех, что "дальше". Это создаёт корректное перекрытие и иллюзию 3D-пространства. Без этого шары отрисовывались бы в порядке создания, и сортировка по глубине была бы некорректной.
Переключение моделей и интерактивность
Пример позволяет циклически переключаться между загруженными моделями по нажатию пробела. Это демонстрирует универсальность подхода: одна система может работать с разными мешами.
this.input.keyboard.on('keydown-SPACE', function () {
this.m++;
if (this.m === this.models.length) { this.m = 0; }
this.model = this.models[this.m];
for (let i = 0; i < this.balls.length; i++) {
this.balls[i].visible = (i < this.model.verts.length);
}
}, this);
Количество вершин у моделей разное, поэтому при переключении нужно обновить видимость каждого шара: если у новой модели вершины с этим индексом нет, шар скрывается. Это эффективнее, чем пересоздавать спрайты каждый раз.
Что попробовать дальше
Пример демонстрирует элегантный гибридный подход: использование 2D-спрайтов Phaser для рендеринга 3D-моделей с корректной сортировкой по глубине через свойство depth. Это открывает возможности для лёгких 3D-эффектов в преимущественно 2D-играх. Для экспериментов попробуйте: изменить текстуру шара на что-то более сложное; добавить расчёт нормалей и закрашивание граней через Graphics; привязать вращение модели к движению мыши; или реализовать простой Z-buffer для более точного определения видимости пикселей.
