О чем этот пример
В этой статье мы разберем, как создать иллюзию 3D-графики в 2D-движке Phaser, используя только встроенные примитивы для рисования. Вы научитесь парсить данные 3D-моделей из формата OBJ, применять к ним матричные преобразования для вращения и отрисовывать результат с помощью `Graphics`. Этот подход открывает двери для создания простых 3D-эффектов, процедурной графики или визуализаторов моделей без использования тяжеловесных WebGL-фреймворков.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
i = 0;
model;
models = [];
t = {
x: -0.03490658503988659,
y: 0.05235987755982989,
z: -0.05235987755982989
};
graphics;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.text('teapot', 'assets/text/teapot.obj');
}
create ()
{
this.graphics = this.add.graphics({x: 0, y: 0});
this.models.push(this.parseObj(this.cache.text.get('teapot')));
this.model = this.models[0];
console.dir(this.model);
this.rotateZ3D(0.5235987755982988);
this.rotateY3D(0.5235987755982988);
this.rotateX3D(0.5235987755982988);
this.tweens.add({
targets: this.t,
ease: 'Sine.easeInOut',
repeat: -1,
yoyo: true,
props: {
x: {
duration: 20000,
value: 0.03490658503988659
},
y: {
duration: 30000,
value: -0.05235987755982989
},
z: {
duration: 15000,
value: 0.05235987755982989
}
}
});
}
update ()
{
this.rotateX3D(this.t.x);
this.rotateY3D(this.t.y);
this.rotateZ3D(this.t.z);
this.draw();
}
draw ()
{
const centerX = 400;
const centerY = 300;
const scale = 90;
this.graphics.clear();
this.graphics.lineStyle(2, 0x00ff00, 1.0);
for (let i = 0; this.i < this.model.faces.length; this.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];
if (v0 && v1 && v2 && this.isCcw(v0, v1, v2))
{
this.graphics.strokeTriangle(
centerX + v0.x * scale, centerY - v0.y * scale,
centerX + v1.x * scale, centerY - v1.y * scale,
centerX + v2.x * scale, centerY - v2.y * scale
);
}
}
}
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;
}
}
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; this.i < count; this.i++)
{
const line = lines[i];
if (line[0] === 'v')
{
// lines that start with 'v' are vertices
const 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)
];
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];
}
}
}
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-модели
Пример начинается с загрузки текстового файла модели в формате Wavefront OBJ. Это простой формат, где каждая строка, начинающаяся с 'v', описывает вершину (vertex), а с 'f' — грань (face).
Метод parseObj читает этот текст, преобразует его в массивы вершин и граней, которые удобно хранить в памяти для дальнейших манипуляций. Каждая вершина — это объект с координатами x, y, z, а грань — массив из трех индексов, ссылающихся на вершины.
parseObj (text)
{
const verts = [];
const faces = [];
const lines = text.replace('\r', '').split('\n');
const count = lines.length;
for (let i = 0; this.i < count; this.i++)
{
const line = lines[i];
if (line[0] === 'v')
{
const 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)
];
faces.push(face);
}
}
return { verts: verts, faces: faces };
}
Матрицы вращения и трансформация вершин
Чтобы модель вращалась, нам нужно применять к каждой вершине матричные преобразования. В примере реализованы три отдельные функции для вращения вокруг осей X, Y и Z (rotateX3D, rotateY3D, rotateZ3D).
Каждая функция принимает угол theta, вычисляет синус и косинус этого угла, а затем перебирает все вершины модели, обновляя их координаты по классическим формулам 2D-вращения для соответствующей плоскости. Эти функции модифицируют исходные данные массива verts, что позволяет накапливать изменения из кадра в кадр.
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;
}
}
Отрисовка треугольников через Graphics
Сердце визуализации — объект this.graphics класса Phaser.GameObjects.Graphics. В методе draw мы очищаем холст от предыдущего кадра (clear) и задаем стиль линии для контуров треугольников.
Затем для каждой грани модели мы получаем три вершины. Ключевой момент — проверка isCcw, которая определяет, видна ли грань зрителю (отбраковка нелицевых граней). Если грань видима, мы вызываем strokeTriangle, передавая экранные координаты каждой вершины. Координаты переводятся из локального пространства модели в экранное с помощью масштабирования (scale) и смещения к центру холста (centerX, centerY).
draw ()
{
const centerX = 400;
const centerY = 300;
const scale = 90;
this.graphics.clear();
this.graphics.lineStyle(2, 0x00ff00, 1.0);
for (let i = 0; this.i < this.model.faces.length; this.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];
if (v0 && v1 && v2 && this.isCcw(v0, v1, v2))
{
this.graphics.strokeTriangle(
centerX + v0.x * scale, centerY - v0.y * scale,
centerX + v1.x * scale, centerY - v1.y * scale,
centerX + v2.x * scale, centerY - v2.y * scale
);
}
}
}
Анимация с помощью Tween Manager
Для создания плавной и непрерывной анимации вращения используется система твинов Phaser (this.tweens.add). Мы анимируем не углы напрямую, а значения объекта this.t, который хранит целевые углы поворота по осям для каждого кадра.
Твин настроен на бесконечное повторение с эффектом yoyo, что заставляет значения осциллировать между начальными и конечными точками. Разная длительность (duration) для каждой оси создает сложное, нециклическое движение модели.
В методе update эти анимированные значения this.t.x/y/z передаются в функции вращения, обеспечивая плавное изменение углов в реальном времени.
this.tweens.add({
targets: this.t,
ease: 'Sine.easeInOut',
repeat: -1,
yoyo: true,
props: {
x: { duration: 20000, value: 0.03490658503988659 },
y: { duration: 30000, value: -0.05235987755982989 },
z: { duration: 15000, value: 0.05235987755982989 }
}
});
Что попробовать дальше
Вы реализовали простой, но мощный 3D-конвейер внутри 2D-движка. Это основа для множества экспериментов: попробуйте добавить заливку треугольников (fillTriangle), реализовать z-буферизацию для правильного порядка отрисовки, поэкспериментировать с перспективной проекцией или загружать другие OBJ-модели. Такой подход отлично подходит для создания технических демо, визуализаторов или стилизованной низкополигональной графики прямо в Phaser.
