О чем этот пример
Хотите добавить в свою 2D-игру на Phaser элементы трёхмерной графики без использования тяжёлых 3D-движков? В этой статье мы разберём пример, который демонстрирует, как можно рендерить 3D-модели в виде каркасных объектов (вайрфреймов), используя стандартный объект `Graphics`. Вы научитесь парсить данные из формата OBJ, реализуете простые матрицы вращения и создадите динамичную 3D-сцену, которая отлично впишется в пиксель-арт или ретро-игры.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
var Obj3D = new Phaser.Class({
initialize:
function Obj3D (camera, modelData, x, y, z)
{
this.camera = camera;
this.model = modelData;
this.x = x;
this.y = y;
this.z = z;
this.thickness = 2;
this.color = 0x00ff00;
this.alpha = 1;
this.scale = 100;
},
rotateX: function (theta)
{
var ts = Math.sin(theta);
var tc = Math.cos(theta);
var model = this.model;
for (var n = 0; n < model.verts.length; n++)
{
var vert = model.verts[n];
var y = vert.y;
var z = vert.z;
vert.y = y * tc - z * ts;
vert.z = z * tc + y * ts;
}
},
rotateY: function (theta)
{
var ts = Math.sin(theta);
var tc = Math.cos(theta);
var model = this.model;
for (var n = 0; n < model.verts.length; n++)
{
var vert = model.verts[n];
var x = vert.x;
var z = vert.z;
vert.x = x * tc - z * ts;
vert.z = z * tc + x * ts;
}
},
rotateZ: function (theta)
{
var ts = Math.sin(theta);
var tc = Math.cos(theta);
var model = this.model;
for (var n = 0; n < model.verts.length; n++)
{
var vert = model.verts[n];
var x = vert.x;
var y = vert.y;
vert.x = x * tc - y * ts;
vert.y = y * tc + x * ts;
}
},
render: function (graphics)
{
var model = this.model;
var x = this.camera.x + this.x;
var y = this.camera.y + this.y;
var z = this.z;
var scale = this.scale;
graphics.lineStyle(this.thickness, this.color, this.alpha);
graphics.beginPath();
for (var i = 0; i < model.faces.length; i++)
{
var face = model.faces[i];
var v0 = model.verts[face[0] - 1];
var v1 = model.verts[face[1] - 1];
var v2 = model.verts[face[2] - 1];
var v3 = model.verts[face[3] - 1];
// if (v0 && v1 && v2 && v3)
// {
this.drawLine(graphics, x + v0.x * scale, y - v0.y * scale, x + v1.x * scale, y - v1.y * scale);
this.drawLine(graphics, x + v1.x * scale, y - v1.y * scale, x + v2.x * scale, y - v2.y * scale);
this.drawLine(graphics, x + v2.x * scale, y - v2.y * scale, x + v3.x * scale, y - v3.y * scale);
this.drawLine(graphics, x + v3.x * scale, y - v3.y * scale, x + v0.x * scale, y - v0.y * scale);
// }
}
graphics.closePath();
graphics.strokePath();
},
drawLine: function (graphics, x0, y0, x1, y1)
{
graphics.moveTo(x0, y0);
graphics.lineTo(x1, y1);
}
});
var WireframeScene = {};
WireframeScene.Start = function ()
{
this.graphics;
this.t = {
x: -0.03490658503988659,
y: 0.03490658503988659,
z: -0.03490658503988659
};
this.modelData = {};
this.objects = [];
};
WireframeScene.Start.prototype.constructor = WireframeScene.Start;
WireframeScene.Start.prototype = {
preload: function ()
{
this.load.text('bevelledcube', 'assets/text/bevelledcube.obj');
this.load.text('computer', 'assets/text/computer.obj');
this.load.text('geosphere', 'assets/text/geosphere.obj');
this.load.text('spike', 'assets/text/spike.obj');
this.load.text('torus', 'assets/text/torus.obj');
},
create: function ()
{
this.parseObj('bevelledcube');
this.parseObj('computer');
this.parseObj('geosphere');
this.parseObj('spike');
this.parseObj('torus');
this.graphics = this.add.graphics();
this.camera = {
x: 400,
y: 340
};
var b = this.addObject('bevelledcube', -200, -200, 0);
var g = this.addObject('geosphere', 200, -200, 0);
g.color = 0x00ffff;
var t = this.addObject('torus', -200, 100, 0);
t.color = 0xff00ff;
t.scale = 200;
var c = this.addObject('computer', 200, 100, 0);
c.color = 0xffff00;
this.tweens.add({
targets: t,
duration: 2000,
scale: 10,
ease: Phaser.Math.Easing.Sine.InOut,
repeat: -1,
yoyo: true
});
this.tweens.add({
targets: c,
duration: 4000,
scale: 10,
ease: Phaser.Math.Easing.Sine.InOut,
repeat: -1,
yoyo: true
});
},
update: function ()
{
this.graphics.clear();
for (var i = 0; i < this.objects.length; i++)
{
this.objects[i].rotateX(0.01);
this.objects[i].rotateY(0.03);
this.objects[i].rotateZ(0.01);
this.objects[i].render(this.graphics);
}
},
addObject: function (key, x, y, z)
{
var model = new Obj3D(this.camera, this.getModel(key), x, y, z);
this.objects.push(model);
return model;
},
getModel: function (key)
{
var data = Phaser.Utils.Objects.Extend(true, this.modelData[key], {});
return data;
},
// Parses out tris and quads from the obj file
parseObj: function (key)
{
var text = this.cache.text.get(key);
var verts = [];
var faces = [];
// split the text into lines
var lines = text.replace('\r', '').split('\n');
var count = lines.length;
for (var i = 0; i < count; i++)
{
var line = lines[i];
if (line[0] === 'v')
{
// lines that start with 'v' are vertices
var 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
var tokens = line.split(' ');
var 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];
}
}
}
this.modelData[key] = {
verts: verts,
faces: faces
};
return this.modelData[key];
}
};
const config = {
type: Phaser.WEBGL,
width: 800,
height: 600,
parent: 'phaser-example',
scene: WireframeScene.Start
};
const game = new Phaser.Game(config);
Структура класса Obj3D
Основу примера составляет класс Obj3D, который отвечает за хранение и трансформацию данных 3D-модели.
Класс принимает в конструкторе ссылку на камеру, данные модели и её координаты в пространстве. Важно отметить, что в этом примере камера — это простой объект с полями `xиy, а не встроенный объектPhaser.Cameras.Scene2D.Camera`.
function Obj3D (camera, modelData, x, y, z)
{
this.camera = camera;
this.model = modelData;
this.x = x;
this.y = y;
this.z = z;
this.thickness = 2;
this.color = 0x00ff00;
this.alpha = 1;
this.scale = 100;
}
Поля thickness, color, alpha и scale отвечают за внешний вид рендера: толщину линий, цвет, прозрачность и масштаб модели соответственно. Эти свойства можно менять динамически для каждого объекта.
Вращение модели по осям
Для создания иллюзии трёхмерности объект должен уметь вращаться. В примере реализованы три метода: rotateX, rotateY и rotateZ. Каждый из них применяет матрицу вращения к массиву вершин модели.
Методы используют классические формулы вращения точки вокруг начала координат. Например, вращение вокруг оси Y изменяет координаты X и Z каждой вершины.
rotateY: function (theta)
{
var ts = Math.sin(theta);
var tc = Math.cos(theta);
var model = this.model;
for (var n = 0; n < model.verts.length; n++)
{
var vert = model.verts[n];
var x = vert.x;
var z = vert.z;
vert.x = x * tc - z * ts;
vert.z = z * tc + x * ts;
}
}
Обратите внимание: преобразования применяются напрямую к данным исходной модели (model.verts). Это означает, что каждое вращение накапливается. Для изоляции трансформаций потребовалось бы хранить копию исходных вершин.
Парсинг данных из формата OBJ
Модели загружаются из текстовых файлов в формате OBJ — простом и распространённом формате для 3D-моделей. Метод parseObj в сцене отвечает за их обработку.
Файл построчно анализируется: строки, начинающиеся с 'v' (вершины), и 'f' (полигоны или faces), извлекаются в соответствующие массивы. Важно, что в этом примере ожидаются четырёхугольные полигоны (quads). Индексы вершин в OBJ начинаются с 1, а в JavaScript массивы — с 0, поэтому в коде делается поправка - 1.
if (line[0] === 'v')
{
var tokens = line.split(' ');
verts.push({
x: parseFloat(tokens[1]),
y: parseFloat(tokens[2]),
z: parseFloat(tokens[3])
});
}
else if (line[0] === 'f')
{
var tokens = line.split(' ');
var face = [
parseInt(tokens[1], 10),
parseInt(tokens[2], 10),
parseInt(tokens[3], 10),
parseInt(tokens[4], 10)
];
faces.push(face);
}
Результат парсинга сохраняется в объекте this.modelData[key] для последующего использования.
Процесс рендеринга кадра
Метод render класса Obj3D отвечает за отрисовку модели на объекте Graphics. Он вызывается каждый кадр в методе update сцены.
Сначала вычисляются итоговые координаты объекта на экране с учётом позиции камеры и масштаба. Обратите внимание на y - v0.y * scale: ось Y в 2D-графике Phaser направлена вниз, поэтому для корректного отображения 3D-координаты её инвертируют.
var x = this.camera.x + this.x;
var y = this.camera.y + this.y;
var z = this.z;
var scale = this.scale;
graphics.lineStyle(this.thickness, this.color, this.alpha);
graphics.beginPath();
Затем для каждого полигона (лица) модели вызывается метод drawLine, который рисует его контур. Метод drawLine использует низкоуровневые команды graphics.moveTo и graphics.lineTo.
this.drawLine(graphics, x + v0.x * scale, y - v0.y * scale, x + v1.x * scale, y - v1.y * scale);
В конце вызываются graphics.closePath() и graphics.strokePath(), чтобы завершить рисование и вывести линии на экран. Важно, что graphics.clear() вызывается в начале каждого кадра в update, чтобы стереть предыдущий кадр.
Оркестрация в сцене Phaser
Сцена WireframeScene.Start управляет всеми объектами, анимациями и жизненным циклом.
В create происходит загрузка моделей, создание объекта this.graphics и камеры. Объекты добавляются в сцену с помощью метода addObject, который создаёт экземпляр Obj3D и помещает его в массив this.objects.
create: function ()
{
this.parseObj('bevelledcube');
// ... парсинг других моделей
this.graphics = this.add.graphics();
this.camera = { x: 400, y: 340 };
var b = this.addObject('bevelledcube', -200, -200, 0);
// ... добавление других объектов
}
Метод update — сердце анимации. В нём очищается холст Graphics, и для каждого объекта применяются вращения и вызывается метод render. Именно здесь задаётся скорость вращения объектов вокруг осей.
update: function ()
{
this.graphics.clear();
for (var i = 0; i < this.objects.length; i++)
{
this.objects[i].rotateX(0.01);
this.objects[i].rotateY(0.03);
this.objects[i].rotateZ(0.01);
this.objects[i].render(this.graphics);
}
}
Также в create используются this.tweens.add для создания простых анимаций изменения масштаба объектов, что добавляет динамики сцене.
Что попробовать дальше
Вы только что разобрали реализацию программного рендеринга 3D-вайрфреймов в Phaser. Этот подход не требует WebGL-шейдеров и отлично работает на Graphics. Для экспериментов попробуйте:
1. Добавить сортировку полигонов по глубине (z-index) для корректного перекрытия.
2. Реализовать загрузку текстур и плоское закрашивание граней (flat shading).
3. Внедрить управление камерой (приближение, поворот) с помощью мыши или клавиатуры.
4. Оптимизировать код, кэшируя преобразованные вершины и избегая повторных вычислений.
