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

Хотите добавить в свою 2D-игру на Phaser эффектную трёхмерную графику без тяжёлых движков? Этот пример демонстрирует, как можно программно парсить модели из формата OBJ, управлять ими в 3D-пространстве и отрисовывать с помощью встроенного графического контекста. Вы научитесь загружать геометрические данные, создавать простейший 3D-конвейер для вращения и анимации объектов, что откроет двери для создания визуально сложных элементов интерфейса, вращающихся логотипов или стилизованных 3D-предметов в вашем проекте.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class DemoD extends Phaser.Scene
{
    constructor ()
    {
        super({ key: 'DemoD', active: true });
        this.graphics;

        this.t = {
            x: -0.03490658503988659,
            y: 0.03490658503988659,
            z: -0.03490658503988659
        };

        this.modelData = {};

        this.objects = [];
    }

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        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 ()
    {
        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: 'Sine.easeInOut',
            repeat: -1,
            yoyo: true
        });

        this.tweens.add({
            targets: c,
            duration: 4000,
            scale: 10,
            ease: 'Sine.easeInOut',
            repeat: -1,
            yoyo: true
        });

        var cam = this.cameras.main;

        cam.x = 800;
        cam.y = 600;
    }

    update ()
    {
        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 (key, x, y, z)
    {
        var model = new Obj3D(this.camera, this.getModel(key), x, y, z);

        this.objects.push(model);

        return model;
    }

    getModel (key)
    {
        var data = Phaser.Utils.Objects.Extend(true, this.modelData[key], {});

        return data;
    }

    //  Parses out tris and quads from the obj file
    parseObj (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];
    }
}

Загрузка и парсинг 3D-моделей

Пример начинает работу с загрузки текстовых файлов с расширением .obj. Это простой формат для хранения 3D-геометрии, где каждая строка определяет вершину (`v) или грань (f`).

В методе preload мы загружаем несколько файлов как обычный текст, используя this.load.text.

this.load.text('bevelledcube', 'assets/text/bevelledcube.obj');

После загрузки в методе create для каждого ключа вызывается parseObj. Этот метод читает текст из кэша, разбивает его на строки и анализирует.

var text = this.cache.text.get(key);
var lines = text.replace('\r', '').split('\n');

Для строк, начинающихся с 'v', извлекаются координаты `x,y,zи сохраняются в массивverts. Для строк с'f'извлекаются индексы вершин, образующих грань (квадрат или треугольник), и сохраняются в массивfaces. Полученные данные сохраняются в объектеthis.modelData` по ключу загрузки для последующего использования.

Создание и управление 3D-объектами

Для работы с распарсенной геометрией в примере используется класс Obj3D (его код в примере не показан, но логику можно понять по контексту). Он, вероятно, хранит вершины, грани, позицию, масштаб, цвет и методы для трансформаций.

Метод addObject создаёт новый экземпляр Obj3D, передавая ему позицию камеры, данные модели и координаты размещения в 3D-пространстве сцены.

var model = new Obj3D(this.camera, this.getModel(key), x, y, z);
this.objects.push(model);

В create создаётся несколько объектов: куб, геосфера, тор и модель компьютера. Каждому можно задать индивидуальный цвет и начальный масштаб. Объекты добавляются в массив this.objects для централизованного обновления.

var t = this.addObject('torus', -200, 100, 0);
t.color = 0xff00ff;
t.scale = 200;

Анимация и твининг

Phaser предоставляет мощную систему твининга для плавной анимации свойств. В примере она используется для пульсирующего изменения масштаба (scale) объектов тора и компьютера.

this.tweens.add({
    targets: t,
    duration: 2000,
    scale: 10,
    ease: 'Sine.easeInOut',
    repeat: -1,
    yoyo: true
});

Здесь targets — это объект `t(тор). Его свойствоscaleбудет изменяться от исходного значения до 10 за 2000 миллисекунд с плавностьюSine.easeInOut. Параметрыrepeat: -1иyoyo: true` заставляют анимацию повторяться бесконечно, чередуя движение вперёд и назад, создавая эффект "дыхания". Аналогичный твин, но с большей длительностью, применяется к модели компьютера.

Непрерывное вращение и отрисовка

Основная логика обновления и отрисовки находится в методе update, который вызывается каждый кадр.

Сначала графический контекст this.graphics очищается, чтобы удалить изображение предыдущего кадра.

this.graphics.clear();

Затем в цикле для каждого объекта в массиве this.objects вызываются методы поворота вокруг осей rotateX, rotateY, rotateZ. Эти методы, принадлежащие классу Obj3D, изменяют внутреннюю матрицу или углы вращения объекта.

this.objects[i].rotateX(0.01);
this.objects[i].rotateY(0.03);
this.objects[i].rotateZ(0.01);

После применения вращения вызывается метод render, который, используя текущее состояние объекта (позицию, поворот, масштаб, цвет) и данные о вершинах/гранях, выполняет проекцию 3D-координат на 2D-экран и отрисовывает линии или полигоны через this.graphics. Таким образом, мы видим постоянно вращающиеся 3D-модели.

Работа с камерой

Интересный момент в коде — это смещение основной камеры сцены. Хотя вся отрисовка происходит программно через Graphics в координатах мира, камера всё равно влияет на итоговую позицию отрисовки.

var cam = this.cameras.main;
cam.x = 800;
cam.y = 600;

Это смещает область просмотра (viewport) камеры. В данном контексте это может использоваться для того, чтобы разместить область отрисовки 3D-объектов в определённой части экрана, в то время как другие элементы интерфейса или игрового мира отрисовываются в стандартных координатах. Также в коде определён объект this.camera, который, судя по передаче в конструктор Obj3D, используется для математики проекции (например, для учёта смещения точки обзора).

Что попробовать дальше

Этот пример — отличная отправная точка для интеграции простой, но эффективной программной 3D-графики в ваши проекты на Phaser. Вы можете экспериментировать: загружать собственные OBJ-модели, изменять логику освещения и заливки в методе render, комбинировать 3D-объекты со спрайтами и частицами, а также управлять камерой для более сложных сцен. Попробуйте добавить интерактивность — вращение объектов по клику или их перемещение по сцене.