О чем этот пример
Хотите добавить в свою 2D-игру простые 3D-объекты, но не готовы подключать тяжелые фреймворки вроде Three.js? В этой статье разберем пример из официального репозитория Phaser, который показывает, как можно парсить OBJ-модели, вращать их в пространстве и отрисовывать с помощью встроенного объекта `Graphics`. Вы научитесь работать с полигональными моделями, применять матричные преобразования и создавать плавную анимацию, используя только базовые возможности Phaser и немного математики.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene {
graphics;
t = {
x: -0.03490658503988659,
y: 0.05235987755982989,
z: -0.05235987755982989
};
models = [];
model;
i = 0;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.text('teapot', 'assets/text/teapot.obj');
this.load.script('TweenMax', 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.4/gsap.min.js');
}
create ()
{
this.graphics = this.add.graphics();
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);
TweenMax.to(this.t, 20, {
x: 0.03490658503988659,
ease: Sine.easeInOut,
repeat: -1,
yoyo: true
});
TweenMax.to(this.t, 30, {
y: -0.05235987755982989,
ease: Sine.easeInOut,
repeat: -1,
yoyo: true
});
TweenMax.to(this.t, 15, {
z: 0.05235987755982989,
ease: Sine.easeInOut,
repeat: -1,
yoyo: true
});
}
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);
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];
if (v0 && v1 && v2 && this.isCcw(v0, v1, v2))
{
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 + v0.x * scale, centerY - v0.y * scale);
}
}
this.graphics.closePath();
this.graphics.strokePath();
}
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;
}
// Helpers functions
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; i < count; 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);
Загрузка модели и парсинг OBJ-файла
Пример начинается с загрузки текстового файла модели в формате Wavefront OBJ. Это простой формат, где вершины описываются строками, начинающимися с `v, а полигоны (в данном случае треугольники) — сf`.
Метод parseObj разбирает этот текст, создавая массив вершин verts и массив граней faces. Важно: индексы вершин в OBJ часто начинаются с 1, а в JavaScript массивы — с 0. Поэтому в коде есть корректировка для отрицательных индексов (редкая, но возможная запись в OBJ).
this.load.text('teapot', 'assets/text/teapot.obj');
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')
{
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 };
}
Вращение вершин в 3D-пространстве
Сердце 3D-преобразований — функции rotateX3D, rotateY3D, rotateZ3D. Они применяют матрицу поворота к каждой вершине модели.
Каждая функция принимает угол theta (в радианах) и вычисляет синус с косинусом. Затем для каждой вершины пересчитываются её координаты по классическим формулам поворота вокруг соответствующей оси. Изменения записываются напрямую в массив this.model.verts.
Например, поворот вокруг оси Y изменяет координаты X и Z вершины, оставляя Y неизменной.
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;
}
}
Отрисовка линий с помощью Graphics
Для визуализации используется объект this.graphics типа Phaser.GameObjects.Graphics. В каждом кадре метод draw очищает старое изображение, задает стиль линии и начинает новый путь.
Цикл проходит по всем граням модели. Для каждой грани берутся три вершины по индексам. Их 3D-координаты проецируются на 2D-экран: умножаются на масштаб (scale), смещаются в центр (centerX, centerY), и координата Y инвертируется (так как ось Y в Canvas направлена вниз).
Перед отрисовкой проверяется, является ли грань видимой, с помощью функции isCcw (Counter-Clockwise). Она вычисляет векторное произведение, чтобы определить, смотрит ли нормаль грани на камеру. Это простейший backface culling.
draw ()
{
const centerX = 400;
const centerY = 300;
const scale = 90;
this.graphics.clear();
this.graphics.lineStyle(2, 0x00ff00, 1.0);
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];
if (v0 && v1 && v2 && this.isCcw(v0, v1, v2))
{
this.drawLine(centerX + v0.x * scale, centerY - v0.y * scale, centerX + v1.x * scale, centerY - v1.y * scale);
// ... рисование остальных сторон треугольника
}
}
this.graphics.closePath();
this.graphics.strokePath();
}
isCcw (v0, v1, v2)
{
return (v1.x - v0.x) * (v2.y - v0.y) - (v1.y - v0.y) * (v2.x - v0.x) >= 0;
}
Анимация с помощью GSAP (TweenMax)
Для создания плавных циклических анимаций вращения пример использует внешнюю библиотеку TweenMax (GSAP). Она не входит в Phaser, но легко подключается через load.script.
Объект this.t содержит три угловые скорости (по одной для каждой оси). TweenMax анимирует значения этих свойств, заставляя их колебаться между начальным и конечным значением с заданной длительностью и функцией плавности Sine.easeInOut. Флаги repeat: -1 и yoyo: true создают бесконечный цикл "туда-обратно".
В методе update текущие значения this.t.x, this.t.y, this.t.z используются как углы для поворота модели в каждом кадре, что и создает сложное непрерывное вращение.
this.load.script('TweenMax', 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.4/gsap.min.js');
// В create()
TweenMax.to(this.t, 20, {
x: 0.03490658503988659,
ease: Sine.easeInOut,
repeat: -1,
yoyo: true
});
// В update()
this.rotateX3D(this.t.x);
this.rotateY3D(this.t.y);
this.rotateZ3D(this.t.z);
Что попробовать дальше
Вы реализовали базовый 3D-конвейер прямо внутри Phaser! Этот подход отлично подходит для стилизованной графики, простых моделей или HUD-элементов. Для экспериментов попробуйте: загрузить другую OBJ-модель (например, куб или монстра), изменить цвет и толщину линий в lineStyle, добавить перспективную проекцию (умножив X и Y на коэффициент, зависящий от Z) или привязать вращение к курсору мыши. Помните, что это "программный" рендеринг, и для сложных сцен стоит рассмотреть интеграцию с Three.js или другими WebGL-библиотеками.
