О чем этот пример

Хотите добавить в свою 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. Оптимизировать код, кэшируя преобразованные вершины и избегая повторных вычислений.